jacquardSnapshot

← snapshot

3109 bytes
/**
 * 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;
}