LineDiff.java

package network.ike.plugin.scaffold;

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

/**
 * Minimal LCS-based line differ used by tracked and tracked-block
 * tier handlers to build human-readable diff output for
 * {@code scaffold-draft}.
 *
 * <p>This is not a full unified-diff implementation — we only emit the
 * prefixed lines ({@code ' '}, {@code '-'}, {@code '+'}) without hunk
 * headers, which is plenty for draft output where the file path is
 * already printed separately.
 *
 * <p>Both inputs are treated as UTF-8 text and split on LF. A trailing
 * newline is normalised away so {@code "a\n"} and {@code "a"} produce
 * identical line lists — callers that care about trailing-newline
 * differences should compare raw bytes.
 */
public final class LineDiff {

    private LineDiff() {}

    /**
     * Count of {@code '+'} and {@code '-'} lines between two texts.
     *
     * @param from the baseline text
     * @param to   the new text
     * @return a {@link Counts} record
     */
    public static Counts counts(String from, String to) {
        List<String> a = lines(from);
        List<String> b = lines(to);
        int[][] dp = lcsTable(a, b);
        int added = 0;
        int removed = 0;
        int i = 0;
        int j = 0;
        int m = a.size();
        int n = b.size();
        while (i < m && j < n) {
            if (a.get(i).equals(b.get(j))) {
                i++;
                j++;
            } else if (dp[i + 1][j] >= dp[i][j + 1]) {
                removed++;
                i++;
            } else {
                added++;
                j++;
            }
        }
        removed += m - i;
        added += n - j;
        return new Counts(added, removed);
    }

    /**
     * A line-prefixed diff: every line in the union starts with one of
     * {@code ' '}, {@code '-'}, or {@code '+'}. Empty output means the
     * texts are identical.
     *
     * @param from the baseline text
     * @param to   the new text
     * @return a newline-terminated, prefixed diff
     */
    public static String unified(String from, String to) {
        List<String> a = lines(from);
        List<String> b = lines(to);
        int[][] dp = lcsTable(a, b);
        StringBuilder sb = new StringBuilder();
        int i = 0;
        int j = 0;
        int m = a.size();
        int n = b.size();
        while (i < m && j < n) {
            if (a.get(i).equals(b.get(j))) {
                sb.append(' ').append(a.get(i)).append('\n');
                i++;
                j++;
            } else if (dp[i + 1][j] >= dp[i][j + 1]) {
                sb.append('-').append(a.get(i)).append('\n');
                i++;
            } else {
                sb.append('+').append(b.get(j)).append('\n');
                j++;
            }
        }
        while (i < m) {
            sb.append('-').append(a.get(i++)).append('\n');
        }
        while (j < n) {
            sb.append('+').append(b.get(j++)).append('\n');
        }
        return sb.toString();
    }

    private static int[][] lcsTable(List<String> a, List<String> b) {
        int m = a.size();
        int n = b.size();
        int[][] dp = new int[m + 1][n + 1];
        for (int i = m - 1; i >= 0; i--) {
            for (int j = n - 1; j >= 0; j--) {
                if (a.get(i).equals(b.get(j))) {
                    dp[i][j] = dp[i + 1][j + 1] + 1;
                } else {
                    dp[i][j] =
                            Math.max(dp[i + 1][j], dp[i][j + 1]);
                }
            }
        }
        return dp;
    }

    private static List<String> lines(String s) {
        if (s == null || s.isEmpty()) {
            return List.of();
        }
        List<String> out = new ArrayList<>();
        int start = 0;
        for (int k = 0; k < s.length(); k++) {
            if (s.charAt(k) == '\n') {
                out.add(s.substring(start, k));
                start = k + 1;
            }
        }
        if (start < s.length()) {
            out.add(s.substring(start));
        }
        return out;
    }

    /**
     * Added/removed line counts.
     *
     * @param added   number of {@code '+'} lines
     * @param removed number of {@code '-'} lines
     */
    public record Counts(int added, int removed) {

        /**
         * Compact {@code "+N/-M"} summary suitable for one-line output.
         *
         * @return the summary string
         */
        public String shortForm() {
            return "+" + added + "/-" + removed;
        }
    }
}