@adecore/merge 0.22.0 → 0.23.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/diff-display.d.ts +12 -0
- package/dist/diff-display.js +17 -0
- package/dist/diff-document.d.ts +47 -0
- package/dist/diff-document.js +199 -0
- package/dist/diff-split.d.ts +17 -0
- package/dist/diff-split.js +31 -0
- package/dist/diff.d.ts +4 -0
- package/dist/diff.js +35 -14
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/patch-of.d.ts +4 -0
- package/dist/patch-of.js +122 -0
- package/dist/patch-parser.d.ts +32 -0
- package/dist/patch-parser.js +112 -0
- package/dist/patch.d.ts +5 -0
- package/dist/patch.js +51 -0
- package/dist/word-changes.d.ts +8 -0
- package/dist/word-changes.js +21 -0
- package/package.json +1 -1
- package/src/diff-display.ts +31 -0
- package/src/diff-document.ts +243 -0
- package/src/diff-split.ts +43 -0
- package/src/diff.ts +40 -14
- package/src/index.ts +7 -0
- package/src/patch-of.ts +128 -0
- package/src/patch-parser.ts +148 -0
- package/src/patch.ts +51 -0
- package/src/word-changes.ts +32 -0
|
@@ -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
|
+
}
|
package/dist/patch.d.ts
ADDED
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,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
|
@@ -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
|
|
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
|
-
|
|
95
|
-
|
|
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
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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';
|