TemplateTextDiff.java

package com.taxonomy.templates;

import org.eclipse.jgit.diff.DiffAlgorithm;
import org.eclipse.jgit.diff.Edit;
import org.eclipse.jgit.diff.Sequence;
import org.eclipse.jgit.diff.SequenceComparator;

import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

/** Bounded, lossless display adapter for JGit's existing text comparison algorithm. */
public final class TemplateTextDiff {
    static final int MAX_CHARACTERS = 131_072;
    static final int MAX_SEGMENTS = 2_048;

    private TemplateTextDiff() { }

    static Result compare(String before, String after) {
        Objects.requireNonNull(before, "before");
        Objects.requireNonNull(after, "after");
        if (before.length() > MAX_CHARACTERS || after.length() > MAX_CHARACTERS) {
            return new Result(true, List.of());
        }
        Segments left = split(before);
        Segments right = split(after);
        if (left == null || right == null) return new Result(true, List.of());
        var edits = DiffAlgorithm.getAlgorithm(DiffAlgorithm.SupportedAlgorithm.HISTOGRAM)
                .diff(COMPARATOR, left, right);
        List<Row> rows = new ArrayList<>();
        int a = 0;
        int b = 0;
        for (Edit edit : edits) {
            while (a < edit.getBeginA()) {
                rows.add(new Row("CONTEXT", ++a, ++b, left.values.get(a - 1)));
            }
            while (a < edit.getEndA()) {
                rows.add(new Row("DELETED", ++a, null, left.values.get(a - 1)));
            }
            while (b < edit.getEndB()) {
                rows.add(new Row("ADDED", null, ++b, right.values.get(b - 1)));
            }
        }
        while (a < left.size()) {
            rows.add(new Row("CONTEXT", ++a, ++b, left.values.get(a - 1)));
        }
        return new Result(false, List.copyOf(rows));
    }

    /**
     * Split before literal '<' and after LF without removing or normalizing any character.
     * These are display segments, not XML parser events or original source-line numbers.
     * Comments, CDATA, entities, whitespace and CRLF are retained verbatim. No XML is parsed
     * or executed, and joining the segments reproduces the exact supplied string.
     */
    private static Segments split(String text) {
        List<String> values = new ArrayList<>();
        int start = 0;
        for (int i = 1; i < text.length(); i++) {
            if (text.charAt(i) == '<' || text.charAt(i - 1) == '\n') {
                values.add(text.substring(start, i));
                if (values.size() >= MAX_SEGMENTS) return null;
                start = i;
            }
        }
        if (start < text.length()) values.add(text.substring(start));
        return new Segments(values);
    }

    private static final SequenceComparator<Segments> COMPARATOR = new SequenceComparator<>() {
        @Override
        public boolean equals(Segments a, int ai, Segments b, int bi) {
            return a.values.get(ai).equals(b.values.get(bi));
        }

        @Override
        public int hash(Segments sequence, int index) {
            return sequence.values.get(index).hashCode();
        }
    };

    private static final class Segments extends Sequence {
        private final List<String> values;

        private Segments(List<String> values) { this.values = values; }

        @Override
        public int size() { return values.size(); }
    }

    public record Row(String kind, Integer beforeNumber, Integer afterNumber, String text) { }
    /** A reversible presentation grouping; only unchanged rows may be folded. */
    public record Block(boolean folded, List<Row> rows) {
        public Block {
            rows = List.copyOf(rows);
        }
    }

    public record Result(boolean limited, List<Row> rows) {
        public Result {
            rows = List.copyOf(rows);
        }

        /**
         * Keep three segments next to each change. Fold only the excess unchanged
         * context, retaining every row and its original display numbers in order.
         * No second diff, source normalization or storage access is involved.
         */
        public List<Block> blocks() {
            List<Block> blocks = new ArrayList<>();
            int start = 0;
            while (start < rows.size()) {
                boolean context = "CONTEXT".equals(rows.get(start).kind());
                int end = start + 1;
                while (end < rows.size()
                        && "CONTEXT".equals(rows.get(end).kind()) == context) {
                    end++;
                }
                int prefix = context && start > 0 ? 3 : 0;
                int suffix = context && end < rows.size() ? 3 : 0;
                if (context && end - start > prefix + suffix + 1) {
                    if (prefix > 0) blocks.add(new Block(false, rows.subList(start, start + prefix)));
                    blocks.add(new Block(true, rows.subList(start + prefix, end - suffix)));
                    if (suffix > 0) blocks.add(new Block(false, rows.subList(end - suffix, end)));
                } else {
                    blocks.add(new Block(false, rows.subList(start, end)));
                }
                start = end;
            }
            return List.copyOf(blocks);
        }
    }
}