rapier-html 1.1.104 → 1.2.5

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,163 @@
1
+ // SPDX-License-Identifier: MIT
2
+ import {letter, within, prev, next} from './tokens.mjs';
3
+
4
+ // Separator-only pieces join the edit beside them; adjacent blocks each carry their own line break.
5
+ export function separate(c, endA = c.before.length, endB = c.after.length) {
6
+ const {before, after, safe, raw, spend} = c;
7
+ // A separator-only piece beside another edit joins it; equal separators at its ends stay unchanged.
8
+ if (!raw) for (let i = c.result.length - 1; i > 0; i--) {
9
+ const p = c.result[i - 1], q = c.result[i], side = p[0][0] === p[0][1] ? 1 : p[1][0] === p[1][1] ? 0 : -1, text = side ? after : before;
10
+ if (side >= 0 && !/\S/.test(text.slice(...p[side]))) {
11
+ let n = 0;
12
+ while (p[0][1] + n < q[0][0] && text[p[side][0] + n] === text[p[side][1] + n] && spend()) n++;
13
+ if (p[0][1] + n === q[0][0] && p.slice(0, 2).every((span, k) => safe(k ? after : before, span[0] + n) && safe(k ? after : before, span[1] + n)))
14
+ for (const span of p.slice(0, 2)) { span[0] += n; span[1] += n; }
15
+ }
16
+ if (p[2] || q[2] || p[0][1] !== q[0][0] || p[1][1] !== q[1][0] ||
17
+ ![p, q].some(r => !/\S/.test(before.slice(...r[0]) + after.slice(...r[1])))) continue;
18
+ const x = [p[0][0], q[0][1]], y = [p[1][0], q[1][1]], keep = /\S/.test(before.slice(...q[0]) + after.slice(...q[1])) ? q : p;
19
+ // CRLF steps as one separator.
20
+ for (let k; x[0] < x[1] && y[0] < y[1] && /\s/.test(before[x[1] - 1]) && before[x[1] - 1] === after[y[1] - 1] &&
21
+ (k = before.endsWith('\r\n', x[1]) && after.endsWith('\r\n', y[1]) ? 2 : 1) <= Math.min(x[1] - x[0], y[1] - y[0]) &&
22
+ safe(before, x[1] - k) && safe(after, y[1] - k);) { x[1] -= k; y[1] -= k; }
23
+ keep[3][0] = x[0] === x[1] ? 2 : y[0] === y[1] ? 0 : 1;
24
+ c.result.splice(i - 1, 2, [x, y, false, keep[3]]);
25
+ }
26
+ // Repeated closing lines can attach to the wrong block. Rotate an exact insertion/deletion to the
27
+ // next blank-line boundary, without crossing another edit or splitting a character.
28
+ if (!raw) for (let i = 0; i < c.result.length; i++) {
29
+ const p = c.result[i], q = c.result[i + 1], side = p[0][0] === p[0][1] ? 1 : p[1][0] === p[1][1] ? 0 : -1;
30
+ if (side < 0 || p[2]) continue;
31
+ const text = side ? after : before;
32
+ const room = Math.min((q?.[0][0] ?? endA) - p[0][1], (q?.[1][0] ?? endB) - p[1][1]);
33
+ let n = 0, shift = 0;
34
+ while (n < room && text[p[side][0] + n] === text[p[side][1] + n] && spend()) {
35
+ n++;
36
+ if (text[p[side][0] + n - 1] === '\n' && /^\r?\n/.test(text.slice(p[side][0] + n))) { shift = n; break; }
37
+ }
38
+ if (shift && p.slice(0, 2).every((span, k) => safe(k ? after : before, span[0] + shift) && safe(k ? after : before, span[1] + shift)))
39
+ for (const span of p.slice(0, 2)) { span[0] += shift; span[1] += shift; }
40
+ }
41
+ // Adjacent blocks deleted or inserted together: when the first carries the line break before it, each carries
42
+ // its own, so any one restores apart from its neighbours.
43
+ if (!raw) for (let i = 1; i < c.result.length; i++) {
44
+ const p = c.result[i - 1], q = c.result[i], kind = p[3][0], side = kind ? 1 : 0, text = side ? after : before;
45
+ if (kind % 2 || q[3][0] !== kind || p[2] || q[2] || p[side][1] !== q[side][0] || p[1 - side][0] !== q[1 - side][0]) continue;
46
+ const blank = /^\r?\n/.test(text.slice(...p[side])) && !/^\r?\n/.test(text.slice(...q[side])) && /(\r?\n)(?:[ \t]*\r?\n)+$/.exec(text.slice(...p[side]));
47
+ if (blank && blank.index > 0) p[side][1] = q[side][0] -= blank[0].length - blank[1].length;
48
+ }
49
+ }
50
+
51
+ // Refine the outer walk and each move's inner walk before assigning transport ownership.
52
+ export function phrases(c) {
53
+ const {before, after, raw, code} = c;
54
+ // Whole-word replacements separated only by spaces read and restore as one phrase, and one number stays one number.
55
+ if (!raw) for (let i = c.result.length - 1; i > 0; i--) {
56
+ const p = c.result[i - 1], q = c.result[i], gap = before.slice(p[0][1], q[0][0]);
57
+ if (p[3][0] !== 1 || q[3][0] !== 1 || p[3][3] || q[3][3] || p[2] || q[2] || gap !== after.slice(p[1][1], q[1][0])) continue;
58
+ // A time, a date, a version or a decimal is one number.
59
+ const number = /^(?:[.,:/-]\d*)+$/.test(gap) && /\d/.test(before[p[0][1] - 1] + after[p[1][1] - 1]) && /\d/.test(before[q[0][0]] + after[q[1][0]]);
60
+ if (!number && (code || !/^[^\S\r\n]+$/.test(gap) || within(before, p[0][0]) || within(after, p[1][0]) ||
61
+ within(before, q[0][1]) || within(after, q[1][1]))) continue;
62
+ p[0][1] = q[0][1]; p[1][1] = q[1][1]; c.result.splice(i, 1);
63
+ }
64
+ // Paired inline markers added or removed around unchanged text on one line are one concern.
65
+ if (!raw && !code) for (let i = 1; i < c.result.length; i++) {
66
+ const p = c.result[i - 1], q = c.result[i], kind = p[3][0], side = kind ? 1 : 0, text = side ? after : before;
67
+ if (kind % 2 || kind !== q[3][0] || p[2] || q[2] || p[3][3] || q[3][3]) continue;
68
+ const open = text.slice(...p[side]), close = text.slice(...q[side]), inner = text.slice(p[side][1], q[side][0]);
69
+ if (!inner.trim() || inner.includes('\n') || !(open === close && /^(?:[*_]{1,3}|~~|==|`+)$/.test(open) ||
70
+ open === '[' && /^\]\([^\s()]*\)$/.test(close))) continue;
71
+ p[3] = q[3] = [kind, 'word', p[3][2], [[p[0][0], q[0][1]], [p[1][0], q[1][1]]]];
72
+ }
73
+ }
74
+
75
+ // Readable concerns: phrases, numbers and paired markers, then the public edits and changes with context.
76
+ export function concerns(c) {
77
+ phrases(c);
78
+ const {before, after, spend, safe, budget, raw, code, evidence, structures, initial, options, pa, pb} = c, lb = c.lb;
79
+ const overlaps = (a, b) => a[0] < b[1] && a[1] > b[0];
80
+ // Siblings are ordered and disjoint: the parts holding a range are found by descent, outermost first.
81
+ const containing = (parts, from, to, out = []) => {
82
+ let lo = 0, hi = parts.length;
83
+ while (lo < hi) { const mid = lo + hi >>> 1; if (parts[mid].end < to) lo = mid + 1; else hi = mid; }
84
+ for (let k = lo; k < parts.length && parts[k].start <= from && spend(); k++)
85
+ if (parts[k].end >= to) { out.push(parts[k]); containing(parts[k].children, from, to, out); }
86
+ return out;
87
+ };
88
+ // Edits are ordered and disjoint on the after side.
89
+ const touched = (edits, range) => {
90
+ let lo = 0, hi = edits.length;
91
+ while (lo < hi) { const mid = lo + hi >>> 1; if (edits[mid].after[1] <= range[0]) lo = mid + 1; else hi = mid; }
92
+ return lo < edits.length && overlaps(range, edits[lo].after);
93
+ };
94
+ const changes = [], edits = [];
95
+ let normalized;
96
+ const meaning = structures?.[0]?.meaning;
97
+ const format = typeof meaning === 'string' && meaning === structures[1].meaning;
98
+ for (let i = 0; i < c.result.length; i++) {
99
+ const item = c.result[i];
100
+ if (raw === 'char' && !evidence) {
101
+ const a = item[0], b = item[1], next = c.result[i + 1];
102
+ if (item[3][0] !== 1) {
103
+ const room = Math.min((next?.[0][0] ?? before.length) - a[1], (next?.[1][0] ?? after.length) - b[1]);
104
+ let n = 0;
105
+ while (n < room && before[a[0] + n] === after[b[0] + n] && spend()) n++;
106
+ while (n && (!safe(before, a[0] + n) || !safe(before, a[1] + n) || !safe(after, b[0] + n) || !safe(after, b[1] + n))) n--;
107
+ a[0] += n; a[1] += n; b[0] += n; b[1] += n;
108
+ }
109
+ const p = normalized;
110
+ if (p && p[0][0] === p[0][1] && item[3][0] === 2 && p[0][0] === a[0] && p[1][1] === b[0]) {
111
+ p[1][1] = b[1]; if (item[2]) edits.at(-1).coarse = true; continue;
112
+ }
113
+ normalized = item;
114
+ }
115
+ const group = item[3];
116
+ if (!raw && !group[3]) group[3] = [[...item[0]], [...item[1]]];
117
+ if (!group[4]) {
118
+ const context = group[3];
119
+ // Context completes a word only where an edge falls inside it.
120
+ if (!raw) for (const [text, span] of [[before, context[0]], [after, context[1]]]) {
121
+ const start = within(text, span[0]), end = within(text, span[1]);
122
+ for (let c; start && letter(c = prev(text, span[0])) && spend() && safe(text, span[0] - c.length);) span[0] -= c.length;
123
+ for (let c; end && letter(c = next(text, span[1])) && spend() && safe(text, span[1] + c.length);) span[1] += c.length;
124
+ }
125
+ group[4] = []; changes.push(group);
126
+ if (format) group[0] = 5;
127
+ }
128
+ group[4].push(edits.length);
129
+ edits.push({before: item[0], after: item[1], ...(item[2] ? {coarse: true} : {})});
130
+ }
131
+ if (structures && !raw && budget.left >= 0) {
132
+ const oldNames = new Set(pa.filter(p => p.nameAt).map(p => p.name)), newNames = new Set(pb.filter(p => p.nameAt).map(p => p.name));
133
+ const linksTo = new Map();
134
+ if (spend(pa.length + pb.length + lb.length)) lb.forEach((link, k) => linksTo.set(link.to, [...linksTo.get(link.to) || [], k]));
135
+ for (const change of changes) {
136
+ if (change[0] === 5) continue;
137
+ if (budget.left < 0) break;
138
+ const range = change[3];
139
+ const older = containing(structures[0].parts, ...range[0]), newer = containing(structures[1].parts, ...range[1]);
140
+ const part = (change[0] === 0 ? older : newer).at(-1) || older.at(-1);
141
+ if (!change[2] && part?.name) { change[2] = part.name; change[1] = part.kind; }
142
+ const names = new Set([change[2], ...older.map(p => p.name), ...newer.map(p => p.name)]), breaks = [], affects = [];
143
+ for (const k of [...names].flatMap(name => linksTo.get(name) || []).sort((a, b) => a - b)) {
144
+ const {at, to} = lb[k];
145
+ if (!newNames.has(to)) { if (oldNames.has(to)) breaks.push({at, to}); continue; }
146
+ if (!spend() || touched(edits, at)) continue;
147
+ const p = containing(structures[1].parts, ...at).filter(p => /^(paragraph|call|item|task|cell|declaration)$/.test(p.kind)).at(-1);
148
+ affects.push(p && !touched(edits, [p.start, p.end]) ? [p.start, p.end] : at);
149
+ }
150
+ if (breaks.length) change[6] = breaks;
151
+ if (affects.length) change[5] = affects;
152
+ }
153
+ }
154
+ if (options._stats) Object.assign(options._stats, {work: Math.max(0, initial - Math.max(0, budget.left)), coarse: edits.some(c => c.coarse)});
155
+ // Concern tuples: kind, unit, name, context, edits, affects, breaks. Wording inside a move shares its context.
156
+ const moves = new Map(changes.map((change, i) => change[0] === 3 && [change[3], i + 1]).filter(Boolean));
157
+ return {edits, changes: changes.map(([kind, unit, name, context, owned, affects, breaks], i) => ({
158
+ id: i + 1, kind: ['delete', 'replace', 'insert', 'move', 'rename', 'format'][kind], unit, edits: owned,
159
+ ...(name ? {name} : {}), ...(context ? {context: {before: context[0], after: context[1]}} : {}),
160
+ ...(kind !== 3 && moves.has(context) ? {move: moves.get(context)} : {}),
161
+ ...(affects ? {affects} : {}), ...(breaks ? {breaks} : {})
162
+ }))};
163
+ }
@@ -0,0 +1,68 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // One comparison: observe structure, walk the source into exact records, find moves and renames, then read them as
3
+ // concerns. Each stage lives in its own module and shares one comparison context.
4
+ import {observe} from './structure.mjs';
5
+ import {boundaries, tokenizer, wordSegmenter} from './tokens.mjs';
6
+ import {matcher} from './matching.mjs';
7
+ import {walker} from './walk.mjs';
8
+ import {moves} from './moves.mjs';
9
+ import {separate, concerns} from './concerns.mjs';
10
+
11
+ export function diff(before, after, options = {}) {
12
+ if (before === after) return {edits: [], changes: []};
13
+ const budget = options._budget || {left: Number.isFinite(options.limit) ? Math.max(0, options.limit) : 1e7};
14
+ const spend = (n = 1) => (budget.left -= n) >= 0, safe = boundaries(), raw = options._unit;
15
+ // Internal records: [before range, after range, coarse, concern, transported fragments].
16
+ const c = {before, after, options, budget, spend, safe, raw, chars: options.unit === 'char', initial: budget.left,
17
+ segmenter: wordSegmenter(), result: [], code: options.as === 'code'};
18
+ // A Markdown fence holds code inside prose; only code outside fences makes the text code.
19
+ if (options.as !== 'prose' && options.as !== 'code') c.code = /(?:^|\n)\s*(?:[{}]|(?:function|class|const|let|var|import|export|def|fn|func|package)\s+\w[^]{0,80}[({=:;])/
20
+ .test((before.slice(0, 4096) + '\n' + after.slice(0, 4096)).replace(/^ {0,3}(`{3,}|~{3,})[^]*?(?:^ {0,3}\1[`~]*[ \t]*$|(?![^]))/gm, ''));
21
+ let fallback;
22
+ if (options.structure && (!raw || options._structure)) {
23
+ let structures;
24
+ try {
25
+ structures = options._observed || [observe(before, options.structure, spend, safe), observe(after, options.structure, spend, safe)];
26
+ if (structures.some(value => !value || value.invalid)) throw null;
27
+ const [pa, pb] = structures.map(value => value.flat), aliases = new Map();
28
+ const oldByKey = new Map(pa.filter(p => p.key != null).map(p => [p.key, p]));
29
+ const oldNames = new Set(pa.map(p => p.name)), newNames = new Set(pb.map(p => p.name));
30
+ for (const p of pb) {
31
+ const old = oldByKey.get(p.key);
32
+ if (old?.nameAt && p.nameAt && old.kind === p.kind && !oldNames.has(p.name) && !newNames.has(old.name)) aliases.set(p, old);
33
+ }
34
+ Object.assign(c, {structures, pa, pb, la: structures[0].links, lb: structures[1].links, aliases});
35
+ } catch {
36
+ // A failed reader preserves an explicit prose choice and reports why structure was not used.
37
+ const failed = structures?.find(value => !value || value.invalid);
38
+ fallback = {structure: failed?.threw ? 'threw' : budget.left < 0 ? 'limit' : 'invalid', ...(failed?.threw ? {error: failed.error} : {})};
39
+ c.code = options.as !== 'prose';
40
+ }
41
+ }
42
+ c.tokens = tokenizer(c); c.matches = matcher(spend, budget);
43
+ const {walk, operations} = Object.assign(c, walker(c)), {structures, pa, pb} = c;
44
+ const evidence = c.evidence = options.from && operations(options.from);
45
+ if (options.from && !evidence) (fallback ||= {}).from = budget.left < 0 ? 'limit' : 'invalid';
46
+ if (evidence) for (const span of evidence) walk(...span, structures ? 0 : raw === 'char' ? 3 : 1, pa, pb);
47
+ else walk(0, before.length, 0, after.length, structures ? 0 : raw === 'word' ? 2 : 1,
48
+ structures?.[0]?.parts, structures?.[1]?.parts);
49
+ separate(c);
50
+ moves(c);
51
+ // A heading edit can re-record its children. Compare both walks after transport is known: unchanged moved
52
+ // fragments cost nothing, so a smaller plain walk cannot discard a move just by aligning some of its lines.
53
+ if (structures && !evidence && !raw && c.result.some(r => r[0][0] === r[0][1]) && c.result.some(r => r[1][0] === r[1][1]) &&
54
+ budget.left >= c.initial - budget.left) {
55
+ const size = (rows, transport = false) => rows.reduce((n, r) => n + (!transport && r[3][0] === 3 ? 0 : r[0][1] - r[0][0] + r[1][1] - r[1][0]), 0);
56
+ const walked = c.result, links = c.lb, cost = size(walked);
57
+ if (cost) {
58
+ c.result = []; c.lb = structures[1].links;
59
+ walk(0, before.length, 0, after.length, 1);
60
+ separate(c); moves(c);
61
+ const plain = size(c.result);
62
+ if (budget.left < 0 || c.result.some(r => r[2]) || plain > cost ||
63
+ plain === cost && size(c.result, true) >= size(walked, true)) { c.result = walked; c.lb = links; }
64
+ }
65
+ }
66
+ const {edits, changes} = concerns(c);
67
+ return {edits, ...(fallback && !raw ? {fallback} : {}), changes};
68
+ }
@@ -0,0 +1,5 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // The package entry: compare two texts, or merge two edits of one base.
3
+ import {diff} from './diff.mjs';
4
+ import {merge} from './merge.mjs';
5
+ export {diff, merge};
@@ -0,0 +1,157 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Subsequence search over token keys, bounded by a shared work budget.
3
+ export function matcher(spend, budget) {
4
+ // Unique anchors and fallback equal pairs share one bounded subsequence search. Repetitive text, where equal pairs
5
+ // are too many to visit, anchors on runs of tokens found once on each side, then on positions, then on a shortest
6
+ // script; only a range none of them can afford within the work limit stays coarse.
7
+ function matches(a, b) {
8
+ const found = new Array(a.length), pending = [[0, a.length, 0, b.length]];
9
+ // Patience chain over candidate pairs in a order; unique anchors split the range into searched gaps.
10
+ const chain = (pairs, a0, a1, b0, b1, split, near) => {
11
+ const tails = [];
12
+ for (const [i, j] of pairs) {
13
+ let lo = 0, hi = tails.length;
14
+ while (lo < hi) { const middle = lo + hi >>> 1; if (tails[middle][1] < j) lo = middle + 1; else hi = middle; }
15
+ tails[lo] = [i, j, tails[lo - 1]];
16
+ }
17
+ const kept = [];
18
+ for (let node = tails.at(-1); node; node = node[2]) kept.push(node);
19
+ let x = a0, y = b0;
20
+ for (const [i, j] of kept.reverse()) {
21
+ found[i] = j;
22
+ if (split && x < i && y < j) pending.push([x, i, y, j, near]);
23
+ x = i + 1; y = j + 1;
24
+ }
25
+ if (split && x < a1 && y < b1) pending.push([x, a1, y, b1, near]);
26
+ };
27
+ while (pending.length) {
28
+ let [a0, a1, b0, b1, near] = pending.pop();
29
+ while (a0 < a1 && b0 < b1 && a[a0] === b[b0]) found[a0++] = b0++;
30
+ while (a0 < a1 && b0 < b1 && a[a1 - 1] === b[b1 - 1]) found[--a1] = --b1;
31
+ if (a0 === a1 || b0 === b1 || !spend(a1 - a0 + b1 - b0)) continue;
32
+ // Between anchored runs the change is usually small; a token found once by chance must not misalign it.
33
+ if (near && shortest(a, b, a0, a1, b0, b1, found, 4 * (a1 - a0 + b1 - b0))) continue;
34
+ const positions = new Map();
35
+ for (let j = b0; j < b1; j++) {
36
+ const list = positions.get(b[j]);
37
+ if (list) list.push(j); else positions.set(b[j], [0, j]);
38
+ }
39
+ for (let i = a0; i < a1; i++) {
40
+ const list = positions.get(a[i]); if (list) list[0]++;
41
+ }
42
+ let unique = 0, count = 0;
43
+ for (const list of positions.values()) {
44
+ count += list[0] * (list.length - 1);
45
+ if (list[0] === 1 && list.length === 2) unique++;
46
+ }
47
+ if (!count) continue;
48
+ // With no unique anchors, equal-length repeated runs prefer their witnessed positions to an arbitrary LIS.
49
+ if (!unique && a1 - a0 === b1 - b0) {
50
+ const anchors = diagonal(a, b, a0, a1, b0, b1);
51
+ if (anchors) { chain(anchors, a0, a1, b0, b1, true, true); continue; }
52
+ }
53
+ // Charge the chain before building it: one that cannot be afforded falls through to cheaper anchors.
54
+ const chained = unique || count, cost = chained * Math.ceil(Math.log2(chained + 2));
55
+ if (cost <= budget.left) {
56
+ const pairs = [];
57
+ for (let i = a0; i < a1; i++) {
58
+ const list = positions.get(a[i]);
59
+ if (!list || unique && (list[0] !== 1 || list.length !== 2)) continue;
60
+ for (let p = list.length - 1; p > 0; p--) pairs.push([i, list[p]]);
61
+ }
62
+ spend(cost);
63
+ chain(pairs, a0, a1, b0, b1, unique > 0);
64
+ continue;
65
+ }
66
+ // Few changes make a shortest script cheap (about n + d²); try that first at a bounded cost.
67
+ if (shortest(a, b, a0, a1, b0, b1, found, a1 - a0 + b1 - b0)) continue;
68
+ const anchors = runs(a, b, a0, a1, b0, b1) || diagonal(a, b, a0, a1, b0, b1);
69
+ if (anchors) chain(anchors, a0, a1, b0, b1, true, true);
70
+ else if (!shortest(a, b, a0, a1, b0, b1, found) && budget.left >= 0) (found.coarse ||= new Set()).add(a0);
71
+ }
72
+ return found;
73
+ }
74
+
75
+ // Runs of k tokens found once on each side anchor text whose single tokens all repeat. Window hashes are checked
76
+ // token by token, so a collision costs work, never correctness.
77
+ function runs(a, b, a0, a1, b0, b1) {
78
+ const ids = new Map(), id = key => ids.get(key) ?? (ids.set(key, ids.size), ids.size - 1);
79
+ const left = Int32Array.from({length: a1 - a0}, (_, k) => id(a[a0 + k])), right = Int32Array.from({length: b1 - b0}, (_, k) => id(b[b0 + k]));
80
+ for (const k of [16, 4, 64]) {
81
+ if (k > Math.min(left.length, right.length) || !spend(2 * (left.length + right.length))) return;
82
+ // A prime modulus below 2^31 and a base below 2^21 keep every product exact in a double.
83
+ const P = 2147483647, B = 1000003;
84
+ let power = 1;
85
+ for (let n = 1; n < k; n++) power = power * B % P;
86
+ const windows = list => {
87
+ const seen = new Map();
88
+ let hash = 0;
89
+ for (let n = 0; n < list.length; n++) {
90
+ if (n >= k) hash = (hash + P - (list[n - k] + 1) * power % P) % P;
91
+ hash = (hash * B + list[n] + 1) % P;
92
+ if (n >= k - 1) seen.set(hash, seen.has(hash) ? -1 : n - k + 1);
93
+ }
94
+ return seen;
95
+ };
96
+ const mine = windows(left), theirs = windows(right), pairs = [];
97
+ for (const [hash, i] of mine) {
98
+ const j = theirs.get(hash);
99
+ if (i < 0 || j === undefined || j < 0) continue;
100
+ let n = 0;
101
+ while (n < k && left[i + n] === right[j + n]) n++;
102
+ if (n === k) pairs.push([a0 + i, b0 + j]);
103
+ }
104
+ // Overlapping windows add nothing a gap's equal-run trim does not find; keep one per k tokens.
105
+ pairs.sort((p, q) => p[0] - q[0]);
106
+ const spaced = [];
107
+ for (const pair of pairs) if (!spaced.length || pair[0] >= spaced.at(-1)[0] + k) spaced.push(pair);
108
+ if (spaced.length && spend(pairs.length + spaced.length * Math.ceil(Math.log2(spaced.length + 2)))) return spaced;
109
+ }
110
+ }
111
+
112
+ // Repeated lines align by position: tokens equal on the start diagonal before a split and on the end diagonal after
113
+ // it, at the split that keeps the most. Used only when they cover at least half of the shorter side.
114
+ function diagonal(a, b, a0, a1, b0, b1) {
115
+ const n = a1 - a0, m = b1 - b0, shift = m - n, size = Math.min(n, m);
116
+ if (!spend(2 * (n + m))) return;
117
+ // head[s]: equal pairs on the start diagonal among the first s tokens; tail[s]: on the end diagonal from s on.
118
+ const head = new Int32Array(n + 1), tail = new Int32Array(n + 2);
119
+ for (let i = 0; i < n; i++) head[i + 1] = head[i] + (i < m && a[a0 + i] === b[b0 + i]);
120
+ for (let i = n - 1; i >= 0; i--) tail[i] = tail[i + 1] + (i + shift >= 0 && a[a0 + i] === b[b0 + i + shift]);
121
+ // A deletion (shift < 0) skips -shift tokens of a between the diagonals.
122
+ let best = -1, split = 0;
123
+ for (let s = 0; s <= n; s++) {
124
+ const rest = Math.min(n, s + Math.max(0, -shift)), kept = head[s] + tail[rest];
125
+ if (kept > best) { best = kept; split = s; }
126
+ }
127
+ if (best * 2 < size) return;
128
+ const pairs = [], rest = Math.min(n, split + Math.max(0, -shift));
129
+ for (let i = 0; i < split; i++) if (i < m && a[a0 + i] === b[b0 + i]) pairs.push([a0 + i, b0 + i]);
130
+ for (let i = rest; i < n; i++) if (i + shift >= 0 && a[a0 + i] === b[b0 + i + shift]) pairs.push([a0 + i, b0 + i + shift]);
131
+ return pairs;
132
+ }
133
+
134
+ // Myers' greedy search: O((N + M) D) steps; each frontier is kept for the backward pass. An attempt can stop at
135
+ // its own share of the work and report no script.
136
+ function shortest(a, b, a0, a1, b0, b1, found, share = Infinity) {
137
+ const n = a1 - a0, m = b1 - b0, v = new Int32Array(2 * (n + m) + 3), o = n + m + 1, trace = [];
138
+ const step = (k = 1) => (share -= k) >= 0 && spend(k);
139
+ for (let d = 0; step(2 * d + 1); d++) {
140
+ trace.push(v.slice(o - d, o + d + 1));
141
+ for (let k = -d; k <= d; k += 2) {
142
+ let x = k === -d || k !== d && v[o + k - 1] < v[o + k + 1] ? v[o + k + 1] : v[o + k - 1] + 1, y = x - k;
143
+ while (x < n && y < m && a[a0 + x] === b[b0 + y]) { if (!step()) return; x++; y++; }
144
+ v[o + k] = x;
145
+ if (x < n || y < m) continue;
146
+ for (x = n, y = m; d >= 0; d--) {
147
+ const w = trace[d], k = x - y, prev = k === -d || k !== d && w[d + k - 1] < w[d + k + 1] ? k + 1 : k - 1;
148
+ const px = d ? w[d + prev] : 0, py = px - prev;
149
+ while (x > px && y > py) if (a[a0 + --x] === b[b0 + --y]) found[a0 + x] = b0 + y;
150
+ x = px; y = py;
151
+ }
152
+ return true;
153
+ }
154
+ }
155
+ }
156
+ return matches;
157
+ }
@@ -0,0 +1,226 @@
1
+ // SPDX-License-Identifier: MIT
2
+ import {diff} from './diff.mjs';
3
+ import {observe} from './structure.mjs';
4
+ import {boundaries, words, wordSegmenter} from './tokens.mjs';
5
+
6
+ export function merge(base, mine, theirs, options = {}) {
7
+ if (mine === theirs || base === theirs || base === mine) return {text: base === mine ? theirs : mine, conflicts: []};
8
+ const budget = {...options, _unit: 'char', _structure: true,
9
+ _budget: options._budget || {left: Number.isFinite(options.limit) ? Math.max(0, options.limit) : 1e7}};
10
+ const sources = [base, mine, theirs];
11
+ const complete = () => ({text: mine, conflicts: [{at: [0, mine.length], theirs}]});
12
+ const spend = (n = 1) => (budget._budget.left -= n) >= 0;
13
+ const check = n => { if (!spend(n)) throw null; };
14
+ const safe = options.structure && boundaries();
15
+ const trees = options.structure && !options._edits
16
+ // A reader that throws gives no structure: the texts merge by source coordinates.
17
+ ? [base, mine, base, theirs].map(text => observe(text, options.structure, spend, safe, false, true)).map(tree => tree?.threw ? null : tree) : [];
18
+ const repeatedBase = trees.splice(2, 1)[0];
19
+ const equal = (a, b) => a.length === b.length && a.every((item, i) => item === b[i]);
20
+ if (trees.some(Boolean) || repeatedBase) {
21
+ try {
22
+ if (!trees.every(tree => tree && !tree.invalid) || !repeatedBase || repeatedBase.invalid) return complete();
23
+ const a = trees[0].flat, b = repeatedBase.flat;
24
+ check(a.length + b.length);
25
+ if (a.length !== b.length) return complete();
26
+ for (let i = 0; i < a.length; i++) {
27
+ const p = a[i], q = b[i];
28
+ if (p.start !== q.start || p.end !== q.end || p.kind !== q.kind || p.name !== q.name || p.key !== q.key ||
29
+ p.children.length !== q.children.length || !equal(p.nameAt || [], q.nameAt || [])) return complete();
30
+ }
31
+ for (const tree of trees) for (const part of tree.flat)
32
+ part.id = part.name && part.kind ? part.kind.length + ':' + part.kind + part.name : null;
33
+ const removed = [new Map(), new Map()], added = [new Map(), new Map()];
34
+ let container = 0;
35
+ const content = (part, side) => sources[side].slice(part.start, part.end);
36
+ function align(nodes) {
37
+ const original = content(nodes[0], 0);
38
+ if (original === content(nodes[1], 1) && original === content(nodes[2], 2)) {
39
+ nodes[0].common = original; return;
40
+ }
41
+ const hidden = nodes.flatMap(node => node.children.filter(part => !part.id));
42
+ while (hidden.length) {
43
+ const part = hidden.pop(); check(1);
44
+ if (part.id) throw null;
45
+ for (const child of part.children) hidden.push(child);
46
+ }
47
+ const rows = nodes.map(node => node.children.filter(part => part.id));
48
+ const width = rows.reduce((n, list) => n + list.length, 0);
49
+ check(1 + width);
50
+ let repeated = false;
51
+ const index = nameOf => {
52
+ const duplicates = new Set();
53
+ const maps = rows.map((list, side) => {
54
+ const map = new Map();
55
+ for (const part of list) {
56
+ const name = nameOf(part, side);
57
+ if (!name) continue;
58
+ const old = map.get(name);
59
+ if (!old) map.set(name, part);
60
+ else {
61
+ duplicates.add(name);
62
+ if (Array.isArray(old)) old.push(part); else map.set(name, [old, part]);
63
+ }
64
+ }
65
+ return map;
66
+ });
67
+ for (const name of duplicates) {
68
+ repeated = true;
69
+ const values = maps.map((map, side) => {
70
+ const part = map.get(name), list = Array.isArray(part) ? part : part ? [part] : [];
71
+ check(list.length); map.set(name, null);
72
+ return list.map(part => content(part, side));
73
+ });
74
+ // Each occurrence keeps its base text on one side, or the whole list does.
75
+ if (!equal(values[0], values[1]) && !equal(values[0], values[2]) && !(values.every(list => list.length === values[0].length) &&
76
+ values[0].every((text, k) => text === values[1][k] || text === values[2][k]))) throw null;
77
+ }
78
+ return maps;
79
+ };
80
+ // A part renamed on one side keeps its reader key and aligns with its base part.
81
+ const keyed = new Map(rows[0].filter(part => part.nameAt && part.key != null).map(part => [part.key, part]));
82
+ const own = new Set(rows[0].map(part => part.id)), renamed = new Map();
83
+ for (const side of [1, 2]) {
84
+ const names = new Set(rows[side].map(part => part.id));
85
+ for (const part of rows[side]) {
86
+ const old = keyed.get(part.key);
87
+ if (old && part.nameAt && old.kind === part.kind && !own.has(part.id) && !names.has(old.id)) renamed.set(part, old.id);
88
+ }
89
+ }
90
+ const id = part => renamed.get(part) ?? part.id;
91
+ const maps = index(id), orders = rows.map(list => list.map(id));
92
+ // A vanished or added copy of a repeated name keeps the order; only a different order of shared names is a reorder.
93
+ const within = (a, b) => { let k = 0; for (const name of b) if (name === a[k]) k++; return k === a.length; };
94
+ const reordered = [1, 2].map(side => {
95
+ const a = orders[0].filter(name => maps[side].has(name)), b = orders[side].filter(name => maps[0].has(name));
96
+ check(a.length + b.length); return !within(a, b) && !within(b, a);
97
+ });
98
+ index((part, side) => {
99
+ check(1);
100
+ if (part.nameAt) return part.kind.length + ':' + part.kind + sources[side].slice(...part.nameAt);
101
+ });
102
+ const parent = container++;
103
+ for (const side of [1, 2]) {
104
+ const peer = 3 - side;
105
+ for (const [name, part] of maps[0]) if (!maps[side].has(name)) {
106
+ removed[side - 1].set(parent + ':' + name, [part.kind, parent]);
107
+ const other = maps[peer].get(name);
108
+ if (part?.nameAt && other?.nameAt && content(part, 0) !== content(other, peer)) throw null;
109
+ }
110
+ for (const [name, part] of maps[side]) if (!maps[0].has(name))
111
+ added[side - 1].set(parent + ':' + name, [part.kind, parent]);
112
+ }
113
+ let frame = -1;
114
+ if (reordered.some(Boolean)) {
115
+ if (repeated || !maps.every(map => map.size === maps[0].size && [...maps[0].keys()].every(name => map.has(name)))) throw null;
116
+ if (reordered.every(Boolean) && !equal(orders[1], orders[2])) throw null;
117
+ frame = reordered[0] ? 1 : 2;
118
+ const gaps = rows.map((list, side) => {
119
+ let at = nodes[side].start;
120
+ return [...list.map(part => { const gap = sources[side].slice(at, part.start); at = part.end; return gap; }),
121
+ sources[side].slice(at, nodes[side].end)];
122
+ });
123
+ if (!equal(gaps[3 - frame], gaps[reordered.every(Boolean) ? frame : 0])) throw null;
124
+ }
125
+ const children = new Map();
126
+ for (const [name, part] of maps[0]) if (part && maps[1].get(name) && maps[2].get(name)) {
127
+ const child = align([part, maps[1].get(name), maps[2].get(name)]);
128
+ if (child) children.set(name, child);
129
+ }
130
+ if (frame < 0 && !children.size) return;
131
+ // A plan is a tree of range triples; a string is an already-admitted gap.
132
+ const plan = [];
133
+ if (frame >= 0) {
134
+ let at = nodes[frame].start;
135
+ for (const part of rows[frame]) {
136
+ plan.push(sources[frame].slice(at, part.start), children.get(id(part)) || (maps[0].get(id(part)).common ?? {ranges: maps.map(map => map.get(id(part)))}));
137
+ at = part.end;
138
+ }
139
+ plan.push(sources[frame].slice(at, nodes[frame].end));
140
+ } else {
141
+ let at = nodes.map(node => node.start);
142
+ for (const child of children.values()) {
143
+ const ranges = child.nodes;
144
+ plan.push({ranges: ranges.map((node, side) => ({start: at[side], end: node.start}))}, child);
145
+ at = ranges.map(node => node.end);
146
+ }
147
+ plan.push({ranges: nodes.map((node, side) => ({start: at[side], end: node.end}))});
148
+ }
149
+ return {nodes, plan};
150
+ }
151
+ const plan = align(trees.map((tree, side) => ({start: 0, end: sources[side].length, children: tree.parts})));
152
+ for (const side of [0, 1]) {
153
+ const destinations = new Map();
154
+ check(added[side].size + removed[side].size);
155
+ for (const [kind, parent] of added[side].values())
156
+ destinations.set(kind, destinations.has(kind) && destinations.get(kind) !== parent ? null : parent);
157
+ for (const [kind, parent] of removed[side].values())
158
+ if (destinations.has(kind) && destinations.get(kind) !== parent) throw null;
159
+ }
160
+ if ([...removed[0].keys()].some(name => removed[1].has(name)) &&
161
+ (added[0].size !== added[1].size || [...added[0].keys()].some(name => !added[1].has(name)))) throw null;
162
+ if (plan) {
163
+ // Project original ranges once. Nested moves never rebuild aligned documents.
164
+ const chunks = [], conflicts = [];
165
+ let length = 0;
166
+ function project(item) {
167
+ if (item.plan) { for (const child of item.plan) project(child); return; }
168
+ check(1);
169
+ let text = item;
170
+ if (typeof item !== 'string') {
171
+ const result = merge(...item.ranges.map(content), {...budget, structure: undefined, from: undefined});
172
+ check(0); text = result.text;
173
+ for (const conflict of result.conflicts)
174
+ conflicts.push({at: conflict.at.map(at => at + length), theirs: conflict.theirs});
175
+ }
176
+ chunks.push(text); length += text.length;
177
+ }
178
+ project(plan);
179
+ return {text: chunks.join(''), conflicts};
180
+ }
181
+ } catch (error) { if (error !== null) throw error; return complete(); }
182
+ }
183
+ const changes = options._edits || [mine, theirs].map((text, side) => diff(base, text,
184
+ {...budget, structure: trees[0] && options.structure, _observed: trees[0] ? [trees[0], trees[side + 1]] : undefined}).edits);
185
+ if (trees.some(Boolean) && budget._budget.left < 0) return complete();
186
+ // An edit reaches over the words it touches; edits whose reaches overlap form one group, so letters
187
+ // joined from both branches never form a word neither wrote.
188
+ const spans = words(wordSegmenter(), base);
189
+ const word = at => {
190
+ let low = 0, high = spans.length;
191
+ while (low < high) { const mid = low + high >> 1; if (spans[mid][1] <= at) low = mid + 1; else high = mid; }
192
+ return spans[low]?.[0] <= at ? spans[low] : null;
193
+ };
194
+ const edits = [mine, theirs].flatMap((text, side) => changes[side].map(({before: [from, to], after}) =>
195
+ [from, to, text.slice(...after), side, from && (word(from - 1)?.[0] ?? from), word(to)?.[1] ?? to]));
196
+ edits.sort((a, b) => a[0] - b[0] || a[3] - b[3] || a[1] - b[1]);
197
+ const conflicts = [];
198
+ let at = 0, text = '';
199
+ for (let index = 0; index < edits.length;) {
200
+ const start = edits[index][0], sides = [['', start], ['', start]];
201
+ let end = start, reach = start, changed = 0;
202
+ for (;;) {
203
+ const edit = edits[index++], side = edit[3], row = sides[side];
204
+ reach = Math.max(reach, edit[5]);
205
+ if (edit[1] >= end) {
206
+ if (edit[1] > end) sides[1 - side][2] = null;
207
+ end = edit[1]; row[2] = edit;
208
+ }
209
+ row[0] += base.slice(row[1], edit[0]) + edit[2]; changed |= 1 << side;
210
+ row[1] = edit[1];
211
+ if (index === edits.length) break;
212
+ const [a, b, t, side2, from] = edits[index], peer = sides[1 - side2][2];
213
+ // One branch's insertion and replacement at the same base position are one contiguous edit.
214
+ if (!(a < end || a === start || from < reach || edits[index - 1][0] === a && edits[index - 1][3] === side2 || peer &&
215
+ (a === end && peer[0] === end || a === b && peer[0] < end &&
216
+ a - end < t.length && (peer[2] + base.slice(end, a)).endsWith(t)))) break;
217
+ }
218
+ for (const row of sides) row[0] += base.slice(row[1], end);
219
+ text += base.slice(at, start);
220
+ const from = text.length;
221
+ text += sides[changed & 1 ? 0 : 1][0];
222
+ if (changed === 3 && sides[0][0] !== sides[1][0]) conflicts.push({at: [from, text.length], theirs: sides[1][0]});
223
+ at = end;
224
+ }
225
+ return {text: text + base.slice(at), conflicts};
226
+ }