char-slop/berry

multipurpose self-modifiable agentic harness

git clone https://git.t4t.associates/char-slop/berry

Charlotte Somdiff: add line counts to diff index3c21631

main
7.8 KiB219 linesraw
1// A file's change as the view draws it: numbered lines, unchanged stretches folded, and what changed within lines.
2
3import type { FileDiff, Sent } from "./types.ts";
4
5/** One line of a change, numbered on the sides it is on. */
6export type Line = {
7  mark: " " | "-" | "+";
8  /** Without its mark; a carriage return stays, for the view to show. */
9  text: string;
10  old: number | null;
11  new: number | null;
12  /** Where it is among the lines of each side the change holds, which are what is highlighted. */
13  at: { old: number | null; new: number | null };
14  /** `false` for the last line of a file that ends without one. */
15  newline: boolean;
16};
17
18export type Piece =
19  | { kind: "lines"; lines: Line[] }
20  /** Unchanged lines, folded until they are opened. */
21  | { kind: "fold"; lines: Line[] }
22  /** Lines the patch left out: how many there are, when that is known. */
23  | { kind: "gap"; count: number };
24
25/** A row of the side-by-side view. */
26export type Pair = { old: Line | null; new: Line | null };
27
28/** Character ranges, `[start, end)`. */
29export type Range = readonly [number, number];
30
31/** Added and removed lines, excluding context and patch metadata. */
32export function lineCounts(file: FileDiff) {
33  let additions = 0;
34  let removals = 0;
35  for (const hunk of file.hunks) {
36    for (const line of hunk.lines) {
37      if (line[0] === "+") additions++;
38      else if (line[0] === "-") removals++;
39    }
40  }
41  return { additions, removals };
42}
43
44/** Every line `file`'s hunks hold, in order, and each side's text, for highlighting. */
45export function linesOf(file: FileDiff) {
46  const sides = { old: [] as string[], new: [] as string[] };
47  const hunks = file.hunks.map((hunk) => {
48    let old = hunk.old;
49    let neu = hunk.new;
50    const lines: Line[] = [];
51    for (const raw of hunk.lines) {
52      const mark = raw[0];
53      if (mark === "\\") {
54        const last = lines.at(-1);
55        if (last) last.newline = false;
56        continue;
57      }
58      if (mark !== " " && mark !== "-" && mark !== "+") continue;
59      const text = raw.slice(1);
60      const onOld = mark !== "+";
61      const onNew = mark !== "-";
62      lines.push({
63        mark,
64        text,
65        old: onOld ? old++ : null,
66        new: onNew ? neu++ : null,
67        at: {
68          old: onOld ? sides.old.push(text.replace(/\r$/, "")) - 1 : null,
69          new: onNew ? sides.new.push(text.replace(/\r$/, "")) - 1 : null,
70        },
71        newline: true,
72      });
73    }
74    return { hunk, lines, end: { old, new: neu } };
75  });
76  return { hunks, sides };
77}
78
79/**
80 * `file`'s lines with each stretch of more than a few unchanged lines folded, but for `context` lines next to each
81 * change; and gaps where the patch leaves lines out.
82 */
83export function pieces(file: FileDiff, context = 3): Piece[] {
84  const out: Piece[] = [];
85  const shown = (lines: Line[]) => {
86    const last = out.at(-1);
87    if (lines.length === 0) return;
88    if (last?.kind === "lines") last.lines.push(...lines);
89    else out.push({ kind: "lines", lines });
90  };
91  let previous: { old: number; new: number } = { old: 1, new: 1 };
92  for (const { hunk, lines, end } of linesOf(file).hunks) {
93    const missing = Math.max(hunk.old - previous.old, hunk.new - previous.new);
94    if (missing > 0) out.push({ kind: "gap", count: missing });
95    previous = end;
96    let i = 0;
97    while (i < lines.length) {
98      let j = i;
99      while (j < lines.length && lines[j].mark === " ") j++;
100      if (j === i) {
101        while (j < lines.length && lines[j].mark !== " ") j++;
102        shown(lines.slice(i, j));
103        i = j;
104        continue;
105      }
106      const before = i === 0 ? 0 : context;
107      const after = j === lines.length ? 0 : context;
108      // folding away less than this saves nothing
109      if (j - i - before - after < 4) shown(lines.slice(i, j));
110      else {
111        shown(lines.slice(i, i + before));
112        out.push({ kind: "fold", lines: lines.slice(i + before, j - after) });
113        shown(lines.slice(j - after, j));
114      }
115      i = j;
116    }
117  }
118  return out;
119}
120
121/** Rows side by side: unchanged lines beside themselves, and each run of removals beside the additions that follow. */
122export function pairs(lines: readonly Line[]): Pair[] {
123  const out: Pair[] = [];
124  let i = 0;
125  while (i < lines.length) {
126    if (lines[i].mark === " ") {
127      out.push({ old: lines[i], new: lines[i] });
128      i++;
129      continue;
130    }
131    const removed: Line[] = [];
132    const added: Line[] = [];
133    while (i < lines.length && lines[i].mark !== " ") {
134      (lines[i].mark === "-" ? removed : added).push(lines[i]);
135      i++;
136    }
137    for (let k = 0; k < Math.max(removed.length, added.length); k++) {
138      out.push({ old: removed[k] ?? null, new: added[k] ?? null });
139    }
140  }
141  return out;
142}
143
144const TOKEN = /[\p{L}\p{N}_]+|\s+|[^\p{L}\p{N}_\s]/gu;
145
146/**
147 * What changed between two versions of a line, word by word, as ranges in each; `null` when they share too little for
148 * that to say more than the whole line changing.
149 */
150export function wordChanges(a: string, b: string): { old: Range[]; new: Range[] } | null {
151  const x = [...a.matchAll(TOKEN)];
152  const y = [...b.matchAll(TOKEN)];
153  if (x.length * y.length > 250_000) return null;
154  // longest common subsequence of tokens, from the ends back
155  const width = y.length + 1;
156  const table = new Uint16Array((x.length + 1) * width);
157  for (let i = x.length - 1; i >= 0; i--) {
158    for (let j = y.length - 1; j >= 0; j--) {
159      table[i * width + j] = x[i][0] === y[j][0]
160        ? table[(i + 1) * width + j + 1] + 1
161        : Math.max(table[(i + 1) * width + j], table[i * width + j + 1]);
162    }
163  }
164  const kept = { old: new Set<number>(), new: new Set<number>() };
165  let shared = 0;
166  for (let i = 0, j = 0; i < x.length && j < y.length;) {
167    if (x[i][0] === y[j][0]) {
168      kept.old.add(i++);
169      kept.new.add(j++);
170      shared += x[i - 1][0].trim() ? x[i - 1][0].length : 0;
171    } else if (table[(i + 1) * width + j] >= table[i * width + j + 1]) i++;
172    else j++;
173  }
174  const visible = Math.max(a.replace(/\s/g, "").length, b.replace(/\s/g, "").length);
175  if (visible > 0 && shared / visible < 0.4) return null;
176  const ranges = (tokens: RegExpExecArray[], kept: Set<number>) => {
177    const out: [number, number][] = [];
178    for (const [i, token] of tokens.entries()) {
179      if (kept.has(i)) continue;
180      const last = out.at(-1);
181      if (last && last[1] === token.index) last[1] += token[0].length;
182      else out.push([token.index, token.index + token[0].length]);
183    }
184    return out;
185  };
186  return { old: ranges(x, kept.old), new: ranges(y, kept.new) };
187}
188
189/** Whitespace at the end of a line, before any carriage return. */
190export function trailing(text: string): Range | null {
191  const match = text.match(/[ \t]+(?=\r?$)/);
192  return match ? [match.index!, match.index! + match[0].length] : null;
193}
194
195/** A short, stable fingerprint of a text. */
196function fingerprint(text: string) {
197  let h1 = 0xdeadbeef;
198  let h2 = 0x41c6ce57;
199  for (let i = 0; i < text.length; i++) {
200    const c = text.charCodeAt(i);
201    h1 = Math.imul(h1 ^ c, 2654435761);
202    h2 = Math.imul(h2 ^ c, 1597334677);
203  }
204  h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
205  h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
206  return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(36);
207}
208
209/** A file's change by its path and a fingerprint of all of it, so marks of having viewed it lapse once it changes again. */
210export const fileKey = (file: FileDiff) => `${file.path}\0${fingerprint(JSON.stringify(file))}`;
211
212/** `files` for a view that has the changes `known` names already, which so cost it only their keys. */
213export function sending(files: readonly FileDiff[], known: readonly string[]): Sent[] {
214  const has = new Set(known);
215  return files.map((file) => {
216    const key = fileKey(file);
217    return { key, diff: has.has(key) ? null : file };
218  });
219}