StructuredRewriteActionParser.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.List;
import java.util.Map;

import org.sandbox.jdt.triggerpattern.api.RewriteActionCatalog;
import org.sandbox.jdt.triggerpattern.api.RewriteActionValue;
import org.sandbox.jdt.triggerpattern.api.SemanticPlanValue;
import org.sandbox.jdt.triggerpattern.api.StructuredRewriteAction;
import org.sandbox.jdt.triggerpattern.internal.HintFileParser.HintParseException;

/** Recursive-descent parser for {@code =>! action(...)} alternatives. */
final class StructuredRewriteActionParser {

	private final String source;
	private final int lineNumber;
	private final RewriteActionCatalog catalog;
	private int offset;

	private StructuredRewriteActionParser(String source, int lineNumber,
			RewriteActionCatalog catalog) {
		this.source= source == null ? "" : source; //$NON-NLS-1$
		this.lineNumber= lineNumber;
		this.catalog= catalog;
	}

	static List<StructuredRewriteAction> parse(String source, int lineNumber,
			RewriteActionCatalog catalog) throws HintParseException {
		StructuredRewriteActionParser parser= new StructuredRewriteActionParser(source, lineNumber, catalog);
		return parser.parseSequence();
	}

	private List<StructuredRewriteAction> parseSequence() throws HintParseException {
		List<StructuredRewriteAction> actions= new ArrayList<>();
		skipWhitespace();
		while (!atEnd()) {
			StructuredRewriteAction action= parseAction();
			try {
				catalog.validate(action);
			} catch (IllegalArgumentException exception) {
				throw error(exception.getMessage());
			}
			actions.add(action);
			skipWhitespace();
			if (atEnd()) {
				break;
			}
			expect(';');
			skipWhitespace();
			if (atEnd()) {
				throw error("Structured action sequence ends after ';'"); //$NON-NLS-1$
			}
		}
		if (actions.isEmpty()) {
			throw error("Structured rewrite alternative contains no action"); //$NON-NLS-1$
		}
		return List.copyOf(actions);
	}

	private StructuredRewriteAction parseAction() throws HintParseException {
		String name= parseIdentifier("action name"); //$NON-NLS-1$
		skipWhitespace();
		expect('(');
		skipWhitespace();
		Map<String, RewriteActionValue> arguments= new LinkedHashMap<>();
		if (!peek(')')) {
			while (true) {
				String argumentName= parseIdentifier("action argument"); //$NON-NLS-1$
				skipWhitespace();
				expect('=');
				skipWhitespace();
				RewriteActionValue value= parseValue();
				if (arguments.putIfAbsent(argumentName, value) != null) {
					throw error("Duplicate action argument " + argumentName); //$NON-NLS-1$
				}
				skipWhitespace();
				if (!peek(',')) {
					break;
				}
				offset++;
				skipWhitespace();
			}
		}
		expect(')');
		try {
			return new StructuredRewriteAction(name, arguments, lineNumber);
		} catch (IllegalArgumentException exception) {
			throw error(exception.getMessage());
		}
	}

	private RewriteActionValue parseValue() throws HintParseException {
		if (atEnd()) {
			throw error("Missing action argument value"); //$NON-NLS-1$
		}
		char current= source.charAt(offset);
		if (current == '"') {
			return RewriteActionValue.literal(SemanticPlanValue.string(parseString()));
		}
		if (current == '$') {
			return RewriteActionValue.binding(parsePlaceholder());
		}
		if (current == '-' || Character.isDigit(current)) {
			return parseIntegerLiteral();
		}
		String token= parseQualifiedIdentifier();
		skipWhitespace();
		if (peek('(')) {
			return parseValueFunction(token);
		}
		if ("true".equals(token) || "false".equals(token)) { //$NON-NLS-1$ //$NON-NLS-2$
			return RewriteActionValue.literal(SemanticPlanValue.bool(Boolean.parseBoolean(token)));
		}
		return RewriteActionValue.literal(SemanticPlanValue.string(token));
	}

	private RewriteActionValue parseValueFunction(String function) throws HintParseException {
		expect('(');
		skipWhitespace();
		List<RewriteActionValue> arguments= new ArrayList<>();
		if (!peek(')')) {
			while (true) {
				arguments.add(parseValue());
				skipWhitespace();
				if (!peek(',')) {
					break;
				}
				offset++;
				skipWhitespace();
			}
		}
		expect(')');
		return switch (function) {
			case "planValue" -> planValue(arguments); //$NON-NLS-1$
			case "classLiteral" -> unary(function, arguments, RewriteActionValue::classLiteral); //$NON-NLS-1$
			case "name" -> unary(function, arguments, RewriteActionValue::name); //$NON-NLS-1$
			case "list" -> new RewriteActionValue.ListValue(arguments); //$NON-NLS-1$
			default -> throw error("Unknown action value function " + function); //$NON-NLS-1$
		};
	}

	private RewriteActionValue planValue(List<RewriteActionValue> arguments) throws HintParseException {
		if (arguments.size() == 1) {
			return RewriteActionValue.planValue(stringLiteral(arguments.get(0), "planValue fact name")); //$NON-NLS-1$
		}
		if (arguments.size() == 2 && arguments.get(0) instanceof RewriteActionValue.Binding binding) {
			return RewriteActionValue.planValue(binding.placeholder(),
					stringLiteral(arguments.get(1), "planValue fact name")); //$NON-NLS-1$
		}
		throw error("planValue expects a fact name or one binding and a fact name"); //$NON-NLS-1$
	}

	private RewriteActionValue unary(String function, List<RewriteActionValue> arguments,
			java.util.function.Function<RewriteActionValue, RewriteActionValue> factory)
			throws HintParseException {
		if (arguments.size() != 1) {
			throw error(function + " expects exactly one value"); //$NON-NLS-1$
		}
		return factory.apply(arguments.get(0));
	}

	private String stringLiteral(RewriteActionValue value, String label) throws HintParseException {
		if (value instanceof RewriteActionValue.Literal literal
				&& literal.value() instanceof SemanticPlanValue.StringValue text) {
			return text.value();
		}
		throw error(label + " must be a string literal"); //$NON-NLS-1$
	}

	private RewriteActionValue parseIntegerLiteral() throws HintParseException {
		int start= offset;
		if (source.charAt(offset) == '-') {
			offset++;
		}
		int digitStart= offset;
		while (!atEnd() && Character.isDigit(source.charAt(offset))) {
			offset++;
		}
		if (digitStart == offset) {
			throw error("Invalid integer action value"); //$NON-NLS-1$
		}
		try {
			return RewriteActionValue.literal(
					SemanticPlanValue.integer(Long.parseLong(source.substring(start, offset))));
		} catch (NumberFormatException exception) {
			throw error("Integer action value is out of range"); //$NON-NLS-1$
		}
	}

	private String parseString() throws HintParseException {
		expect('"');
		StringBuilder value= new StringBuilder();
		while (!atEnd()) {
			char current= source.charAt(offset++);
			if (current == '"') {
				return value.toString();
			}
			if (current != '\\') {
				value.append(current);
				continue;
			}
			if (atEnd()) {
				throw error("Unterminated escape in action string"); //$NON-NLS-1$
			}
			char escaped= source.charAt(offset++);
			value.append(switch (escaped) {
				case 'n' -> '\n';
				case 'r' -> '\r';
				case 't' -> '\t';
				case '"' -> '"';
				case '\\' -> '\\';
				default -> throw error("Unsupported action string escape \\" + escaped); //$NON-NLS-1$
			});
		}
		throw error("Unterminated action string"); //$NON-NLS-1$
	}

	private String parsePlaceholder() throws HintParseException {
		int start= offset++;
		if (atEnd() || !Character.isJavaIdentifierStart(source.charAt(offset))) {
			throw error("Invalid binding placeholder"); //$NON-NLS-1$
		}
		offset++;
		while (!atEnd() && Character.isJavaIdentifierPart(source.charAt(offset))) {
			offset++;
		}
		if (!atEnd() && source.charAt(offset) == '$') {
			offset++;
		}
		return source.substring(start, offset);
	}

	private String parseIdentifier(String label) throws HintParseException {
		skipWhitespace();
		if (atEnd() || !Character.isJavaIdentifierStart(source.charAt(offset))) {
			throw error("Expected " + label); //$NON-NLS-1$
		}
		int start= offset++;
		while (!atEnd() && Character.isJavaIdentifierPart(source.charAt(offset))) {
			offset++;
		}
		return source.substring(start, offset);
	}

	private String parseQualifiedIdentifier() throws HintParseException {
		int start= offset;
		parseIdentifier("action value"); //$NON-NLS-1$
		while (!atEnd() && source.charAt(offset) == '.') {
			offset++;
			parseIdentifier("qualified action value segment"); //$NON-NLS-1$
		}
		return source.substring(start, offset);
	}

	private void expect(char expected) throws HintParseException {
		skipWhitespace();
		if (atEnd() || source.charAt(offset) != expected) {
			throw error("Expected '" + expected + "'"); //$NON-NLS-1$ //$NON-NLS-2$
		}
		offset++;
	}

	private boolean peek(char expected) {
		skipWhitespace();
		return !atEnd() && source.charAt(offset) == expected;
	}

	private void skipWhitespace() {
		while (!atEnd() && Character.isWhitespace(source.charAt(offset))) {
			offset++;
		}
	}

	private boolean atEnd() {
		return offset >= source.length();
	}

	private HintParseException error(String message) {
		return new HintParseException(message + " at action offset " + offset, lineNumber); //$NON-NLS-1$
	}
}