ContainerLocalRewriteResolver.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.cleanup.multifile;

import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Objects;
import java.util.Set;

import org.eclipse.core.runtime.CoreException;
import org.eclipse.core.runtime.IStatus;
import org.eclipse.core.runtime.Status;

import org.eclipse.jdt.core.ICompilationUnit;
import org.eclipse.jdt.core.dom.ASTNode;
import org.eclipse.jdt.core.dom.ASTVisitor;
import org.eclipse.jdt.core.dom.ArrayAccess;
import org.eclipse.jdt.core.dom.ArrayCreation;
import org.eclipse.jdt.core.dom.ArrayType;
import org.eclipse.jdt.core.dom.Assignment;
import org.eclipse.jdt.core.dom.Block;
import org.eclipse.jdt.core.dom.EnhancedForStatement;
import org.eclipse.jdt.core.dom.Expression;
import org.eclipse.jdt.core.dom.ExpressionStatement;
import org.eclipse.jdt.core.dom.FieldAccess;
import org.eclipse.jdt.core.dom.IBinding;
import org.eclipse.jdt.core.dom.IMethodBinding;
import org.eclipse.jdt.core.dom.IVariableBinding;
import org.eclipse.jdt.core.dom.InfixExpression;
import org.eclipse.jdt.core.dom.MethodInvocation;
import org.eclipse.jdt.core.dom.NumberLiteral;
import org.eclipse.jdt.core.dom.ParenthesizedExpression;
import org.eclipse.jdt.core.dom.QualifiedName;
import org.eclipse.jdt.core.dom.SimpleName;
import org.eclipse.jdt.core.dom.Statement;
import org.eclipse.jdt.core.dom.SuperFieldAccess;
import org.eclipse.jdt.core.dom.VariableDeclarationFragment;
import org.eclipse.jdt.core.dom.VariableDeclarationStatement;

import org.sandbox.jdt.container.api.ContainerLocalRewritePlan;
import org.sandbox.jdt.container.api.ContainerLocalRewritePlan.EditKind;

/** Binding-based, fail-closed resolution of a local container rewrite plan. */
final class ContainerLocalRewriteResolver {

	private static final String PLUGIN_ID= "sandbox_common"; //$NON-NLS-1$

	private ContainerLocalRewriteResolver() {
	}

	static ResolvedPlan resolve(
			ICompilationUnit unit,
			org.eclipse.jdt.core.dom.CompilationUnit root,
			ContainerLocalRewritePlan plan) throws CoreException {
		Objects.requireNonNull(unit, "unit"); //$NON-NLS-1$
		Objects.requireNonNull(root, "root"); //$NON-NLS-1$
		Objects.requireNonNull(plan, "plan"); //$NON-NLS-1$
		if (!plan.compilationUnitHandle().equals(unit.getHandleIdentifier())) {
			throw stale(unit, "compilation-unit handle changed"); //$NON-NLS-1$
		}

		CollectedAst collected= collectAst(unit, root, plan.bindingKey());
		VariableDeclarationFragment fragment= collected.declaration();
		if (fragment == null
				|| !(fragment.getParent() instanceof VariableDeclarationStatement statement)
				|| statement.fragments().size() != 1
				|| !(statement.getType() instanceof ArrayType arrayType)
				|| arrayType.dimensions().size() != 1
				|| !(fragment.getInitializer() instanceof ArrayCreation initializer)
				|| !isEmptyOneDimensionalArray(initializer)) {
			throw stale(unit, "local array declaration or empty initializer changed"); //$NON-NLS-1$
		}

		List<Assignment> growthAssignments= collected.assignments().stream()
				.filter(assignment -> isGrowthAssignment(assignment, plan.bindingKey()))
				.sorted(Comparator.comparingInt(ASTNode::getStartPosition))
				.toList();
		List<Assignment> appendAssignments= collected.assignments().stream()
				.filter(assignment -> isTailAppendAssignment(assignment, plan.bindingKey()))
				.sorted(Comparator.comparingInt(ASTNode::getStartPosition))
				.toList();
		validateOccurrenceCounts(unit, plan, growthAssignments, appendAssignments);

		List<AppendPair> pairs= pairAppends(unit, growthAssignments, appendAssignments);
		Set<Assignment> recognisedAssignments=
				java.util.Collections.newSetFromMap(new IdentityHashMap<>());
		for (AppendPair pair : pairs) {
			recognisedAssignments.add(pair.growthAssignment());
			recognisedAssignments.add(pair.appendAssignment());
		}

		Set<SourceRange> expectedTransfers= plannedRanges(
				plan, EditKind.VERIFY_ARGUMENT_TRANSFER);
		if (expectedTransfers.size()
				!= editCount(plan, EditKind.VERIFY_ARGUMENT_TRANSFER)) {
			throw stale(unit, "duplicate argument-transfer source range in plan"); //$NON-NLS-1$
		}
		Set<SourceRange> observedTransfers= new HashSet<>(expectedTransfers.size() * 2);
		Set<Expression> lengthExpressions=
				java.util.Collections.newSetFromMap(new IdentityHashMap<>());
		for (SimpleName reference : collected.references()) {
			if (fragment.getName() == reference) {
				continue;
			}
			Assignment containingAssignment= containingAssignment(reference);
			if (containingAssignment != null && recognisedAssignments.contains(containingAssignment)) {
				continue;
			}
			ResolvedLength length= lengthRead(reference, plan.bindingKey());
			if (length != null) {
				lengthExpressions.add(length.expression());
				continue;
			}
			if (isEnhancedForExpression(reference, plan.bindingKey())) {
				continue;
			}
			if (isPlannedArgumentTransfer(
					reference, expectedTransfers, observedTransfers)) {
				continue;
			}
			throw stale(unit, "unexpected use of local array binding at source offset " //$NON-NLS-1$
					+ reference.getStartPosition());
		}
		if (lengthExpressions.size() != editCount(plan, EditKind.REPLACE_LENGTH_WITH_SIZE)) {
			throw stale(unit, "array length occurrence count changed"); //$NON-NLS-1$
		}
		if (!observedTransfers.equals(expectedTransfers)) {
			throw stale(unit, "argument-transfer occurrence set changed"); //$NON-NLS-1$
		}

		List<ResolvedLength> lengths= new ArrayList<>();
		for (Expression expression : lengthExpressions) {
			ResolvedLength length= resolvedLength(expression, plan.bindingKey());
			if (length == null) {
				throw stale(unit, "array length expression could not be re-resolved"); //$NON-NLS-1$
			}
			lengths.add(length);
		}
		lengths.sort(Comparator.comparingInt(length -> length.expression().getStartPosition()));
		return new ResolvedPlan(
				plan,
				statement,
				fragment,
				arrayType,
				initializer,
				pairs,
				lengths);
	}

	private static CollectedAst collectAst(
			ICompilationUnit unit,
			org.eclipse.jdt.core.dom.CompilationUnit root,
			String bindingKey) throws CoreException {
		List<SimpleName> references= new ArrayList<>();
		List<Assignment> assignments= new ArrayList<>();
		VariableDeclarationFragment[] declaration= { null };
		try {
			root.accept(new ASTVisitor() {
				@Override
				public boolean visit(SimpleName node) {
					IVariableBinding binding= variableBinding(node.resolveBinding());
					if (binding == null
							|| !bindingKey.equals(binding.getVariableDeclaration().getKey())) {
						return true;
					}
					references.add(node);
					if (node.getParent() instanceof VariableDeclarationFragment fragment
							&& fragment.getName() == node) {
						if (declaration[0] != null && declaration[0] != fragment) {
							throw new StalePlanRuntimeException(stale(
									unit, "multiple declarations match the planned binding")); //$NON-NLS-1$
						}
						declaration[0]= fragment;
					}
					return true;
				}

				@Override
				public boolean visit(Assignment node) {
					assignments.add(node);
					return true;
				}
			});
		} catch (StalePlanRuntimeException exception) {
			throw exception.coreException();
		}
		return new CollectedAst(declaration[0], references, assignments);
	}

	private static void validateOccurrenceCounts(
			ICompilationUnit unit,
			ContainerLocalRewritePlan plan,
			List<Assignment> growthAssignments,
			List<Assignment> appendAssignments) throws CoreException {
		int expectedGrowth= editCount(plan, EditKind.REMOVE_ARRAY_GROWTH);
		int expectedAppend= editCount(plan, EditKind.REPLACE_TAIL_WRITE_WITH_ADD);
		if (growthAssignments.size() != expectedGrowth
				|| appendAssignments.size() != expectedAppend
				|| growthAssignments.size() != appendAssignments.size()) {
			throw stale(unit, "array growth or append occurrence count changed"); //$NON-NLS-1$
		}
	}

	private static List<AppendPair> pairAppends(
			ICompilationUnit unit,
			List<Assignment> growthAssignments,
			List<Assignment> appendAssignments) throws CoreException {
		List<AppendPair> pairs= new ArrayList<>();
		for (int index= 0; index < growthAssignments.size(); index++) {
			Assignment growth= growthAssignments.get(index);
			Assignment append= appendAssignments.get(index);
			ExpressionStatement growthStatement= expressionStatement(growth);
			ExpressionStatement appendStatement= expressionStatement(append);
			if (growthStatement == null || appendStatement == null
					|| nextStatement(growthStatement) != appendStatement) {
				throw stale(unit, "array growth is no longer immediately followed by its tail write"); //$NON-NLS-1$
			}
			ArrayAccess access= (ArrayAccess) unwrap(append.getLeftHandSide());
			pairs.add(new AppendPair(
					growth,
					growthStatement,
					append,
					access,
					append.getRightHandSide()));
		}
		return List.copyOf(pairs);
	}

	private static boolean isPlannedArgumentTransfer(
			SimpleName reference,
			Set<SourceRange> expected,
			Set<SourceRange> observed) {
		ASTNode parent= reference.getParent();
		if (!(parent instanceof MethodInvocation invocation)
				|| !invocation.arguments().contains(reference)) {
			return false;
		}
		SourceRange range= new SourceRange(
				reference.getStartPosition(), reference.getLength());
		return expected.contains(range) && observed.add(range);
	}

	private static Set<SourceRange> plannedRanges(
			ContainerLocalRewritePlan plan,
			EditKind kind) {
		Set<SourceRange> result= new HashSet<>();
		plan.edits().stream()
				.filter(edit -> edit.kind() == kind)
				.map(edit -> new SourceRange(
						edit.sourceStart(), edit.sourceLength()))
				.forEach(result::add);
		return Set.copyOf(result);
	}

	static record ResolvedPlan(
			ContainerLocalRewritePlan plan,
			VariableDeclarationStatement declaration,
			VariableDeclarationFragment fragment,
			ArrayType arrayType,
			ArrayCreation initializer,
			List<AppendPair> appendPairs,
			List<ResolvedLength> lengths) {

		ResolvedPlan {
			appendPairs= List.copyOf(appendPairs);
			lengths= List.copyOf(lengths);
		}
	}

	static record AppendPair(
			Assignment growthAssignment,
			ExpressionStatement growthStatement,
			Assignment appendAssignment,
			ArrayAccess arrayAccess,
			Expression value) {
	}

	static record ResolvedLength(Expression expression, Expression arrayExpression) {
	}

	private record CollectedAst(
			VariableDeclarationFragment declaration,
			List<SimpleName> references,
			List<Assignment> assignments) {

		CollectedAst {
			references= List.copyOf(references);
			assignments= List.copyOf(assignments);
		}
	}

	private record SourceRange(int start, int length) {
		private SourceRange {
			if (start < 0 || length < 0) {
				throw new IllegalArgumentException(
						"Source range must not be negative"); //$NON-NLS-1$
			}
		}
	}

	private static boolean isEmptyOneDimensionalArray(ArrayCreation creation) {
		return creation.getType().dimensions().size() == 1
				&& creation.getInitializer() == null
				&& creation.dimensions().size() == 1
				&& creation.dimensions().get(0) instanceof NumberLiteral literal
				&& "0".equals(literal.getToken()); //$NON-NLS-1$
	}

	private static boolean isGrowthAssignment(Assignment assignment, String bindingKey) {
		if (assignment.getOperator() != Assignment.Operator.ASSIGN
				|| !sameVariable(assignment.getLeftHandSide(), bindingKey)) {
			return false;
		}
		Expression right= unwrap(assignment.getRightHandSide());
		if (!(right instanceof MethodInvocation invocation)
				|| !isArraysCopyOf(invocation)
				|| invocation.arguments().size() != 2
				|| !sameVariable((Expression) invocation.arguments().get(0), bindingKey)) {
			return false;
		}
		return isLengthPlusOne((Expression) invocation.arguments().get(1), bindingKey);
	}

	private static boolean isTailAppendAssignment(Assignment assignment, String bindingKey) {
		if (assignment.getOperator() != Assignment.Operator.ASSIGN
				|| !(unwrap(assignment.getLeftHandSide()) instanceof ArrayAccess access)
				|| !sameVariable(access.getArray(), bindingKey)) {
			return false;
		}
		return isLengthMinusOne(access.getIndex(), bindingKey);
	}

	private static boolean isArraysCopyOf(MethodInvocation invocation) {
		if (!"copyOf".equals(invocation.getName().getIdentifier())) { //$NON-NLS-1$
			return false;
		}
		IMethodBinding binding= invocation.resolveMethodBinding();
		return binding != null
				&& binding.getDeclaringClass() != null
				&& "java.util.Arrays".equals( //$NON-NLS-1$
						binding.getDeclaringClass().getErasure().getQualifiedName());
	}

	private static boolean isLengthPlusOne(Expression expression, String bindingKey) {
		Expression unwrapped= unwrap(expression);
		if (!(unwrapped instanceof InfixExpression infix)
				|| infix.getOperator() != InfixExpression.Operator.PLUS
				|| !infix.extendedOperands().isEmpty()) {
			return false;
		}
		return isArrayLength(infix.getLeftOperand(), bindingKey) && isOne(infix.getRightOperand())
				|| isOne(infix.getLeftOperand()) && isArrayLength(infix.getRightOperand(), bindingKey);
	}

	private static boolean isLengthMinusOne(Expression expression, String bindingKey) {
		Expression unwrapped= unwrap(expression);
		return unwrapped instanceof InfixExpression infix
				&& infix.getOperator() == InfixExpression.Operator.MINUS
				&& infix.extendedOperands().isEmpty()
				&& isArrayLength(infix.getLeftOperand(), bindingKey)
				&& isOne(infix.getRightOperand());
	}

	private static boolean isArrayLength(Expression expression, String bindingKey) {
		return resolvedLength(unwrap(expression), bindingKey) != null;
	}

	private static ResolvedLength lengthRead(SimpleName reference, String bindingKey) {
		ASTNode parent= reference.getParent();
		if (parent instanceof QualifiedName qualified
				&& qualified.getQualifier() == reference
				&& "length".equals(qualified.getName().getIdentifier()) //$NON-NLS-1$
				&& sameVariable(reference, bindingKey)) {
			return new ResolvedLength(qualified, reference);
		}
		if (parent instanceof FieldAccess fieldAccess
				&& fieldAccess.getExpression() == reference
				&& "length".equals(fieldAccess.getName().getIdentifier()) //$NON-NLS-1$
				&& sameVariable(reference, bindingKey)) {
			return new ResolvedLength(fieldAccess, reference);
		}
		return null;
	}

	private static ResolvedLength resolvedLength(Expression expression, String bindingKey) {
		if (expression instanceof QualifiedName qualified
				&& "length".equals(qualified.getName().getIdentifier())) { //$NON-NLS-1$
			Expression array= (Expression) qualified.getQualifier();
			return sameVariable(array, bindingKey)
					? new ResolvedLength(qualified, array)
					: null;
		}
		if (expression instanceof FieldAccess fieldAccess
				&& "length".equals(fieldAccess.getName().getIdentifier()) //$NON-NLS-1$
				&& fieldAccess.getExpression() != null
				&& sameVariable(fieldAccess.getExpression(), bindingKey)) {
			return new ResolvedLength(fieldAccess, fieldAccess.getExpression());
		}
		return null;
	}

	private static boolean isEnhancedForExpression(SimpleName reference, String bindingKey) {
		return reference.getParent() instanceof EnhancedForStatement enhanced
				&& enhanced.getExpression() == reference
				&& sameVariable(reference, bindingKey);
	}

	private static Assignment containingAssignment(ASTNode node) {
		ASTNode current= node;
		while (current != null && !(current instanceof Statement)) {
			if (current instanceof Assignment assignment) {
				return assignment;
			}
			current= current.getParent();
		}
		return null;
	}

	private static ExpressionStatement expressionStatement(Assignment assignment) {
		ASTNode current= assignment;
		while (current.getParent() instanceof ParenthesizedExpression) {
			current= current.getParent();
		}
		return current.getParent() instanceof ExpressionStatement statement ? statement : null;
	}

	private static Statement nextStatement(Statement statement) {
		if (!(statement.getParent() instanceof Block block)) {
			return null;
		}
		List<?> statements= block.statements();
		int index= statements.indexOf(statement);
		return index >= 0 && index + 1 < statements.size()
				? (Statement) statements.get(index + 1)
				: null;
	}

	private static boolean sameVariable(Expression expression, String bindingKey) {
		IVariableBinding binding= variableBinding(unwrap(expression));
		return binding != null
				&& bindingKey.equals(binding.getVariableDeclaration().getKey());
	}

	private static IVariableBinding variableBinding(IBinding binding) {
		return binding instanceof IVariableBinding variable ? variable : null;
	}

	private static IVariableBinding variableBinding(Expression expression) {
		Expression unwrapped= unwrap(expression);
		if (unwrapped instanceof SimpleName name) {
			return variableBinding(name.resolveBinding());
		}
		if (unwrapped instanceof QualifiedName name) {
			return variableBinding(name.resolveBinding());
		}
		if (unwrapped instanceof FieldAccess access) {
			return access.resolveFieldBinding();
		}
		if (unwrapped instanceof SuperFieldAccess access) {
			return access.resolveFieldBinding();
		}
		return null;
	}

	private static Expression unwrap(Expression expression) {
		Expression current= expression;
		while (current instanceof ParenthesizedExpression parenthesized) {
			current= parenthesized.getExpression();
		}
		return current;
	}

	private static boolean isOne(Expression expression) {
		Expression unwrapped= unwrap(expression);
		return unwrapped instanceof NumberLiteral literal
				&& "1".equals(literal.getToken()); //$NON-NLS-1$
	}

	private static int editCount(ContainerLocalRewritePlan plan, EditKind kind) {
		return Math.toIntExact(plan.edits().stream()
				.filter(edit -> edit.kind() == kind)
				.count());
	}

	private static CoreException stale(ICompilationUnit unit, String detail) {
		return new CoreException(new Status(
				IStatus.ERROR,
				PLUGIN_ID,
				"Container rewrite plan is stale for " + unit.getElementName() + ": " + detail)); //$NON-NLS-1$ //$NON-NLS-2$
	}

	private static final class StalePlanRuntimeException extends RuntimeException {
		private static final long serialVersionUID= 1L;
		private final CoreException coreException;

		StalePlanRuntimeException(CoreException coreException) {
			this.coreException= coreException;
		}

		CoreException coreException() {
			return coreException;
		}
	}
}