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