PlaceholderAstMatcher.java

/*******************************************************************************
 * Copyright (c) 2025 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.internal;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import org.eclipse.jdt.core.dom.ASTMatcher;
import org.eclipse.jdt.core.dom.ASTNode;
import org.eclipse.jdt.core.dom.Expression;
import org.eclipse.jdt.core.dom.FieldDeclaration;
import org.eclipse.jdt.core.dom.IExtendedModifier;
import org.eclipse.jdt.core.dom.MarkerAnnotation;
import org.eclipse.jdt.core.dom.MemberValuePair;
import org.eclipse.jdt.core.dom.MethodInvocation;
import org.eclipse.jdt.core.dom.NormalAnnotation;
import org.eclipse.jdt.core.dom.NumberLiteral;
import org.eclipse.jdt.core.dom.SimpleName;
import org.eclipse.jdt.core.dom.SingleMemberAnnotation;
import org.eclipse.jdt.core.dom.Statement;
import org.eclipse.jdt.core.dom.StringLiteral;
import org.eclipse.jdt.core.dom.TypeLiteral;

/**
 * An AST matcher that supports placeholder matching with multi-placeholder and type constraint support.
 * 
 * <p>Placeholders are identified by a {@code $} prefix in SimpleName nodes.
 * When a placeholder is encountered:</p>
 * <ul>
 *   <li>If it's the first occurrence, the placeholder is bound to the corresponding node</li>
 *   <li>If it's a subsequent occurrence, the node must match the previously bound node</li>
 *   <li>Multi-placeholders (ending with $) match zero or more nodes and are stored as lists</li>
 *   <li>Type constraints (e.g., $x:StringLiteral) validate the matched node's type</li>
 * </ul>
 * 
 * <p>Example: In pattern {@code "$x + $x"}, both occurrences of {@code $x} must match
 * the same expression.</p>
 * 
 * @since 1.2.2
 */
public class PlaceholderAstMatcher extends ASTMatcher {
	
	private final Map<String, Object> bindings = new HashMap<>();  // Object can be ASTNode or List<ASTNode>
	private final ASTMatcher reusableMatcher = new ASTMatcher();
	
	/**
	 * Creates a new placeholder matcher.
	 */
	public PlaceholderAstMatcher() {
		super();
	}
	
	/**
	 * Returns the placeholder bindings.
	 * 
	 * @return a map of placeholder names to bound AST nodes or lists of AST nodes
	 */
	public Map<String, Object> getBindings() {
		return new HashMap<>(bindings);
	}
	
	/**
	 * Clears all placeholder bindings.
	 */
	public void clearBindings() {
		bindings.clear();
	}
	
	/**
	 * Detects if a placeholder name represents a multi-placeholder (e.g., $args$).
	 * 
	 * @param name the placeholder name
	 * @return true if this is a multi-placeholder
	 */
	private boolean isMultiPlaceholder(String name) {
		return name.startsWith("$") && name.endsWith("$") && name.length() > 2; //$NON-NLS-1$ //$NON-NLS-2$
	}
	
	/**
	 * Parses placeholder information from a placeholder name.
	 * Supports syntax: $name, $name$, $name:Type, $name$:Type
	 * 
	 * @param placeholderName the placeholder name (e.g., "$x", "$args$", "$msg:StringLiteral")
	 * @return parsed placeholder information
	 */
	private PlaceholderInfo parsePlaceholder(String placeholderName) {
		String name = placeholderName;
		String typeConstraint = null;
		
		// Check for type constraint (e.g., $x:StringLiteral)
		int colonIndex = name.indexOf(':');
		if (colonIndex > 0) {
			typeConstraint = name.substring(colonIndex + 1);
			name = name.substring(0, colonIndex);
		}
		
		boolean isMulti = isMultiPlaceholder(name);
		return new PlaceholderInfo(name, typeConstraint, isMulti);
	}
	
	/**
	 * Validates that a node matches the specified type constraint.
	 * 
	 * @param node the AST node to validate
	 * @param typeConstraint the type constraint (e.g., "StringLiteral"), null means any type
	 * @return true if the node matches the constraint
	 */
	private boolean matchesTypeConstraint(ASTNode node, String typeConstraint) {
		if (typeConstraint == null) {
			return true;
		}
		
		return switch (typeConstraint) {
			case "StringLiteral" -> node instanceof StringLiteral; //$NON-NLS-1$
			case "NumberLiteral" -> node instanceof NumberLiteral; //$NON-NLS-1$
			case "TypeLiteral" -> node instanceof TypeLiteral; //$NON-NLS-1$
			case "SimpleName" -> node instanceof SimpleName; //$NON-NLS-1$
			case "MethodInvocation" -> node instanceof MethodInvocation; //$NON-NLS-1$
			case "Expression" -> node instanceof Expression; //$NON-NLS-1$
			case "Statement" -> node instanceof Statement; //$NON-NLS-1$
			default -> node.getClass().getSimpleName().equals(typeConstraint);
		};
	}
	
	@Override
	public boolean match(SimpleName patternNode, Object other) {
		if (!(other instanceof ASTNode)) {
			return false;
		}
		
		String name = patternNode.getIdentifier();
		
		// Check if this is a placeholder (starts with $)
		if (name != null && name.startsWith("$")) { //$NON-NLS-1$
			ASTNode otherNode = (ASTNode) other;
			
			// Parse placeholder info (handles type constraints)
			PlaceholderInfo placeholderInfo = parsePlaceholder(name);
			
			// Validate type constraint if specified
			if (!matchesTypeConstraint(otherNode, placeholderInfo.typeConstraint())) {
				return false;
			}
			
			// Use the cleaned placeholder name (without type constraint) for binding
			String placeholderName = placeholderInfo.name();
			
			// Check if this placeholder has been bound before
			if (bindings.containsKey(placeholderName)) {
				// Placeholder already bound - must match the previously bound node
				Object boundValue = bindings.get(placeholderName);
				if (boundValue instanceof ASTNode) {
					ASTNode boundNode = (ASTNode) boundValue;
					return boundNode.subtreeMatch(reusableMatcher, otherNode);
				}
				// If it's a list binding, that's an error - shouldn't happen for SimpleName
				return false;
			} else {
				// First occurrence - bind the placeholder to this node
				bindings.put(placeholderName, otherNode);
				return true;
			}
		}
		
		// Not a placeholder - use default matching
		return super.match(patternNode, other);
	}
	
	/**
	 * Matches marker annotations (e.g., @Before, @After).
	 * 
	 * @param patternNode the pattern annotation
	 * @param other the candidate node
	 * @return {@code true} if the annotations match
	 * @since 1.2.3
	 */
	@Override
	public boolean match(MarkerAnnotation patternNode, Object other) {
		if (!(other instanceof MarkerAnnotation)) {
			return false;
		}
		MarkerAnnotation otherAnnotation = (MarkerAnnotation) other;
		
		// Match annotation name
		return patternNode.getTypeName().getFullyQualifiedName()
				.equals(otherAnnotation.getTypeName().getFullyQualifiedName());
	}
	
	/**
	 * Matches single member annotations (e.g., {@code @SuppressWarnings("unchecked")}).
	 * 
	 * @param patternNode the pattern annotation
	 * @param other the candidate node
	 * @return {@code true} if the annotations match
	 * @since 1.2.3
	 */
	@Override
	public boolean match(SingleMemberAnnotation patternNode, Object other) {
		if (!(other instanceof SingleMemberAnnotation)) {
			return false;
		}
		SingleMemberAnnotation otherAnnotation = (SingleMemberAnnotation) other;
		
		// Match annotation name
		if (!patternNode.getTypeName().getFullyQualifiedName()
				.equals(otherAnnotation.getTypeName().getFullyQualifiedName())) {
			return false;
		}
		
		// Match the value with placeholder support
		return safeSubtreeMatch(patternNode.getValue(), otherAnnotation.getValue());
	}
	
	/**
	 * Matches normal annotations (e.g., @Test(expected=Exception.class, timeout=1000)).
	 * 
	 * @param patternNode the pattern annotation
	 * @param other the candidate node
	 * @return {@code true} if the annotations match
	 * @since 1.2.3
	 */
	@Override
	public boolean match(NormalAnnotation patternNode, Object other) {
		if (!(other instanceof NormalAnnotation)) {
			return false;
		}
		NormalAnnotation otherAnnotation = (NormalAnnotation) other;
		
		// Match annotation name
		if (!patternNode.getTypeName().getFullyQualifiedName()
				.equals(otherAnnotation.getTypeName().getFullyQualifiedName())) {
			return false;
		}
		
		// Match member-value pairs with placeholder support
		@SuppressWarnings("unchecked")
		List<MemberValuePair> patternPairs = patternNode.values();
		@SuppressWarnings("unchecked")
		List<MemberValuePair> otherPairs = otherAnnotation.values();
		
		// Must have same number of pairs
		if (patternPairs.size() != otherPairs.size()) {
			return false;
		}
		
		// Create a map for O(n) lookup instead of O(n²)
		Map<String, MemberValuePair> otherPairMap = new HashMap<>();
		for (MemberValuePair otherPair : otherPairs) {
			otherPairMap.put(otherPair.getName().getIdentifier(), otherPair);
		}
		
		// Match each pattern pair with corresponding pair in other annotation
		// (annotation pairs can be in any order)
		for (MemberValuePair patternPair : patternPairs) {
			String patternName = patternPair.getName().getIdentifier();
			
			// Find corresponding pair in other annotation
			MemberValuePair matchingOtherPair = otherPairMap.get(patternName);
			
			// If no matching pair found, annotations don't match
			if (matchingOtherPair == null) {
				return false;
			}
			
			// Values must match (with placeholder support)
			if (!safeSubtreeMatch(patternPair.getValue(), matchingOtherPair.getValue())) {
				return false;
			}
		}
		
		return true;
	}
	
	/**
	 * Matches field declarations with support for annotations and placeholders.
	 * 
	 * @param patternNode the pattern field declaration
	 * @param other the candidate node
	 * @return {@code true} if the fields match
	 * @since 1.2.3
	 */
	@Override
	public boolean match(FieldDeclaration patternNode, Object other) {
		if (!(other instanceof FieldDeclaration)) {
			return false;
		}
		FieldDeclaration otherField = (FieldDeclaration) other;
		
		// Match modifiers (including annotations)
		@SuppressWarnings("unchecked")
		List<IExtendedModifier> patternModifiers = patternNode.modifiers();
		@SuppressWarnings("unchecked")
		List<IExtendedModifier> otherModifiers = otherField.modifiers();
		
		// Match each modifier/annotation in the pattern
		for (IExtendedModifier patternMod : patternModifiers) {
			if (patternMod.isAnnotation()) {
				// Find matching annotation in other field
				boolean found = false;
				for (IExtendedModifier otherMod : otherModifiers) {
					if (otherMod.isAnnotation()) {
						if (safeSubtreeMatch((ASTNode) patternMod, (ASTNode) otherMod)) {
							found = true;
							break;
						}
					}
				}
				if (!found) {
					return false;
				}
			} else if (patternMod.isModifier()) {
				// Check if other has the same modifier
				boolean found = false;
				for (IExtendedModifier otherMod : otherModifiers) {
					if (otherMod.isModifier()) {
						if (safeSubtreeMatch((ASTNode) patternMod, (ASTNode) otherMod)) {
							found = true;
							break;
						}
					}
				}
				if (!found) {
					return false;
				}
			}
		}
		
		// Match type
		if (!safeSubtreeMatch(patternNode.getType(), otherField.getType())) {
			return false;
		}
		
		// Match fragments (variable names) - need special handling for placeholders
		@SuppressWarnings("unchecked")
		List<Object> patternFragments = patternNode.fragments();
		@SuppressWarnings("unchecked")
		List<Object> otherFragments = otherField.fragments();
		
		if (patternFragments.size() != otherFragments.size()) {
			return false;
		}
		
		// For each fragment, we only need to match the variable name (not the initializer)
		// because the pattern might have placeholder names like $name
		for (int i = 0; i < patternFragments.size(); i++) {
			org.eclipse.jdt.core.dom.VariableDeclarationFragment patternFrag = 
					(org.eclipse.jdt.core.dom.VariableDeclarationFragment) patternFragments.get(i);
			org.eclipse.jdt.core.dom.VariableDeclarationFragment otherFrag = 
					(org.eclipse.jdt.core.dom.VariableDeclarationFragment) otherFragments.get(i);
			
			// Match the variable name (this handles placeholders via SimpleName matching)
			if (!safeSubtreeMatch(patternFrag.getName(), otherFrag.getName())) {
				return false;
			}
			
			// Only check initializers if pattern has one
			if (patternFrag.getInitializer() != null) {
				if (!safeSubtreeMatch(patternFrag.getInitializer(), otherFrag.getInitializer())) {
					return false;
				}
			}
		}
		
		return true;
	}
	
	/**
	 * Helper method to perform subtree matching using this matcher.
	 */
	private boolean safeSubtreeMatch(ASTNode node1, ASTNode node2) {
		if (node1 == null) {
			return node2 == null;
		}
		return node1.subtreeMatch(this, node2);
	}
	
	/**
	 * Matches method invocations with support for multi-placeholder arguments.
	 * 
	 * @param patternNode the pattern method invocation
	 * @param other the candidate node
	 * @return {@code true} if the method invocations match
	 * @since 1.3.1
	 */
	@Override
	public boolean match(MethodInvocation patternNode, Object other) {
		if (!(other instanceof MethodInvocation)) {
			return false;
		}
		MethodInvocation otherInvocation = (MethodInvocation) other;
		
		// Match method name
		if (!safeSubtreeMatch(patternNode.getName(), otherInvocation.getName())) {
			return false;
		}
		
		// Match expression (receiver)
		if (!safeSubtreeMatch(patternNode.getExpression(), otherInvocation.getExpression())) {
			return false;
		}
		
		// Match type arguments if present
		@SuppressWarnings("unchecked")
		List<org.eclipse.jdt.core.dom.Type> patternTypeArgs = patternNode.typeArguments();
		@SuppressWarnings("unchecked")
		List<org.eclipse.jdt.core.dom.Type> otherTypeArgs = otherInvocation.typeArguments();
		
		if (patternTypeArgs.size() != otherTypeArgs.size()) {
			return false;
		}
		
		for (int i = 0; i < patternTypeArgs.size(); i++) {
			if (!safeSubtreeMatch(patternTypeArgs.get(i), otherTypeArgs.get(i))) {
				return false;
			}
		}
		
		// Match arguments with multi-placeholder support
		@SuppressWarnings("unchecked")
		List<Expression> patternArgs = patternNode.arguments();
		@SuppressWarnings("unchecked")
		List<Expression> otherArgs = otherInvocation.arguments();
		
		return matchArgumentsWithMultiPlaceholders(patternArgs, otherArgs);
	}
	
	/**
	 * Matches argument lists with support for multi-placeholders.
	 * 
	 * @param patternArgs the pattern arguments
	 * @param otherArgs the candidate arguments
	 * @return true if arguments match (considering multi-placeholders)
	 */
	private boolean matchArgumentsWithMultiPlaceholders(List<Expression> patternArgs, List<Expression> otherArgs) {
		// Check if pattern has a single multi-placeholder argument
		if (patternArgs.size() == 1 && patternArgs.get(0) instanceof SimpleName) {
			SimpleName patternArg = (SimpleName) patternArgs.get(0);
			String name = patternArg.getIdentifier();
			
			if (name != null && name.startsWith("$")) { //$NON-NLS-1$
				PlaceholderInfo info = parsePlaceholder(name);
				
				if (info.isMulti()) {
					// Multi-placeholder: bind to list of all arguments
					String placeholderName = info.name();
					
					// Validate type constraints for all arguments if specified
					if (info.typeConstraint() != null) {
						for (Expression arg : otherArgs) {
							if (!matchesTypeConstraint(arg, info.typeConstraint())) {
								return false;
							}
						}
					}
					
					// Check if already bound
					if (bindings.containsKey(placeholderName)) {
						Object boundValue = bindings.get(placeholderName);
						if (boundValue instanceof List<?>) {
							@SuppressWarnings("unchecked")
							List<ASTNode> boundList = (List<ASTNode>) boundValue;
							if (boundList.size() != otherArgs.size()) {
								return false;
							}
							for (int i = 0; i < boundList.size(); i++) {
								if (!boundList.get(i).subtreeMatch(reusableMatcher, otherArgs.get(i))) {
									return false;
								}
							}
							return true;
						}
						return false;
					} else {
						// First occurrence - bind to list
						bindings.put(placeholderName, new ArrayList<>(otherArgs));
						return true;
					}
				}
			}
		}
		
		// Standard matching: same number of arguments, each matching
		if (patternArgs.size() != otherArgs.size()) {
			return false;
		}
		
		for (int i = 0; i < patternArgs.size(); i++) {
			if (!safeSubtreeMatch(patternArgs.get(i), otherArgs.get(i))) {
				return false;
			}
		}
		
		return true;
	}
}