FqnAwarePlaceholderAstMatcher.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
 *******************************************************************************/
package org.sandbox.jdt.triggerpattern.internal;

import java.util.List;
import java.util.LinkedHashSet;
import java.util.Set;

import org.eclipse.jdt.core.dom.ArrayType;
import org.eclipse.jdt.core.dom.ASTMatcher;
import org.eclipse.jdt.core.dom.ASTNode;
import org.eclipse.jdt.core.dom.CastExpression;
import org.eclipse.jdt.core.dom.ClassInstanceCreation;
import org.eclipse.jdt.core.dom.CompilationUnit;
import org.eclipse.jdt.core.dom.Expression;
import org.eclipse.jdt.core.dom.IBinding;
import org.eclipse.jdt.core.dom.IMethodBinding;
import org.eclipse.jdt.core.dom.ITypeBinding;
import org.eclipse.jdt.core.dom.IVariableBinding;
import org.eclipse.jdt.core.dom.ImportDeclaration;
import org.eclipse.jdt.core.dom.MethodInvocation;
import org.eclipse.jdt.core.dom.Name;
import org.eclipse.jdt.core.dom.NameQualifiedType;
import org.eclipse.jdt.core.dom.ParameterizedType;
import org.eclipse.jdt.core.dom.PrimitiveType;
import org.eclipse.jdt.core.dom.QualifiedName;
import org.eclipse.jdt.core.dom.QualifiedType;
import org.eclipse.jdt.core.dom.SimpleName;
import org.eclipse.jdt.core.dom.SimpleType;
import org.eclipse.jdt.core.dom.Type;
import org.eclipse.jdt.core.dom.TypeLiteral;
import org.eclipse.jdt.core.dom.WildcardType;
import org.sandbox.jdt.triggerpattern.api.GuardContext.UnknownSemanticRequirement;

/**
 * FQN-aware variant of {@link PlaceholderAstMatcher}.
 *
 * <p>DSL snippets should express concrete APIs using fully qualified names. This
 * matcher accepts source code that uses the same API via imports and simple
 * names. It applies that principle to type references, parameterized types,
 * arrays, constructors, type literals, casts and method receivers.</p>
 *
 * <p>Literal dotted receiver names request a semantic API identity. An ambiguous
 * value receiver such as {@code holder.field} must use an owner placeholder
 * ({@code $owner.field}) so a variable shadowing an API name is not accepted.</p>
 */
public final class FqnAwarePlaceholderAstMatcher extends PlaceholderAstMatcher {

	private final ASTMatcher structuralMatcher = new ASTMatcher();
	private final Set<UnknownSemanticRequirement> unknownRequirements = new LinkedHashSet<>();
	private final boolean ignoreSemanticIdentities;

	public FqnAwarePlaceholderAstMatcher() {
		this(false);
	}

	private FqnAwarePlaceholderAstMatcher(boolean ignoreSemanticIdentities) {
		this.ignoreSemanticIdentities = ignoreSemanticIdentities;
	}

	/**
	 * Tests whether an unresolved candidate otherwise has the required structure.
	 * This isolated diagnostic probe never supplies bindings for an executable match.
	 */
	public boolean couldMatchIgnoringSemanticIdentities(ASTNode pattern, ASTNode source) {
		return pattern != null && source != null
				&& pattern.subtreeMatch(new FqnAwarePlaceholderAstMatcher(true), source);
	}

	@Override
	public List<UnknownSemanticRequirement> getUnknownSemanticRequirements() {
		return List.copyOf(unknownRequirements);
	}

	@Override
	public boolean match(SimpleType patternNode, Object other) {
		return other instanceof SimpleType otherType
				&& namesEquivalent(patternNode.getName(), otherType.getName());
	}

	@Override
	public boolean match(QualifiedType patternNode, Object other) {
		return other instanceof Type otherType && typesEquivalent(patternNode, otherType);
	}

	@Override
	public boolean match(NameQualifiedType patternNode, Object other) {
		return other instanceof Type otherType && typesEquivalent(patternNode, otherType);
	}

	@Override
	public boolean match(ParameterizedType patternNode, Object other) {
		return other instanceof Type otherType && typesEquivalent(patternNode, otherType);
	}

	@Override
	public boolean match(ArrayType patternNode, Object other) {
		return other instanceof Type otherType && typesEquivalent(patternNode, otherType);
	}

	@Override
	public boolean match(PrimitiveType patternNode, Object other) {
		return other instanceof PrimitiveType otherType
				&& patternNode.getPrimitiveTypeCode().equals(otherType.getPrimitiveTypeCode());
	}

	@Override
	public boolean match(WildcardType patternNode, Object other) {
		if (!(other instanceof WildcardType otherType)) {
			return false;
		}
		if (patternNode.isUpperBound() != otherType.isUpperBound()) {
			return false;
		}
		return typesEquivalent(patternNode.getBound(), otherType.getBound());
	}

	@Override
	public boolean match(TypeLiteral patternNode, Object other) {
		return other instanceof TypeLiteral otherLiteral
				&& typesEquivalent(patternNode.getType(), otherLiteral.getType());
	}

	@Override
	public boolean match(CastExpression patternNode, Object other) {
		return other instanceof CastExpression otherCast
				&& typesEquivalent(patternNode.getType(), otherCast.getType())
				&& patternNode.getExpression().subtreeMatch(this, otherCast.getExpression());
	}

	@Override
	public boolean match(ClassInstanceCreation patternNode, Object other) {
		if (!(other instanceof ClassInstanceCreation otherCreation)) {
			return false;
		}
		if (!typesEquivalent(patternNode.getType(), otherCreation.getType())) {
			return false;
		}
		@SuppressWarnings("unchecked")
		List<Type> patternTypeArgs = patternNode.typeArguments();
		@SuppressWarnings("unchecked")
		List<Type> otherTypeArgs = otherCreation.typeArguments();
		if (!typeListsEquivalent(patternTypeArgs, otherTypeArgs)) {
			return false;
		}
		if (!subtreeNullable(patternNode.getExpression(), otherCreation.getExpression())) {
			return false;
		}
		if (!subtreeNullable(patternNode.getAnonymousClassDeclaration(), otherCreation.getAnonymousClassDeclaration())) {
			return false;
		}
		@SuppressWarnings("unchecked")
		List<Expression> patternArgs = patternNode.arguments();
		@SuppressWarnings("unchecked")
		List<Expression> otherArgs = otherCreation.arguments();
		if (!matchArgumentsWithMultiPlaceholders(patternArgs, otherArgs)) {
			return false;
		}
		if (typeIdentity(patternNode.getType()).contains(".")) { //$NON-NLS-1$
			IMethodBinding constructor = otherCreation.resolveConstructorBinding();
			if (constructor == null || constructor.isRecovered()) {
				markUnknown("Constructor binding unavailable for " + typeIdentity(patternNode.getType())); //$NON-NLS-1$
			}
		}
		return true;
	}

	@Override
	public boolean match(MethodInvocation patternNode, Object other) {
		if (!(other instanceof MethodInvocation otherInvocation)) {
			return false;
		}
		if (!patternNode.getName().subtreeMatch(this, otherInvocation.getName())) {
			return false;
		}
		if (!receiversEquivalent(patternNode.getExpression(), otherInvocation.getExpression())) {
			return false;
		}
		if (!methodBindingCompatible(patternNode, otherInvocation)) {
			return false;
		}
		@SuppressWarnings("unchecked")
		List<Type> patternTypeArgs = patternNode.typeArguments();
		@SuppressWarnings("unchecked")
		List<Type> otherTypeArgs = otherInvocation.typeArguments();
		if (!typeListsEquivalent(patternTypeArgs, otherTypeArgs)) {
			return false;
		}
		@SuppressWarnings("unchecked")
		List<Expression> patternArgs = patternNode.arguments();
		@SuppressWarnings("unchecked")
		List<Expression> otherArgs = otherInvocation.arguments();
		return matchArgumentsWithMultiPlaceholders(patternArgs, otherArgs);
	}

	private boolean typesEquivalent(Type patternType, Type sourceType) {
		if (patternType == null) {
			return sourceType == null;
		}
		if (sourceType == null) {
			return false;
		}
		if (patternType instanceof SimpleType patternSimple && sourceType instanceof SimpleType sourceSimple) {
			return namesEquivalent(patternSimple.getName(), sourceSimple.getName());
		}
		if (patternType instanceof ParameterizedType patternParam && sourceType instanceof ParameterizedType sourceParam) {
			return typesEquivalent(patternParam.getType(), sourceParam.getType())
					&& typeListsEquivalent(patternParam.typeArguments(), sourceParam.typeArguments());
		}
		if (patternType instanceof ParameterizedType patternParam) {
			return typesEquivalent(patternParam.getType(), sourceType);
		}
		if (sourceType instanceof ParameterizedType sourceParam) {
			return typesEquivalent(patternType, sourceParam.getType());
		}
		if (patternType instanceof ArrayType patternArray && sourceType instanceof ArrayType sourceArray) {
			return patternArray.getDimensions() == sourceArray.getDimensions()
					&& typesEquivalent(patternArray.getElementType(), sourceArray.getElementType());
		}
		if (patternType instanceof WildcardType patternWildcard && sourceType instanceof WildcardType sourceWildcard) {
			return patternWildcard.isUpperBound() == sourceWildcard.isUpperBound()
					&& typesEquivalent(patternWildcard.getBound(), sourceWildcard.getBound());
		}
		String patternIdentity = typeIdentity(patternType);
		String sourceIdentity = typeIdentity(sourceType);
		return patternIdentity != null && patternIdentity.equals(sourceIdentity);
	}

	private boolean typeListsEquivalent(List<?> patternTypes, List<?> sourceTypes) {
		if (patternTypes.size() != sourceTypes.size()) {
			return false;
		}
		for (int i = 0; i < patternTypes.size(); i++) {
			if (!typesEquivalent((Type) patternTypes.get(i), (Type) sourceTypes.get(i))) {
				return false;
			}
		}
		return true;
	}

	private boolean subtreeNullable(ASTNode patternNode, ASTNode sourceNode) {
		if (patternNode == null) {
			return sourceNode == null;
		}
		return patternNode.subtreeMatch(this, sourceNode);
	}

	private boolean receiversEquivalent(Expression patternExpr, Expression sourceExpr) {
		if (patternExpr == null) {
			return sourceExpr == null;
		}
		if (sourceExpr == null) {
			return false;
		}
		if (patternExpr instanceof Name patternName && sourceExpr instanceof Name sourceName) {
			return namesEquivalent(patternName, sourceName);
		}
		if (patternExpr.subtreeMatch(this, sourceExpr)) {
			return true;
		}
		return patternExpr instanceof Name patternName
				&& sourceExpr instanceof Name sourceName
				&& namesEquivalent(patternName, sourceName);
	}

	private boolean methodBindingCompatible(MethodInvocation patternNode, MethodInvocation sourceNode) {
		if (ignoreSemanticIdentities || !(patternNode.getExpression() instanceof Name patternReceiver)
				|| containsPlaceholder(patternReceiver)
				|| !isFullyQualified(patternReceiver.getFullyQualifiedName())) {
			return true;
		}
		IMethodBinding binding = sourceNode.resolveMethodBinding();
		if (binding == null || binding.isRecovered() || binding.getDeclaringClass() == null
				|| binding.getDeclaringClass().isRecovered()) {
			markUnknown("Method binding unavailable for " + patternReceiver.getFullyQualifiedName() //$NON-NLS-1$
					+ '.' + patternNode.getName().getIdentifier());
			return true;
		}
		// receiversEquivalent already verified the actual receiver identity. The
		// method can be inherited and declared on a different type.
		return true;
	}

	private boolean namesEquivalent(Name patternName, Name sourceName) {
		if (containsPlaceholder(patternName)) {
			return patternName.subtreeMatch(this, sourceName);
		}
		String patternFqn = patternName.getFullyQualifiedName();
		if (ignoreSemanticIdentities && isFullyQualified(patternFqn)) {
			return true;
		}
		if (isFullyQualified(patternFqn)) {
			IBinding binding = sourceName.resolveBinding();
			if (binding != null && !binding.isRecovered()) {
				// A variable can shadow an imported type; an explicit resolved identity
				// takes precedence over plausible imports or java.lang spelling.
				if (binding instanceof ITypeBinding type) {
					return patternFqn.equals(type.getErasure().getQualifiedName());
				}
				if (binding instanceof IVariableBinding field && field.isField() && field.getDeclaringClass() != null) {
					if (patternFqn.equals(field.getDeclaringClass().getErasure().getQualifiedName() + '.' + field.getName())) {
						return true;
					}
					// A relative field receiver such as System.out names its type
					// without a package. Resolve that qualifier rather than comparing
					// the relative spelling with the field's fully qualified identity.
					return patternName instanceof QualifiedName patternQualified
							&& patternQualified.getQualifier() instanceof SimpleName
							&& sourceName instanceof QualifiedName sourceQualified
							&& sourceQualified.getQualifier().resolveBinding() instanceof ITypeBinding
							&& patternQualified.getName().getIdentifier().equals(field.getName())
							&& namesEquivalent(patternQualified.getQualifier(), sourceQualified.getQualifier());
				}
				return false;
			}
			markUnknown("Type binding unavailable for " + patternFqn); //$NON-NLS-1$
		}
		if (patternName.subtreeMatch(structuralMatcher, sourceName)) {
			return true;
		}
		String sourceFqn = sourceName.getFullyQualifiedName();
		if (patternFqn.equals(sourceFqn)) {
			return true;
		}
		if (isFullyQualified(patternFqn) && sourceName instanceof SimpleName sourceSimple) {
			return simpleNameResolvesTo(sourceSimple, patternFqn);
		}
		if (isFullyQualified(sourceFqn) && patternName instanceof SimpleName patternSimple) {
			return simpleNameResolvesTo(patternSimple, sourceFqn);
		}
		return false;
	}

	private boolean containsPlaceholder(Name name) {
		if (name instanceof SimpleName simpleName) {
			return simpleName.getIdentifier().startsWith("$"); //$NON-NLS-1$
		}
		return name instanceof QualifiedName qualifiedName
				&& (containsPlaceholder(qualifiedName.getQualifier()) || containsPlaceholder(qualifiedName.getName()));
	}

	private String typeIdentity(Type type) {
		if (type == null) {
			return null;
		}
		if (type instanceof SimpleType simpleType) {
			return nameIdentity(simpleType.getName());
		}
		if (type instanceof ParameterizedType parameterizedType) {
			String raw = typeIdentity(parameterizedType.getType());
			@SuppressWarnings("unchecked")
			List<Type> args = parameterizedType.typeArguments();
			if (args.isEmpty()) {
				return raw;
			}
			StringBuilder sb = new StringBuilder(raw == null ? "" : raw); //$NON-NLS-1$
			sb.append('<');
			for (int i = 0; i < args.size(); i++) {
				if (i > 0) {
					sb.append(',');
				}
				sb.append(typeIdentity(args.get(i)));
			}
			sb.append('>');
			return sb.toString();
		}
		if (type instanceof ArrayType arrayType) {
			return typeIdentity(arrayType.getElementType()) + "[]".repeat(arrayType.getDimensions()); //$NON-NLS-1$
		}
		if (type instanceof PrimitiveType primitiveType) {
			return primitiveType.getPrimitiveTypeCode().toString();
		}
		if (type instanceof WildcardType wildcardType) {
			if (wildcardType.getBound() == null) {
				return "?"; //$NON-NLS-1$
			}
			return wildcardType.isUpperBound()
					? "? extends " + typeIdentity(wildcardType.getBound()) //$NON-NLS-1$
					: "? super " + typeIdentity(wildcardType.getBound()); //$NON-NLS-1$
		}
		return type.toString();
	}

	private String nameIdentity(Name name) {
		if (name instanceof SimpleName simpleName) {
			String resolved = resolveSimpleNameViaImports(simpleName);
			return resolved != null ? resolved : simpleName.getIdentifier();
		}
		return name.getFullyQualifiedName();
	}

	private boolean simpleNameResolvesTo(SimpleName simpleName, String expectedFqn) {
		String identifier = simpleName.getIdentifier();
		int lastDot = expectedFqn.lastIndexOf('.');
		String expectedSimpleName = lastDot >= 0 ? expectedFqn.substring(lastDot + 1) : expectedFqn;
		if (!identifier.equals(expectedSimpleName)) {
			return false;
		}
		String resolved = resolveSimpleNameViaImports(simpleName);
		if (expectedFqn.equals(resolved)) {
			return true;
		}
		if (expectedFqn.startsWith("java.lang.")) { //$NON-NLS-1$
			return true;
		}
		CompilationUnit cu = findCompilationUnit(simpleName);
		if (cu != null && cu.getPackage() != null) {
			String packageName = cu.getPackage().getName().getFullyQualifiedName();
			if (expectedFqn.equals(packageName + '.' + identifier)) {
				return true;
			}
		}
		return importOnDemandCanResolve(simpleName, expectedFqn);
	}

	private String resolveSimpleNameViaImports(SimpleName simpleName) {
		CompilationUnit cu = findCompilationUnit(simpleName);
		if (cu == null) {
			return null;
		}
		@SuppressWarnings("unchecked")
		List<ImportDeclaration> imports = cu.imports();
		for (ImportDeclaration importDecl : imports) {
			if (importDecl.isStatic() || importDecl.isOnDemand()) {
				continue;
			}
			String importFqn = importDecl.getName().getFullyQualifiedName();
			int lastDot = importFqn.lastIndexOf('.');
			String importSimpleName = lastDot >= 0 ? importFqn.substring(lastDot + 1) : importFqn;
			if (simpleName.getIdentifier().equals(importSimpleName)) {
				return importFqn;
			}
		}
		return null;
	}

	private boolean importOnDemandCanResolve(SimpleName simpleName, String expectedFqn) {
		CompilationUnit cu = findCompilationUnit(simpleName);
		if (cu == null) {
			return false;
		}
		@SuppressWarnings("unchecked")
		List<ImportDeclaration> imports = cu.imports();
		for (ImportDeclaration importDecl : imports) {
			if (importDecl.isStatic() || !importDecl.isOnDemand()) {
				continue;
			}
			String packageName = importDecl.getName().getFullyQualifiedName();
			if (expectedFqn.equals(packageName + '.' + simpleName.getIdentifier())) {
				return true;
			}
		}
		return false;
	}

	private CompilationUnit findCompilationUnit(ASTNode node) {
		ASTNode current = node;
		while (current != null) {
			if (current instanceof CompilationUnit cu) {
				return cu;
			}
			current = current.getParent();
		}
		return null;
	}

	private boolean isFullyQualified(String name) {
		return name != null && name.indexOf('.') > 0;
	}

	private void markUnknown(String detail) {
		unknownRequirements.add(new UnknownSemanticRequirement("source-pattern", detail)); //$NON-NLS-1$
	}
}