@adecore/merge 0.22.1 → 0.23.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,112 @@
1
+ const HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(?: ?(.*))?$/;
2
+ /* Reads one `@@ -a,b +c,d @@ section` line without its line ending; undefined when it is not a hunk header. */
3
+ export function parseHunkHeader(line) {
4
+ const match = HEADER.exec(line);
5
+ if (match === null) {
6
+ return undefined;
7
+ }
8
+ return {
9
+ oldStart: Number(match[1]),
10
+ oldCount: Number(match[2] ?? 1),
11
+ newStart: Number(match[3]),
12
+ newCount: Number(match[4] ?? 1),
13
+ section: match[5] ?? ''
14
+ };
15
+ }
16
+ export function parsePatch(patch) {
17
+ const hunks = [];
18
+ const lines = patch.split('\n');
19
+ let current;
20
+ let oldCount = 0;
21
+ let newCount = 0;
22
+ let partial = false;
23
+ let oldEnded = false;
24
+ let newEnded = false;
25
+ const header = [];
26
+ const finish = () => {
27
+ if (current !== undefined && (oldCount !== current.oldCount || newCount !== current.newCount)) {
28
+ partial = true;
29
+ }
30
+ current = undefined;
31
+ };
32
+ for (let index = 0; index < lines.length; index++) {
33
+ const line = lines[index];
34
+ const metadata = line.replace(/\r$/, '');
35
+ if (metadata.startsWith('@@')) {
36
+ finish();
37
+ const match = parseHunkHeader(metadata);
38
+ if (match === undefined) {
39
+ partial = true;
40
+ continue;
41
+ }
42
+ const oldLength = match.oldCount;
43
+ const newLength = match.newCount;
44
+ const oldStart = match.oldStart - (oldLength === 0 ? 0 : 1);
45
+ const newStart = match.newStart - (newLength === 0 ? 0 : 1);
46
+ if (![oldStart, newStart, oldLength, newLength, oldStart + oldLength, newStart + newLength].every((value) => Number.isSafeInteger(value) && value >= 0)) {
47
+ partial = true;
48
+ continue;
49
+ }
50
+ const previous = hunks.at(-1);
51
+ if (previous !== undefined && (oldStart < previous.oldStart + previous.oldCount || newStart < previous.newStart + previous.newCount)) {
52
+ partial = true;
53
+ }
54
+ current = { oldStart, oldCount: oldLength, newStart, newCount: newLength, section: match.section, lines: [] };
55
+ hunks.push(current);
56
+ oldCount = 0;
57
+ newCount = 0;
58
+ continue;
59
+ }
60
+ if (metadata.startsWith('diff --git ') && hunks.length > 0) {
61
+ finish();
62
+ partial = true;
63
+ break;
64
+ }
65
+ if (current === undefined) {
66
+ if (hunks.length === 0) {
67
+ header.push(metadata);
68
+ }
69
+ else if (line !== '') {
70
+ partial = true;
71
+ }
72
+ continue;
73
+ }
74
+ if (metadata === '\') {
75
+ const previous = current.lines.at(-1);
76
+ if (previous === undefined || previous.noNewline) {
77
+ partial = true;
78
+ }
79
+ else {
80
+ current.lines[current.lines.length - 1] = { ...previous, noNewline: true };
81
+ oldEnded ||= previous.kind !== 'insert';
82
+ newEnded ||= previous.kind !== 'delete';
83
+ }
84
+ continue;
85
+ }
86
+ // The last empty split field is the patch's terminator, not a source line.
87
+ if (line === '' && index === lines.length - 1) {
88
+ continue;
89
+ }
90
+ const kind = line.startsWith('-') ? 'delete' : line.startsWith('+') ? 'insert' : line.startsWith(' ') || line === '' ? 'context' : undefined;
91
+ if (kind === undefined) {
92
+ partial = true;
93
+ continue;
94
+ }
95
+ if (kind !== 'insert') {
96
+ partial ||= oldEnded;
97
+ oldCount += 1;
98
+ }
99
+ if (kind !== 'delete') {
100
+ partial ||= newEnded;
101
+ newCount += 1;
102
+ }
103
+ current.lines.push({ kind, text: line === '' ? '' : line.slice(1) });
104
+ }
105
+ finish();
106
+ return {
107
+ text: patch,
108
+ hunks,
109
+ missing: { old: header.some((line) => /^--- \/dev\/null(?:\t|$)/.test(line)), new: header.some((line) => /^\+\+\+ \/dev\/null(?:\t|$)/.test(line)) },
110
+ partial
111
+ };
112
+ }
@@ -0,0 +1,5 @@
1
+ export declare function missingSides(patch: string): {
2
+ old: boolean;
3
+ new: boolean;
4
+ };
5
+ export declare function patchFits(patch: string, oldText: string, newText: string, ignoreWhitespace: boolean): boolean;
package/dist/patch.js ADDED
@@ -0,0 +1,51 @@
1
+ /* Which side a patch says the file is missing on, read from its header so no line of the file can pass for it. */
2
+ export function missingSides(patch) {
3
+ const start = patch.search(/^@@/m);
4
+ const header = start === -1 ? patch : patch.slice(0, start);
5
+ return { old: /^--- \/dev\/null$/m.test(header), new: /^\+\+\+ \/dev\/null$/m.test(header) };
6
+ }
7
+ const HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
8
+ /* Git's --ignore-all-space may show either side's spacing on a context line. */
9
+ export function patchFits(patch, oldText, newText, ignoreWhitespace) {
10
+ const oldLines = oldText.split('\n');
11
+ const newLines = newText.split('\n');
12
+ const same = ignoreWhitespace
13
+ ? (shown, actual) => actual !== undefined && shown.replace(/\s+/g, '') === actual.replace(/\s+/g, '')
14
+ : (shown, actual) => shown === actual;
15
+ let oldAt = -1;
16
+ let newAt = -1;
17
+ let hunks = 0;
18
+ for (const line of patch.split('\n')) {
19
+ const header = HUNK_HEADER.exec(line);
20
+ if (header) {
21
+ hunks += 1;
22
+ oldAt = Number(header[1]) - (header[2] === '0' ? 0 : 1);
23
+ newAt = Number(header[3]) - (header[4] === '0' ? 0 : 1);
24
+ continue;
25
+ }
26
+ if (oldAt === -1) {
27
+ continue;
28
+ }
29
+ const body = line.slice(1);
30
+ if (line.startsWith(' ')) {
31
+ if (!same(body, oldLines[oldAt]) || !same(body, newLines[newAt])) {
32
+ return false;
33
+ }
34
+ oldAt += 1;
35
+ newAt += 1;
36
+ }
37
+ else if (line.startsWith('-')) {
38
+ if (!same(body, oldLines[oldAt])) {
39
+ return false;
40
+ }
41
+ oldAt += 1;
42
+ }
43
+ else if (line.startsWith('+')) {
44
+ if (!same(body, newLines[newAt])) {
45
+ return false;
46
+ }
47
+ newAt += 1;
48
+ }
49
+ }
50
+ return hunks > 0;
51
+ }
@@ -0,0 +1,8 @@
1
+ export interface DiffWordRange {
2
+ readonly from: number;
3
+ readonly to: number;
4
+ }
5
+ export declare function wordChanges(oldLine: string, newLine: string): {
6
+ old: DiffWordRange[];
7
+ new: DiffWordRange[];
8
+ } | null;
@@ -0,0 +1,21 @@
1
+ import { diffLines } from './diff.js';
2
+ let segmenter;
3
+ export function wordChanges(oldLine, newLine) {
4
+ if (oldLine.length + newLine.length > 20000) {
5
+ return null;
6
+ }
7
+ segmenter ??= new Intl.Segmenter('en', { granularity: 'word' });
8
+ const before = [...segmenter.segment(oldLine)];
9
+ const after = [...segmenter.segment(newLine)];
10
+ if (before.length + after.length > 2000) {
11
+ return null;
12
+ }
13
+ const range = (segments, text, from, to) => from === to ? [] : [{ from: segments[from].index, to: segments[to]?.index ?? text.length }];
14
+ const old = [];
15
+ const fresh = [];
16
+ for (const change of diffLines(before.map((part) => part.segment), after.map((part) => part.segment))) {
17
+ old.push(...range(before, oldLine, change.baseStart, change.baseEnd));
18
+ fresh.push(...range(after, newLine, change.otherStart, change.otherEnd));
19
+ }
20
+ return { old, new: fresh };
21
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/merge",
3
- "version": "0.22.1",
3
+ "version": "0.23.1",
4
4
  "description": "Pure line diffs, three-way merge blocks and conflict resolution.",
5
5
  "license": "FSL-1.1-MIT",
6
6
  "author": "Bas Milius",
@@ -0,0 +1,31 @@
1
+ import { diffRows, type DiffDocument, type DiffGapRow, type DiffLineRow } from './diff-document.ts';
2
+
3
+ export interface DiffDisplayGap {
4
+ /* The gap precedes this display line; a value equal to the line count puts it after the text. */
5
+ readonly before: number;
6
+ readonly row: DiffGapRow;
7
+ }
8
+
9
+ export interface DiffDisplay {
10
+ readonly text: string;
11
+ readonly lines: readonly string[];
12
+ /* One source mapping per display line, including removed lines as real text. */
13
+ readonly rows: readonly DiffLineRow[];
14
+ readonly gaps: readonly DiffDisplayGap[];
15
+ }
16
+
17
+ export function diffDisplay(document: DiffDocument, unfolded?: ReadonlyMap<number, number>): DiffDisplay {
18
+ const lines: string[] = [];
19
+ const rows: DiffLineRow[] = [];
20
+ const gaps: DiffDisplayGap[] = [];
21
+ for (const row of diffRows(document, 'unified', unfolded)) {
22
+ if (row.kind === 'gap') {
23
+ gaps.push({ before: lines.length, row });
24
+ } else {
25
+ const ref = row.new ?? row.old!;
26
+ lines.push((row.new === undefined ? document.sides.old : document.sides.new)[ref.index]!);
27
+ rows.push(row);
28
+ }
29
+ }
30
+ return { text: lines.join('\n'), lines, rows, gaps };
31
+ }
@@ -0,0 +1,243 @@
1
+ import type { Hunk, ParsedPatch, PatchLine } from './patch-parser.ts';
2
+ import { shapeOf, splitLines, type TextShape } from './text.ts';
3
+
4
+ export interface DiffLineRef {
5
+ readonly index: number;
6
+ readonly number: number;
7
+ }
8
+
9
+ export interface DiffLineRow {
10
+ readonly kind: 'line';
11
+ readonly change: 'context' | 'delete' | 'insert' | 'pair';
12
+ readonly old?: DiffLineRef;
13
+ readonly new?: DiffLineRef;
14
+ readonly noNewline?: 'old' | 'new' | 'both';
15
+ readonly hunk?: number;
16
+ }
17
+
18
+ export interface DiffGapRow {
19
+ readonly kind: 'gap';
20
+ readonly id: number;
21
+ readonly oldStart: number;
22
+ readonly newStart: number;
23
+ readonly oldCount: number;
24
+ readonly newCount: number;
25
+ readonly unfoldable: boolean;
26
+ }
27
+
28
+ export type DiffRow = DiffLineRow | DiffGapRow;
29
+
30
+ export interface DiffDocument {
31
+ readonly hunks: readonly Hunk[];
32
+ readonly sides: { readonly old: readonly string[]; readonly new: readonly string[] };
33
+ readonly shapes?: { readonly old: TextShape; readonly new: TextShape };
34
+ readonly offsets: readonly { readonly old: number; readonly new: number }[];
35
+ readonly snapshots: boolean;
36
+ readonly ignoreWhitespace: boolean;
37
+ readonly partial: boolean;
38
+ }
39
+
40
+ function sourceText(line: PatchLine): string {
41
+ return line.noNewline ? line.text : line.text.replace(/\r$/, '');
42
+ }
43
+
44
+ function snapshotsFit(parsed: ParsedPatch, contents: { old: string; new: string }, ignoreWhitespace: boolean): boolean {
45
+ if (parsed.partial || parsed.hunks.length === 0) {
46
+ return false;
47
+ }
48
+ const rawLines = (text: string): string[] => {
49
+ if (text === '') {
50
+ return [];
51
+ }
52
+ const lines = text.split('\n');
53
+ if (text.endsWith('\n')) {
54
+ lines.pop();
55
+ }
56
+ return lines;
57
+ };
58
+ const oldLines = rawLines(contents.old);
59
+ const newLines = rawLines(contents.new);
60
+ const oldShape = shapeOf(contents.old);
61
+ const newShape = shapeOf(contents.new);
62
+ const same = (left: string, right: string): boolean => (ignoreWhitespace ? left.replace(/\s+/g, '') === right.replace(/\s+/g, '') : left === right);
63
+ let oldAt = 0;
64
+ let newAt = 0;
65
+ const gapFits = (oldEnd: number, newEnd: number): boolean => {
66
+ if (oldEnd - oldAt !== newEnd - newAt) {
67
+ return false;
68
+ }
69
+ while (oldAt < oldEnd && newAt < newEnd) {
70
+ if (!same(oldLines[oldAt]!, newLines[newAt]!)) {
71
+ return false;
72
+ }
73
+ oldAt += 1;
74
+ newAt += 1;
75
+ }
76
+ return true;
77
+ };
78
+ for (const hunk of parsed.hunks) {
79
+ if (hunk.oldStart + hunk.oldCount > oldLines.length || hunk.newStart + hunk.newCount > newLines.length || !gapFits(hunk.oldStart, hunk.newStart)) {
80
+ return false;
81
+ }
82
+ for (const line of hunk.lines) {
83
+ if (line.kind !== 'insert') {
84
+ if (!same(line.text, oldLines[oldAt]!) || Boolean(line.noNewline) !== (oldAt === oldLines.length - 1 && !oldShape.finalNewline)) {
85
+ return false;
86
+ }
87
+ oldAt += 1;
88
+ }
89
+ if (line.kind !== 'delete') {
90
+ if (!same(line.text, newLines[newAt]!) || Boolean(line.noNewline) !== (newAt === newLines.length - 1 && !newShape.finalNewline)) {
91
+ return false;
92
+ }
93
+ newAt += 1;
94
+ }
95
+ }
96
+ }
97
+ const endShown = oldAt === oldLines.length && newAt === newLines.length;
98
+ return gapFits(oldLines.length, newLines.length) && (oldShape.finalNewline === newShape.finalNewline || endShown);
99
+ }
100
+
101
+ export function diffDocument(parsed: ParsedPatch, contents: { old: string; new: string } | undefined, ignoreWhitespace = false): DiffDocument {
102
+ if (contents !== undefined && snapshotsFit(parsed, contents, ignoreWhitespace)) {
103
+ return {
104
+ hunks: parsed.hunks,
105
+ sides: { old: splitLines(contents.old), new: splitLines(contents.new) },
106
+ shapes: { old: shapeOf(contents.old), new: shapeOf(contents.new) },
107
+ offsets: parsed.hunks.map((hunk) => ({ old: hunk.oldStart, new: hunk.newStart })),
108
+ snapshots: true,
109
+ ignoreWhitespace,
110
+ partial: false
111
+ };
112
+ }
113
+ const old: string[] = [];
114
+ const fresh: string[] = [];
115
+ const offsets = parsed.hunks.map((hunk) => {
116
+ const offset = { old: old.length, new: fresh.length };
117
+ for (const line of hunk.lines) {
118
+ if (line.kind !== 'insert') {
119
+ old.push(sourceText(line));
120
+ }
121
+ if (line.kind !== 'delete') {
122
+ fresh.push(sourceText(line));
123
+ }
124
+ }
125
+ return offset;
126
+ });
127
+ return { hunks: parsed.hunks, sides: { old, new: fresh }, offsets, snapshots: false, ignoreWhitespace, partial: true };
128
+ }
129
+
130
+ function newline(old: boolean, fresh: boolean): Pick<DiffLineRow, 'noNewline'> {
131
+ return old || fresh ? { noNewline: old && fresh ? 'both' : old ? 'old' : 'new' } : {};
132
+ }
133
+
134
+ function hunkRows(document: DiffDocument, index: number, layout: 'unified' | 'split'): DiffLineRow[] {
135
+ const hunk = document.hunks[index]!;
136
+ const offset = document.offsets[index]!;
137
+ let oldAt = 0;
138
+ let newAt = 0;
139
+ const rows: DiffLineRow[] = [];
140
+ const deletes: DiffLineRow[] = [];
141
+ const inserts: DiffLineRow[] = [];
142
+ const flush = (): void => {
143
+ for (let pair = 0; pair < Math.max(deletes.length, inserts.length); pair++) {
144
+ const old = deletes[pair];
145
+ const fresh = inserts[pair];
146
+ rows.push({
147
+ kind: 'line',
148
+ change: old && fresh ? 'pair' : old ? 'delete' : 'insert',
149
+ hunk: index,
150
+ ...(old ? { old: old.old } : {}),
151
+ ...(fresh ? { new: fresh.new } : {}),
152
+ ...newline(old?.noNewline === 'old', fresh?.noNewline === 'new')
153
+ });
154
+ }
155
+ deletes.length = 0;
156
+ inserts.length = 0;
157
+ };
158
+ for (const line of hunk.lines) {
159
+ const old = line.kind === 'insert' ? undefined : { index: offset.old + oldAt, number: hunk.oldStart + oldAt + 1 };
160
+ const fresh = line.kind === 'delete' ? undefined : { index: offset.new + newAt, number: hunk.newStart + newAt + 1 };
161
+ const row: DiffLineRow = {
162
+ kind: 'line',
163
+ change: line.kind,
164
+ hunk: index,
165
+ ...(old ? { old } : {}),
166
+ ...(fresh ? { new: fresh } : {}),
167
+ ...newline(Boolean(old && line.noNewline), Boolean(fresh && line.noNewline))
168
+ };
169
+ oldAt += old ? 1 : 0;
170
+ newAt += fresh ? 1 : 0;
171
+ if (layout === 'unified') {
172
+ rows.push(row);
173
+ } else if (line.kind === 'context') {
174
+ flush();
175
+ rows.push(row);
176
+ } else {
177
+ (line.kind === 'delete' ? deletes : inserts).push(row);
178
+ }
179
+ }
180
+ flush();
181
+ return rows;
182
+ }
183
+
184
+ export function diffRows(document: DiffDocument, layout: 'unified' | 'split', unfolded: ReadonlyMap<number, number> = new Map()): DiffRow[] {
185
+ const rows: DiffRow[] = [];
186
+ const gap = (id: number, oldStart: number, newStart: number, oldCount: number, newCount: number, position: 'before' | 'between' | 'after'): void => {
187
+ if (oldCount <= 0 && newCount <= 0) {
188
+ return;
189
+ }
190
+ oldCount = Math.max(0, oldCount);
191
+ newCount = Math.max(0, newCount);
192
+ const unfoldable = document.snapshots && oldCount === newCount;
193
+ const amount = unfolded.get(id) ?? 0;
194
+ const count = unfoldable
195
+ ? Math.min(oldCount, amount === Number.POSITIVE_INFINITY ? oldCount : Number.isFinite(amount) ? Math.max(0, Math.floor(amount)) : 0)
196
+ : 0;
197
+ const leading = position === 'before' ? 0 : count;
198
+ const trailing = position === 'after' ? 0 : Math.min(count, oldCount - leading);
199
+ const context = (from: number, to: number): void => {
200
+ for (let line = from; line < to; line++) {
201
+ const old = oldStart + line;
202
+ const fresh = newStart + line;
203
+ rows.push({
204
+ kind: 'line',
205
+ change: 'context',
206
+ old: { index: old, number: old + 1 },
207
+ new: { index: fresh, number: fresh + 1 },
208
+ ...newline(
209
+ old === document.sides.old.length - 1 && document.shapes?.old.finalNewline === false,
210
+ fresh === document.sides.new.length - 1 && document.shapes?.new.finalNewline === false
211
+ )
212
+ });
213
+ }
214
+ };
215
+ context(0, leading);
216
+ if (oldCount - leading - trailing > 0 || newCount - leading - trailing > 0) {
217
+ rows.push({
218
+ kind: 'gap',
219
+ id,
220
+ oldStart: oldStart + leading,
221
+ newStart: newStart + leading,
222
+ oldCount: Math.max(0, oldCount - leading - trailing),
223
+ newCount: Math.max(0, newCount - leading - trailing),
224
+ unfoldable
225
+ });
226
+ }
227
+ context(oldCount - trailing, oldCount);
228
+ };
229
+ let oldAt = 0;
230
+ let newAt = 0;
231
+ document.hunks.forEach((hunk, index) => {
232
+ gap(index, oldAt, newAt, hunk.oldStart - oldAt, hunk.newStart - newAt, index === 0 ? 'before' : 'between');
233
+ for (const row of hunkRows(document, index, layout)) {
234
+ rows.push(row);
235
+ }
236
+ oldAt = hunk.oldStart + hunk.oldCount;
237
+ newAt = hunk.newStart + hunk.newCount;
238
+ });
239
+ if (document.snapshots) {
240
+ gap(document.hunks.length, oldAt, newAt, document.sides.old.length - oldAt, document.sides.new.length - newAt, 'after');
241
+ }
242
+ return rows;
243
+ }
@@ -0,0 +1,43 @@
1
+ import { diffRows, type DiffDocument, type DiffGapRow, type DiffLineRow } from './diff-document.ts';
2
+ import type { DiffDisplay } from './diff-display.ts';
3
+
4
+ export type DiffSplitRow =
5
+ | { readonly kind: 'line'; readonly row: DiffLineRow; readonly old?: number; readonly new?: number }
6
+ | { readonly kind: 'gap'; readonly row: DiffGapRow };
7
+
8
+ export interface DiffSplit {
9
+ readonly old: DiffDisplay;
10
+ readonly new: DiffDisplay;
11
+ readonly aligned: readonly DiffSplitRow[];
12
+ }
13
+
14
+ /* Each side contains only source text; alignment rows never enter selection, copy or Find. */
15
+ export function diffSplit(document: DiffDocument, unfolded?: ReadonlyMap<number, number>): DiffSplit {
16
+ const old = { lines: [] as string[], rows: [] as DiffLineRow[], gaps: [] as DiffDisplay['gaps'][number][] };
17
+ const fresh = { lines: [] as string[], rows: [] as DiffLineRow[], gaps: [] as DiffDisplay['gaps'][number][] };
18
+ const aligned: DiffSplitRow[] = [];
19
+ for (const row of diffRows(document, 'split', unfolded)) {
20
+ if (row.kind === 'gap') {
21
+ old.gaps.push({ before: old.lines.length, row });
22
+ fresh.gaps.push({ before: fresh.lines.length, row });
23
+ aligned.push({ kind: 'gap', row });
24
+ continue;
25
+ }
26
+ const entry = {
27
+ kind: 'line' as const,
28
+ row,
29
+ old: row.old === undefined ? undefined : old.lines.length,
30
+ new: row.new === undefined ? undefined : fresh.lines.length
31
+ };
32
+ if (row.old !== undefined) {
33
+ old.lines.push(document.sides.old[row.old.index]!);
34
+ old.rows.push({ ...row, new: undefined, noNewline: row.noNewline === 'new' ? undefined : row.noNewline });
35
+ }
36
+ if (row.new !== undefined) {
37
+ fresh.lines.push(document.sides.new[row.new.index]!);
38
+ fresh.rows.push({ ...row, old: undefined, noNewline: row.noNewline === 'old' ? undefined : row.noNewline });
39
+ }
40
+ aligned.push(entry);
41
+ }
42
+ return { old: { ...old, text: old.lines.join('\n') }, new: { ...fresh, text: fresh.lines.join('\n') }, aligned };
43
+ }
package/src/diff.ts CHANGED
@@ -16,6 +16,10 @@ const MAX_DISTANCE = 4000;
16
16
  state per round came to over a hundred megabytes at MAX_DISTANCE. */
17
17
  const STRIDE = 64;
18
18
 
19
+ interface WorkBudget {
20
+ remaining: number;
21
+ }
22
+
19
23
  /* One edit and the run of equal lines behind it, as the walk found them. */
20
24
  interface Move {
21
25
  prevX: number;
@@ -30,12 +34,21 @@ function cameDown(reach: Int32Array, offset: number, diagonal: number, distance:
30
34
  }
31
35
 
32
36
  /* One round of the walk: every diagonal of this distance taken as far as its lines match. True once the far corner is reached. */
33
- function extend(left: readonly string[], right: readonly string[], reach: Int32Array, offset: number, distance: number): boolean {
37
+ function extend(left: readonly string[], right: readonly string[], reach: Int32Array, offset: number, distance: number, work?: WorkBudget): boolean | null {
34
38
  for (let diagonal = -distance; diagonal <= distance; diagonal += 2) {
39
+ if (work !== undefined && work.remaining-- <= 0) {
40
+ return null;
41
+ }
35
42
  const down = cameDown(reach, offset, diagonal, distance);
36
43
  let x = down ? reach[offset + diagonal + 1]! : reach[offset + diagonal - 1]! + 1;
37
44
  let y = x - diagonal;
38
- while (x < left.length && y < right.length && left[x] === right[y]) {
45
+ while (x < left.length && y < right.length) {
46
+ if (work !== undefined && work.remaining-- <= 0) {
47
+ return null;
48
+ }
49
+ if (left[x] !== right[y]) {
50
+ break;
51
+ }
39
52
  x += 1;
40
53
  y += 1;
41
54
  }
@@ -82,20 +95,26 @@ function backtrack(left: readonly string[], right: readonly string[], saved: rea
82
95
  * STRIDE-th state a round started in, which is what the path is read back from once the far corner
83
96
  * is reached. Null once the distance passes what is worth walking.
84
97
  */
85
- function walk(left: readonly string[], right: readonly string[]): Move[] | null {
98
+ function walk(left: readonly string[], right: readonly string[], maxWork?: number): { moves: Move[] | null; limited: boolean } {
86
99
  const limit = Math.min(left.length + right.length, MAX_DISTANCE);
87
100
  const offset = limit;
88
101
  const reach = new Int32Array(2 * limit + 1);
89
102
  const saved: Int32Array[] = [];
103
+ // Replay visits each earlier round once; reserve half the budget for backtracking.
104
+ const work = maxWork === undefined ? undefined : { remaining: Math.floor(maxWork / 2) };
90
105
  for (let distance = 0; distance <= limit; distance += 1) {
91
106
  if (distance % STRIDE === 0) {
92
107
  saved.push(reach.slice());
93
108
  }
94
- if (extend(left, right, reach, offset, distance)) {
95
- return backtrack(left, right, saved, distance, offset);
109
+ const complete = extend(left, right, reach, offset, distance, work);
110
+ if (complete === null) {
111
+ return { moves: null, limited: true };
112
+ }
113
+ if (complete) {
114
+ return { moves: backtrack(left, right, saved, distance, offset), limited: false };
96
115
  }
97
116
  }
98
- return null;
117
+ return { moves: null, limited: false };
99
118
  }
100
119
 
101
120
  /* The stretches the moves cover, with edits that follow each other straight away folded into one. */
@@ -130,6 +149,10 @@ function changesOf(moves: readonly Move[]): Change[] {
130
149
 
131
150
  /* Where `other` differs from `base`, in line stretches, in the order they appear. */
132
151
  export function diffLines(base: readonly string[], other: readonly string[]): Change[] {
152
+ return diffChanges(base, other).changes;
153
+ }
154
+
155
+ export function diffChanges(base: readonly string[], other: readonly string[], maxWork?: number): { changes: Change[]; limited: boolean } {
133
156
  let prefix = 0;
134
157
  while (prefix < base.length && prefix < other.length && base[prefix] === other[prefix]) {
135
158
  prefix += 1;
@@ -141,14 +164,17 @@ export function diffLines(base: readonly string[], other: readonly string[]): Ch
141
164
  const left = base.slice(prefix, base.length - suffix);
142
165
  const right = other.slice(prefix, other.length - suffix);
143
166
  if (left.length === 0 && right.length === 0) {
144
- return [];
167
+ return { changes: [], limited: false };
145
168
  }
146
- const moves = left.length === 0 || right.length === 0 ? null : walk(left, right);
169
+ const { moves, limited } = left.length === 0 || right.length === 0 ? { moves: null, limited: false } : walk(left, right, maxWork);
147
170
  const changes = moves === null ? [{ baseStart: 0, baseEnd: left.length, otherStart: 0, otherEnd: right.length }] : changesOf(moves);
148
- return changes.map((change) => ({
149
- baseStart: change.baseStart + prefix,
150
- baseEnd: change.baseEnd + prefix,
151
- otherStart: change.otherStart + prefix,
152
- otherEnd: change.otherEnd + prefix
153
- }));
171
+ return {
172
+ changes: changes.map((change) => ({
173
+ baseStart: change.baseStart + prefix,
174
+ baseEnd: change.baseEnd + prefix,
175
+ otherStart: change.otherStart + prefix,
176
+ otherEnd: change.otherEnd + prefix
177
+ })),
178
+ limited
179
+ };
154
180
  }
package/src/index.ts CHANGED
@@ -2,3 +2,10 @@ export { diffLines, type Change } from './diff.ts';
2
2
  export { openBlocks, splitBlocks, type MergeBlock, type MergeBlockKind } from './blocks.ts';
3
3
  export { autoLines, bothLines, draftOf, fingerprint, sideLines, wandLines, type MergeDraft, type MergeSide, type MergeSpan } from './resolve.ts';
4
4
  export { joinLines, shapeOf, splitLines, type TextShape } from './text.ts';
5
+ export { missingSides, patchFits } from './patch.ts';
6
+ export { parsePatch, parseHunkHeader, type HunkHeader, type Hunk, type ParsedPatch, type PatchLine } from './patch-parser.ts';
7
+ export { diffDocument, diffRows, type DiffDocument, type DiffGapRow, type DiffLineRef, type DiffLineRow, type DiffRow } from './diff-document.ts';
8
+ export { patchOf } from './patch-of.ts';
9
+ export { wordChanges, type DiffWordRange } from './word-changes.ts';
10
+ export { diffDisplay, type DiffDisplay, type DiffDisplayGap } from './diff-display.ts';
11
+ export { diffSplit, type DiffSplit, type DiffSplitRow } from './diff-split.ts';