ArchitectureSemanticPatch.java

package com.taxonomy.dsl.command;

import com.taxonomy.dsl.ast.BlockAst;
import com.taxonomy.dsl.ast.DocumentAst;
import com.taxonomy.dsl.parser.TaxDslParser;
import com.taxonomy.dsl.serializer.TaxDslSerializer;

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

/**
 * A reconstructible semantic patch between canonical workspace or checkpoint documents.
 * Only changed blocks are replaced; unrelated source remains byte-for-byte intact.
 * Comments in edited blocks are retained, but their placement, indentation and line endings may change.
 * Applying an inverse requires the affected blocks to still match the accepted after-state.
 */
public final class ArchitectureSemanticPatch {
    private static final TaxDslParser PARSER = new TaxDslParser();
    private static final TaxDslSerializer SERIALIZER = new TaxDslSerializer();
    private static final Set<String> COMPOSITE_IDENTITY_KINDS =
            Set.of("relation", "mapping", "projectRequirement", "requirementVersion");

    private ArchitectureSemanticPatch() {}

    public record BlockChange(String id, String before, String after) {}

    public static List<BlockChange> between(String before, String after) {
        Map<String, BlockAst> oldBlocks = index(before);
        Map<String, BlockAst> newBlocks = index(after);
        var ids = new LinkedHashSet<>(oldBlocks.keySet());
        ids.addAll(newBlocks.keySet());
        List<BlockChange> changes = new ArrayList<>();
        for (String id : ids) {
            BlockAst oldBlock = oldBlocks.get(id);
            BlockAst newBlock = newBlocks.get(id);
            if (!Objects.equals(canonical(oldBlock), canonical(newBlock))) {
                changes.add(new BlockChange(id, raw(before, oldBlock), raw(after, newBlock)));
            }
        }
        return List.copyOf(changes);
    }

    /** Apply a server-generated projection without reformatting unrelated blocks or document comments. */
    public static String applyProjection(String source, String projection) {
        Map<String, BlockAst> before = index(source), after = index(projection);
        List<BlockAst> existing = new ArrayList<>(before.values());
        String result = source;
        // Earlier source locations remain valid when blocks are replaced from the end of the document.
        for (int i = existing.size() - 1; i >= 0; i--) {
            BlockAst block = existing.get(i); result = replace(result, block, after.get(key(block)));
        }
        for (var entry : after.entrySet()) if (!before.containsKey(entry.getKey())) result = replace(result, null, entry.getValue());
        return result;
    }

    public static String inverse(String current, String original, List<BlockChange> changes) {
        Map<String, BlockAst> currentBlocks = index(current);
        for (BlockChange change : changes) {
            String actual = raw(current, currentBlocks.get(change.id()));
            String expected = change.after();
            if (!Objects.equals(actual, expected)) {
                throw new ArchitectureDslCommands.CommandProblem("UNDO_CONFLICT", change.id(),
                        "The affected object changed after the target command", List.of(change.id()));
            }
        }
        String result = current;
        for (BlockChange change : changes) {
            BlockAst existing = index(result).get(change.id());
            if (existing == null && change.before() != null) {
                int insertion = restorationOffset(result, original, change.id());
                result = result.substring(0, insertion) + change.before() + result.substring(insertion);
            } else {
                result = replaceRaw(result, existing, change.before());
            }
        }
        return result;
    }

    /** Restore beside an original neighbor, preserving intervening source and later unrelated block edits. */
    private static int restorationOffset(String current, String original, String id) {
        Map<String, BlockAst> oldBlocks = index(original);
        List<String> order = new ArrayList<>(oldBlocks.keySet());
        int position = order.indexOf(id);
        int[] target = range(original, oldBlocks.get(id));
        Map<String, BlockAst> live = index(current);
        String previous = position == 0 ? null : order.get(position - 1);
        String next = position + 1 < order.size() ? order.get(position + 1) : null;
        int oldLeft = previous == null ? 0 : range(original, oldBlocks.get(previous))[1];
        String leftGap = original.substring(oldLeft, target[0]);
        int liveLeft = previous == null ? 0 : live.containsKey(previous) ? range(current, live.get(previous))[1] : -1;
        int liveRight = next != null && live.containsKey(next) ? range(current, live.get(next))[0] : current.length();
        if (liveLeft >= 0 && liveLeft + leftGap.length() <= liveRight && current.startsWith(leftGap, liveLeft)) {
            return liveLeft + leftGap.length();
        }

        if (next != null && live.containsKey(next)) {
            String rightGap = original.substring(target[1], range(original, oldBlocks.get(next))[0]);
            int insertion = liveRight - rightGap.length();
            if (insertion >= Math.max(0, liveLeft) && current.startsWith(rightGap, insertion)) return insertion;
        }
        throw new ArchitectureDslCommands.CommandProblem("UNDO_CONFLICT", id,
                "The original insertion boundary changed; choose a new explicit placement", List.of(id));
    }

    public static Map<String, BlockAst> index(String source) {
        Map<String, BlockAst> result = new LinkedHashMap<>();
        for (BlockAst block : PARSER.parse(source, "architecture.taxdsl").getBlocks()) {
            String id = key(block);
            if (result.putIfAbsent(id, block) != null) {
                throw new ArchitectureDslCommands.CommandProblem("DUPLICATE_ID", id,
                        "Ambiguous duplicate block identity", List.of(id));
            }
        }
        return result;
    }

    public static String key(BlockAst block) {
        List<String> tokens = block.getHeaderTokens();
        if (tokens.isEmpty()) {
            throw new ArchitectureDslCommands.CommandProblem("INVALID_HEADER", block.getKind(),
                    "Block identity is missing", List.of());
        }
        return block.getKind() + ":" + (COMPOSITE_IDENTITY_KINDS.contains(block.getKind())
                ? String.join(" ", tokens) : tokens.getFirst());
    }

    public static String replace(String source, BlockAst existing, BlockAst replacement) {
        String next = canonical(replacement);
        if (Objects.equals(canonical(existing), next)) return source;
        if (existing != null && replacement != null) {
            // The formatter does not model comments. Retain them explicitly inside the edited block.
            StringBuilder comments = new StringBuilder();
            raw(source, existing).lines().map(ArchitectureSemanticPatch::comment).filter(Objects::nonNull)
                    .forEach(comment -> comments.append(comment).append('\n'));
            int opening = next.indexOf('\n') + 1;
            next = next.substring(0, opening) + comments + next.substring(opening);
        }
        return replaceRaw(source, existing, next);
    }

    private static String replaceRaw(String source, BlockAst existing, String replacement) {
        if (existing == null) {
            if (replacement == null) return source;
            return source + (source.isEmpty() || source.endsWith("\n") ? "" : "\n") + replacement;
        }
        int[] range = range(source, existing);
        return source.substring(0, range[0]) + (replacement == null ? "" : replacement)
                + source.substring(range[1]);
    }

    private static String raw(String source, BlockAst block) {
        if (block == null) return null;
        int[] range = range(source, block);
        return source.substring(range[0], range[1]);
    }

    private static int[] range(String source, BlockAst block) {
        int start = 0;
        for (int line = 1; line < block.getSourceLocation().line(); line++) {
            start = lineEnd(source, start);
        }
        int depth = 0;
        for (int cursor = start; cursor < source.length();) {
            int end = lineEnd(source, cursor);
            int delimiter = TaxDslParser.blockDelimiter(source.substring(cursor, end));
            depth += delimiter;
            if (depth > 1) throw new ArchitectureDslCommands.CommandProblem("INVALID_DOCUMENT", key(block),
                    "Nested blocks are not supported on an edited object", List.of(key(block)));
            if (depth == 0 && delimiter < 0) return new int[]{start, end};
            cursor = end;
        }
        throw new ArchitectureDslCommands.CommandProblem("INVALID_DOCUMENT", key(block),
                "Block has no closing delimiter", List.of(key(block)));
    }

    private static int lineEnd(String source, int start) {
        for (int cursor = start; cursor < source.length(); cursor++) {
            char value = source.charAt(cursor);
            if (value == '\n') return cursor + 1;
            if (value == '\r') return cursor + 1 < source.length() && source.charAt(cursor + 1) == '\n' ? cursor + 2 : cursor + 1;
        }
        return source.length();
    }

    /** Retain both full-line and trailing comments without treating a quoted hash as a comment. */
    private static String comment(String line) {
        boolean quoted = false;
        boolean escaped = false;
        for (int index = 0; index < line.length(); index++) {
            char value = line.charAt(index);
            if (escaped) { escaped = false; continue; }
            if (quoted && value == '\\') { escaped = true; continue; }
            if (value == '"') quoted = !quoted;
            if (!quoted && value == '#') return "  " + line.substring(index);
        }
        return null;
    }

    private static String canonical(BlockAst block) {
        return block == null ? null : SERIALIZER.serialize(new DocumentAst(null, List.of(block)));
    }
}