/**
* Word-level highlighting inside a changed line pair.
*
* Line diffs tell you *that* a line changed; they make you find *what*
* changed yourself. When a removal is followed by an addition, the two are
* almost always one edit, and marking the handful of tokens that actually
* moved is the difference between reading a diff and scanning one.
*/
import type { DiffRow } from "./diff.ts";
export interface Span {
text: string;
/** True when this token is part of what actually changed. */
changed: boolean;
}
/** Splits on word boundaries but keeps the separators, so joins are lossless. */
function tokenize(line: string): string[] {
return line.match(/(\w+|\s+|[^\w\s])/g) ?? [];
}
/**
* Marks the tokens that differ between two versions of a line.
*
* A common prefix and suffix are peeled off first — which is what nearly
* every real edit looks like — and everything between is marked. That is far
* cheaper than a full token LCS and produces the same answer for the edits
* people actually make.
*/
export function wordSpans(before: string, after: string): [Span[], Span[]] {
const a = tokenize(before);
const b = tokenize(after);
let head = 0;
while (head < a.length && head < b.length && a[head] === b[head]) head += 1;
let tail = 0;
while (
tail < a.length - head &&
tail < b.length - head &&
a[a.length - 1 - tail] === b[b.length - 1 - tail]
) {
tail += 1;
}
const build = (tokens: string[]): Span[] => {
const spans: Span[] = [];
const push = (text: string, changed: boolean) => {
if (!text) return;
const last = spans[spans.length - 1];
// Merge neighbours so the DOM gets a few spans, not one per token.
if (last && last.changed === changed) last.text += text;
else spans.push({ text, changed });
};
push(tokens.slice(0, head).join(""), false);
push(tokens.slice(head, tokens.length - tail).join(""), true);
push(tokens.slice(tokens.length - tail).join(""), false);
return spans;
};
return [build(a), build(b)];
}
/**
* Pairs each removal with the addition that replaced it.
*
* Only adjacent runs of equal length are paired: when three lines go and five
* arrive, there is no honest one-to-one mapping, and inventing one would
* highlight noise. Those rows are left whole.
*/
export function pairChanges(rows: (DiffRow | null)[]): Map<number, Span[]> {
const spans = new Map<number, Span[]>();
let i = 0;
while (i < rows.length) {
if (rows[i]?.kind !== "del") {
i += 1;
continue;
}
let dels = 0;
while (rows[i + dels]?.kind === "del") dels += 1;
let adds = 0;
while (rows[i + dels + adds]?.kind === "add") adds += 1;
if (dels === adds && dels > 0) {
for (let k = 0; k < dels; k++) {
const del = rows[i + k];
const add = rows[i + dels + k];
if (!del || !add) continue;
const [delSpans, addSpans] = wordSpans(del.text, add.text);
spans.set(i + k, delSpans);
spans.set(i + dels + k, addSpans);
}
}
i += dels + adds || 1;
}
return spans;
}