@cofold/yaml 0.2.0 → 0.3.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 +5 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/stringify.d.ts +3 -0
- package/dist/stringify.d.ts.map +1 -0
- package/dist/stringify.js +138 -0
- package/dist/stringify.js.map +1 -0
- package/dist/yaml.js +4 -2
- package/dist/yaml.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +6 -5
- package/src/load.ts +78 -78
- package/src/stringify.ts +155 -0
- package/src/types/load.ts +11 -11
- package/src/types/parse.ts +8 -8
- package/src/yaml.ts +465 -463
package/src/yaml.ts
CHANGED
|
@@ -1,463 +1,465 @@
|
|
|
1
|
-
import type { YamlParseOptions } from "./types/parse.js";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* @cofold/yaml reads a documented YAML subset as plain data, without I/O or dependencies.
|
|
5
|
-
* Unsupported syntax is rejected rather than interpreted approximately.
|
|
6
|
-
* File loading, document composition, and reference resolution belong to callers.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
export class YamlError extends Error {
|
|
10
|
-
public readonly line: number;
|
|
11
|
-
public readonly source: string | undefined;
|
|
12
|
-
public readonly detail: string;
|
|
13
|
-
|
|
14
|
-
public constructor(message: string, line: number, source?: string) {
|
|
15
|
-
super(`${message} (${source === undefined ? "" : `${source}: `}line ${line})`);
|
|
16
|
-
this.name = "YamlError";
|
|
17
|
-
this.line = line;
|
|
18
|
-
this.source = source;
|
|
19
|
-
this.detail = message;
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/** One significant line: its depth, its content without the comment, and where it came from. */
|
|
24
|
-
interface Line {
|
|
25
|
-
indent: number;
|
|
26
|
-
text: string;
|
|
27
|
-
number: number;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/** A cursor over one line's text, for the flow collections and the quoted scalars. */
|
|
31
|
-
interface Reader {
|
|
32
|
-
text: string;
|
|
33
|
-
at: number;
|
|
34
|
-
line: number;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/** What a plain scalar may not start with, because each one means a feature this does not read. */
|
|
38
|
-
const REFUSED: readonly { start: string; what: string }[] = [
|
|
39
|
-
{ start: "&", what: "anchors" },
|
|
40
|
-
{ start: "*", what: "aliases" },
|
|
41
|
-
{ start: "!", what: "tags" },
|
|
42
|
-
{ start: "|", what: "block scalars" },
|
|
43
|
-
{ start: ">", what: "folded scalars" },
|
|
44
|
-
{ start: "%", what: "directives" },
|
|
45
|
-
{ start: "`", what: "reserved indicators" },
|
|
46
|
-
{ start: "@", what: "reserved indicators" },
|
|
47
|
-
];
|
|
48
|
-
|
|
49
|
-
/** The document as data. Mappings become objects, sequences arrays, scalars themselves. */
|
|
50
|
-
export function parseYaml(text: string, options: YamlParseOptions = {}): unknown {
|
|
51
|
-
try {
|
|
52
|
-
if (Buffer.byteLength(text, "utf8") > (options.maxBytes ?? 16 * 1024 * 1024)) {
|
|
53
|
-
throw new YamlError("YAML exceeds maxBytes", 1);
|
|
54
|
-
}
|
|
55
|
-
return parse(text, options.maxDepth ?? 128);
|
|
56
|
-
} catch (error) {
|
|
57
|
-
if (error instanceof YamlError && options.source !== undefined) {
|
|
58
|
-
throw new YamlError(error.detail, error.line, options.source);
|
|
59
|
-
}
|
|
60
|
-
throw error;
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function parse(text: string, maxDepth: number): unknown {
|
|
65
|
-
const lines = scan(text);
|
|
66
|
-
const levels: number[] = [];
|
|
67
|
-
for (const line of lines) {
|
|
68
|
-
while (levels.length && levels.at(-1)! >= line.indent) levels.pop();
|
|
69
|
-
levels.push(line.indent);
|
|
70
|
-
let flowDepth = 0;
|
|
71
|
-
let quote = "";
|
|
72
|
-
for (let i = 0; i < line.text.length; i++) {
|
|
73
|
-
const char = line.text[i]!;
|
|
74
|
-
if (quote) { if (char === "\\" && quote === '"') i++; else if (char === quote) quote = ""; continue; }
|
|
75
|
-
if (char === '"' || char === "'") quote = char;
|
|
76
|
-
else if (char === "[" || char === "{") { if (++flowDepth + levels.length > maxDepth) throw new YamlError("YAML exceeds maxDepth", line.number); }
|
|
77
|
-
else if (char === "]" || char === "}") flowDepth--;
|
|
78
|
-
}
|
|
79
|
-
if (levels.length > maxDepth) throw new YamlError("YAML exceeds maxDepth", line.number);
|
|
80
|
-
}
|
|
81
|
-
if (lines.length === 0) return null;
|
|
82
|
-
const first = lines[0]!;
|
|
83
|
-
if (lines.length === 1 && keyAt(first.text) === -1 && !isItem(first)) {
|
|
84
|
-
return first.text.startsWith("[") || first.text.startsWith("{") ? parseFlow(first.text, first.number) : scalar(first.text, first.number);
|
|
85
|
-
}
|
|
86
|
-
const [value, at] = block(lines, 0, first.indent);
|
|
87
|
-
if (at < lines.length) {
|
|
88
|
-
throw new YamlError("This line is outside the document above it", lines[at]!.number);
|
|
89
|
-
}
|
|
90
|
-
return value;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* Text as the lines that carry meaning.
|
|
95
|
-
*
|
|
96
|
-
* Comments and blank lines are dropped here rather than in the parser, so
|
|
97
|
-
* nothing below has to ask whether a `#` is a comment or part of a value.
|
|
98
|
-
*/
|
|
99
|
-
function scan(text: string): Line[] {
|
|
100
|
-
const lines: Line[] = [];
|
|
101
|
-
let opened = false;
|
|
102
|
-
|
|
103
|
-
blockScalars(text).forEach((raw, index) => {
|
|
104
|
-
const number = index + 1;
|
|
105
|
-
const stripped = stripComment(raw);
|
|
106
|
-
const trimmed = stripped.trim();
|
|
107
|
-
if (trimmed === "") return;
|
|
108
|
-
|
|
109
|
-
if (trimmed === "---") {
|
|
110
|
-
if (opened || lines.length > 0) throw new YamlError("This parser reads one document per file", number);
|
|
111
|
-
opened = true;
|
|
112
|
-
return;
|
|
113
|
-
}
|
|
114
|
-
if (trimmed === "...") throw new YamlError("This parser reads one document per file", number);
|
|
115
|
-
|
|
116
|
-
const indent = stripped.length - stripped.trimStart().length;
|
|
117
|
-
if (stripped.slice(0, indent).includes("\t")) {
|
|
118
|
-
throw new YamlError("YAML is indented with spaces, and this line uses a tab", number);
|
|
119
|
-
}
|
|
120
|
-
lines.push({ indent, text: trimmed, number });
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
return lines;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/** Turn block scalars into quoted values while retaining physical line numbers. */
|
|
127
|
-
function blockScalars(text: string): string[] {
|
|
128
|
-
const lines = text.replace(/\r\n?/gu, "\n").split("\n");
|
|
129
|
-
if (lines.at(-1) === "") lines.pop();
|
|
130
|
-
for (let i = 0; i < lines.length; i++) {
|
|
131
|
-
const raw = stripComment(lines[i]!);
|
|
132
|
-
const match = /^(.*?(?::\s+|-\s+))([|>])([1-9][+-]?|[+-][1-9]?|)\s*$/u.exec(raw);
|
|
133
|
-
if (!match) continue;
|
|
134
|
-
const parent = raw.length - raw.trimStart().length;
|
|
135
|
-
const modifier = match[3]!;
|
|
136
|
-
const explicit = /[1-9]/u.exec(modifier);
|
|
137
|
-
let end = i + 1;
|
|
138
|
-
while (end < lines.length && (lines[end]!.trim() === "" || lines[end]!.search(/\S/u) > parent)) end++;
|
|
139
|
-
const held = lines.slice(i + 1, end);
|
|
140
|
-
const nonempty = held.find((line) => line.trim() !== "");
|
|
141
|
-
const indent = explicit ? parent + Number(explicit[0]) : nonempty?.search(/\S/u) ?? parent + 1;
|
|
142
|
-
const parts = held.map((line, at) => {
|
|
143
|
-
if (!line.trim()) return "";
|
|
144
|
-
if (line.search(/\S/u) < indent) throw new YamlError("Block scalar indentation is inconsistent", i + at + 2);
|
|
145
|
-
return line.slice(indent);
|
|
146
|
-
});
|
|
147
|
-
let value = "";
|
|
148
|
-
for (let j = 0; j < parts.length; j++) {
|
|
149
|
-
const current = parts[j]!;
|
|
150
|
-
const next = parts[j + 1];
|
|
151
|
-
value += current;
|
|
152
|
-
if (match[2] === "|" || next === undefined || current.startsWith(" ") || next.startsWith(" ")) value += "\n";
|
|
153
|
-
else if (current !== "" && next !== "") value += " ";
|
|
154
|
-
else if (current === "" || (next === "" && parts.slice(j + 1).every((part) => part === ""))) value += "\n";
|
|
155
|
-
}
|
|
156
|
-
if (!modifier.includes("+")) value = value.replace(/\n+$/u, "") + (modifier.includes("-") || !parts.some((part) => part !== "") ? "" : "\n");
|
|
157
|
-
lines[i] = match[1]! + JSON.stringify(value);
|
|
158
|
-
for (let j = i + 1; j < end; j++) lines[j] = "";
|
|
159
|
-
i = end - 1;
|
|
160
|
-
}
|
|
161
|
-
return lines;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/** A `#` that opens a comment: one at the start of the line or after a space, outside quotes. */
|
|
165
|
-
function stripComment(raw: string): string {
|
|
166
|
-
let quote: string | null = null;
|
|
167
|
-
for (let at = 0; at < raw.length; at += 1) {
|
|
168
|
-
const char = raw[at]!;
|
|
169
|
-
if (quote !== null) {
|
|
170
|
-
if (char === "\\" && quote === '"') { at += 1; continue; }
|
|
171
|
-
if (char === quote) quote = null;
|
|
172
|
-
continue;
|
|
173
|
-
}
|
|
174
|
-
if ((char === '"' || char === "'") && (at === 0 || /[\s[{:,]/u.test(raw[at - 1]!))) { quote = char; continue; }
|
|
175
|
-
if (char === "#" && (at === 0 || /\s/u.test(raw[at - 1]!))) return raw.slice(0, at);
|
|
176
|
-
}
|
|
177
|
-
return raw;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
function isItem(line: Line): boolean {
|
|
181
|
-
return line.text === "-" || line.text.startsWith("- ");
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
function block(lines: Line[], at: number, indent: number): [unknown, number] {
|
|
185
|
-
const first = lines[at]!;
|
|
186
|
-
if (!isItem(first) && keyAt(first.text) === -1) {
|
|
187
|
-
return [first.text.startsWith("[") || first.text.startsWith("{") ? parseFlow(first.text, first.number) : scalar(first.text, first.number), at + 1];
|
|
188
|
-
}
|
|
189
|
-
return isItem(lines[at]!) ? sequence(lines, at, indent) : mapping(lines, at, indent);
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
function sequence(lines: Line[], at: number, indent: number): [unknown[], number] {
|
|
193
|
-
const items: unknown[] = [];
|
|
194
|
-
let cursor = at;
|
|
195
|
-
|
|
196
|
-
while (cursor < lines.length && lines[cursor]!.indent === indent && isItem(lines[cursor]!)) {
|
|
197
|
-
const line = lines[cursor]!;
|
|
198
|
-
const body = line.text === "-" ? "" : line.text.slice(2);
|
|
199
|
-
const rest = body.trim();
|
|
200
|
-
|
|
201
|
-
if (rest === "") {
|
|
202
|
-
const next = lines[cursor + 1];
|
|
203
|
-
if (next !== undefined && next.indent > indent) {
|
|
204
|
-
const [value, after] = block(lines, cursor + 1, next.indent);
|
|
205
|
-
items.push(value);
|
|
206
|
-
cursor = after;
|
|
207
|
-
} else {
|
|
208
|
-
items.push(null);
|
|
209
|
-
cursor += 1;
|
|
210
|
-
}
|
|
211
|
-
continue;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
if (rest.startsWith("[") || rest.startsWith("{")) {
|
|
215
|
-
items.push(parseFlow(rest, line.number));
|
|
216
|
-
cursor += 1;
|
|
217
|
-
continue;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
if (keyAt(rest) === -1) {
|
|
221
|
-
items.push(scalar(rest, line.number));
|
|
222
|
-
cursor += 1;
|
|
223
|
-
continue;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
/*
|
|
227
|
-
* `- key: value` opens a mapping whose first key sits on the dash's own
|
|
228
|
-
* line. The line is restated at the column that key actually starts in, so
|
|
229
|
-
* the following keys - indented to that column - read as the same mapping.
|
|
230
|
-
*/
|
|
231
|
-
const offset = indent + 2 + (body.length - body.trimStart().length);
|
|
232
|
-
lines[cursor] = { indent: offset, text: rest, number: line.number };
|
|
233
|
-
const [value, after] = mapping(lines, cursor, offset);
|
|
234
|
-
items.push(value);
|
|
235
|
-
cursor = after;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
return [items, cursor];
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
function mapping(lines: Line[], at: number, indent: number): [Record<string, unknown>, number] {
|
|
242
|
-
const map: Record<string, unknown> = {};
|
|
243
|
-
let cursor = at;
|
|
244
|
-
|
|
245
|
-
while (cursor < lines.length && lines[cursor]!.indent === indent) {
|
|
246
|
-
const line = lines[cursor]!;
|
|
247
|
-
if (isItem(line)) break;
|
|
248
|
-
|
|
249
|
-
const colon = keyAt(line.text);
|
|
250
|
-
if (colon === -1) {
|
|
251
|
-
throw new YamlError(`"${line.text}" is not a mapping entry: a key is written "name: value"`, line.number);
|
|
252
|
-
}
|
|
253
|
-
const
|
|
254
|
-
|
|
255
|
-
if (
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
*
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
continue;
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
if (char === "
|
|
306
|
-
if (char === "
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
if (text
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
if (text === "
|
|
332
|
-
if (
|
|
333
|
-
if (
|
|
334
|
-
if (/^-?\d
|
|
335
|
-
return text;
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
if (char ===
|
|
356
|
-
if (char ===
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
reader.at += 1;
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
reader.at += 1;
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
const
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
if (escape === "
|
|
446
|
-
if (escape === "
|
|
447
|
-
if (escape ===
|
|
448
|
-
if (escape === "
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
1
|
+
import type { YamlParseOptions } from "./types/parse.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @cofold/yaml reads a documented YAML subset as plain data, without I/O or dependencies.
|
|
5
|
+
* Unsupported syntax is rejected rather than interpreted approximately.
|
|
6
|
+
* File loading, document composition, and reference resolution belong to callers.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export class YamlError extends Error {
|
|
10
|
+
public readonly line: number;
|
|
11
|
+
public readonly source: string | undefined;
|
|
12
|
+
public readonly detail: string;
|
|
13
|
+
|
|
14
|
+
public constructor(message: string, line: number, source?: string) {
|
|
15
|
+
super(`${message} (${source === undefined ? "" : `${source}: `}line ${line})`);
|
|
16
|
+
this.name = "YamlError";
|
|
17
|
+
this.line = line;
|
|
18
|
+
this.source = source;
|
|
19
|
+
this.detail = message;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** One significant line: its depth, its content without the comment, and where it came from. */
|
|
24
|
+
interface Line {
|
|
25
|
+
indent: number;
|
|
26
|
+
text: string;
|
|
27
|
+
number: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A cursor over one line's text, for the flow collections and the quoted scalars. */
|
|
31
|
+
interface Reader {
|
|
32
|
+
text: string;
|
|
33
|
+
at: number;
|
|
34
|
+
line: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** What a plain scalar may not start with, because each one means a feature this does not read. */
|
|
38
|
+
const REFUSED: readonly { start: string; what: string }[] = [
|
|
39
|
+
{ start: "&", what: "anchors" },
|
|
40
|
+
{ start: "*", what: "aliases" },
|
|
41
|
+
{ start: "!", what: "tags" },
|
|
42
|
+
{ start: "|", what: "block scalars" },
|
|
43
|
+
{ start: ">", what: "folded scalars" },
|
|
44
|
+
{ start: "%", what: "directives" },
|
|
45
|
+
{ start: "`", what: "reserved indicators" },
|
|
46
|
+
{ start: "@", what: "reserved indicators" },
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
/** The document as data. Mappings become objects, sequences arrays, scalars themselves. */
|
|
50
|
+
export function parseYaml(text: string, options: YamlParseOptions = {}): unknown {
|
|
51
|
+
try {
|
|
52
|
+
if (Buffer.byteLength(text, "utf8") > (options.maxBytes ?? 16 * 1024 * 1024)) {
|
|
53
|
+
throw new YamlError("YAML exceeds maxBytes", 1);
|
|
54
|
+
}
|
|
55
|
+
return parse(text, options.maxDepth ?? 128);
|
|
56
|
+
} catch (error) {
|
|
57
|
+
if (error instanceof YamlError && options.source !== undefined) {
|
|
58
|
+
throw new YamlError(error.detail, error.line, options.source);
|
|
59
|
+
}
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function parse(text: string, maxDepth: number): unknown {
|
|
65
|
+
const lines = scan(text);
|
|
66
|
+
const levels: number[] = [];
|
|
67
|
+
for (const line of lines) {
|
|
68
|
+
while (levels.length && levels.at(-1)! >= line.indent) levels.pop();
|
|
69
|
+
levels.push(line.indent);
|
|
70
|
+
let flowDepth = 0;
|
|
71
|
+
let quote = "";
|
|
72
|
+
for (let i = 0; i < line.text.length; i++) {
|
|
73
|
+
const char = line.text[i]!;
|
|
74
|
+
if (quote) { if (char === "\\" && quote === '"') i++; else if (char === quote) quote = ""; continue; }
|
|
75
|
+
if (char === '"' || char === "'") quote = char;
|
|
76
|
+
else if (char === "[" || char === "{") { if (++flowDepth + levels.length > maxDepth) throw new YamlError("YAML exceeds maxDepth", line.number); }
|
|
77
|
+
else if (char === "]" || char === "}") flowDepth--;
|
|
78
|
+
}
|
|
79
|
+
if (levels.length > maxDepth) throw new YamlError("YAML exceeds maxDepth", line.number);
|
|
80
|
+
}
|
|
81
|
+
if (lines.length === 0) return null;
|
|
82
|
+
const first = lines[0]!;
|
|
83
|
+
if (lines.length === 1 && keyAt(first.text) === -1 && !isItem(first)) {
|
|
84
|
+
return first.text.startsWith("[") || first.text.startsWith("{") ? parseFlow(first.text, first.number) : scalar(first.text, first.number);
|
|
85
|
+
}
|
|
86
|
+
const [value, at] = block(lines, 0, first.indent);
|
|
87
|
+
if (at < lines.length) {
|
|
88
|
+
throw new YamlError("This line is outside the document above it", lines[at]!.number);
|
|
89
|
+
}
|
|
90
|
+
return value;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Text as the lines that carry meaning.
|
|
95
|
+
*
|
|
96
|
+
* Comments and blank lines are dropped here rather than in the parser, so
|
|
97
|
+
* nothing below has to ask whether a `#` is a comment or part of a value.
|
|
98
|
+
*/
|
|
99
|
+
function scan(text: string): Line[] {
|
|
100
|
+
const lines: Line[] = [];
|
|
101
|
+
let opened = false;
|
|
102
|
+
|
|
103
|
+
blockScalars(text).forEach((raw, index) => {
|
|
104
|
+
const number = index + 1;
|
|
105
|
+
const stripped = stripComment(raw);
|
|
106
|
+
const trimmed = stripped.trim();
|
|
107
|
+
if (trimmed === "") return;
|
|
108
|
+
|
|
109
|
+
if (trimmed === "---") {
|
|
110
|
+
if (opened || lines.length > 0) throw new YamlError("This parser reads one document per file", number);
|
|
111
|
+
opened = true;
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (trimmed === "...") throw new YamlError("This parser reads one document per file", number);
|
|
115
|
+
|
|
116
|
+
const indent = stripped.length - stripped.trimStart().length;
|
|
117
|
+
if (stripped.slice(0, indent).includes("\t")) {
|
|
118
|
+
throw new YamlError("YAML is indented with spaces, and this line uses a tab", number);
|
|
119
|
+
}
|
|
120
|
+
lines.push({ indent, text: trimmed, number });
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
return lines;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Turn block scalars into quoted values while retaining physical line numbers. */
|
|
127
|
+
function blockScalars(text: string): string[] {
|
|
128
|
+
const lines = text.replace(/\r\n?/gu, "\n").split("\n");
|
|
129
|
+
if (lines.at(-1) === "") lines.pop();
|
|
130
|
+
for (let i = 0; i < lines.length; i++) {
|
|
131
|
+
const raw = stripComment(lines[i]!);
|
|
132
|
+
const match = /^(.*?(?::\s+|-\s+))([|>])([1-9][+-]?|[+-][1-9]?|)\s*$/u.exec(raw);
|
|
133
|
+
if (!match) continue;
|
|
134
|
+
const parent = raw.length - raw.trimStart().length;
|
|
135
|
+
const modifier = match[3]!;
|
|
136
|
+
const explicit = /[1-9]/u.exec(modifier);
|
|
137
|
+
let end = i + 1;
|
|
138
|
+
while (end < lines.length && (lines[end]!.trim() === "" || lines[end]!.search(/\S/u) > parent)) end++;
|
|
139
|
+
const held = lines.slice(i + 1, end);
|
|
140
|
+
const nonempty = held.find((line) => line.trim() !== "");
|
|
141
|
+
const indent = explicit ? parent + Number(explicit[0]) : nonempty?.search(/\S/u) ?? parent + 1;
|
|
142
|
+
const parts = held.map((line, at) => {
|
|
143
|
+
if (!line.trim()) return "";
|
|
144
|
+
if (line.search(/\S/u) < indent) throw new YamlError("Block scalar indentation is inconsistent", i + at + 2);
|
|
145
|
+
return line.slice(indent);
|
|
146
|
+
});
|
|
147
|
+
let value = "";
|
|
148
|
+
for (let j = 0; j < parts.length; j++) {
|
|
149
|
+
const current = parts[j]!;
|
|
150
|
+
const next = parts[j + 1];
|
|
151
|
+
value += current;
|
|
152
|
+
if (match[2] === "|" || next === undefined || current.startsWith(" ") || next.startsWith(" ")) value += "\n";
|
|
153
|
+
else if (current !== "" && next !== "") value += " ";
|
|
154
|
+
else if (current === "" || (next === "" && parts.slice(j + 1).every((part) => part === ""))) value += "\n";
|
|
155
|
+
}
|
|
156
|
+
if (!modifier.includes("+")) value = value.replace(/\n+$/u, "") + (modifier.includes("-") || !parts.some((part) => part !== "") ? "" : "\n");
|
|
157
|
+
lines[i] = match[1]! + JSON.stringify(value);
|
|
158
|
+
for (let j = i + 1; j < end; j++) lines[j] = "";
|
|
159
|
+
i = end - 1;
|
|
160
|
+
}
|
|
161
|
+
return lines;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** A `#` that opens a comment: one at the start of the line or after a space, outside quotes. */
|
|
165
|
+
function stripComment(raw: string): string {
|
|
166
|
+
let quote: string | null = null;
|
|
167
|
+
for (let at = 0; at < raw.length; at += 1) {
|
|
168
|
+
const char = raw[at]!;
|
|
169
|
+
if (quote !== null) {
|
|
170
|
+
if (char === "\\" && quote === '"') { at += 1; continue; }
|
|
171
|
+
if (char === quote) quote = null;
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if ((char === '"' || char === "'") && (at === 0 || /[\s[{:,]/u.test(raw[at - 1]!))) { quote = char; continue; }
|
|
175
|
+
if (char === "#" && (at === 0 || /\s/u.test(raw[at - 1]!))) return raw.slice(0, at);
|
|
176
|
+
}
|
|
177
|
+
return raw;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function isItem(line: Line): boolean {
|
|
181
|
+
return line.text === "-" || line.text.startsWith("- ");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function block(lines: Line[], at: number, indent: number): [unknown, number] {
|
|
185
|
+
const first = lines[at]!;
|
|
186
|
+
if (!isItem(first) && keyAt(first.text) === -1) {
|
|
187
|
+
return [first.text.startsWith("[") || first.text.startsWith("{") ? parseFlow(first.text, first.number) : scalar(first.text, first.number), at + 1];
|
|
188
|
+
}
|
|
189
|
+
return isItem(lines[at]!) ? sequence(lines, at, indent) : mapping(lines, at, indent);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function sequence(lines: Line[], at: number, indent: number): [unknown[], number] {
|
|
193
|
+
const items: unknown[] = [];
|
|
194
|
+
let cursor = at;
|
|
195
|
+
|
|
196
|
+
while (cursor < lines.length && lines[cursor]!.indent === indent && isItem(lines[cursor]!)) {
|
|
197
|
+
const line = lines[cursor]!;
|
|
198
|
+
const body = line.text === "-" ? "" : line.text.slice(2);
|
|
199
|
+
const rest = body.trim();
|
|
200
|
+
|
|
201
|
+
if (rest === "") {
|
|
202
|
+
const next = lines[cursor + 1];
|
|
203
|
+
if (next !== undefined && next.indent > indent) {
|
|
204
|
+
const [value, after] = block(lines, cursor + 1, next.indent);
|
|
205
|
+
items.push(value);
|
|
206
|
+
cursor = after;
|
|
207
|
+
} else {
|
|
208
|
+
items.push(null);
|
|
209
|
+
cursor += 1;
|
|
210
|
+
}
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (rest.startsWith("[") || rest.startsWith("{")) {
|
|
215
|
+
items.push(parseFlow(rest, line.number));
|
|
216
|
+
cursor += 1;
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (keyAt(rest) === -1) {
|
|
221
|
+
items.push(scalar(rest, line.number));
|
|
222
|
+
cursor += 1;
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/*
|
|
227
|
+
* `- key: value` opens a mapping whose first key sits on the dash's own
|
|
228
|
+
* line. The line is restated at the column that key actually starts in, so
|
|
229
|
+
* the following keys - indented to that column - read as the same mapping.
|
|
230
|
+
*/
|
|
231
|
+
const offset = indent + 2 + (body.length - body.trimStart().length);
|
|
232
|
+
lines[cursor] = { indent: offset, text: rest, number: line.number };
|
|
233
|
+
const [value, after] = mapping(lines, cursor, offset);
|
|
234
|
+
items.push(value);
|
|
235
|
+
cursor = after;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return [items, cursor];
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function mapping(lines: Line[], at: number, indent: number): [Record<string, unknown>, number] {
|
|
242
|
+
const map: Record<string, unknown> = {};
|
|
243
|
+
let cursor = at;
|
|
244
|
+
|
|
245
|
+
while (cursor < lines.length && lines[cursor]!.indent === indent) {
|
|
246
|
+
const line = lines[cursor]!;
|
|
247
|
+
if (isItem(line)) break;
|
|
248
|
+
|
|
249
|
+
const colon = keyAt(line.text);
|
|
250
|
+
if (colon === -1) {
|
|
251
|
+
throw new YamlError(`"${line.text}" is not a mapping entry: a key is written "name: value"`, line.number);
|
|
252
|
+
}
|
|
253
|
+
const written = line.text.slice(0, colon).trim();
|
|
254
|
+
// A quoted "<<" is an ordinary key, as YAML 1.2 reads it; only the plain one is a merge key.
|
|
255
|
+
if (written === "<<") throw new YamlError("This parser does not read merge keys", line.number);
|
|
256
|
+
const key = String(scalar(written, line.number));
|
|
257
|
+
if (Object.hasOwn(map, key)) throw new YamlError(`${key} is given twice`, line.number);
|
|
258
|
+
|
|
259
|
+
const rest = line.text.slice(colon + 1).trim();
|
|
260
|
+
if (rest !== "") {
|
|
261
|
+
Object.defineProperty(map, key, { value: rest.startsWith("[") || rest.startsWith("{")
|
|
262
|
+
? parseFlow(rest, line.number) : scalar(rest, line.number), enumerable: true, writable: true, configurable: true });
|
|
263
|
+
cursor += 1;
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/*
|
|
268
|
+
* A key with nothing after it opens a block, which is either indented under
|
|
269
|
+
* it or - for a sequence, which YAML lets sit at the key's own column - at
|
|
270
|
+
* the same depth.
|
|
271
|
+
*/
|
|
272
|
+
const next = lines[cursor + 1];
|
|
273
|
+
if (next !== undefined && (next.indent > indent || (next.indent === indent && isItem(next)))) {
|
|
274
|
+
const [value, after] = block(lines, cursor + 1, next.indent);
|
|
275
|
+
Object.defineProperty(map, key, { value, enumerable: true, writable: true, configurable: true });
|
|
276
|
+
cursor = after;
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
Object.defineProperty(map, key, { value: null, enumerable: true, writable: true, configurable: true });
|
|
280
|
+
cursor += 1;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (cursor < lines.length && lines[cursor]!.indent > indent) {
|
|
284
|
+
throw new YamlError("This line is indented further than anything above it opened", lines[cursor]!.number);
|
|
285
|
+
}
|
|
286
|
+
return [map, cursor];
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Where a key ends: a colon followed by a space or the end of the line.
|
|
291
|
+
*
|
|
292
|
+
* The space is what keeps `endpoint: http://host/path` one key and one value
|
|
293
|
+
* rather than two keys, and it is the same rule YAML itself uses.
|
|
294
|
+
*/
|
|
295
|
+
function keyAt(text: string): number {
|
|
296
|
+
let quote: string | null = null;
|
|
297
|
+
let depth = 0;
|
|
298
|
+
for (let at = 0; at < text.length; at += 1) {
|
|
299
|
+
const char = text[at]!;
|
|
300
|
+
if (quote !== null) {
|
|
301
|
+
if (char === "\\" && quote === '"') { at += 1; continue; }
|
|
302
|
+
if (char === quote) quote = null;
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
if ((char === '"' || char === "'") && (at === 0 || /[\s[{:,]/u.test(text[at - 1]!))) { quote = char; continue; }
|
|
306
|
+
if (char === "[" || char === "{") { depth += 1; continue; }
|
|
307
|
+
if (char === "]" || char === "}") { depth -= 1; continue; }
|
|
308
|
+
if (char === ":" && depth === 0 && (text[at + 1] === undefined || text[at + 1] === " ")) return at;
|
|
309
|
+
}
|
|
310
|
+
return -1;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** One scalar: quoted text as written, or a plain word read as null, a boolean, a number or text. */
|
|
314
|
+
function scalar(text: string, line: number): unknown {
|
|
315
|
+
if (text === "") return null;
|
|
316
|
+
|
|
317
|
+
if (text.startsWith('"') || text.startsWith("'")) {
|
|
318
|
+
const reader: Reader = { text, at: 0, line };
|
|
319
|
+
const value = quotedFrom(reader);
|
|
320
|
+
skipSpace(reader);
|
|
321
|
+
if (reader.at < text.length) throw new YamlError("There is text after the closing quote", line);
|
|
322
|
+
return value;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
for (const { start, what } of REFUSED) {
|
|
326
|
+
if (text.startsWith(start)) {
|
|
327
|
+
throw new YamlError(`This parser does not read ${what}; quote the value if it is text`, line);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (text === "~" || text === "null") return null;
|
|
332
|
+
if (text === "true") return true;
|
|
333
|
+
if (text === "false") return false;
|
|
334
|
+
if (/^-?\d+$/u.test(text)) return Number(text);
|
|
335
|
+
if (/^-?(?:\d+\.\d*|\.\d+)(?:[eE][-+]?\d+)?$/u.test(text)) return Number(text);
|
|
336
|
+
if (/^-?\d+[eE][-+]?\d+$/u.test(text)) return Number(text);
|
|
337
|
+
return text;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function parseFlow(text: string, line: number): unknown {
|
|
341
|
+
const reader: Reader = { text, at: 0, line };
|
|
342
|
+
const value = flowValue(reader);
|
|
343
|
+
skipSpace(reader);
|
|
344
|
+
if (reader.at < text.length) throw new YamlError("There is text after the flow collection", line);
|
|
345
|
+
return value;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function skipSpace(reader: Reader): void {
|
|
349
|
+
while (reader.at < reader.text.length && /\s/u.test(reader.text[reader.at]!)) reader.at += 1;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function flowValue(reader: Reader): unknown {
|
|
353
|
+
skipSpace(reader);
|
|
354
|
+
const char = reader.text[reader.at];
|
|
355
|
+
if (char === undefined) throw new YamlError("A flow collection ends with a value missing", reader.line);
|
|
356
|
+
if (char === "[") return flowSequence(reader);
|
|
357
|
+
if (char === "{") return flowMapping(reader);
|
|
358
|
+
if (char === '"' || char === "'") return quotedFrom(reader);
|
|
359
|
+
|
|
360
|
+
const start = reader.at;
|
|
361
|
+
while (reader.at < reader.text.length && !",]}".includes(reader.text[reader.at]!)) reader.at += 1;
|
|
362
|
+
return scalar(reader.text.slice(start, reader.at).trim(), reader.line);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function flowSequence(reader: Reader): unknown[] {
|
|
366
|
+
reader.at += 1;
|
|
367
|
+
const items: unknown[] = [];
|
|
368
|
+
skipSpace(reader);
|
|
369
|
+
if (reader.text[reader.at] === "]") { reader.at += 1; return items; }
|
|
370
|
+
|
|
371
|
+
for (;;) {
|
|
372
|
+
items.push(flowValue(reader));
|
|
373
|
+
skipSpace(reader);
|
|
374
|
+
const char = reader.text[reader.at];
|
|
375
|
+
if (char === "]") { reader.at += 1; return items; }
|
|
376
|
+
if (char !== ",") throw new YamlError("A flow sequence separates values with , and ends with ]", reader.line);
|
|
377
|
+
reader.at += 1;
|
|
378
|
+
skipSpace(reader);
|
|
379
|
+
if (reader.text[reader.at] === "]") { reader.at += 1; return items; }
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function flowMapping(reader: Reader): Record<string, unknown> {
|
|
384
|
+
reader.at += 1;
|
|
385
|
+
const map: Record<string, unknown> = {};
|
|
386
|
+
skipSpace(reader);
|
|
387
|
+
if (reader.text[reader.at] === "}") { reader.at += 1; return map; }
|
|
388
|
+
|
|
389
|
+
for (;;) {
|
|
390
|
+
skipSpace(reader);
|
|
391
|
+
const opener = reader.text[reader.at];
|
|
392
|
+
const key = opener === '"' || opener === "'" ? quotedFrom(reader) : plainKey(reader);
|
|
393
|
+
skipSpace(reader);
|
|
394
|
+
let value: unknown = null;
|
|
395
|
+
if (reader.text[reader.at] === ":") {
|
|
396
|
+
reader.at += 1;
|
|
397
|
+
skipSpace(reader);
|
|
398
|
+
if (reader.text[reader.at] !== "," && reader.text[reader.at] !== "}") value = flowValue(reader);
|
|
399
|
+
} else if (reader.text[reader.at] !== "," && reader.text[reader.at] !== "}") {
|
|
400
|
+
throw new YamlError("A flow mapping entry is written name: value", reader.line);
|
|
401
|
+
}
|
|
402
|
+
if (Object.hasOwn(map, key)) throw new YamlError(`${key} is given twice`, reader.line);
|
|
403
|
+
Object.defineProperty(map, key, { value, enumerable: true, writable: true, configurable: true });
|
|
404
|
+
|
|
405
|
+
skipSpace(reader);
|
|
406
|
+
const char = reader.text[reader.at];
|
|
407
|
+
if (char === "}") { reader.at += 1; return map; }
|
|
408
|
+
if (char !== ",") throw new YamlError("A flow mapping separates entries with , and ends with }", reader.line);
|
|
409
|
+
reader.at += 1;
|
|
410
|
+
skipSpace(reader);
|
|
411
|
+
if (reader.text[reader.at] === "}") { reader.at += 1; return map; }
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function plainKey(reader: Reader): string {
|
|
416
|
+
const start = reader.at;
|
|
417
|
+
while (reader.at < reader.text.length && !":,}".includes(reader.text[reader.at]!)) reader.at += 1;
|
|
418
|
+
const key = reader.text.slice(start, reader.at).trim();
|
|
419
|
+
if (key === "") throw new YamlError("A flow mapping entry needs a name", reader.line);
|
|
420
|
+
return key;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** A quoted scalar from the cursor. Single quotes are literal but for `''`; double quotes escape. */
|
|
424
|
+
function quotedFrom(reader: Reader): string {
|
|
425
|
+
const quote = reader.text[reader.at]!;
|
|
426
|
+
reader.at += 1;
|
|
427
|
+
let out = "";
|
|
428
|
+
|
|
429
|
+
while (reader.at < reader.text.length) {
|
|
430
|
+
const char = reader.text[reader.at]!;
|
|
431
|
+
|
|
432
|
+
if (char === quote) {
|
|
433
|
+
if (quote === "'" && reader.text[reader.at + 1] === "'") {
|
|
434
|
+
out += "'";
|
|
435
|
+
reader.at += 2;
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
reader.at += 1;
|
|
439
|
+
return out;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
if (char === "\\" && quote === '"') {
|
|
443
|
+
const escape = reader.text[reader.at + 1];
|
|
444
|
+
reader.at += 2;
|
|
445
|
+
if (escape === "n") { out += "\n"; continue; }
|
|
446
|
+
if (escape === "t") { out += "\t"; continue; }
|
|
447
|
+
if (escape === "r") { out += "\r"; continue; }
|
|
448
|
+
if (escape === "0") { out += "\0"; continue; }
|
|
449
|
+
if (escape === '"' || escape === "\\" || escape === "/" || escape === "'") { out += escape; continue; }
|
|
450
|
+
if (escape === "u") {
|
|
451
|
+
const digits = reader.text.slice(reader.at, reader.at + 4);
|
|
452
|
+
if (!/^[0-9a-fA-F]{4}$/u.test(digits)) throw new YamlError("\\u needs four hex digits", reader.line);
|
|
453
|
+
out += String.fromCharCode(Number.parseInt(digits, 16));
|
|
454
|
+
reader.at += 4;
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
throw new YamlError(`\\${escape ?? ""} is not an escape this parser reads`, reader.line);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
out += char;
|
|
461
|
+
reader.at += 1;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
throw new YamlError("A quoted value is not closed", reader.line);
|
|
465
|
+
}
|