@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,128 @@
1
+ import { diffChanges, type Change } from './diff.ts';
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
+ }
15
+
16
+ function keysOf(lines: readonly string[], finalNewline: boolean): string[] {
17
+ return lines.map((line, index) => `${index === lines.length - 1 && !finalNewline ? 'unterminated:' : 'terminated:'}${line}`);
18
+ }
19
+
20
+ function alignChanges(changes: Change[], old: readonly string[], fresh: readonly string[]): void {
21
+ for (const [index, change] of changes.entries()) {
22
+ const previous = changes[index - 1];
23
+ const oldBound = previous?.baseEnd ?? 0;
24
+ const newBound = previous?.otherEnd ?? 0;
25
+ const insert = change.baseStart === change.baseEnd;
26
+ const remove = change.otherStart === change.otherEnd;
27
+ if (!insert && !remove) {
28
+ continue;
29
+ }
30
+ // Move a repeated blank from the end of an edit to its start, between adjacent functions.
31
+ while (change.baseStart > oldBound && change.otherStart > newBound) {
32
+ const tail = insert ? fresh[change.otherEnd - 1]! : old[change.baseEnd - 1]!;
33
+ if (tail.slice(tail.indexOf(':') + 1).trim() !== '' || old[change.baseStart - 1] !== tail || fresh[change.otherStart - 1] !== tail) {
34
+ break;
35
+ }
36
+ change.baseStart -= 1;
37
+ change.baseEnd -= 1;
38
+ change.otherStart -= 1;
39
+ change.otherEnd -= 1;
40
+ }
41
+ }
42
+ }
43
+
44
+ function rangeText(start: number, count: number): string {
45
+ return `${start + (count === 0 ? 0 : 1)},${count}`;
46
+ }
47
+
48
+ export function patchOf(oldText: string, newText: string, context = 3, options?: { readonly maxWork?: number }): ParsedPatch {
49
+ if (!Number.isSafeInteger(context) || context < 0) {
50
+ throw new RangeError('Patch context must be a nonnegative safe integer.');
51
+ }
52
+ const maxWork = options?.maxWork;
53
+ if (maxWork !== undefined && (!Number.isSafeInteger(maxWork) || maxWork < 0)) {
54
+ throw new RangeError('Patch work budget must be a nonnegative safe integer.');
55
+ }
56
+ const old = fileLines(oldText);
57
+ const fresh = fileLines(newText);
58
+ const oldShape = shapeOf(oldText);
59
+ const newShape = shapeOf(newText);
60
+ const oldKeys = keysOf(old, oldShape.finalNewline);
61
+ const newKeys = keysOf(fresh, newShape.finalNewline);
62
+ const { changes, limited } = diffChanges(oldKeys, newKeys, maxWork);
63
+ alignChanges(changes, oldKeys, newKeys);
64
+ const hunks: Hunk[] = [];
65
+ let group: Change[] = [];
66
+ const finish = (): void => {
67
+ if (group.length === 0) {
68
+ return;
69
+ }
70
+ const first = group[0]!;
71
+ const last = group.at(-1)!;
72
+ const before = Math.min(context, first.baseStart, first.otherStart);
73
+ const after = Math.min(context, old.length - last.baseEnd, fresh.length - last.otherEnd);
74
+ const oldStart = first.baseStart - before;
75
+ const newStart = first.otherStart - before;
76
+ const lines: PatchLine[] = [];
77
+ const line = (kind: PatchLine['kind'], at: number): void => {
78
+ const source = kind === 'insert' ? fresh : old;
79
+ const shape = kind === 'insert' ? newShape : oldShape;
80
+ lines.push({ kind, text: source[at]!, ...(at === source.length - 1 && !shape.finalNewline ? { noNewline: true as const } : {}) });
81
+ };
82
+ let oldAt = oldStart;
83
+ for (const change of group) {
84
+ for (; oldAt < change.baseStart; oldAt++) {
85
+ line('context', oldAt);
86
+ }
87
+ for (; oldAt < change.baseEnd; oldAt++) {
88
+ line('delete', oldAt);
89
+ }
90
+ for (let newAt = change.otherStart; newAt < change.otherEnd; newAt++) {
91
+ line('insert', newAt);
92
+ }
93
+ }
94
+ for (; oldAt < last.baseEnd + after; oldAt++) {
95
+ line('context', oldAt);
96
+ }
97
+ const coarse = limited || group.some((change) => change.baseEnd - change.baseStart + change.otherEnd - change.otherStart > 4000);
98
+ hunks.push({
99
+ oldStart,
100
+ newStart,
101
+ oldCount: last.baseEnd + after - oldStart,
102
+ newCount: last.otherEnd + after - newStart,
103
+ section: '',
104
+ lines,
105
+ ...(coarse ? { coarse: true as const } : {})
106
+ });
107
+ group = [];
108
+ };
109
+ for (const change of changes) {
110
+ const previous = group.at(-1);
111
+ if (previous !== undefined && change.baseStart - previous.baseEnd > 2 * context) {
112
+ finish();
113
+ }
114
+ group.push(change);
115
+ }
116
+ finish();
117
+ const parts: string[] = [];
118
+ for (const hunk of hunks) {
119
+ parts.push(`@@ -${rangeText(hunk.oldStart, hunk.oldCount)} +${rangeText(hunk.newStart, hunk.newCount)} @@`);
120
+ for (const line of hunk.lines) {
121
+ parts.push(`${line.kind === 'context' ? ' ' : line.kind === 'delete' ? '-' : '+'}${line.text}`);
122
+ if (line.noNewline) {
123
+ parts.push('\');
124
+ }
125
+ }
126
+ }
127
+ return { text: parts.length === 0 ? '' : `${parts.join('\n')}\n`, hunks, missing: { old: false, new: false }, partial: false };
128
+ }
@@ -0,0 +1,148 @@
1
+ export interface PatchLine {
2
+ readonly kind: 'context' | 'delete' | 'insert';
3
+ readonly text: string;
4
+ readonly noNewline?: true;
5
+ }
6
+
7
+ export interface Hunk {
8
+ readonly oldStart: number;
9
+ readonly oldCount: number;
10
+ readonly newStart: number;
11
+ readonly newCount: number;
12
+ readonly section: string;
13
+ readonly coarse?: true;
14
+ readonly lines: readonly PatchLine[];
15
+ }
16
+
17
+ export interface ParsedPatch {
18
+ readonly text: string;
19
+ readonly hunks: readonly Hunk[];
20
+ readonly missing: { readonly old: boolean; readonly new: boolean };
21
+ readonly partial: boolean;
22
+ }
23
+
24
+ const HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(?: ?(.*))?$/;
25
+
26
+ /* The numbers of a hunk header as written: one-based starts, and a count that is one when omitted. */
27
+ export interface HunkHeader {
28
+ readonly oldStart: number;
29
+ readonly oldCount: number;
30
+ readonly newStart: number;
31
+ readonly newCount: number;
32
+ readonly section: string;
33
+ }
34
+
35
+ /* Reads one `@@ -a,b +c,d @@ section` line without its line ending; undefined when it is not a hunk header. */
36
+ export function parseHunkHeader(line: string): HunkHeader | undefined {
37
+ const match = HEADER.exec(line);
38
+ if (match === null) {
39
+ return undefined;
40
+ }
41
+ return {
42
+ oldStart: Number(match[1]),
43
+ oldCount: Number(match[2] ?? 1),
44
+ newStart: Number(match[3]),
45
+ newCount: Number(match[4] ?? 1),
46
+ section: match[5] ?? ''
47
+ };
48
+ }
49
+
50
+ export function parsePatch(patch: string): ParsedPatch {
51
+ const hunks: Hunk[] = [];
52
+ const lines = patch.split('\n');
53
+ let current: (Omit<Hunk, 'lines'> & { lines: PatchLine[] }) | undefined;
54
+ let oldCount = 0;
55
+ let newCount = 0;
56
+ let partial = false;
57
+ let oldEnded = false;
58
+ let newEnded = false;
59
+ const header: string[] = [];
60
+ const finish = (): void => {
61
+ if (current !== undefined && (oldCount !== current.oldCount || newCount !== current.newCount)) {
62
+ partial = true;
63
+ }
64
+ current = undefined;
65
+ };
66
+ for (let index = 0; index < lines.length; index++) {
67
+ const line = lines[index]!;
68
+ const metadata = line.replace(/\r$/, '');
69
+ if (metadata.startsWith('@@')) {
70
+ finish();
71
+ const match = parseHunkHeader(metadata);
72
+ if (match === undefined) {
73
+ partial = true;
74
+ continue;
75
+ }
76
+ const oldLength = match.oldCount;
77
+ const newLength = match.newCount;
78
+ const oldStart = match.oldStart - (oldLength === 0 ? 0 : 1);
79
+ const newStart = match.newStart - (newLength === 0 ? 0 : 1);
80
+ if (
81
+ ![oldStart, newStart, oldLength, newLength, oldStart + oldLength, newStart + newLength].every(
82
+ (value) => Number.isSafeInteger(value) && value >= 0
83
+ )
84
+ ) {
85
+ partial = true;
86
+ continue;
87
+ }
88
+ const previous = hunks.at(-1);
89
+ if (previous !== undefined && (oldStart < previous.oldStart + previous.oldCount || newStart < previous.newStart + previous.newCount)) {
90
+ partial = true;
91
+ }
92
+ current = { oldStart, oldCount: oldLength, newStart, newCount: newLength, section: match.section, lines: [] };
93
+ hunks.push(current);
94
+ oldCount = 0;
95
+ newCount = 0;
96
+ continue;
97
+ }
98
+ if (metadata.startsWith('diff --git ') && hunks.length > 0) {
99
+ finish();
100
+ partial = true;
101
+ break;
102
+ }
103
+ if (current === undefined) {
104
+ if (hunks.length === 0) {
105
+ header.push(metadata);
106
+ } else if (line !== '') {
107
+ partial = true;
108
+ }
109
+ continue;
110
+ }
111
+ if (metadata === '\') {
112
+ const previous = current.lines.at(-1);
113
+ if (previous === undefined || previous.noNewline) {
114
+ partial = true;
115
+ } else {
116
+ current.lines[current.lines.length - 1] = { ...previous, noNewline: true };
117
+ oldEnded ||= previous.kind !== 'insert';
118
+ newEnded ||= previous.kind !== 'delete';
119
+ }
120
+ continue;
121
+ }
122
+ // The last empty split field is the patch's terminator, not a source line.
123
+ if (line === '' && index === lines.length - 1) {
124
+ continue;
125
+ }
126
+ const kind = line.startsWith('-') ? 'delete' : line.startsWith('+') ? 'insert' : line.startsWith(' ') || line === '' ? 'context' : undefined;
127
+ if (kind === undefined) {
128
+ partial = true;
129
+ continue;
130
+ }
131
+ if (kind !== 'insert') {
132
+ partial ||= oldEnded;
133
+ oldCount += 1;
134
+ }
135
+ if (kind !== 'delete') {
136
+ partial ||= newEnded;
137
+ newCount += 1;
138
+ }
139
+ current.lines.push({ kind, text: line === '' ? '' : line.slice(1) });
140
+ }
141
+ finish();
142
+ return {
143
+ text: patch,
144
+ hunks,
145
+ missing: { old: header.some((line) => /^--- \/dev\/null(?:\t|$)/.test(line)), new: header.some((line) => /^\+\+\+ \/dev\/null(?:\t|$)/.test(line)) },
146
+ partial
147
+ };
148
+ }
package/src/patch.ts 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: string): { old: boolean; new: boolean } {
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
+
8
+ const HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
9
+
10
+ /* Git's --ignore-all-space may show either side's spacing on a context line. */
11
+ export function patchFits(patch: string, oldText: string, newText: string, ignoreWhitespace: boolean): boolean {
12
+ const oldLines = oldText.split('\n');
13
+ const newLines = newText.split('\n');
14
+ const same = ignoreWhitespace
15
+ ? (shown: string, actual: string | undefined): boolean => actual !== undefined && shown.replace(/\s+/g, '') === actual.replace(/\s+/g, '')
16
+ : (shown: string, actual: string | undefined): boolean => shown === actual;
17
+ let oldAt = -1;
18
+ let newAt = -1;
19
+ let hunks = 0;
20
+ for (const line of patch.split('\n')) {
21
+ const header = HUNK_HEADER.exec(line);
22
+ if (header) {
23
+ hunks += 1;
24
+ oldAt = Number(header[1]) - (header[2] === '0' ? 0 : 1);
25
+ newAt = Number(header[3]) - (header[4] === '0' ? 0 : 1);
26
+ continue;
27
+ }
28
+ if (oldAt === -1) {
29
+ continue;
30
+ }
31
+ const body = line.slice(1);
32
+ if (line.startsWith(' ')) {
33
+ if (!same(body, oldLines[oldAt]) || !same(body, newLines[newAt])) {
34
+ return false;
35
+ }
36
+ oldAt += 1;
37
+ newAt += 1;
38
+ } else if (line.startsWith('-')) {
39
+ if (!same(body, oldLines[oldAt])) {
40
+ return false;
41
+ }
42
+ oldAt += 1;
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,32 @@
1
+ import { diffLines } from './diff.ts';
2
+
3
+ export interface DiffWordRange {
4
+ readonly from: number;
5
+ readonly to: number;
6
+ }
7
+
8
+ let segmenter: Intl.Segmenter | undefined;
9
+
10
+ export function wordChanges(oldLine: string, newLine: string): { old: DiffWordRange[]; new: DiffWordRange[] } | null {
11
+ if (oldLine.length + newLine.length > 20000) {
12
+ return null;
13
+ }
14
+ segmenter ??= new Intl.Segmenter('en', { granularity: 'word' });
15
+ const before = [...segmenter.segment(oldLine)];
16
+ const after = [...segmenter.segment(newLine)];
17
+ if (before.length + after.length > 2000) {
18
+ return null;
19
+ }
20
+ const range = (segments: readonly Intl.SegmentData[], text: string, from: number, to: number): DiffWordRange[] =>
21
+ from === to ? [] : [{ from: segments[from]!.index, to: segments[to]?.index ?? text.length }];
22
+ const old: DiffWordRange[] = [];
23
+ const fresh: DiffWordRange[] = [];
24
+ for (const change of diffLines(
25
+ before.map((part) => part.segment),
26
+ after.map((part) => part.segment)
27
+ )) {
28
+ old.push(...range(before, oldLine, change.baseStart, change.baseEnd));
29
+ fresh.push(...range(after, newLine, change.otherStart, change.otherEnd));
30
+ }
31
+ return { old, new: fresh };
32
+ }