rapier-html 1.2.3 → 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.
- package/BUILD.json +18 -18
- package/README.md +67 -87
- package/dist/ledger/LICENSE +3 -2
- package/dist/ledger/authored-origin.mjs +49 -0
- package/dist/ledger/authorship.mjs +18 -9
- package/dist/ledger/data.mjs +7 -4
- package/dist/ledger/diff/concerns.mjs +163 -0
- package/dist/ledger/diff/diff.mjs +68 -0
- package/dist/ledger/diff/index.mjs +5 -0
- package/dist/ledger/diff/matching.mjs +157 -0
- package/dist/ledger/diff/merge.mjs +226 -0
- package/dist/ledger/diff/moves.mjs +130 -0
- package/dist/ledger/diff/structure.mjs +50 -0
- package/dist/ledger/diff/tokens.mjs +72 -0
- package/dist/ledger/diff/walk.mjs +219 -0
- package/dist/ledger/format.mjs +25 -8
- package/dist/ledger/history.mjs +31 -5
- package/dist/ledger/inverse-source.mjs +112 -50
- package/dist/ledger/journal-records.mjs +24 -29
- package/dist/ledger/transport.mjs +30 -45
- package/dist/page.mjs +10 -7
- package/dist/rapier.html +140 -139
- package/dist/return-address.mjs +2 -2
- package/dist/wrap.mjs +1 -1
- package/package.json +1 -1
- package/dist/ledger/diff.mjs +0 -745
|
@@ -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,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
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
import {separate, phrases} from './concerns.mjs';
|
|
3
|
+
|
|
4
|
+
// Moves bind equal transported fragments at both ends; renames group a binding's sites.
|
|
5
|
+
export function moves(c) {
|
|
6
|
+
const {before, after, spend, safe, budget, raw, code, tokens, matches, walk, structures, pa, pb, la, aliases} = c;
|
|
7
|
+
const overlaps = (a, b) => a[0] < b[1] && a[1] > b[0];
|
|
8
|
+
const inside = (c, a, b) => [a, b].every((range, side) => c[side][0] === c[side][1] ||
|
|
9
|
+
c[side][0] >= range[0] && c[side][1] <= range[1]);
|
|
10
|
+
try { if (!raw && budget.left >= 0) {
|
|
11
|
+
const check = n => { if (!spend(n)) throw null; };
|
|
12
|
+
// Split transported text at wording boundaries; positions and words own distinct edits.
|
|
13
|
+
const deleted = new Map();
|
|
14
|
+
const moveKey = (item, side, text, parts = []) => {
|
|
15
|
+
const [from, to] = item[side];
|
|
16
|
+
let k = 0, hi = parts.length;
|
|
17
|
+
while (k < hi) { const mid = k + hi >>> 1; if (parts[mid].start < from) k = mid + 1; else hi = mid; }
|
|
18
|
+
for (; k < parts.length && parts[k].start <= to; k++) {
|
|
19
|
+
const p = parts[k]; check(1);
|
|
20
|
+
if (p.end <= to && p.name === item[3][2] && !/\S/.test(text.slice(from, p.start) + text.slice(p.end, to))) return item[3][1] + '\0' + item[3][2];
|
|
21
|
+
}
|
|
22
|
+
return text.slice(from, to).trim();
|
|
23
|
+
};
|
|
24
|
+
for (const item of c.result) if (item[3][0] === 0 && !item[2]) {
|
|
25
|
+
const value = before.slice(...item[0]), key = moveKey(item, 0, before, pa);
|
|
26
|
+
if (item[3][2] || value.trim().length >= 4 && /[\n ]/.test(value)) deleted.set(key, deleted.has(key) ? null : item);
|
|
27
|
+
}
|
|
28
|
+
for (const item of c.result) if (item[3][0] === 2 && !item[2]) {
|
|
29
|
+
const value = after.slice(...item[1]), key = moveKey(item, 1, after, pb);
|
|
30
|
+
let old = deleted.get(key), score = 0;
|
|
31
|
+
// Edited wording still travels: plain text, or a prose part of the same kind.
|
|
32
|
+
const text = value.trim(), prose = !structures || /^(paragraph|item|task|row|quote|section)$/.test(item[3][1]);
|
|
33
|
+
if (!old && prose && text.length >= 24) for (const candidate of deleted.values()) {
|
|
34
|
+
check(1);
|
|
35
|
+
if (!candidate || candidate[4] || candidate[3][1] !== item[3][1]) continue;
|
|
36
|
+
const prior = before.slice(...candidate[0]).trim();
|
|
37
|
+
const a = tokens(prior, 0, prior.length, 2), b = tokens(text, 0, text.length, 2);
|
|
38
|
+
if (!a || !b) throw null;
|
|
39
|
+
const n = matches(a[0], b[0]).reduce((n, j, i) => n + a[1][i + 1] - a[1][i], 0);
|
|
40
|
+
check(0);
|
|
41
|
+
if (n * 5 > Math.max(prior.length, text.length) * 3 && n >= score) { old = n === score ? null : candidate; score = n; }
|
|
42
|
+
}
|
|
43
|
+
if (!old || old[4]) continue;
|
|
44
|
+
// Separators that only one end carries stay outside the transported text.
|
|
45
|
+
const x = [...old[0]], y = [...item[1]];
|
|
46
|
+
for (const end of [0, 1]) {
|
|
47
|
+
const pattern = end ? /\s*$/ : /^\s*/, left = before.slice(...x).match(pattern)[0], right = after.slice(...y).match(pattern)[0];
|
|
48
|
+
let common = 0;
|
|
49
|
+
while (common < Math.min(left.length, right.length) && left.at(end ? common : -1 - common) === right.at(end ? common : -1 - common)) common++;
|
|
50
|
+
const dx = (left.length - common) * (end ? -1 : 1), dy = (right.length - common) * (end ? -1 : 1);
|
|
51
|
+
if (safe(before, x[end] + dx) && safe(after, y[end] + dy)) { x[end] += dx; y[end] += dy; }
|
|
52
|
+
}
|
|
53
|
+
const outer = c.result; c.result = [];
|
|
54
|
+
walk(...x, ...y, 1);
|
|
55
|
+
// A changed heading can occupy a whole line while its marker still anchors the move.
|
|
56
|
+
if (c.result.length && (c.result[0][0][0] === x[0] || c.result[0][1][0] === y[0] ||
|
|
57
|
+
c.result.at(-1)[0][1] === x[1] || c.result.at(-1)[1][1] === y[1])) {
|
|
58
|
+
c.result = []; walk(...x, ...y, 2);
|
|
59
|
+
}
|
|
60
|
+
separate(c, x[1], y[1]);
|
|
61
|
+
phrases(c);
|
|
62
|
+
const inner = c.result; c.result = outer;
|
|
63
|
+
// Equal boundary fragments make the exact extent recoverable without display context.
|
|
64
|
+
if (inner.length && (inner[0][0][0] === x[0] || inner[0][1][0] === y[0] ||
|
|
65
|
+
inner.at(-1)[0][1] === x[1] || inner.at(-1)[1][1] === y[1] || inner.some(c => c[2]))) continue;
|
|
66
|
+
const move = [3, item[3][2] ? item[3][1] : code ? 'line' : 'block', item[3][2], [x, y]];
|
|
67
|
+
old[4] = []; item[4] = [];
|
|
68
|
+
const fragment = (a0, a1, b0, b1, group) => {
|
|
69
|
+
if (a0 < a1) old[4].push([[a0, a1], [old[1][0], old[1][0]], false, group]);
|
|
70
|
+
if (b0 < b1) item[4].push([[item[0][0], item[0][0]], [b0, b1], false, group]);
|
|
71
|
+
};
|
|
72
|
+
// Unmatched outer separators travel with the move, so restoring it alone keeps blocks apart.
|
|
73
|
+
fragment(old[0][0], x[0], 0, 0, move); fragment(0, 0, item[1][0], y[0], move);
|
|
74
|
+
let a = x[0], b = y[0], wording;
|
|
75
|
+
for (const edit of inner) {
|
|
76
|
+
if (edit[3][3]) wording = edit[3];
|
|
77
|
+
else if (a !== edit[0][0] || b !== edit[1][0]) wording = [edit[3][0], edit[3][1]];
|
|
78
|
+
wording[2] = item[3][2]; wording[3] = move[3];
|
|
79
|
+
if (wording[0] !== edit[3][0]) wording[0] = 1;
|
|
80
|
+
fragment(a, edit[0][0], b, edit[1][0], move);
|
|
81
|
+
fragment(...edit[0], ...edit[1], wording);
|
|
82
|
+
a = edit[0][1]; b = edit[1][1];
|
|
83
|
+
}
|
|
84
|
+
fragment(a, x[1], b, y[1], move);
|
|
85
|
+
fragment(x[1], old[0][1], 0, 0, move);
|
|
86
|
+
fragment(0, 0, y[1], item[1][1], move);
|
|
87
|
+
}
|
|
88
|
+
c.result = c.result.flatMap(item => item[4] || [item]);
|
|
89
|
+
|
|
90
|
+
if (structures) {
|
|
91
|
+
check(la.length * Math.ceil(Math.log2(la.length + 1)) + c.lb.length + pa.length + pb.length);
|
|
92
|
+
const mapped = {}; let cursor = 0, delta = 0;
|
|
93
|
+
for (const link of [...la].sort((a, b) => a.at[0] - b.at[0])) {
|
|
94
|
+
while (cursor < c.result.length && c.result[cursor][0][1] <= link.at[0]) { delta = c.result[cursor][1][1] - c.result[cursor][0][1]; cursor++; }
|
|
95
|
+
if (cursor === c.result.length || c.result[cursor][0][0] >= link.at[1]) mapped[[link.at[0] + delta, link.at[1] + delta]] = link.to;
|
|
96
|
+
}
|
|
97
|
+
c.lb = c.lb.map(l => l.key != null ? l : {...l, to: mapped[l.at] || l.to});
|
|
98
|
+
for (const [p, old] of aliases) {
|
|
99
|
+
if (before.slice(...old.nameAt) === after.slice(...p.nameAt)) continue;
|
|
100
|
+
check(la.length + c.lb.length);
|
|
101
|
+
const left = la.filter(l => l.key === old.key), right = c.lb.filter(l => l.key === p.key);
|
|
102
|
+
if (left.length !== right.length) continue;
|
|
103
|
+
const x = [old.nameAt], y = [p.nameAt], owned = [];
|
|
104
|
+
for (let i = 0; i < left.length; i++) {
|
|
105
|
+
x.push(left[i].at); y.push(right[i].at);
|
|
106
|
+
if (left[i].labelAt && right[i].labelAt && before.slice(...left[i].labelAt) === before.slice(...old.nameAt) &&
|
|
107
|
+
after.slice(...right[i].labelAt) === after.slice(...p.nameAt)) { x.push(left[i].labelAt); y.push(right[i].labelAt); }
|
|
108
|
+
}
|
|
109
|
+
x.sort((a, b) => a[0] - b[0]); y.sort((a, b) => a[0] - b[0]);
|
|
110
|
+
let complete = true;
|
|
111
|
+
for (let i = 0; i < x.length; i++) {
|
|
112
|
+
check(c.result.length * 2);
|
|
113
|
+
if (c.result.some(c => c[3]?.[0] !== 3 && (overlaps(c[0], x[i]) || overlaps(c[1], y[i])) && !inside(c, x[i], y[i]))) { complete = false; break; }
|
|
114
|
+
const found = c.result.filter(c => c[3][0] < 3 && inside(c, x[i], y[i]));
|
|
115
|
+
if (!found.length) { complete = false; break; }
|
|
116
|
+
owned.push(...found);
|
|
117
|
+
}
|
|
118
|
+
if (!complete) continue;
|
|
119
|
+
check(c.result.length + owned.length * 2);
|
|
120
|
+
const owners = new Set(owned.map(c => c[3]).filter(Boolean)), members = new Set(owned);
|
|
121
|
+
if (c.result.some(c => owners.has(c[3]) && !members.has(c))) continue;
|
|
122
|
+
const group = [4, p.kind, old.name, [[...old.nameAt], [...p.nameAt]]];
|
|
123
|
+
for (const item of owned) item[3] = group;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
} } catch (error) {
|
|
127
|
+
if (error !== null) throw error;
|
|
128
|
+
c.result = c.result.flatMap(item => item[4] || [item]);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Capture only the reader contract before another observation can reuse its objects.
|
|
3
|
+
export function observe(text, reader, spend, safe, references = true, lenient = false) {
|
|
4
|
+
let value;
|
|
5
|
+
try { value = reader(text); } catch (error) { return {invalid: true, threw: true, error}; }
|
|
6
|
+
try {
|
|
7
|
+
const flat = [], links = [], meaning = references && value?.meaning;
|
|
8
|
+
const span = (at, from, end) => {
|
|
9
|
+
if (!Array.isArray(at) || at.length !== 2 || !at.every(Number.isSafeInteger) ||
|
|
10
|
+
at[0] < from || at[1] < at[0] || at[1] > end) throw null;
|
|
11
|
+
if (safe(text, at[0]) && safe(text, at[1])) return [at[0], at[1]];
|
|
12
|
+
if (!lenient) throw null;
|
|
13
|
+
};
|
|
14
|
+
const scalar = (value, key = false) => {
|
|
15
|
+
if (value != null && typeof value !== 'string' && !(key && (typeof value === 'boolean' || Number.isFinite(value)))) throw false;
|
|
16
|
+
return value;
|
|
17
|
+
};
|
|
18
|
+
let count = 0;
|
|
19
|
+
function copy(rows, from, end, depth, retained = true) {
|
|
20
|
+
if (!Array.isArray(rows) || rows.length > 131072 || depth > 128) throw null;
|
|
21
|
+
const parts = [];
|
|
22
|
+
let at = from;
|
|
23
|
+
for (const row of rows) {
|
|
24
|
+
if (!spend() || count++ >= 131072) throw null;
|
|
25
|
+
const {start, end: stop, kind, name, key, nameAt, children} = row;
|
|
26
|
+
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(stop) ||
|
|
27
|
+
start < at || stop < start || stop > end) throw null;
|
|
28
|
+
at = stop;
|
|
29
|
+
const usable = safe(text, start) && safe(text, stop);
|
|
30
|
+
if (!usable && !lenient) throw null;
|
|
31
|
+
const part = {start, end: stop, kind: scalar(kind), name: scalar(name), key: scalar(key, true)};
|
|
32
|
+
if (nameAt != null) part.nameAt = span(nameAt, start, stop);
|
|
33
|
+
// Discard unsafe evidence only after validating its complete subtree.
|
|
34
|
+
if (retained && usable) { parts.push(part); flat.push(part); }
|
|
35
|
+
part.children = children == null ? [] : copy(children, start, stop, depth + 1, retained && usable);
|
|
36
|
+
}
|
|
37
|
+
return parts;
|
|
38
|
+
}
|
|
39
|
+
const parts = copy(value?.parts, 0, text.length, 0), rows = references && value.links || [];
|
|
40
|
+
if (!Array.isArray(rows) || rows.length > 131072) throw null;
|
|
41
|
+
for (const row of rows) {
|
|
42
|
+
if (!spend()) throw null;
|
|
43
|
+
const {at, to, key, labelAt} = row;
|
|
44
|
+
if (typeof to !== 'string') throw null;
|
|
45
|
+
const place = span(at, 0, text.length), label = labelAt == null ? null : span(labelAt, 0, text.length), binding = scalar(key, true);
|
|
46
|
+
if (place) links.push({at: place, to, key: binding, ...(label && {labelAt: label})});
|
|
47
|
+
}
|
|
48
|
+
return {parts, flat, links, meaning: typeof meaning === 'string' ? meaning : undefined};
|
|
49
|
+
} catch (error) { return error === false ? {invalid: true} : null; }
|
|
50
|
+
}
|