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.List;
/**
* 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;
}
/**
* 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()) + "_";
}
}