SandboxHintContentAssistProcessor.java

/*******************************************************************************
 * Copyright (c) 2026 Carsten Hammer.
 *
 * This program and the accompanying materials
 * are made available under the terms of the Eclipse Public License 2.0
 * which accompanies this distribution, and is available at
 * https://www.eclipse.org/legal/epl-2.0/
 *
 * SPDX-License-Identifier: EPL-2.0
 *
 * Contributors:
 *     Carsten Hammer - initial API and implementation
 *******************************************************************************/
package org.sandbox.jdt.triggerpattern.editor;

import java.util.ArrayList;
import java.util.List;

import org.eclipse.jface.text.BadLocationException;
import org.eclipse.jface.text.IDocument;
import org.eclipse.jface.text.ITextViewer;
import org.eclipse.jface.text.contentassist.CompletionProposal;
import org.eclipse.jface.text.contentassist.ICompletionProposal;
import org.eclipse.jface.text.contentassist.IContentAssistProcessor;
import org.eclipse.jface.text.contentassist.IContextInformation;
import org.eclipse.jface.text.contentassist.IContextInformationValidator;
import org.sandbox.jdt.triggerpattern.internal.GuardRegistry;

/**
 * Content assist processor for {@code .sandbox-hint} files.
 *
 * <p>Provides completion proposals for guard function names after the
 * {@code ::} guard separator. Proposals are sourced from the
 * {@link GuardRegistry}.</p>
 *
 * @since 1.3.6
 */
public class SandboxHintContentAssistProcessor implements IContentAssistProcessor {

	/**
	 * Guard function entries with name and description.
	 */
	private static final String[][] GUARD_PROPOSALS = {
		{ "instanceof", "$x instanceof Type – type check" }, //$NON-NLS-1$ //$NON-NLS-2$
		{ "matchesAny", "matchesAny($x, \"lit1\", \"lit2\") – value is one of the given literals" }, //$NON-NLS-1$ //$NON-NLS-2$
		{ "matchesNone", "matchesNone($x, \"lit1\", ...) – value is none of the given literals" }, //$NON-NLS-1$ //$NON-NLS-2$
		{ "referencedIn", "referencedIn($x, $y) – variable $x is referenced in expression $y" }, //$NON-NLS-1$ //$NON-NLS-2$
		{ "hasNoSideEffect", "hasNoSideEffect($x) – expression has no side effects" }, //$NON-NLS-1$ //$NON-NLS-2$
		{ "sourceVersionGE", "sourceVersionGE(n) – source version >= n" }, //$NON-NLS-1$ //$NON-NLS-2$
		{ "sourceVersionLE", "sourceVersionLE(n) – source version <= n" }, //$NON-NLS-1$ //$NON-NLS-2$
		{ "sourceVersionBetween", "sourceVersionBetween(min, max) – source version in range" }, //$NON-NLS-1$ //$NON-NLS-2$
		{ "elementKindMatches", "elementKindMatches($x, KIND) – element is of a specific kind" }, //$NON-NLS-1$ //$NON-NLS-2$
		{ "isStatic", "isStatic($x) – element has static modifier" }, //$NON-NLS-1$ //$NON-NLS-2$
		{ "isFinal", "isFinal($x) – element has final modifier" }, //$NON-NLS-1$ //$NON-NLS-2$
		{ "hasAnnotation", "hasAnnotation($x, \"fully.qualified.Annotation\")" }, //$NON-NLS-1$ //$NON-NLS-2$
		{ "isDeprecated", "isDeprecated($x) – element is @Deprecated" }, //$NON-NLS-1$ //$NON-NLS-2$
		{ "otherwise", "otherwise – catch-all guard (always true)" }, //$NON-NLS-1$ //$NON-NLS-2$
		{ "parent", "parent($x, Type) – parent node is of the given type" }, //$NON-NLS-1$ //$NON-NLS-2$
		{ "contains", "contains($x, \"text\") – enclosing method contains the text" }, //$NON-NLS-1$ //$NON-NLS-2$
		{ "notContains", "notContains($x, \"text\") – enclosing method does not contain the text" }, //$NON-NLS-1$ //$NON-NLS-2$
	};

	@Override
	public ICompletionProposal[] computeCompletionProposals(ITextViewer viewer, int offset) {
		IDocument document = viewer.getDocument();
		String prefix = extractPrefix(document, offset);

		// Check if we are after a :: guard separator
		boolean afterGuard = isAfterGuardSeparator(document, offset - prefix.length());

		if (!afterGuard && prefix.isEmpty()) {
			return new ICompletionProposal[0];
		}

		List<ICompletionProposal> proposals = new ArrayList<>();
		String lowerPrefix = prefix.toLowerCase();

		for (String[] entry : GUARD_PROPOSALS) {
			String name = entry[0];
			String description = entry[1];
			if (name.toLowerCase().startsWith(lowerPrefix)) {
				String replacement = name;
				int replacementOffset = offset - prefix.length();
				int replacementLength = prefix.length();
				int cursorPosition = replacement.length();
				proposals.add(new CompletionProposal(
						replacement, replacementOffset, replacementLength,
						cursorPosition, null, name + " – " + description, //$NON-NLS-1$
						null, description));
			}
		}

		// Also propose guard functions from the registry
		GuardRegistry registry = GuardRegistry.getInstance();
		for (String guardName : registry.getRegisteredNames()) {
			if (guardName.toLowerCase().startsWith(lowerPrefix)) {
				boolean alreadyProposed = false;
				for (String[] entry : GUARD_PROPOSALS) {
					if (entry[0].equals(guardName)) {
						alreadyProposed = true;
						break;
					}
				}
				if (!alreadyProposed) {
					String replacement = guardName;
					int replacementOffset = offset - prefix.length();
					int replacementLength = prefix.length();
					int cursorPosition = replacement.length();
					proposals.add(new CompletionProposal(
							replacement, replacementOffset, replacementLength,
							cursorPosition, null, guardName,
							null, "Custom guard function")); //$NON-NLS-1$
				}
			}
		}

		return proposals.toArray(new ICompletionProposal[0]);
	}

	@Override
	public IContextInformation[] computeContextInformation(ITextViewer viewer, int offset) {
		return new IContextInformation[0];
	}

	@Override
	public char[] getCompletionProposalAutoActivationCharacters() {
		return new char[] { ':' };
	}

	@Override
	public char[] getContextInformationAutoActivationCharacters() {
		return null;
	}

	@Override
	public String getErrorMessage() {
		return null;
	}

	@Override
	public IContextInformationValidator getContextInformationValidator() {
		return null;
	}

	/**
	 * Extracts the word prefix before the cursor position.
	 */
	private String extractPrefix(IDocument document, int offset) {
		try {
			int start = offset;
			while (start > 0) {
				char c = document.getChar(start - 1);
				if (!Character.isLetterOrDigit(c) && c != '_') {
					break;
				}
				start--;
			}
			return document.get(start, offset - start);
		} catch (BadLocationException e) {
			return ""; //$NON-NLS-1$
		}
	}

	/**
	 * Checks if the position is after a {@code ::} guard separator
	 * (possibly with whitespace in between).
	 */
	private boolean isAfterGuardSeparator(IDocument document, int offset) {
		try {
			int pos = offset - 1;
			// Skip whitespace
			while (pos >= 0 && Character.isWhitespace(document.getChar(pos))) {
				pos--;
			}
			// Check for '::'
			if (pos >= 1
					&& document.getChar(pos) == ':'
					&& document.getChar(pos - 1) == ':') {
				return true;
			}
		} catch (BadLocationException e) {
			// ignore
		}
		return false;
	}
}