Match.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.api;

import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;

import org.eclipse.jdt.core.dom.ASTNode;

/**
 * Represents a successful match of a pattern against Java code.
 * 
 * <p>A match contains:</p>
 * <ul>
 *   <li>The matched AST node</li>
 *   <li>A map of placeholder bindings (e.g., {@code "$x" -> InfixExpression})</li>
 *   <li>A map of multi-placeholder bindings (e.g., {@code "$args$" -> List<Expression>})</li>
 *   <li>Source location (offset and length)</li>
 * </ul>
 * 
 * @since 1.2.2
 */
public final class Match {
	/**
	 * Represents a type-safe placeholder binding.
	 * 
	 * @since 1.2.6
	 */
	public sealed interface Binding {
		/**
		 * A binding to a single AST node.
		 */
		record SingleNode(ASTNode node) implements Binding {}
		
		/**
		 * A binding to a list of AST nodes (for variadic/multi-placeholders like $args$).
		 */
		record NodeList(List<ASTNode> nodes) implements Binding {}
	}

	private final ASTNode matchedNode;
	private final Map<String, Object> bindings;  // Changed to Object to support both ASTNode and List<ASTNode>
	private final int offset;
	private final int length;
	private Map<String, Object> extraData;
	
	/**
	 * Creates a new match.
	 * 
	 * @param matchedNode the AST node that matched the pattern
	 * @param bindings map of placeholder names to their bound AST nodes or lists of AST nodes
	 * @param offset the character offset of the match in the source
	 * @param length the character length of the match in the source
	 */
	public Match(ASTNode matchedNode, Map<String, Object> bindings, int offset, int length) {
		this.matchedNode = Objects.requireNonNull(matchedNode, "Matched node cannot be null"); //$NON-NLS-1$
		this.bindings = bindings != null ? Collections.unmodifiableMap(bindings) : Collections.emptyMap();
		this.offset = offset;
		this.length = length;
	}
	
	/**
	 * Returns the matched AST node.
	 * 
	 * @return the matched node
	 */
	public ASTNode getMatchedNode() {
		return matchedNode;
	}
	
	/**
	 * Returns the placeholder bindings.
	 * 
	 * @return an unmodifiable map of placeholder names to AST nodes or lists of AST nodes
	 */
	public Map<String, Object> getBindings() {
		return bindings;
	}
	
	/**
	 * Gets a single-placeholder binding as an AST node.
	 * 
	 * @param placeholderName the placeholder name including $ marker (e.g., "$x")
	 * @return the bound AST node, or null if not found or if it's a multi-placeholder binding
	 */
	public ASTNode getBinding(String placeholderName) {
		Object binding = bindings.get(placeholderName);
		if (binding instanceof ASTNode) {
			return (ASTNode) binding;
		}
		return null;
	}
	
	/**
	 * Gets a multi-placeholder binding as a list of nodes.
	 * 
	 * @param placeholderName the placeholder name including $ markers (e.g., "$args$")
	 * @return the list of matched nodes, or empty list if not found or if it's a single-placeholder binding
	 */
	@SuppressWarnings("unchecked")
	public List<ASTNode> getListBinding(String placeholderName) {
		Object binding = bindings.get(placeholderName);
		if (binding instanceof List<?>) {
			return (List<ASTNode>) binding;
		}
		return Collections.emptyList();
	}
	
	/**
	 * Returns the placeholder bindings as type-safe {@link Binding} objects.
	 * 
	 * @return an unmodifiable map of placeholder names to their typed bindings
	 * @since 1.2.6
	 */
	public Map<String, Binding> getTypedBindings() {
		Map<String, Binding> typed = new LinkedHashMap<>();
		for (Map.Entry<String, Object> entry : bindings.entrySet()) {
			Object value = entry.getValue();
			if (value instanceof ASTNode astNode) {
				typed.put(entry.getKey(), new Binding.SingleNode(astNode));
			} else if (value instanceof List<?> list) {
				@SuppressWarnings("unchecked")
				List<ASTNode> nodeList = (List<ASTNode>) list;
				typed.put(entry.getKey(), new Binding.NodeList(Collections.unmodifiableList(nodeList)));
			}
		}
		return Collections.unmodifiableMap(typed);
	}

	/**
	 * Checks if a binding exists for the given placeholder name.
	 * 
	 * @param placeholderName the placeholder name including $ marker
	 * @return {@code true} if a binding exists
	 * @since 1.2.6
	 */
	public boolean hasBinding(String placeholderName) {
		return bindings.containsKey(placeholderName);
	}

	/**
	 * Stores extra data that guards can pass to replacement functions.
	 *
	 * <p>This is used by guards like {@code canWidenType} to pass computed
	 * results (e.g., the widest type name) to replacement functions like
	 * {@code $widestType} without re-computing them.</p>
	 *
	 * @param key the data key
	 * @param value the data value
	 * @since 1.3.12
	 */
	public void putExtraData(String key, Object value) {
		if (extraData == null) {
			extraData = new HashMap<>();
		}
		extraData.put(key, value);
	}

	/**
	 * Retrieves extra data stored by a guard function.
	 *
	 * @param key the data key
	 * @return the stored value, or {@code null} if not found
	 * @since 1.3.12
	 */
	public Object getExtraData(String key) {
		return extraData != null ? extraData.get(key) : null;
	}

	/**
	 * Returns the character offset of the match in the source.
	 * 
	 * @return the offset
	 */
	public int getOffset() {
		return offset;
	}
	
	/**
	 * Returns the character length of the match in the source.
	 * 
	 * @return the length
	 */
	public int getLength() {
		return length;
	}
	
	@Override
	public boolean equals(Object obj) {
		if (this == obj) {
			return true;
		}
		if (obj == null || getClass() != obj.getClass()) {
			return false;
		}
		Match other = (Match) obj;
		return offset == other.offset 
				&& length == other.length
				&& Objects.equals(matchedNode, other.matchedNode);
	}
	
	@Override
	public int hashCode() {
		return Objects.hash(matchedNode, offset, length);
	}
	
	@Override
	public String toString() {
		return "Match[offset=" + offset + ", length=" + length + ", bindings=" + bindings.keySet() + "]"; //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ //$NON-NLS-4$
	}
}