@cplieger/deadset-ts 3.9.1 → 3.11.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 +13 -13
- package/dist/src/analysis.js +5 -3
- package/dist/src/config.js +6 -0
- package/dist/src/conformance-record.js +26 -0
- package/dist/src/conformance.js +8 -20
- package/dist/src/findings-pass.js +3 -5
- package/dist/src/report.js +14 -1
- package/dist/src/reporters/annotations.js +36 -0
- package/dist/src/reporters/fingerprint.js +61 -0
- package/dist/src/reporters/reporter.js +7 -1
- package/dist/src/reporters/reporters.js +18 -0
- package/dist/src/reporters/rules.js +178 -0
- package/dist/src/reporters/sarif.js +235 -0
- package/dist/src/reporters/sha256.js +101 -0
- package/dist/src/reporters/template.js +877 -0
- package/dist/src/run.js +4 -10
- package/dist/src/verbs/analyze.js +45 -7
- package/dist/src/verbs/describe.js +8 -12
- package/dist/src/verbs/explain.js +303 -0
- package/jsr.json +1 -1
- package/package.json +1 -1
- package/src/analysis.ts +14 -4
- package/src/config.ts +8 -0
- package/src/conformance-record.ts +29 -0
- package/src/conformance.ts +21 -23
- package/src/findings-pass.ts +5 -7
- package/src/report.ts +25 -3
- package/src/reporters/annotations.ts +67 -0
- package/src/reporters/fingerprint.ts +69 -0
- package/src/reporters/json.ts +2 -2
- package/src/reporters/reporter.ts +21 -2
- package/src/reporters/reporters.ts +18 -1
- package/src/reporters/rules.ts +195 -0
- package/src/reporters/sarif.ts +287 -0
- package/src/reporters/sha256.ts +120 -0
- package/src/reporters/template.ts +989 -0
- package/src/reporters/text.ts +2 -3
- package/src/run.ts +5 -11
- package/src/verbs/analyze.ts +57 -7
- package/src/verbs/describe.ts +9 -13
- package/src/verbs/explain.ts +363 -0
|
@@ -0,0 +1,989 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A user's template in a subset of Go's `text/template` action grammar, over the report
|
|
3
|
+
* document by its JSON member names. Text, comments, trim markers, pipelines, variables,
|
|
4
|
+
* `if`, `else if`, `with`, and `range` with `break` and `continue` are implemented; so are
|
|
5
|
+
* `and`, `or`, `not`, `len`, `index`, `eq`, `ne`, `lt`, `le`, `gt`, `ge`, `print`, `printf`
|
|
6
|
+
* and `println`. Any other function, `define`, `template` and `block` are refused at parse,
|
|
7
|
+
* and a member the document does not carry fails the rendering.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { RenderError } from "./reporter.ts";
|
|
11
|
+
import { utf8Bytes } from "./sha256.ts";
|
|
12
|
+
|
|
13
|
+
/** A template that does not parse, which refuses the invocation before any analysis. */
|
|
14
|
+
export class TemplateError extends Error {
|
|
15
|
+
constructor(message: string) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = "TemplateError";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** One value of the document, as a JSON decode produces it. */
|
|
22
|
+
type Value = null | boolean | number | string | readonly Value[] | ValueObject;
|
|
23
|
+
|
|
24
|
+
/** A JSON object. */
|
|
25
|
+
interface ValueObject {
|
|
26
|
+
readonly [key: string]: Value;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type Arg =
|
|
30
|
+
| { readonly k: "dot" }
|
|
31
|
+
| { readonly k: "var"; readonly name: string }
|
|
32
|
+
| { readonly k: "lit"; readonly value: Value }
|
|
33
|
+
| { readonly k: "func"; readonly name: string }
|
|
34
|
+
| { readonly k: "pipe"; readonly pipe: Pipeline }
|
|
35
|
+
| { readonly k: "field"; readonly recv: Arg | undefined; readonly names: readonly string[] };
|
|
36
|
+
|
|
37
|
+
interface Pipeline {
|
|
38
|
+
/** The variables the pipeline declares or assigns, and which of the two it does. */
|
|
39
|
+
readonly vars: readonly string[];
|
|
40
|
+
readonly assign: boolean;
|
|
41
|
+
readonly commands: readonly (readonly Arg[])[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** An `if`, `with` or `range`: its pipeline, its body, and its `else` body. */
|
|
45
|
+
interface Control {
|
|
46
|
+
readonly k: "if" | "with" | "range";
|
|
47
|
+
readonly pipe: Pipeline;
|
|
48
|
+
readonly list: readonly Node[];
|
|
49
|
+
readonly otherwise: readonly Node[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type Node =
|
|
53
|
+
| { readonly k: "text"; readonly text: string }
|
|
54
|
+
| { readonly k: "action"; readonly pipe: Pipeline }
|
|
55
|
+
| { readonly k: "break" }
|
|
56
|
+
| { readonly k: "continue" }
|
|
57
|
+
| Control;
|
|
58
|
+
|
|
59
|
+
/** A parsed template, ready to render a report. */
|
|
60
|
+
export interface Template {
|
|
61
|
+
readonly nodes: readonly Node[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const LEFT = "{{";
|
|
65
|
+
const RIGHT = "}}";
|
|
66
|
+
const TRIM_SPACE = new Set([" ", "\t", "\r", "\n"]);
|
|
67
|
+
const FUNCTIONS = new Set([
|
|
68
|
+
"and",
|
|
69
|
+
"or",
|
|
70
|
+
"not",
|
|
71
|
+
"len",
|
|
72
|
+
"index",
|
|
73
|
+
"eq",
|
|
74
|
+
"ne",
|
|
75
|
+
"lt",
|
|
76
|
+
"le",
|
|
77
|
+
"gt",
|
|
78
|
+
"ge",
|
|
79
|
+
"print",
|
|
80
|
+
"printf",
|
|
81
|
+
"println",
|
|
82
|
+
]);
|
|
83
|
+
const REFUSED_KEYWORDS = new Set(["define", "template", "block"]);
|
|
84
|
+
|
|
85
|
+
type Token =
|
|
86
|
+
| { readonly t: "word"; readonly text: string }
|
|
87
|
+
/** A field, attached where nothing separates it from the operand before it. */
|
|
88
|
+
| { readonly t: "field"; readonly name: string; readonly attached: boolean }
|
|
89
|
+
| { readonly t: "var"; readonly name: string }
|
|
90
|
+
| { readonly t: "lit"; readonly value: Value }
|
|
91
|
+
| { readonly t: "punct"; readonly text: "(" | ")" | "|" | ":=" | "=" | "," };
|
|
92
|
+
|
|
93
|
+
/** One action's source, its tokens, and where it starts, for a message that names it. */
|
|
94
|
+
interface Action {
|
|
95
|
+
readonly tokens: readonly Token[];
|
|
96
|
+
readonly line: number;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
type Piece =
|
|
100
|
+
{ readonly k: "text"; readonly text: string } | { readonly k: "action"; readonly action: Action };
|
|
101
|
+
|
|
102
|
+
const ESCAPES: Readonly<Record<string, string>> = {
|
|
103
|
+
a: "\u0007",
|
|
104
|
+
b: "\b",
|
|
105
|
+
f: "\f",
|
|
106
|
+
n: "\n",
|
|
107
|
+
r: "\r",
|
|
108
|
+
t: "\t",
|
|
109
|
+
v: "\v",
|
|
110
|
+
"\\": "\\",
|
|
111
|
+
'"': '"',
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/** The number of hexadecimal digits each numeric escape takes. */
|
|
115
|
+
const ESCAPE_WIDTHS: Readonly<Record<string, number>> = { x: 2, u: 4, U: 8 };
|
|
116
|
+
|
|
117
|
+
const IDENTIFIER = /[A-Za-z_][A-Za-z0-9_]*/uy;
|
|
118
|
+
const NUMBER = /[+-]?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/uy;
|
|
119
|
+
|
|
120
|
+
function lineOf(source: string, at: number): number {
|
|
121
|
+
let line = 1;
|
|
122
|
+
for (let i = 0; i < at; i += 1) {
|
|
123
|
+
if (source[i] === "\n") {
|
|
124
|
+
line += 1;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return line;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The interpreted string literal starting at `at`, and the index after its closing quote. */
|
|
131
|
+
function quoted(source: string, at: number, line: number): { value: string; end: number } {
|
|
132
|
+
let value = "";
|
|
133
|
+
for (let i = at + 1; i < source.length; i += 1) {
|
|
134
|
+
const char = source[i] ?? "";
|
|
135
|
+
if (char === '"') {
|
|
136
|
+
return { value, end: i + 1 };
|
|
137
|
+
}
|
|
138
|
+
if (char === "\n") {
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
if (char !== "\\") {
|
|
142
|
+
value += char;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
const next = source[i + 1] ?? "";
|
|
146
|
+
const simple = ESCAPES[next];
|
|
147
|
+
if (simple !== undefined) {
|
|
148
|
+
value += simple;
|
|
149
|
+
i += 1;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const width = Object.hasOwn(ESCAPE_WIDTHS, next) ? ESCAPE_WIDTHS[next] : undefined;
|
|
153
|
+
const digits = width === undefined ? "" : source.slice(i + 2, i + 2 + width);
|
|
154
|
+
if (width === undefined || !/^[0-9A-Fa-f]+$/u.test(digits) || digits.length !== width) {
|
|
155
|
+
throw new TemplateError(`line ${String(line)}: the escape \\${next} is not supported`);
|
|
156
|
+
}
|
|
157
|
+
value += String.fromCodePoint(Number.parseInt(digits, 16));
|
|
158
|
+
i += 1 + width;
|
|
159
|
+
}
|
|
160
|
+
throw new TemplateError(`line ${String(line)}: unterminated quoted string`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The tokens of one action, from `at` to its closing delimiter, and the index after it. */
|
|
164
|
+
function lexAction(
|
|
165
|
+
source: string,
|
|
166
|
+
at: number,
|
|
167
|
+
line: number,
|
|
168
|
+
): { tokens: Token[]; end: number; trimRight: boolean } {
|
|
169
|
+
const tokens: Token[] = [];
|
|
170
|
+
let i = at;
|
|
171
|
+
for (;;) {
|
|
172
|
+
const char = source[i];
|
|
173
|
+
if (char === undefined) {
|
|
174
|
+
throw new TemplateError(`line ${String(line)}: unclosed action`);
|
|
175
|
+
}
|
|
176
|
+
if (TRIM_SPACE.has(char) && source.startsWith(`-${RIGHT}`, i + 1)) {
|
|
177
|
+
return { tokens, end: i + 2 + RIGHT.length, trimRight: true };
|
|
178
|
+
}
|
|
179
|
+
if (source.startsWith(RIGHT, i)) {
|
|
180
|
+
return { tokens, end: i + RIGHT.length, trimRight: false };
|
|
181
|
+
}
|
|
182
|
+
if (TRIM_SPACE.has(char)) {
|
|
183
|
+
i += 1;
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (char === '"') {
|
|
187
|
+
const { value, end } = quoted(source, i, line);
|
|
188
|
+
tokens.push({ t: "lit", value });
|
|
189
|
+
i = end;
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (char === "`") {
|
|
193
|
+
const end = source.indexOf("`", i + 1);
|
|
194
|
+
if (end < 0) {
|
|
195
|
+
throw new TemplateError(`line ${String(line)}: unterminated raw quoted string`);
|
|
196
|
+
}
|
|
197
|
+
tokens.push({ t: "lit", value: source.slice(i + 1, end) });
|
|
198
|
+
i = end + 1;
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (source.startsWith(":=", i)) {
|
|
202
|
+
tokens.push({ t: "punct", text: ":=" });
|
|
203
|
+
i += 2;
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (char === "(" || char === ")" || char === "|" || char === "=" || char === ",") {
|
|
207
|
+
tokens.push({ t: "punct", text: char });
|
|
208
|
+
i += 1;
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (char === "." || char === "$") {
|
|
212
|
+
IDENTIFIER.lastIndex = i + 1;
|
|
213
|
+
const name = IDENTIFIER.exec(source)?.[0] ?? "";
|
|
214
|
+
if (char === "$") {
|
|
215
|
+
tokens.push({ t: "var", name: `$${name}` });
|
|
216
|
+
} else if (name === "" && /[0-9]/u.test(source[i + 1] ?? "")) {
|
|
217
|
+
throw new TemplateError(`line ${String(line)}: a number is written with a leading digit`);
|
|
218
|
+
} else {
|
|
219
|
+
tokens.push({ t: "field", name, attached: i > at && !TRIM_SPACE.has(source[i - 1] ?? "") });
|
|
220
|
+
}
|
|
221
|
+
i += 1 + name.length;
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
NUMBER.lastIndex = i;
|
|
225
|
+
const number = NUMBER.exec(source)?.[0];
|
|
226
|
+
if (number !== undefined) {
|
|
227
|
+
tokens.push({ t: "lit", value: Number(number) });
|
|
228
|
+
i += number.length;
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
IDENTIFIER.lastIndex = i;
|
|
232
|
+
const word = IDENTIFIER.exec(source)?.[0];
|
|
233
|
+
if (word !== undefined) {
|
|
234
|
+
const literal: Readonly<Record<string, Value>> = { true: true, false: false, nil: null };
|
|
235
|
+
tokens.push(
|
|
236
|
+
Object.hasOwn(literal, word)
|
|
237
|
+
? { t: "lit", value: literal[word] ?? null }
|
|
238
|
+
: { t: "word", text: word },
|
|
239
|
+
);
|
|
240
|
+
i += word.length;
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
throw new TemplateError(
|
|
244
|
+
`line ${String(line)}: unexpected ${JSON.stringify(char)} in an action`,
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** The template split into text and actions, trim markers and comments applied. */
|
|
250
|
+
function lex(source: string): Piece[] {
|
|
251
|
+
const pieces: Piece[] = [];
|
|
252
|
+
let i = 0;
|
|
253
|
+
let trimNext = false;
|
|
254
|
+
while (i < source.length) {
|
|
255
|
+
const open = source.indexOf(LEFT, i);
|
|
256
|
+
let text = source.slice(i, open < 0 ? source.length : open);
|
|
257
|
+
if (trimNext) {
|
|
258
|
+
text = text.replace(/^[ \t\r\n]+/u, "");
|
|
259
|
+
}
|
|
260
|
+
if (open < 0) {
|
|
261
|
+
pieces.push({ k: "text", text });
|
|
262
|
+
break;
|
|
263
|
+
}
|
|
264
|
+
const line = lineOf(source, open);
|
|
265
|
+
let at = open + LEFT.length;
|
|
266
|
+
if (source[at] === "-" && TRIM_SPACE.has(source[at + 1] ?? "")) {
|
|
267
|
+
text = text.replace(/[ \t\r\n]+$/u, "");
|
|
268
|
+
at += 2;
|
|
269
|
+
}
|
|
270
|
+
pieces.push({ k: "text", text });
|
|
271
|
+
if (source.startsWith("/*", at)) {
|
|
272
|
+
const close = source.indexOf("*/", at + 2);
|
|
273
|
+
if (close < 0) {
|
|
274
|
+
throw new TemplateError(`line ${String(line)}: unclosed comment`);
|
|
275
|
+
}
|
|
276
|
+
const after = close + 2;
|
|
277
|
+
if (source.startsWith(RIGHT, after)) {
|
|
278
|
+
i = after + RIGHT.length;
|
|
279
|
+
trimNext = false;
|
|
280
|
+
} else if (source.startsWith(` -${RIGHT}`, after)) {
|
|
281
|
+
i = after + 2 + RIGHT.length;
|
|
282
|
+
trimNext = true;
|
|
283
|
+
} else {
|
|
284
|
+
throw new TemplateError(`line ${String(line)}: a comment ends the action it is written in`);
|
|
285
|
+
}
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
const lexed = lexAction(source, at, line);
|
|
289
|
+
pieces.push({ k: "action", action: { tokens: lexed.tokens, line } });
|
|
290
|
+
i = lexed.end;
|
|
291
|
+
trimNext = lexed.trimRight;
|
|
292
|
+
}
|
|
293
|
+
return pieces.filter((piece) => piece.k !== "text" || piece.text !== "");
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** The parser over one template's pieces, with the variables in scope at each point. */
|
|
297
|
+
class Parser {
|
|
298
|
+
private at = 0;
|
|
299
|
+
private readonly scopes: Set<string>[] = [new Set(["$"])];
|
|
300
|
+
private ranges = 0;
|
|
301
|
+
private readonly pieces: readonly Piece[];
|
|
302
|
+
|
|
303
|
+
constructor(pieces: readonly Piece[]) {
|
|
304
|
+
this.pieces = pieces;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
parse(): Node[] {
|
|
308
|
+
const { list, ended } = this.list();
|
|
309
|
+
if (ended !== undefined) {
|
|
310
|
+
throw new TemplateError(`line ${String(ended.line)}: unexpected {{${ended.word}}}`);
|
|
311
|
+
}
|
|
312
|
+
return list;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Nodes up to the end of the template or to an `else` or `end`, which it returns. */
|
|
316
|
+
private list(): {
|
|
317
|
+
list: Node[];
|
|
318
|
+
ended: { word: string; action: Action; line: number } | undefined;
|
|
319
|
+
} {
|
|
320
|
+
const list: Node[] = [];
|
|
321
|
+
while (this.at < this.pieces.length) {
|
|
322
|
+
const piece = this.pieces[this.at];
|
|
323
|
+
this.at += 1;
|
|
324
|
+
if (piece === undefined) {
|
|
325
|
+
break;
|
|
326
|
+
}
|
|
327
|
+
if (piece.k === "text") {
|
|
328
|
+
list.push({ k: "text", text: piece.text });
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
const action = piece.action;
|
|
332
|
+
const first = action.tokens[0];
|
|
333
|
+
const word = first?.t === "word" ? first.text : undefined;
|
|
334
|
+
if (word === "end" || word === "else") {
|
|
335
|
+
return { list, ended: { word, action, line: action.line } };
|
|
336
|
+
}
|
|
337
|
+
list.push(this.node(action, word));
|
|
338
|
+
}
|
|
339
|
+
return { list, ended: undefined };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
private node(action: Action, word: string | undefined): Node {
|
|
343
|
+
const line = action.line;
|
|
344
|
+
if (word === "if" || word === "with" || word === "range") {
|
|
345
|
+
return this.control(word, action.tokens.slice(1), line);
|
|
346
|
+
}
|
|
347
|
+
if (word === "break" || word === "continue") {
|
|
348
|
+
if (action.tokens.length !== 1) {
|
|
349
|
+
throw new TemplateError(`line ${String(line)}: {{${word}}} takes nothing`);
|
|
350
|
+
}
|
|
351
|
+
if (this.ranges === 0) {
|
|
352
|
+
throw new TemplateError(`line ${String(line)}: {{${word}}} outside {{range}}`);
|
|
353
|
+
}
|
|
354
|
+
return { k: word };
|
|
355
|
+
}
|
|
356
|
+
if (word !== undefined && REFUSED_KEYWORDS.has(word)) {
|
|
357
|
+
throw new TemplateError(`line ${String(line)}: {{${word}}} is not supported`);
|
|
358
|
+
}
|
|
359
|
+
return { k: "action", pipe: this.pipeline(action.tokens, line, "action") };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
private control(word: "if" | "with" | "range", tokens: readonly Token[], line: number): Node {
|
|
363
|
+
this.scopes.push(new Set());
|
|
364
|
+
try {
|
|
365
|
+
if (word === "range") {
|
|
366
|
+
this.ranges += 1;
|
|
367
|
+
}
|
|
368
|
+
const pipe = this.pipeline(tokens, line, word);
|
|
369
|
+
const body = this.list();
|
|
370
|
+
if (word === "range") {
|
|
371
|
+
this.ranges -= 1;
|
|
372
|
+
}
|
|
373
|
+
const ended = body.ended;
|
|
374
|
+
if (ended === undefined) {
|
|
375
|
+
throw new TemplateError(`line ${String(line)}: {{${word}}} has no {{end}}`);
|
|
376
|
+
}
|
|
377
|
+
const rest = ended.action.tokens.slice(1);
|
|
378
|
+
if (ended.word === "end") {
|
|
379
|
+
if (rest.length > 0) {
|
|
380
|
+
throw new TemplateError(`line ${String(ended.line)}: unexpected tokens after {{end}}`);
|
|
381
|
+
}
|
|
382
|
+
return { k: word, pipe, list: body.list, otherwise: [] };
|
|
383
|
+
}
|
|
384
|
+
const chained = rest[0];
|
|
385
|
+
if (word !== "range" && chained?.t === "word" && chained.text === word) {
|
|
386
|
+
const otherwise = [this.control(word, rest.slice(1), ended.line)];
|
|
387
|
+
return { k: word, pipe, list: body.list, otherwise };
|
|
388
|
+
}
|
|
389
|
+
if (rest.length > 0) {
|
|
390
|
+
throw new TemplateError(`line ${String(ended.line)}: unexpected tokens after {{else}}`);
|
|
391
|
+
}
|
|
392
|
+
const tail = this.list();
|
|
393
|
+
if (tail.ended?.word !== "end" || tail.ended.action.tokens.length !== 1) {
|
|
394
|
+
throw new TemplateError(
|
|
395
|
+
`line ${String(line)}: {{${word}}} has no {{end}} after its {{else}}`,
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
return { k: word, pipe, list: body.list, otherwise: tail.list };
|
|
399
|
+
} finally {
|
|
400
|
+
this.scopes.pop();
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
private declared(name: string): boolean {
|
|
405
|
+
return this.scopes.some((scope) => scope.has(name));
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
private pipeline(tokens: readonly Token[], line: number, context: string): Pipeline {
|
|
409
|
+
let rest = tokens;
|
|
410
|
+
let vars: string[] = [];
|
|
411
|
+
let assign = false;
|
|
412
|
+
const declaration = rest.findIndex(
|
|
413
|
+
(token) => token.t === "punct" && (token.text === ":=" || token.text === "="),
|
|
414
|
+
);
|
|
415
|
+
if (declaration > 0) {
|
|
416
|
+
const head = rest.slice(0, declaration);
|
|
417
|
+
const names = head.filter((_token, i) => i % 2 === 0);
|
|
418
|
+
const commas = head.filter((_token, i) => i % 2 === 1);
|
|
419
|
+
const valid =
|
|
420
|
+
names.every((token) => token.t === "var" && token.name !== "$") &&
|
|
421
|
+
commas.every((token) => token.t === "punct" && token.text === ",") &&
|
|
422
|
+
names.length <= (context === "range" ? 2 : 1);
|
|
423
|
+
if (valid) {
|
|
424
|
+
vars = names.map((token) => (token.t === "var" ? token.name : ""));
|
|
425
|
+
const operator = rest[declaration];
|
|
426
|
+
assign = operator?.t === "punct" && operator.text === "=";
|
|
427
|
+
rest = rest.slice(declaration + 1);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
const commands = this.commands(rest, line);
|
|
431
|
+
if (commands.length === 0) {
|
|
432
|
+
throw new TemplateError(`line ${String(line)}: missing value for ${context}`);
|
|
433
|
+
}
|
|
434
|
+
for (const name of vars) {
|
|
435
|
+
if (assign) {
|
|
436
|
+
if (!this.declared(name)) {
|
|
437
|
+
throw new TemplateError(`line ${String(line)}: undefined variable ${name}`);
|
|
438
|
+
}
|
|
439
|
+
} else {
|
|
440
|
+
this.scopes.at(-1)?.add(name);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
return { vars, assign, commands };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
private commands(tokens: readonly Token[], line: number): Arg[][] {
|
|
447
|
+
const commands: Arg[][] = [];
|
|
448
|
+
let current: Arg[] = [];
|
|
449
|
+
let i = 0;
|
|
450
|
+
const finish = (): void => {
|
|
451
|
+
if (current.length === 0) {
|
|
452
|
+
throw new TemplateError(`line ${String(line)}: missing command in a pipeline`);
|
|
453
|
+
}
|
|
454
|
+
commands.push(current);
|
|
455
|
+
current = [];
|
|
456
|
+
};
|
|
457
|
+
while (i < tokens.length) {
|
|
458
|
+
const token = tokens[i];
|
|
459
|
+
if (token?.t === "punct" && token.text === "|") {
|
|
460
|
+
finish();
|
|
461
|
+
i += 1;
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
const { arg, next } = this.operand(tokens, i, line);
|
|
465
|
+
current.push(arg);
|
|
466
|
+
i = next;
|
|
467
|
+
}
|
|
468
|
+
if (current.length > 0 || commands.length > 0) {
|
|
469
|
+
finish();
|
|
470
|
+
}
|
|
471
|
+
return commands;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** One operand starting at token `i`, its field chain included, and the index after it. */
|
|
475
|
+
private operand(tokens: readonly Token[], i: number, line: number): { arg: Arg; next: number } {
|
|
476
|
+
const token = tokens[i];
|
|
477
|
+
let arg: Arg;
|
|
478
|
+
let next = i + 1;
|
|
479
|
+
if (token === undefined) {
|
|
480
|
+
throw new TemplateError(`line ${String(line)}: missing operand`);
|
|
481
|
+
}
|
|
482
|
+
switch (token.t) {
|
|
483
|
+
case "lit":
|
|
484
|
+
arg = { k: "lit", value: token.value };
|
|
485
|
+
break;
|
|
486
|
+
case "word":
|
|
487
|
+
if (!FUNCTIONS.has(token.text)) {
|
|
488
|
+
throw new TemplateError(
|
|
489
|
+
`line ${String(line)}: function ${JSON.stringify(token.text)} not defined`,
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
return { arg: { k: "func", name: token.text }, next };
|
|
493
|
+
case "var":
|
|
494
|
+
if (!this.declared(token.name)) {
|
|
495
|
+
throw new TemplateError(`line ${String(line)}: undefined variable ${token.name}`);
|
|
496
|
+
}
|
|
497
|
+
arg = { k: "var", name: token.name };
|
|
498
|
+
break;
|
|
499
|
+
case "field":
|
|
500
|
+
if (token.name === "") {
|
|
501
|
+
arg = { k: "dot" };
|
|
502
|
+
break;
|
|
503
|
+
}
|
|
504
|
+
arg = { k: "field", recv: undefined, names: [token.name] };
|
|
505
|
+
break;
|
|
506
|
+
case "punct": {
|
|
507
|
+
if (token.text !== "(") {
|
|
508
|
+
throw new TemplateError(`line ${String(line)}: unexpected ${token.text} in an operand`);
|
|
509
|
+
}
|
|
510
|
+
let depth = 1;
|
|
511
|
+
let close = i + 1;
|
|
512
|
+
for (; close < tokens.length; close += 1) {
|
|
513
|
+
const inner = tokens[close];
|
|
514
|
+
if (inner?.t === "punct" && inner.text === "(") {
|
|
515
|
+
depth += 1;
|
|
516
|
+
} else if (inner?.t === "punct" && inner.text === ")") {
|
|
517
|
+
depth -= 1;
|
|
518
|
+
if (depth === 0) {
|
|
519
|
+
break;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
if (depth !== 0) {
|
|
524
|
+
throw new TemplateError(`line ${String(line)}: unclosed left parenthesis`);
|
|
525
|
+
}
|
|
526
|
+
arg = {
|
|
527
|
+
k: "pipe",
|
|
528
|
+
pipe: this.pipeline(tokens.slice(i + 1, close), line, "parenthesized pipeline"),
|
|
529
|
+
};
|
|
530
|
+
next = close + 1;
|
|
531
|
+
break;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
const names: string[] = [];
|
|
535
|
+
for (
|
|
536
|
+
let chained = tokens[next];
|
|
537
|
+
chained?.t === "field" && chained.attached && chained.name !== "";
|
|
538
|
+
chained = tokens[next]
|
|
539
|
+
) {
|
|
540
|
+
names.push(chained.name);
|
|
541
|
+
next += 1;
|
|
542
|
+
}
|
|
543
|
+
if (names.length > 0) {
|
|
544
|
+
if (arg.k === "lit") {
|
|
545
|
+
throw new TemplateError(`line ${String(line)}: a literal has no field`);
|
|
546
|
+
}
|
|
547
|
+
arg =
|
|
548
|
+
arg.k === "field" && arg.recv === undefined
|
|
549
|
+
? { k: "field", recv: undefined, names: [...arg.names, ...names] }
|
|
550
|
+
: { k: "field", recv: arg, names };
|
|
551
|
+
}
|
|
552
|
+
return { arg, next };
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** Parses a template, or throws a {@link TemplateError} naming the line it fails at. */
|
|
557
|
+
export function parseTemplate(source: string): Template {
|
|
558
|
+
return { nodes: new Parser(lex(source)).parse() };
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** The stop a `break` or a `continue` raises out of the body of its `range`. */
|
|
562
|
+
class LoopStop extends Error {
|
|
563
|
+
readonly kind: "break" | "continue";
|
|
564
|
+
|
|
565
|
+
constructor(kind: "break" | "continue") {
|
|
566
|
+
super(kind);
|
|
567
|
+
this.kind = kind;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function isList(value: Value): value is readonly Value[] {
|
|
572
|
+
return Array.isArray(value);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function isObject(value: Value): value is ValueObject {
|
|
576
|
+
return typeof value === "object" && value !== null && !isList(value);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function truthy(value: Value): boolean {
|
|
580
|
+
if (value === null) {
|
|
581
|
+
return false;
|
|
582
|
+
}
|
|
583
|
+
if (typeof value === "boolean") {
|
|
584
|
+
return value;
|
|
585
|
+
}
|
|
586
|
+
if (typeof value === "number") {
|
|
587
|
+
return value !== 0;
|
|
588
|
+
}
|
|
589
|
+
if (typeof value === "string" || isList(value)) {
|
|
590
|
+
return value.length > 0;
|
|
591
|
+
}
|
|
592
|
+
return Object.keys(value).length > 0;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/** One value as an operand prints it: nested lists and objects in the bracketed form. */
|
|
596
|
+
function formatted(value: Value, nested: boolean): string {
|
|
597
|
+
if (value === null) {
|
|
598
|
+
return nested ? "<nil>" : "<no value>";
|
|
599
|
+
}
|
|
600
|
+
if (typeof value === "string") {
|
|
601
|
+
return value;
|
|
602
|
+
}
|
|
603
|
+
if (typeof value === "number" || typeof value === "boolean") {
|
|
604
|
+
return String(value);
|
|
605
|
+
}
|
|
606
|
+
if (isList(value)) {
|
|
607
|
+
return `[${value.map((one) => formatted(one, true)).join(" ")}]`;
|
|
608
|
+
}
|
|
609
|
+
const object: ValueObject = value;
|
|
610
|
+
const entries = Object.keys(object)
|
|
611
|
+
.sort()
|
|
612
|
+
.map((key) => `${key}:${formatted(object[key] ?? null, true)}`);
|
|
613
|
+
return `map[${entries.join(" ")}]`;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function kindOf(value: Value): string {
|
|
617
|
+
if (value === null) {
|
|
618
|
+
return "nil";
|
|
619
|
+
}
|
|
620
|
+
if (isList(value)) {
|
|
621
|
+
return "list";
|
|
622
|
+
}
|
|
623
|
+
return typeof value === "object" ? "object" : typeof value;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function compareBasic(name: string, a: Value, b: Value): number {
|
|
627
|
+
const kind = kindOf(a);
|
|
628
|
+
if (kind !== kindOf(b) || (kind !== "number" && kind !== "string" && kind !== "boolean")) {
|
|
629
|
+
throw new RenderError(
|
|
630
|
+
`${name}: incompatible types for comparison: ${kindOf(a)} and ${kindOf(b)}`,
|
|
631
|
+
);
|
|
632
|
+
}
|
|
633
|
+
if (a === b) {
|
|
634
|
+
return 0;
|
|
635
|
+
}
|
|
636
|
+
if (kind === "boolean") {
|
|
637
|
+
return Number.NaN;
|
|
638
|
+
}
|
|
639
|
+
return (a as number | string) < (b as number | string) ? -1 : 1;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/** `printf`'s verbs, each over one operand. */
|
|
643
|
+
function printf(format: string, args: readonly Value[]): string {
|
|
644
|
+
let out = "";
|
|
645
|
+
let next = 0;
|
|
646
|
+
for (let i = 0; i < format.length; i += 1) {
|
|
647
|
+
const char = format[i];
|
|
648
|
+
if (char !== "%") {
|
|
649
|
+
out += char ?? "";
|
|
650
|
+
continue;
|
|
651
|
+
}
|
|
652
|
+
const verb = format[i + 1] ?? "";
|
|
653
|
+
i += 1;
|
|
654
|
+
if (verb === "%") {
|
|
655
|
+
out += "%";
|
|
656
|
+
continue;
|
|
657
|
+
}
|
|
658
|
+
if (next >= args.length) {
|
|
659
|
+
throw new RenderError(`printf: %${verb} has no operand`);
|
|
660
|
+
}
|
|
661
|
+
const arg = args[next] ?? null;
|
|
662
|
+
next += 1;
|
|
663
|
+
switch (verb) {
|
|
664
|
+
case "v":
|
|
665
|
+
case "s":
|
|
666
|
+
out += formatted(arg, false);
|
|
667
|
+
break;
|
|
668
|
+
case "q":
|
|
669
|
+
out += JSON.stringify(formatted(arg, false));
|
|
670
|
+
break;
|
|
671
|
+
case "d":
|
|
672
|
+
if (typeof arg !== "number" || !Number.isInteger(arg)) {
|
|
673
|
+
throw new RenderError(`printf: %d over a ${kindOf(arg)}`);
|
|
674
|
+
}
|
|
675
|
+
out += String(arg);
|
|
676
|
+
break;
|
|
677
|
+
case "t":
|
|
678
|
+
if (typeof arg !== "boolean") {
|
|
679
|
+
throw new RenderError(`printf: %t over a ${kindOf(arg)}`);
|
|
680
|
+
}
|
|
681
|
+
out += String(arg);
|
|
682
|
+
break;
|
|
683
|
+
default:
|
|
684
|
+
throw new RenderError(`printf: the verb %${verb} is not supported`);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
if (next < args.length) {
|
|
688
|
+
throw new RenderError(`printf: ${String(args.length - next)} operand(s) left over`);
|
|
689
|
+
}
|
|
690
|
+
return out;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function ordered(name: "lt" | "le" | "gt" | "ge", order: number): boolean {
|
|
694
|
+
switch (name) {
|
|
695
|
+
case "lt":
|
|
696
|
+
return order < 0;
|
|
697
|
+
case "le":
|
|
698
|
+
return order <= 0;
|
|
699
|
+
case "gt":
|
|
700
|
+
return order > 0;
|
|
701
|
+
case "ge":
|
|
702
|
+
return order >= 0;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/** The functions a command calls, each over its evaluated operands. */
|
|
707
|
+
function call(name: string, args: readonly Value[]): Value {
|
|
708
|
+
const [first = null, second = null] = args;
|
|
709
|
+
const arity = (count: number): void => {
|
|
710
|
+
if (args.length !== count) {
|
|
711
|
+
throw new RenderError(
|
|
712
|
+
`${name} takes ${String(count)} operand(s), and ${String(args.length)} were given`,
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
};
|
|
716
|
+
switch (name) {
|
|
717
|
+
case "not":
|
|
718
|
+
arity(1);
|
|
719
|
+
return !truthy(first);
|
|
720
|
+
case "len":
|
|
721
|
+
arity(1);
|
|
722
|
+
if (typeof first === "string") {
|
|
723
|
+
return utf8Bytes(first).length;
|
|
724
|
+
}
|
|
725
|
+
if (isList(first)) {
|
|
726
|
+
return first.length;
|
|
727
|
+
}
|
|
728
|
+
if (isObject(first)) {
|
|
729
|
+
return Object.keys(first).length;
|
|
730
|
+
}
|
|
731
|
+
throw new RenderError(`len of a ${kindOf(first)}`);
|
|
732
|
+
case "index": {
|
|
733
|
+
let value = first;
|
|
734
|
+
for (const key of args.slice(1)) {
|
|
735
|
+
if (isList(value) && typeof key === "number" && Number.isInteger(key)) {
|
|
736
|
+
if (key < 0 || key >= value.length) {
|
|
737
|
+
throw new RenderError(`index out of range: ${String(key)}`);
|
|
738
|
+
}
|
|
739
|
+
value = value[key] ?? null;
|
|
740
|
+
} else if (isObject(value) && typeof key === "string" && Object.hasOwn(value, key)) {
|
|
741
|
+
value = value[key] ?? null;
|
|
742
|
+
} else {
|
|
743
|
+
throw new RenderError(`index of ${formatted(key, false)} into a ${kindOf(value)}`);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
return value;
|
|
747
|
+
}
|
|
748
|
+
case "eq":
|
|
749
|
+
if (args.length < 2) {
|
|
750
|
+
throw new RenderError("eq takes at least two operands");
|
|
751
|
+
}
|
|
752
|
+
return args.slice(1).some((other) => compareBasic(name, first, other) === 0);
|
|
753
|
+
case "ne":
|
|
754
|
+
arity(2);
|
|
755
|
+
return compareBasic(name, first, second) !== 0;
|
|
756
|
+
case "lt":
|
|
757
|
+
case "le":
|
|
758
|
+
case "gt":
|
|
759
|
+
case "ge": {
|
|
760
|
+
arity(2);
|
|
761
|
+
const order = compareBasic(name, first, second);
|
|
762
|
+
if (Number.isNaN(order)) {
|
|
763
|
+
throw new RenderError(`${name}: booleans have no order`);
|
|
764
|
+
}
|
|
765
|
+
return ordered(name, order);
|
|
766
|
+
}
|
|
767
|
+
case "print":
|
|
768
|
+
return args
|
|
769
|
+
.map((arg, i) => {
|
|
770
|
+
const spaced = i > 0 && typeof arg !== "string" && typeof args[i - 1] !== "string";
|
|
771
|
+
return `${spaced ? " " : ""}${formatted(arg, false)}`;
|
|
772
|
+
})
|
|
773
|
+
.join("");
|
|
774
|
+
case "println":
|
|
775
|
+
return `${args.map((arg) => formatted(arg, false)).join(" ")}\n`;
|
|
776
|
+
case "printf":
|
|
777
|
+
if (typeof first !== "string") {
|
|
778
|
+
throw new RenderError("printf takes a format string first");
|
|
779
|
+
}
|
|
780
|
+
return printf(first, args.slice(1));
|
|
781
|
+
default:
|
|
782
|
+
throw new RenderError(`function ${JSON.stringify(name)} not defined`);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/** One execution of a template over a document. */
|
|
787
|
+
class Execution {
|
|
788
|
+
private readonly scopes: Map<string, Value>[];
|
|
789
|
+
out = "";
|
|
790
|
+
|
|
791
|
+
constructor(root: Value) {
|
|
792
|
+
this.scopes = [new Map([["$", root]])];
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
private lookup(name: string): Value {
|
|
796
|
+
for (let i = this.scopes.length - 1; i >= 0; i -= 1) {
|
|
797
|
+
const scope = this.scopes[i];
|
|
798
|
+
if (scope?.has(name) === true) {
|
|
799
|
+
return scope.get(name) ?? null;
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
throw new RenderError(`undefined variable ${name}`);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
private assign(name: string, value: Value): void {
|
|
806
|
+
for (let i = this.scopes.length - 1; i >= 0; i -= 1) {
|
|
807
|
+
const scope = this.scopes[i];
|
|
808
|
+
if (scope?.has(name) === true) {
|
|
809
|
+
scope.set(name, value);
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
throw new RenderError(`undefined variable ${name}`);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
private field(value: Value, names: readonly string[]): Value {
|
|
817
|
+
let held = value;
|
|
818
|
+
for (const name of names) {
|
|
819
|
+
if (!isObject(held)) {
|
|
820
|
+
throw new RenderError(`can't evaluate field ${name} in a ${kindOf(held)}`);
|
|
821
|
+
}
|
|
822
|
+
if (!Object.hasOwn(held, name)) {
|
|
823
|
+
throw new RenderError(`the document has no member ${JSON.stringify(name)} here`);
|
|
824
|
+
}
|
|
825
|
+
held = held[name] ?? null;
|
|
826
|
+
}
|
|
827
|
+
return held;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
private arg(arg: Arg, dot: Value): Value {
|
|
831
|
+
switch (arg.k) {
|
|
832
|
+
case "dot":
|
|
833
|
+
return dot;
|
|
834
|
+
case "var":
|
|
835
|
+
return this.lookup(arg.name);
|
|
836
|
+
case "lit":
|
|
837
|
+
return arg.value;
|
|
838
|
+
case "pipe":
|
|
839
|
+
return this.pipeline(arg.pipe, dot, false);
|
|
840
|
+
case "field":
|
|
841
|
+
return this.field(arg.recv === undefined ? dot : this.arg(arg.recv, dot), arg.names);
|
|
842
|
+
case "func":
|
|
843
|
+
return call(arg.name, []);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
private command(command: readonly Arg[], dot: Value, piped: Value | undefined): Value {
|
|
848
|
+
const [head, ...rest] = command;
|
|
849
|
+
if (head === undefined) {
|
|
850
|
+
throw new RenderError("empty command");
|
|
851
|
+
}
|
|
852
|
+
if (head.k !== "func") {
|
|
853
|
+
if (rest.length > 0 || piped !== undefined) {
|
|
854
|
+
throw new RenderError("can't give an argument to a non-function");
|
|
855
|
+
}
|
|
856
|
+
return this.arg(head, dot);
|
|
857
|
+
}
|
|
858
|
+
const operands = (): Value[] => {
|
|
859
|
+
const values = rest.map((one) => this.arg(one, dot));
|
|
860
|
+
return piped === undefined ? values : [...values, piped];
|
|
861
|
+
};
|
|
862
|
+
if (head.name === "and" || head.name === "or") {
|
|
863
|
+
const all = [
|
|
864
|
+
...rest.map((one) => (): Value => this.arg(one, dot)),
|
|
865
|
+
...(piped === undefined ? [] : [(): Value => piped]),
|
|
866
|
+
];
|
|
867
|
+
if (all.length === 0) {
|
|
868
|
+
throw new RenderError(`${head.name} takes at least one operand`);
|
|
869
|
+
}
|
|
870
|
+
let value: Value = null;
|
|
871
|
+
for (const operand of all) {
|
|
872
|
+
value = operand();
|
|
873
|
+
if (truthy(value) === (head.name === "or")) {
|
|
874
|
+
return value;
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
return value;
|
|
878
|
+
}
|
|
879
|
+
return call(head.name, operands());
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
private pipeline(pipe: Pipeline, dot: Value, declares: boolean): Value {
|
|
883
|
+
let value: Value | undefined;
|
|
884
|
+
for (const command of pipe.commands) {
|
|
885
|
+
value = this.command(command, dot, value);
|
|
886
|
+
}
|
|
887
|
+
const result = value ?? null;
|
|
888
|
+
const [only] = pipe.vars;
|
|
889
|
+
if (only !== undefined && (declares || pipe.assign)) {
|
|
890
|
+
if (pipe.assign) {
|
|
891
|
+
this.assign(only, result);
|
|
892
|
+
} else {
|
|
893
|
+
this.scopes.at(-1)?.set(only, result);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
return result;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
run(nodes: readonly Node[], dot: Value): void {
|
|
900
|
+
for (const node of nodes) {
|
|
901
|
+
switch (node.k) {
|
|
902
|
+
case "text":
|
|
903
|
+
this.out += node.text;
|
|
904
|
+
break;
|
|
905
|
+
case "action": {
|
|
906
|
+
const value = this.pipeline(node.pipe, dot, true);
|
|
907
|
+
if (node.pipe.vars.length === 0) {
|
|
908
|
+
this.out += formatted(value, false);
|
|
909
|
+
}
|
|
910
|
+
break;
|
|
911
|
+
}
|
|
912
|
+
case "break":
|
|
913
|
+
case "continue":
|
|
914
|
+
throw new LoopStop(node.k);
|
|
915
|
+
case "if":
|
|
916
|
+
case "with": {
|
|
917
|
+
this.scopes.push(new Map());
|
|
918
|
+
try {
|
|
919
|
+
const value = this.pipeline(node.pipe, dot, true);
|
|
920
|
+
const taken = truthy(value);
|
|
921
|
+
this.run(taken ? node.list : node.otherwise, taken && node.k === "with" ? value : dot);
|
|
922
|
+
} finally {
|
|
923
|
+
this.scopes.pop();
|
|
924
|
+
}
|
|
925
|
+
break;
|
|
926
|
+
}
|
|
927
|
+
case "range":
|
|
928
|
+
this.range(node, dot);
|
|
929
|
+
break;
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
private range(node: Control, dot: Value): void {
|
|
935
|
+
this.scopes.push(new Map());
|
|
936
|
+
try {
|
|
937
|
+
const value = this.pipeline({ ...node.pipe, vars: [] }, dot, false);
|
|
938
|
+
let entries: [Value, Value][];
|
|
939
|
+
if (value === null) {
|
|
940
|
+
entries = [];
|
|
941
|
+
} else if (isList(value)) {
|
|
942
|
+
entries = value.map((one, i) => [i, one]);
|
|
943
|
+
} else if (isObject(value)) {
|
|
944
|
+
entries = Object.keys(value)
|
|
945
|
+
.sort()
|
|
946
|
+
.map((key) => [key, value[key] ?? null]);
|
|
947
|
+
} else {
|
|
948
|
+
throw new RenderError(`range can't iterate over a ${kindOf(value)}`);
|
|
949
|
+
}
|
|
950
|
+
if (entries.length === 0) {
|
|
951
|
+
this.run(node.otherwise, dot);
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
954
|
+
const [first, second] = node.pipe.vars;
|
|
955
|
+
for (const [key, element] of entries) {
|
|
956
|
+
const scope = this.scopes.at(-1);
|
|
957
|
+
if (second !== undefined && first !== undefined) {
|
|
958
|
+
scope?.set(first, key);
|
|
959
|
+
scope?.set(second, element);
|
|
960
|
+
} else if (first !== undefined) {
|
|
961
|
+
scope?.set(first, element);
|
|
962
|
+
}
|
|
963
|
+
try {
|
|
964
|
+
this.run(node.list, element);
|
|
965
|
+
} catch (stop: unknown) {
|
|
966
|
+
if (!(stop instanceof LoopStop)) {
|
|
967
|
+
throw stop;
|
|
968
|
+
}
|
|
969
|
+
if (stop.kind === "break") {
|
|
970
|
+
break;
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
} finally {
|
|
975
|
+
this.scopes.pop();
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
/**
|
|
981
|
+
* The template's rendering of one document. The rendering is built whole, so a template
|
|
982
|
+
* that fails partway renders nothing, and the failure names what it could not evaluate.
|
|
983
|
+
*/
|
|
984
|
+
export function renderTemplate(template: Template, document: unknown): string {
|
|
985
|
+
const root = JSON.parse(JSON.stringify(document)) as Value;
|
|
986
|
+
const execution = new Execution(root);
|
|
987
|
+
execution.run(template.nodes, root);
|
|
988
|
+
return execution.out;
|
|
989
|
+
}
|