diff --git a/docs/src/compiler.md b/docs/src/compiler.md index 798ee80..92167c5 100644 --- a/docs/src/compiler.md +++ b/docs/src/compiler.md @@ -2658,7 +2658,9 @@ $ prism index --diff before.json after.json Changed Lib.base ``` -The viewer accepts the older artifact through `?diff=` and renders changed definitions side by side. Authored changes lead the review; cosmetic and dependency-cone changes remain available without overwhelming the primary diff. +The artifact carries both revisions of every definition that moved, and the edges one revision has and the other does not, so a consumer holding the new index recovers the old dependency graph exactly without a second index. + +The viewer accepts the older artifact through `?diff=` and shows every field of a changed definition as a diff against the other revision: the body line by line with the words that moved marked inside an edited line, the signature, effect row and docstring the same way, the claims, visibility and deprecation tags as what left and what arrived, and each relation row (callers, calls, tests, uses, members) as the set it was against the set it is. The status line names which fields moved. A page-wide control lays the pairs out split (old beside new) or unified (old above new), remembered across visits, and any card can choose its own layout. Authored changes lead the review; cosmetic and dependency-cone changes remain available without overwhelming the primary diff. ### 30.3 Review State and Questions {#review-state} diff --git a/src/index/diff.rs b/src/index/diff.rs index 022c650..5f2c9d4 100644 --- a/src/index/diff.rs +++ b/src/index/diff.rs @@ -23,7 +23,7 @@ use std::collections::{BTreeMap, BTreeSet}; use serde::{Deserialize, Serialize}; -use super::{Def, Index}; +use super::{Def, Edge, Index}; /// Schema tag for the diff artifact. pub const INDEX_DIFF_FORMAT: &str = "prism-index-diff-v1"; @@ -119,6 +119,28 @@ pub struct DiffEnvelope { pub counts: Counts, } +/// The edges one revision has and the other does not. +/// +/// The entries carry each changed definition's two records, which is enough to +/// show its two bodies but not its two *neighbourhoods*: who called it before, +/// what it called, which tests reached it. Those are edges, and an edge's other +/// end is very often an untouched definition the entry list omits. Carrying the +/// whole old edge set would repeat the index; carrying the difference is a few +/// rows per edit, and a consumer that has the new index recovers the old edge +/// set exactly as `new − added + removed`. +/// +/// Always present, even when empty, so a consumer can tell "nothing moved" from +/// an artifact written before the delta existed. +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +pub struct EdgeDelta { + /// In the new revision only. Sorted like an index's edge list. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub added: Vec, + /// In the old revision only. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub removed: Vec, +} + /// The diff artifact. #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct IndexDiff { @@ -127,6 +149,11 @@ pub struct IndexDiff { /// name. Untouched definitions are omitted rather than listed: a review view /// wants what moved, and the count is in the envelope. pub entries: Vec, + /// What moved in the dependency graph. Absent only in an artifact older + /// than the field, which a consumer should treat as "unknown" rather than + /// "nothing". + #[serde(default)] + pub edges: Option, } impl IndexDiff { @@ -277,6 +304,21 @@ pub fn diff(old: &Index, new: &Index) -> Result { // within each. A reviewer reads this top to bottom. entries.sort_by(|a, b| a.status.cmp(&b.status).then_with(|| a.id.cmp(&b.id))); + // Both edge lists are sorted and deduplicated, so the two differences are + // set differences and come out in the same order. + let old_edges: BTreeSet<&Edge> = old.edges.iter().collect(); + let new_edges: BTreeSet<&Edge> = new.edges.iter().collect(); + let edges = EdgeDelta { + added: new_edges + .difference(&old_edges) + .map(|e| (*e).clone()) + .collect(), + removed: old_edges + .difference(&new_edges) + .map(|e| (*e).clone()) + .collect(), + }; + Ok(IndexDiff { envelope: DiffEnvelope { format: INDEX_DIFF_FORMAT.to_string(), @@ -296,6 +338,7 @@ pub fn diff(old: &Index, new: &Index) -> Result { counts, }, entries, + edges: Some(edges), }) } diff --git a/src/index/tests.rs b/src/index/tests.rs index 1918cad..d0e4b7f 100644 --- a/src/index/tests.rs +++ b/src/index/tests.rs @@ -1023,6 +1023,59 @@ fn a_revision_against_itself_has_no_entries() { let d = super::diff(&index, &index).expect("comparable schemes"); assert!(d.entries.is_empty()); assert_eq!(d.envelope.counts.unchanged, index.defs.len()); + // The edge delta is carried even when empty: "nothing moved" is a fact, and + // must read differently from an artifact that never recorded edges at all. + let edges = d.edges.as_ref().expect("delta present"); + assert!(edges.added.is_empty() && edges.removed.is_empty()); + let json = d.to_json().expect("serialize"); + assert!(json.contains("\"edges\": {}"), "{json}"); +} + +// The entries say what a definition's text became; the edge delta says what +// its neighbourhood became, which the entries cannot: the other end of an edge +// is usually an untouched definition the entry list omits. A consumer holding +// the new index recovers the old edge set as `new − added + removed`. +#[test] +fn the_diff_carries_the_edges_that_moved() { + use super::Edge; + let old = index_of(REV_OLD); + // `top` stops calling `mid` and calls `spare` instead. + let new = index_of(&REV_OLD.replace("mid(n) + mid(n)", "spare(n) + spare(n)")); + let d = super::diff(&old, &new).expect("comparable schemes"); + let edges = d.edges.as_ref().expect("delta present"); + let calls = |from: &str, to: &str| Edge { + kind: EdgeKind::Calls, + from: from.into(), + to: to.into(), + }; + assert!(edges.removed.contains(&calls("top", "mid")), "{edges:?}"); + assert!(edges.added.contains(&calls("top", "spare")), "{edges:?}"); + // Exactly the set difference, in both directions. + let old_set: BTreeSet<&Edge> = old.edges.iter().collect(); + let new_set: BTreeSet<&Edge> = new.edges.iter().collect(); + let recovered: BTreeSet<&Edge> = new_set + .iter() + .copied() + .filter(|e| !edges.added.contains(e)) + .chain(edges.removed.iter()) + .collect(); + assert_eq!(recovered, old_set); + // And the delta survives the artifact boundary; an artifact without one + // still reads, as unknown rather than as empty. + let back = super::IndexDiff::from_json(&d.to_json().expect("serialize")).expect("reads back"); + assert_eq!(back.edges, d.edges); + let without = d.to_json().expect("serialize"); + let without = without.replace( + &format!( + ",\n \"edges\": {}", + serde_json::to_string_pretty(edges) + .expect("serialize") + .replace('\n', "\n ") + ), + "", + ); + let older = super::IndexDiff::from_json(&without).expect("reads back"); + assert!(older.edges.is_none(), "{without}"); } // The artifact is the input to a `--check` gate, so identical source must yield diff --git a/web/src/viewer-diff.ts b/web/src/viewer-diff.ts new file mode 100644 index 0000000..b4a88c4 --- /dev/null +++ b/web/src/viewer-diff.ts @@ -0,0 +1,263 @@ +// Comparing two texts, for the revision pair a card shows. +// +// The index diff says *that* a definition changed, and carries both revisions of +// it; it does not say where. Two full bodies side by side leave the reader to find +// the edit by eye, which on anything longer than a few lines is most of the work a +// diff exists to remove. So the viewer draws a real one: lines are aligned by the +// shortest edit script, and within a changed line the words that moved are marked, +// so a one-token change in a twelve-line body reads as one token. +// +// Deliberately not a diff library. The viewer is a self-contained reader of one +// JSON file, no wasm and no dependencies, and the painting is already its own: +// every name in a body is a link and every token a class the compiler chose, and +// a library that renders diffs would render them without either. What is needed +// is the alignment, which is Myers' algorithm and fits in a screen. + +/// One step of an edit script: a line (or token) kept, dropped, or introduced. +/// Indices are into the old (`a`) and new (`b`) sequences. +export type Op = + | { kind: "eq"; a: number; b: number } + | { kind: "del"; a: number } + | { kind: "ins"; b: number }; + +// Retaining one full diagonal array per edit-distance round is what makes Myers +// easy to walk backward, but its worst case is quadratic. Past this many cells, +// preserve the common ends and render the unrelated middle as one replacement; +// a coarse diff is more useful than a viewer tab lost to memory pressure. +const MAX_TRACE_CELLS = 4_000_000; + +/// The shortest edit script from `a` to `b` (Myers, 1986), with a bounded +/// coarse fallback for unusually large, unrelated inputs. +/// +/// The greedy forward search with a trace of each round's furthest-reaching +/// paths, walked back from the end. O((N+M)·D) time and memory, where D is the +/// edit distance; a definition's two revisions are mostly the same text, so +/// D is small where N is large. +export function diff( + a: readonly T[], + b: readonly T[], + eq: (x: T, y: T) => boolean = same, +): Op[] { + const n = a.length; + const m = b.length; + const max = n + m; + // `v[k]` is the furthest x reached on diagonal k; shifted by `off` so that + // negative diagonals index an array. + const off = max + 1; + const v = new Int32Array(2 * max + 3); + const trace: Int32Array[] = []; + for (let d = 0; d <= max; d++) { + if ((trace.length + 1) * v.length > MAX_TRACE_CELLS) return coarse(a, b, eq); + trace.push(v.slice()); + for (let k = -d; k <= d; k += 2) { + let x = + k === -d || (k !== d && v[off + k - 1] < v[off + k + 1]) + ? v[off + k + 1] + : v[off + k - 1] + 1; + let y = x - k; + while (x < n && y < m && eq(a[x], b[y])) { + x++; + y++; + } + v[off + k] = x; + if (x >= n && y >= m) return backtrack(trace, off, n, m); + } + } + // Unreachable: the search always terminates by round `max`. + return backtrack(trace, off, n, m); +} + +// A valid edit script that keeps the shared prefix and suffix and treats the +// middle as one replacement. Used only when retaining the shortest script's +// trace would exceed the memory budget above. +function coarse(a: readonly T[], b: readonly T[], eq: (x: T, y: T) => boolean): Op[] { + let head = 0; + while (head < a.length && head < b.length && eq(a[head], b[head])) head++; + let oldTail = a.length; + let newTail = b.length; + while (oldTail > head && newTail > head && eq(a[oldTail - 1], b[newTail - 1])) { + oldTail--; + newTail--; + } + const out: Op[] = []; + for (let i = 0; i < head; i++) out.push({ kind: "eq", a: i, b: i }); + for (let i = head; i < oldTail; i++) out.push({ kind: "del", a: i }); + for (let i = head; i < newTail; i++) out.push({ kind: "ins", b: i }); + for (let i = 0; oldTail + i < a.length; i++) { + out.push({ kind: "eq", a: oldTail + i, b: newTail + i }); + } + return out; +} + +const same = (x: T, y: T): boolean => x === y; + +function backtrack(trace: Int32Array[], off: number, n: number, m: number): Op[] { + const ops: Op[] = []; + let x = n; + let y = m; + for (let d = trace.length - 1; d >= 0; d--) { + const v = trace[d]; + const k = x - y; + const prevK = k === -d || (k !== d && v[off + k - 1] < v[off + k + 1]) ? k + 1 : k - 1; + const prevX = v[off + prevK]; + const prevY = prevX - prevK; + while (x > prevX && y > prevY) { + ops.push({ kind: "eq", a: x - 1, b: y - 1 }); + x--; + y--; + } + if (d > 0) ops.push(x === prevX ? { kind: "ins", b: y - 1 } : { kind: "del", a: x - 1 }); + x = prevX; + y = prevY; + } + return ops.reverse(); +} + +/// A run of kept lines, or one edit: the old lines it drops and the new ones it +/// introduces, together. +/// +/// An `eq` pair means the two lines are byte-identical; a line edited *within* +/// is a del and an ins in a `change` block, re-paired by `textDiff` for its +/// word-level marks. The script interleaves drops and introductions however the +/// search happened to reach them; a reader wants each edit as one thing, old +/// above (or beside) new. +export type Block = + | { kind: "eq"; pairs: [number, number][] } + | { kind: "change"; dels: number[]; inss: number[] }; + +export function blocks(ops: Op[]): Block[] { + const out: Block[] = []; + for (const op of ops) { + const last = out.at(-1); + if (op.kind === "eq") { + if (last?.kind === "eq") last.pairs.push([op.a, op.b]); + else out.push({ kind: "eq", pairs: [[op.a, op.b]] }); + } else if (last?.kind === "change") { + if (op.kind === "del") last.dels.push(op.a); + else last.inss.push(op.b); + } else { + out.push( + op.kind === "del" + ? { kind: "change", dels: [op.a], inss: [] } + : { kind: "change", dels: [], inss: [op.b] }, + ); + } + } + return out; +} + +/// A half-open span of one text, in code units. +export type Range = [number, number]; + +/// Two revisions of one text, compared line by line and then, inside each edited +/// line, word by word: how the lines align, and what to emphasise inside them. +export interface TextDiff { + /// The line alignment. Indices are into each text's lines, in order. + blocks: Block[]; + /// The words that moved inside edited lines (the marks a rendered diff + /// highlights), as absolute spans of each whole text: the painter works over + /// the whole text, not line by line. Empty for a line that was replaced + /// outright rather than edited. + oldEmph: Range[]; + newEmph: Range[]; +} + +/// The threshold below which two paired lines are called a replacement rather +/// than an edit, and nothing inside them is marked: marking most of a line +/// says less than tinting all of it. The ratio is shared characters over the +/// longer line. +const ALIKE = 0.4; + +export function textDiff(oldText: string, newText: string): TextDiff { + // No text has no lines. Keeping `""` as one artificial line makes a field + // added or removed in unified mode index a painted line that does not exist. + const oldLines = oldText === "" ? [] : oldText.split("\n"); + const newLines = newText === "" ? [] : newText.split("\n"); + const oldStarts = starts(oldLines); + const newStarts = starts(newLines); + const bs = blocks(diff(oldLines, newLines)); + const oldEmph: Range[] = []; + const newEmph: Range[] = []; + for (const b of bs) { + if (b.kind !== "change") continue; + // Pair the dropped lines with the introduced ones in order. A block that + // drops three and introduces four is, nearly always, three edited lines and + // one new one; pairing by position is what every diff tool does, and the + // likeness test below catches the cases where it is wrong. + for (let i = 0; i < Math.min(b.dels.length, b.inss.length); i++) { + const a = b.dels[i]; + const c = b.inss[i]; + const within = wordDiff(oldLines[a], newLines[c]); + if (!within) continue; + for (const [s, e] of within.old) oldEmph.push([oldStarts[a] + s, oldStarts[a] + e]); + for (const [s, e] of within.new) newEmph.push([newStarts[c] + s, newStarts[c] + e]); + } + } + return { blocks: bs, oldEmph, newEmph }; +} + +// Where each part starts when the parts are joined by a separator of `sep` +// characters: lines by a newline, tokens by nothing. +function starts(parts: string[], sep = 1): number[] { + const out: number[] = []; + let at = 0; + for (const p of parts) { + out.push(at); + at += p.length + sep; + } + return out; +} + +/// One line's tokens: words, runs of space, and single punctuation marks, so the +/// unit of change is a name or an operator and never half of one. +const TOKEN = /[\p{L}\p{N}_]+|\s+|./gsu; + +/// Where two lines differ, as spans of each; `null` when they are not alike +/// enough for the spans to mean anything. +function wordDiff(a: string, b: string): { old: Range[]; new: Range[] } | null { + const ta = a.match(TOKEN) ?? []; + const tb = b.match(TOKEN) ?? []; + const ops = diff(ta, tb); + let kept = 0; + for (const op of ops) if (op.kind === "eq") kept += ta[op.a].length; + if (kept < ALIKE * Math.max(a.length, b.length)) return null; + const sa = starts(ta, 0); + const sb = starts(tb, 0); + const old: Range[] = []; + const fresh: Range[] = []; + for (const op of ops) { + if (op.kind === "del") extend(old, [sa[op.a], sa[op.a] + ta[op.a].length]); + else if (op.kind === "ins") extend(fresh, [sb[op.b], sb[op.b] + tb[op.b].length]); + } + return { old, new: fresh }; +} + +// Append a span, merging it into the last one when they touch: the marks are +// read as runs, and `foo` then `(` then `x` marked separately is three boxes +// where one is meant. +function extend(ranges: Range[], r: Range): void { + const last = ranges.at(-1); + if (last && last[1] === r[0]) last[1] = r[1]; + else ranges.push(r); +} + +/// How many kept lines it takes before the middle of a run is folded away, and +/// how many stay visible on each side of a fold. A body that is mostly unchanged +/// is mostly not what the reader came for; the fold says how much was skipped and +/// opens on a click. +export const FOLD_AT = 10; +export const CONTEXT = 3; + +/// A run of kept lines, cut for display: the head, a fold over the middle (if +/// the run is long enough and not opened), and the tail. +export function folded( + pairs: [number, number][], + open: boolean, +): { head: [number, number][]; hidden: [number, number][]; tail: [number, number][] } { + if (open || pairs.length < FOLD_AT) return { head: pairs, hidden: [], tail: [] }; + return { + head: pairs.slice(0, CONTEXT), + hidden: pairs.slice(CONTEXT, pairs.length - CONTEXT), + tail: pairs.slice(pairs.length - CONTEXT), + }; +} diff --git a/web/src/viewer-model.ts b/web/src/viewer-model.ts index fc2947e..2419a30 100644 --- a/web/src/viewer-model.ts +++ b/web/src/viewer-model.ts @@ -10,6 +10,21 @@ /// Every relation `prism index` derives. export type EdgeKind = "calls" | "uses-type" | "performs" | "handles" | "instance-of" | "tests"; +export interface Edge { + kind: EdgeKind; + from: string; + to: string; +} + +/// What the relation lookups below are built over: a revision's definitions +/// and edges. The loaded index is one; the old revision a diff can rebuild +/// (`Revisions.before`) is another, and the lookups do not care which. +export interface Graph { + defs: Def[]; + byId: Map; + edges: Edge[]; +} + export type Kind = | "value" | "const" @@ -142,7 +157,7 @@ interface Wire { envelope: Envelope; modules: IndexModule[]; defs: Def[]; - edges: { kind: EdgeKind; from: string; to: string }[]; + edges: Edge[]; builtins?: Primitive[]; token_classes?: string[]; type_table?: string[]; @@ -156,7 +171,7 @@ export class Index { readonly modules: IndexModule[]; readonly defs: Def[]; readonly byId: Map; - readonly edges: { kind: EdgeKind; from: string; to: string }[]; + readonly edges: Edge[]; /// The compiler's own primitives, by name. One of these has no definition /// anywhere, so it is not a link. It is also not missing, and saying which /// of the two it is is the difference between "primitive" and "this index is @@ -233,7 +248,7 @@ export class Relations { private readonly out = new Map(); private readonly inn = new Map(); - constructor(index: Index) { + constructor(index: Graph) { for (const e of index.edges) { push(this.out, `${e.kind} ${e.from}`, e.to); push(this.inn, `${e.kind} ${e.to}`, e.from); @@ -259,7 +274,7 @@ export class Mentions { private readonly out = new Map(); private readonly inn = new Map(); - constructor(index: Index) { + constructor(index: Graph) { const term = (id: string): boolean => { const kind = index.byId.get(id)?.kind; return kind === "value" || kind === "const" || kind === "test" || kind === "logic"; @@ -317,7 +332,7 @@ export class Members { /// definition → the members its own source names. private readonly byUser = new Map(); - constructor(index: Index) { + constructor(index: Graph) { // Seeded from the declarations themselves, so a member nothing uses is still a // member: an effect's operations are performed by *programs*, so a library // index would otherwise list none of `Output`'s. @@ -402,6 +417,9 @@ interface DiffWire { counts: Record; }; entries: DiffEntry[]; + /// The edges one revision has and the other does not. Absent from an + /// artifact older than the field, which is "unknown", not "none". + edges?: { added?: Edge[]; removed?: Edge[] }; } const DIFF_FORMAT = "prism-index-diff-v1"; @@ -414,6 +432,11 @@ const DIFF_FORMAT = "prism-index-diff-v1"; /// consults this for whatever the other revision had. export class Revisions { readonly envelope: DiffWire["envelope"]; + /// What moved in the dependency graph, or `null` when the artifact predates + /// the delta and cannot say. + readonly edges: { added: Edge[]; removed: Edge[] } | null; + /// The renames the diff knows as facts: old canonical name to new. + readonly movedTo = new Map(); private readonly byId = new Map(); /// `classes` and `types` belong to the index loaded in the viewer. They are the @@ -440,13 +463,39 @@ export class Revisions { ); } this.envelope = wire.envelope; + this.edges = wire.edges + ? { added: wire.edges.added ?? [], removed: wire.edges.removed ?? [] } + : null; for (const e of wire.entries) { if (e.old) adopt(e.old, wire.envelope.old, classes, types); if (e.new) adopt(e.new, wire.envelope.new, classes, types); this.byId.set(e.id, e); + if (e.status === "moved" && e.old_id !== undefined) this.movedTo.set(e.old_id, e.id); } } + /// The old revision's definitions and edges, rebuilt from the new revision's + /// and this diff. + /// + /// The artifact carries what differs and nothing else, so the other side is + /// recovered rather than read: every definition the diff does not mention is + /// the same on both sides, and every edge not in the delta likewise. The + /// result is what lets a card ask the old revision the same questions it asks + /// the new one, who called this and what it called, through the same lookups. + /// `null` when the artifact carries no edge delta, since a graph with the + /// old definitions and the new edges would answer those questions wrongly. + before(index: Index): Graph | null { + if (!this.edges) return null; + const byId = new Map(index.byId); + for (const e of this.byId.values()) { + byId.delete(e.id); + if (e.old) byId.set(e.old_id ?? e.id, e.old); + } + const added = new Set(this.edges.added.map(edgeKey)); + const edges = [...index.edges.filter((e) => !added.has(edgeKey(e))), ...this.edges.removed]; + return { defs: [...byId.values()], byId, edges }; + } + get(id: string): DiffEntry | undefined { return this.byId.get(id); } @@ -580,6 +629,8 @@ function repack(packed: string | undefined, classes: string[], map: Int32Array): return out.join(" "); } +const edgeKey = (e: Edge): string => `${e.kind} ${e.from} ${e.to}`; + function push(map: Map, key: string, value: string): void { const at = map.get(key); if (at) at.push(value); diff --git a/web/src/viewer-review.ts b/web/src/viewer-review.ts index 086d6d9..514203f 100644 --- a/web/src/viewer-review.ts +++ b/web/src/viewer-review.ts @@ -128,6 +128,21 @@ export class Review { } } + /// How a card lays a revision pair out: the two sides beside each other, or + /// one above the other. A layout preference like the rail's, kept the same + /// way and for the same reason. + diffMode(): "split" | "unified" { + return this.storage?.getItem(`${VERSION}:diff`) === "unified" ? "unified" : "split"; + } + + setDiffMode(mode: "split" | "unified"): void { + try { + this.storage?.setItem(`${VERSION}:diff`, mode); + } catch { + // Quota or a disabled store: the preference is not worth failing over. + } + } + get(id: string): Mark | undefined { return this.marks.get(id); } diff --git a/web/src/viewer.css b/web/src/viewer.css index 9f9c974..f3f4d06 100644 --- a/web/src/viewer.css +++ b/web/src/viewer.css @@ -3,6 +3,15 @@ for the two-column reading surface. */ @import "./theme.css"; +/* The diff tints: the site's own red and green, washed for a whole line and + stronger for the words inside it that moved. */ +:root { + --del-wash: rgb(164 54 47 / 9%); + --del-mark: rgb(164 54 47 / 26%); + --ins-wash: rgb(47 111 79 / 10%); + --ins-mark: rgb(47 111 79 / 28%); +} + .viewer-body { display: flex; flex-direction: column; @@ -37,6 +46,43 @@ border-color: var(--line); } +/* The split/unified control: two buttons reading as one, the current layout + pressed. On the page header for every card, and on a card for itself. */ +.seg { + display: inline-flex; + flex: 0 0 auto; + overflow: hidden; + border: 1px solid var(--line); + border-radius: 4px; +} + +.seg[hidden] { + display: none; +} + +.seg-btn { + padding: 1px 7px; + font-family: var(--ui); + font-size: 10.5px; + color: var(--muted); + cursor: pointer; + background: var(--bg-soft); + border: 0; +} + +.seg-btn + .seg-btn { + border-left: 1px solid var(--line); +} + +.seg-btn:hover { + color: var(--ink); +} + +.seg-btn[aria-pressed="true"] { + color: var(--panel); + background: var(--accent); +} + /* With the rail away the deck takes the whole width; the grid must give up the column too, or an empty track is left behind. */ .viewer-main.is-alone { @@ -251,6 +297,15 @@ kbd { padding: 8px 10px 10px; } +/* Keep the card's controls as one unit. At narrow widths the unit moves below + the definition name instead of shrinking the diff selector out of sight. */ +.card-actions { + display: inline-flex; + flex: 0 0 auto; + gap: 7px; + align-items: center; +} + /* Expanded by default: opening a card is already the reader asking for the definition, and a second click to see what they asked for buys nothing. Folding stays for a card being kept open for reference, and folds to the signature. */ @@ -307,6 +362,56 @@ kbd { color: var(--warn); } +/* A field shown as a diff bleeds to the card's edges, whatever measure or box + it has when shown whole: every split on a card must divide at the same + line, and the body's divider is the card's midpoint. The cells carry the + indent the whole field had, so the text still starts where it did. */ +.card-sig.card-sig--diff, +.card-eff.card-eff--diff, +.card-doc.card-doc--diff { + max-width: none; + margin: 0; + padding: 0; + background: none; + border-radius: 0; +} + +.card-sig--diff .card-diff, +.card-eff--diff .card-diff, +.card-doc--diff .card-diff { + padding: 3px 0 6px; + border-top: 0; +} + +.card-sig--diff .card-diff { + font-size: 12.5px; + line-height: 1.6; +} + +.card-eff--diff .card-diff { + font-size: 11.5px; +} + +/* The name of a field whose diff has no room for it inline: a caption row + above, in the field's own colour. */ +.diff-cap { + padding: 0 0 0 32px; +} + +/* Prose diffs in the reading face; the inline-code styling a rendered + docstring gives its `code` must not reach the cells. */ +.card-doc--diff .card-diff--prose { + font-family: var(--ui); + font-size: 12.5px; + line-height: 1.55; +} + +.card-doc--diff .dl code { + padding: 0; + font: inherit; + background: none; +} + .card-doc { max-width: 82ch; padding: 0 10px 9px 32px; @@ -451,44 +556,128 @@ button.ref--prim:hover { color: var(--muted); } -/* Two revisions of one definition, side by side: comparing means reading across, - not scrolling. Each pane scrolls its own long lines, so neither can push the - other off the card. */ +/* Two revisions of one text, as a diff. Rows are aligned: the line on the left + is the line the one on the right replaced, with the short side of an uneven + edit padded so that the rows stay level. Cells wrap rather than scroll: a + pane that scrolls on its own unaligns the very rows the layout exists to + align. */ .card-diff { display: grid; - grid-template-columns: 1fr 1fr; + padding: 5px 0 11px; + font-family: var(--mono); + font-size: 12.5px; + line-height: 1.45; border-top: 1px solid var(--line); } -.card-pane { - min-width: 0; - border-left: 1px solid var(--line); +.card-diff--split { + grid-template-columns: 1fr 1fr; } -.card-pane:first-child { - border-left: 0; +.card-diff--unified { + grid-template-columns: 1fr; } -.card-pane-head { - padding: 5px 12px 0 32px; +/* Folding hides the body whichever form it takes; a diffed signature stays, + as the signature does. */ +.card.is-folded > .card-diff { + display: none; +} + +.diff-head { + padding: 0 12px 4px 32px; + font-family: var(--ui); font-size: 10px; letter-spacing: 0.03em; color: var(--muted); text-transform: uppercase; } -.card-diff .card-src { - border-top: 0; +/* One line of one side. The gutter carries the sign, for a reader who does not + see the tint; an empty line still takes its row. */ +.dl { + position: relative; + min-width: 0; + min-height: 1.45em; + padding: 0 12px 0 32px; + white-space: pre-wrap; + overflow-wrap: anywhere; } -/* The old pane is styled exactly like the new one. It was tinted and dimmed back - when the two were stacked and nothing else told them apart; side by side, the - captions and the divider do that, and a wash over one side now suggests a - meaning it does not have. In a conventional diff a tinted region marks what - changed, and this tints a whole pane regardless. It also made the version being - reviewed the harder of the two to read. */ -.card-src--was { - border-top: 0; +.dl::before { + position: absolute; + left: 16px; + color: var(--muted); + content: ""; +} + +.dl--new { + border-left: 1px solid var(--line); +} + +/* What one revision has and the other does not. The whole line is tinted, and + within an edited line the words that moved are marked more strongly: the + tint says "here", the mark says "this". */ +.dl.is-del { + background: var(--del-wash); +} + +.dl.is-ins { + background: var(--ins-wash); +} + +.dl.is-del::before { + content: "−"; +} + +.dl.is-ins::before { + content: "+"; +} + +.dfx { + color: inherit; + border-radius: 2px; +} + +.is-del .dfx { + background: var(--del-mark); +} + +.is-ins .dfx { + background: var(--ins-mark); +} + +/* The padding opposite a line the other side lacks: hatched, so it reads as + "nothing here" rather than as an empty line. */ +.dl--pad { + background: repeating-linear-gradient( + -45deg, + transparent, + transparent 5px, + var(--bg-soft) 5px, + var(--bg-soft) 6px + ); +} + +/* A folded run of unchanged lines: one row across both columns, saying how + much it hides. */ +.dl-fold { + grid-column: 1 / -1; + padding: 2px 12px 2px 32px; + font-family: var(--ui); + font-size: 11px; + color: var(--muted); + text-align: left; + cursor: pointer; + background: var(--bg-soft); + border: 0; + border-top: 1px solid var(--line); + border-bottom: 1px solid var(--line); +} + +.dl-fold:hover { + color: var(--ink); + background: var(--accent-wash); } .status { @@ -676,6 +865,43 @@ button.ref--prim:hover { border-color: var(--accent); } +/* A chip, tag or badge the other revision had and this one lacks, or the + reverse: the same red and green the line diff uses, and a strike through + what left so the mark survives without its colour. */ +.chip.is-del, +.tag.is-del, +.kind.is-del { + color: var(--err); + text-decoration: line-through; + background: var(--del-wash); + border-color: rgb(164 54 47 / 35%); +} + +.chip.is-ins, +.tag.is-ins, +.kind.is-ins { + color: var(--accent-strong); + background: var(--ins-wash); + border-color: rgb(47 111 79 / 45%); +} + +/* A relation row in both revisions, side by side: what it was, what it is. + The divider sits at the card's midpoint, where the body's does, so every + split on the card reads down one line. The strip is inset 32px on the + left and 10px on the right, the label takes 92px, and the row's 8px gap + falls twice before the new half; so the old half is half the strip, less + half the inset difference (11px), the label, and the two gaps. */ +.rel--split { + display: grid; + grid-template-columns: 92px calc(50% - 11px - 92px - 16px) 1fr; + align-items: baseline; +} + +.rel-chips--new { + padding-left: 8px; + border-left: 1px solid var(--line); +} + /* A compiler primitive: muted like a name that leaves the index, but a live link to the builtin's synthesized card. */ .chip--prim { @@ -798,24 +1024,32 @@ button.ref--prim:hover { cursor: help; } -/* One column when there is no room for two: a pane squeezed to nothing compares - nothing. */ -@media (max-width: 1000px) { - .card-diff { - grid-template-columns: 1fr; +@media (max-width: 720px) { + /* The desktop header has enough room for its identity, revision summary and + navigation on one line. Give each a readable row on a narrow screen rather + than squeezing the summary one word wide and pushing links off the page. */ + .viewer-body > header { + flex-wrap: wrap; + gap: 0.45rem 0.7rem; + padding: 0.65rem 0.75rem; } - .card-pane { - border-top: 1px solid var(--line); - border-left: 0; + .viewer-body > header #index-title, + .viewer-body > header .controls { + flex: 1 0 100%; } - .card-pane:first-child { - border-top: 0; + .viewer-body > header #index-title { + order: 2; + line-height: 1.35; + } + + .viewer-body > header .controls { + order: 3; + flex-wrap: wrap; + gap: 0.45rem 0.8rem; } -} -@media (max-width: 720px) { .viewer-main { grid-template-columns: 1fr; } @@ -823,4 +1057,31 @@ button.ref--prim:hover { .rail { max-height: 32vh; } + + .card-head { + flex-wrap: wrap; + } + + .card-name { + min-width: 0; + overflow-wrap: anywhere; + text-align: left; + } + + .card-actions { + margin-left: auto; + } + + /* Keep the code itself split: even here it remains useful. Only the relation + label moves above its pair, restoring two real half-width chip columns in + place of the fixed label plus a 30px old side. */ + .rel--split { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + row-gap: 4px; + } + + .rel--split .rel-label { + grid-column: 1 / -1; + text-align: left; + } } diff --git a/web/src/viewer.ts b/web/src/viewer.ts index 6332569..4d21b80 100644 --- a/web/src/viewer.ts +++ b/web/src/viewer.ts @@ -15,6 +15,7 @@ // generated somewhere else and handed over. import "./viewer.css"; +import { folded, type Range, type TextDiff, textDiff } from "./viewer-diff.js"; import { type Def, type DiffEntry, @@ -52,6 +53,10 @@ const RELATIONS: { kind: EdgeKind; dir: "in" | "out"; label: string; hint: strin { kind: "instance-of", dir: "in", label: "instances", hint: "instances of this class" }, ]; +// How a revision pair is laid out: the two sides beside each other, or one +// above the other. +type Mode = "split" | "unified"; + // One search result: a definition, or one member of one, with how well it matched // (0 exact, 1 prefix, 2 substring, 3 only through the module path). type Hit = { def: Def; member?: string; score: number }; @@ -91,6 +96,10 @@ const CHIPS = 12; const el = (id: string): T => document.getElementById(id) as T; const esc = (s: string): string => s.replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c] ?? c); +// For an attribute inside painted text, whose lines are split on the newline: a +// tooltip puts a type on its own line, and that newline must not read as a +// line of source. +const attr = (s: string): string => esc(s).replace(/\n/g, " "); class Viewer { private readonly rel: Relations; @@ -110,6 +119,15 @@ class Viewer { private readonly openMods = new Set(); // The relation rows the reader asked to see in full. private readonly wide = new Set(); + // The folded runs of unchanged lines a reader opened, by card, field and run. + private readonly unfolded = new Set(); + // How a revision pair is laid out: old beside new, or old above new. The page + // has one setting, remembered; a card can depart from it, for this visit. + private mode: Mode = "split"; + private readonly modeOf = new Map(); + // The old revision's relations, when the loaded diff carries what it takes to + // rebuild them: what every row on a card had before. + private readonly was: { rel: Relations; members: Members; mentions: Mentions } | null; // The cards whose note field is open. A note is rare and a definition is not, so // the field appears when there is one or when it has been asked for, rather than // standing on every card in the deck waiting to be used. @@ -130,6 +148,8 @@ class Viewer { rail: HTMLElement; main: HTMLElement; railToggle: HTMLElement; + /// The page's split/unified control, shown only with a revision pair. + mode: HTMLElement; }, storage: Storage | null = null, /// The mark store's namespace. The caller joins the artifact URL with the @@ -140,7 +160,12 @@ class Viewer { this.rel = new Relations(index); this.members = new Members(index); this.mentions = new Mentions(index); + const before = revs?.before(index) ?? null; + this.was = before + ? { rel: new Relations(before), members: new Members(before), mentions: new Mentions(before) } + : null; this.review = new Review(unit, storage); + this.mode = this.review.diffMode(); // Follow marks across the renames a loaded diff knows as facts, and across // file moves by unambiguous content address. this.review.rekey( @@ -161,6 +186,8 @@ class Viewer { : this.index.envelope.title; this.nodes.title.innerHTML = esc(title) + testLayer(this.index.envelope.tests) + brokenModules(this.index.modules); + this.nodes.mode.hidden = !this.revs; + this.reflectMode(); this.renderList(""); this.nodes.search.addEventListener("input", () => this.renderList(this.nodes.search.value)); window.addEventListener("hashchange", () => this.fromUrl()); @@ -641,6 +668,13 @@ class Viewer { if (!d) return this.builtinCard(id); const focused = id === this.focused ? " is-focused" : ""; const mark = this.review.get(id); + // The other revision of this definition, when there is one to compare + // with. Every field below is then shown as a diff against it: the body, + // but equally the signature, the doc, the claims, the relation rows: a + // review reads the whole definition, and "changed" with no sign of what + // asks the reviewer to find out. + const old = this.other(id); + const mode = this.layout(id); // A definition's own text is what a reviewer reads, so the body is the card's // subject and it is shown: opening a card is already the reader asking for it, // and a second click to see what they asked for is a click that buys nothing. @@ -648,28 +682,30 @@ class Viewer { const shut = this.folded.has(id); // Nothing relates to `compose` in either direction because it calls only its // own parameters. Omit the empty relation strip. - const rel = this.relations(id); + const rel = this.relations(id, mode); return `
- ${kindBadge(d.kind)} + ${old && old.kind !== d.kind ? kindBadge(old.kind, "del") + kindBadge(d.kind, "ins") : kindBadge(d.kind)} - ${d.vis === "public" ? 'pub' : ""} - ${d.vis === "opaque" ? 'opaque' : ""} - ${(d.claims ?? []).map((c) => `${esc(c)}`).join("")} - ${d.deprecated ? `deprecated` : ""} + ${visTags(d, old)} + ${claimTags(d, old)} + ${deprecatedTags(d, old)} - ${this.seen(id, mark)} - - ${hashChip(d)} - + + ${old ? modeToggle(mode, id) : ""} + ${this.seen(id, mark)} + + ${hashChip(d)} + +
${this.since(id)} - ${this.signature(d)} - ${this.effectRow(d)} - ${d.doc ? `
${renderDoc(d.doc)}
` : ""} - ${this.before(id)} - ${this.sources(d)} + ${this.signature(d, old, mode)} + ${this.effectRow(d, old, mode)} + ${this.docs(d, old, mode)} + ${this.before(id, d, old)} + ${this.sources(d, old, mode)} ${rel ? `
${rel}
` : ""} ${ mark?.note || this.noting.has(id) @@ -699,8 +735,10 @@ class Viewer { ${builtinBadge()} - - + + + + ${p.signature ? `
${esc(p.name)} : ${this.linkedSig(p.signature)}
` : ""} ${p.doc ? `

${esc(p.doc)}

` : ""} @@ -723,6 +761,16 @@ class Viewer { }); } + // The other revision's record of a definition, when the card can compare the + // two: the loaded diff has an old side for it and the loaded index the new. + // A definition only added or only removed has one revision, and reading that + // one as a diff against nothing would tint every line of it for no reader's + // benefit; its status line says what it is. + private other(id: string): Def | undefined { + const e = this.revs?.get(id); + return e?.old && this.index.byId.has(id) ? e.old : undefined; + } + // The read mark. Its freshness is a comparison against the revision the mark was // made at, not against a loaded pair, so it does its job on a single index too: // mark, edit, re-index, and the card says whether what moved was the formatting @@ -758,56 +806,191 @@ class Viewer { } } - // What the other revision had, when it had something different. + // What the other revision had, and in which fields. // // A `cone` entry gets a sentence rather than a second copy of identical text: // its bytes did not move, only its address did, because something it depends on // changed. The classification lets a reviewer read the three edits without // scrolling past all forty-seven consequences. - private before(id: string): string { + // + // A `changed` entry names what changed ("doc", "body, calls", "claims"), so + // the line is a table of contents for the diffs below it rather than a bare + // verdict. The hash sees none of a doc edit and a reviewer would otherwise be + // left to find the one line that moved. + private before(id: string, d: Def, old: Def | undefined): string { const e = this.revs?.get(id); if (!e) return ""; const was = e.old_id && e.old_id !== id ? ` · was ${esc(e.old_id)}` : ""; const head = (note: string): string => `
${e.status}${note}${was}
`; + const moved = old ? this.changes(id, d, old) : []; + const fields = moved.length > 0 ? ` ${moved.join(", ")}` : ""; switch (e.status) { case "cone": - return head(" text unchanged; re-addressed because a dependency moved"); + return head( + ` text unchanged; re-addressed because a dependency moved${moved.length > 0 ? ` ·${fields}` : ""}`, + ); case "added": return head(" new in this revision"); case "removed": return head(" gone in this revision"); case "moved": - return head(" same bytes, new name"); + return head(` same bytes, new name${moved.length > 0 ? ` ·${fields}` : ""}`); + case "cosmetic": + return head(" same behavior, different text"); default: - return head(e.status === "cosmetic" ? " same behavior, different text" : " previously"); + return head(fields || " previously"); + } + } + + // The fields that differ between a definition's two revisions, in the order + // the card shows them, then the relation rows that moved. + private changes(id: string, d: Def, old: Def): string[] { + const out: string[] = []; + if (old.kind !== d.kind) out.push("kind"); + if ((old.vis ?? "private") !== (d.vis ?? "private")) out.push("visibility"); + if (!sameList(old.claims ?? [], d.claims ?? [])) out.push("claims"); + if ((old.deprecated ?? "") !== (d.deprecated ?? "")) out.push("deprecation"); + if ((old.ty ?? "") !== (d.ty ?? "")) out.push("signature"); + if ((old.effects ?? "") !== (d.effects ?? "")) out.push("effects"); + if ((old.doc ?? "") !== (d.doc ?? "")) out.push("doc"); + if (old.source !== d.source) out.push("body"); + for (const { kind, dir, label } of RELATIONS) { + const was = this.wasTargets(id, kind, dir); + if (was && !sameSet(was, this.targets(id, kind, dir))) out.push(label); + } + const members = this.memberUsers(id); + for (const [name, { now, was }] of members) { + if (was && !sameSet(was, now)) out.push(name); } + return out; } - // The definition's text, beside the other revision's when there is one. + // The definition's text, as a diff against the other revision's when there is + // one. + // + // A diff, not two bodies: the revisions are being compared, and two full texts + // side by side leave the reader to find the edit by eye, which on a body of any + // length is the work a diff exists to do. Lines are aligned by the shortest edit + // script and each edited line marks the words that moved, so a one-token change + // in a twelve-line body reads as one token (`viewer-diff.ts`). // - // Side by side rather than stacked: the two versions of a definition are being - // compared, and comparing means reading across, not scrolling. The left pane is - // painted and linked exactly like the right one, from the old revision's own + // Both sides are painted and linked exactly alike, each from its own revision's // occurrence rows. A name in the version you are moving away from is as worth // following as one in the version you are moving to, and the artifact carries // what it needs to do that. A target the old revision had and this one does not // keeps its text without becoming a link, the same rule every other reference // outside the index follows. - private sources(d: Def): string { - const old = this.revs?.get(d.id)?.old; - const now = `
${this.body(d)}
`; - if (!old || old.source === d.source) return now; - return `
-
-
before
-
${this.body(old)}
-
-
-
after
- ${now} -
-
`; + private sources(d: Def, old: Def | undefined, mode: Mode): string { + if (!old || old.source === d.source) { + return `
${this.body(d)}
`; + } + return this.fieldDiff( + `${d.id} source`, + mode, + old.source, + d.source, + (def, emph) => this.painted(def.source, this.marks(def), def.tokens, def, false, emph), + [old, d], + { heads: true }, + ); + } + + // One field of a definition, in both revisions, as a diff laid out by `mode`. + // + // `paint` renders one side's text, painted and linked where the artifact + // carries spans for it and escaped where it does not, with the words that moved + // marked. The same engine and the same rows serve the body, the signature, + // the effect row and the docstring: a reviewer reads them all, and a change + // to any of them is shown the same way. + private fieldDiff( + key: string, + mode: Mode, + oldText: string, + newText: string, + paint: (def: Def, emph: Range[]) => string, + [old, now]: [Def, Def], + opts: { heads?: boolean; cls?: string } = {}, + ): string { + const td = textDiff(oldText, newText); + const was = oldText === "" ? [] : paint(old, td.oldEmph).split("\n"); + const is = newText === "" ? [] : paint(now, td.newEmph).split("\n"); + return mode === "split" + ? this.splitDiff(key, td, was, is, opts) + : this.unifiedDiff(key, td, was, is, opts); + } + + // Two revisions of one text, side by side, line against line. + // + // Aligned rather than two independent panes: comparing means reading across, + // and reading across only works when the line on the left is the line the one + // on the right replaced. An edit that drops three lines and adds one pads the + // short side, so the rows stay level. Long runs of kept lines fold to their + // ends, since a body that is mostly unchanged is mostly not what the reader + // came for; the fold says how much it hides and opens on a click. + // + // `was` and `now` are the two texts painted, one fragment per line. + private splitDiff( + key: string, + td: TextDiff, + was: string[], + now: string[], + { heads = false, cls = "" }: { heads?: boolean; cls?: string }, + ): string { + const cell = ( + side: "old" | "new", + line: string | undefined, + mark: "" | "del" | "ins", + ): string => + line === undefined + ? `
` + : `
${line}
`; + let html = `
${ + heads ? '
before
after
' : "" + }`; + td.blocks.forEach((b, i) => { + if (b.kind === "change") { + for (let k = 0; k < Math.max(b.dels.length, b.inss.length); k++) { + html += cell("old", was[b.dels[k]], "del") + cell("new", now[b.inss[k]], "ins"); + } + return; + } + const fold = `${key} ${i}`; + const { head, hidden, tail } = folded(b.pairs, this.unfolded.has(fold)); + for (const [a, c] of head) html += cell("old", was[a], "") + cell("new", now[c], ""); + if (hidden.length > 0) html += foldRow(fold, hidden.length); + for (const [a, c] of tail) html += cell("old", was[a], "") + cell("new", now[c], ""); + }); + return `${html}
`; + } + + // Two revisions of one text, one above the other: what was dropped, then what + // was introduced, in place among the lines both have. Narrower than the split + // layout and the better fit for prose, and for a reader who moves down rather + // than across. + private unifiedDiff( + key: string, + td: TextDiff, + was: string[], + now: string[], + { cls = "" }: { heads?: boolean; cls?: string }, + ): string { + const cell = (line: string, mark: "" | "del" | "ins"): string => + `
${line}
`; + let html = `
`; + td.blocks.forEach((b, i) => { + if (b.kind === "change") { + for (const a of b.dels) html += cell(was[a], "del"); + for (const c of b.inss) html += cell(now[c], "ins"); + return; + } + const fold = `${key} ${i}`; + const { head, hidden, tail } = folded(b.pairs, this.unfolded.has(fold)); + for (const [, c] of head) html += cell(now[c], ""); + if (hidden.length > 0) html += foldRow(fold, hidden.length); + for (const [, c] of tail) html += cell(now[c], ""); + }); + return `${html}
`; } // The definition's own text, with every name that resolves to a definition @@ -833,16 +1016,62 @@ class Viewer { // signature are the same colour and the same link they are in a body, which is // the point: the signature is the part a reader reads first. It leads with the // name it types, so the line reads as the declaration a reader would write. - private signature(d: Def): string { + // + // Against the other revision, when the type moved, it is the two types as a + // diff: a widened effect row or a new parameter is the first thing a reviewer + // of a changed function needs, and the two types share most of their text. + private signature(d: Def, old: Def | undefined, mode: Mode): string { + if (old && (old.ty ?? "") !== (d.ty ?? "")) { + return `
${this.fieldDiff( + `${d.id} signature`, + mode, + old.ty ?? "", + d.ty ?? "", + (def, emph) => + this.painted(def.ty ?? "", def.ty_refs ?? [], def.ty_tokens, def, false, emph), + [old, d], + )}
`; + } if (!d.ty) return ""; return `
${esc(d.name)} : ${this.painted(d.ty, d.ty_refs ?? [], d.ty_tokens, d, true)}
`; } - private effectRow(d: Def): string { + private effectRow(d: Def, old: Def | undefined, mode: Mode): string { + if (old && (old.effects ?? "") !== (d.effects ?? "")) { + return `
effects
${this.fieldDiff( + `${d.id} effects`, + mode, + old.effects ?? "", + d.effects ?? "", + (def, emph) => + this.painted(def.effects ?? "", def.eff_refs ?? [], def.eff_tokens, def, false, emph), + [old, d], + )}
`; + } if (!d.effects) return ""; return `
effects ${this.painted(d.effects, d.eff_refs ?? [], d.eff_tokens, d, true)}
`; } + // The docstring, rendered; or, when the other revision's differs, the two as + // a diff of their text. A diff of the rendering would have to mark words + // inside paragraphs and fences it also has to lay out, and the text is what + // the author edited: the lines as written, with the words that moved marked, + // is the honest view and the one every other diff tool shows for prose. + private docs(d: Def, old: Def | undefined, mode: Mode): string { + if (old && (old.doc ?? "") !== (d.doc ?? "")) { + return `
${this.fieldDiff( + `${d.id} doc`, + mode, + old.doc ?? "", + d.doc ?? "", + (def, emph) => this.painted(def.doc ?? "", [], undefined, def, false, emph), + [old, d], + { cls: " card-diff--prose" }, + )}
`; + } + return d.doc ? `
${renderDoc(d.doc)}
` : ""; + } + // Paint one text with its highlight spans and wrap its references in links. // // `brief` drops the module from a qualified name, so `Data.Vec.Vec(a, 0)` reads as @@ -855,14 +1084,54 @@ class Viewer { // since that is exactly the ambiguity the qualification exists to resolve. No // signature in the standard library does, across 1108 qualified names, but a // corpus property is not a guarantee. + // + // `emph` marks the parts of the text a revision pair moved (see `sources`). + // They are the innermost layer: a mark wraps only raw text, inside whatever + // token and reference it falls in, so it can never cross either's markup. + // + // A newline is never inside a tag. A token that spans lines (a multi-line + // string, a block comment) is painted once per line, so the result splits on + // `\n` into one well-formed fragment per line, which is what lets a diff lay + // the lines of two revisions beside each other without painting twice. private painted( text: string, marks: Mark[], packed: string | undefined, d: Def, brief = false, + emph: Range[] = [], ): string { const spans = decodeSpans(packed, this.index.tokenClasses); + // One slice of text, escaped, in its token class, with the emphasised parts + // marked and every line break left bare between tags. + let em = 0; + const chunk = (from: number, to: number, cls: string | null): string => { + let html = ""; + let pos = from; + while (em < emph.length && emph[em][1] <= from) em++; + const piece = (lo: number, hi: number, marked: boolean): void => { + html += text + .slice(lo, hi) + .split("\n") + .map((line) => { + let h = esc(line); + if (line && marked) h = `${h}`; + if (line && cls) h = `${h}`; + return h; + }) + .join("\n"); + }; + for (let i = em; i < emph.length && emph[i][0] < to; i++) { + const lo = Math.max(emph[i][0], pos); + const hi = Math.min(emph[i][1], to); + if (hi <= lo) continue; + if (lo > pos) piece(pos, lo, false); + piece(lo, hi, true); + pos = hi; + } + if (pos < to) piece(pos, to, false); + return html; + }; const distinct = new Map(); if (brief) { for (const m of marks) { @@ -884,11 +1153,11 @@ class Viewer { const from = Math.max(spans[i].start, lo); const to = Math.min(spans[i].end, hi); if (to <= from) continue; - html += esc(text.slice(pos, from)); - html += `${esc(text.slice(from, to))}`; + html += chunk(pos, from, null); + html += chunk(from, to, spans[i].cls); pos = to; } - return html + esc(text.slice(pos, hi)); + return html + chunk(pos, hi, null); }; let html = ""; @@ -912,12 +1181,12 @@ class Viewer { if (r.ty !== undefined) { // Hoverable, not navigable: a local binds here and leads nowhere. const tip = `${text.slice(r.start, r.end)}\n${r.ty}`; - html += `${name}`; + html += `${name}`; continue; } if (r.member !== undefined) { const users = this.members.users(d.id, r.member); - const tip = esc(this.aboutMember(d, r.member)); + const tip = attr(this.aboutMember(d, r.member)); // A link only when there is somewhere to go. A member nothing uses still // says what it is on hover, but an underline promising a destination that // does not exist is worse than plain text. @@ -931,7 +1200,7 @@ class Viewer { // Prism definition because it is implemented in the compiler, which is a // different fact from a name this artifact happens not to cover. It still // leads somewhere, to the builtin's own synthesized card. - const tip = `data-tip="${esc(this.index.describe(r.target))}"`; + const tip = `data-tip="${attr(this.index.describe(r.target))}"`; switch (this.index.classify(r.target)) { case "definition": html += ``; @@ -979,12 +1248,12 @@ class Viewer { // synthesized card; a target the index genuinely does not cover is rendered as // plain text rather than a dead link, so it reads as leaving the index instead // of looking broken. - private relations(id: string): string { + private relations(id: string, mode: Mode): string { // Edges first, members after. On a type the member rows are the heaviest thing // on the card. `Option` has 127 uses of `None` and 135 of `Some`, and leading // with them buries the summary of what the definition relates to under the // detail of who writes each of its parts. - return this.edgeRows(id) + this.memberRows(id); + return this.edgeRows(id, mode) + this.memberRows(id, mode); } // One row per member of this declaration that anything uses: who writes `pure`, @@ -996,26 +1265,48 @@ class Viewer { // occurrence set, read from the far end. A reference to a member resolves to // the declaration that owns it, and the span it covers says which member was // meant. Without this a class card can list its instances and nothing else. - private memberRows(id: string): string { - return this.members - .of(id) - .map(([name, users]) => + private memberRows(id: string, mode: Mode): string { + return [...this.memberUsers(id)] + .map(([name, { now, was }]) => this.row({ key: `${id} member ${name}`, label: `${esc(name)}`, hint: `definitions that write \`${name}\`, a member of this declaration`, - targets: users, + targets: now, + was, + mode, attrs: ` rel--member" data-uses="${esc(name)}`, }), ) .join(""); } - private edgeRows(id: string): string { + // Each member of a declaration with its users, in this revision and, when the + // card has the other revision to compare with, in that one. A member only the + // old revision had is here with no current users, so its row can show them + // going. + private memberUsers(id: string): Map { + const out = new Map(); + for (const [name, users] of this.members.of(id)) out.set(name, { now: users }); + const oldId = this.oldId(id); + if (oldId === undefined || !this.was) return out; + for (const [name, users] of this.was.members.of(oldId)) { + const at = out.get(name) ?? { now: [] }; + at.was = users; + out.set(name, at); + } + // A member the old revision did not have had no users then, and its row + // should say so: every user is arriving. + for (const at of out.values()) at.was ??= []; + return new Map([...out].sort((a, b) => a[0].localeCompare(b[0]))); + } + + private edgeRows(id: string, mode: Mode): string { return RELATIONS.map(({ kind, dir, label, hint }) => { - const edges = this.rel.get(kind, dir, id); + const targets = this.targets(id, kind, dir); + const was = this.wasTargets(id, kind, dir); if (kind !== "calls") { - return this.row({ key: `${id} ${kind} ${dir}`, label, hint, targets: edges }); + return this.row({ key: `${id} ${kind} ${dir}`, label, hint, targets, was, mode }); } // The call rows lead with what the source names, in the order it names them, // and then with what the dependency graph adds. The two are not the same set: @@ -1025,17 +1316,83 @@ class Viewer { // A chip is marked when it is only the second, since a name appearing in // a row and nowhere in the body it belongs to reads as a bug. const written = this.mentions.get(dir, id); - const derived = edges.filter((t) => !written.includes(t)); + const oldId = this.oldId(id); + const wasWritten = oldId === undefined ? [] : (this.was?.mentions.get(dir, oldId) ?? []); return this.row({ key: `${id} ${kind} ${dir}`, label, hint: `${hint}; a dotted chip is reached through elaboration rather than named here`, - targets: [...written, ...derived], - derived: new Set(derived), + targets, + was, + mode, + derived: new Set(targets.filter((t) => !written.includes(t))), + wasDerived: new Set((was ?? []).filter((t) => !wasWritten.includes(t))), }); }).join(""); } + // One relation row's targets in this revision: the edges, led for the call + // rows by what the source names (see `edgeRows`). + private targets(id: string, kind: EdgeKind, dir: "in" | "out"): string[] { + const edges = this.rel.get(kind, dir, id); + if (kind !== "calls") return edges; + const written = this.mentions.get(dir, id); + return [...written, ...edges.filter((t) => !written.includes(t))]; + } + + // The same row in the other revision, or `undefined` when the pair cannot + // say: no diff is loaded, this definition has no other revision, or the + // artifact predates the edge delta. The same lookups over the old graph, + // keyed by the name the definition had then. + private wasTargets(id: string, kind: EdgeKind, dir: "in" | "out"): string[] | undefined { + const oldId = this.oldId(id); + if (oldId === undefined || !this.was) return undefined; + const edges = this.was.rel.get(kind, dir, oldId); + if (kind !== "calls") return edges; + const written = this.was.mentions.get(dir, oldId); + return [...written, ...edges.filter((t) => !written.includes(t))]; + } + + // What the definition was called in the other revision, when it has one. + private oldId(id: string): string | undefined { + const e = this.revs?.get(id); + return e?.old && this.index.byId.has(id) ? (e.old_id ?? id) : undefined; + } + + /// Lay every card's diffs out split or unified. Remembered, and it resets any + /// card that had departed from the page's setting: the page-wide control is + /// the reader saying how they want to read, and a card's override was only + /// ever relative to the previous answer. + setMode(mode: Mode): void { + this.mode = mode; + this.modeOf.clear(); + this.review.setDiffMode(mode); + this.reflectMode(); + this.render(); + } + + /// Lay one card's diffs out its own way, for this visit. + setCardMode(id: string, mode: Mode): void { + this.modeOf.set(id, mode); + this.render(); + } + + private layout(id: string): Mode { + return this.modeOf.get(id) ?? this.mode; + } + + private reflectMode(): void { + for (const b of this.nodes.mode.querySelectorAll("[data-mode]")) { + b.setAttribute("aria-pressed", String(b.dataset.mode === this.mode)); + } + } + + /// Open a folded run of unchanged lines in a diff. + unfold(key: string): void { + this.unfolded.add(key); + this.render(); + } + /// Show a row in full rather than capped. widen(key: string): void { if (!this.wide.delete(key)) this.wide.add(key); @@ -1054,42 +1411,105 @@ class Viewer { label: string; hint: string; targets: string[]; + /// The same row in the other revision, when the pair can say. + was?: string[]; + mode?: Mode; attrs?: string; /// Targets the dependency graph reports that the source does not name. derived?: Set; + /// The same classification in the old revision. + wasDerived?: Set; }): string { - const { key, label, hint, targets, attrs = "", derived } = spec; - if (targets.length === 0) return ""; + const { + key, + label, + hint, + targets, + was, + mode = "split", + attrs = "", + derived, + wasDerived, + } = spec; + if (targets.length === 0 && !was?.length) return ""; + const head = (count: string): string => + `${label} + ${count}`; + // The row as it is, when there is nothing to compare with or the two + // revisions agree. + if (!was || sameSet(was, targets)) { + return `
${head(String(targets.length))} +
${this.chips(key, targets, derived)}
`; + } + // The row moved. Split: what it was beside what it is, each side marking + // what the other lacks. Unified: one row, what it is with what arrived + // marked, and what left struck through at the end. + const count = `${was.length} → ${targets.length}`; + if (mode === "split") { + return `
${head(count)} +
${this.chips(`${key} was`, was, wasDerived, (t) => (targets.includes(t) ? "" : "del"))}
+
${this.chips(key, targets, derived, (t) => (was.includes(t) ? "" : "ins"))}
`; + } + const gone = was.filter((t) => !targets.includes(t)); + const unifiedDerived = new Set([...(derived ?? []), ...gone.filter((t) => wasDerived?.has(t))]); + return `
${head(count)} +
${this.chips(key, [...targets, ...gone], unifiedDerived, (t) => + gone.includes(t) ? "del" : was.includes(t) ? "" : "ins", + )}
`; + } + + // The chips of one row, capped (see `row`), each marked by `mark` as staying, + // arriving or leaving. + private chips( + key: string, + targets: string[], + derived: Set | undefined, + mark: (t: string) => "" | "del" | "ins" = () => "", + ): string { const all = this.wide.has(key); - const shown = all ? targets : targets.slice(0, CHIPS); - const chips = shown - .map((t) => { - const only = derived?.has(t) ?? false; - const why = only - ? "\nreached through elaboration, not named in this source: a constant is inlined, an instance method is lifted out" - : ""; - const tip = `data-tip="${esc(this.index.describe(t) + why)}"`; - const mark = only ? " chip--derived" : ""; - switch (this.index.classify(t)) { - case "definition": - return ``; - case "builtin": { - const to = this.index.primitive(t)?.name ?? t; - return ``; - } - default: - return `${esc(short(t))}`; - } - }) - .join(""); + // The cap never hides a change: a chip that arrived or left is the row's + // point, and "+1 more" over the one that moved would be the row saying + // nothing. + const shown = all ? targets : targets.filter((t, i) => i < CHIPS || mark(t) !== ""); + const chips = shown.map((t) => this.chip(t, derived?.has(t) ?? false, mark(t))).join(""); const rest = targets.length - shown.length; const more = rest > 0 || all ? `` : ""; - return `
${label} - ${targets.length} -
${chips}${more}
`; + return chips + more; + } + + // One chip. A target names a definition, a builtin, or something outside the + // index; and a chip from the other revision may name a definition by a name + // this revision no longer has. A renamed one leads to its new name, since the + // diff knows the rename as a fact; a removed one leads to the card its old + // record still opens, since a reviewer wants to see what went. + private chip(t: string, derived: boolean, mark: "" | "del" | "ins"): string { + const why = derived + ? "\nreached through elaboration, not named in this source: a constant is inlined, an instance method is lifted out" + : ""; + const cls = `chip${derived ? " chip--derived" : ""}${mark ? ` is-${mark}` : ""}`; + const name = esc(short(t)); + const to = this.revs?.movedTo.get(t); + if (to !== undefined) { + const tip = `data-tip="${esc(`${this.index.describe(to)}\nwas ${t}${why}`)}"`; + return ``; + } + const tip = `data-tip="${esc(this.index.describe(t) + why)}"`; + switch (this.index.classify(t)) { + case "definition": + return ``; + case "builtin": { + const prim = this.index.primitive(t)?.name ?? t; + return ``; + } + default: + if (this.revs?.get(t)?.status === "removed") { + return ``; + } + return `${name}`; + } } } @@ -1192,6 +1612,11 @@ const brokenModules = (modules: IndexModule[]): string => { const short = (id: string): string => id.split(/[.@]/).at(-1) ?? id; const shortName = short; +// The row standing in for a folded run of unchanged lines: how many, and an +// invitation. Spans both columns of a split diff. +const foldRow = (key: string, n: number): string => + ``; + const hashChip = (d: Def): string => d.hash ? `` @@ -1222,12 +1647,65 @@ export const KINDS: Record = { // nothing: `T`, `C`, `I` and a bare `!` are four different guesses, and three of // the thirteen kinds wanted `!` at once. The word costs a few pixels and needs no // legend; the tooltip carries the sentence the word still leaves out. -const kindBadge = (kind: string): string => { +const kindBadge = (kind: string, mark: "" | "del" | "ins" = ""): string => { const k = KINDS[kind]; const tip = k ? `${k.label}: ${k.gloss}` : kind; - return `${esc(k?.label ?? kind)}`; + return `${esc(k?.label ?? kind)}`; +}; + +// The header tags, each against the other revision's when the card has one: a +// tag the old revision had and this one lacks is struck through, one this +// revision gained is marked as arriving. A claims edit (`total` to `assume +// total`) moves no hashed byte and no line of source, and this is where it +// shows. +const visTags = (d: Def, old: Def | undefined): string => { + const now = d.vis ?? "private"; + const was = old ? (old.vis ?? "private") : now; + const tag = (vis: string, mark: "" | "del" | "ins"): string => + vis === "private" && !mark + ? "" + : `${vis === "public" ? "pub" : esc(vis)}`; + return was === now ? tag(now, "") : tag(was, "del") + tag(now, "ins"); }; +const claimTags = (d: Def, old: Def | undefined): string => { + const now = d.claims ?? []; + const was = old ? (old.claims ?? []) : now; + const tag = (c: string, mark: "" | "del" | "ins"): string => + `${esc(c)}`; + return ( + was + .filter((c) => !now.includes(c)) + .map((c) => tag(c, "del")) + .join("") + now.map((c) => tag(c, was.includes(c) ? "" : "ins")).join("") + ); +}; + +const deprecatedTags = (d: Def, old: Def | undefined): string => { + const now = d.deprecated; + const was = old ? old.deprecated : now; + const tag = (why: string, mark: "" | "del" | "ins"): string => + `deprecated`; + if (was === now) return now ? tag(now, "") : ""; + return (was ? tag(was, "del") : "") + (now ? tag(now, "ins") : ""); +}; + +// The split/unified control: on the page header for every card, and on a card +// for itself. Two buttons rather than one that toggles, so the current layout +// is read off it and not inferred from what clicking did. +const modeToggle = (mode: Mode, card?: string): string => { + const own = card === undefined ? "" : ` data-card-mode="${esc(card)}"`; + const btn = (m: Mode, tip: string): string => + ``; + return `${btn("split", "old beside new")}${btn("unified", "old above new")}`; +}; + +const sameList = (a: string[], b: string[]): boolean => + a.length === b.length && a.every((x, i) => x === b[i]); + +const sameSet = (a: string[], b: string[]): boolean => + a.length === b.length && a.every((x) => b.includes(x)); + // The badge naming a compiler primitive, wherever one stands in for a kind // badge. One word for all three primitive kinds: the signature says the rest, // and "builtin" is the fact that distinguishes the row from every definition @@ -1345,6 +1823,18 @@ function wireNavigation(viewer: Viewer): void { viewer.widen(wide.dataset.wide ?? ""); return; } + const unfold = target?.closest("[data-unfold]"); + if (unfold) { + viewer.unfold(unfold.dataset.unfold ?? ""); + return; + } + const mode = target?.closest("[data-mode]"); + if (mode) { + const m: Mode = mode.dataset.mode === "unified" ? "unified" : "split"; + if (mode.dataset.cardMode !== undefined) viewer.setCardMode(mode.dataset.cardMode, m); + else viewer.setMode(m); + return; + } const mod = target?.closest("[data-mod]"); if (mod) { viewer.toggleModule(mod.dataset.mod ?? ""); @@ -1415,6 +1905,7 @@ async function boot(): Promise { rail: el("rail"), main: el("viewer-main"), railToggle: el("rail-toggle"), + mode: el("diff-mode"), }, globalThis.localStorage ?? null, // Marks are namespaced by where the artifact lives *and* what it calls diff --git a/web/test/build.mjs b/web/test/build.mjs index 83cc82d..758999d 100644 --- a/web/test/build.mjs +++ b/web/test/build.mjs @@ -18,7 +18,7 @@ const src = join(here, "..", "src"); const out = join(here, "build"); mkdirSync(out, { recursive: true }); -const MODULES = ["viewer-model", "viewer-review", "viewer-context", "viewer"]; +const MODULES = ["viewer-model", "viewer-diff", "viewer-review", "viewer-context", "viewer"]; for (const name of MODULES) { const adapted = readFileSync(join(src, `${name}.ts`), "utf8") diff --git a/web/test/dom.mjs b/web/test/dom.mjs index 0507ae3..9115e9b 100644 --- a/web/test/dom.mjs +++ b/web/test/dom.mjs @@ -70,6 +70,7 @@ export function nodes() { rail: new El("nav"), main: new El("main"), railToggle: new El("button"), + mode: new El("span"), }; } @@ -140,6 +141,15 @@ export function card(html, id) { return at ? at.split("
")[0] : ""; } +/// The cells of one side of a split diff, concatenated. +export function column(html, side) { + return [ + ...html.matchAll(new RegExp(`
(.*?)
`, "gs")), + ] + .map((m) => m[1]) + .join("\n"); +} + export function done() { console.log(failures === 0 ? "\nALL CHECKS PASSED" : `\n${failures} CHECK(S) FAILED`); process.exit(failures === 0 ? 0 : 1); diff --git a/web/test/viewer.mjs b/web/test/viewer.mjs index 54ac4af..3306634 100644 --- a/web/test/viewer.mjs +++ b/web/test/viewer.mjs @@ -5,7 +5,17 @@ import { readFileSync } from "node:fs"; -import { card, check, done, installGlobals, nodes, plain, Storage, section } from "./dom.mjs"; +import { + card, + check, + column, + done, + installGlobals, + nodes, + plain, + Storage, + section, +} from "./dom.mjs"; installGlobals(); @@ -13,6 +23,7 @@ const { Index, Members, Mentions, Relations, Revisions } = await import("./build const { Review, freshness, needsAttention } = await import("./build/viewer-review.mjs"); const { packet } = await import("./build/viewer-context.mjs"); const { KINDS, Viewer, isEditable, renderDoc } = await import("./build/viewer.mjs"); +const { blocks, diff, textDiff } = await import("./build/viewer-diff.mjs"); const { decodeSpans } = await import("./build/viewer-model.mjs"); const index = new Index(JSON.parse(readFileSync("public/stdlib-index.json", "utf8"))); @@ -507,7 +518,7 @@ check( ); const painted2 = viewer.body(typed); check("a local is hoverable but not a link", painted2.includes('class="ref ref--local"')); -check("carrying its type", /data-tip="xs\nList\(/.test(painted2), painted2.slice(0, 120)); +check("carrying its type", /data-tip="xs List\(/.test(painted2), painted2.slice(0, 120)); // Nothing claims a span twice: a reference's tooltip already names the definition's // own type, which is the better answer where there is one. const claimed = decodeSpans(typed.types, index.typeTable).filter((s) => @@ -738,16 +749,22 @@ pair.show("Data.List.map"); const changed = card(pair.nodes.cards.innerHTML, "Data.List.map"); check("a changed card says so", changed.includes('class="status status--changed">changed<')); check("and on a changed one", changed.includes("data-reviewed")); -// Side by side, and the left pane is painted and linked like the right one. -check("the two revisions sit side by side", changed.includes('class="card-diff"')); +// Side by side as a diff, and the left side is painted and linked like the right. +check("the two revisions sit side by side", changed.includes('class="card-diff card-diff--split"')); check( "before on the left, after on the right", changed.indexOf(">before<") < changed.indexOf(">after<"), ); -const before = changed.split("card-src--was")[1].split("")[0]; check( "and carries what the other revision had", - plain(before).includes("fn map(f, xs) = previously"), + plain(column(changed, "old")).includes("fn map(f, xs) = previously"), +); +check("the old line is marked dropped", /class="dl dl--old is-del"/.test(changed)); +check("the new lines are marked introduced", /class="dl dl--new is-ins"/.test(changed)); +check( + "and the old column still links its names", + column(changed, "old").includes('data-goto="Data.List.map"') || + column(changed, "old").includes('class="ref'), ); // A removed definition exists only on the diff's old side, and its review row @@ -882,6 +899,485 @@ check( JSON.stringify(moved.source.slice(moved.refs[0].start, moved.refs[0].end)), ); +section("a revision pair is shown as a diff"); +// The engine: the shortest edit script, grouped into edits, with the words that +// moved inside each edited line. +const ops = diff(["a", "b", "c", "d"], ["a", "x", "c", "d", "e"]); +check( + "the edit script keeps what is shared and edits the rest", + JSON.stringify(ops) === + JSON.stringify([ + { kind: "eq", a: 0, b: 0 }, + { kind: "del", a: 1 }, + { kind: "ins", b: 1 }, + { kind: "eq", a: 2, b: 2 }, + { kind: "eq", a: 3, b: 3 }, + { kind: "ins", b: 4 }, + ]), + JSON.stringify(ops), +); +check( + "edits are grouped, drops before introductions", + JSON.stringify(blocks(ops)) === + JSON.stringify([ + { kind: "eq", pairs: [[0, 0]] }, + { kind: "change", dels: [1], inss: [1] }, + { + kind: "eq", + pairs: [ + [2, 2], + [3, 3], + ], + }, + { kind: "change", dels: [], inss: [4] }, + ]), +); +check( + "a text against itself has no edits", + textDiff("a\nb", "a\nb").blocks.every((b) => b.kind === "eq"), +); +const fromEmpty = textDiff("", "a\nb"); +check( + "an empty old side is all introduction", + fromEmpty.blocks.length === 1 && + fromEmpty.blocks[0].kind === "change" && + fromEmpty.blocks[0].dels.length === 0 && + fromEmpty.blocks[0].inss.length === 2, +); +const td = textDiff(" let x = foo(a, b)\n x", " let x = bar(a, b)\n x"); +check( + "an edited line marks only the word that moved", + td.oldEmph.length === 1 && + " let x = foo(a, b)".slice(td.oldEmph[0][0], td.oldEmph[0][1]) === "foo" && + td.newEmph.length === 1 && + " let x = bar(a, b)".slice(td.newEmph[0][0], td.newEmph[0][1]) === "bar", + JSON.stringify([td.oldEmph, td.newEmph]), +); +check( + "a line replaced outright marks nothing inside it", + textDiff("fn f(x) = x + 1", "-- entirely different now").oldEmph.length === 0, +); +// Myers on a larger pair: every old line is either kept or dropped exactly +// once, and likewise every new line, so the script is a bijection. +{ + const a = Array.from({ length: 120 }, (_, i) => `line ${i}`); + const b = a.filter((_, i) => i % 7 !== 3).map((l, i) => (i % 11 === 5 ? `${l}!` : l)); + b.splice(40, 0, "new 1", "new 2"); + const script = diff(a, b); + const seenA = new Set(); + const seenB = new Set(); + for (const op of script) { + if ("a" in op) seenA.add(op.a); + if ("b" in op) seenB.add(op.b); + } + check( + "the script covers both sides exactly once", + seenA.size === a.length && + seenB.size === b.length && + script.filter((o) => "a" in o).length === a.length && + script.filter((o) => "b" in o).length === b.length, + ); +} +// A wholly rewritten large middle falls back before Myers' retained trace can +// grow quadratically, while preserving the useful common ends. +{ + const a = ["shared head", ...Array.from({ length: 1_200 }, (_, i) => `old ${i}`), "shared tail"]; + const b = ["shared head", ...Array.from({ length: 1_200 }, (_, i) => `new ${i}`), "shared tail"]; + const script = diff(a, b); + check( + "a large unrelated middle keeps its common boundaries", + script[0]?.kind === "eq" && + script.at(-1)?.kind === "eq" && + script.filter((o) => o.kind === "del").length === 1_200 && + script.filter((o) => o.kind === "ins").length === 1_200, + ); +} + +// The rendering: a real definition edited in one place. Its old column is the +// old text and its new column the new, each painted; the edited line carries +// the moved word as a mark; the long unchanged run is folded and opens. +const long = index.defs.find( + (d) => d.source.split("\n").length >= 24 && (d.refs ?? []).some((r) => r.end < 200), +); +const lines = long.source.split("\n"); +// A line introduced in the middle, and a word appended to the last line. The +// new revision keeps only the spans that lie before the insertion, since the +// rest would drift; what is checked is the text, which painting preserves. +const cut = lines.slice(0, 12).join("\n").length; +const edited = { + ...long, + source: [ + ...lines.slice(0, 12), + " -- a new line", + ...lines.slice(12, -1), + `${lines.at(-1)} -- trailing`, + ].join("\n"), + tokens: "", + types: "", + refs: (long.refs ?? []).filter((r) => r.end <= cut), + members: (long.members ?? []).filter((m) => m.end <= cut), +}; +const oldRev = { ...long }; +const diffDeck = new Viewer( + index, + new Revisions({ + envelope: { + format: "prism-index-diff-v1", + old: { title: "t", contract: "aaaa" }, + new: { title: "t", contract: "bbbb" }, + counts: { changed: 1, added: 0, removed: 0, moved: 0, cone: 0, cosmetic: 0, unchanged: 9 }, + }, + entries: [{ status: "changed", id: long.id, old: oldRev, new: edited }], + }), + nodes(), + new Storage(), +); +// The loaded index holds the new revision. +index.byId.set(long.id, edited); +diffDeck.start(); +diffDeck.show(long.id); +let diffCard = card(diffDeck.nodes.cards.innerHTML, long.id); +const cells = (html, side) => + [ + ...html.matchAll( + new RegExp(`
(?:(.*?))?
`, "gs"), + ), + ].map((m) => m[1]); +check( + "the inserted line is introduced on the right only", + cells(diffCard, "new").some((c) => c !== undefined && plain(c) === " -- a new line") && + !cells(diffCard, "old").some((c) => c !== undefined && plain(c) === " -- a new line"), +); +check("and padded on the left", /dl--old dl--pad/.test(diffCard)); +check( + "the edited last line marks what was appended", + /is-ins">.*[^<]*trailing<\/mark>/.test(diffCard), +); +const folds = [...diffCard.matchAll(/data-unfold="([^"]+)"[^>]*>⋯ \d+ unchanged lines/g)]; +check("a long unchanged run is folded", folds.length > 0); +for (const [, key] of folds) diffDeck.unfold(key); +diffCard = card(diffDeck.nodes.cards.innerHTML, long.id); +check("and opens on request", !diffCard.includes("unchanged lines")); +check( + "opened, the old column is the old text", + cells(diffCard, "old") + .filter((c) => c !== undefined) + .map(plain) + .join("\n") === long.source, +); +check( + "and the new column is the new text", + cells(diffCard, "new") + .filter((c) => c !== undefined) + .map(plain) + .join("\n") === edited.source, +); +check( + "links survive on both sides", + cells(diffCard, "old").join("").includes('class="ref"') && + cells(diffCard, "new").join("").includes('class="ref"'), +); +index.byId.set(long.id, long); + +// A token that spans lines (a multi-line string) is painted once per line, so +// each line of the body is a well-formed fragment on its own. None in the +// standard library does, so the case is built: a three-line string literal. +{ + const str = index.tokenClasses.indexOf("str"); + const kw = index.tokenClasses.indexOf("kw"); + const source = 'fn f() =\n "one\ntwo\nthree"\n'; + const spanning = { ...oldOnly, source, tokens: `0 2 ${kw} 9 15 ${str}`, refs: [], members: [] }; + const painted = viewer.body(spanning).split("\n"); + const balanced = (h) => (h.match(//g) ?? []).length; + check( + "a token spanning lines closes at each line end", + painted.length === source.split("\n").length && + painted.every(balanced) && + painted[2] === 'two', + JSON.stringify(painted), + ); + check("and the text is still exact", plain(painted.join("\n")) === source); +} + +section("every field of a revision pair reads as a diff"); +// A definition whose every review-facing fact moved: visibility, claims, +// deprecation, signature, doc and body, and, through the edge delta, what it +// relates to. The old side is the record as it was; the index holds the new. +const mapNow = index.byId.get("Data.List.map"); +const mapWas = { + ...mapNow, + vis: "private", + claims: ["total"], + deprecated: "use map2", + ty: "forall e0 a b. ((b) -> a ! {e0}, List(b)) -> List(b) ! {e0}", + doc: mapNow.doc.replace( + "Apply `f` to every element, preserving order and length.", + "Apply `f` to every element.", + ), + source: `${mapNow.source}\n -- gone`, +}; +const fieldDeck = new Viewer( + index, + new Revisions({ + envelope: { + format: "prism-index-diff-v1", + old: { title: "t", contract: "aaaa" }, + new: { title: "t", contract: "bbbb" }, + counts: { changed: 1, added: 0, removed: 1, moved: 1, cone: 0, cosmetic: 0, unchanged: 9 }, + }, + entries: [ + { status: "changed", id: "Data.List.map", old: mapWas }, + { status: "removed", id: "Data.List.gone", old: { ...oldOnly, id: "Data.List.gone" } }, + { + status: "moved", + id: "Data.List.reverse", + old_id: "Data.List.rev", + old: index.byId.get("Data.List.reverse"), + }, + ], + // `map` used to call the removed definition and the renamed one, and did not + // yet type-mention `List`. + edges: { + removed: [ + { kind: "calls", from: "Data.List.map", to: "Data.List.gone" }, + { kind: "calls", from: "Data.List.map", to: "Data.List.rev" }, + ], + added: [{ kind: "uses-type", from: "Data.List.map", to: "List" }], + }, + }), + nodes(), + new Storage(), +); +fieldDeck.start(); +fieldDeck.show("Data.List.map"); +let fieldCard = card(fieldDeck.nodes.cards.innerHTML, "Data.List.map"); +check("the page offers the layout control", fieldDeck.nodes.mode.hidden === false); +check( + "and so does the card", + /data-mode="split" data-card-mode="Data.List.map" aria-pressed="true"/.test(fieldCard), +); +check( + "the status line names every field that moved", + /status--changed">changed<\/span> visibility, claims, deprecation, signature, doc, body, calls, types[^<]*/)?.[0], +); +check( + "visibility: the old tag struck, the new marked", + fieldCard.includes('class="tag tag--pub is-del">private') && + fieldCard.includes('class="tag tag--pub is-ins">pub'), +); +check( + "a claim that left is struck", + fieldCard.includes('class="tag tag--claim is-del">total'), +); +check( + "a deprecation that was lifted is struck", + /tag--dep is-del" data-tip="deprecated: use map2"/.test(fieldCard), +); +check("the signature is a diff", fieldCard.includes('class="card-sig card-sig--diff"')); +check( + "marking the type that moved", + /card-sig--diff.*is-del">.*b<\/mark>/s.test(fieldCard) && + /card-sig--diff.*is-ins">.*a<\/mark>/s.test(fieldCard), +); +check("the doc is a diff of its text", fieldCard.includes('class="card-doc card-doc--diff"')); +check( + "in the reading face, with the words that moved marked", + /card-diff--split card-diff--prose.*is-ins">Apply `f` to every element, preserving order and length<\/mark>\./s.test( + fieldCard, + ), +); +check("the body is a diff", /dl--old is-del">.*-- gone/.test(fieldCard)); +// The relation rows: the old graph is rebuilt from the new one and the delta, +// and a row that moved shows both sides. +const callsRow = fieldCard + .split('data-tip="definitions this body calls')[1] + .split("")[0]; +check( + "a moved relation row shows old beside new", + callsRow.includes('class="rel-chips rel-chips--old"'), +); +check( + "a target the old revision called and this one does not is struck", + /class="chip chip--derived is-del" data-goto="Data.List.gone" data-tip="Data.List.gone\nremoved in this revision[^"]*">gonerevcalls\s*2 → 0<\/span>/.test(callsRow), +); +const typesRow = fieldCard + .split('data-tip="types this signature mentions"')[1] + .split("")[0]; +check("a target that arrived is marked", /class="chip is-ins" data-goto="List"/.test(typesRow)); +const mapCallers = rel.get("calls", "in", "Data.List.map").length; +check( + "a row the two revisions agree on reads as before", + new RegExp( + `data-tip="definitions whose body calls this[^"]*">callers\\s*${mapCallers}`, + ).test(fieldCard), +); + +// Unified: one row per relation, what left struck at the end; one column for +// the text diffs. Chosen for one card, then for the page. +fieldDeck.setCardMode("Data.List.map", "unified"); +fieldCard = card(fieldDeck.nodes.cards.innerHTML, "Data.List.map"); +check( + "a card can choose its own layout", + /data-mode="unified" data-card-mode="Data.List.map" aria-pressed="true"/.test(fieldCard), +); +check("the body is then one column", fieldCard.includes('class="card-diff card-diff--unified"')); +check( + "and a relation row one row, with what left at its end", + /class="chip is-ins" data-goto="List".*class="chip is-del" data-goto="Data.List.gone"/s.test( + fieldCard, + ) === false && + /rel-chips">.* + + Gallery Playground Viewer