@helping-ai-workflow/md2doc 3.3.0 → 3.4.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/README.md +33 -8
- package/THIRD-PARTY-NOTICES.md +44 -0
- package/lib/drawio.js +178 -0
- package/lib/editor/blockmap.js +79 -6
- package/lib/editor/client.js +3212 -140
- package/lib/editor/inline-md.js +122 -12
- package/lib/editor/lineops.js +190 -3
- package/lib/editor/server.js +323 -3
- package/lib/editor/toolbar-model.js +51 -14
- package/lib/editor/wave-codec.js +2631 -0
- package/lib/editor/wave-geometry.js +320 -0
- package/lib/editor/wave-store.js +950 -0
- package/lib/editor/wave-ui.js +2124 -0
- package/lib/md2doc.js +677 -14
- package/package.json +14 -6
- package/vendor/drawio/LICENSE +201 -0
- package/vendor/drawio/NOTICE +109 -0
- package/vendor/drawio/README.md +25 -0
- package/vendor/drawio/viewer-static.min.js +8596 -0
|
@@ -0,0 +1,2631 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* wave-codec — a tolerant WaveJSON reader that remembers where every value came
|
|
5
|
+
* from, and a write-back that changes only those bytes.
|
|
6
|
+
*
|
|
7
|
+
* WaveJSON is a JavaScript object literal, not strict JSON. Real wavedrom code
|
|
8
|
+
* blocks use unquoted keys, single-quoted strings, trailing commas and line or
|
|
9
|
+
* block comments; `JSON.parse` rejects all four. So this is a small
|
|
10
|
+
* recursive-descent parser instead.
|
|
11
|
+
*
|
|
12
|
+
* It also records, for every value it parses, the half-open source range
|
|
13
|
+
* `[start, end)` of that value's literal text. That is what lets `patchSource`
|
|
14
|
+
* rewrite only the bytes of the one value that changed, leaving the author's
|
|
15
|
+
* indentation, quote characters and comments untouched. Nothing is normalised
|
|
16
|
+
* on the way in: `0..0` and `0...` render differently in wavedrom, so a parser
|
|
17
|
+
* that "tidied" a wave string would be destroying information.
|
|
18
|
+
*
|
|
19
|
+
* Alongside those it records a second table, `members`, holding the range of
|
|
20
|
+
* each whole member — key, colon, value and trailing comma. Value spans alone
|
|
21
|
+
* are enough to change a lane's wave string, and adding a cycle is only that:
|
|
22
|
+
* each lane's wave string gets rewritten and nothing else moves. Adding or
|
|
23
|
+
* deleting a *lane* is the one genuinely structural thing a wave GUI does, and
|
|
24
|
+
* member spans turn it into a local splice too, so it does not have to fall
|
|
25
|
+
* back on rewriting the file.
|
|
26
|
+
*
|
|
27
|
+
* Security: the source text comes out of the user's markdown file, so it is
|
|
28
|
+
* never handed to any code-executing API. This module builds no code at run
|
|
29
|
+
* time and imports nothing at all; `test/wave-codec.test.js` greps it for the
|
|
30
|
+
* spellings that would introduce either, so keep the prose here clear of them.
|
|
31
|
+
*
|
|
32
|
+
* Contract: this does not throw on any input. Every malformed source —
|
|
33
|
+
* including one nested past `MAX_DEPTH` — comes back as
|
|
34
|
+
* `{ok: false, message, offset}`, so callers in the layers above are entitled
|
|
35
|
+
* to call it without a `try`.
|
|
36
|
+
*
|
|
37
|
+
* "On any input" is the honest form of that promise, not "never": if the
|
|
38
|
+
* CALLER arrives with its stack already at the limit, the engine can raise
|
|
39
|
+
* RangeError in a frame where the catch handler itself has no stack left to
|
|
40
|
+
* run. Measured under `--stack-size=200`. Nothing in this file can close that,
|
|
41
|
+
* and the input is not what decides it.
|
|
42
|
+
*
|
|
43
|
+
* @typedef {Array<string|number>} DocPath
|
|
44
|
+
* Object keys are strings, array indices are numbers — `['signal', 0, 'wave']`.
|
|
45
|
+
* `spans` is keyed by `JSON.stringify(path)`, so index types must match:
|
|
46
|
+
* `['signal','0','wave']` is a different key and is never produced here.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Every character ECMAScript counts as WhiteSpace or a LineTerminator. The
|
|
51
|
+
* exotic ones matter: a wavedrom block pasted out of a rendered web page
|
|
52
|
+
* carries U+00A0, and one pasted out of a CJK document carries U+3000. JS
|
|
53
|
+
* itself treats both as whitespace, so this parser does too — otherwise a lane
|
|
54
|
+
* ends up with the key `'name '`, the GUI shows no name, and the rename
|
|
55
|
+
* the user then types has no span to write through.
|
|
56
|
+
*/
|
|
57
|
+
const SPACE = {};
|
|
58
|
+
for (const c of ' \t\n\r\f\v ' +
|
|
59
|
+
'
') {
|
|
60
|
+
SPACE[c] = 1;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* U+200B is neither JS whitespace nor a valid identifier character, and it is
|
|
65
|
+
* the other invisible that survives a web paste. Excluding it here turns it
|
|
66
|
+
* into a reported error rather than a silently wrong key.
|
|
67
|
+
*/
|
|
68
|
+
const ZERO_WIDTH_SPACE = '';
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Nesting ceiling. The deepest legitimate WaveJSON is about ten containers —
|
|
72
|
+
* root, `signal`, a few nested groups, a lane, its `data`, an element — so 64
|
|
73
|
+
* leaves roughly six times the headroom any real document needs, while sitting
|
|
74
|
+
* ~49x below the depth at which this host's stack actually gives out (measured:
|
|
75
|
+
* 3124 levels parse, 3125 throws). Choosing a fixed ceiling rather than
|
|
76
|
+
* catching the stack overflow is what makes the failure deterministic: it does
|
|
77
|
+
* not move with `--stack-size` or with how deep the caller's own stack already
|
|
78
|
+
* was, and it can report a real `offset`.
|
|
79
|
+
*/
|
|
80
|
+
const MAX_DEPTH = 64;
|
|
81
|
+
|
|
82
|
+
const NUMBER_RE = /[+-]?(?:0[xX][0-9a-fA-F]+|0[oO][0-7]+|0[bB][01]+|Infinity|(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)/y;
|
|
83
|
+
const IDENT_START_RE = /[A-Za-z_$-]/;
|
|
84
|
+
const IDENT_PART_RE = /[A-Za-z0-9_$-]/;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Parse a WaveJSON source string.
|
|
88
|
+
*
|
|
89
|
+
* @param {string} text
|
|
90
|
+
* @returns {{ok: true, doc: any, spans: Map<string, [number, number]>}
|
|
91
|
+
* |{ok: false, message: string, offset: number}}
|
|
92
|
+
*/
|
|
93
|
+
function parseSource(text) {
|
|
94
|
+
if (typeof text !== 'string') {
|
|
95
|
+
return {
|
|
96
|
+
ok: false,
|
|
97
|
+
offset: 0,
|
|
98
|
+
message: 'parseSource expects a string, got ' + (text === null ? 'null' : typeof text),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
const st = {
|
|
102
|
+
text: text, i: 0, depth: 0,
|
|
103
|
+
spans: new Map(), members: new Map(), leads: new Map(), dups: new Set(),
|
|
104
|
+
};
|
|
105
|
+
try {
|
|
106
|
+
skipTrivia(st);
|
|
107
|
+
if (st.i >= text.length) fail(st, st.i, 'Empty source: expected a WaveJSON value');
|
|
108
|
+
const doc = parseValue(st, []);
|
|
109
|
+
skipTrivia(st);
|
|
110
|
+
if (st.i < text.length) {
|
|
111
|
+
fail(st, st.i, 'Unexpected trailing content after the top-level value');
|
|
112
|
+
}
|
|
113
|
+
// `source` is what lets `patchSource` refuse a stale parse result. Spans are
|
|
114
|
+
// offsets into one particular string; applied to a text that has moved on,
|
|
115
|
+
// they land mid-token and write a file that no longer parses. Holding the
|
|
116
|
+
// string costs nothing — strings are immutable, so this is the same one the
|
|
117
|
+
// caller already has, not a copy.
|
|
118
|
+
return {
|
|
119
|
+
ok: true,
|
|
120
|
+
doc: doc,
|
|
121
|
+
spans: st.spans,
|
|
122
|
+
members: st.members,
|
|
123
|
+
leads: st.leads,
|
|
124
|
+
dups: st.dups,
|
|
125
|
+
source: text,
|
|
126
|
+
};
|
|
127
|
+
} catch (err) {
|
|
128
|
+
if (err && err.isWaveParseError) {
|
|
129
|
+
return { ok: false, message: err.message, offset: err.offset };
|
|
130
|
+
}
|
|
131
|
+
if (err instanceof RangeError) {
|
|
132
|
+
// MAX_DEPTH normally gets here first, so this looks unreachable and was
|
|
133
|
+
// reported as unproven when it was written. It is not: a review reached
|
|
134
|
+
// it under `--stack-size=200` from 12 of 13 sampled caller depths, and
|
|
135
|
+
// again at 500. The earlier negative result came from probing at the
|
|
136
|
+
// default stack size, where V8 re-tiers the calibrating harness between
|
|
137
|
+
// calibration and test. This branch is what holds the no-`try` contract
|
|
138
|
+
// when a caller arrives on a deep stack — which a GUI event path does.
|
|
139
|
+
return { ok: false, message: 'Source is too deeply nested to parse', offset: st.i };
|
|
140
|
+
}
|
|
141
|
+
throw err;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Throw a positioned parse error. `parseSource` turns it into `{ok:false}`. */
|
|
146
|
+
function fail(st, offset, message) {
|
|
147
|
+
const before = st.text.slice(0, offset);
|
|
148
|
+
const line = before.split('\n').length;
|
|
149
|
+
const col = offset - (before.lastIndexOf('\n') + 1) + 1;
|
|
150
|
+
const err = new Error(message + ' (line ' + line + ', column ' + col + ')');
|
|
151
|
+
err.isWaveParseError = true;
|
|
152
|
+
err.offset = offset;
|
|
153
|
+
throw err;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Skip whitespace, line comments and block comments. */
|
|
157
|
+
function skipTrivia(st) {
|
|
158
|
+
const t = st.text;
|
|
159
|
+
for (;;) {
|
|
160
|
+
const c = t[st.i];
|
|
161
|
+
if (c === undefined) return;
|
|
162
|
+
if (SPACE[c] === 1) { st.i++; continue; }
|
|
163
|
+
if (c === '/' && t[st.i + 1] === '/') {
|
|
164
|
+
st.i += 2;
|
|
165
|
+
while (st.i < t.length && t[st.i] !== '\n' && t[st.i] !== '\r') st.i++;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (c === '/' && t[st.i + 1] === '*') {
|
|
169
|
+
const start = st.i;
|
|
170
|
+
const end = t.indexOf('*/', st.i + 2);
|
|
171
|
+
if (end === -1) fail(st, start, 'Unterminated block comment');
|
|
172
|
+
st.i = end + 2;
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Parse one value and record its span under `path`. */
|
|
180
|
+
function parseValue(st, path) {
|
|
181
|
+
skipTrivia(st);
|
|
182
|
+
const start = st.i;
|
|
183
|
+
const c = st.text[st.i];
|
|
184
|
+
if (c === undefined) fail(st, start, 'Unexpected end of input: expected a value');
|
|
185
|
+
let value;
|
|
186
|
+
if (c === '{' || c === '[') {
|
|
187
|
+
if (st.depth >= MAX_DEPTH) {
|
|
188
|
+
fail(st, start, 'Nesting is too deep: more than ' + MAX_DEPTH +
|
|
189
|
+
' levels of nested objects and arrays');
|
|
190
|
+
}
|
|
191
|
+
st.depth++;
|
|
192
|
+
value = c === '{' ? parseObject(st, path) : parseArray(st, path);
|
|
193
|
+
st.depth--;
|
|
194
|
+
} else if (c === '"' || c === "'") value = parseString(st);
|
|
195
|
+
else if (c === '-' || c === '+' || c === '.' || (c >= '0' && c <= '9')) value = parseNumber(st);
|
|
196
|
+
else value = parseWord(st);
|
|
197
|
+
st.spans.set(JSON.stringify(path), [start, st.i]);
|
|
198
|
+
return value;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Record the span of a whole member — the key, the colon, the value and the
|
|
203
|
+
* trailing comma if one is there.
|
|
204
|
+
*
|
|
205
|
+
* This is recorded here, while the cursor is walking the member, rather than by
|
|
206
|
+
* a later scan over the source. A scan cannot do it: looking forward from a
|
|
207
|
+
* value's end for the separator finds the comma written inside a block comment,
|
|
208
|
+
* and looking backward from a value for its key has to walk past a line comment
|
|
209
|
+
* that is allowed to contain a colon of its own. Both are already handled here
|
|
210
|
+
* for free, because `skipTrivia` has been over that text.
|
|
211
|
+
*
|
|
212
|
+
* The range starts at the first character of the key (for an array, of the
|
|
213
|
+
* element), so leading indentation and any comment sitting above the member are
|
|
214
|
+
* outside it. It ends after the comma, so the trivia between the value and that
|
|
215
|
+
* comma — including a comment written there — is inside it. Without a trailing
|
|
216
|
+
* comma it ends at the last character of the value, and the trailing comment on
|
|
217
|
+
* the same line stays outside.
|
|
218
|
+
*/
|
|
219
|
+
function recordMember(st, memberKey, start, end, lead) {
|
|
220
|
+
st.members.set(memberKey, [start, end]);
|
|
221
|
+
st.leads.set(memberKey, lead);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function parseObject(st, path) {
|
|
225
|
+
const obj = {};
|
|
226
|
+
const seen = new Set();
|
|
227
|
+
st.i++; // consume the opening brace
|
|
228
|
+
for (;;) {
|
|
229
|
+
const lead = st.i; // where this member's leading trivia starts
|
|
230
|
+
skipTrivia(st);
|
|
231
|
+
const c = st.text[st.i];
|
|
232
|
+
if (c === undefined) fail(st, st.i, 'Unterminated object: expected a closing brace');
|
|
233
|
+
if (c === '}') { st.i++; return obj; }
|
|
234
|
+
const memberStart = st.i;
|
|
235
|
+
const key = parseKey(st);
|
|
236
|
+
skipTrivia(st);
|
|
237
|
+
if (st.text[st.i] !== ':') {
|
|
238
|
+
fail(st, st.i, "Expected ':' after key " + JSON.stringify(key));
|
|
239
|
+
}
|
|
240
|
+
st.i++;
|
|
241
|
+
const memberKey = JSON.stringify(path.concat([key]));
|
|
242
|
+
// `{a: {b: 1}, a: 2}` is legal here and the later member wins in `doc`, so
|
|
243
|
+
// both the value span and the member span at ["a"] end up describing the
|
|
244
|
+
// second one while the span at ["a","b"] still describes text inside the
|
|
245
|
+
// first. Writing a value through a span stays correct; splicing a member
|
|
246
|
+
// out does not, so the paths involved are remembered and refused there.
|
|
247
|
+
if (seen.has(memberKey)) st.dups.add(memberKey);
|
|
248
|
+
else seen.add(memberKey);
|
|
249
|
+
const value = parseValue(st, path.concat([key]));
|
|
250
|
+
assign(obj, key, value);
|
|
251
|
+
const valueEnd = st.i;
|
|
252
|
+
skipTrivia(st);
|
|
253
|
+
const sep = st.text[st.i];
|
|
254
|
+
if (sep === ',') {
|
|
255
|
+
st.i++;
|
|
256
|
+
recordMember(st, memberKey, memberStart, st.i, lead);
|
|
257
|
+
continue; // a trailing comma just loops round to the brace
|
|
258
|
+
}
|
|
259
|
+
recordMember(st, memberKey, memberStart, valueEnd, lead);
|
|
260
|
+
if (sep === '}') { st.i++; return obj; }
|
|
261
|
+
if (sep === undefined) fail(st, st.i, 'Unterminated object: expected a closing brace');
|
|
262
|
+
fail(st, st.i, "Expected ',' or '}' but found " + JSON.stringify(sep));
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function parseArray(st, path) {
|
|
267
|
+
const arr = [];
|
|
268
|
+
st.i++; // consume the opening bracket
|
|
269
|
+
for (;;) {
|
|
270
|
+
const lead = st.i; // where this element's leading trivia starts
|
|
271
|
+
skipTrivia(st);
|
|
272
|
+
const c = st.text[st.i];
|
|
273
|
+
if (c === undefined) fail(st, st.i, 'Unterminated array: expected a closing bracket');
|
|
274
|
+
if (c === ']') { st.i++; return arr; }
|
|
275
|
+
const memberStart = st.i;
|
|
276
|
+
const memberKey = JSON.stringify(path.concat([arr.length]));
|
|
277
|
+
arr.push(parseValue(st, path.concat([arr.length])));
|
|
278
|
+
const valueEnd = st.i;
|
|
279
|
+
skipTrivia(st);
|
|
280
|
+
const sep = st.text[st.i];
|
|
281
|
+
if (sep === ',') {
|
|
282
|
+
st.i++;
|
|
283
|
+
recordMember(st, memberKey, memberStart, st.i, lead);
|
|
284
|
+
continue; // a trailing comma just loops round to the bracket
|
|
285
|
+
}
|
|
286
|
+
recordMember(st, memberKey, memberStart, valueEnd, lead);
|
|
287
|
+
if (sep === ']') { st.i++; return arr; }
|
|
288
|
+
if (sep === undefined) fail(st, st.i, 'Unterminated array: expected a closing bracket');
|
|
289
|
+
fail(st, st.i, "Expected ',' or ']' but found " + JSON.stringify(sep));
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Object keys: quoted string, bare identifier, or numeric literal. */
|
|
294
|
+
function parseKey(st) {
|
|
295
|
+
const c = st.text[st.i];
|
|
296
|
+
if (c === '"' || c === "'") return parseString(st);
|
|
297
|
+
if (c === '-' || c === '+' || c === '.' || (c >= '0' && c <= '9')) return String(parseNumber(st));
|
|
298
|
+
const word = scanWord(st);
|
|
299
|
+
if (word === '') {
|
|
300
|
+
fail(st, st.i, 'Expected a property name but found ' + JSON.stringify(c === undefined ? '' : c));
|
|
301
|
+
}
|
|
302
|
+
return word;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Scan a bare identifier. Stops at anything `SPACE` calls trivia, so a space
|
|
307
|
+
* character can never be absorbed into a key, and at U+200B. Returns '' (with
|
|
308
|
+
* the position unmoved) when there is no identifier here.
|
|
309
|
+
*/
|
|
310
|
+
function scanWord(st) {
|
|
311
|
+
const t = st.text;
|
|
312
|
+
if (!isIdentChar(t[st.i], true)) return '';
|
|
313
|
+
let j = st.i + 1;
|
|
314
|
+
while (isIdentChar(t[j], false)) j++;
|
|
315
|
+
const word = t.slice(st.i, j);
|
|
316
|
+
st.i = j;
|
|
317
|
+
return word;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function isIdentChar(c, isFirst) {
|
|
321
|
+
if (c === undefined) return false;
|
|
322
|
+
if (SPACE[c] === 1 || c === ZERO_WIDTH_SPACE) return false;
|
|
323
|
+
return (isFirst ? IDENT_START_RE : IDENT_PART_RE).test(c);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Write a key without letting a `__proto__` key in the source reach the
|
|
328
|
+
* prototype chain — the text is untrusted.
|
|
329
|
+
*/
|
|
330
|
+
function assign(obj, key, value) {
|
|
331
|
+
if (key === '__proto__') {
|
|
332
|
+
Object.defineProperty(obj, key, {
|
|
333
|
+
value: value, enumerable: true, writable: true, configurable: true,
|
|
334
|
+
});
|
|
335
|
+
} else {
|
|
336
|
+
obj[key] = value;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function parseString(st) {
|
|
341
|
+
const quote = st.text[st.i];
|
|
342
|
+
const start = st.i;
|
|
343
|
+
st.i++;
|
|
344
|
+
let out = '';
|
|
345
|
+
for (;;) {
|
|
346
|
+
if (st.i >= st.text.length) fail(st, start, 'Unterminated string literal');
|
|
347
|
+
const c = st.text[st.i];
|
|
348
|
+
if (c === quote) { st.i++; return out; }
|
|
349
|
+
if (c === '\n' || c === '\r') {
|
|
350
|
+
fail(st, st.i, 'Unterminated string literal: line break inside a string');
|
|
351
|
+
}
|
|
352
|
+
if (c === '\\') { st.i++; out += readEscape(st); continue; }
|
|
353
|
+
out += c;
|
|
354
|
+
st.i++;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function readEscape(st) {
|
|
359
|
+
if (st.i >= st.text.length) fail(st, st.i, 'Unterminated escape sequence');
|
|
360
|
+
const c = st.text[st.i];
|
|
361
|
+
st.i++;
|
|
362
|
+
switch (c) {
|
|
363
|
+
case 'n': return '\n';
|
|
364
|
+
case 't': return '\t';
|
|
365
|
+
case 'r': return '\r';
|
|
366
|
+
case 'b': return '\b';
|
|
367
|
+
case 'f': return '\f';
|
|
368
|
+
case 'v': return '\v';
|
|
369
|
+
case '0':
|
|
370
|
+
if (st.text[st.i] >= '0' && st.text[st.i] <= '9') {
|
|
371
|
+
fail(st, st.i - 2, 'Octal escape sequences are not supported');
|
|
372
|
+
}
|
|
373
|
+
return '\0';
|
|
374
|
+
case 'x': return String.fromCharCode(readHex(st, 2, st.i - 2));
|
|
375
|
+
case 'u': {
|
|
376
|
+
if (st.text[st.i] === '{') {
|
|
377
|
+
const open = st.i;
|
|
378
|
+
st.i++;
|
|
379
|
+
let digits = '';
|
|
380
|
+
while (st.i < st.text.length && st.text[st.i] !== '}') digits += st.text[st.i++];
|
|
381
|
+
if (st.text[st.i] !== '}' || !/^[0-9a-fA-F]{1,6}$/.test(digits)) {
|
|
382
|
+
fail(st, open - 2, 'Invalid unicode escape sequence');
|
|
383
|
+
}
|
|
384
|
+
st.i++;
|
|
385
|
+
const code = parseInt(digits, 16);
|
|
386
|
+
if (code > 0x10ffff) fail(st, open - 2, 'Unicode escape sequence out of range');
|
|
387
|
+
return String.fromCodePoint(code);
|
|
388
|
+
}
|
|
389
|
+
return String.fromCharCode(readHex(st, 4, st.i - 2));
|
|
390
|
+
}
|
|
391
|
+
case '\r':
|
|
392
|
+
if (st.text[st.i] === '\n') st.i++;
|
|
393
|
+
return ''; // line continuation
|
|
394
|
+
case '\n': return '';
|
|
395
|
+
default: return c; // an escaped quote, backslash or slash stands for itself
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function readHex(st, count, errAt) {
|
|
400
|
+
const digits = st.text.slice(st.i, st.i + count);
|
|
401
|
+
if (digits.length < count || !/^[0-9a-fA-F]+$/.test(digits)) {
|
|
402
|
+
fail(st, errAt, 'Invalid hexadecimal escape sequence');
|
|
403
|
+
}
|
|
404
|
+
st.i += count;
|
|
405
|
+
return parseInt(digits, 16);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function parseNumber(st) {
|
|
409
|
+
NUMBER_RE.lastIndex = st.i;
|
|
410
|
+
const m = NUMBER_RE.exec(st.text);
|
|
411
|
+
if (!m) fail(st, st.i, 'Invalid number literal');
|
|
412
|
+
const start = st.i;
|
|
413
|
+
st.i += m[0].length;
|
|
414
|
+
let body = m[0];
|
|
415
|
+
let sign = 1;
|
|
416
|
+
if (body[0] === '+' || body[0] === '-') {
|
|
417
|
+
if (body[0] === '-') sign = -1;
|
|
418
|
+
body = body.slice(1);
|
|
419
|
+
}
|
|
420
|
+
const n = Number(body);
|
|
421
|
+
if (Number.isNaN(n)) fail(st, start, 'Invalid number literal ' + JSON.stringify(m[0]));
|
|
422
|
+
return sign * n;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function parseWord(st) {
|
|
426
|
+
const start = st.i;
|
|
427
|
+
const word = scanWord(st);
|
|
428
|
+
if (word === '') fail(st, st.i, 'Unexpected character ' + JSON.stringify(st.text[st.i]));
|
|
429
|
+
switch (word) {
|
|
430
|
+
case 'true': return true;
|
|
431
|
+
case 'false': return false;
|
|
432
|
+
case 'null': return null;
|
|
433
|
+
case 'undefined': return undefined;
|
|
434
|
+
case 'NaN': return NaN;
|
|
435
|
+
case 'Infinity': return Infinity;
|
|
436
|
+
default:
|
|
437
|
+
return fail(st, start, 'Unexpected token ' + JSON.stringify(word));
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Write a set of edits back into the source as the smallest replacement that
|
|
443
|
+
* expresses them.
|
|
444
|
+
*
|
|
445
|
+
* Every edit names a value by path; the span recorded for that path says which
|
|
446
|
+
* bytes are that value, and only those bytes are rewritten. Everything else in
|
|
447
|
+
* the file — the author's indentation, the comment two columns to the right of
|
|
448
|
+
* the wave string, whichever quote character that one value happened to use —
|
|
449
|
+
* survives because it is never read and never written. That is not tidiness:
|
|
450
|
+
* against wavedrom 3.5.0 `0..0` and `0...` render as different waveforms (a
|
|
451
|
+
* transition marker against a plain hold), and so do the other levels and data
|
|
452
|
+
* characters, so a write-back that re-serialised or "normalised" a wave string
|
|
453
|
+
* would change the picture the user is looking at.
|
|
454
|
+
*
|
|
455
|
+
* Three kinds of edit:
|
|
456
|
+
*
|
|
457
|
+
* {path, value} or {op:'set', path, value}
|
|
458
|
+
* Replace the value at `path`. Refused when nothing is parsed at that
|
|
459
|
+
* path, or when the new value is a different kind from the old one —
|
|
460
|
+
* turning a string into an array is a structural change, not a local
|
|
461
|
+
* replacement, and this returns `{ok:false}` rather than guessing.
|
|
462
|
+
*
|
|
463
|
+
* {op:'insert', path, value}
|
|
464
|
+
* Splice a new element into an array; the last path segment is the index
|
|
465
|
+
* it should end up at, and may be the array's current length to append.
|
|
466
|
+
* This is what a GUI's "add a lane" does. Adding a *cycle* is not here on
|
|
467
|
+
* purpose: it only rewrites each lane's wave string, which is an ordinary
|
|
468
|
+
* `set`.
|
|
469
|
+
*
|
|
470
|
+
* {op:'insert', path, value} with a named last segment
|
|
471
|
+
* Add a member an object does not have yet — `['signal',0,'name']` on a
|
|
472
|
+
* lane written without a name. It goes in after the object's last member,
|
|
473
|
+
* so nothing already written moves.
|
|
474
|
+
*
|
|
475
|
+
* {op:'remove', path}
|
|
476
|
+
* Splice out one whole member — key, colon, value and trailing comma.
|
|
477
|
+
*
|
|
478
|
+
* {op:'move', path, to}
|
|
479
|
+
* Move one array element. `to` is a destination path of the same shape an
|
|
480
|
+
* insert takes — `laneInsertPath` builds one — read against the document
|
|
481
|
+
* as it stands. The element's own bytes are picked up and put down again,
|
|
482
|
+
* so the comment the author wrote beside it travels with it, which a
|
|
483
|
+
* remove plus an insert cannot do because an insert renders a value and a
|
|
484
|
+
* value carries no comment. An array the element leaves with no lane in it
|
|
485
|
+
* goes too, the same rule `removeLane` follows.
|
|
486
|
+
*
|
|
487
|
+
* {op:'replace', path, value}
|
|
488
|
+
* Replace the value at `path` with a fresh serialization, keeping the key,
|
|
489
|
+
* its position among its siblings and the comment beside it. Unlike `set`
|
|
490
|
+
* it may change the value's type and may write a container, because the
|
|
491
|
+
* whole meaning of asking for it is "I intend to replace this wholesale".
|
|
492
|
+
* That is also its cost: a comment written INSIDE the old value is part of
|
|
493
|
+
* what is replaced. `set` stays the safe default and keeps refusing both,
|
|
494
|
+
* so a silent structural rewrite still cannot happen by accident.
|
|
495
|
+
*
|
|
496
|
+
* Refusals carry `rewroteRange`, which says which stretch of the file the edit
|
|
497
|
+
* wanted, so a caller can show the user what it would have had to touch instead
|
|
498
|
+
* of failing silently or rewriting the file behind them.
|
|
499
|
+
*
|
|
500
|
+
* @param {string} text the same source that was handed to parseSource
|
|
501
|
+
* @param {object} parsed a successful parseSource result
|
|
502
|
+
* @param {Array<object>} edits
|
|
503
|
+
* @returns {{ok: true, text: string}
|
|
504
|
+
* |{ok: false, reason: string, rewroteRange: object|null}}
|
|
505
|
+
*/
|
|
506
|
+
function patchSource(text, parsed, edits) {
|
|
507
|
+
if (typeof text !== 'string') {
|
|
508
|
+
return refuse('patchSource expects the source text as a string', null);
|
|
509
|
+
}
|
|
510
|
+
if (!parsed || parsed.ok !== true || !(parsed.spans instanceof Map) ||
|
|
511
|
+
!(parsed.members instanceof Map) || !(parsed.leads instanceof Map) ||
|
|
512
|
+
!(parsed.dups instanceof Set)) {
|
|
513
|
+
return refuse('patchSource needs a successful parseSource result from this same module', null);
|
|
514
|
+
}
|
|
515
|
+
if (parsed.source !== text) {
|
|
516
|
+
// Two patches in a row without re-parsing in between is the shape a GUI
|
|
517
|
+
// reaches for, and it is the one input on which silence would be worst:
|
|
518
|
+
// every span past the first edit is off by however much that edit changed
|
|
519
|
+
// the length, so the write lands mid-token and the user's diagram stops
|
|
520
|
+
// parsing. Refusing is the only honest answer — the spans cannot be
|
|
521
|
+
// adjusted from here, because this has no idea which edits produced `text`.
|
|
522
|
+
return refuse(
|
|
523
|
+
'this parse result came from a different source text; re-run parseSource on ' +
|
|
524
|
+
'the text being patched, because spans from the old one point at the wrong bytes',
|
|
525
|
+
null);
|
|
526
|
+
}
|
|
527
|
+
if (!Array.isArray(edits)) return refuse('patchSource expects an array of edits', null);
|
|
528
|
+
if (edits.length === 0) return { ok: true, text: text };
|
|
529
|
+
|
|
530
|
+
const ctx = { text: text, parsed: parsed };
|
|
531
|
+
// Two edits naming one path have no defined result: for a value they would
|
|
532
|
+
// fight over the same bytes, and for two inserts at one index the order they
|
|
533
|
+
// land in is whatever the sort happens to do with two ranges that are both
|
|
534
|
+
// empty at the same offset. The overlap test below catches the first because
|
|
535
|
+
// the ranges genuinely overlap, and cannot catch the second because an empty
|
|
536
|
+
// range does not overlap itself — so both are refused here instead, and the
|
|
537
|
+
// module says the same thing about a repeated path whatever the op was.
|
|
538
|
+
const seenPaths = new Set();
|
|
539
|
+
for (const edit of edits) {
|
|
540
|
+
if (!edit || typeof edit !== 'object' || !Array.isArray(edit.path)) continue;
|
|
541
|
+
const key = JSON.stringify(edit.path);
|
|
542
|
+
if (seenPaths.has(key)) {
|
|
543
|
+
return refuse(
|
|
544
|
+
'the same path ' + key + ' appears in two edits, so which one wins is not defined',
|
|
545
|
+
nearestRange(ctx, edit.path));
|
|
546
|
+
}
|
|
547
|
+
seenPaths.add(key);
|
|
548
|
+
}
|
|
549
|
+
const resolved = [];
|
|
550
|
+
for (const edit of edits) {
|
|
551
|
+
const r = resolveEdit(ctx, edit);
|
|
552
|
+
if (r.ok !== true) return r;
|
|
553
|
+
// A move is one edit and two replacements — the bytes leave one place and
|
|
554
|
+
// land in another — so an edit is allowed to resolve to several, and the
|
|
555
|
+
// overlap test below then sees each of them separately.
|
|
556
|
+
if (Array.isArray(r.parts)) {
|
|
557
|
+
for (const part of r.parts) resolved.push(part);
|
|
558
|
+
} else {
|
|
559
|
+
resolved.push(r);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Back to front, so applying one edit cannot move the spans of the ones that
|
|
564
|
+
// have not been applied yet.
|
|
565
|
+
resolved.sort(function (a, b) { return (b.start - a.start) || (b.end - a.end); });
|
|
566
|
+
for (let k = 1; k < resolved.length; k++) {
|
|
567
|
+
const later = resolved[k - 1];
|
|
568
|
+
const earlier = resolved[k];
|
|
569
|
+
if (earlier.start === later.start) {
|
|
570
|
+
// Two edits aimed at one offset. The overlap test below cannot see this
|
|
571
|
+
// when both are insertions, because an empty range does not overlap
|
|
572
|
+
// anything — and two member inserts on one object have different paths,
|
|
573
|
+
// so the same-path guard does not see it either. Which one goes in first
|
|
574
|
+
// is whatever the sort does with two equal keys, so neither does.
|
|
575
|
+
return refuse(
|
|
576
|
+
'two edits land at the same place in the file, so which of them goes in first ' +
|
|
577
|
+
'is not defined',
|
|
578
|
+
{ path: earlier.path, spanPath: earlier.path, start: earlier.start, end: later.end });
|
|
579
|
+
}
|
|
580
|
+
if (earlier.end > later.start) {
|
|
581
|
+
return refuse(
|
|
582
|
+
'two edits cover overlapping ranges, so applying both would not be a local replacement',
|
|
583
|
+
{ path: earlier.path, spanPath: earlier.path, start: earlier.start, end: later.end });
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
let out = text;
|
|
588
|
+
for (const r of resolved) out = out.slice(0, r.start) + r.text + out.slice(r.end);
|
|
589
|
+
return { ok: true, text: out };
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function refuse(reason, rewroteRange) {
|
|
593
|
+
return { ok: false, reason: reason, rewroteRange: rewroteRange };
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/** Turn one edit into a `{start, end, text}` replacement, or refuse. */
|
|
597
|
+
function resolveEdit(ctx, edit) {
|
|
598
|
+
if (!edit || typeof edit !== 'object') return refuse('each edit must be an object', null);
|
|
599
|
+
if (!Array.isArray(edit.path)) {
|
|
600
|
+
return refuse('each edit needs a path array, the same shape parseSource keys its spans by', null);
|
|
601
|
+
}
|
|
602
|
+
const op = edit.op === undefined ? 'set' : edit.op;
|
|
603
|
+
if (op === 'set') return resolveSet(ctx, edit);
|
|
604
|
+
if (op === 'insert') return resolveInsert(ctx, edit);
|
|
605
|
+
if (op === 'remove') return resolveRemove(ctx, edit);
|
|
606
|
+
if (op === 'move') return resolveMove(ctx, edit);
|
|
607
|
+
if (op === 'replace') return resolveReplace(ctx, edit);
|
|
608
|
+
return refuse('unknown edit op ' + JSON.stringify(edit.op), nearestRange(ctx, edit.path));
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function resolveSet(ctx, edit) {
|
|
612
|
+
const path = edit.path;
|
|
613
|
+
const span = ctx.parsed.spans.get(JSON.stringify(path));
|
|
614
|
+
if (!span) {
|
|
615
|
+
return refuse(
|
|
616
|
+
'nothing is parsed at ' + JSON.stringify(path) +
|
|
617
|
+
', so this is a structural change rather than a local replacement',
|
|
618
|
+
nearestRange(ctx, path));
|
|
619
|
+
}
|
|
620
|
+
const found = lookup(ctx.parsed.doc, path);
|
|
621
|
+
const oldKind = found.found ? kindOf(found.value) : 'missing';
|
|
622
|
+
const newKind = kindOf(edit.value);
|
|
623
|
+
if (newKind === 'undefined') {
|
|
624
|
+
return refuse('a patch will not write undefined into the source',
|
|
625
|
+
rangeOf(path, path, span));
|
|
626
|
+
}
|
|
627
|
+
if (oldKind !== newKind) {
|
|
628
|
+
return refuse(
|
|
629
|
+
'the new value is a ' + newKind + ' where the source has a ' + oldKind +
|
|
630
|
+
'; changing a value type is a structural change, not a local replacement',
|
|
631
|
+
rangeOf(path, path, span));
|
|
632
|
+
}
|
|
633
|
+
if (newKind === 'object' || newKind === 'array') {
|
|
634
|
+
// A container `set` is a structural rewrite wearing a value replacement's
|
|
635
|
+
// clothes: its span covers the whole subtree, so writing it re-spells every
|
|
636
|
+
// member and drops every comment inside — the one thing this module exists
|
|
637
|
+
// to prevent. Change the fields, or splice with insert and remove.
|
|
638
|
+
return refuse(
|
|
639
|
+
'setting a whole ' + newKind + ' at ' + JSON.stringify(path) + ' would re-serialise ' +
|
|
640
|
+
'that subtree and drop any comment or formatting inside it; set the fields ' +
|
|
641
|
+
'individually, or use insert and remove',
|
|
642
|
+
rangeOf(path, path, span));
|
|
643
|
+
}
|
|
644
|
+
if (sameScalar(found.value, edit.value)) {
|
|
645
|
+
// Nothing changed, so the bytes that are already there are written back
|
|
646
|
+
// unexamined. Without this, a save that touches every field would re-spell
|
|
647
|
+
// literals it has no business touching: the author's `0xff` would come back
|
|
648
|
+
// as `255`, because a number is rendered from its value and not from the
|
|
649
|
+
// text it was written as. It keeps its range rather than dropping out of
|
|
650
|
+
// the batch, so whether a batch is refused as overlapping stays a question
|
|
651
|
+
// about the batch's shape and not about which values happened to match.
|
|
652
|
+
return {
|
|
653
|
+
ok: true, path: path, start: span[0], end: span[1],
|
|
654
|
+
text: ctx.text.slice(span[0], span[1]),
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
const quote = ctx.text[span[0]] === "'" ? "'" : '"';
|
|
658
|
+
const ser = serialize(edit.value, quote, MAX_DEPTH - path.length);
|
|
659
|
+
if (ser.ok !== true) return refuse(ser.why, rangeOf(path, path, span));
|
|
660
|
+
return { ok: true, path: path, start: span[0], end: span[1], text: ser.text };
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* Swap one value's bytes for a new serialization.
|
|
665
|
+
*
|
|
666
|
+
* `set` refuses a type change and refuses a container, and both refusals earn
|
|
667
|
+
* their keep: they are what stops a caller that meant to change one field from
|
|
668
|
+
* silently re-spelling a subtree and dropping the comments inside it. But the
|
|
669
|
+
* document half of this file can legitimately produce a retype — a lane's `data`
|
|
670
|
+
* moves from the space-separated string to an array the moment an edit needs a
|
|
671
|
+
* blank or space-bearing label — and with only `set` there was no single call
|
|
672
|
+
* that could write it: `set` refused the type change, and remove-plus-insert was
|
|
673
|
+
* refused as two edits on one path. The two-pass route that remained ate the
|
|
674
|
+
* comment beside `data` and moved the key to the end of the lane.
|
|
675
|
+
*
|
|
676
|
+
* So this is the explicit form of the same thing: it names the value, not the
|
|
677
|
+
* member, and it says the retype is intended. The key, its position and the
|
|
678
|
+
* comment beside it are outside the value's span and are never touched.
|
|
679
|
+
*/
|
|
680
|
+
function resolveReplace(ctx, edit) {
|
|
681
|
+
const path = edit.path;
|
|
682
|
+
if (!Array.isArray(path) || path.length === 0) {
|
|
683
|
+
return refuse('replace addresses a value, so it needs a path to one', nearestRange(ctx, path));
|
|
684
|
+
}
|
|
685
|
+
const span = ctx.parsed.spans.get(JSON.stringify(path));
|
|
686
|
+
if (!span) {
|
|
687
|
+
return refuse(
|
|
688
|
+
'nothing is parsed at ' + JSON.stringify(path) + ', so there is no value to replace',
|
|
689
|
+
nearestRange(ctx, path));
|
|
690
|
+
}
|
|
691
|
+
if (kindOf(edit.value) === 'undefined') {
|
|
692
|
+
return refuse('a patch will not write undefined into the source', rangeOf(path, path, span));
|
|
693
|
+
}
|
|
694
|
+
const found = lookup(ctx.parsed.doc, path);
|
|
695
|
+
// Two lines this will not cross, and they are different lines.
|
|
696
|
+
//
|
|
697
|
+
// The first is structural. A value that is, or contains, an object is the
|
|
698
|
+
// document's shape rather than a field of it: `signal`, every group array and
|
|
699
|
+
// every lane. Replacing one re-spells every member it holds and drops whatever
|
|
700
|
+
// was written between them — which is the wall `set` builds by refusing
|
|
701
|
+
// containers, and the op added to get past that wall for `data` must not
|
|
702
|
+
// become the hole in it.
|
|
703
|
+
if (found.found && holdsObject(found.value)) {
|
|
704
|
+
return refuse(
|
|
705
|
+
'the value at ' + JSON.stringify(path) + ' is or contains an object, so replacing it ' +
|
|
706
|
+
'would re-serialise part of the document\'s structure; change the fields ' +
|
|
707
|
+
'individually, or splice with insert and remove',
|
|
708
|
+
rangeOf(path, path, span));
|
|
709
|
+
}
|
|
710
|
+
// The second is textual, and it is where the reachable harm lives. A value
|
|
711
|
+
// written across more than one line, or with a comment inside it, cannot be
|
|
712
|
+
// swapped out without reflowing bytes nobody asked about — a `data` array with
|
|
713
|
+
// a note beside each label is exactly that, and element-wise insert and remove
|
|
714
|
+
// keep those notes where this cannot. The retype this op exists for never
|
|
715
|
+
// reaches it: a string-form `data` is one string literal on one line.
|
|
716
|
+
if (!isInlineValue(ctx, path, found.found ? found.value : null, span)) {
|
|
717
|
+
return refuse(
|
|
718
|
+
'the value at ' + JSON.stringify(path) + ' runs across lines or has a comment inside ' +
|
|
719
|
+
'it, so replacing it would rewrite formatting and drop comments; edit its elements ' +
|
|
720
|
+
'individually instead',
|
|
721
|
+
rangeOf(path, path, span));
|
|
722
|
+
}
|
|
723
|
+
const quote = ctx.text[span[0]] === "'" ? "'" : '"';
|
|
724
|
+
const ser = serialize(edit.value, quote, MAX_DEPTH - path.length);
|
|
725
|
+
if (ser.ok !== true) return refuse(ser.why, rangeOf(path, path, span));
|
|
726
|
+
return { ok: true, path: path, start: span[0], end: span[1], text: ser.text };
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/** Whether a value is an object, or an array with one anywhere inside it. */
|
|
730
|
+
function holdsObject(value) {
|
|
731
|
+
if (Array.isArray(value)) {
|
|
732
|
+
for (const item of value) {
|
|
733
|
+
if (holdsObject(item)) return true;
|
|
734
|
+
}
|
|
735
|
+
return false;
|
|
736
|
+
}
|
|
737
|
+
return value !== null && typeof value === 'object';
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* Whether a value's source text is one line with nothing but the value in it.
|
|
742
|
+
*
|
|
743
|
+
* A scalar is decided by its own span: a literal cannot contain a comment, and
|
|
744
|
+
* this parser rejects a string that spans lines, so there is nothing to look at.
|
|
745
|
+
* An array is decided by the gaps between its elements' spans — the parser has
|
|
746
|
+
* already walked those, so anything there that is not a comma, a space or a tab
|
|
747
|
+
* is a comment or a line break, with no chance of a slash inside a string being
|
|
748
|
+
* mistaken for one.
|
|
749
|
+
*/
|
|
750
|
+
function isInlineValue(ctx, path, value, span) {
|
|
751
|
+
if (!Array.isArray(value)) return true;
|
|
752
|
+
let at = span[0] + 1;
|
|
753
|
+
for (let i = 0; i < value.length; i++) {
|
|
754
|
+
const child = ctx.parsed.spans.get(JSON.stringify(path.concat([i])));
|
|
755
|
+
if (!child || !isPlainGap(ctx.text, at, child[0])) return false;
|
|
756
|
+
at = child[1];
|
|
757
|
+
}
|
|
758
|
+
return isPlainGap(ctx.text, at, span[1] - 1);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function isPlainGap(text, from, to) {
|
|
762
|
+
for (let j = from; j < to; j++) {
|
|
763
|
+
const c = text[j];
|
|
764
|
+
if (c !== ',' && c !== ' ' && c !== '\t') return false;
|
|
765
|
+
}
|
|
766
|
+
return true;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function resolveInsert(ctx, edit) {
|
|
770
|
+
const path = edit.path;
|
|
771
|
+
if (path.length > 0 && typeof path[path.length - 1] === 'string') {
|
|
772
|
+
return resolveInsertMember(ctx, edit);
|
|
773
|
+
}
|
|
774
|
+
if (path.length === 0 || typeof path[path.length - 1] !== 'number') {
|
|
775
|
+
return refuse('insert addresses an array element or a named member, so its path must ' +
|
|
776
|
+
'end in a numeric index or a key', nearestRange(ctx, path));
|
|
777
|
+
}
|
|
778
|
+
const index = path[path.length - 1];
|
|
779
|
+
const parentPath = path.slice(0, -1);
|
|
780
|
+
const parentSpan = ctx.parsed.spans.get(JSON.stringify(parentPath));
|
|
781
|
+
const parent = lookup(ctx.parsed.doc, parentPath);
|
|
782
|
+
if (!parentSpan || !parent.found || !Array.isArray(parent.value)) {
|
|
783
|
+
return refuse('insert needs an array at ' + JSON.stringify(parentPath),
|
|
784
|
+
nearestRange(ctx, parentPath));
|
|
785
|
+
}
|
|
786
|
+
if (index !== Math.floor(index) || !(index >= 0 && index <= parent.value.length)) {
|
|
787
|
+
return refuse(
|
|
788
|
+
'insert index ' + index + ' is outside the array at ' + JSON.stringify(parentPath) +
|
|
789
|
+
', which holds ' + parent.value.length + ' elements',
|
|
790
|
+
rangeOf(path, parentPath, parentSpan));
|
|
791
|
+
}
|
|
792
|
+
if (touchesDuplicateKey(ctx, path)) {
|
|
793
|
+
return refuse(duplicateWhy(path), rangeOf(path, parentPath, parentSpan));
|
|
794
|
+
}
|
|
795
|
+
const ser = serialize(edit.value, dominantQuote(ctx), MAX_DEPTH - path.length);
|
|
796
|
+
if (ser.ok !== true) return refuse(ser.why, rangeOf(path, parentPath, parentSpan));
|
|
797
|
+
|
|
798
|
+
if (parent.value.length === 0) {
|
|
799
|
+
const at = parentSpan[0] + 1;
|
|
800
|
+
// An empty array has no element to copy a layout from. If it was written
|
|
801
|
+
// across lines, the new element takes a line of its own, indented one step
|
|
802
|
+
// in from the closing bracket; two spaces because there is nothing in the
|
|
803
|
+
// file to measure a step from. A one-line array stays on its line.
|
|
804
|
+
const close = lineIndentBefore(ctx.text, parentSpan[1] - 1);
|
|
805
|
+
if (close !== null) {
|
|
806
|
+
const eol = lineBreakBefore(ctx.text, parentSpan[1] - 1);
|
|
807
|
+
return { ok: true, path: path, start: at, end: at, text: eol + close + ' ' + ser.text };
|
|
808
|
+
}
|
|
809
|
+
return { ok: true, path: path, start: at, end: at, text: ser.text };
|
|
810
|
+
}
|
|
811
|
+
if (index < parent.value.length) {
|
|
812
|
+
const member = ctx.parsed.members.get(JSON.stringify(parentPath.concat([index])));
|
|
813
|
+
if (!member) {
|
|
814
|
+
return refuse('no member span for ' + JSON.stringify(path), rangeOf(path, parentPath, parentSpan));
|
|
815
|
+
}
|
|
816
|
+
// Go in above whatever comments are written over that member, not between
|
|
817
|
+
// them and it — otherwise the new lane silently inherits the note the
|
|
818
|
+
// author wrote about the old one. Same rule the end of the array uses.
|
|
819
|
+
const lead = ctx.parsed.leads.get(JSON.stringify(parentPath.concat([index])));
|
|
820
|
+
const at = commentRunStart(ctx.text, lead === undefined ? member[0] : lead, member[0]);
|
|
821
|
+
const indent = lineIndentBefore(ctx.text, at);
|
|
822
|
+
const gap = indent === null ? ' ' : lineBreakBefore(ctx.text, at) + indent;
|
|
823
|
+
return { ok: true, path: path, start: at, end: at, text: ser.text + ',' + gap };
|
|
824
|
+
}
|
|
825
|
+
const last = ctx.parsed.members.get(JSON.stringify(parentPath.concat([index - 1])));
|
|
826
|
+
if (!last) {
|
|
827
|
+
return refuse('no member span for ' + JSON.stringify(parentPath.concat([index - 1])),
|
|
828
|
+
rangeOf(path, parentPath, parentSpan));
|
|
829
|
+
}
|
|
830
|
+
return appendAfterMember(ctx, path, last, ser.text);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/**
|
|
834
|
+
* Put `body` in as a new last member of the container whose current last member
|
|
835
|
+
* is `last`.
|
|
836
|
+
*
|
|
837
|
+
* It goes after whatever that member has left on its own line, so a comment
|
|
838
|
+
* written beside it keeps describing it instead of drifting onto the new one.
|
|
839
|
+
* When that member had no trailing comma the comma has to go in front of the
|
|
840
|
+
* trivia rather than after it — a comma appended after a line comment would be
|
|
841
|
+
* swallowed by the comment and the container would no longer parse — so the
|
|
842
|
+
* trivia is re-emitted, byte for byte, behind it.
|
|
843
|
+
*/
|
|
844
|
+
function appendAfterMember(ctx, path, last, body) {
|
|
845
|
+
const indent = lineIndentBefore(ctx.text, last[0]);
|
|
846
|
+
const gap = indent === null ? ' ' : lineBreakBefore(ctx.text, last[0]) + indent;
|
|
847
|
+
const comma = ctx.text[last[1] - 1] === ',' ? '' : ',';
|
|
848
|
+
const at = endOfTrailingTrivia(ctx.text, last[1], true);
|
|
849
|
+
const kept = ctx.text.slice(last[1], at);
|
|
850
|
+
return { ok: true, path: path, start: last[1], end: at, text: comma + kept + gap + body };
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/**
|
|
854
|
+
* Add a member an object does not have yet.
|
|
855
|
+
*
|
|
856
|
+
* A GUI reaches this the moment it names a lane the author wrote without a
|
|
857
|
+
* name, which is legal wavedrom and common for spacers. Without it the only way
|
|
858
|
+
* to write that rename back is to remove the whole lane and re-insert it, which
|
|
859
|
+
* re-serialises the lane and drops the comment beside it — so the smallest
|
|
860
|
+
* honest edit is this one: the new member goes after the object's last, and not
|
|
861
|
+
* a byte of what is already there moves.
|
|
862
|
+
*/
|
|
863
|
+
function resolveInsertMember(ctx, edit) {
|
|
864
|
+
const path = edit.path;
|
|
865
|
+
const key = path[path.length - 1];
|
|
866
|
+
const parentPath = path.slice(0, -1);
|
|
867
|
+
const parentSpan = ctx.parsed.spans.get(JSON.stringify(parentPath));
|
|
868
|
+
const parent = lookup(ctx.parsed.doc, parentPath);
|
|
869
|
+
if (!parentSpan || !parent.found || parent.value === null ||
|
|
870
|
+
typeof parent.value !== 'object' || Array.isArray(parent.value)) {
|
|
871
|
+
return refuse('insert with a named key needs an object at ' + JSON.stringify(parentPath),
|
|
872
|
+
nearestRange(ctx, parentPath));
|
|
873
|
+
}
|
|
874
|
+
if (Object.prototype.hasOwnProperty.call(parent.value, key)) {
|
|
875
|
+
return refuse(JSON.stringify(path) + ' already has a member there, so this is a set ' +
|
|
876
|
+
'rather than an insert', rangeOf(path, parentPath, parentSpan));
|
|
877
|
+
}
|
|
878
|
+
if (touchesDuplicateKey(ctx, parentPath)) {
|
|
879
|
+
return refuse(duplicateWhy(path), rangeOf(path, parentPath, parentSpan));
|
|
880
|
+
}
|
|
881
|
+
const ser = serialize(edit.value, dominantQuote(ctx), MAX_DEPTH - path.length);
|
|
882
|
+
if (ser.ok !== true) return refuse(ser.why, rangeOf(path, parentPath, parentSpan));
|
|
883
|
+
const body = renderKey(key, '"') + ': ' + ser.text;
|
|
884
|
+
const keys = Object.keys(parent.value);
|
|
885
|
+
if (keys.length === 0) {
|
|
886
|
+
const at = parentSpan[0] + 1;
|
|
887
|
+
const close = lineIndentBefore(ctx.text, parentSpan[1] - 1);
|
|
888
|
+
if (close !== null) {
|
|
889
|
+
const eol = lineBreakBefore(ctx.text, parentSpan[1] - 1);
|
|
890
|
+
return { ok: true, path: path, start: at, end: at, text: eol + close + ' ' + body };
|
|
891
|
+
}
|
|
892
|
+
return { ok: true, path: path, start: at, end: at, text: body };
|
|
893
|
+
}
|
|
894
|
+
const lastPath = parentPath.concat([keys[keys.length - 1]]);
|
|
895
|
+
const last = ctx.parsed.members.get(JSON.stringify(lastPath));
|
|
896
|
+
if (!last) {
|
|
897
|
+
return refuse('no member span for ' + JSON.stringify(lastPath),
|
|
898
|
+
rangeOf(path, parentPath, parentSpan));
|
|
899
|
+
}
|
|
900
|
+
return appendAfterMember(ctx, path, last, body);
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/**
|
|
904
|
+
* Move one array element, bytes and all.
|
|
905
|
+
*
|
|
906
|
+
* Written as a remove plus an insert, a move loses whatever the author put
|
|
907
|
+
* beside the element: `commentRunStart` gives the comment to that member, so
|
|
908
|
+
* the remove takes it away, and the insert renders a value, which carries no
|
|
909
|
+
* comment. The member span table exists so the member's own text can be picked
|
|
910
|
+
* up and put down again instead — one deletion where it was, one insertion of
|
|
911
|
+
* those same bytes where it goes, and the comment travels because it is part of
|
|
912
|
+
* the bytes.
|
|
913
|
+
*
|
|
914
|
+
* `to` is the index the element should end up at once it has been lifted out,
|
|
915
|
+
* the counting `moveLane` and `Array.prototype.splice` both use.
|
|
916
|
+
*/
|
|
917
|
+
function resolveMove(ctx, edit) {
|
|
918
|
+
const path = edit.path;
|
|
919
|
+
if (path.length === 0 || typeof path[path.length - 1] !== 'number') {
|
|
920
|
+
return refuse('move addresses an array element, so its path must end in a numeric index',
|
|
921
|
+
nearestRange(ctx, path));
|
|
922
|
+
}
|
|
923
|
+
const member = ctx.parsed.members.get(JSON.stringify(path));
|
|
924
|
+
if (!member) {
|
|
925
|
+
return refuse('nothing is parsed at ' + JSON.stringify(path) + ', so there is no member to move',
|
|
926
|
+
nearestRange(ctx, path));
|
|
927
|
+
}
|
|
928
|
+
const to = edit.to;
|
|
929
|
+
if (!Array.isArray(to) || to.length === 0 || typeof to[to.length - 1] !== 'number') {
|
|
930
|
+
return refuse('move needs a destination path ending in a numeric index, the same shape ' +
|
|
931
|
+
'insert takes; laneInsertPath builds one', rangeOf(path, path, member));
|
|
932
|
+
}
|
|
933
|
+
const destParentPath = to.slice(0, -1);
|
|
934
|
+
const destIndex = to[to.length - 1];
|
|
935
|
+
const destParent = lookup(ctx.parsed.doc, destParentPath);
|
|
936
|
+
const destParentSpan = ctx.parsed.spans.get(JSON.stringify(destParentPath));
|
|
937
|
+
if (!destParentSpan || !destParent.found || !Array.isArray(destParent.value) ||
|
|
938
|
+
!isBound(destIndex, destParent.value.length)) {
|
|
939
|
+
return refuse('move destination ' + JSON.stringify(to) +
|
|
940
|
+
' is not a position in an array', rangeOf(path, path, member));
|
|
941
|
+
}
|
|
942
|
+
if (touchesDuplicateKey(ctx, path) || touchesDuplicateKey(ctx, destParentPath)) {
|
|
943
|
+
return refuse(duplicateWhy(path), rangeOf(path, path, member));
|
|
944
|
+
}
|
|
945
|
+
const here = path.slice(0, -1);
|
|
946
|
+
const index = path[path.length - 1];
|
|
947
|
+
if (JSON.stringify(destParentPath) === JSON.stringify(here) &&
|
|
948
|
+
(destIndex === index || destIndex === index + 1)) {
|
|
949
|
+
// Landing where it already is. The bytes are written back unexamined, and
|
|
950
|
+
// the range is kept so that whether a batch overlaps stays a question about
|
|
951
|
+
// the batch and not about which move happened to be a no-op.
|
|
952
|
+
return { ok: true, path: path, start: member[0], end: member[1],
|
|
953
|
+
text: ctx.text.slice(member[0], member[1]) };
|
|
954
|
+
}
|
|
955
|
+
const lead = ctx.parsed.leads.get(JSON.stringify(path));
|
|
956
|
+
const leadStart = lead === undefined ? member[0] : lead;
|
|
957
|
+
const runStart = commentRunStart(ctx.text, leadStart, member[0]);
|
|
958
|
+
const runEnd = endOfTrailingTrivia(ctx.text, member[1], true);
|
|
959
|
+
const hadComma = ctx.text[member[1] - 1] === ',';
|
|
960
|
+
// The element's own bytes, with a comma guaranteed after the value: it may be
|
|
961
|
+
// landing somewhere that is no longer the end of the array. A trailing comma
|
|
962
|
+
// is legal here and in the engine's own reader, so one is safe wherever it
|
|
963
|
+
// ends up. The comment that follows on the same line comes along behind it.
|
|
964
|
+
const payload = ctx.text.slice(runStart, member[1]) + (hadComma ? '' : ',') +
|
|
965
|
+
ctx.text.slice(member[1], runEnd);
|
|
966
|
+
// What leaves is not always the element: an array the element leaves with no
|
|
967
|
+
// lane in it has nothing left to draw and no position that resolves inside it,
|
|
968
|
+
// so it goes too. `removeLane` reads the same function, which is what keeps
|
|
969
|
+
// the two ends of a move agreeing about how much came out.
|
|
970
|
+
const cutPath = emptiedMemberPath(ctx.parsed.doc, path);
|
|
971
|
+
const cutMember = ctx.parsed.members.get(JSON.stringify(cutPath));
|
|
972
|
+
if (!cutMember) {
|
|
973
|
+
return refuse('no member span for ' + JSON.stringify(cutPath), rangeOf(path, path, member));
|
|
974
|
+
}
|
|
975
|
+
const cutLead = ctx.parsed.leads.get(JSON.stringify(cutPath));
|
|
976
|
+
const cut = removalRange(ctx.text, cutMember[0], cutMember[1],
|
|
977
|
+
cutLead === undefined ? cutMember[0] : cutLead);
|
|
978
|
+
const drop = destParent.value.length === 0
|
|
979
|
+
? intoEmptyArray(ctx, destParentSpan, payload)
|
|
980
|
+
: (destIndex >= destParent.value.length
|
|
981
|
+
? appendPayload(ctx, destParentPath, destParent.value.length - 1, payload)
|
|
982
|
+
: beforePayload(ctx, destParentPath, destIndex, payload));
|
|
983
|
+
if (drop.ok !== true) return refuse(drop.why, rangeOf(path, destParentPath, destParentSpan));
|
|
984
|
+
if (drop.at >= cut[0] && drop.at < cut[1]) {
|
|
985
|
+
return refuse('the move would land inside the bytes it is taking out',
|
|
986
|
+
rangeOf(path, path, member));
|
|
987
|
+
}
|
|
988
|
+
return {
|
|
989
|
+
ok: true,
|
|
990
|
+
path: path,
|
|
991
|
+
parts: [
|
|
992
|
+
{ ok: true, path: path, start: cut[0], end: cut[1], text: '' },
|
|
993
|
+
{ ok: true, path: path, start: drop.at, end: drop.end, text: drop.text },
|
|
994
|
+
],
|
|
995
|
+
};
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
/** Where a moved member's bytes go when the destination array is empty. */
|
|
999
|
+
function intoEmptyArray(ctx, parentSpan, payload) {
|
|
1000
|
+
const at = parentSpan[0] + 1;
|
|
1001
|
+
const close = lineIndentBefore(ctx.text, parentSpan[1] - 1);
|
|
1002
|
+
if (close !== null) {
|
|
1003
|
+
const eol = lineBreakBefore(ctx.text, parentSpan[1] - 1);
|
|
1004
|
+
return { ok: true, at: at, end: at, text: eol + close + ' ' + payload };
|
|
1005
|
+
}
|
|
1006
|
+
return { ok: true, at: at, end: at, text: payload };
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
/** Where a moved member's bytes go when they land in front of another member. */
|
|
1010
|
+
function beforePayload(ctx, parentPath, index, payload) {
|
|
1011
|
+
const memberPath = JSON.stringify(parentPath.concat([index]));
|
|
1012
|
+
const member = ctx.parsed.members.get(memberPath);
|
|
1013
|
+
if (!member) return { ok: false, why: 'no member span for ' + memberPath };
|
|
1014
|
+
const lead = ctx.parsed.leads.get(memberPath);
|
|
1015
|
+
const at = commentRunStart(ctx.text, lead === undefined ? member[0] : lead, member[0]);
|
|
1016
|
+
const indent = lineIndentBefore(ctx.text, at);
|
|
1017
|
+
const gap = indent === null ? ' ' : lineBreakBefore(ctx.text, at) + indent;
|
|
1018
|
+
return { ok: true, at: at, end: at, text: payload + gap };
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
/** Where they go when they land at the end of the array. */
|
|
1022
|
+
function appendPayload(ctx, parentPath, index, payload) {
|
|
1023
|
+
const memberPath = JSON.stringify(parentPath.concat([index]));
|
|
1024
|
+
const last = ctx.parsed.members.get(memberPath);
|
|
1025
|
+
if (!last) return { ok: false, why: 'no member span for ' + memberPath };
|
|
1026
|
+
const part = appendAfterMember(ctx, parentPath, last, payload);
|
|
1027
|
+
return { ok: true, at: part.start, end: part.end, text: part.text };
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
function resolveRemove(ctx, edit) {
|
|
1031
|
+
const path = edit.path;
|
|
1032
|
+
if (path.length === 0) {
|
|
1033
|
+
return refuse('the whole document is not a member that can be removed', nearestRange(ctx, path));
|
|
1034
|
+
}
|
|
1035
|
+
const member = ctx.parsed.members.get(JSON.stringify(path));
|
|
1036
|
+
if (!member) {
|
|
1037
|
+
return refuse('nothing is parsed at ' + JSON.stringify(path) + ', so there is no member to remove',
|
|
1038
|
+
nearestRange(ctx, path));
|
|
1039
|
+
}
|
|
1040
|
+
if (touchesDuplicateKey(ctx, path)) {
|
|
1041
|
+
return refuse(duplicateWhy(path), rangeOf(path, path, member));
|
|
1042
|
+
}
|
|
1043
|
+
const lead = ctx.parsed.leads.get(JSON.stringify(path));
|
|
1044
|
+
const range = removalRange(ctx.text, member[0], member[1],
|
|
1045
|
+
lead === undefined ? member[0] : lead);
|
|
1046
|
+
return { ok: true, path: path, start: range[0], end: range[1], text: '' };
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
/**
|
|
1050
|
+
* A key written twice in the same object is legal WaveJSON and the later one
|
|
1051
|
+
* wins, which leaves the earlier one's subtree still holding spans that the doc
|
|
1052
|
+
* says nothing about: `{a: {b: 1}, a: 2}` has a span at ["a","b"] while `doc.a`
|
|
1053
|
+
* is `2`. Writing a value through a span is still right, because the span at a
|
|
1054
|
+
* path and the value at that path were recorded by the same pass and describe
|
|
1055
|
+
* the same occurrence. Splicing a member out is not: the member span at ["a"]
|
|
1056
|
+
* describes only the second one, so removing it would leave the first behind
|
|
1057
|
+
* and the key would still be there. So a member-level edit refuses as soon as
|
|
1058
|
+
* the path, or any path above it, is one of the repeated keys.
|
|
1059
|
+
*/
|
|
1060
|
+
function touchesDuplicateKey(ctx, path) {
|
|
1061
|
+
for (let n = 1; n <= path.length; n++) {
|
|
1062
|
+
if (ctx.parsed.dups.has(JSON.stringify(path.slice(0, n)))) return true;
|
|
1063
|
+
}
|
|
1064
|
+
return false;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
function duplicateWhy(path) {
|
|
1068
|
+
return JSON.stringify(path) + ' sits under a duplicate key, where a member span ' +
|
|
1069
|
+
'describes only one of the occurrences, so splicing there would leave the other behind';
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
/**
|
|
1073
|
+
* The horizontal whitespace between the start of `at`'s line and `at`, or null
|
|
1074
|
+
* when something other than whitespace is in front of it on that line. An
|
|
1075
|
+
* inserted element copies this so it lands on its own line, indented like its
|
|
1076
|
+
* neighbour, without any existing byte being rewritten.
|
|
1077
|
+
*/
|
|
1078
|
+
function lineIndentBefore(text, at) {
|
|
1079
|
+
let s = at;
|
|
1080
|
+
while (s > 0 && (text[s - 1] === ' ' || text[s - 1] === '\t')) s--;
|
|
1081
|
+
if (s > 0 && text[s - 1] !== '\n') return null;
|
|
1082
|
+
return text.slice(s, at);
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
/**
|
|
1086
|
+
* The line terminator that ended the line before `at` — the one an inserted
|
|
1087
|
+
* line has to match. Writing a bare newline into a file the author keeps in
|
|
1088
|
+
* CRLF leaves it with mixed endings, which is the same kind of harm as
|
|
1089
|
+
* reindenting it: a change to bytes nobody asked to change.
|
|
1090
|
+
*/
|
|
1091
|
+
function lineBreakBefore(text, at) {
|
|
1092
|
+
let s = at;
|
|
1093
|
+
while (s > 0 && (text[s - 1] === ' ' || text[s - 1] === '\t')) s--;
|
|
1094
|
+
if (s >= 2 && text[s - 1] === '\n' && text[s - 2] === '\r') return '\r\n';
|
|
1095
|
+
return '\n';
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
/**
|
|
1099
|
+
* Where the run of comments that belongs to a member begins, or `memberStart`
|
|
1100
|
+
* when none does.
|
|
1101
|
+
*
|
|
1102
|
+
* This is the whole of the comment-ownership rule, and the rule is one
|
|
1103
|
+
* sentence: **a comment belongs to the member it follows on the same line, or —
|
|
1104
|
+
* when it stands on its own line or lines — to the member immediately below it,
|
|
1105
|
+
* provided no blank line comes between.** Insert and remove both read it from
|
|
1106
|
+
* here, which is what keeps them from disagreeing about who owns a given note:
|
|
1107
|
+
* an insert goes in above the run so the note keeps describing the member it
|
|
1108
|
+
* was written for, and a remove takes the run with it so the note does not
|
|
1109
|
+
* outlive its subject and start reading as a description of the next member.
|
|
1110
|
+
*
|
|
1111
|
+
* It is decided by reading `[leadStart, memberStart)` — the trivia the parser
|
|
1112
|
+
* walked over on its way to this member — forwards, one comment at a time.
|
|
1113
|
+
* Reading it backwards a line at a time is what the earlier attempt did, and it
|
|
1114
|
+
* could not see a block comment spanning more than one line: its closing
|
|
1115
|
+
* line starts with a star, not a slash, so the run ended there and the comment
|
|
1116
|
+
* was handed to the wrong member. Forwards, a comment of any shape is one
|
|
1117
|
+
* token, so there is no line shape left to get wrong.
|
|
1118
|
+
*
|
|
1119
|
+
* The one guess left is the first member of a container: a heading written
|
|
1120
|
+
* immediately above it, with no blank line, is taken as that member's and
|
|
1121
|
+
* travels with it when it is deleted. A blank line is how an author says
|
|
1122
|
+
* otherwise, and that is the same signal on both operations.
|
|
1123
|
+
*/
|
|
1124
|
+
function commentRunStart(text, leadStart, memberStart) {
|
|
1125
|
+
if (!(leadStart >= 0) || leadStart > memberStart) return memberStart;
|
|
1126
|
+
let j = leadStart;
|
|
1127
|
+
let runStart = -1;
|
|
1128
|
+
let lastEnd = -1;
|
|
1129
|
+
while (j < memberStart) {
|
|
1130
|
+
const c = text[j];
|
|
1131
|
+
if (c === undefined) break;
|
|
1132
|
+
if (SPACE[c] === 1) { j++; continue; }
|
|
1133
|
+
const start = j;
|
|
1134
|
+
if (c === '/' && text[j + 1] === '/') {
|
|
1135
|
+
while (j < memberStart && text[j] !== '\n' && text[j] !== '\r') j++;
|
|
1136
|
+
} else if (c === '/' && text[j + 1] === '*') {
|
|
1137
|
+
const close = text.indexOf('*/', j + 2);
|
|
1138
|
+
if (close === -1 || close + 2 > memberStart) break;
|
|
1139
|
+
j = close + 2;
|
|
1140
|
+
} else {
|
|
1141
|
+
break; // not trivia; the parser says this cannot happen
|
|
1142
|
+
}
|
|
1143
|
+
if (lineBreaksBetween(text, leadStart, start) === 0) {
|
|
1144
|
+
// It starts on the line the previous member (or the opening delimiter)
|
|
1145
|
+
// ended on, so it follows that and not this one.
|
|
1146
|
+
lastEnd = j;
|
|
1147
|
+
continue;
|
|
1148
|
+
}
|
|
1149
|
+
if (runStart === -1 || lineBreaksBetween(text, lastEnd, start) >= 2) runStart = start;
|
|
1150
|
+
lastEnd = j;
|
|
1151
|
+
}
|
|
1152
|
+
if (runStart === -1) return memberStart;
|
|
1153
|
+
// Exactly one line break between the run and the member: no blank line, and
|
|
1154
|
+
// the run is not sitting on the member's own line in front of it.
|
|
1155
|
+
return lineBreaksBetween(text, lastEnd, memberStart) === 1 ? runStart : memberStart;
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
/** How many lines `text[from..to)` ends, counting CRLF once. Stops counting at 2. */
|
|
1159
|
+
function lineBreaksBetween(text, from, to) {
|
|
1160
|
+
let n = 0;
|
|
1161
|
+
for (let j = from; j < to && n < 2; j++) {
|
|
1162
|
+
const c = text[j];
|
|
1163
|
+
if (c === '\r') {
|
|
1164
|
+
n++;
|
|
1165
|
+
if (text[j + 1] === '\n') j++;
|
|
1166
|
+
} else if (c === '\n') {
|
|
1167
|
+
n++;
|
|
1168
|
+
} else {
|
|
1169
|
+
const code = c.charCodeAt(0);
|
|
1170
|
+
if (code === 0x2028 || code === 0x2029) n++; // the other two line terminators
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
return n;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
/**
|
|
1177
|
+
* How much of a removal is the member's own line rather than the member itself,
|
|
1178
|
+
* and what to take with it.
|
|
1179
|
+
*
|
|
1180
|
+
* A member that had its line to itself takes the whole line: its indentation,
|
|
1181
|
+
* the comment that follows it on that line, the comments written above it, and
|
|
1182
|
+
* the line break. Leaving any of that behind is what orphans a note against the
|
|
1183
|
+
* wrong lane, with the deleted lane's indentation fused onto it. A comment
|
|
1184
|
+
* that follows the member on its line but runs on past it goes too — it is
|
|
1185
|
+
* that member's by the same sentence `commentRunStart` states, and the insert
|
|
1186
|
+
* side already treats it that way, so the two would otherwise disagree about
|
|
1187
|
+
* who owns it.
|
|
1188
|
+
*
|
|
1189
|
+
* A member sharing its line with others takes only itself plus the horizontal
|
|
1190
|
+
* whitespace that followed it, so a one-line array closes up instead of keeping
|
|
1191
|
+
* the gap where the element used to be.
|
|
1192
|
+
*/
|
|
1193
|
+
function removalRange(text, start, end, leadStart) {
|
|
1194
|
+
const withComment = endOfTrailingTrivia(text, end, true);
|
|
1195
|
+
const e = withComment > end ? withComment : end;
|
|
1196
|
+
const s = commentRunStart(text, leadStart, start);
|
|
1197
|
+
|
|
1198
|
+
let head = s;
|
|
1199
|
+
while (head > 0 && (text[head - 1] === ' ' || text[head - 1] === '\t')) head--;
|
|
1200
|
+
const ownsTheLine = head === 0 || text[head - 1] === '\n';
|
|
1201
|
+
|
|
1202
|
+
let tail = e;
|
|
1203
|
+
while (tail < text.length && (text[tail] === ' ' || text[tail] === '\t')) tail++;
|
|
1204
|
+
if (ownsTheLine && (text[tail] === '\n' || text[tail] === '\r')) {
|
|
1205
|
+
if (text[tail] === '\r') tail++;
|
|
1206
|
+
if (text[tail] === '\n') tail++;
|
|
1207
|
+
return [head, tail];
|
|
1208
|
+
}
|
|
1209
|
+
return [s, tail];
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
/** True when `text[from..to)` holds any character the language ends a line at. */
|
|
1213
|
+
function hasLineBreakBetween(text, from, to) {
|
|
1214
|
+
for (let j = from; j < to; j++) {
|
|
1215
|
+
if (isFormatBreak(text[j]) || text[j] === '\n' || text[j] === '\r') return true;
|
|
1216
|
+
}
|
|
1217
|
+
return false;
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
/**
|
|
1221
|
+
* DEL, and the two separators the language counts as line terminators inside a
|
|
1222
|
+
* string. They are written as code points rather than as themselves so that
|
|
1223
|
+
* nothing invisible sits in this file.
|
|
1224
|
+
*/
|
|
1225
|
+
function isFormatBreak(ch) {
|
|
1226
|
+
if (ch === undefined) return false;
|
|
1227
|
+
const code = ch.charCodeAt(0);
|
|
1228
|
+
return code === 0x7f || code === 0x2028 || code === 0x2029;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
/**
|
|
1232
|
+
* An unpaired surrogate. `quoteString` iterates by code point, so a character
|
|
1233
|
+
* still in the surrogate range here is one that has no partner: writing it out
|
|
1234
|
+
* as itself survives an in-memory round trip but not the trip through UTF-8
|
|
1235
|
+
* that saving the file is, so it is escaped like the other characters that
|
|
1236
|
+
* cannot be written literally.
|
|
1237
|
+
*/
|
|
1238
|
+
function isLoneSurrogate(ch) {
|
|
1239
|
+
const code = ch.charCodeAt(0);
|
|
1240
|
+
return code >= 0xd800 && code <= 0xdfff && ch.length === 1;
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
/**
|
|
1244
|
+
* Walk forward from `from` over the trivia that belongs to whatever just ended —
|
|
1245
|
+
* horizontal whitespace and comments — and answer where the line break is.
|
|
1246
|
+
* Anything else, such as the closing bracket of a one-line array, answers
|
|
1247
|
+
* `from`, so nothing is assumed about text this has not accounted for.
|
|
1248
|
+
*
|
|
1249
|
+
* `acrossLines` says whether a block comment may carry the scan onto later
|
|
1250
|
+
* lines. Both callers want that, for the same reason: a comment written after
|
|
1251
|
+
* an element describes that element however many lines it takes, so an insert
|
|
1252
|
+
* that landed in front of it would hand it to the new element, and a remove
|
|
1253
|
+
* that left it behind would hand it to the next one.
|
|
1254
|
+
*/
|
|
1255
|
+
function endOfTrailingTrivia(text, from, acrossLines) {
|
|
1256
|
+
let j = from;
|
|
1257
|
+
for (;;) {
|
|
1258
|
+
const c = text[j];
|
|
1259
|
+
if (c === ' ' || c === '\t') { j++; continue; }
|
|
1260
|
+
if (c === '\n' || c === '\r') return j;
|
|
1261
|
+
if (c === '/' && text[j + 1] === '/') {
|
|
1262
|
+
while (j < text.length && text[j] !== '\n' && text[j] !== '\r') j++;
|
|
1263
|
+
return j;
|
|
1264
|
+
}
|
|
1265
|
+
if (c === '/' && text[j + 1] === '*') {
|
|
1266
|
+
const close = text.indexOf('*/', j + 2);
|
|
1267
|
+
if (close === -1) return from;
|
|
1268
|
+
if (!acrossLines && hasLineBreakBetween(text, j, close)) return from;
|
|
1269
|
+
j = close + 2;
|
|
1270
|
+
continue;
|
|
1271
|
+
}
|
|
1272
|
+
return from;
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
/** Walk `doc` down `path`, distinguishing "absent" from "present and undefined". */
|
|
1277
|
+
function lookup(doc, path) {
|
|
1278
|
+
let cur = doc;
|
|
1279
|
+
for (let k = 0; k < path.length; k++) {
|
|
1280
|
+
if (cur === null || typeof cur !== 'object') return { found: false };
|
|
1281
|
+
const seg = path[k];
|
|
1282
|
+
if (Array.isArray(cur)) {
|
|
1283
|
+
if (typeof seg !== 'number' || !(seg >= 0 && seg < cur.length)) return { found: false };
|
|
1284
|
+
} else if (typeof seg !== 'string' || !Object.prototype.hasOwnProperty.call(cur, seg)) {
|
|
1285
|
+
return { found: false };
|
|
1286
|
+
}
|
|
1287
|
+
cur = cur[seg];
|
|
1288
|
+
}
|
|
1289
|
+
return { found: true, value: cur };
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
/**
|
|
1293
|
+
* Whether two scalars are the same value for the purpose of "did this edit
|
|
1294
|
+
* change anything". NaN counts as equal to itself, and negative zero as equal
|
|
1295
|
+
* to zero — the difference has no meaning in a waveform, and treating them as
|
|
1296
|
+
* different would mean rewriting the author's literal to say the same thing.
|
|
1297
|
+
* Containers never reach this: a container set is refused before it gets here.
|
|
1298
|
+
*/
|
|
1299
|
+
function sameScalar(a, b) {
|
|
1300
|
+
if (a === b) return true;
|
|
1301
|
+
return Number.isNaN(a) && Number.isNaN(b);
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
function kindOf(value) {
|
|
1305
|
+
if (value === null) return 'null';
|
|
1306
|
+
if (Array.isArray(value)) return 'array';
|
|
1307
|
+
const t = typeof value;
|
|
1308
|
+
return t === 'object' ? 'object' : t;
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
function rangeOf(path, spanPath, span) {
|
|
1312
|
+
return { path: path, spanPath: spanPath, start: span[0], end: span[1] };
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
/**
|
|
1316
|
+
* The quote character this source spells its strings with, by counting them.
|
|
1317
|
+
*
|
|
1318
|
+
* A REPLACED value inherits the quote of the span it is replacing — that is
|
|
1319
|
+
* `resolveSet` a few hundred lines up, and it is exact. A value that is being
|
|
1320
|
+
* INSERTED has no span to inherit from, and writing it with a hard-coded `"`
|
|
1321
|
+
* put `{ name: "rst", … }` into files whose every other line reads
|
|
1322
|
+
* `{ name: 'clk', … }`. The document is still correct and still parses; it is
|
|
1323
|
+
* simply not the file the author wrote, and this module's whole point is that
|
|
1324
|
+
* the bytes it does not have to touch stay the bytes the author wrote.
|
|
1325
|
+
*
|
|
1326
|
+
* Counted from the PARSE rather than from the raw text, so a `"` inside a
|
|
1327
|
+
* single-quoted string or a comment cannot vote. Ties, and a source with no
|
|
1328
|
+
* strings in it at all, go to `"` — which is what the inserts used
|
|
1329
|
+
* unconditionally before and is JSON's own spelling.
|
|
1330
|
+
*/
|
|
1331
|
+
function dominantQuote(ctx) {
|
|
1332
|
+
let single = 0;
|
|
1333
|
+
let double = 0;
|
|
1334
|
+
for (const span of ctx.parsed.spans.values()) {
|
|
1335
|
+
const ch = ctx.text[span[0]];
|
|
1336
|
+
if (ch === "'") single++;
|
|
1337
|
+
else if (ch === '"') double++;
|
|
1338
|
+
}
|
|
1339
|
+
return single > double ? "'" : '"';
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
/** The range of the closest ancestor that does have a span. The root always has one. */
|
|
1343
|
+
function nearestRange(ctx, path) {
|
|
1344
|
+
const p = Array.isArray(path) ? path : [];
|
|
1345
|
+
for (let n = p.length; n >= 0; n--) {
|
|
1346
|
+
const sub = p.slice(0, n);
|
|
1347
|
+
const span = ctx.parsed.spans.get(JSON.stringify(sub));
|
|
1348
|
+
if (span) return rangeOf(p, sub, span);
|
|
1349
|
+
}
|
|
1350
|
+
return null;
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
/**
|
|
1354
|
+
* Render a value as WaveJSON source text. `quote` is the quote character the
|
|
1355
|
+
* span being replaced already used, so a single-quoted value stays single
|
|
1356
|
+
* quoted; the other quote character is then left alone inside the string rather
|
|
1357
|
+
* than escaped, because it needs no escaping there.
|
|
1358
|
+
*
|
|
1359
|
+
* A value with no WaveJSON spelling — undefined, and the other host-only value
|
|
1360
|
+
* kinds — comes back as a refusal instead of being written out as the bare word
|
|
1361
|
+
* undefined, which would read back as something the editor never asked for.
|
|
1362
|
+
*
|
|
1363
|
+
* `budget` is how many containers may be opened below this point: the reading
|
|
1364
|
+
* half of this file stops at `MAX_DEPTH`, so a value written at a path that
|
|
1365
|
+
* already uses some of that may only have the rest. Anything deeper is refused
|
|
1366
|
+
* rather than written, because it would produce a file this very module then
|
|
1367
|
+
* refuses to read. A value that contains itself is refused too, caught on the
|
|
1368
|
+
* stack of containers currently being written rather than by running out of
|
|
1369
|
+
* actual stack. Both matter because the value arrives from an editor's own
|
|
1370
|
+
* model, where a lane pointing back at the document it belongs to is an
|
|
1371
|
+
* ordinary shape — and so is a Date, which is why an object that is not a plain
|
|
1372
|
+
* one is refused as well instead of being written out as `{}`.
|
|
1373
|
+
*/
|
|
1374
|
+
function serialize(value, quote, budget) {
|
|
1375
|
+
return writeValue(value, quote, budget, budget, []);
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
/** The recursive half of `serialize`, carrying the depth budget and the stack. */
|
|
1379
|
+
function writeValue(value, quote, depthLeft, budget, stack) {
|
|
1380
|
+
const kind = kindOf(value);
|
|
1381
|
+
if (kind === 'string') return { ok: true, text: quoteString(value, quote) };
|
|
1382
|
+
if (kind === 'number') {
|
|
1383
|
+
if (Number.isNaN(value)) return { ok: true, text: 'NaN' };
|
|
1384
|
+
if (value === Infinity) return { ok: true, text: 'Infinity' };
|
|
1385
|
+
if (value === -Infinity) return { ok: true, text: '-Infinity' };
|
|
1386
|
+
return { ok: true, text: String(value) };
|
|
1387
|
+
}
|
|
1388
|
+
if (kind === 'boolean') return { ok: true, text: String(value) };
|
|
1389
|
+
if (kind === 'null') return { ok: true, text: 'null' };
|
|
1390
|
+
if (kind === 'array' || kind === 'object') {
|
|
1391
|
+
if (kind === 'object' && !isPlainObject(value)) {
|
|
1392
|
+
// A Date, Map, Set, RegExp or class instance answers `Object.keys` with an
|
|
1393
|
+
// empty list, so writing it would emit `{}` and report success — data loss
|
|
1394
|
+
// wearing a successful return, which is the one outcome this module exists
|
|
1395
|
+
// to prevent. It has no WaveJSON spelling, so it is refused like the other
|
|
1396
|
+
// host-only values.
|
|
1397
|
+
return { ok: false, why: 'the value holds something that is not a plain object, ' +
|
|
1398
|
+
'which has no WaveJSON spelling of its own and would be written as an empty ' +
|
|
1399
|
+
'object, so nothing was written' };
|
|
1400
|
+
}
|
|
1401
|
+
if (stack.indexOf(value) !== -1) {
|
|
1402
|
+
return { ok: false, why: 'the value contains a cycle — it holds itself, ' +
|
|
1403
|
+
'which has no WaveJSON spelling, so nothing was written' };
|
|
1404
|
+
}
|
|
1405
|
+
if (!(depthLeft >= 1)) {
|
|
1406
|
+
return { ok: false, why: 'the value is nested too deeply: more than the ' + budget +
|
|
1407
|
+
' levels left at this path (' + MAX_DEPTH + ' is all this module will read back, ' +
|
|
1408
|
+
'and the path itself uses ' + (MAX_DEPTH - budget) + '), so nothing was written' };
|
|
1409
|
+
}
|
|
1410
|
+
stack.push(value);
|
|
1411
|
+
let out;
|
|
1412
|
+
if (kind === 'array') {
|
|
1413
|
+
const parts = [];
|
|
1414
|
+
for (const item of value) {
|
|
1415
|
+
const ser = writeValue(item, quote, depthLeft - 1, budget, stack);
|
|
1416
|
+
if (ser.ok !== true) { stack.pop(); return ser; }
|
|
1417
|
+
parts.push(ser.text);
|
|
1418
|
+
}
|
|
1419
|
+
out = '[' + parts.join(', ') + ']';
|
|
1420
|
+
} else {
|
|
1421
|
+
const parts = [];
|
|
1422
|
+
for (const key of Object.keys(value)) {
|
|
1423
|
+
if (key === PROTO_KEY) {
|
|
1424
|
+
// There is no spelling of this key that stores it as data and reads
|
|
1425
|
+
// back. Measured against this host: inside the engine's reader, which
|
|
1426
|
+
// runs WaveJSON as source text, `__proto__: v`, `"__proto__": v`
|
|
1427
|
+
// and `'__proto__': v` all set the loaded object's prototype rather
|
|
1428
|
+
// than adding a member — quoting changes nothing. Only a computed key
|
|
1429
|
+
// stores it, and this file's own parser cannot read that back. So it
|
|
1430
|
+
// is refused like the other values with no WaveJSON spelling, rather
|
|
1431
|
+
// than written as something that reads back as a different thing. The
|
|
1432
|
+
// reading half already neutralises it on the way in, with `assign`.
|
|
1433
|
+
stack.pop();
|
|
1434
|
+
return { ok: false, why: 'the value has a ' + PROTO_KEY + ' key, which has no ' +
|
|
1435
|
+
'WaveJSON spelling that stores it as data — every spelling of it sets the ' +
|
|
1436
|
+
'prototype of the object that reads it back, quoted or not — so nothing was written' };
|
|
1437
|
+
}
|
|
1438
|
+
const ser = writeValue(value[key], quote, depthLeft - 1, budget, stack);
|
|
1439
|
+
if (ser.ok !== true) { stack.pop(); return ser; }
|
|
1440
|
+
parts.push(renderKey(key, quote) + ': ' + ser.text);
|
|
1441
|
+
}
|
|
1442
|
+
out = parts.length === 0 ? '{}' : '{ ' + parts.join(', ') + ' }';
|
|
1443
|
+
}
|
|
1444
|
+
stack.pop();
|
|
1445
|
+
return { ok: true, text: out };
|
|
1446
|
+
}
|
|
1447
|
+
return { ok: false, why: 'a value of kind ' + kind + ' has no WaveJSON spelling, so nothing was written' };
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
/**
|
|
1451
|
+
* An object literal's worth of object: one whose prototype is the ordinary one,
|
|
1452
|
+
* or none at all. Anything else carries its content somewhere `Object.keys`
|
|
1453
|
+
* cannot see it.
|
|
1454
|
+
*/
|
|
1455
|
+
function isPlainObject(value) {
|
|
1456
|
+
const proto = Object.getPrototypeOf(value);
|
|
1457
|
+
return proto === null || proto === Object.prototype;
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
const BARE_KEY_RE = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
1461
|
+
|
|
1462
|
+
/** Spelled out of its own characters so that nothing in this file carries it literally. */
|
|
1463
|
+
const PROTO_KEY = '__' + 'proto' + '__';
|
|
1464
|
+
|
|
1465
|
+
function renderKey(key, quote) {
|
|
1466
|
+
return BARE_KEY_RE.test(key) ? key : quoteString(key, quote);
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
function quoteString(s, quote) {
|
|
1470
|
+
let out = quote;
|
|
1471
|
+
for (const ch of s) {
|
|
1472
|
+
if (ch === quote || ch === '\\') { out += '\\' + ch; continue; }
|
|
1473
|
+
if (ch === '\n') { out += '\\n'; continue; }
|
|
1474
|
+
if (ch === '\r') { out += '\\r'; continue; }
|
|
1475
|
+
if (ch === '\t') { out += '\\t'; continue; }
|
|
1476
|
+
if (ch === '\b') { out += '\\b'; continue; }
|
|
1477
|
+
if (ch === '\f') { out += '\\f'; continue; }
|
|
1478
|
+
if (ch < ' ' || isFormatBreak(ch) || isLoneSurrogate(ch)) {
|
|
1479
|
+
out += '\\u' + ch.charCodeAt(0).toString(16).padStart(4, '0');
|
|
1480
|
+
continue;
|
|
1481
|
+
}
|
|
1482
|
+
out += ch;
|
|
1483
|
+
}
|
|
1484
|
+
return out + quote;
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
/**
|
|
1488
|
+
* The characters a wave string is made of, and what each one means for the
|
|
1489
|
+
* picture.
|
|
1490
|
+
*
|
|
1491
|
+
* `.` is the one that decides the shape of everything below it, and it does NOT
|
|
1492
|
+
* mean "the same character again". Measured against the pinned wavedrom 3.5.0,
|
|
1493
|
+
* over the 19 level characters `01xzpnhlud=23456789`: `ch..ch` and `ch...`
|
|
1494
|
+
* render the same for exactly two of them, the clocks `p` and `n`. For the
|
|
1495
|
+
* other 17 the doubled form puts a transition marker at that cycle where the
|
|
1496
|
+
* dotted form draws a plain hold — `0..0` emits `#0m0` where `0...` emits
|
|
1497
|
+
* `#000`, and `h..h` emits `#pclk` where `h...` emits `#111`. So a model that
|
|
1498
|
+
* resolved `.` down to the character it continues would be throwing away
|
|
1499
|
+
* something the reader can see, and `collapseWave(expandWave(w)) === w` would
|
|
1500
|
+
* be false for 17 characters out of 19.
|
|
1501
|
+
*
|
|
1502
|
+
* `|` is a gap drawn over whatever level is current. It carries no level of its
|
|
1503
|
+
* own, so a `.` behind it continues the level from in front of it — measured,
|
|
1504
|
+
* `0|.` draws 0 at every cycle, while `0|0` draws the transition that says the
|
|
1505
|
+
* level was asserted again.
|
|
1506
|
+
*
|
|
1507
|
+
* `=` and `2` through `9` are the value characters, and each one written out
|
|
1508
|
+
* explicitly consumes one entry of the lane's `data` list. A `.` or a `|`
|
|
1509
|
+
* consumes none: measured, `2.2.` with `['A','B']` labels two runs A and B,
|
|
1510
|
+
* while `2222` with four labels uses all four.
|
|
1511
|
+
*/
|
|
1512
|
+
const HELD_CHAR = '.';
|
|
1513
|
+
const GAP_CHAR = '|';
|
|
1514
|
+
const VALUE_CHARS = '=23456789';
|
|
1515
|
+
|
|
1516
|
+
/**
|
|
1517
|
+
* The four characters that draw the same thing whatever comes in front of them,
|
|
1518
|
+
* and the reason a lane beginning with a repeater is not simply "unresolved".
|
|
1519
|
+
*
|
|
1520
|
+
* wavedrom builds each cycle from the PAIR (previous character, this one) —
|
|
1521
|
+
* `node_modules/wavedrom/lib/parse-wave-lane.js` shifts the first character out,
|
|
1522
|
+
* eats the repeater run behind it, and then walks pairs. A `.` or `|` at cycle 0
|
|
1523
|
+
* is therefore a literal unknown character, and so is the pair it forms with
|
|
1524
|
+
* whatever follows. Measured over all nineteen level characters after a leading
|
|
1525
|
+
* repeater: `p n h l` still draw their own glyph (`.p` draws #nclk, `.h` draws
|
|
1526
|
+
* #111), and the other fifteen draw x. From the cycle after that the pairs are
|
|
1527
|
+
* ordinary again — `.0110` draws x x 1 1 0, not x throughout.
|
|
1528
|
+
*/
|
|
1529
|
+
const ABSOLUTE_CHARS = 'pnhl';
|
|
1530
|
+
|
|
1531
|
+
function isValueChar(ch) {
|
|
1532
|
+
return typeof ch === 'string' && ch.length === 1 && VALUE_CHARS.indexOf(ch) !== -1;
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
/**
|
|
1536
|
+
* Split a wave string into one cell per cycle, losslessly.
|
|
1537
|
+
*
|
|
1538
|
+
* A cell is `{ch, held}`: `held` is true exactly where the source wrote a `.`,
|
|
1539
|
+
* and `ch` is the level that cycle displays — for a held cell, the level of the
|
|
1540
|
+
* run it continues. Nothing is normalised, so `collapseWave` puts the source
|
|
1541
|
+
* string back character for character. That round trip is the invariant every
|
|
1542
|
+
* layer above rests on, and it is only true because `held` is kept.
|
|
1543
|
+
*
|
|
1544
|
+
* A `.` with nothing in front of it has no level to continue and answers `''`.
|
|
1545
|
+
* wavedrom 3.5.0 draws such a lane entirely as x — measured, `..0` renders six
|
|
1546
|
+
* x segments and never draws the 0 at all — so `''` is the honest answer rather
|
|
1547
|
+
* than a guess at what the author meant.
|
|
1548
|
+
*
|
|
1549
|
+
* Iteration is by code point, so a character outside the basic plane stays one
|
|
1550
|
+
* cell and still round trips.
|
|
1551
|
+
*/
|
|
1552
|
+
function expandWave(wave) {
|
|
1553
|
+
const cells = [];
|
|
1554
|
+
if (typeof wave !== 'string') return cells;
|
|
1555
|
+
let level = '';
|
|
1556
|
+
for (const ch of wave) {
|
|
1557
|
+
if (ch === HELD_CHAR) {
|
|
1558
|
+
cells.push({ ch: level, held: true });
|
|
1559
|
+
continue;
|
|
1560
|
+
}
|
|
1561
|
+
cells.push({ ch: ch, held: false });
|
|
1562
|
+
if (ch !== GAP_CHAR) level = ch;
|
|
1563
|
+
}
|
|
1564
|
+
return cells;
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
/**
|
|
1568
|
+
* Put cells back together into a wave string.
|
|
1569
|
+
*
|
|
1570
|
+
* A `.` is written only where the cell says that cycle is a continuation; this
|
|
1571
|
+
* never introduces one on its own. A plain string is accepted as a cell and is
|
|
1572
|
+
* always an explicit character, so `['p','p','p','p']` comes back as `pppp` and
|
|
1573
|
+
* not as `p...` — collapsing repeats into dots would change the picture, for
|
|
1574
|
+
* the reason `HELD_CHAR` states.
|
|
1575
|
+
*/
|
|
1576
|
+
function collapseWave(cells) {
|
|
1577
|
+
if (!Array.isArray(cells)) return '';
|
|
1578
|
+
let out = '';
|
|
1579
|
+
for (const cell of cells) out += cellChar(cell);
|
|
1580
|
+
return out;
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
/** The single source character a cell stands for. */
|
|
1584
|
+
function cellChar(cell) {
|
|
1585
|
+
if (typeof cell === 'string') return cell;
|
|
1586
|
+
if (cell === null || typeof cell !== 'object') return '';
|
|
1587
|
+
if (cell.held === true) return HELD_CHAR;
|
|
1588
|
+
return typeof cell.ch === 'string' ? cell.ch : '';
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
/**
|
|
1592
|
+
* What level each cycle displays — what the geometry and rendering layers ask
|
|
1593
|
+
* for, and the half of the model that `expandWave` deliberately does not answer
|
|
1594
|
+
* on its own. `.` and `|` both take the level from in front of them, and a
|
|
1595
|
+
* cycle with nothing in front of it answers `''`.
|
|
1596
|
+
*
|
|
1597
|
+
* Takes a wave string or an array of cells.
|
|
1598
|
+
*/
|
|
1599
|
+
function levelsOf(wave) {
|
|
1600
|
+
const cells = typeof wave === 'string' ? expandWave(wave) : wave;
|
|
1601
|
+
const out = [];
|
|
1602
|
+
if (!Array.isArray(cells)) return out;
|
|
1603
|
+
let shown = 'x';
|
|
1604
|
+
let unanchored = cells.length > 0 && isRepeater(cells[0]);
|
|
1605
|
+
for (const cell of cells) {
|
|
1606
|
+
const ch = cellChar(cell);
|
|
1607
|
+
if (ch === HELD_CHAR || ch === GAP_CHAR || ch === '') {
|
|
1608
|
+
out.push(unanchored ? 'x' : shown);
|
|
1609
|
+
continue;
|
|
1610
|
+
}
|
|
1611
|
+
if (ABSOLUTE_CHARS.indexOf(ch) !== -1) {
|
|
1612
|
+
shown = ch;
|
|
1613
|
+
unanchored = false;
|
|
1614
|
+
} else if (unanchored) {
|
|
1615
|
+
shown = 'x'; // the pair this would form is not one the engine knows
|
|
1616
|
+
unanchored = false; // the pair after it is, so ordinary service resumes
|
|
1617
|
+
} else {
|
|
1618
|
+
shown = ch;
|
|
1619
|
+
}
|
|
1620
|
+
out.push(shown);
|
|
1621
|
+
}
|
|
1622
|
+
return out;
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
/** A cycle that takes its level from the one in front of it: `.` or `|`. */
|
|
1626
|
+
function isRepeater(cell) {
|
|
1627
|
+
const ch = cellChar(cell);
|
|
1628
|
+
return ch === HELD_CHAR || ch === GAP_CHAR;
|
|
1629
|
+
}
|
|
1630
|
+
/**
|
|
1631
|
+
* Read one lane into the shape the cycle operations work on: a cell per cycle,
|
|
1632
|
+
* each explicit value cell carrying the `data` label it consumes.
|
|
1633
|
+
*
|
|
1634
|
+
* Folding the labels into the cells is what lets inserting, deleting and
|
|
1635
|
+
* pasting cycles keep them lined up without a second bookkeeping pass — the
|
|
1636
|
+
* label travels with the cell that owns it. `spare` holds any labels the wave
|
|
1637
|
+
* never reaches; wavedrom ignores those, so they are handed back untouched
|
|
1638
|
+
* rather than dropped. The author's own spelling of `data` is kept too — the
|
|
1639
|
+
* raw string and the runs of whitespace inside it — so a write-back that
|
|
1640
|
+
* changes no label can hand the very same bytes straight back.
|
|
1641
|
+
*/
|
|
1642
|
+
function readLane(lane) {
|
|
1643
|
+
const cells = expandWave(typeof lane.wave === 'string' ? lane.wave : '');
|
|
1644
|
+
const raw = lane.data;
|
|
1645
|
+
const asText = typeof raw === 'string';
|
|
1646
|
+
let labels = null;
|
|
1647
|
+
let gaps = null;
|
|
1648
|
+
let tail = '';
|
|
1649
|
+
if (Array.isArray(raw)) {
|
|
1650
|
+
labels = raw;
|
|
1651
|
+
} else if (asText) {
|
|
1652
|
+
const split = splitLabels(raw);
|
|
1653
|
+
labels = split.labels;
|
|
1654
|
+
gaps = split.gaps;
|
|
1655
|
+
tail = split.tail;
|
|
1656
|
+
}
|
|
1657
|
+
let slot = 0;
|
|
1658
|
+
const rich = [];
|
|
1659
|
+
for (const cell of cells) {
|
|
1660
|
+
const out = { ch: cell.ch, held: cell.held, label: undefined };
|
|
1661
|
+
if (!cell.held && isValueChar(cell.ch)) {
|
|
1662
|
+
if (labels !== null) out.label = labels[slot];
|
|
1663
|
+
slot++;
|
|
1664
|
+
}
|
|
1665
|
+
rich.push(out);
|
|
1666
|
+
}
|
|
1667
|
+
return {
|
|
1668
|
+
cells: rich,
|
|
1669
|
+
hasLabels: labels !== null,
|
|
1670
|
+
asText: asText,
|
|
1671
|
+
raw: asText ? raw : null,
|
|
1672
|
+
read: labels === null ? [] : labels.slice(),
|
|
1673
|
+
gaps: gaps,
|
|
1674
|
+
tail: tail,
|
|
1675
|
+
spare: labels === null ? [] : labels.slice(slot),
|
|
1676
|
+
};
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
/**
|
|
1680
|
+
* Split a space-separated `data` string the way wavedrom does, keeping the
|
|
1681
|
+
* whitespace that came before each label so a rewrite can put it back.
|
|
1682
|
+
*/
|
|
1683
|
+
function splitLabels(text) {
|
|
1684
|
+
const labels = [];
|
|
1685
|
+
const gaps = [];
|
|
1686
|
+
let gap = '';
|
|
1687
|
+
let i = 0;
|
|
1688
|
+
while (i < text.length) {
|
|
1689
|
+
if (SPACE[text[i]] === 1) {
|
|
1690
|
+
gap += text[i];
|
|
1691
|
+
i++;
|
|
1692
|
+
continue;
|
|
1693
|
+
}
|
|
1694
|
+
let j = i;
|
|
1695
|
+
while (j < text.length && SPACE[text[j]] !== 1) j++;
|
|
1696
|
+
labels.push(text.slice(i, j));
|
|
1697
|
+
gaps.push(gap);
|
|
1698
|
+
gap = '';
|
|
1699
|
+
i = j;
|
|
1700
|
+
}
|
|
1701
|
+
return { labels: labels, gaps: gaps, tail: gap };
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
/** Whether the space-separated spelling of `data` can carry this label at all. */
|
|
1705
|
+
function isPlainLabel(label) {
|
|
1706
|
+
if (label === '') return false;
|
|
1707
|
+
for (const ch of label) {
|
|
1708
|
+
if (SPACE[ch] === 1) return false;
|
|
1709
|
+
}
|
|
1710
|
+
return true;
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
/** Write a lane model back out as a new lane object, sharing nothing mutable with the old one. */
|
|
1714
|
+
function writeLane(lane, model) {
|
|
1715
|
+
const next = copyObject(lane);
|
|
1716
|
+
next.wave = collapseWave(model.cells);
|
|
1717
|
+
if (model.hasLabels) {
|
|
1718
|
+
const labels = [];
|
|
1719
|
+
for (const cell of model.cells) {
|
|
1720
|
+
if (!cell.held && isValueChar(cell.ch)) labels.push(cell.label);
|
|
1721
|
+
}
|
|
1722
|
+
// Value cells past the end of the author's list never had a label, so the
|
|
1723
|
+
// list stops where theirs did rather than growing a tail of blanks.
|
|
1724
|
+
while (labels.length > 0 && labels[labels.length - 1] === undefined) labels.pop();
|
|
1725
|
+
for (let k = 0; k < labels.length; k++) {
|
|
1726
|
+
if (labels[k] === undefined) labels[k] = '';
|
|
1727
|
+
}
|
|
1728
|
+
for (const left of model.spare) labels.push(left);
|
|
1729
|
+
next.data = model.asText ? writeLabelText(labels, model) : labels;
|
|
1730
|
+
}
|
|
1731
|
+
return next;
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
/**
|
|
1735
|
+
* The space-separated spelling of `data`, or an array when that spelling cannot
|
|
1736
|
+
* say what the labels now say.
|
|
1737
|
+
*
|
|
1738
|
+
* wavedrom reads the string as `trim().split(/\s+/)`
|
|
1739
|
+
* (`node_modules/wavedrom/lib/parse-wave-lanes.js`), so two labels do not
|
|
1740
|
+
* survive it: a blank one vanishes and slides every later label one value cell
|
|
1741
|
+
* to the left, and one containing a space splits in two and pushes the last
|
|
1742
|
+
* label off the end. Both are silent, and both put a label on a cycle the user
|
|
1743
|
+
* did not touch — the failure this whole module exists to prevent. So where the
|
|
1744
|
+
* string form cannot say it, the lane is converted to array form. That is the
|
|
1745
|
+
* one place this changes how the author wrote something, and it is a deliberate
|
|
1746
|
+
* trade: a spelling change they can see, instead of a label landing on the
|
|
1747
|
+
* wrong cycle, which they cannot.
|
|
1748
|
+
*
|
|
1749
|
+
* When no label changed, the original string is handed back byte for byte, so
|
|
1750
|
+
* an edit that does not touch a label cannot touch its spacing either. When
|
|
1751
|
+
* labels did change and are all representable, the author's own runs of
|
|
1752
|
+
* whitespace are reused from the left and only genuinely new positions get a
|
|
1753
|
+
* single space.
|
|
1754
|
+
*/
|
|
1755
|
+
function writeLabelText(labels, model) {
|
|
1756
|
+
if (sameStrings(labels, model.read)) return model.raw;
|
|
1757
|
+
for (const label of labels) {
|
|
1758
|
+
if (!isPlainLabel(label)) return labels;
|
|
1759
|
+
}
|
|
1760
|
+
let out = '';
|
|
1761
|
+
for (let k = 0; k < labels.length; k++) {
|
|
1762
|
+
const known = model.gaps !== null && k < model.gaps.length;
|
|
1763
|
+
out += (known ? model.gaps[k] : (k === 0 ? '' : ' ')) + labels[k];
|
|
1764
|
+
}
|
|
1765
|
+
return out + model.tail;
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
function sameStrings(a, b) {
|
|
1769
|
+
if (a.length !== b.length) return false;
|
|
1770
|
+
for (let k = 0; k < a.length; k++) {
|
|
1771
|
+
if (a[k] !== b[k]) return false;
|
|
1772
|
+
}
|
|
1773
|
+
return true;
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
/**
|
|
1777
|
+
* A shallow copy that keeps key order and cannot be tricked by a `__proto__`
|
|
1778
|
+
* key the source file wrote: `Object.assign` would run that one through the
|
|
1779
|
+
* prototype setter instead of copying it, which is the same hole `assign`
|
|
1780
|
+
* closes on the way in.
|
|
1781
|
+
*/
|
|
1782
|
+
function copyObject(obj) {
|
|
1783
|
+
const out = {};
|
|
1784
|
+
if (obj === null || typeof obj !== 'object') return out;
|
|
1785
|
+
for (const key of Object.keys(obj)) assign(out, key, obj[key]);
|
|
1786
|
+
return out;
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
/**
|
|
1790
|
+
* A copy all the way down, for a value arriving from a caller who keeps their
|
|
1791
|
+
* own reference to it. A shallow copy leaves the arrays shared, so a caller
|
|
1792
|
+
* appending to their own `data` afterwards would be writing inside the
|
|
1793
|
+
* document. Depth is bounded by the same ceiling the reader uses, which also
|
|
1794
|
+
* means a value holding itself stops rather than running the stack out; below
|
|
1795
|
+
* the ceiling, and for anything that is not a plain object or an array, the
|
|
1796
|
+
* value is carried over as it is.
|
|
1797
|
+
*/
|
|
1798
|
+
function copyValue(value, depth) {
|
|
1799
|
+
if (Array.isArray(value)) {
|
|
1800
|
+
if (!(depth > 0)) return value;
|
|
1801
|
+
const out = [];
|
|
1802
|
+
for (const item of value) out.push(copyValue(item, depth - 1));
|
|
1803
|
+
return out;
|
|
1804
|
+
}
|
|
1805
|
+
if (value === null || typeof value !== 'object') return value;
|
|
1806
|
+
if (!isPlainObject(value) || !(depth > 0)) return value;
|
|
1807
|
+
const out = {};
|
|
1808
|
+
for (const key of Object.keys(value)) assign(out, key, copyValue(value[key], depth - 1));
|
|
1809
|
+
return out;
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
/** The level and the data label in force at each cycle, structurally. */
|
|
1813
|
+
function resolveCells(cells) {
|
|
1814
|
+
const levels = [];
|
|
1815
|
+
const labels = [];
|
|
1816
|
+
let level = '';
|
|
1817
|
+
let label;
|
|
1818
|
+
for (const cell of cells) {
|
|
1819
|
+
if (!cell.held && cell.ch !== GAP_CHAR && cell.ch !== '') {
|
|
1820
|
+
level = cell.ch;
|
|
1821
|
+
label = isValueChar(cell.ch) ? cell.label : undefined;
|
|
1822
|
+
}
|
|
1823
|
+
levels.push(level);
|
|
1824
|
+
labels.push(label);
|
|
1825
|
+
}
|
|
1826
|
+
return { levels: levels, labels: labels };
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
/**
|
|
1830
|
+
* Write out the continuation that a change at `index` would otherwise re-point,
|
|
1831
|
+
* and answer the cells to change instead.
|
|
1832
|
+
*
|
|
1833
|
+
* Painting one cycle moves every cycle that was holding its level. On `0...`,
|
|
1834
|
+
* setting cycle 2 to `1` naively gives `0.1.`, and cycle 3 — which nobody
|
|
1835
|
+
* touched — goes from 0 to 1; measured, `0.1.` and `0.10` are different
|
|
1836
|
+
* pictures. So the first held cell after `index` is written out at the level it
|
|
1837
|
+
* was already showing, and every continuation past it is anchored again by
|
|
1838
|
+
* that one.
|
|
1839
|
+
*
|
|
1840
|
+
* A `|` is stepped over rather than written out. It has no level of its own to
|
|
1841
|
+
* pin, and a gap drawn over the old level has no spelling once the level in
|
|
1842
|
+
* front of it changes; that is wavedrom's shape, not this model's, and it is
|
|
1843
|
+
* why `0|.` repainted at cycle 0 gives `1|0` and not something that keeps the
|
|
1844
|
+
* gap showing 0.
|
|
1845
|
+
*/
|
|
1846
|
+
function anchorAfter(cells, index) {
|
|
1847
|
+
for (let j = index + 1; j < cells.length; j++) {
|
|
1848
|
+
if (cells[j].held) {
|
|
1849
|
+
const out = cells.slice();
|
|
1850
|
+
out[j] = anchoredCell(cells, resolveCells(cells), j);
|
|
1851
|
+
return out;
|
|
1852
|
+
}
|
|
1853
|
+
if (cells[j].ch !== GAP_CHAR) break;
|
|
1854
|
+
}
|
|
1855
|
+
return cells;
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
/**
|
|
1859
|
+
* Write out the cell at `index` when it is a continuation, so the cells from
|
|
1860
|
+
* there on stand without whatever is in front of them today. This is what makes
|
|
1861
|
+
* a clipping self-contained, and what protects the cycle a block is about to be
|
|
1862
|
+
* pushed in front of.
|
|
1863
|
+
*/
|
|
1864
|
+
function anchorSelf(cells, index) {
|
|
1865
|
+
if (!(index >= 0 && index < cells.length) || !cells[index].held) return cells;
|
|
1866
|
+
const out = cells.slice();
|
|
1867
|
+
out[index] = anchoredCell(cells, resolveCells(cells), index);
|
|
1868
|
+
return out;
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
/**
|
|
1872
|
+
* The same, for a cell that is about to become cycle 0 — which takes a gap as
|
|
1873
|
+
* well as a continuation, because a `|` at cycle 0 has nothing to draw over.
|
|
1874
|
+
*
|
|
1875
|
+
* Measured: `0|0.` draws 0 at every cycle and `|0.` draws x at every cycle, so
|
|
1876
|
+
* deleting the first cycle without this turns a whole lane to x. The gap glyph
|
|
1877
|
+
* is what gets lost, because a gap over a level that no longer has anything in
|
|
1878
|
+
* front of it has no spelling at all; the levels are what is kept, and they are
|
|
1879
|
+
* what the user was looking at.
|
|
1880
|
+
*/
|
|
1881
|
+
function anchorAsHead(cells, index) {
|
|
1882
|
+
if (!(index >= 0 && index < cells.length) || !isRepeater(cells[index])) return cells;
|
|
1883
|
+
const out = cells.slice();
|
|
1884
|
+
out[index] = anchoredCell(cells, resolveCells(cells), index);
|
|
1885
|
+
return out;
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
/**
|
|
1889
|
+
* One cell written out at the level and label it was already showing. A cell
|
|
1890
|
+
* whose level is `''` becomes `x`, because x is what wavedrom draws for a cycle
|
|
1891
|
+
* with nothing in front of it.
|
|
1892
|
+
*/
|
|
1893
|
+
function anchoredCell(cells, resolved, j) {
|
|
1894
|
+
const cell = cells[j];
|
|
1895
|
+
let level = resolved.levels[j];
|
|
1896
|
+
if (level === '' && cell.held && typeof cell.ch === 'string' &&
|
|
1897
|
+
cell.ch !== '' && cell.ch !== HELD_CHAR && cell.ch !== GAP_CHAR) {
|
|
1898
|
+
// At cycle 0 there is nothing in front to resolve against, but a held cell
|
|
1899
|
+
// carries the level it continues, and a clipping built by hand relies on
|
|
1900
|
+
// that being read rather than thrown away.
|
|
1901
|
+
level = cell.ch;
|
|
1902
|
+
}
|
|
1903
|
+
const ch = level === '' ? 'x' : level;
|
|
1904
|
+
return { ch: ch, held: false, label: isValueChar(ch) ? resolved.labels[j] : undefined };
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
/**
|
|
1908
|
+
* Every lane in the document, in the order the user sees them: depth first
|
|
1909
|
+
* through `signal`, down into each group, skipping the strings a group uses for
|
|
1910
|
+
* its own title.
|
|
1911
|
+
*
|
|
1912
|
+
* This is the one enumeration the whole module counts by — the lane index a
|
|
1913
|
+
* caller passes in, the order a clipping's lanes come out in, and the order a
|
|
1914
|
+
* paste puts them back. Two counts is what let a paste land on a lane nobody
|
|
1915
|
+
* pointed at, so there is deliberately only one, and `flattenLanes` and
|
|
1916
|
+
* `spanOp` both get their ordinals from the same walk rather than from two
|
|
1917
|
+
* walks that happen to agree today.
|
|
1918
|
+
*/
|
|
1919
|
+
function flattenLanes(doc) {
|
|
1920
|
+
const out = [];
|
|
1921
|
+
const lanes = lanesOf(doc);
|
|
1922
|
+
if (lanes === null) return out;
|
|
1923
|
+
rebuildLanes(lanes, function (lane, ordinal, container, position, path) {
|
|
1924
|
+
out.push({ lane: lane, container: container, position: position, path: path });
|
|
1925
|
+
return lane;
|
|
1926
|
+
});
|
|
1927
|
+
return out;
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
/**
|
|
1931
|
+
* Walk every lane at any depth in display order, hand each to `fn` with its
|
|
1932
|
+
* ordinal and where it lives, and rebuild the tree around whatever comes back.
|
|
1933
|
+
* An array nothing changed inside comes back as itself, so untouched groups and
|
|
1934
|
+
* lanes stay the same objects and the diff a store has to write stays small.
|
|
1935
|
+
*/
|
|
1936
|
+
function rebuildLanes(container, fn, state, prefix) {
|
|
1937
|
+
const st = state === undefined ? { n: 0 } : state;
|
|
1938
|
+
const base = prefix === undefined ? ['signal'] : prefix;
|
|
1939
|
+
let changed = false;
|
|
1940
|
+
const out = [];
|
|
1941
|
+
for (let i = 0; i < container.length; i++) {
|
|
1942
|
+
const item = container[i];
|
|
1943
|
+
if (Array.isArray(item)) {
|
|
1944
|
+
const sub = rebuildLanes(item, fn, st, base.concat([i]));
|
|
1945
|
+
if (sub !== item) changed = true;
|
|
1946
|
+
out.push(sub);
|
|
1947
|
+
} else if (isLane(item)) {
|
|
1948
|
+
const next = fn(item, st.n, container, i, base.concat([i]));
|
|
1949
|
+
st.n++;
|
|
1950
|
+
if (next !== item) changed = true;
|
|
1951
|
+
out.push(next);
|
|
1952
|
+
} else {
|
|
1953
|
+
out.push(item);
|
|
1954
|
+
}
|
|
1955
|
+
}
|
|
1956
|
+
return changed ? out : container;
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
/**
|
|
1960
|
+
* Run `change` over the lane at display index `index` and answer a new doc — or
|
|
1961
|
+
* the very same doc, the identical object rather than an equal one, when
|
|
1962
|
+
* nothing changed.
|
|
1963
|
+
*
|
|
1964
|
+
* That identity is how a store above can tell a no-op from an edit without
|
|
1965
|
+
* comparing anything, and it is what every operation here answers for an index
|
|
1966
|
+
* out of range, a character that is not one character, or an argument it cannot
|
|
1967
|
+
* read. None of them throw, which matches the promise the parsing half of this
|
|
1968
|
+
* file makes. Lanes that were not touched come through as the same objects.
|
|
1969
|
+
*
|
|
1970
|
+
* The cost of that promise, stated rather than left to be discovered: a caller
|
|
1971
|
+
* cannot tell「你傳錯了」from「這裡本來就沒東西可改」— both are the same object
|
|
1972
|
+
* coming back. The two are genuinely different things (one is a bug, the other
|
|
1973
|
+
* is an ordinary state: a cursor at the end of a lane, a paste with an empty
|
|
1974
|
+
* clipboard), and a caller who needs them apart can ask the same question this
|
|
1975
|
+
* file asks — `Number.isInteger(i)` for an index, one code point for a brush —
|
|
1976
|
+
* BEFORE calling. Making the type errors throw instead was considered for
|
|
1977
|
+
* v3.4.0 and rejected: it would overturn the promise above, six assertions in
|
|
1978
|
+
* `test/wave-codec.test.js` that pin it, and the property the UI depends on
|
|
1979
|
+
* (one gesture costs at most one status line, never a page-level error).
|
|
1980
|
+
* `test/wave-codec.test.js` now pins the type case too, so it is a decision on
|
|
1981
|
+
* the record rather than an accident of the argument checks.
|
|
1982
|
+
*/
|
|
1983
|
+
function mapLane(doc, index, change) {
|
|
1984
|
+
const flat = flattenLanes(doc);
|
|
1985
|
+
if (!isIndex(index, flat.length)) return doc;
|
|
1986
|
+
const lane = flat[index].lane;
|
|
1987
|
+
const next = change(lane);
|
|
1988
|
+
if (next === null || next === lane || sameLane(lane, next)) return doc;
|
|
1989
|
+
return withLanes(doc, rebuildLanes(lanesOf(doc), function (item, ordinal) {
|
|
1990
|
+
return ordinal === index ? next : item;
|
|
1991
|
+
}));
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
/** A lane is an object; a nested array in `signal` is a group and a string is its title. */
|
|
1995
|
+
function isLane(lane) {
|
|
1996
|
+
return lane !== null && typeof lane === 'object' && !Array.isArray(lane);
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
function lanesOf(doc) {
|
|
2000
|
+
if (doc === null || typeof doc !== 'object') return null;
|
|
2001
|
+
return Array.isArray(doc.signal) ? doc.signal : null;
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
function withLanes(doc, lanes) {
|
|
2005
|
+
const next = copyObject(doc);
|
|
2006
|
+
next.signal = lanes;
|
|
2007
|
+
return next;
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
function isIndex(n, length) {
|
|
2011
|
+
return Number.isInteger(n) && n >= 0 && n < length;
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
function isBound(n, length) {
|
|
2015
|
+
return Number.isInteger(n) && n >= 0 && n <= length;
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
/**
|
|
2019
|
+
* Whether two lanes say the same thing. Only the top level is compared value by
|
|
2020
|
+
* value, with arrays compared element by element; anything deeper was carried
|
|
2021
|
+
* over by reference and so is the same object when it is unchanged.
|
|
2022
|
+
*/
|
|
2023
|
+
function sameLane(a, b) {
|
|
2024
|
+
const keys = Object.keys(b);
|
|
2025
|
+
if (keys.length !== Object.keys(a).length) return false;
|
|
2026
|
+
for (const key of keys) {
|
|
2027
|
+
const x = a[key];
|
|
2028
|
+
const y = b[key];
|
|
2029
|
+
if (Array.isArray(x) && Array.isArray(y)) {
|
|
2030
|
+
if (!sameStrings(x, y)) return false;
|
|
2031
|
+
continue;
|
|
2032
|
+
}
|
|
2033
|
+
if (x !== y) return false;
|
|
2034
|
+
}
|
|
2035
|
+
return true;
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
/**
|
|
2039
|
+
* Paint one cycle of one lane, addressed by display index.
|
|
2040
|
+
*
|
|
2041
|
+
* `ch` is a single character: a level, a value character, `|` for a gap, or `.`
|
|
2042
|
+
* to make the cycle continue the one in front of it. Anything else — a longer
|
|
2043
|
+
* string, a cycle outside the lane, a `.` or `|` at cycle 0 where there is
|
|
2044
|
+
* nothing to continue or to draw over — answers the doc unchanged.
|
|
2045
|
+
*
|
|
2046
|
+
* A newly painted value cell keeps the label of the value cell it replaced and
|
|
2047
|
+
* otherwise starts blank; a cell painted with something that is not a value
|
|
2048
|
+
* character gives its label up, so the lane's `data` list stays exactly as long
|
|
2049
|
+
* as the number of value characters written out.
|
|
2050
|
+
*/
|
|
2051
|
+
function setCell(doc, laneIndex, cycle, ch) {
|
|
2052
|
+
return paint(doc, laneIndex, cycle, cycle, ch, false);
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
/**
|
|
2056
|
+
* Paint a range of cycles of one lane, `from` and `to` both included and in
|
|
2057
|
+
* either order — a drag that went right to left selects the same cycles.
|
|
2058
|
+
*
|
|
2059
|
+
* The range becomes one run: `ch` at its first cycle and continuations after
|
|
2060
|
+
* it. That is what a bus painted across four cycles means, and it keeps the
|
|
2061
|
+
* range to a single `data` entry instead of one per cycle. `|` is written at
|
|
2062
|
+
* every cycle instead, having no run to start.
|
|
2063
|
+
*/
|
|
2064
|
+
function setCellRange(doc, laneIndex, from, to, ch) {
|
|
2065
|
+
return paint(doc, laneIndex, from, to, ch, true);
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
function paint(doc, laneIndex, from, to, ch, asRun) {
|
|
2069
|
+
if (typeof ch !== 'string' || Array.from(ch).length !== 1) return doc;
|
|
2070
|
+
return mapLane(doc, laneIndex, function (lane) {
|
|
2071
|
+
const model = readLane(lane);
|
|
2072
|
+
const n = model.cells.length;
|
|
2073
|
+
let lo = from;
|
|
2074
|
+
let hi = to;
|
|
2075
|
+
if (!Number.isInteger(lo) || !Number.isInteger(hi)) return null;
|
|
2076
|
+
if (lo > hi) {
|
|
2077
|
+
const swap = lo;
|
|
2078
|
+
lo = hi;
|
|
2079
|
+
hi = swap;
|
|
2080
|
+
}
|
|
2081
|
+
if (!isIndex(lo, n) || !isIndex(hi, n)) return null;
|
|
2082
|
+
// A repeater at cycle 0 has nothing to repeat, and the engine answers that
|
|
2083
|
+
// by drawing the run and the cycle after it as x. Refusing is the honest
|
|
2084
|
+
// reply to a brush that cannot mean anything where it was aimed.
|
|
2085
|
+
if (lo === 0 && (ch === HELD_CHAR || ch === GAP_CHAR)) return null;
|
|
2086
|
+
const anchored = anchorAfter(model.cells, hi);
|
|
2087
|
+
const before = resolveCells(anchored);
|
|
2088
|
+
const heldLevel = lo > 0 ? before.levels[lo - 1] : '';
|
|
2089
|
+
const cells = anchored.slice();
|
|
2090
|
+
for (let j = lo; j <= hi; j++) {
|
|
2091
|
+
if (ch === HELD_CHAR) {
|
|
2092
|
+
cells[j] = { ch: heldLevel, held: true, label: undefined };
|
|
2093
|
+
} else if (asRun && j > lo && ch !== GAP_CHAR) {
|
|
2094
|
+
cells[j] = { ch: ch, held: true, label: undefined };
|
|
2095
|
+
} else if (isValueChar(ch)) {
|
|
2096
|
+
const old = anchored[j];
|
|
2097
|
+
// A value cell that replaces another keeps its label exactly, `undefined`
|
|
2098
|
+
// included: a lane whose `data` simply runs out before its value cycles
|
|
2099
|
+
// has no label there, and inventing a blank one would force the whole
|
|
2100
|
+
// lane into array form over a label the author never wrote.
|
|
2101
|
+
cells[j] = { ch: ch, held: false,
|
|
2102
|
+
label: !old.held && isValueChar(old.ch) ? old.label : '' };
|
|
2103
|
+
} else {
|
|
2104
|
+
cells[j] = { ch: ch, held: false, label: undefined };
|
|
2105
|
+
}
|
|
2106
|
+
}
|
|
2107
|
+
model.cells = cells;
|
|
2108
|
+
return writeLane(lane, model);
|
|
2109
|
+
});
|
|
2110
|
+
}
|
|
2111
|
+
|
|
2112
|
+
/**
|
|
2113
|
+
* Widen every lane at the same cycle, so nothing slips out of line.
|
|
2114
|
+
*
|
|
2115
|
+
* The new cycles are continuations, which is what leaves every level on either
|
|
2116
|
+
* side exactly where it was. At cycle 0 there is nothing to continue, so the
|
|
2117
|
+
* lane's first cell is written out a second time in front of itself and the
|
|
2118
|
+
* rest of the new cycles continue that; a lane with no cycles at all starts
|
|
2119
|
+
* with `x`, which is what wavedrom draws where a lane says nothing.
|
|
2120
|
+
*/
|
|
2121
|
+
function insertCycles(doc, at, count) {
|
|
2122
|
+
return spanOp(doc, function (lane) {
|
|
2123
|
+
const model = readLane(lane);
|
|
2124
|
+
if (!Number.isInteger(count) || count < 1 || !isBound(at, model.cells.length)) return null;
|
|
2125
|
+
const filler = fillerCells(model.cells, at, count);
|
|
2126
|
+
model.cells = model.cells.slice(0, at).concat(filler, model.cells.slice(at));
|
|
2127
|
+
return writeLane(lane, model);
|
|
2128
|
+
});
|
|
2129
|
+
}
|
|
2130
|
+
|
|
2131
|
+
/**
|
|
2132
|
+
* Narrow every lane at the same cycle.
|
|
2133
|
+
*
|
|
2134
|
+
* The first continuation after the cut is written out first, so the cycle that
|
|
2135
|
+
* follows it keeps the level it was showing instead of picking up whatever now
|
|
2136
|
+
* lands in front of it — deleting cycle 0 of `0.11` gives `011`. When the cut
|
|
2137
|
+
* takes cycle 0 itself, the cycle that inherits the front of the lane is
|
|
2138
|
+
* written out too, gap included: `0|0.` loses its first cycle as `00.`, not as
|
|
2139
|
+
* a `|0.` whose every cycle the engine then draws as x.
|
|
2140
|
+
*/
|
|
2141
|
+
function deleteCycles(doc, at, count) {
|
|
2142
|
+
return spanOp(doc, function (lane) {
|
|
2143
|
+
const model = readLane(lane);
|
|
2144
|
+
const n = model.cells.length;
|
|
2145
|
+
if (!Number.isInteger(count) || count < 1 || !isIndex(at, n)) return null;
|
|
2146
|
+
const take = Math.min(count, n - at);
|
|
2147
|
+
let cells = anchorAfter(model.cells, at + take - 1);
|
|
2148
|
+
if (at === 0 && !isRepeater(model.cells[0])) cells = anchorAsHead(cells, take);
|
|
2149
|
+
model.cells = cells.slice(0, at).concat(cells.slice(at + take));
|
|
2150
|
+
return writeLane(lane, model);
|
|
2151
|
+
});
|
|
2152
|
+
}
|
|
2153
|
+
|
|
2154
|
+
/**
|
|
2155
|
+
* Run `change` over every lane at once, at any depth, and answer a new doc or
|
|
2156
|
+
* the same one when no lane changed.
|
|
2157
|
+
*
|
|
2158
|
+
* Every operation that changes how many cycles a lane has goes through here,
|
|
2159
|
+
* because a widening that reached only some lanes would slide the rest of the
|
|
2160
|
+
* picture out of line — the one failure a wave editor cannot let past. Lanes
|
|
2161
|
+
* inside a group are ordinary displayed lanes and are reached like any other;
|
|
2162
|
+
* leaving them out is exactly that failure, with the group's rising edges one
|
|
2163
|
+
* cycle away from everyone else's.
|
|
2164
|
+
*/
|
|
2165
|
+
function spanOp(doc, change) {
|
|
2166
|
+
const lanes = lanesOf(doc);
|
|
2167
|
+
if (lanes === null) return doc;
|
|
2168
|
+
const next = rebuildLanes(lanes, function (lane, ordinal) {
|
|
2169
|
+
const out = change(lane, ordinal);
|
|
2170
|
+
return out === null || sameLane(lane, out) ? lane : out;
|
|
2171
|
+
});
|
|
2172
|
+
return next === lanes ? doc : withLanes(doc, next);
|
|
2173
|
+
}
|
|
2174
|
+
|
|
2175
|
+
/**
|
|
2176
|
+
* The cells a widening puts in at `at`: continuations of the level in front of
|
|
2177
|
+
* them, or — at cycle 0, where there is no such level — a second copy of the
|
|
2178
|
+
* lane's first cell followed by continuations of that.
|
|
2179
|
+
*/
|
|
2180
|
+
function fillerCells(cells, at, count) {
|
|
2181
|
+
const out = [];
|
|
2182
|
+
if (at > 0) {
|
|
2183
|
+
const level = resolveCells(cells).levels[at - 1];
|
|
2184
|
+
for (let k = 0; k < count; k++) out.push({ ch: level, held: true, label: undefined });
|
|
2185
|
+
return out;
|
|
2186
|
+
}
|
|
2187
|
+
const head = cells.length > 0 ? cells[0] : null;
|
|
2188
|
+
out.push(head === null
|
|
2189
|
+
? { ch: 'x', held: false, label: undefined }
|
|
2190
|
+
: { ch: head.ch, held: head.held, label: head.label });
|
|
2191
|
+
const level = levelsOf(out)[0];
|
|
2192
|
+
for (let k = 1; k < count; k++) out.push({ ch: level, held: true, label: undefined });
|
|
2193
|
+
return out;
|
|
2194
|
+
}
|
|
2195
|
+
|
|
2196
|
+
/**
|
|
2197
|
+
* Lift a stretch of cycles off every lane, in display order.
|
|
2198
|
+
*
|
|
2199
|
+
* The clipping's first cycle is written out rather than left as a continuation
|
|
2200
|
+
* or a gap, so it carries its own level and can be pasted anywhere without
|
|
2201
|
+
* picking up whatever happens to sit in front of it there. `data` holds the
|
|
2202
|
+
* labels of the value characters inside the stretch, in order, the same way a
|
|
2203
|
+
* lane does.
|
|
2204
|
+
*/
|
|
2205
|
+
function copyCycles(doc, at, count) {
|
|
2206
|
+
if (!Number.isInteger(at) || at < 0 || !Number.isInteger(count) || count < 1) {
|
|
2207
|
+
return { kind: 'cycles', count: 0, lanes: [] };
|
|
2208
|
+
}
|
|
2209
|
+
const out = [];
|
|
2210
|
+
for (const spot of flattenLanes(doc)) {
|
|
2211
|
+
const model = readLane(spot.lane);
|
|
2212
|
+
const cells = anchorAsHead(model.cells, at).slice(at, at + count);
|
|
2213
|
+
const chars = [];
|
|
2214
|
+
const data = [];
|
|
2215
|
+
for (const cell of cells) {
|
|
2216
|
+
chars.push({ ch: cell.ch, held: cell.held });
|
|
2217
|
+
if (!cell.held && isValueChar(cell.ch)) data.push(cell.label);
|
|
2218
|
+
}
|
|
2219
|
+
out.push({ chars: chars, data: data });
|
|
2220
|
+
}
|
|
2221
|
+
return { kind: 'cycles', count: count, lanes: out };
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
/**
|
|
2225
|
+
* Put a clipping back at `at`. Clip lanes are matched to document lanes by the
|
|
2226
|
+
* same display order `copyCycles` wrote them in.
|
|
2227
|
+
*
|
|
2228
|
+
* `insert` widens every lane by the clipping's width, including the lanes the
|
|
2229
|
+
* clipping says nothing about — widening only some of them would slide the rest
|
|
2230
|
+
* of the picture out of line, the same reason `insertCycles` works on every
|
|
2231
|
+
* lane at once. `overwrite` replaces cycles in place and never lengthens a
|
|
2232
|
+
* lane: a clipping running past the end is cut off there, and a lane the
|
|
2233
|
+
* clipping does not reach is left alone, because nothing moves.
|
|
2234
|
+
*
|
|
2235
|
+
* A clipping lane whose cells do not number `count` is treated as saying
|
|
2236
|
+
* nothing about that lane rather than as a width to guess at. A clipping that
|
|
2237
|
+
* begins with a repeater is written out when it lands at cycle 0, because there
|
|
2238
|
+
* it would have nothing to repeat.
|
|
2239
|
+
*/
|
|
2240
|
+
function pasteCycles(doc, at, clip, mode) {
|
|
2241
|
+
if (mode !== 'insert' && mode !== 'overwrite') return doc;
|
|
2242
|
+
if (clip === null || typeof clip !== 'object' || clip.kind !== 'cycles' ||
|
|
2243
|
+
!Array.isArray(clip.lanes) || !Number.isInteger(clip.count) || clip.count < 1) {
|
|
2244
|
+
return doc;
|
|
2245
|
+
}
|
|
2246
|
+
if (!Number.isInteger(at) || at < 0) return doc;
|
|
2247
|
+
return spanOp(doc, function (lane, ordinal) {
|
|
2248
|
+
const model = readLane(lane);
|
|
2249
|
+
const n = model.cells.length;
|
|
2250
|
+
const source = clipCells(clip.lanes[ordinal], clip.count);
|
|
2251
|
+
// A lane that already begins with a repeater is malformed input, not
|
|
2252
|
+
// something an edit did; repairing it here would change cycles the user did
|
|
2253
|
+
// not touch, so the repair is only for a head this operation itself makes.
|
|
2254
|
+
const wasLoose = n > 0 && isRepeater(model.cells[0]);
|
|
2255
|
+
if (mode === 'overwrite') {
|
|
2256
|
+
if (source === null || !isIndex(at, n)) return null;
|
|
2257
|
+
const width = Math.min(clip.count, n - at);
|
|
2258
|
+
const anchored = anchorAfter(model.cells, at + width - 1);
|
|
2259
|
+
const block = at === 0 && !wasLoose ? anchorAsHead(source, 0) : source;
|
|
2260
|
+
model.cells = anchored.slice(0, at)
|
|
2261
|
+
.concat(block.slice(0, width), anchored.slice(at + width));
|
|
2262
|
+
return writeLane(lane, model);
|
|
2263
|
+
}
|
|
2264
|
+
if (!isBound(at, n)) return null;
|
|
2265
|
+
const anchored = anchorSelf(model.cells, at);
|
|
2266
|
+
let block = source === null ? fillerCells(anchored, at, clip.count) : source;
|
|
2267
|
+
if (at === 0 && !wasLoose) block = anchorAsHead(block, 0);
|
|
2268
|
+
model.cells = anchored.slice(0, at).concat(block, anchored.slice(at));
|
|
2269
|
+
return writeLane(lane, model);
|
|
2270
|
+
});
|
|
2271
|
+
}
|
|
2272
|
+
|
|
2273
|
+
/** One clipping lane as cells with their labels folded back in, or null when it has none. */
|
|
2274
|
+
function clipCells(clipLane, count) {
|
|
2275
|
+
if (clipLane === null || typeof clipLane !== 'object' || !Array.isArray(clipLane.chars)) {
|
|
2276
|
+
return null;
|
|
2277
|
+
}
|
|
2278
|
+
if (clipLane.chars.length !== count) return null;
|
|
2279
|
+
const labels = Array.isArray(clipLane.data) ? clipLane.data : [];
|
|
2280
|
+
let slot = 0;
|
|
2281
|
+
const out = [];
|
|
2282
|
+
for (const cell of clipLane.chars) {
|
|
2283
|
+
const ch = cellChar(cell);
|
|
2284
|
+
const held = ch === HELD_CHAR;
|
|
2285
|
+
const level = held && cell !== null && typeof cell === 'object' &&
|
|
2286
|
+
typeof cell.ch === 'string' ? cell.ch : ch;
|
|
2287
|
+
const rich = { ch: held ? level : ch, held: held, label: undefined };
|
|
2288
|
+
if (!held && isValueChar(ch)) {
|
|
2289
|
+
rich.label = labels[slot];
|
|
2290
|
+
slot++;
|
|
2291
|
+
}
|
|
2292
|
+
out.push(rich);
|
|
2293
|
+
}
|
|
2294
|
+
return out;
|
|
2295
|
+
}
|
|
2296
|
+
|
|
2297
|
+
/**
|
|
2298
|
+
* Put a lane into the document so that it becomes the lane at display position
|
|
2299
|
+
* `at`, as a copy all the way down — the object the caller hands over stays
|
|
2300
|
+
* theirs, and changing it afterwards cannot reach inside the doc.
|
|
2301
|
+
*/
|
|
2302
|
+
function addLane(doc, at, lane) {
|
|
2303
|
+
if (!isLane(lane)) return doc;
|
|
2304
|
+
const path = laneInsertPath(doc, at);
|
|
2305
|
+
if (path === null) return doc;
|
|
2306
|
+
return insertAtPath(doc, path, copyValue(lane, MAX_DEPTH));
|
|
2307
|
+
}
|
|
2308
|
+
|
|
2309
|
+
/**
|
|
2310
|
+
* Take the lane at display position `at` out — and with it the group it leaves
|
|
2311
|
+
* with no lanes at all, however many levels of group that empties.
|
|
2312
|
+
*
|
|
2313
|
+
* An empty group draws nothing and, because a position resolves against the
|
|
2314
|
+
* lane sitting there, no display position ever resolves inside one again. Left
|
|
2315
|
+
* behind, it would be a region of the document the user could see in their file
|
|
2316
|
+
* and never address through this module again; that is a trap rather than a
|
|
2317
|
+
* trade, so the group goes with its last lane.
|
|
2318
|
+
*/
|
|
2319
|
+
function removeLane(doc, at) {
|
|
2320
|
+
const path = laneRemovePath(doc, at);
|
|
2321
|
+
if (path === null) return doc;
|
|
2322
|
+
return withoutPath(doc, path);
|
|
2323
|
+
}
|
|
2324
|
+
|
|
2325
|
+
/**
|
|
2326
|
+
* Move the lane at display position `from` to `to`, counted after it has been
|
|
2327
|
+
* lifted out, which is what a drag down the lane list does. It may cross into or
|
|
2328
|
+
* out of a group, by the rule `laneInsertPath` states, and it takes an emptied
|
|
2329
|
+
* group with it exactly as `removeLane` does.
|
|
2330
|
+
*
|
|
2331
|
+
* The lane object itself comes through as the same object. To write the move
|
|
2332
|
+
* back, a store asks the bridge for both ends:
|
|
2333
|
+
*
|
|
2334
|
+
* {op: 'move',
|
|
2335
|
+
* path: lanePath(doc, from),
|
|
2336
|
+
* to: laneInsertPath(doc, to < from ? to : to + 1)}
|
|
2337
|
+
*
|
|
2338
|
+
* The `to + 1` is the same counting this function does: a destination measured
|
|
2339
|
+
* after the lift is one place further along while the lane is still in the way,
|
|
2340
|
+
* and a `move` edit is resolved against the document as it stands.
|
|
2341
|
+
*/
|
|
2342
|
+
function moveLane(doc, from, to) {
|
|
2343
|
+
const flat = flattenLanes(doc);
|
|
2344
|
+
if (!isIndex(from, flat.length) || !isIndex(to, flat.length) || from === to) return doc;
|
|
2345
|
+
const lane = flat[from].lane;
|
|
2346
|
+
const reduced = removeLane(doc, from);
|
|
2347
|
+
const path = laneInsertPath(reduced, to);
|
|
2348
|
+
if (path === null) return doc;
|
|
2349
|
+
return insertAtPath(reduced, path, lane);
|
|
2350
|
+
}
|
|
2351
|
+
|
|
2352
|
+
/**
|
|
2353
|
+
* Where the lane at display position `index` lives, as a path the patching half
|
|
2354
|
+
* of this file addresses members by — `['signal', 1, 2]` for the second lane of
|
|
2355
|
+
* the group at `signal[1]`. `null` when there is no such lane.
|
|
2356
|
+
*
|
|
2357
|
+
* This is the bridge between the two halves, and it is exported because there is
|
|
2358
|
+
* no other honest way across. The document half counts lanes in display order,
|
|
2359
|
+
* because that is the only thing a click on a waveform can mean; the patching
|
|
2360
|
+
* half addresses container paths, because that is how spans are keyed. They do
|
|
2361
|
+
* not coincide as soon as a group is involved. For
|
|
2362
|
+
* `{signal: [{a}, ['grp', {b}, {c}], {d}]}` the display positions are a=0, b=1,
|
|
2363
|
+
* c=2, d=3, and their paths are `['signal',0]`, `['signal',1,1]`,
|
|
2364
|
+
* `['signal',1,2]`, `['signal',2]`. A caller that builds `['signal', index]` for
|
|
2365
|
+
* itself renames `d` when the user asked for `c`, and nothing refuses, because
|
|
2366
|
+
* that path names a real lane too.
|
|
2367
|
+
*
|
|
2368
|
+
* So every structural edit this module can produce has exactly one supported way
|
|
2369
|
+
* to be written back. All five are pinned as tests against a grouped fixture in
|
|
2370
|
+
* `test/wave-codec.test.js`; this list is a map to them, not the authority:
|
|
2371
|
+
*
|
|
2372
|
+
* renameLane(doc, k, n), lane already has a name
|
|
2373
|
+
* {path: lanePath(doc, k).concat(['name']), value: n}
|
|
2374
|
+
* renameLane(doc, k, n), lane has no name
|
|
2375
|
+
* {op: 'insert', path: lanePath(doc, k).concat(['name']), value: n}
|
|
2376
|
+
* addLane(doc, k, lane)
|
|
2377
|
+
* {op: 'insert', path: laneInsertPath(doc, k), value: lane}
|
|
2378
|
+
* removeLane(doc, k)
|
|
2379
|
+
* {op: 'remove', path: laneRemovePath(doc, k)}
|
|
2380
|
+
* moveLane(doc, f, t)
|
|
2381
|
+
* {op: 'move', path: lanePath(doc, f),
|
|
2382
|
+
* to: laneInsertPath(doc, t < f ? t : t + 1)}
|
|
2383
|
+
*
|
|
2384
|
+
* The rename is the one with a branch, and picking the wrong side of it cannot
|
|
2385
|
+
* go unnoticed: `set` on a lane with no `name` refuses because nothing is parsed
|
|
2386
|
+
* at that path, and `insert` on a lane that has one refuses because the member
|
|
2387
|
+
* is already there. Both refusals are pinned too.
|
|
2388
|
+
*/
|
|
2389
|
+
function lanePath(doc, index) {
|
|
2390
|
+
const flat = flattenLanes(doc);
|
|
2391
|
+
return isIndex(index, flat.length) ? flat[index].path : null;
|
|
2392
|
+
}
|
|
2393
|
+
|
|
2394
|
+
/**
|
|
2395
|
+
* Every lane's path, in display order, from ONE walk.
|
|
2396
|
+
*
|
|
2397
|
+
* `lanePath` answers a single position and walks the document to do it, which
|
|
2398
|
+
* is the right shape for an edit — a document op touches one lane. A layer that
|
|
2399
|
+
* needs the whole list, like the geometry that lays out every row, would have
|
|
2400
|
+
* to ask N times and pay N walks for it, and that cost follows the DOCUMENT's
|
|
2401
|
+
* length rather than whatever ceiling the caller puts on its own result.
|
|
2402
|
+
*
|
|
2403
|
+
* So the bridge gets this second door. It is not a second ORDER: both doors are
|
|
2404
|
+
* `flattenLanes`, the single enumeration this module counts by, and
|
|
2405
|
+
* `test/wave-codec.test.js` pins them against each other position by position.
|
|
2406
|
+
* The array is freshly built, so a caller may keep or mutate it.
|
|
2407
|
+
*/
|
|
2408
|
+
function lanePaths(doc) {
|
|
2409
|
+
return flattenLanes(doc).map(function (entry) { return entry.path; });
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
/**
|
|
2413
|
+
* Where a lane inserted at display position `index` goes, and what happens at
|
|
2414
|
+
* the two edges.
|
|
2415
|
+
*
|
|
2416
|
+
* A position resolves to a container as well as an offset: inserting at `index`
|
|
2417
|
+
* means going in immediately in front of whichever lane sits there now, in that
|
|
2418
|
+
* lane's own container. Two edges follow, and neither is arbitrary. Inserting at
|
|
2419
|
+
* a group's FIRST lane goes inside the group, in front of it. Inserting at the
|
|
2420
|
+
* position just past a group's LAST lane resolves to the next lane outside the
|
|
2421
|
+
* group, so it lands outside — a group can be joined at its head but not at its
|
|
2422
|
+
* tail. Past the last lane of the document there is no lane to resolve against
|
|
2423
|
+
* at all, so it goes on the end of `signal` itself.
|
|
2424
|
+
*/
|
|
2425
|
+
function laneInsertPath(doc, index) {
|
|
2426
|
+
const lanes = lanesOf(doc);
|
|
2427
|
+
if (lanes === null) return null;
|
|
2428
|
+
const flat = flattenLanes(doc);
|
|
2429
|
+
if (!isBound(index, flat.length)) return null;
|
|
2430
|
+
return index === flat.length ? ['signal', lanes.length] : flat[index].path;
|
|
2431
|
+
}
|
|
2432
|
+
|
|
2433
|
+
/**
|
|
2434
|
+
* What a removal of the lane at display position `index` actually has to take
|
|
2435
|
+
* out: the lane, or the group around it when that group would be left holding
|
|
2436
|
+
* no lane at all.
|
|
2437
|
+
*/
|
|
2438
|
+
function laneRemovePath(doc, index) {
|
|
2439
|
+
const path = lanePath(doc, index);
|
|
2440
|
+
return path === null ? null : emptiedMemberPath(doc, path);
|
|
2441
|
+
}
|
|
2442
|
+
|
|
2443
|
+
/**
|
|
2444
|
+
* What a removal of a SET of lanes has to take out, decided for the whole set
|
|
2445
|
+
* at once.
|
|
2446
|
+
*
|
|
2447
|
+
* A caller with several lanes to remove cannot get this by asking
|
|
2448
|
+
* `laneRemovePath` once per lane: every answer is measured in a document where
|
|
2449
|
+
* the other doomed lanes are still present, so a group whose lanes are ALL
|
|
2450
|
+
* going is told, each time, that it still holds one. Every answer comes back
|
|
2451
|
+
* "just this lane", and what is left in the file is an empty group — one that
|
|
2452
|
+
* draws nothing and that no display position ever resolves inside again, which
|
|
2453
|
+
* is the trap `removeLane` exists to avoid. It is not a hypothetical: two lanes
|
|
2454
|
+
* of one group is an ordinary multi-select.
|
|
2455
|
+
*
|
|
2456
|
+
* The rule is the one `emptiedMemberPath` and `holdsContent` already state, and
|
|
2457
|
+
* it lives here beside them for the reason their own comment gives — so a
|
|
2458
|
+
* removal and the patch that writes it cannot disagree about how much goes. A
|
|
2459
|
+
* group goes when every lane under it goes, at any depth; a group the author
|
|
2460
|
+
* wrote with no lane in it at all is content of its own and keeps its parent
|
|
2461
|
+
* alive; `signal` itself is never the answer.
|
|
2462
|
+
*
|
|
2463
|
+
* Answers the topmost member that goes for each part of the tree, so the paths
|
|
2464
|
+
* returned never contain one another and can all be handed to `patchSource` in
|
|
2465
|
+
* one batch. `null` when any index is not a lane position, or when `doc` has no
|
|
2466
|
+
* lane list — the same answer the single-lane door gives.
|
|
2467
|
+
*
|
|
2468
|
+
* `laneRemovePaths(doc, [k])` and `[laneRemovePath(doc, k)]` are the same
|
|
2469
|
+
* answer, and `test/wave-codec.test.js` pins that position by position on a
|
|
2470
|
+
* grouped fixture, because two rules that are allowed to drift eventually do.
|
|
2471
|
+
*
|
|
2472
|
+
* @param {any} doc
|
|
2473
|
+
* @param {Array<number>} indexes display positions, in any order
|
|
2474
|
+
* @returns {Array<DocPath>|null}
|
|
2475
|
+
*/
|
|
2476
|
+
function laneRemovePaths(doc, indexes) {
|
|
2477
|
+
const lanes = lanesOf(doc);
|
|
2478
|
+
if (lanes === null || !Array.isArray(indexes)) return null;
|
|
2479
|
+
const flat = flattenLanes(doc);
|
|
2480
|
+
const gone = new Set();
|
|
2481
|
+
for (const index of indexes) {
|
|
2482
|
+
if (!isIndex(index, flat.length)) return null;
|
|
2483
|
+
gone.add(JSON.stringify(flat[index].path));
|
|
2484
|
+
}
|
|
2485
|
+
const out = [];
|
|
2486
|
+
emitRemovals(markRemovals(lanes, ['signal'], gone, 0), out);
|
|
2487
|
+
return out;
|
|
2488
|
+
}
|
|
2489
|
+
|
|
2490
|
+
/**
|
|
2491
|
+
* Mark one lane container: whether the whole of it goes, and the same answer
|
|
2492
|
+
* for each child. Depth is bounded by the ceiling the reader uses, so a
|
|
2493
|
+
* document this module could not have parsed cannot run the stack out here.
|
|
2494
|
+
*/
|
|
2495
|
+
function markRemovals(container, path, gone, depth) {
|
|
2496
|
+
let hasLane = false;
|
|
2497
|
+
let allGone = true;
|
|
2498
|
+
const kids = [];
|
|
2499
|
+
if (depth <= MAX_DEPTH) {
|
|
2500
|
+
for (let i = 0; i < container.length; i++) {
|
|
2501
|
+
const item = container[i];
|
|
2502
|
+
const here = path.concat([i]);
|
|
2503
|
+
if (Array.isArray(item)) {
|
|
2504
|
+
const sub = markRemovals(item, here, gone, depth + 1);
|
|
2505
|
+
kids.push(sub);
|
|
2506
|
+
if (sub.hasLane) hasLane = true;
|
|
2507
|
+
if (!sub.gone) allGone = false;
|
|
2508
|
+
} else if (isLane(item)) {
|
|
2509
|
+
hasLane = true;
|
|
2510
|
+
const doomed = gone.has(JSON.stringify(here));
|
|
2511
|
+
kids.push({ path: here, gone: doomed, kids: null, hasLane: true });
|
|
2512
|
+
if (!doomed) allGone = false;
|
|
2513
|
+
}
|
|
2514
|
+
// A group's title string is neither, and keeps nothing alive on its own.
|
|
2515
|
+
}
|
|
2516
|
+
}
|
|
2517
|
+
return { path: path, gone: hasLane && allGone, kids: kids, hasLane: hasLane };
|
|
2518
|
+
}
|
|
2519
|
+
|
|
2520
|
+
/** The topmost members that go, from a marked tree. The root is never one. */
|
|
2521
|
+
function emitRemovals(node, out) {
|
|
2522
|
+
for (const kid of node.kids) {
|
|
2523
|
+
if (kid.gone) out.push(kid.path);
|
|
2524
|
+
else if (kid.kids !== null) emitRemovals(kid, out);
|
|
2525
|
+
}
|
|
2526
|
+
}
|
|
2527
|
+
|
|
2528
|
+
/**
|
|
2529
|
+
* The member that has to go when the element at `path` leaves its array: the
|
|
2530
|
+
* element itself, or the innermost enclosing array left with no lane in it,
|
|
2531
|
+
* however many levels up that reaches. `signal` is never the answer — the
|
|
2532
|
+
* document always has a lane list, even an empty one.
|
|
2533
|
+
*
|
|
2534
|
+
* Both halves of the file read this one function rather than each deciding for
|
|
2535
|
+
* itself, so a removal and the patch that writes it cannot disagree about how
|
|
2536
|
+
* much goes.
|
|
2537
|
+
*/
|
|
2538
|
+
function emptiedMemberPath(doc, path) {
|
|
2539
|
+
let target = path;
|
|
2540
|
+
for (;;) {
|
|
2541
|
+
const last = target[target.length - 1];
|
|
2542
|
+
if (target.length < 2 || typeof last !== 'number') return target;
|
|
2543
|
+
const parentPath = target.slice(0, -1);
|
|
2544
|
+
if (typeof parentPath[parentPath.length - 1] !== 'number') return target;
|
|
2545
|
+
const parent = lookup(doc, parentPath);
|
|
2546
|
+
if (!parent.found || !Array.isArray(parent.value)) return target;
|
|
2547
|
+
let survives = false;
|
|
2548
|
+
for (let i = 0; i < parent.value.length; i++) {
|
|
2549
|
+
if (i !== last && holdsContent(parent.value[i])) {
|
|
2550
|
+
survives = true;
|
|
2551
|
+
break;
|
|
2552
|
+
}
|
|
2553
|
+
}
|
|
2554
|
+
if (survives) return target;
|
|
2555
|
+
target = parentPath;
|
|
2556
|
+
}
|
|
2557
|
+
}
|
|
2558
|
+
|
|
2559
|
+
/**
|
|
2560
|
+
* Whether this is something that keeps the container around it alive: a lane, or
|
|
2561
|
+
* a group — including one the author wrote with nothing in it.
|
|
2562
|
+
*
|
|
2563
|
+
* An empty group of their own is content, not the space a removal just made, so
|
|
2564
|
+
* taking the last lane out of `['g1', {b}, ['g2']]` leaves `['g1', ['g2']]` and
|
|
2565
|
+
* not nothing; the title they typed is theirs. What is nothing is the run of
|
|
2566
|
+
* title strings left behind by a group this removal itself emptied, and that is
|
|
2567
|
+
* the only case `emptiedMemberPath` climbs through.
|
|
2568
|
+
*
|
|
2569
|
+
* Because a nested group counts whatever is inside it, this needs no recursion,
|
|
2570
|
+
* and a lane two levels down can no longer be lost to a removal one level up.
|
|
2571
|
+
*/
|
|
2572
|
+
function holdsContent(node) {
|
|
2573
|
+
return isLane(node) || Array.isArray(node);
|
|
2574
|
+
}
|
|
2575
|
+
|
|
2576
|
+
/** A new document with the member at `path` gone, sharing everything it does not reach. */
|
|
2577
|
+
function withoutPath(node, path) {
|
|
2578
|
+
const seg = path[0];
|
|
2579
|
+
if (path.length === 1) {
|
|
2580
|
+
if (Array.isArray(node)) {
|
|
2581
|
+
const out = node.slice();
|
|
2582
|
+
out.splice(seg, 1);
|
|
2583
|
+
return out;
|
|
2584
|
+
}
|
|
2585
|
+
const out = copyObject(node);
|
|
2586
|
+
delete out[seg];
|
|
2587
|
+
return out;
|
|
2588
|
+
}
|
|
2589
|
+
return replaceSegment(node, seg, withoutPath(node[seg], path.slice(1)));
|
|
2590
|
+
}
|
|
2591
|
+
|
|
2592
|
+
/** A new document with `value` spliced in at `path`, sharing everything else. */
|
|
2593
|
+
function insertAtPath(node, path, value) {
|
|
2594
|
+
const seg = path[0];
|
|
2595
|
+
if (path.length === 1) {
|
|
2596
|
+
const out = node.slice();
|
|
2597
|
+
out.splice(seg, 0, value);
|
|
2598
|
+
return out;
|
|
2599
|
+
}
|
|
2600
|
+
return replaceSegment(node, seg, insertAtPath(node[seg], path.slice(1), value));
|
|
2601
|
+
}
|
|
2602
|
+
|
|
2603
|
+
function replaceSegment(node, seg, child) {
|
|
2604
|
+
if (Array.isArray(node)) {
|
|
2605
|
+
const out = node.slice();
|
|
2606
|
+
out[seg] = child;
|
|
2607
|
+
return out;
|
|
2608
|
+
}
|
|
2609
|
+
const out = copyObject(node);
|
|
2610
|
+
assign(out, seg, child);
|
|
2611
|
+
return out;
|
|
2612
|
+
}
|
|
2613
|
+
|
|
2614
|
+
/** Rename the lane at display position `at`. */
|
|
2615
|
+
function renameLane(doc, at, name) {
|
|
2616
|
+
if (typeof name !== 'string') return doc;
|
|
2617
|
+
return mapLane(doc, at, function (lane) {
|
|
2618
|
+
const next = copyObject(lane);
|
|
2619
|
+
next.name = name;
|
|
2620
|
+
return next;
|
|
2621
|
+
});
|
|
2622
|
+
}
|
|
2623
|
+
|
|
2624
|
+
module.exports = {
|
|
2625
|
+
parseSource, patchSource,
|
|
2626
|
+
expandWave, collapseWave, levelsOf,
|
|
2627
|
+
setCell, setCellRange, insertCycles, deleteCycles, copyCycles, pasteCycles,
|
|
2628
|
+
addLane, removeLane, moveLane, renameLane,
|
|
2629
|
+
lanePath, lanePaths, laneInsertPath, laneRemovePath, laneRemovePaths,
|
|
2630
|
+
MAX_DEPTH,
|
|
2631
|
+
};
|