RuleAuthoringDocument.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.triggerpattern.internal;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* The complete, unmodified text of an authored rule file. The wizard, clipboard,
* project preview and file writer use this same document instead of extracting
* a first rule or a first replacement from it.
*
* <p>Parsing is deliberately separate: successful parsing does not prove that a
* transformation preserves behavior.</p>
*
* @since 1.6.0
*/
public record RuleAuthoringDocument(String content) {
private static final Pattern METADATA = Pattern.compile(
"(?m)^[\\t ]*<!([A-Za-z][A-Za-z0-9-]*)[\\t ]*[:=][^>]*>[\\t ]*(?:\\r\\n|\\r|\\n|$)"); //$NON-NLS-1$
private enum LexicalMode {
JAVA, HINT_DOCUMENT
}
public RuleAuthoringDocument {
Objects.requireNonNull(content, "content"); //$NON-NLS-1$
}
/** Builds an exact before/after draft; names and literals are never inferred. */
public static RuleAuthoringDocument fromChange(String before, String after, String guard) {
Objects.requireNonNull(before, "before"); //$NON-NLS-1$
Objects.requireNonNull(after, "after"); //$NON-NLS-1$
if (containsJavaTextBlock(before) || containsJavaTextBlock(after)) {
throw new IllegalArgumentException(textBlockDiagnostic());
}
StringBuilder dsl = new StringBuilder("<!binding-policy: required>\n\n"); //$NON-NLS-1$
dsl.append(before);
if (guard != null && !guard.isBlank()) {
if (!endsWithLineBreak(dsl)) {
dsl.append('\n');
}
dsl.append(":: ").append(guard.strip()); //$NON-NLS-1$
}
if (!endsWithLineBreak(dsl)) {
dsl.append('\n');
}
if (!after.isEmpty()) {
dsl.append("=> ").append(after); //$NON-NLS-1$
if (!endsWithLineBreak(dsl)) {
dsl.append('\n');
}
}
dsl.append(";;\n"); //$NON-NLS-1$
return new RuleAuthoringDocument(dsl.toString());
}
/**
* Changes only explicitly edited metadata. All other bytes, including unknown
* directives, comments, multiline patterns and alternative guards, survive.
*/
public RuleAuthoringDocument withMetadata(Map<String, String> overrides) {
if (overrides.isEmpty()) {
return this;
}
Map<String, String> remaining = new LinkedHashMap<>(overrides);
remaining.forEach((key, value) -> {
if (!key.matches("[A-Za-z][A-Za-z0-9-]*") || value == null //$NON-NLS-1$
|| value.indexOf('>') >= 0 || value.indexOf('\n') >= 0 || value.indexOf('\r') >= 0) {
throw new IllegalArgumentException("Metadata must fit in a single directive"); //$NON-NLS-1$
}
});
Matcher matcher = METADATA.matcher(content);
StringBuilder result = new StringBuilder();
while (matcher.find()) {
String key = matcher.group(1);
if (overrides.containsKey(key) && isActiveDirective(content, matcher.start())) {
String original = matcher.group();
String lineEnd = original.endsWith("\r\n") ? "\r\n" //$NON-NLS-1$ //$NON-NLS-2$
: original.endsWith("\r") ? "\r" : original.endsWith("\n") ? "\n" : ""; //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ //$NON-NLS-4$ //$NON-NLS-5$
String value = overrides.get(key);
remaining.remove(key);
matcher.appendReplacement(result, Matcher.quoteReplacement(value.isEmpty() ? "" //$NON-NLS-1$
: "<!" + key + ": " + value + ">" + lineEnd)); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$
}
}
matcher.appendTail(result);
String lineEnd = content.contains("\r\n") ? "\r\n" : "\n"; //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$
StringBuilder prefix = new StringBuilder();
remaining.forEach((key, value) -> {
if (!value.isEmpty()) {
prefix.append("<!").append(key).append(": ").append(value).append('>').append(lineEnd); //$NON-NLS-1$ //$NON-NLS-2$
}
});
return new RuleAuthoringDocument(prefix.append(result).toString());
}
/**
* Finds the actual saved-to-editor change and expands it to complete lines.
* Several edits produce one visible before/after range which the author can
* narrow in the wizard; unchanged documents never create a made-up example.
*/
public static Optional<ChangeExample> changedLines(String before, String after) {
Objects.requireNonNull(before, "before"); //$NON-NLS-1$
Objects.requireNonNull(after, "after"); //$NON-NLS-1$
int prefix = 0;
int shortest = Math.min(before.length(), after.length());
while (prefix < shortest && before.charAt(prefix) == after.charAt(prefix)) {
prefix++;
}
if (prefix == before.length() && prefix == after.length()) {
return Optional.empty();
}
int suffix = 0;
while (suffix < shortest - prefix
&& before.charAt(before.length() - suffix - 1) == after.charAt(after.length() - suffix - 1)) {
suffix++;
}
int start = prefix;
while (start > 0 && before.charAt(start - 1) != '\n' && before.charAt(start - 1) != '\r') {
start--;
}
int beforeEnd = lineEnd(before, before.length() - suffix);
int afterEnd = lineEnd(after, after.length() - suffix);
return Optional.of(new ChangeExample(before.substring(start, beforeEnd), after.substring(start, afterEnd)));
}
private static int lineEnd(String source, int offset) {
// If a changed range already ends at a line boundary, keep following lines out.
if (offset == 0 || source.charAt(offset - 1) == '\n') {
return offset;
}
while (offset < source.length() && source.charAt(offset) != '\r' && source.charAt(offset) != '\n') {
offset++;
}
if (offset < source.length() && source.charAt(offset) == '\r') {
offset++;
}
if (offset < source.length() && source.charAt(offset) == '\n') {
offset++;
}
return offset;
}
private static boolean endsWithLineBreak(CharSequence value) {
return !value.isEmpty() && (value.charAt(value.length() - 1) == '\n' || value.charAt(value.length() - 1) == '\r');
}
/** Detects text-block delimiters outside comments and ordinary Java literals. */
public static boolean containsJavaTextBlock(String code) {
int offset = -3;
while ((offset = code.indexOf("\"\"\"", offset + 3)) >= 0) { //$NON-NLS-1$
if (isCodePosition(code, offset, LexicalMode.JAVA)) {
return true;
}
}
return false;
}
/** A bounded fail-closed limitation of the current DSL whitespace handling. */
public static String textBlockDiagnostic() {
return "Java text blocks are not supported in rule patterns or replacements because their whitespace cannot yet be preserved safely"; //$NON-NLS-1$
}
private static boolean isActiveDirective(String source, int offset) {
return isCodePosition(source, offset, LexicalMode.HINT_DOCUMENT);
}
private static boolean isCodePosition(String source, int offset, LexicalMode mode) {
boolean blockComment = false;
boolean lineComment = false;
boolean embeddedJava = false;
boolean textBlock = false;
char quote = 0;
for (int i = 0; i < offset; i++) {
char ch = source.charAt(i);
if (lineComment) {
lineComment = ch != '\r' && ch != '\n';
} else if (blockComment) {
if (source.startsWith("*/", i)) { //$NON-NLS-1$
blockComment = false;
i++;
}
} else if (textBlock) {
if (source.startsWith("\"\"\"", i)) { //$NON-NLS-1$
textBlock = false;
i += 2;
} else if (ch == '\\') {
i++;
}
} else if (quote != 0) {
if (ch == '\\') {
i++;
} else if (ch == quote) {
quote = 0;
}
} else if (mode == LexicalMode.HINT_DOCUMENT && source.startsWith("<?", i)) { //$NON-NLS-1$
embeddedJava = true;
i++;
} else if (mode == LexicalMode.HINT_DOCUMENT && source.startsWith("?>", i)) { //$NON-NLS-1$
embeddedJava = false;
i++;
} else if (source.startsWith("/*", i)) { //$NON-NLS-1$
blockComment = true;
i++;
} else if (source.startsWith("//", i)) { //$NON-NLS-1$
lineComment = true;
i++;
} else if (mode == LexicalMode.HINT_DOCUMENT && source.startsWith("<!", i)) { //$NON-NLS-1$
// Metadata prose is not Java: apostrophes and paths there must not
// start character literals or comments that hide later directives.
int end = source.indexOf('>', i + 2);
if (end >= 0 && end < offset) {
i = end;
}
} else if (source.startsWith("\"\"\"", i)) { //$NON-NLS-1$
textBlock = true;
i += 2;
} else if (ch == '\'' || ch == '"') {
quote = ch;
}
}
return !blockComment && !lineComment && !embeddedJava && !textBlock && quote == 0;
}
/** Editable evidence from a real source change, with all selected lines intact. */
public record ChangeExample(String before, String after) {
}
}