UniqueSequencePattern.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
 *******************************************************************************/
package org.sandbox.jdt.container.analysis;

import static org.sandbox.jdt.container.analysis.ContainerAstFacts.unwrap;
import static org.sandbox.jdt.container.analysis.ContainerAstFacts.variableBinding;

import java.util.Objects;
import java.util.Optional;

import org.eclipse.jdt.core.dom.ASTNode;
import org.eclipse.jdt.core.dom.Block;
import org.eclipse.jdt.core.dom.BooleanLiteral;
import org.eclipse.jdt.core.dom.CharacterLiteral;
import org.eclipse.jdt.core.dom.Expression;
import org.eclipse.jdt.core.dom.ExpressionStatement;
import org.eclipse.jdt.core.dom.IfStatement;
import org.eclipse.jdt.core.dom.IVariableBinding;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.MethodInvocation;
import org.eclipse.jdt.core.dom.NullLiteral;
import org.eclipse.jdt.core.dom.NumberLiteral;
import org.eclipse.jdt.core.dom.PrefixExpression;
import org.eclipse.jdt.core.dom.SimpleName;
import org.eclipse.jdt.core.dom.Statement;
import org.eclipse.jdt.core.dom.StringLiteral;

/**
 * Shared AST facts for the deliberately narrow
 * {@code if (!values.contains(value)) values.add(value)} pattern.
 *
 * <p>The matcher proves only the local syntactic relationship. The surrounding
 * analyzer must still classify every use of the collection binding before a
 * migration may be offered.</p>
 */
public final class UniqueSequencePattern {

	private UniqueSequencePattern() {
		// Utility class.
	}

	/**
	 * Matches one duplicate-suppressing insertion for the supplied collection binding.
	 *
	 * @param statement candidate conditional
	 * @param bindingKey stable collection binding key
	 * @return the matched guard and insertion, or an empty result
	 */
	public static Optional<GuardedAdd> match(
			IfStatement statement,
			String bindingKey) {
		Objects.requireNonNull(statement, "statement"); //$NON-NLS-1$
		if (bindingKey == null || bindingKey.isBlank() || statement.getElseStatement() != null) {
			return Optional.empty();
		}

		Expression condition= unwrap(statement.getExpression());
		if (!(condition instanceof PrefixExpression negation)
				|| negation.getOperator() != PrefixExpression.Operator.NOT
				|| !(unwrap(negation.getOperand()) instanceof MethodInvocation contains)
				|| !isInvocation(contains, "contains", bindingKey) //$NON-NLS-1$
				|| contains.arguments().size() != 1) {
			return Optional.empty();
		}

		MethodInvocation add= singleInvocation(statement.getThenStatement());
		if (add == null
				|| !isInvocation(add, "add", bindingKey) //$NON-NLS-1$
				|| add.arguments().size() != 1) {
			return Optional.empty();
		}

		Expression testedValue= (Expression) contains.arguments().get(0);
		Expression insertedValue= (Expression) add.arguments().get(0);
		if (!sameStableValue(testedValue, insertedValue)) {
			return Optional.empty();
		}
		return Optional.of(new GuardedAdd(statement, contains, add, insertedValue));
	}

	/** Returns the nearest matching guarded add enclosing the supplied AST node. */
	public static Optional<GuardedAdd> enclosing(
			ASTNode node,
			String bindingKey) {
		ASTNode current= Objects.requireNonNull(node, "node"); //$NON-NLS-1$
		while (current != null && !(current instanceof MethodDeclaration)) {
			if (current instanceof IfStatement statement) {
				Optional<GuardedAdd> match= match(statement, bindingKey);
				if (match.isPresent()) {
					GuardedAdd guardedAdd= match.get();
					if (isDescendant(node, guardedAdd.contains())
							|| isDescendant(node, guardedAdd.add())) {
						return match;
					}
				}
			}
			current= current.getParent();
		}
		return Optional.empty();
	}

	/** Returns whether the invocation receiver resolves to the supplied binding. */
	public static boolean isInvocation(
			MethodInvocation invocation,
			String methodName,
			String bindingKey) {
		return methodName.equals(invocation.getName().getIdentifier())
				&& invocation.getExpression() != null
				&& variableBinding(unwrap(invocation.getExpression()))
						.map(IVariableBinding::getVariableDeclaration)
						.map(IVariableBinding::getKey)
						.filter(bindingKey::equals)
						.isPresent();
	}

	private static MethodInvocation singleInvocation(Statement statement) {
		Statement effective= statement;
		if (effective instanceof Block block) {
			if (block.statements().size() != 1
					|| !(block.statements().get(0) instanceof Statement nested)) {
				return null;
			}
			effective= nested;
		}
		if (!(effective instanceof ExpressionStatement expressionStatement)
				|| !(unwrap(expressionStatement.getExpression()) instanceof MethodInvocation invocation)) {
			return null;
		}
		return invocation;
	}

	private static boolean sameStableValue(Expression first, Expression second) {
		Expression left= unwrap(first);
		Expression right= unwrap(second);
		if (left instanceof SimpleName leftName && right instanceof SimpleName rightName) {
			return sameStableLocalValue(leftName, rightName);
		}
		if (left instanceof StringLiteral leftLiteral && right instanceof StringLiteral rightLiteral) {
			return leftLiteral.getLiteralValue().equals(rightLiteral.getLiteralValue());
		}
		if (left instanceof NumberLiteral leftLiteral && right instanceof NumberLiteral rightLiteral) {
			return leftLiteral.getToken().equals(rightLiteral.getToken());
		}
		if (left instanceof CharacterLiteral leftLiteral
				&& right instanceof CharacterLiteral rightLiteral) {
			return leftLiteral.charValue() == rightLiteral.charValue();
		}
		if (left instanceof BooleanLiteral leftLiteral
				&& right instanceof BooleanLiteral rightLiteral) {
			return leftLiteral.booleanValue() == rightLiteral.booleanValue();
		}
		return left instanceof NullLiteral && right instanceof NullLiteral;
	}

	private static boolean sameStableLocalValue(
			SimpleName first,
			SimpleName second) {
		IVariableBinding firstBinding= variableBinding(first).orElse(null);
		IVariableBinding secondBinding= variableBinding(second).orElse(null);
		return firstBinding != null && secondBinding != null
				&& sameLocalValue(firstBinding, secondBinding);
	}

	private static boolean sameLocalValue(
			IVariableBinding first,
			IVariableBinding second) {
		IVariableBinding firstDeclaration= first.getVariableDeclaration();
		IVariableBinding secondDeclaration= second.getVariableDeclaration();
		if (firstDeclaration.isField() || secondDeclaration.isField()) {
			return false;
		}
		String firstKey= firstDeclaration.getKey();
		String secondKey= secondDeclaration.getKey();
		return firstKey != null && firstKey.equals(secondKey);
	}

	private static boolean isDescendant(ASTNode candidate, ASTNode ancestor) {
		ASTNode current= candidate;
		while (current != null) {
			if (current == ancestor) {
				return true;
			}
			current= current.getParent();
		}
		return false;
	}

	/** One matched membership guard and its sole insertion. */
	public record GuardedAdd(
			IfStatement statement,
			MethodInvocation contains,
			MethodInvocation add,
			Expression value) {

		public GuardedAdd {
			Objects.requireNonNull(statement, "statement"); //$NON-NLS-1$
			Objects.requireNonNull(contains, "contains"); //$NON-NLS-1$
			Objects.requireNonNull(add, "add"); //$NON-NLS-1$
			Objects.requireNonNull(value, "value"); //$NON-NLS-1$
		}
	}
}