// The lines of a file a diff leaves out, before, between and after its // hunks, and its rows with some of them put back as a reader asks for // more, read from the file as it is after the change (dev.knotless.getFile), // highlighted with the file's ranges where the diff's do not reach. import type { DiffRowView } from '#lib/lexicons/dev/knotless/defs.ts'; /** How many lines one step shows of what a diff leaves out. */ export const STEP = 20; /** * The lines left out before the hunk whose header is row `at` of a diff, * or after its last hunk, `at` then the number of rows: from `from` to * `to` by their new numbers, `to` null after the last hunk until the * file is read. `shift` turns a new number into the old. */ export type Gap = { at: number; from: number; to: number | null; shift: number }; /** How much of a gap is shown: `top` lines from its start, `bottom` from its end. */ export type Open = { top: number; bottom: number }; /** A file as it is after the change: its lines, and the ranges its highlighting paints. */ export type Read = { lines: string[]; ranges?: string }; /** * A row as drawn: one of the diff's, a line put `back`, or, with `gap`, * the place of the lines still left out there, `left` of them, or null * after the last hunk while the file is unread. The diff's hunk header * shows that place; after the last hunk, a row of its own. */ export type Shown = { key: string; row: DiffRowView; back?: true; gap?: Gap; left?: number | null; }; const HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/; /** The gaps between the hunks of `rows`, or none for rows with a header unread. */ export function gaps(rows: DiffRowView[]): Gap[] { const out: Gap[] = []; let next = 1; let shift = 0; for (let at = 0; at < rows.length; at++) { if (rows[at].kind !== 'h') continue; const m = HEADER.exec(rows[at].text); if (!m) return []; const [a, b, c, d] = [m[1], m[2] ?? '1', m[3], m[4] ?? '1'].map(Number); // A side with no lines names the line before where it would be. const old = b ? a : a + 1; const start = d ? c : c + 1; if (start > next) out.push({ at, from: next, to: start - 1, shift: old - start }); next = start + d; shift = old + b - next; } if (rows.some((r) => r.kind === 'h')) out.push({ at: rows.length, from: next, to: null, shift }); return out; } /** Whether everything left out is shown, as far as can be told with `file` read or not. */ export function whole(all: Gap[], file: Read | null, open: Map): boolean { return all.every((g) => { const to = g.to ?? file?.lines.length; if (to === undefined) return false; const o = open.get(g.at); return to < g.from || (!!o && o.top + o.bottom > to - g.from); }); } /** The ranges `ranges` encodes, by name, each `[start, end]` from the start of the text. */ function decode(ranges: string | undefined): Map { const out = new Map(); for (const group of (ranges ?? '').split(';')) { if (!group) continue; const [name, list] = group.split(':'); const x = list.split(','); const got = out.get(name) ?? []; const before = got.length; let o = 0; for (let i = 0; i + 1 < x.length; i += 2) { o += +x[i]; got.push([o, o + +x[i + 1]]); o += +x[i + 1]; } if (before) got.sort((a, b) => a[0] - b[0]); out.set(name, got); } return out; } /** Each of `by`'s ranges, encoded as the pages read them. */ function encode(by: Map): string { const groups: string[] = []; for (const [name, list] of by) { if (!list.length) continue; const parts: number[] = []; let end = 0; for (const [s, e] of list) { parts.push(s - end, e - s); end = e; } groups.push(`${name}:${parts.join(',')}`); } return groups.join(';'); } /** A run of text from the diff's (`d`) or the file's (`f`), `from` to `to` there, drawn from `at`. */ type Run = { src: 'd' | 'f'; from: number; to: number; at: number }; /** The ranges `runs` take with them from the diff's and the file's, where they are drawn. */ function carried(runs: Run[], diff: string | undefined, file: string | undefined): string { const from = { d: decode(diff), f: decode(file) }; const names = new Set([...from.d.keys(), ...from.f.keys()]); const out = new Map(); for (const name of names) { const got: [number, number][] = []; for (const run of runs) { const list = from[run.src].get(name); if (!list) continue; // The first range ending past the run's start. let lo = 0; let hi = list.length; while (lo < hi) { const mid = (lo + hi) >> 1; if (list[mid][1] <= run.from) lo = mid + 1; else hi = mid; } for (let i = lo; i < list.length && list[i][0] < run.to; i++) { const s = Math.max(list[i][0], run.from) - run.from + run.at; const e = Math.min(list[i][1], run.to) - run.from + run.at; if (e > s) got.push([s, e]); } } out.set(name, got); } return encode(out); } /** * `rows` as drawn with the lines `open` asks for put back from `file`, * once read, and the ranges that paint them: the diff's `ranges` over its * own lines, the file's over those put back. */ export function expand( rows: DiffRowView[], ranges: string | undefined, all: Gap[], file: Read | null, open: Map ): { shown: Shown[]; ranges: string | undefined } { const shown: Shown[] = []; const runs: Run[] = []; let at = 0; /** Adds a line of text from `src`, `from` there, to what is drawn. */ const take = (src: Run['src'], from: number, length: number) => { const run = runs.at(-1); if (run && run.src === src && run.to + 1 === from) run.to = from + length; else runs.push({ src, from, to: from + length, at }); at += length + 1; }; // Where each of the file's lines starts in its text. const starts: number[] = []; if (file) for (let i = 0, o = 0; i < file.lines.length; o += file.lines[i].length + 1, i++) starts.push(o); const back = (g: Gap, n: number) => { const text = file!.lines[n - 1]; shown.push({ key: `b${n}`, row: { kind: '', line: true, old: n + g.shift, new: n, text }, back: true }); take('f', starts[n - 1], text.length); }; const end: DiffRowView = { kind: 'h', line: false, text: '' }; /** The gap before row `i`, its lines put back as asked, around its place while any are left. */ const gapAt = (i: number, header?: DiffRowView) => { const g = all.find((g) => g.at === i); if (!g) { if (header) shown.push({ key: `r${i}`, row: header }); return; } if (!file) { const left = g.to === null ? null : g.to - g.from + 1; shown.push({ key: `r${i}`, row: header ?? end, gap: g, left }); return; } const to = Math.min(g.to ?? Infinity, file.lines.length); const size = Math.max(0, to - g.from + 1); const o = open.get(i) ?? { top: 0, bottom: 0 }; const top = Math.min(o.top, size); const bottom = Math.min(o.bottom, size - top); for (let n = g.from; n < g.from + top; n++) back(g, n); if (size > top + bottom) shown.push({ key: `r${i}`, row: header ?? end, gap: g, left: size - top - bottom }); for (let n = g.from + size - bottom; n < g.from + size; n++) back(g, n); }; let d = 0; rows.forEach((row, i) => { if (row.kind === 'h') return gapAt(i, row); shown.push({ key: `r${i}`, row }); if (!row.line) return; take('d', d, row.text.length); d += row.text.length + 1; }); gapAt(rows.length); if (!file || !runs.some((r) => r.src === 'f')) return { shown, ranges }; return { shown, ranges: carried(runs, ranges, file.ranges) }; }