~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
211 lines · 7.4 KB Raw History
// 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<number, Open>): 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<string, [number, number][]> {
	const out = new Map<string, [number, number][]>();
	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, [number, number][]>): 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<string, [number, number][]>();
	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<number, Open>
): { 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) };
}