HintFileSerializer.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
*
* Contributors:
* Carsten Hammer - initial API and implementation
*******************************************************************************/
package org.sandbox.jdt.triggerpattern.internal;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.StringJoiner;
import org.sandbox.jdt.triggerpattern.api.GuardExpression;
import org.sandbox.jdt.triggerpattern.api.HintFile;
import org.sandbox.jdt.triggerpattern.api.RewriteAlternative;
import org.sandbox.jdt.triggerpattern.api.TransformationRule;
/**
* Serializes a {@link HintFile} back to the {@code .sandbox-hint} DSL text format.
*
* <p>This is the inverse of {@link HintFileParser}. Given a populated {@code HintFile}
* model, it produces a textual representation that can be written to a
* {@code .sandbox-hint} file and subsequently re-parsed by {@link HintFileParser}.</p>
*
* <p>Metadata directives are emitted for non-null/non-default fields. Rules are
* written with their source pattern, optional guard, rewrite alternatives, and
* the {@code ;;} terminator.</p>
*
* @since 1.3.2
*/
public final class HintFileSerializer {
private static final String LINE_SEP = "\n"; //$NON-NLS-1$
/**
* Cached reverse map from AST node type constant to field name.
* Built lazily on first use via reflection on {@link org.eclipse.jdt.core.dom.ASTNode}.
*/
private static volatile Map<Integer, String> reverseNodeTypeMap;
/**
* Serializes the given {@link HintFile} to {@code .sandbox-hint} DSL text.
*
* @param hintFile the hint file to serialize (must not be {@code null})
* @return the serialized DSL text
*/
public String serialize(HintFile hintFile) {
Objects.requireNonNull(hintFile, "hintFile must not be null"); //$NON-NLS-1$
StringBuilder sb = new StringBuilder();
appendMetadata(sb, hintFile);
appendRules(sb, hintFile.getRules());
return sb.toString();
}
/**
* Appends metadata directives for non-null/non-default fields.
*/
private void appendMetadata(StringBuilder sb, HintFile hintFile) {
if (hintFile.getId() != null) {
sb.append("<!id: ").append(hintFile.getId()).append('>').append(LINE_SEP); //$NON-NLS-1$
}
if (hintFile.getDescription() != null) {
sb.append("<!description: ").append(hintFile.getDescription()).append('>').append(LINE_SEP); //$NON-NLS-1$
}
if (!"info".equals(hintFile.getSeverityAsString())) { //$NON-NLS-1$
sb.append("<!severity: ").append(hintFile.getSeverityAsString()).append('>').append(LINE_SEP); //$NON-NLS-1$
}
if (hintFile.getMinJavaVersion() > 0) {
sb.append("<!minJavaVersion: ").append(hintFile.getMinJavaVersion()).append('>').append(LINE_SEP); //$NON-NLS-1$
}
if (!hintFile.getTags().isEmpty()) {
StringJoiner joiner = new StringJoiner(", "); //$NON-NLS-1$
for (String tag : hintFile.getTags()) {
joiner.add(tag);
}
sb.append("<!tags: ").append(joiner).append('>').append(LINE_SEP); //$NON-NLS-1$
}
for (String include : hintFile.getIncludes()) {
sb.append("<!include: ").append(include).append('>').append(LINE_SEP); //$NON-NLS-1$
}
if (hintFile.isCaseInsensitive()) {
sb.append("<!caseInsensitive>").append(LINE_SEP); //$NON-NLS-1$
}
if (!hintFile.getSuppressWarnings().isEmpty()) {
StringJoiner joiner = new StringJoiner(", "); //$NON-NLS-1$
for (String sw : hintFile.getSuppressWarnings()) {
joiner.add(sw);
}
sb.append("<!suppressWarnings: ").append(joiner).append('>').append(LINE_SEP); //$NON-NLS-1$
}
if (!hintFile.getTreeKindNodeTypes().isEmpty()) {
StringJoiner joiner = new StringJoiner(", "); //$NON-NLS-1$
for (Integer nodeType : hintFile.getTreeKindNodeTypes()) {
String name = nodeTypeToName(nodeType);
joiner.add(name);
}
sb.append("<!treeKind: ").append(joiner).append('>').append(LINE_SEP); //$NON-NLS-1$
}
}
/**
* Appends all transformation rules.
*/
private void appendRules(StringBuilder sb, List<TransformationRule> rules) {
for (TransformationRule rule : rules) {
if (sb.length() > 0) {
sb.append(LINE_SEP);
}
appendRule(sb, rule);
}
}
/**
* Appends a single transformation rule in DSL format.
*/
private void appendRule(StringBuilder sb, TransformationRule rule) {
// Per-rule metadata annotations
if (rule.getRuleId() != null) {
sb.append("@id: ").append(rule.getRuleId()).append(LINE_SEP); //$NON-NLS-1$
}
if (rule.getSeverity() != null) {
sb.append("@severity: ").append(rule.getSeverity().name().toLowerCase(java.util.Locale.ROOT)).append(LINE_SEP); //$NON-NLS-1$
}
// Optional description prefix
if (rule.getDescription() != null) {
sb.append('"').append(rule.getDescription()).append("\":").append(LINE_SEP); //$NON-NLS-1$
}
// Source pattern with optional guard
sb.append(rule.sourcePattern().getValue());
if (rule.sourceGuard() != null) {
sb.append(" :: ").append(formatGuard(rule.sourceGuard())); //$NON-NLS-1$
}
sb.append(LINE_SEP);
// Rewrite alternatives
for (RewriteAlternative alt : rule.alternatives()) {
sb.append("=> ").append(alt.replacementPattern()); //$NON-NLS-1$
if (!alt.isOtherwise()) {
sb.append(" :: ").append(formatGuard(alt.condition())); //$NON-NLS-1$
}
sb.append(LINE_SEP);
}
// Terminator
sb.append(";;").append(LINE_SEP); //$NON-NLS-1$
}
/**
* Formats a {@link GuardExpression} back to DSL text.
*
* @param guard the guard expression
* @return the DSL representation
*/
private static String formatGuard(GuardExpression guard) {
return formatGuard(guard, 0);
}
/**
* Formats a {@link GuardExpression} with awareness of the parent operator
* precedence, adding parentheses where necessary so that the expression
* round-trips correctly through {@link HintFileParser}.
* <p>
* Precedence (low to high): {@code Or} (1) < {@code And} (2) <
* {@code Not} (3) < function calls (4).
* </p>
*
* @param guard the guard expression to format
* @param parentPrecedence the precedence of the enclosing operator
* @return the DSL representation
*/
private static String formatGuard(GuardExpression guard, int parentPrecedence) {
final int myPrecedence;
final String result;
switch (guard) {
case GuardExpression.FunctionCall fc -> {
myPrecedence = 4;
result = formatFunctionCall(fc);
}
case GuardExpression.And and -> {
myPrecedence = 2;
result = formatGuard(and.left(), myPrecedence) + " && " //$NON-NLS-1$
+ formatGuard(and.right(), myPrecedence);
}
case GuardExpression.Or or -> {
myPrecedence = 1;
result = formatGuard(or.left(), myPrecedence) + " || " //$NON-NLS-1$
+ formatGuard(or.right(), myPrecedence);
}
case GuardExpression.Not not -> {
myPrecedence = 3;
String operandText = formatGuard(not.operand(), myPrecedence);
if (!(not.operand() instanceof GuardExpression.FunctionCall)) {
operandText = "(" + operandText + ")"; //$NON-NLS-1$ //$NON-NLS-2$
}
result = "!" + operandText; //$NON-NLS-1$
}
}
if (myPrecedence < parentPrecedence) {
return "(" + result + ")"; //$NON-NLS-1$ //$NON-NLS-2$
}
return result;
}
/**
* Formats a {@link GuardExpression.FunctionCall}, handling the special
* {@code instanceof} syntax.
*/
private static String formatFunctionCall(GuardExpression.FunctionCall fc) {
if ("instanceof".equals(fc.name()) && fc.args().size() == 2) { //$NON-NLS-1$
return fc.args().get(0) + " instanceof " + fc.args().get(1); //$NON-NLS-1$
}
if (fc.args().isEmpty()) {
return fc.name() + "()"; //$NON-NLS-1$
}
StringJoiner joiner = new StringJoiner(", "); //$NON-NLS-1$
for (String arg : fc.args()) {
joiner.add(arg);
}
return fc.name() + "(" + joiner + ")"; //$NON-NLS-1$ //$NON-NLS-2$
}
/**
* Converts an AST node type constant to its field name in {@link org.eclipse.jdt.core.dom.ASTNode}.
* Falls back to the numeric value if the name is not found.
* Uses a cached reverse map built lazily on first use.
*/
private static String nodeTypeToName(int nodeType) {
return getReverseNodeTypeMap().getOrDefault(nodeType, String.valueOf(nodeType));
}
/**
* Returns the cached reverse map from AST node type constant to field name,
* initializing it lazily via reflection on first use.
*/
private static Map<Integer, String> getReverseNodeTypeMap() {
if (reverseNodeTypeMap == null) {
synchronized (HintFileSerializer.class) {
if (reverseNodeTypeMap == null) {
reverseNodeTypeMap = buildReverseNodeTypeMap();
}
}
}
return reverseNodeTypeMap;
}
/**
* Builds a reverse map from AST node type int constants to their field names
* using reflection on {@link org.eclipse.jdt.core.dom.ASTNode}.
*/
private static Map<Integer, String> buildReverseNodeTypeMap() {
Map<Integer, String> map = new HashMap<>(128);
for (java.lang.reflect.Field field : org.eclipse.jdt.core.dom.ASTNode.class.getDeclaredFields()) {
if (field.getType() == int.class
&& java.lang.reflect.Modifier.isPublic(field.getModifiers())
&& java.lang.reflect.Modifier.isStatic(field.getModifiers())
&& java.lang.reflect.Modifier.isFinal(field.getModifiers())) {
try {
map.put(field.getInt(null), field.getName());
} catch (IllegalAccessException e) {
// skip inaccessible fields
}
}
}
return Collections.unmodifiableMap(map);
}
}