HintDeclarationDiffPreprocessor.java

/*******************************************************************************
 * Copyright (c) 2026 Carsten Hammer and others.
 *
 * 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.triggerpattern.internal;

import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

import org.eclipse.jdt.core.dom.ASTNode;
import org.eclipse.jdt.core.dom.Annotation;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.Modifier;
import org.eclipse.jdt.core.dom.SingleVariableDeclaration;

import org.sandbox.jdt.triggerpattern.api.HintPlanRequirement;
import org.sandbox.jdt.triggerpattern.api.Pattern;
import org.sandbox.jdt.triggerpattern.api.PatternKind;
import org.sandbox.jdt.triggerpattern.internal.HintFileParser.HintParseException;

/**
 * Lowers unambiguous plan-aware method declaration diffs to structured actions.
 *
 * <p>This is deliberately a small compiler, not a general Java text rewriter.
 * It accepts one-line method headers without annotations, bodies, varargs,
 * generic parameters or changed throws/return contracts. The supported surface
 * covers method renames, modifier changes, retained-parameter type changes,
 * parameter removals and append-only parameter additions. Anything ambiguous
 * fails with a source diagnostic rather than silently becoming a text rewrite.</p>
 */
final class HintDeclarationDiffPreprocessor {

	private HintDeclarationDiffPreprocessor() {
	}

	static String preprocess(String source) throws HintParseException {
		String normalized= source == null ? "" : source.replace("\r\n", "\n").replace('\r', '\n'); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$
		if (HintPlanRequirement.fromContent(normalized).isEmpty()) {
			return normalized;
		}
		String[] lines= normalized.split("\n", -1); //$NON-NLS-1$
		List<String> output= new ArrayList<>(lines.length);
		boolean inBlockComment= false;
		boolean inEmbeddedJava= false;
		int ruleStart= 0;

		for (int lineIndex= 0; lineIndex < lines.length; lineIndex++) {
			String line= lines[lineIndex];
			String trimmed= line.stripLeading();
			boolean visible= true;
			if (inEmbeddedJava) {
				visible= false;
				if (trimmed.contains("?>")) { //$NON-NLS-1$
					inEmbeddedJava= false;
				}
			} else if (inBlockComment) {
				visible= false;
				if (trimmed.contains("*/")) { //$NON-NLS-1$
					inBlockComment= false;
				}
			} else if (trimmed.startsWith("<?")) { //$NON-NLS-1$
				visible= false;
				inEmbeddedJava= !trimmed.contains("?>"); //$NON-NLS-1$
			} else if (trimmed.startsWith("/*")) { //$NON-NLS-1$
				visible= false;
				inBlockComment= !trimmed.contains("*/"); //$NON-NLS-1$
			} else if (trimmed.startsWith("//")) { //$NON-NLS-1$
				visible= false;
			}

			if (visible && trimmed.startsWith("=>") && !trimmed.startsWith("=>!")) { //$NON-NLS-1$ //$NON-NLS-2$
				int sourceIndex= previousSourceLine(output, ruleStart);
				if (sourceIndex >= 0) {
					ActionAndGuard sourceSplit= splitGuard(output.get(sourceIndex).stripLeading());
					ActionAndGuard targetSplit= splitGuard(trimmed.substring(2).trim());
					Compilation compilation= compile(sourceSplit.actionText(), targetSplit.actionText(),
							sourceIndex + 1, lineIndex + 1);
					if (compilation != null && !compilation.actions().isEmpty()) {
						String indentation= line.substring(0, line.length() - trimmed.length());
						appendActions(output, indentation, compilation.actions(), targetSplit.guardText());
						continue;
					}
				}
			}

			output.add(line);
			if (visible && ";;".equals(trimmed)) { //$NON-NLS-1$
				ruleStart= output.size();
			}
		}
		return String.join("\n", output); //$NON-NLS-1$
	}

	private static int previousSourceLine(List<String> output, int ruleStart) {
		for (int index= output.size() - 1; index >= ruleStart; index--) {
			String candidate= output.get(index).stripLeading();
			if (candidate.isBlank() || candidate.startsWith("@") //$NON-NLS-1$
					|| candidate.startsWith("//") || candidate.startsWith("/*") //$NON-NLS-1$ //$NON-NLS-2$
					|| candidate.startsWith("\"") || candidate.startsWith("<!")) { //$NON-NLS-1$ //$NON-NLS-2$
				continue;
			}
			return index;
		}
		return -1;
	}

	private static void appendActions(List<String> output, String indentation,
			List<String> actions, String guard) {
		for (int index= 0; index < actions.size(); index++) {
			String prefix= index == 0 ? "=>! " : "    "; //$NON-NLS-1$ //$NON-NLS-2$
			String suffix= index + 1 < actions.size() ? ";" : ""; //$NON-NLS-1$ //$NON-NLS-2$
			if (index + 1 == actions.size() && guard != null) {
				suffix+= " :: " + guard.trim(); //$NON-NLS-1$
			}
			output.add(indentation + prefix + actions.get(index) + suffix);
		}
	}

	private static Compilation compile(String sourceText, String targetText,
			int sourceLine, int targetLine) throws HintParseException {
		MethodShape source= MethodShape.parse(sourceText);
		MethodShape target= MethodShape.parse(targetText);
		if (source == null || target == null) {
			return null;
		}
		if (source.hasAnnotations() || target.hasAnnotations()) {
			return null;
		}
		if (source.constructor() || target.constructor()) {
			return null;
		}
		if (!source.returnType().equals(target.returnType())) {
			throw new HintParseException(
					"Method declaration diff cannot change the return type yet; use a dedicated typed action", //$NON-NLS-1$
					targetLine);
		}
		if (!source.typeParameters().equals(target.typeParameters())
				|| !source.thrownTypes().equals(target.thrownTypes())) {
			throw new HintParseException(
					"Method declaration diff cannot change type parameters or thrown exceptions", targetLine); //$NON-NLS-1$
		}
		if (source.parameters().stream().anyMatch(ParameterShape::varargs)
				|| target.parameters().stream().anyMatch(ParameterShape::varargs)) {
			return null;
		}

		List<String> actions= new ArrayList<>();
		compileName(source, target, actions, targetLine);
		compileModifiers(source, target, actions);
		compileParameters(source, target, actions, sourceLine, targetLine);
		return new Compilation(actions);
	}

	private static void compileName(MethodShape source, MethodShape target,
			List<String> actions, int targetLine) throws HintParseException {
		if (source.name().equals(target.name())) {
			return;
		}
		if (target.name().startsWith("$")) { //$NON-NLS-1$
			throw new HintParseException(
					"A changed method name must be a concrete target identifier", targetLine); //$NON-NLS-1$
		}
		actions.add("renameDeclaration(name=" + quote(target.name()) + ")"); //$NON-NLS-1$ //$NON-NLS-2$
	}

	private static void compileModifiers(MethodShape source, MethodShape target,
			List<String> actions) {
		for (String modifier : source.modifiers()) {
			if (!target.modifiers().contains(modifier)) {
				actions.add("removeModifier(modifier=" + modifier + ")"); //$NON-NLS-1$ //$NON-NLS-2$
			}
		}
		for (String modifier : target.modifiers()) {
			if (!source.modifiers().contains(modifier)) {
				actions.add("addModifier(modifier=" + modifier + ")"); //$NON-NLS-1$ //$NON-NLS-2$
			}
		}
	}

	private static void compileParameters(MethodShape source, MethodShape target,
			List<String> actions, int sourceLine, int targetLine) throws HintParseException {
		Map<String, ParameterShape> sourceByName= uniqueParameters(source.parameters(), sourceLine);
		Map<String, ParameterShape> targetByName= uniqueParameters(target.parameters(), targetLine);

		List<String> retainedInSource= source.parameters().stream().map(ParameterShape::name)
				.filter(targetByName::containsKey).toList();
		List<String> retainedInTarget= target.parameters().stream().map(ParameterShape::name)
				.filter(sourceByName::containsKey).toList();
		if (!retainedInSource.equals(retainedInTarget)) {
			throw new HintParseException(
					"Method declaration diff cannot reorder retained parameters; bind them in their original order", //$NON-NLS-1$
					targetLine);
		}

		boolean seenAdded= false;
		for (ParameterShape parameter : target.parameters()) {
			if (!sourceByName.containsKey(parameter.name())) {
				seenAdded= true;
				if (parameter.name().startsWith("$")) { //$NON-NLS-1$
					throw new HintParseException(
							"New parameters need a concrete target name", targetLine); //$NON-NLS-1$
				}
			} else if (seenAdded) {
				throw new HintParseException(
						"New parameters are currently append-only in declaration diffs", targetLine); //$NON-NLS-1$
			}
		}

		for (ParameterShape sourceParameter : source.parameters()) {
			ParameterShape targetParameter= targetByName.get(sourceParameter.name());
			if (targetParameter == null) {
				actions.add("removeParameter(name=" + selector(sourceParameter.name()) + ")"); //$NON-NLS-1$ //$NON-NLS-2$
			} else if (!sourceParameter.type().equals(targetParameter.type())) {
				ensureSupportedType(targetParameter.type(), targetLine);
				actions.add("replaceParameterType(name=" + selector(sourceParameter.name()) //$NON-NLS-1$
						+ ", type=" + quote(targetParameter.type()) + ")"); //$NON-NLS-1$ //$NON-NLS-2$
			}
		}
		for (ParameterShape targetParameter : target.parameters()) {
			if (!sourceByName.containsKey(targetParameter.name())) {
				ensureSupportedType(targetParameter.type(), targetLine);
				actions.add("addParameter(type=" + quote(targetParameter.type()) //$NON-NLS-1$
						+ ", name=" + quote(targetParameter.name()) + ")"); //$NON-NLS-1$ //$NON-NLS-2$
			}
		}
	}

	private static Map<String, ParameterShape> uniqueParameters(Iterable<ParameterShape> parameters,
			int line) throws HintParseException {
		Map<String, ParameterShape> result= new LinkedHashMap<>();
		for (ParameterShape parameter : parameters) {
			if (result.putIfAbsent(parameter.name(), parameter) != null) {
				throw new HintParseException("Method declaration contains duplicate parameter name " //$NON-NLS-1$
						+ parameter.name(), line);
			}
		}
		return result;
	}

	private static void ensureSupportedType(String type, int line) throws HintParseException {
		if (type.indexOf('<') >= 0 || type.indexOf('>') >= 0 || type.indexOf('?') >= 0
				|| type.endsWith("...") || type.contains("$")) { //$NON-NLS-1$ //$NON-NLS-2$
			throw new HintParseException(
					"Declaration diff target type requires a dedicated typed representation: " + type, line); //$NON-NLS-1$
		}
	}

	private static String selector(String name) {
		return name.startsWith("$") ? name : quote(name); //$NON-NLS-1$
	}

	private static String quote(String value) {
		return '"' + value.replace("\\", "\\\\").replace("\"", "\\\"") + '"'; //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ //$NON-NLS-4$
	}

	private static ActionAndGuard splitGuard(String text) {
		int depth= 0;
		boolean inQuote= false;
		boolean escaped= false;
		for (int index= 0; index < text.length() - 1; index++) {
			char current= text.charAt(index);
			if (inQuote) {
				if (escaped) {
					escaped= false;
				} else if (current == '\\') {
					escaped= true;
				} else if (current == '"') {
					inQuote= false;
				}
				continue;
			}
			if (current == '"') {
				inQuote= true;
			} else if (current == '(') {
				depth++;
			} else if (current == ')') {
				depth--;
			} else if (depth == 0 && current == ':' && text.charAt(index + 1) == ':') {
				return new ActionAndGuard(text.substring(0, index), text.substring(index + 2));
			}
		}
		return new ActionAndGuard(text, null);
	}

	private record ActionAndGuard(String actionText, String guardText) {
	}

	private record Compilation(List<String> actions) {
		Compilation {
			actions= List.copyOf(actions);
		}
	}

	private record ParameterShape(String name, String type, boolean varargs) {
	}

	private record MethodShape(String name, String returnType, List<ParameterShape> parameters,
			Set<String> modifiers, List<String> typeParameters, List<String> thrownTypes,
			boolean hasAnnotations, boolean constructor) {

		static MethodShape parse(String text) {
			if (text == null || text.isBlank() || text.contains("{") || text.contains("}")) { //$NON-NLS-1$ //$NON-NLS-2$
				return null;
			}
			ASTNode parsed= new PatternParser().parse(Pattern.of(text.trim(), PatternKind.METHOD_DECLARATION));
			if (!(parsed instanceof MethodDeclaration method)) {
				return null;
			}
			boolean annotations= method.modifiers().stream().anyMatch(Annotation.class::isInstance);
			Set<String> modifiers= new LinkedHashSet<>();
			for (Object modifierObject : method.modifiers()) {
				if (modifierObject instanceof Modifier modifier) {
					modifiers.add(modifier.getKeyword().toString());
				}
			}
			List<ParameterShape> parameters= new ArrayList<>(method.parameters().size());
			for (Object parameterObject : method.parameters()) {
				SingleVariableDeclaration parameter= (SingleVariableDeclaration) parameterObject;
				String type= parameter.getType().toString();
				for (int dimension= 0; dimension < parameter.getExtraDimensions(); dimension++) {
					type+= "[]"; //$NON-NLS-1$
				}
				parameters.add(new ParameterShape(parameter.getName().getIdentifier(), type,
						parameter.isVarargs()));
			}
			String returnType= method.getReturnType2() == null ? "" : method.getReturnType2().toString(); //$NON-NLS-1$
			return new MethodShape(method.getName().getIdentifier(), returnType, parameters,
					Set.copyOf(modifiers), method.typeParameters().stream().map(Object::toString).toList(),
					method.thrownExceptionTypes().stream().map(Object::toString).toList(),
					annotations, method.isConstructor());
		}
	}
}