@adecore/merge 0.38.0 → 0.40.0

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.
package/dist/blocks.d.ts CHANGED
@@ -5,4 +5,5 @@ export interface MergeBlock {
5
5
  ours: string[];
6
6
  theirs: string[];
7
7
  }
8
+ export declare function same(left: readonly string[], right: readonly string[]): boolean;
8
9
  export declare function splitBlocks(base: readonly string[], ours: readonly string[], theirs: readonly string[]): MergeBlock[];
package/dist/blocks.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { diffLines } from './diff.js';
2
- function same(left, right) {
2
+ export function same(left, right) {
3
3
  return left.length === right.length && left.every((line, index) => line === right[index]);
4
4
  }
5
5
  function kindOf(base, ours, theirs) {
@@ -1,4 +1,4 @@
1
- import { shapeOf, splitLines } from './text.js';
1
+ import { lineMatcher, rawLines, shapeOf, splitLines } from './text.js';
2
2
  function sourceText(line) {
3
3
  return line.noNewline ? line.text : line.text.replace(/\r$/, '');
4
4
  }
@@ -6,21 +6,11 @@ function snapshotsFit(parsed, contents, ignoreWhitespace) {
6
6
  if (parsed.partial || parsed.hunks.length === 0) {
7
7
  return false;
8
8
  }
9
- const rawLines = (text) => {
10
- if (text === '') {
11
- return [];
12
- }
13
- const lines = text.split('\n');
14
- if (text.endsWith('\n')) {
15
- lines.pop();
16
- }
17
- return lines;
18
- };
19
9
  const oldLines = rawLines(contents.old);
20
10
  const newLines = rawLines(contents.new);
21
11
  const oldShape = shapeOf(contents.old);
22
12
  const newShape = shapeOf(contents.new);
23
- const same = (left, right) => (ignoreWhitespace ? left.replace(/\s+/g, '') === right.replace(/\s+/g, '') : left === right);
13
+ const same = lineMatcher(ignoreWhitespace);
24
14
  let oldAt = 0;
25
15
  let newAt = 0;
26
16
  const gapFits = (oldEnd, newEnd) => {
@@ -184,14 +174,14 @@ export function diffRows(document, layout, unfolded = new Map()) {
184
174
  };
185
175
  let oldAt = 0;
186
176
  let newAt = 0;
187
- document.hunks.forEach((hunk, index) => {
177
+ for (const [index, hunk] of document.hunks.entries()) {
188
178
  gap(index, oldAt, newAt, hunk.oldStart - oldAt, hunk.newStart - newAt, index === 0 ? 'before' : 'between');
189
179
  for (const row of hunkRows(document, index, layout)) {
190
180
  rows.push(row);
191
181
  }
192
182
  oldAt = hunk.oldStart + hunk.oldCount;
193
183
  newAt = hunk.newStart + hunk.newCount;
194
- });
184
+ }
195
185
  if (document.snapshots) {
196
186
  gap(document.hunks.length, oldAt, newAt, document.sides.old.length - oldAt, document.sides.new.length - newAt, 'after');
197
187
  }
package/dist/patch-of.js CHANGED
@@ -1,15 +1,5 @@
1
1
  import { diffChanges } from './diff.js';
2
- import { shapeOf } from './text.js';
3
- function fileLines(text) {
4
- if (text === '') {
5
- return [];
6
- }
7
- const lines = text.split('\n');
8
- if (text.endsWith('\n')) {
9
- lines.pop();
10
- }
11
- return lines;
12
- }
2
+ import { rawLines, shapeOf } from './text.js';
13
3
  function keysOf(lines, finalNewline) {
14
4
  return lines.map((line, index) => `${index === lines.length - 1 && !finalNewline ? 'unterminated:' : 'terminated:'}${line}`);
15
5
  }
@@ -47,8 +37,8 @@ export function patchOf(oldText, newText, context = 3, options) {
47
37
  if (maxWork !== undefined && (!Number.isSafeInteger(maxWork) || maxWork < 0)) {
48
38
  throw new RangeError('Patch work budget must be a nonnegative safe integer.');
49
39
  }
50
- const old = fileLines(oldText);
51
- const fresh = fileLines(newText);
40
+ const old = rawLines(oldText);
41
+ const fresh = rawLines(newText);
52
42
  const oldShape = shapeOf(oldText);
53
43
  const newShape = shapeOf(newText);
54
44
  const oldKeys = keysOf(old, oldShape.finalNewline);
package/dist/patch.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { parsePatch } from './patch-parser.js';
2
+ import { lineMatcher } from './text.js';
2
3
  /* Which side a patch says the file is missing on, read from its header so no line of the file can pass for it. */
3
4
  export function missingSides(patch) {
4
5
  const start = patch.search(/^@@/m);
@@ -6,16 +7,14 @@ export function missingSides(patch) {
6
7
  return { old: /^--- \/dev\/null$/m.test(header), new: /^\+\+\+ \/dev\/null$/m.test(header) };
7
8
  }
8
9
  const HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
9
- /* Git's --ignore-all-space may show either side's spacing on a context line. A patch cut off before its hunks end fits nothing. */
10
+ /* A patch cut off before its hunks end fits nothing. */
10
11
  export function patchFits(patch, oldText, newText, ignoreWhitespace) {
11
12
  if (parsePatch(patch).partial) {
12
13
  return false;
13
14
  }
14
15
  const oldLines = oldText.split('\n');
15
16
  const newLines = newText.split('\n');
16
- const same = ignoreWhitespace
17
- ? (shown, actual) => actual !== undefined && shown.replace(/\s+/g, '') === actual.replace(/\s+/g, '')
18
- : (shown, actual) => shown === actual;
17
+ const same = lineMatcher(ignoreWhitespace);
19
18
  let oldAt = -1;
20
19
  let newAt = -1;
21
20
  let hunks = 0;
package/dist/resolve.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { MergeBlock } from './blocks.ts';
1
+ import { type MergeBlock } from './blocks.ts';
2
2
  export type MergeSide = 'ours' | 'theirs';
3
3
  export declare function sideLines(block: MergeBlock, side: MergeSide): string[];
4
4
  export declare function bothLines(block: MergeBlock, first: MergeSide): string[];
package/dist/resolve.js CHANGED
@@ -1,5 +1,6 @@
1
+ import { same } from './blocks.js';
1
2
  export function sideLines(block, side) {
2
- return side === 'ours' ? [...block.ours] : [...block.theirs];
3
+ return [...block[side]];
3
4
  }
4
5
  /* Both sides, one after the other, for the conflict where the two additions both belong. */
5
6
  export function bothLines(block, first) {
@@ -32,8 +33,11 @@ function runIn(outer, inner) {
32
33
  * what `outer` adds is one `inner` deleted, and taking `outer` would put it back.
33
34
  */
34
35
  function covers(base, outer, inner) {
36
+ if (inner.length === 0) {
37
+ return false;
38
+ }
35
39
  const start = runIn(outer, inner);
36
- if (inner.length === 0 || start < 0) {
40
+ if (start < 0) {
37
41
  return false;
38
42
  }
39
43
  const added = [...outer.slice(0, start), ...outer.slice(start + inner.length)];
@@ -48,10 +52,7 @@ export function wandLines(block) {
48
52
  if (block.kind !== 'conflict') {
49
53
  return autoLines(block);
50
54
  }
51
- if (block.ours.length === block.theirs.length && block.ours.every((line, index) => line === block.theirs[index])) {
52
- return [...block.ours];
53
- }
54
- if (covers(block.base, block.ours, block.theirs)) {
55
+ if (same(block.ours, block.theirs) || covers(block.base, block.ours, block.theirs)) {
55
56
  return [...block.ours];
56
57
  }
57
58
  if (covers(block.base, block.theirs, block.ours)) {
package/dist/text.d.ts CHANGED
@@ -4,4 +4,6 @@ export interface TextShape {
4
4
  }
5
5
  export declare function shapeOf(text: string): TextShape;
6
6
  export declare function splitLines(text: string): string[];
7
+ export declare function rawLines(text: string): string[];
8
+ export declare function lineMatcher(ignoreWhitespace: boolean): (shown: string, actual: string | undefined) => boolean;
7
9
  export declare function joinLines(lines: readonly string[], shape: TextShape): string;
package/dist/text.js CHANGED
@@ -17,6 +17,23 @@ export function splitLines(text) {
17
17
  }
18
18
  return lines;
19
19
  }
20
+ /* The lines of a file split on `\n` alone, so a `\r` stays part of its line. */
21
+ export function rawLines(text) {
22
+ if (text === '') {
23
+ return [];
24
+ }
25
+ const lines = text.split('\n');
26
+ if (text.endsWith('\n')) {
27
+ lines.pop();
28
+ }
29
+ return lines;
30
+ }
31
+ /* Git's --ignore-all-space may show either side's spacing, so a shown line matches by its non-space characters. */
32
+ export function lineMatcher(ignoreWhitespace) {
33
+ return ignoreWhitespace
34
+ ? (shown, actual) => actual !== undefined && shown.replace(/\s+/g, '') === actual.replace(/\s+/g, '')
35
+ : (shown, actual) => shown === actual;
36
+ }
20
37
  export function joinLines(lines, shape) {
21
38
  return lines.length === 0 ? '' : lines.join(shape.eol) + (shape.finalNewline ? shape.eol : '');
22
39
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/merge",
3
- "version": "0.38.0",
3
+ "version": "0.40.0",
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",
@@ -36,8 +36,8 @@
36
36
  "test:memory": "bun --config=scripts/memory.bunfig.toml test src/diff-memory.integration.test.ts"
37
37
  },
38
38
  "devDependencies": {
39
- "@types/bun": "^1.4.2",
40
- "@types/node": "^24.13.6",
39
+ "@types/bun": "^1.4.4",
40
+ "@types/node": "^24.19.2",
41
41
  "typescript": "^7.0.2"
42
42
  }
43
43
  }
package/src/blocks.ts CHANGED
@@ -14,7 +14,7 @@ export interface MergeBlock {
14
14
  theirs: string[];
15
15
  }
16
16
 
17
- function same(left: readonly string[], right: readonly string[]): boolean {
17
+ export function same(left: readonly string[], right: readonly string[]): boolean {
18
18
  return left.length === right.length && left.every((line, index) => line === right[index]);
19
19
  }
20
20
 
@@ -22,12 +22,15 @@ function kindOf(base: readonly string[], ours: readonly string[], theirs: readon
22
22
  if (same(ours, theirs)) {
23
23
  return same(base, ours) ? 'stable' : 'both';
24
24
  }
25
+
25
26
  if (same(base, ours)) {
26
27
  return 'theirs';
27
28
  }
29
+
28
30
  if (same(base, theirs)) {
29
31
  return 'ours';
30
32
  }
33
+
31
34
  return 'conflict';
32
35
  }
33
36
 
@@ -54,9 +57,11 @@ export function splitBlocks(base: readonly string[], ours: readonly string[], th
54
57
 
55
58
  const push = (baseEnd: number, ourEnd: number, theirEnd: number): void => {
56
59
  const stretch = { base: base.slice(baseAt, baseEnd), ours: ours.slice(ourAt, ourEnd), theirs: theirs.slice(theirAt, theirEnd) };
60
+
57
61
  if (stretch.base.length > 0 || stretch.ours.length > 0 || stretch.theirs.length > 0) {
58
62
  blocks.push({ kind: kindOf(stretch.base, stretch.ours, stretch.theirs), ...stretch });
59
63
  }
64
+
60
65
  baseAt = baseEnd;
61
66
  ourAt = ourEnd;
62
67
  theirAt = theirEnd;
@@ -64,19 +69,24 @@ export function splitBlocks(base: readonly string[], ours: readonly string[], th
64
69
 
65
70
  while (ourNext < ourChanges.length || theirNext < theirChanges.length) {
66
71
  const start = Math.min(ourChanges[ourNext]?.baseStart ?? Infinity, theirChanges[theirNext]?.baseStart ?? Infinity);
72
+
67
73
  if (start > baseAt) {
68
74
  const length = start - baseAt;
69
75
  push(start, ourAt + length, theirAt + length);
70
76
  }
77
+
71
78
  let end = baseAt;
72
79
  let ourGrowth = 0;
73
80
  let theirGrowth = 0;
81
+
74
82
  /* The stretch grows while a change of either side reaches into it: two changes that overlap
75
83
  in the base are one thing a person has to look at, however far apart they started. */
76
84
  for (let taken = true; taken;) {
77
85
  taken = false;
86
+
78
87
  const reaches = (change: Change | undefined): boolean =>
79
88
  change !== undefined && change.baseStart <= end && (change.baseStart < end || end === baseAt);
89
+
80
90
  while (reaches(ourChanges[ourNext])) {
81
91
  const change = ourChanges[ourNext]!;
82
92
  end = Math.max(end, change.baseEnd);
@@ -84,6 +94,7 @@ export function splitBlocks(base: readonly string[], ours: readonly string[], th
84
94
  ourNext += 1;
85
95
  taken = true;
86
96
  }
97
+
87
98
  while (reaches(theirChanges[theirNext])) {
88
99
  const change = theirChanges[theirNext]!;
89
100
  end = Math.max(end, change.baseEnd);
@@ -92,8 +103,11 @@ export function splitBlocks(base: readonly string[], ours: readonly string[], th
92
103
  taken = true;
93
104
  }
94
105
  }
106
+
95
107
  push(end, ourAt + (end - baseAt) + ourGrowth, theirAt + (end - baseAt) + theirGrowth);
96
108
  }
109
+
97
110
  push(base.length, ours.length, theirs.length);
111
+
98
112
  return blocks;
99
113
  }
@@ -18,6 +18,7 @@ export function diffDisplay(document: DiffDocument, unfolded?: ReadonlyMap<numbe
18
18
  const lines: string[] = [];
19
19
  const rows: DiffLineRow[] = [];
20
20
  const gaps: DiffDisplayGap[] = [];
21
+
21
22
  for (const row of diffRows(document, 'unified', unfolded)) {
22
23
  if (row.kind === 'gap') {
23
24
  gaps.push({ before: lines.length, row });
@@ -27,5 +28,6 @@ export function diffDisplay(document: DiffDocument, unfolded?: ReadonlyMap<numbe
27
28
  rows.push(row);
28
29
  }
29
30
  }
31
+
30
32
  return { text: lines.join('\n'), lines, rows, gaps };
31
33
  }
@@ -1,5 +1,5 @@
1
1
  import type { Hunk, ParsedPatch, PatchLine } from './patch-parser.ts';
2
- import { shapeOf, splitLines, type TextShape } from './text.ts';
2
+ import { lineMatcher, rawLines, shapeOf, splitLines, type TextShape } from './text.ts';
3
3
 
4
4
  export interface DiffLineRef {
5
5
  readonly index: number;
@@ -45,56 +45,58 @@ function snapshotsFit(parsed: ParsedPatch, contents: { old: string; new: string
45
45
  if (parsed.partial || parsed.hunks.length === 0) {
46
46
  return false;
47
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
- };
48
+
58
49
  const oldLines = rawLines(contents.old);
59
50
  const newLines = rawLines(contents.new);
60
51
  const oldShape = shapeOf(contents.old);
61
52
  const newShape = shapeOf(contents.new);
62
- const same = (left: string, right: string): boolean => (ignoreWhitespace ? left.replace(/\s+/g, '') === right.replace(/\s+/g, '') : left === right);
53
+ const same = lineMatcher(ignoreWhitespace);
63
54
  let oldAt = 0;
64
55
  let newAt = 0;
56
+
65
57
  const gapFits = (oldEnd: number, newEnd: number): boolean => {
66
58
  if (oldEnd - oldAt !== newEnd - newAt) {
67
59
  return false;
68
60
  }
61
+
69
62
  while (oldAt < oldEnd && newAt < newEnd) {
70
63
  if (!same(oldLines[oldAt]!, newLines[newAt]!)) {
71
64
  return false;
72
65
  }
66
+
73
67
  oldAt += 1;
74
68
  newAt += 1;
75
69
  }
70
+
76
71
  return true;
77
72
  };
73
+
78
74
  for (const hunk of parsed.hunks) {
79
75
  if (hunk.oldStart + hunk.oldCount > oldLines.length || hunk.newStart + hunk.newCount > newLines.length || !gapFits(hunk.oldStart, hunk.newStart)) {
80
76
  return false;
81
77
  }
78
+
82
79
  for (const line of hunk.lines) {
83
80
  if (line.kind !== 'insert') {
84
81
  if (!same(line.text, oldLines[oldAt]!) || Boolean(line.noNewline) !== (oldAt === oldLines.length - 1 && !oldShape.finalNewline)) {
85
82
  return false;
86
83
  }
84
+
87
85
  oldAt += 1;
88
86
  }
87
+
89
88
  if (line.kind !== 'delete') {
90
89
  if (!same(line.text, newLines[newAt]!) || Boolean(line.noNewline) !== (newAt === newLines.length - 1 && !newShape.finalNewline)) {
91
90
  return false;
92
91
  }
92
+
93
93
  newAt += 1;
94
94
  }
95
95
  }
96
96
  }
97
+
97
98
  const endShown = oldAt === oldLines.length && newAt === newLines.length;
99
+
98
100
  return gapFits(oldLines.length, newLines.length) && (oldShape.finalNewline === newShape.finalNewline || endShown);
99
101
  }
100
102
 
@@ -110,20 +112,26 @@ export function diffDocument(parsed: ParsedPatch, contents: { old: string; new:
110
112
  partial: false
111
113
  };
112
114
  }
115
+
113
116
  const old: string[] = [];
114
117
  const fresh: string[] = [];
118
+
115
119
  const offsets = parsed.hunks.map((hunk) => {
116
120
  const offset = { old: old.length, new: fresh.length };
121
+
117
122
  for (const line of hunk.lines) {
118
123
  if (line.kind !== 'insert') {
119
124
  old.push(sourceText(line));
120
125
  }
126
+
121
127
  if (line.kind !== 'delete') {
122
128
  fresh.push(sourceText(line));
123
129
  }
124
130
  }
131
+
125
132
  return offset;
126
133
  });
134
+
127
135
  return { hunks: parsed.hunks, sides: { old, new: fresh }, offsets, snapshots: false, ignoreWhitespace, partial: true };
128
136
  }
129
137
 
@@ -139,10 +147,12 @@ function hunkRows(document: DiffDocument, index: number, layout: 'unified' | 'sp
139
147
  const rows: DiffLineRow[] = [];
140
148
  const deletes: DiffLineRow[] = [];
141
149
  const inserts: DiffLineRow[] = [];
150
+
142
151
  const flush = (): void => {
143
152
  for (let pair = 0; pair < Math.max(deletes.length, inserts.length); pair++) {
144
153
  const old = deletes[pair];
145
154
  const fresh = inserts[pair];
155
+
146
156
  rows.push({
147
157
  kind: 'line',
148
158
  change: old && fresh ? 'pair' : old ? 'delete' : 'insert',
@@ -152,12 +162,15 @@ function hunkRows(document: DiffDocument, index: number, layout: 'unified' | 'sp
152
162
  ...newline(old?.noNewline === 'old', fresh?.noNewline === 'new')
153
163
  });
154
164
  }
165
+
155
166
  deletes.length = 0;
156
167
  inserts.length = 0;
157
168
  };
169
+
158
170
  for (const line of hunk.lines) {
159
171
  const old = line.kind === 'insert' ? undefined : { index: offset.old + oldAt, number: hunk.oldStart + oldAt + 1 };
160
172
  const fresh = line.kind === 'delete' ? undefined : { index: offset.new + newAt, number: hunk.newStart + newAt + 1 };
173
+
161
174
  const row: DiffLineRow = {
162
175
  kind: 'line',
163
176
  change: line.kind,
@@ -166,8 +179,10 @@ function hunkRows(document: DiffDocument, index: number, layout: 'unified' | 'sp
166
179
  ...(fresh ? { new: fresh } : {}),
167
180
  ...newline(Boolean(old && line.noNewline), Boolean(fresh && line.noNewline))
168
181
  };
182
+
169
183
  oldAt += old ? 1 : 0;
170
184
  newAt += fresh ? 1 : 0;
185
+
171
186
  if (layout === 'unified') {
172
187
  rows.push(row);
173
188
  } else if (line.kind === 'context') {
@@ -177,29 +192,37 @@ function hunkRows(document: DiffDocument, index: number, layout: 'unified' | 'sp
177
192
  (line.kind === 'delete' ? deletes : inserts).push(row);
178
193
  }
179
194
  }
195
+
180
196
  flush();
197
+
181
198
  return rows;
182
199
  }
183
200
 
184
201
  export function diffRows(document: DiffDocument, layout: 'unified' | 'split', unfolded: ReadonlyMap<number, number> = new Map()): DiffRow[] {
185
202
  const rows: DiffRow[] = [];
203
+
186
204
  const gap = (id: number, oldStart: number, newStart: number, oldCount: number, newCount: number, position: 'before' | 'between' | 'after'): void => {
187
205
  if (oldCount <= 0 && newCount <= 0) {
188
206
  return;
189
207
  }
208
+
190
209
  oldCount = Math.max(0, oldCount);
191
210
  newCount = Math.max(0, newCount);
192
211
  const unfoldable = document.snapshots && oldCount === newCount;
193
212
  const amount = unfolded.get(id) ?? 0;
213
+
194
214
  const count = unfoldable
195
215
  ? Math.min(oldCount, amount === Number.POSITIVE_INFINITY ? oldCount : Number.isFinite(amount) ? Math.max(0, Math.floor(amount)) : 0)
196
216
  : 0;
217
+
197
218
  const leading = position === 'before' ? 0 : count;
198
219
  const trailing = position === 'after' ? 0 : Math.min(count, oldCount - leading);
220
+
199
221
  const context = (from: number, to: number): void => {
200
222
  for (let line = from; line < to; line++) {
201
223
  const old = oldStart + line;
202
224
  const fresh = newStart + line;
225
+
203
226
  rows.push({
204
227
  kind: 'line',
205
228
  change: 'context',
@@ -212,7 +235,9 @@ export function diffRows(document: DiffDocument, layout: 'unified' | 'split', un
212
235
  });
213
236
  }
214
237
  };
238
+
215
239
  context(0, leading);
240
+
216
241
  if (oldCount - leading - trailing > 0 || newCount - leading - trailing > 0) {
217
242
  rows.push({
218
243
  kind: 'gap',
@@ -224,20 +249,27 @@ export function diffRows(document: DiffDocument, layout: 'unified' | 'split', un
224
249
  unfoldable
225
250
  });
226
251
  }
252
+
227
253
  context(oldCount - trailing, oldCount);
228
254
  };
255
+
229
256
  let oldAt = 0;
230
257
  let newAt = 0;
231
- document.hunks.forEach((hunk, index) => {
258
+
259
+ for (const [index, hunk] of document.hunks.entries()) {
232
260
  gap(index, oldAt, newAt, hunk.oldStart - oldAt, hunk.newStart - newAt, index === 0 ? 'before' : 'between');
261
+
233
262
  for (const row of hunkRows(document, index, layout)) {
234
263
  rows.push(row);
235
264
  }
265
+
236
266
  oldAt = hunk.oldStart + hunk.oldCount;
237
267
  newAt = hunk.newStart + hunk.newCount;
238
- });
268
+ }
269
+
239
270
  if (document.snapshots) {
240
271
  gap(document.hunks.length, oldAt, newAt, document.sides.old.length - oldAt, document.sides.new.length - newAt, 'after');
241
272
  }
273
+
242
274
  return rows;
243
275
  }
package/src/diff-split.ts CHANGED
@@ -16,28 +16,35 @@ export function diffSplit(document: DiffDocument, unfolded?: ReadonlyMap<number,
16
16
  const old = { lines: [] as string[], rows: [] as DiffLineRow[], gaps: [] as DiffDisplay['gaps'][number][] };
17
17
  const fresh = { lines: [] as string[], rows: [] as DiffLineRow[], gaps: [] as DiffDisplay['gaps'][number][] };
18
18
  const aligned: DiffSplitRow[] = [];
19
+
19
20
  for (const row of diffRows(document, 'split', unfolded)) {
20
21
  if (row.kind === 'gap') {
21
22
  old.gaps.push({ before: old.lines.length, row });
22
23
  fresh.gaps.push({ before: fresh.lines.length, row });
23
24
  aligned.push({ kind: 'gap', row });
25
+
24
26
  continue;
25
27
  }
28
+
26
29
  const entry = {
27
30
  kind: 'line' as const,
28
31
  row,
29
32
  old: row.old === undefined ? undefined : old.lines.length,
30
33
  new: row.new === undefined ? undefined : fresh.lines.length
31
34
  };
35
+
32
36
  if (row.old !== undefined) {
33
37
  old.lines.push(document.sides.old[row.old.index]!);
34
38
  old.rows.push({ ...row, new: undefined, noNewline: row.noNewline === 'new' ? undefined : row.noNewline });
35
39
  }
40
+
36
41
  if (row.new !== undefined) {
37
42
  fresh.lines.push(document.sides.new[row.new.index]!);
38
43
  fresh.rows.push({ ...row, old: undefined, noNewline: row.noNewline === 'old' ? undefined : row.noNewline });
39
44
  }
45
+
40
46
  aligned.push(entry);
41
47
  }
48
+
42
49
  return { old: { ...old, text: old.lines.join('\n') }, new: { ...fresh, text: fresh.lines.join('\n') }, aligned };
43
50
  }
package/src/diff.ts CHANGED
@@ -39,24 +39,31 @@ function extend(left: readonly string[], right: readonly string[], reach: Int32A
39
39
  if (work !== undefined && work.remaining-- <= 0) {
40
40
  return null;
41
41
  }
42
+
42
43
  const down = cameDown(reach, offset, diagonal, distance);
43
44
  let x = down ? reach[offset + diagonal + 1]! : reach[offset + diagonal - 1]! + 1;
44
45
  let y = x - diagonal;
46
+
45
47
  while (x < left.length && y < right.length) {
46
48
  if (work !== undefined && work.remaining-- <= 0) {
47
49
  return null;
48
50
  }
51
+
49
52
  if (left[x] !== right[y]) {
50
53
  break;
51
54
  }
55
+
52
56
  x += 1;
53
57
  y += 1;
54
58
  }
59
+
55
60
  reach[offset + diagonal] = x;
61
+
56
62
  if (x >= left.length && y >= right.length) {
57
63
  return true;
58
64
  }
59
65
  }
66
+
60
67
  return false;
61
68
  }
62
69
 
@@ -66,15 +73,18 @@ function backtrack(left: readonly string[], right: readonly string[], saved: rea
66
73
  let x = left.length;
67
74
  let y = right.length;
68
75
  const states = Array.from({ length: Math.min(distance, STRIDE) + 1 }, () => new Int32Array(2 * offset + 1));
76
+
69
77
  for (let step = distance; step > 0;) {
70
78
  // The states the rounds from the saved one below this step started in, walked again.
71
79
  const first = Math.floor((step - 1) / STRIDE) * STRIDE;
72
80
  states[0]!.set(saved[first / STRIDE]!);
81
+
73
82
  for (let round = first; round < step; round += 1) {
74
83
  const next = states[round - first + 1]!;
75
84
  next.set(states[round - first]!);
76
85
  extend(left, right, next, offset, round);
77
86
  }
87
+
78
88
  for (; step > first; step -= 1) {
79
89
  const state = states[step - first]!;
80
90
  const diagonal = x - y;
@@ -87,6 +97,7 @@ function backtrack(left: readonly string[], right: readonly string[], saved: rea
87
97
  y = prevY;
88
98
  }
89
99
  }
100
+
90
101
  return moves.reverse();
91
102
  }
92
103
 
@@ -102,18 +113,23 @@ function walk(left: readonly string[], right: readonly string[], maxWork?: numbe
102
113
  const saved: Int32Array[] = [];
103
114
  // Replay visits each earlier round once; reserve half the budget for backtracking.
104
115
  const work = maxWork === undefined ? undefined : { remaining: Math.floor(maxWork / 2) };
116
+
105
117
  for (let distance = 0; distance <= limit; distance += 1) {
106
118
  if (distance % STRIDE === 0) {
107
119
  saved.push(reach.slice());
108
120
  }
121
+
109
122
  const complete = extend(left, right, reach, offset, distance, work);
123
+
110
124
  if (complete === null) {
111
125
  return { moves: null, limited: true };
112
126
  }
127
+
113
128
  if (complete) {
114
129
  return { moves: backtrack(left, right, saved, distance, offset), limited: false };
115
130
  }
116
131
  }
132
+
117
133
  return { moves: null, limited: false };
118
134
  }
119
135
 
@@ -121,11 +137,13 @@ function walk(left: readonly string[], right: readonly string[], maxWork?: numbe
121
137
  function changesOf(moves: readonly Move[]): Change[] {
122
138
  const changes: Change[] = [];
123
139
  let current: Change | null = null;
140
+
124
141
  for (const move of moves) {
125
142
  // A delete moves along the base side and so raises the diagonal; an insert lowers it.
126
143
  const deleted = move.x - move.y > move.prevX - move.prevY;
127
144
  const editX = deleted ? move.prevX + 1 : move.prevX;
128
145
  const editY = deleted ? move.prevY : move.prevY + 1;
146
+
129
147
  if (current !== null && current.baseEnd === move.prevX && current.otherEnd === move.prevY) {
130
148
  current.baseEnd = editX;
131
149
  current.otherEnd = editY;
@@ -133,17 +151,21 @@ function changesOf(moves: readonly Move[]): Change[] {
133
151
  if (current !== null) {
134
152
  changes.push(current);
135
153
  }
154
+
136
155
  current = { baseStart: move.prevX, baseEnd: editX, otherStart: move.prevY, otherEnd: editY };
137
156
  }
157
+
138
158
  // What follows the edit is a run of equal lines, which ends the stretch.
139
159
  if (move.x !== editX || move.y !== editY) {
140
160
  changes.push(current);
141
161
  current = null;
142
162
  }
143
163
  }
164
+
144
165
  if (current !== null) {
145
166
  changes.push(current);
146
167
  }
168
+
147
169
  return changes;
148
170
  }
149
171
 
@@ -154,20 +176,27 @@ export function diffLines(base: readonly string[], other: readonly string[]): Ch
154
176
 
155
177
  export function diffChanges(base: readonly string[], other: readonly string[], maxWork?: number): { changes: Change[]; limited: boolean } {
156
178
  let prefix = 0;
179
+
157
180
  while (prefix < base.length && prefix < other.length && base[prefix] === other[prefix]) {
158
181
  prefix += 1;
159
182
  }
183
+
160
184
  let suffix = 0;
185
+
161
186
  while (suffix < base.length - prefix && suffix < other.length - prefix && base[base.length - 1 - suffix] === other[other.length - 1 - suffix]) {
162
187
  suffix += 1;
163
188
  }
189
+
164
190
  const left = base.slice(prefix, base.length - suffix);
165
191
  const right = other.slice(prefix, other.length - suffix);
192
+
166
193
  if (left.length === 0 && right.length === 0) {
167
194
  return { changes: [], limited: false };
168
195
  }
196
+
169
197
  const { moves, limited } = left.length === 0 || right.length === 0 ? { moves: null, limited: false } : walk(left, right, maxWork);
170
198
  const changes = moves === null ? [{ baseStart: 0, baseEnd: left.length, otherStart: 0, otherEnd: right.length }] : changesOf(moves);
199
+
171
200
  return {
172
201
  changes: changes.map((change) => ({
173
202
  baseStart: change.baseStart + prefix,
package/src/patch-of.ts CHANGED
@@ -1,17 +1,6 @@
1
1
  import { diffChanges, type Change } from './diff.ts';
2
2
  import type { Hunk, ParsedPatch, PatchLine } from './patch-parser.ts';
3
- import { shapeOf } from './text.ts';
4
-
5
- function fileLines(text: string): string[] {
6
- if (text === '') {
7
- return [];
8
- }
9
- const lines = text.split('\n');
10
- if (text.endsWith('\n')) {
11
- lines.pop();
12
- }
13
- return lines;
14
- }
3
+ import { rawLines, shapeOf } from './text.ts';
15
4
 
16
5
  function keysOf(lines: readonly string[], finalNewline: boolean): string[] {
17
6
  return lines.map((line, index) => `${index === lines.length - 1 && !finalNewline ? 'unterminated:' : 'terminated:'}${line}`);
@@ -24,15 +13,19 @@ function alignChanges(changes: Change[], old: readonly string[], fresh: readonly
24
13
  const newBound = previous?.otherEnd ?? 0;
25
14
  const insert = change.baseStart === change.baseEnd;
26
15
  const remove = change.otherStart === change.otherEnd;
16
+
27
17
  if (!insert && !remove) {
28
18
  continue;
29
19
  }
20
+
30
21
  // Move a repeated blank from the end of an edit to its start, between adjacent functions.
31
22
  while (change.baseStart > oldBound && change.otherStart > newBound) {
32
23
  const tail = insert ? fresh[change.otherEnd - 1]! : old[change.baseEnd - 1]!;
24
+
33
25
  if (tail.slice(tail.indexOf(':') + 1).trim() !== '' || old[change.baseStart - 1] !== tail || fresh[change.otherStart - 1] !== tail) {
34
26
  break;
35
27
  }
28
+
36
29
  change.baseStart -= 1;
37
30
  change.baseEnd -= 1;
38
31
  change.otherStart -= 1;
@@ -49,12 +42,15 @@ export function patchOf(oldText: string, newText: string, context = 3, options?:
49
42
  if (!Number.isSafeInteger(context) || context < 0) {
50
43
  throw new RangeError('Patch context must be a nonnegative safe integer.');
51
44
  }
45
+
52
46
  const maxWork = options?.maxWork;
47
+
53
48
  if (maxWork !== undefined && (!Number.isSafeInteger(maxWork) || maxWork < 0)) {
54
49
  throw new RangeError('Patch work budget must be a nonnegative safe integer.');
55
50
  }
56
- const old = fileLines(oldText);
57
- const fresh = fileLines(newText);
51
+
52
+ const old = rawLines(oldText);
53
+ const fresh = rawLines(newText);
58
54
  const oldShape = shapeOf(oldText);
59
55
  const newShape = shapeOf(newText);
60
56
  const oldKeys = keysOf(old, oldShape.finalNewline);
@@ -63,10 +59,12 @@ export function patchOf(oldText: string, newText: string, context = 3, options?:
63
59
  alignChanges(changes, oldKeys, newKeys);
64
60
  const hunks: Hunk[] = [];
65
61
  let group: Change[] = [];
62
+
66
63
  const finish = (): void => {
67
64
  if (group.length === 0) {
68
65
  return;
69
66
  }
67
+
70
68
  const first = group[0]!;
71
69
  const last = group.at(-1)!;
72
70
  const before = Math.min(context, first.baseStart, first.otherStart);
@@ -74,27 +72,35 @@ export function patchOf(oldText: string, newText: string, context = 3, options?:
74
72
  const oldStart = first.baseStart - before;
75
73
  const newStart = first.otherStart - before;
76
74
  const lines: PatchLine[] = [];
75
+
77
76
  const line = (kind: PatchLine['kind'], at: number): void => {
78
77
  const source = kind === 'insert' ? fresh : old;
79
78
  const shape = kind === 'insert' ? newShape : oldShape;
80
79
  lines.push({ kind, text: source[at]!, ...(at === source.length - 1 && !shape.finalNewline ? { noNewline: true as const } : {}) });
81
80
  };
81
+
82
82
  let oldAt = oldStart;
83
+
83
84
  for (const change of group) {
84
85
  for (; oldAt < change.baseStart; oldAt++) {
85
86
  line('context', oldAt);
86
87
  }
88
+
87
89
  for (; oldAt < change.baseEnd; oldAt++) {
88
90
  line('delete', oldAt);
89
91
  }
92
+
90
93
  for (let newAt = change.otherStart; newAt < change.otherEnd; newAt++) {
91
94
  line('insert', newAt);
92
95
  }
93
96
  }
97
+
94
98
  for (; oldAt < last.baseEnd + after; oldAt++) {
95
99
  line('context', oldAt);
96
100
  }
101
+
97
102
  const coarse = limited || group.some((change) => change.baseEnd - change.baseStart + change.otherEnd - change.otherStart > 4000);
103
+
98
104
  hunks.push({
99
105
  oldStart,
100
106
  newStart,
@@ -104,25 +110,34 @@ export function patchOf(oldText: string, newText: string, context = 3, options?:
104
110
  lines,
105
111
  ...(coarse ? { coarse: true as const } : {})
106
112
  });
113
+
107
114
  group = [];
108
115
  };
116
+
109
117
  for (const change of changes) {
110
118
  const previous = group.at(-1);
119
+
111
120
  if (previous !== undefined && change.baseStart - previous.baseEnd > 2 * context) {
112
121
  finish();
113
122
  }
123
+
114
124
  group.push(change);
115
125
  }
126
+
116
127
  finish();
117
128
  const parts: string[] = [];
129
+
118
130
  for (const hunk of hunks) {
119
131
  parts.push(`@@ -${rangeText(hunk.oldStart, hunk.oldCount)} +${rangeText(hunk.newStart, hunk.newCount)} @@`);
132
+
120
133
  for (const line of hunk.lines) {
121
134
  parts.push(`${line.kind === 'context' ? ' ' : line.kind === 'delete' ? '-' : '+'}${line.text}`);
135
+
122
136
  if (line.noNewline) {
123
137
  parts.push('\');
124
138
  }
125
139
  }
126
140
  }
141
+
127
142
  return { text: parts.length === 0 ? '' : `${parts.join('\n')}\n`, hunks, missing: { old: false, new: false }, partial: false };
128
143
  }
@@ -35,9 +35,11 @@ export interface HunkHeader {
35
35
  /* Reads one `@@ -a,b +c,d @@ section` line without its line ending; undefined when it is not a hunk header. */
36
36
  export function parseHunkHeader(line: string): HunkHeader | undefined {
37
37
  const match = HEADER.exec(line);
38
+
38
39
  if (match === null) {
39
40
  return undefined;
40
41
  }
42
+
41
43
  return {
42
44
  oldStart: Number(match[1]),
43
45
  oldCount: Number(match[2] ?? 1),
@@ -57,59 +59,78 @@ export function parsePatch(patch: string): ParsedPatch {
57
59
  let oldEnded = false;
58
60
  let newEnded = false;
59
61
  const header: string[] = [];
62
+
60
63
  const finish = (): void => {
61
64
  if (current !== undefined && (oldCount !== current.oldCount || newCount !== current.newCount)) {
62
65
  partial = true;
63
66
  }
67
+
64
68
  current = undefined;
65
69
  };
70
+
66
71
  for (let index = 0; index < lines.length; index++) {
67
72
  const line = lines[index]!;
68
73
  const metadata = line.replace(/\r$/, '');
74
+
69
75
  if (metadata.startsWith('@@')) {
70
76
  finish();
71
77
  const match = parseHunkHeader(metadata);
78
+
72
79
  if (match === undefined) {
73
80
  partial = true;
81
+
74
82
  continue;
75
83
  }
84
+
76
85
  const oldLength = match.oldCount;
77
86
  const newLength = match.newCount;
78
87
  const oldStart = match.oldStart - (oldLength === 0 ? 0 : 1);
79
88
  const newStart = match.newStart - (newLength === 0 ? 0 : 1);
89
+
80
90
  if (
81
91
  ![oldStart, newStart, oldLength, newLength, oldStart + oldLength, newStart + newLength].every(
82
92
  (value) => Number.isSafeInteger(value) && value >= 0
83
93
  )
84
94
  ) {
85
95
  partial = true;
96
+
86
97
  continue;
87
98
  }
99
+
88
100
  const previous = hunks.at(-1);
101
+
89
102
  if (previous !== undefined && (oldStart < previous.oldStart + previous.oldCount || newStart < previous.newStart + previous.newCount)) {
90
103
  partial = true;
91
104
  }
105
+
92
106
  current = { oldStart, oldCount: oldLength, newStart, newCount: newLength, section: match.section, lines: [] };
93
107
  hunks.push(current);
94
108
  oldCount = 0;
95
109
  newCount = 0;
110
+
96
111
  continue;
97
112
  }
113
+
98
114
  if (metadata.startsWith('diff --git ') && hunks.length > 0) {
99
115
  finish();
100
116
  partial = true;
117
+
101
118
  break;
102
119
  }
120
+
103
121
  if (current === undefined) {
104
122
  if (hunks.length === 0) {
105
123
  header.push(metadata);
106
124
  } else if (line !== '') {
107
125
  partial = true;
108
126
  }
127
+
109
128
  continue;
110
129
  }
130
+
111
131
  if (metadata === '\') {
112
132
  const previous = current.lines.at(-1);
133
+
113
134
  if (previous === undefined || previous.noNewline) {
114
135
  partial = true;
115
136
  } else {
@@ -117,28 +138,38 @@ export function parsePatch(patch: string): ParsedPatch {
117
138
  oldEnded ||= previous.kind !== 'insert';
118
139
  newEnded ||= previous.kind !== 'delete';
119
140
  }
141
+
120
142
  continue;
121
143
  }
144
+
122
145
  // The last empty split field is the patch's terminator, not a source line.
123
146
  if (line === '' && index === lines.length - 1) {
124
147
  continue;
125
148
  }
149
+
126
150
  const kind = line.startsWith('-') ? 'delete' : line.startsWith('+') ? 'insert' : line.startsWith(' ') || line === '' ? 'context' : undefined;
151
+
127
152
  if (kind === undefined) {
128
153
  partial = true;
154
+
129
155
  continue;
130
156
  }
157
+
131
158
  if (kind !== 'insert') {
132
159
  partial ||= oldEnded;
133
160
  oldCount += 1;
134
161
  }
162
+
135
163
  if (kind !== 'delete') {
136
164
  partial ||= newEnded;
137
165
  newCount += 1;
138
166
  }
167
+
139
168
  current.lines.push({ kind, text: line === '' ? '' : line.slice(1) });
140
169
  }
170
+
141
171
  finish();
172
+
142
173
  return {
143
174
  text: patch,
144
175
  hunks,
package/src/patch.ts CHANGED
@@ -1,56 +1,67 @@
1
1
  import { parsePatch } from './patch-parser.ts';
2
+ import { lineMatcher } from './text.ts';
2
3
 
3
4
  /* Which side a patch says the file is missing on, read from its header so no line of the file can pass for it. */
4
5
  export function missingSides(patch: string): { old: boolean; new: boolean } {
5
6
  const start = patch.search(/^@@/m);
6
7
  const header = start === -1 ? patch : patch.slice(0, start);
8
+
7
9
  return { old: /^--- \/dev\/null$/m.test(header), new: /^\+\+\+ \/dev\/null$/m.test(header) };
8
10
  }
9
11
 
10
12
  const HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
11
13
 
12
- /* Git's --ignore-all-space may show either side's spacing on a context line. A patch cut off before its hunks end fits nothing. */
14
+ /* A patch cut off before its hunks end fits nothing. */
13
15
  export function patchFits(patch: string, oldText: string, newText: string, ignoreWhitespace: boolean): boolean {
14
16
  if (parsePatch(patch).partial) {
15
17
  return false;
16
18
  }
19
+
17
20
  const oldLines = oldText.split('\n');
18
21
  const newLines = newText.split('\n');
19
- const same = ignoreWhitespace
20
- ? (shown: string, actual: string | undefined): boolean => actual !== undefined && shown.replace(/\s+/g, '') === actual.replace(/\s+/g, '')
21
- : (shown: string, actual: string | undefined): boolean => shown === actual;
22
+ const same = lineMatcher(ignoreWhitespace);
22
23
  let oldAt = -1;
23
24
  let newAt = -1;
24
25
  let hunks = 0;
26
+
25
27
  for (const line of patch.split('\n')) {
26
28
  const header = HUNK_HEADER.exec(line);
29
+
27
30
  if (header) {
28
31
  hunks += 1;
29
32
  oldAt = Number(header[1]) - (header[2] === '0' ? 0 : 1);
30
33
  newAt = Number(header[3]) - (header[4] === '0' ? 0 : 1);
34
+
31
35
  continue;
32
36
  }
37
+
33
38
  if (oldAt === -1) {
34
39
  continue;
35
40
  }
41
+
36
42
  const body = line.slice(1);
43
+
37
44
  if (line.startsWith(' ')) {
38
45
  if (!same(body, oldLines[oldAt]) || !same(body, newLines[newAt])) {
39
46
  return false;
40
47
  }
48
+
41
49
  oldAt += 1;
42
50
  newAt += 1;
43
51
  } else if (line.startsWith('-')) {
44
52
  if (!same(body, oldLines[oldAt])) {
45
53
  return false;
46
54
  }
55
+
47
56
  oldAt += 1;
48
57
  } else if (line.startsWith('+')) {
49
58
  if (!same(body, newLines[newAt])) {
50
59
  return false;
51
60
  }
61
+
52
62
  newAt += 1;
53
63
  }
54
64
  }
65
+
55
66
  return hunks > 0;
56
67
  }
package/src/resolve.ts CHANGED
@@ -1,9 +1,9 @@
1
- import type { MergeBlock } from './blocks.ts';
1
+ import { same, type MergeBlock } from './blocks.ts';
2
2
 
3
3
  export type MergeSide = 'ours' | 'theirs';
4
4
 
5
5
  export function sideLines(block: MergeBlock, side: MergeSide): string[] {
6
- return side === 'ours' ? [...block.ours] : [...block.theirs];
6
+ return [...block[side]];
7
7
  }
8
8
 
9
9
  /* Both sides, one after the other, for the conflict where the two additions both belong. */
@@ -32,6 +32,7 @@ function runIn(outer: readonly string[], inner: readonly string[]): number {
32
32
  return start;
33
33
  }
34
34
  }
35
+
35
36
  return -1;
36
37
  }
37
38
 
@@ -40,11 +41,18 @@ function runIn(outer: readonly string[], inner: readonly string[]): number {
40
41
  * what `outer` adds is one `inner` deleted, and taking `outer` would put it back.
41
42
  */
42
43
  function covers(base: readonly string[], outer: readonly string[], inner: readonly string[]): boolean {
44
+ if (inner.length === 0) {
45
+ return false;
46
+ }
47
+
43
48
  const start = runIn(outer, inner);
44
- if (inner.length === 0 || start < 0) {
49
+
50
+ if (start < 0) {
45
51
  return false;
46
52
  }
53
+
47
54
  const added = [...outer.slice(0, start), ...outer.slice(start + inner.length)];
55
+
48
56
  return added.every((line) => !base.includes(line) || inner.includes(line));
49
57
  }
50
58
 
@@ -57,15 +65,15 @@ export function wandLines(block: MergeBlock): string[] | null {
57
65
  if (block.kind !== 'conflict') {
58
66
  return autoLines(block);
59
67
  }
60
- if (block.ours.length === block.theirs.length && block.ours.every((line, index) => line === block.theirs[index])) {
61
- return [...block.ours];
62
- }
63
- if (covers(block.base, block.ours, block.theirs)) {
68
+
69
+ if (same(block.ours, block.theirs) || covers(block.base, block.ours, block.theirs)) {
64
70
  return [...block.ours];
65
71
  }
72
+
66
73
  if (covers(block.base, block.theirs, block.ours)) {
67
74
  return [...block.theirs];
68
75
  }
76
+
69
77
  return null;
70
78
  }
71
79
 
@@ -76,12 +84,15 @@ export function wandLines(block: MergeBlock): string[] | null {
76
84
  */
77
85
  export function fingerprint(block: MergeBlock): string {
78
86
  let hash = 0x811c9dc5;
87
+
79
88
  for (const line of [...block.ours, '\u0000', ...block.theirs]) {
80
89
  for (let index = 0; index < line.length; index += 1) {
81
90
  hash = Math.imul(hash ^ line.charCodeAt(index), 0x01000193);
82
91
  }
92
+
83
93
  hash = Math.imul(hash ^ 0x0a, 0x01000193);
84
94
  }
95
+
85
96
  return (hash >>> 0).toString(16).padStart(8, '0');
86
97
  }
87
98
 
@@ -106,13 +117,17 @@ export interface MergeDraft {
106
117
  export function draftOf(blocks: readonly MergeBlock[], answered: ReadonlyMap<number, readonly string[]> = new Map()): MergeDraft {
107
118
  const lines: string[] = [];
108
119
  const spans: MergeSpan[] = [];
120
+
109
121
  for (const [index, block] of blocks.entries()) {
110
122
  const from = lines.length;
123
+
111
124
  // One push per line: a block of a few hundred thousand lines is more arguments than V8 takes in one call.
112
125
  for (const line of answered.get(index) ?? autoLines(block) ?? block.ours) {
113
126
  lines.push(line);
114
127
  }
128
+
115
129
  spans.push({ block: index, kind: block.kind, from, to: lines.length });
116
130
  }
131
+
117
132
  return { lines, spans };
118
133
  }
package/src/text.ts CHANGED
@@ -10,6 +10,7 @@ export interface TextShape {
10
10
  /* The line ending a file uses, by the first one in it, and whether it ends on one. */
11
11
  export function shapeOf(text: string): TextShape {
12
12
  const line = text.indexOf('\n');
13
+
13
14
  return {
14
15
  eol: line > 0 && text[line - 1] === '\r' ? '\r\n' : '\n',
15
16
  finalNewline: text === '' || text.endsWith('\n')
@@ -21,13 +22,38 @@ export function splitLines(text: string): string[] {
21
22
  if (text === '') {
22
23
  return [];
23
24
  }
25
+
24
26
  const lines = text.split(/\r\n|\n/);
27
+
25
28
  if (lines[lines.length - 1] === '') {
26
29
  lines.pop();
27
30
  }
31
+
28
32
  return lines;
29
33
  }
30
34
 
35
+ /* The lines of a file split on `\n` alone, so a `\r` stays part of its line. */
36
+ export function rawLines(text: string): string[] {
37
+ if (text === '') {
38
+ return [];
39
+ }
40
+
41
+ const lines = text.split('\n');
42
+
43
+ if (text.endsWith('\n')) {
44
+ lines.pop();
45
+ }
46
+
47
+ return lines;
48
+ }
49
+
50
+ /* Git's --ignore-all-space may show either side's spacing, so a shown line matches by its non-space characters. */
51
+ export function lineMatcher(ignoreWhitespace: boolean): (shown: string, actual: string | undefined) => boolean {
52
+ return ignoreWhitespace
53
+ ? (shown, actual) => actual !== undefined && shown.replace(/\s+/g, '') === actual.replace(/\s+/g, '')
54
+ : (shown, actual) => shown === actual;
55
+ }
56
+
31
57
  export function joinLines(lines: readonly string[], shape: TextShape): string {
32
58
  return lines.length === 0 ? '' : lines.join(shape.eol) + (shape.finalNewline ? shape.eol : '');
33
59
  }
@@ -11,16 +11,21 @@ export function wordChanges(oldLine: string, newLine: string): { old: DiffWordRa
11
11
  if (oldLine.length + newLine.length > 20000) {
12
12
  return null;
13
13
  }
14
+
14
15
  segmenter ??= new Intl.Segmenter('en', { granularity: 'word' });
15
16
  const before = [...segmenter.segment(oldLine)];
16
17
  const after = [...segmenter.segment(newLine)];
18
+
17
19
  if (before.length + after.length > 2000) {
18
20
  return null;
19
21
  }
22
+
20
23
  const range = (segments: readonly Intl.SegmentData[], text: string, from: number, to: number): DiffWordRange[] =>
21
24
  from === to ? [] : [{ from: segments[from]!.index, to: segments[to]?.index ?? text.length }];
25
+
22
26
  const old: DiffWordRange[] = [];
23
27
  const fresh: DiffWordRange[] = [];
28
+
24
29
  for (const change of diffLines(
25
30
  before.map((part) => part.segment),
26
31
  after.map((part) => part.segment)
@@ -28,5 +33,6 @@ export function wordChanges(oldLine: string, newLine: string): { old: DiffWordRa
28
33
  old.push(...range(before, oldLine, change.baseStart, change.baseEnd));
29
34
  fresh.push(...range(after, newLine, change.otherStart, change.otherEnd));
30
35
  }
36
+
31
37
  return { old, new: fresh };
32
38
  }