PacketAssembler.java

package network.ike.docs.plugin.diff;

import com.github.difflib.UnifiedDiffUtils;
import com.github.difflib.patch.Patch;
import com.github.difflib.DiffUtils;

import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * Composes the review packet's generated AsciiDoc (ike-issues#648):
 * the master document with cover sheet, change summary, Record of
 * Changes, per-topic includes, Change Glossary, and Change Index, plus
 * the unified-diff listings for assembly scaffolding files.
 *
 * <p>All methods are pure functions over already-loaded content — the
 * mojo owns every file read and write.
 */
public final class PacketAssembler {

    private PacketAssembler() {
    }

    /**
     * One marked topic destined for the packet.
     *
     * @param change  the underlying file change
     * @param outName the flattened file name of the marked copy under
     *                {@code _diff/}
     * @param result  the marker's output for this topic
     */
    public record TopicEntry(GitSource.Change change, String outName,
                             AdocDiffMarker.MarkResult result) {
    }

    /**
     * One scaffolding (non-fragment) AsciiDoc change shown as a
     * unified-diff listing.
     *
     * @param path        the repository-relative path
     * @param unifiedDiff the unified diff text
     */
    public record ScaffoldEntry(String path, String unifiedDiff) {
    }

    /**
     * Produce a unified diff for a scaffolding file.
     *
     * @param path     the repository-relative path (used in headers)
     * @param oldLines the from-side lines (empty when added)
     * @param newLines the to-side lines (empty when deleted)
     * @return the unified diff text, without trailing newline
     */
    public static String unifiedDiff(String path, List<String> oldLines, List<String> newLines) {
        Patch<String> patch = DiffUtils.diff(oldLines, newLines);
        List<String> diff = UnifiedDiffUtils.generateUnifiedDiff(
                "a/" + path, "b/" + path, oldLines, patch, 3);
        return String.join("\n", diff);
    }

    /**
     * Inject one silent change-index term per owning change after a
     * fragment's level-1 title, so the ordinary {@code [index]} section
     * collects a change → pages mapping. Titles are quoted because a
     * comma in an indexterm splits levels.
     *
     * @param lines        the marked fragment
     * @param changeTitles the owning changes' titles
     * @return the fragment with terms injected (a new list)
     */
    public static List<String> injectChangeTerms(List<String> lines, List<String> changeTitles) {
        if (changeTitles.isEmpty()) {
            return lines;
        }
        List<String> out = new ArrayList<>(lines.size() + changeTitles.size());
        boolean placed = false;
        for (String l : lines) {
            out.add(l);
            if (!placed && l.startsWith("= ")) {
                for (String t : changeTitles) {
                    out.add("indexterm:[change, \"" + t.replace("\"", "'") + "\"]");
                }
                placed = true;
            }
        }
        return out;
    }

    /**
     * Find a fragment's anchor id ({@code [[id]]}).
     *
     * @param lines the fragment
     * @return the anchor id, or {@code null} when none is present
     */
    public static String anchorOf(List<String> lines) {
        for (String l : lines) {
            if (l.startsWith("[[") && l.endsWith("]]")) {
                return l.substring(2, l.length() - 2);
            }
        }
        return null;
    }

    /** An {@code include::target[attrlist]} directive line. */
    private static final Pattern INCLUDE = Pattern.compile(
            "^include::([^\\[]+)\\[(.*)]\\s*$");

    /** A URI scheme prefix — such targets are never repository paths. */
    private static final Pattern URI_SCHEME = Pattern.compile("^[A-Za-z][A-Za-z0-9+.-]*://");

    /**
     * A staged fragment with its relative includes re-pointed at their
     * staged copies (ike-issues#1096).
     *
     * @param lines   the fragment with each accepted include rewritten
     * @param targets repository-relative include targets → staged file
     *                name, in first-seen order; only targets the namer
     *                accepted
     */
    public record IncludeRewrite(List<String> lines, Map<String, String> targets) {
    }

    /**
     * Resolve an {@code include::} target the way the AsciiDoc
     * preprocessor does — relative to the directory of the including
     * file — into a repository-relative path.
     *
     * <p>Targets that cannot be resolved statically are reported as
     * {@code null}: attribute references ({@code {topics}/…}), absolute
     * paths, URIs, and relative paths that climb above the repository
     * root.
     *
     * @param sourcePath the repository-relative path of the including
     *                   file
     * @param target     the include target as written
     * @return the repository-relative path of the target, or
     *         {@code null} when it cannot be resolved statically
     */
    public static String resolveInclude(String sourcePath, String target) {
        if (target.isBlank() || target.contains("{") || target.startsWith("/")
                || URI_SCHEME.matcher(target).find()) {
            return null;
        }
        int slash = sourcePath.lastIndexOf('/');
        String dir = slash < 0 ? "" : sourcePath.substring(0, slash);
        List<String> segments = new ArrayList<>();
        for (String seg : (dir.isEmpty() ? target : dir + "/" + target).split("/")) {
            if (seg.isEmpty() || seg.equals(".")) {
                continue;
            }
            if (seg.equals("..")) {
                if (segments.isEmpty()) {
                    return null;
                }
                segments.remove(segments.size() - 1);
                continue;
            }
            segments.add(seg);
        }
        return segments.isEmpty() ? null : String.join("/", segments);
    }

    /**
     * Re-point every statically resolvable {@code include::} line of a
     * fragment staged under {@code _diff/} at the flat staged name of
     * its target, so the include resolves from the staging directory
     * exactly as it did from the source tree (ike-issues#1096). The
     * attribute list is preserved; lines the namer declines, and
     * targets {@link #resolveInclude} cannot resolve, are left as
     * written.
     *
     * @param lines      the staged fragment
     * @param sourcePath the fragment's repository-relative source path
     * @param stagedName maps a resolved repository-relative target to
     *                   its staged file name, or returns {@code null}
     *                   to leave that include untouched
     * @return the rewritten fragment (a new list) and the accepted
     *         targets
     */
    public static IncludeRewrite rewriteIncludes(List<String> lines, String sourcePath,
                                                 Function<String, String> stagedName) {
        List<String> out = new ArrayList<>(lines.size());
        Map<String, String> targets = new LinkedHashMap<>();
        for (String l : lines) {
            Matcher m = INCLUDE.matcher(l);
            if (!m.matches()) {
                out.add(l);
                continue;
            }
            String path = resolveInclude(sourcePath, m.group(1).strip());
            String name = path == null ? null
                    : targets.containsKey(path) ? targets.get(path) : stagedName.apply(path);
            if (name == null) {
                out.add(l);
                continue;
            }
            targets.put(path, name);
            out.add("include::" + name + "[" + m.group(2) + "]");
        }
        return new IncludeRewrite(out, targets);
    }

    /**
     * Compose the packet master document.
     *
     * @param title         the document title
     * @param fromLabel     human label for the from side
     * @param toLabel       human label for the to side
     * @param topics        marked topics, in presentation order
     * @param deleted       repository-relative paths deleted in range
     * @param manifest      the change manifest
     * @param anchorsByChange per-change anchor ids for context links,
     *                      parallel to {@code manifest.changes()}
     * @param hasRegistryDelta whether a registry-delta partial exists
     * @param scaffolds     scaffolding diffs, in presentation order
     * @param singleStampLine the packet's one stamp when in-flow refs
     *                      are suppressed, or {@code null}
     * @param stampsUsed    the stamps used in-flow, for the Stamp
     *                      Register; empty when suppressed
     * @return the master document text
     */
    public static String masterDoc(String title, String fromLabel, String toLabel,
                                   List<TopicEntry> topics, List<String> deleted,
                                   ChangeManifest manifest,
                                   List<List<String>> anchorsByChange,
                                   boolean hasRegistryDelta,
                                   List<ScaffoldEntry> scaffolds,
                                   String singleStampLine,
                                   List<StampRegistry.Stamp> stampsUsed) {
        StringBuilder m = new StringBuilder();
        m.append("= ").append(title).append('\n')
         .append(":doctype: book\n:toc: left\n:toclevels: 1\n:sectnums!:\n")
         .append(":icons: font\n:source-highlighter: rouge\n\n")
         .append("ifdef::backend-html5[]\n++++\n<style>\n")
         .append("span.diff-ins{color:#1a7f37;background:#e6ffec;}\n")
         .append("span.diff-del{color:#cf222e;background:#ffebe9;text-decoration:line-through;}\n")
         .append("span.diff-meta{color:#6e40c9;}\n")
         .append("</style>\n++++\nendif::[]\n\n");

        m.append("== How to Read This Packet\n\n")
         .append("Compared: `").append(fromLabel).append("` → `").append(toLabel).append("`.\n")
         .append("Each changed topic follows as its own chapter with inline markup:\n")
         .append("[.diff-ins]#inserted text renders like this#, and\n")
         .append("[.diff-del]#removed text renders like this#.\n")
         .append("[.diff-meta]#Metadata notes render like this# and carry changes the\n")
         .append("renderer cannot show inline (attributes, keywords, index terms,\n")
         .append("verbatim blocks).\n");
        if (singleStampLine != null) {
            m.append("All changes in this packet carry one stamp — ")
             .append(singleStampLine).append(" — so per-change stamp endnotes are omitted.\n");
        } else if (!stampsUsed.isEmpty()) {
            m.append("Superscript endnotes stamp each change boundary with its\n")
             .append("provenance coordinate; the Stamp Register at the back lists them.\n");
        }
        m.append('\n');

        m.append("== Change Summary\n\n.Changed topics\n")
         .append("[cols=\"4,1,1,1,1\", options=\"header\"]\n|===\n")
         .append("| Topic file | status | +words | -words | notes\n");
        for (TopicEntry t : topics) {
            m.append("| ").append(t.change().displayPath())
             .append(" | ").append(t.change().status().name().toLowerCase())
             .append(" | ").append(t.result().insWords())
             .append(" | ").append(t.result().delWords())
             .append(" | ").append(t.result().notes().size()).append('\n');
        }
        for (String d : deleted) {
            m.append("| ").append(d).append(" | deleted | 0 | — | —\n");
        }
        m.append("|===\n\n");

        m.append("== Record of Changes\n\n")
         .append("Each named change, with context links into the marked topics;\n")
         .append("the Change Index at the back resolves each change to its pages.\n\n")
         .append("[cols=\"2,4,3\", options=\"header\"]\n|===\n")
         .append("| Change | Description | Context\n");
        List<ChangeManifest.ChangeEntity> changes = manifest.changes();
        for (int i = 0; i < changes.size(); i++) {
            ChangeManifest.ChangeEntity c = changes.get(i);
            List<String> anchors = anchorsByChange.get(i);
            String links = anchors.isEmpty()
                    ? "(registry/assembly only)"
                    : String.join(", ", anchors.stream().map(a -> "xref:" + a + "[]").toList());
            m.append("| *").append(c.title()).append("*\n`").append(c.id()).append('`')
             .append(refsSuffix(c))
             .append(" | ").append(c.description())
             .append(" | ").append(links).append('\n');
        }
        m.append("|===\n\n");

        for (TopicEntry t : topics) {
            m.append("<<<\ninclude::_diff/").append(t.outName())
             .append("[leveloffset=+1]\n\n");
        }
        if (hasRegistryDelta) {
            m.append("<<<\ninclude::_diff/registry-delta.adoc[leveloffset=+1]\n\n");
        }
        if (!scaffolds.isEmpty()) {
            m.append("<<<\n== Assembly Scaffolding Changes\n\n")
             .append("Source diffs for assembly files (structural includes, not prose).\n\n");
            for (ScaffoldEntry s : scaffolds) {
                m.append("=== ").append(s.path()).append("\n\n[source,diff]\n-----\n")
                 .append(s.unifiedDiff()).append("\n-----\n\n");
            }
        }

        if (!stampsUsed.isEmpty()) {
            m.append("<<<\n== Stamp Register\n\n")
             .append("Every stamp used in this packet. S is stated redundantly —\n")
             .append("the marks already render it — for STAMP consistency.\n\n")
             .append("[cols=\"1,2,2,1,1,1,2\", options=\"header\"]\n|===\n")
             .append("| S | Time | Author | Module | Path | Commit | Refs\n");
            for (StampRegistry.Stamp s : stampsUsed) {
                m.append("| ").append(s.status().label())
                 .append(" | ").append(s.time())
                 .append(" | ").append(s.author())
                 .append(" | ").append(s.module())
                 .append(" | ").append(s.branch())
                 .append(" | `").append(s.commitId()).append('`')
                 .append(" | ").append(s.refs().isEmpty() ? "—" : s.refs())
                 .append('\n');
            }
            m.append("|===\n\n");
        }

        m.append("<<<\n[glossary]\n== Change Glossary\n\n");
        for (int i = 0; i < changes.size(); i++) {
            ChangeManifest.ChangeEntity c = changes.get(i);
            m.append(c.title()).append("::\n  ").append(c.description()).append('\n');
            List<String> anchors = anchorsByChange.get(i);
            if (!anchors.isEmpty()) {
                m.append("  Context: ")
                 .append(String.join(", ",
                         anchors.stream().map(a -> "xref:" + a + "[]").toList()))
                 .append(".\n");
            }
            m.append('\n');
        }

        m.append("<<<\n[index]\n== Change Index\n");
        return m.toString();
    }

    private static String refsSuffix(ChangeManifest.ChangeEntity c) {
        if (c.refs().isEmpty()) {
            return "";
        }
        return "\n_" + String.join(", ", c.refs()) + "_";
    }
}