@weborigami/language 0.2.10 → 0.2.12

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.
@@ -1,7 +1,13 @@
1
1
  import { trailingSlash } from "@weborigami/async-tree";
2
+ import * as YAMLModule from "yaml";
2
3
  import codeFragment from "../runtime/codeFragment.js";
3
4
  import * as ops from "../runtime/ops.js";
4
5
 
6
+ // The "yaml" package doesn't seem to provide a default export that the browser can
7
+ // recognize, so we have to handle two ways to accommodate Node and the browser.
8
+ // @ts-ignore
9
+ const YAML = YAMLModule.default ?? YAMLModule.YAML;
10
+
5
11
  // Parser helpers
6
12
 
7
13
  /** @typedef {import("../../index.ts").AnnotatedCode} AnnotatedCode */
@@ -30,6 +36,28 @@ export function annotate(code, location) {
30
36
  return annotated;
31
37
  }
32
38
 
39
+ /**
40
+ * In the given code, replace all scope refernces to the given name with the
41
+ * given macro code.
42
+ *
43
+ * @param {AnnotatedCode} code
44
+ * @param {string} name
45
+ * @param {AnnotatedCode} macro
46
+ */
47
+ export function applyMacro(code, name, macro) {
48
+ if (!(code instanceof Array)) {
49
+ return code;
50
+ }
51
+
52
+ const [fn, ...args] = code;
53
+ if (fn === ops.scope && args[0] === name) {
54
+ return macro;
55
+ }
56
+
57
+ const applied = code.map((child) => applyMacro(child, name, macro));
58
+ return annotate(applied, code.location);
59
+ }
60
+
33
61
  /**
34
62
  * The indicated code is being used to define a property named by the given key.
35
63
  * Rewrite any [ops.scope, key] calls to be [ops.inherited, key] to avoid
@@ -198,7 +226,13 @@ export function makeCall(target, args) {
198
226
  }
199
227
  } else if (args[0] === ops.template) {
200
228
  // Tagged template
201
- fnCall = [upgradeReference(target), ...args.slice(1)];
229
+ const strings = args[1];
230
+ const values = args.slice(2);
231
+ fnCall = makeTaggedTemplateCall(
232
+ upgradeReference(target),
233
+ strings,
234
+ ...values
235
+ );
202
236
  } else {
203
237
  // Function call with explicit or implicit parentheses
204
238
  fnCall = [upgradeReference(target), ...args];
@@ -345,6 +379,24 @@ export function makeReference(identifier) {
345
379
  return [op, identifier];
346
380
  }
347
381
 
382
+ /**
383
+ * Make a tagged template call
384
+ *
385
+ * Because the tagged template function may not be an Origami function, we wrap
386
+ * each argument in a ops.concat call to convert it to a string.
387
+ *
388
+ * @param {AnnotatedCode} fn
389
+ * @param {AnnotatedCode} strings
390
+ * @param {AnnotatedCode[]} values
391
+ */
392
+ function makeTaggedTemplateCall(fn, strings, ...values) {
393
+ const args = values.map((value) =>
394
+ // @ts-ignore
395
+ annotate([ops.concat, value], value.location)
396
+ );
397
+ return annotate([fn, strings, ...args], strings.location);
398
+ }
399
+
348
400
  /**
349
401
  * Make a template
350
402
  *
@@ -357,8 +409,7 @@ export function makeTemplate(op, head, tail, location) {
357
409
  const strings = [head[1]];
358
410
  const values = [];
359
411
  for (const [value, literal] of tail) {
360
- const concat = annotate([ops.concat, value], value.location);
361
- values.push(concat);
412
+ values.push(value);
362
413
  strings.push(literal[1]);
363
414
  }
364
415
  const stringsCode = annotate(strings, location);
@@ -385,6 +436,59 @@ export function makeUnaryOperation(operator, value, location) {
385
436
  return annotate([operators[operator], value], location);
386
437
  }
387
438
 
439
+ /**
440
+ * Make an object from YAML front matter
441
+ *
442
+ * @param {string} text
443
+ * @param {CodeLocation} location
444
+ */
445
+ export function makeYamlObject(text, location) {
446
+ // Account for the "---" delimiter at the beginning of the YAML front matter
447
+ const yamlLineDelta = 1;
448
+ const yamlOffsetDelta = 4; // 3 dashes + 1 newline
449
+
450
+ let parsed;
451
+ try {
452
+ parsed = YAML.parse(text);
453
+ } catch (/** @type {any} */ yamlError) {
454
+ // Convert YAML error to a SyntaxError
455
+
456
+ let { message } = yamlError;
457
+ // Remove the line number and column if present
458
+ const lineNumberRegex = /( at line )(\d+)(,)/;
459
+ const lineNumberMatch = message.match(lineNumberRegex);
460
+ if (lineNumberMatch) {
461
+ message = message.slice(0, lineNumberMatch.index);
462
+ }
463
+
464
+ /** @type {any} */
465
+ const error = new SyntaxError(message);
466
+ error.location = {
467
+ end: {
468
+ column: yamlError.linePos[1].col,
469
+ line: yamlError.linePos[1].line + yamlLineDelta,
470
+ offset: yamlError.pos[1] + yamlOffsetDelta,
471
+ },
472
+ source: location.source,
473
+ start: {
474
+ column: yamlError.linePos[0].col,
475
+ line: yamlError.linePos[0].line + yamlLineDelta,
476
+ offset: yamlError.pos[0] + yamlOffsetDelta,
477
+ },
478
+ };
479
+ throw error;
480
+ }
481
+
482
+ if (!(parsed instanceof Object)) {
483
+ /** @type {any} */
484
+ const error = new SyntaxError("YAML front matter must be an object.");
485
+ error.location = location;
486
+ throw error;
487
+ }
488
+
489
+ return annotate([ops.literal, parsed], location);
490
+ }
491
+
388
492
  /**
389
493
  * Upgrade a potential builtin reference to an actual builtin reference.
390
494
  *
@@ -5,6 +5,8 @@ import {
5
5
  trailingSlash,
6
6
  TraverseError,
7
7
  } from "@weborigami/async-tree";
8
+ import path from "node:path";
9
+ import { fileURLToPath } from "node:url";
8
10
  import codeFragment from "./codeFragment.js";
9
11
  import { typos } from "./typos.js";
10
12
 
@@ -85,9 +87,24 @@ export function formatError(error) {
85
87
  if (!fragmentInMessage) {
86
88
  message += `\nevaluating: ${fragment}`;
87
89
  }
90
+
88
91
  if (typeof source === "object" && source.url) {
89
- message += `\n at ${source.url.href}:${line}:${start.column}`;
92
+ const { url } = source;
93
+ let fileRef;
94
+ // If URL is a file: URL, change to a relative path
95
+ if (url.protocol === "file:") {
96
+ fileRef = fileURLToPath(url);
97
+ const relativePath = path.relative(process.cwd(), fileRef);
98
+ if (!relativePath.startsWith("..")) {
99
+ fileRef = relativePath;
100
+ }
101
+ } else {
102
+ // Not a file: URL, use as is
103
+ fileRef = url.href;
104
+ }
105
+ message += `\n at ${fileRef}:${line}:${start.column}`;
90
106
  } else if (source.text.includes("\n")) {
107
+ // Don't know the URL, but has multiple lines so add line number
91
108
  message += `\n at line ${line}, column ${start.column}`;
92
109
  }
93
110
  }
@@ -113,7 +130,7 @@ export function maybeOrigamiSourceCode(text) {
113
130
 
114
131
  export async function scopeReferenceError(scope, key) {
115
132
  const messages = [
116
- `"${key}" is not in scope.`,
133
+ `"${key}" is not in scope or is undefined.`,
117
134
  await formatScopeTypos(scope, key),
118
135
  ];
119
136
  const message = messages.join(" ");
@@ -1,6 +1,5 @@
1
1
  import { Tree, isUnpackable, scope } from "@weborigami/async-tree";
2
2
  import codeFragment from "./codeFragment.js";
3
- import { ops } from "./internal.js";
4
3
  import { codeSymbol, scopeSymbol, sourceSymbol } from "./symbols.js";
5
4
 
6
5
  /**
@@ -20,14 +19,7 @@ export default async function evaluate(code) {
20
19
  }
21
20
 
22
21
  let evaluated;
23
- const unevaluatedFns = [
24
- ops.external,
25
- ops.lambda,
26
- ops.merge,
27
- ops.object,
28
- ops.literal,
29
- ];
30
- if (unevaluatedFns.includes(code[0])) {
22
+ if (code[0]?.unevaluatedArgs) {
31
23
  // Don't evaluate instructions, use as is.
32
24
  evaluated = code;
33
25
  } else {
@@ -8,8 +8,10 @@
8
8
  import {
9
9
  ObjectTree,
10
10
  Tree,
11
+ concatTrees,
11
12
  isUnpackable,
12
13
  scope as scopeFn,
14
+ symbols,
13
15
  concat as treeConcat,
14
16
  } from "@weborigami/async-tree";
15
17
  import os from "node:os";
@@ -20,7 +22,6 @@ import { evaluate } from "./internal.js";
20
22
  import mergeTrees from "./mergeTrees.js";
21
23
  import OrigamiFiles from "./OrigamiFiles.js";
22
24
  import { codeSymbol } from "./symbols.js";
23
- import taggedTemplate from "./taggedTemplate.js";
24
25
 
25
26
  function addOpLabel(op, label) {
26
27
  Object.defineProperty(op, "toString", {
@@ -113,6 +114,29 @@ export async function conditional(condition, truthy, falsy) {
113
114
  return value instanceof Function ? await value() : value;
114
115
  }
115
116
 
117
+ /**
118
+ * Construct a document object by invoking the body code (a lambda) and adding
119
+ * the resulting text to the front data.
120
+ *
121
+ * @this {AsyncTree|null}
122
+ * @param {any} frontData
123
+ * @param {AnnotatedCode} bodyCode
124
+ */
125
+ export async function document(frontData, bodyCode) {
126
+ const context = new ObjectTree(frontData);
127
+ context.parent = this;
128
+ const bodyFn = await evaluate.call(context, bodyCode);
129
+ const body = await bodyFn();
130
+ const object = {
131
+ ...frontData,
132
+ "@text": body,
133
+ };
134
+ object[symbols.parent] = this;
135
+ return object;
136
+ }
137
+ addOpLabel(document, "«ops.document");
138
+ document.unevaluatedArgs = true;
139
+
116
140
  export function division(a, b) {
117
141
  return a / b;
118
142
  }
@@ -158,6 +182,7 @@ export async function external(path, code, cache) {
158
182
  return value;
159
183
  }
160
184
  addOpLabel(external, "«ops.external»");
185
+ external.unevaluatedArgs = true;
161
186
 
162
187
  /**
163
188
  * This op is only used during parsing. It signals to ops.object that the
@@ -263,6 +288,7 @@ export function lambda(parameters, code) {
263
288
  return invoke;
264
289
  }
265
290
  addOpLabel(lambda, "«ops.lambda");
291
+ lambda.unevaluatedArgs = true;
266
292
 
267
293
  export function lessThan(a, b) {
268
294
  return a < b;
@@ -284,6 +310,7 @@ export async function literal(value) {
284
310
  return value;
285
311
  }
286
312
  addOpLabel(literal, "«ops.literal»");
313
+ literal.unevaluatedArgs = true;
287
314
 
288
315
  /**
289
316
  * Logical AND operator
@@ -377,6 +404,7 @@ export async function merge(...codes) {
377
404
  return mergeTrees.call(this, ...trees);
378
405
  }
379
406
  addOpLabel(merge, "«ops.merge»");
407
+ merge.unevaluatedArgs = true;
380
408
 
381
409
  export function multiplication(a, b) {
382
410
  return a * b;
@@ -425,6 +453,7 @@ export async function object(...entries) {
425
453
  return expressionObject(entries, this);
426
454
  }
427
455
  addOpLabel(object, "«ops.object»");
456
+ object.unevaluatedArgs = true;
428
457
 
429
458
  export function remainder(a, b) {
430
459
  return a % b;
@@ -503,15 +532,15 @@ addOpLabel(subtraction, "«ops.subtraction»");
503
532
  /**
504
533
  * Apply the default tagged template function.
505
534
  */
506
- export function template(strings, ...values) {
507
- return taggedTemplate(strings, ...values);
535
+ export async function template(strings, ...values) {
536
+ return concatTrees(strings, ...values);
508
537
  }
509
538
  addOpLabel(template, "«ops.template»");
510
539
 
511
540
  /**
512
541
  * Apply the tagged template indent function.
513
542
  */
514
- export function templateIndent(strings, ...values) {
543
+ export async function templateIndent(strings, ...values) {
515
544
  return taggedTemplateIndent(strings, ...values);
516
545
  }
517
546
  addOpLabel(templateIndent, "«ops.templateIndent");
@@ -1,22 +1,27 @@
1
+ import { concat, toString, Tree } from "@weborigami/async-tree";
2
+
1
3
  const lastLineWhitespaceRegex = /\n(?<indent>[ \t]*)$/;
2
4
 
3
5
  const mapStringsToModifications = new Map();
4
6
 
5
7
  /**
6
- * Normalize indentation in a tagged template string.
8
+ * Normalize indentation in a tagged template string
7
9
  *
8
10
  * @param {TemplateStringsArray} strings
9
11
  * @param {...any} values
10
- * @returns {string}
12
+ * @returns {Promise<string>}
11
13
  */
12
- export default function indent(strings, ...values) {
14
+ export default async function indent(strings, ...values) {
13
15
  let modified = mapStringsToModifications.get(strings);
14
16
  if (!modified) {
15
17
  modified = modifyStrings(strings);
16
18
  mapStringsToModifications.set(strings, modified);
17
19
  }
18
20
  const { blockIndentations, strings: modifiedStrings } = modified;
19
- return joinBlocks(modifiedStrings, values, blockIndentations);
21
+ const valueTexts = await Promise.all(
22
+ values.map((value) => (Tree.isTreelike(value) ? concat(value) : value))
23
+ );
24
+ return joinBlocks(modifiedStrings, valueTexts, blockIndentations);
20
25
  }
21
26
 
22
27
  // Join strings and values, applying the given block indentation to the lines of
@@ -24,7 +29,7 @@ export default function indent(strings, ...values) {
24
29
  function joinBlocks(strings, values, blockIndentations) {
25
30
  let result = strings[0];
26
31
  for (let i = 0; i < values.length; i++) {
27
- let text = values[i];
32
+ let text = toString(values[i]);
28
33
  if (text) {
29
34
  const blockIndentation = blockIndentations[i];
30
35
  if (blockIndentation) {
@@ -4,7 +4,7 @@ import assert from "node:assert";
4
4
  export function assertCodeEqual(actual, expected) {
5
5
  const actualStripped = stripCodeLocations(actual);
6
6
  const expectedStripped = stripCodeLocations(expected);
7
- assert.deepEqual(actualStripped, expectedStripped);
7
+ assert.deepStrictEqual(actualStripped, expectedStripped);
8
8
  }
9
9
 
10
10
  /**
@@ -251,20 +251,20 @@ describe("Origami parser", () => {
251
251
  assertParse("conditionalExpression", "true ? 1 : 0", [
252
252
  ops.conditional,
253
253
  [ops.scope, "true"],
254
- [ops.literal, "1"],
255
- [ops.literal, "0"],
254
+ [ops.literal, 1],
255
+ [ops.literal, 0],
256
256
  ]);
257
257
  assertParse("conditionalExpression", "false ? () => 1 : 0", [
258
258
  ops.conditional,
259
259
  [ops.scope, "false"],
260
- [ops.lambda, [], [ops.lambda, [], [ops.literal, "1"]]],
261
- [ops.literal, "0"],
260
+ [ops.lambda, [], [ops.lambda, [], [ops.literal, 1]]],
261
+ [ops.literal, 0],
262
262
  ]);
263
263
  assertParse("conditionalExpression", "false ? =1 : 0", [
264
264
  ops.conditional,
265
265
  [ops.scope, "false"],
266
- [ops.lambda, [], [ops.lambda, [[ops.literal, "_"]], [ops.literal, "1"]]],
267
- [ops.literal, "0"],
266
+ [ops.lambda, [], [ops.lambda, [[ops.literal, "_"]], [ops.literal, 1]]],
267
+ [ops.literal, 0],
268
268
  ]);
269
269
  });
270
270
 
@@ -286,7 +286,7 @@ describe("Origami parser", () => {
286
286
  ]);
287
287
  });
288
288
 
289
- test("errors for missing pieces", () => {
289
+ test("error thrown for missing token", () => {
290
290
  assertThrows("arrowFunction", "(a) => ", "Expected an expression");
291
291
  assertThrows("arrowFunction", "a ⇒ ", "Expected an expression");
292
292
  assertThrows("callExpression", "fn(a", "Expected right parenthesis");
@@ -298,6 +298,31 @@ describe("Origami parser", () => {
298
298
  assertThrows("templateLiteral", "`foo", "Expected closing backtick");
299
299
  });
300
300
 
301
+ test("error thrown for invalid Origami front matter expression", () => {
302
+ assertThrows(
303
+ "templateDocument",
304
+ `---
305
+ (name) => foo)
306
+ ---
307
+ Body`,
308
+ 'Expected "---"',
309
+ { line: 2, column: 14 }
310
+ );
311
+ });
312
+
313
+ test("error thrown for invalid YAML front matter", () => {
314
+ assertThrows(
315
+ "templateDocument",
316
+ `---
317
+ a : 1
318
+ }
319
+ ---
320
+ Body`,
321
+ "Unexpected flow-map-end token",
322
+ { line: 3, column: 1 }
323
+ );
324
+ });
325
+
301
326
  test("exponentiationExpression", () => {
302
327
  assertParse("exponentiationExpression", "2 ** 2 ** 3", [
303
328
  ops.exponentiation,
@@ -309,12 +334,10 @@ describe("Origami parser", () => {
309
334
  test("expression", () => {
310
335
  assertParse(
311
336
  "expression",
312
- `
313
- {
314
- index.html = index.ori(teamData.yaml)
315
- thumbnails = map(images, { value: thumbnail.js })
316
- }
317
- `,
337
+ `{
338
+ index.html = index.ori(teamData.yaml)
339
+ thumbnails = map(images, { value: thumbnail.js })
340
+ }`,
318
341
  [
319
342
  ops.object,
320
343
  [
@@ -423,11 +446,7 @@ describe("Origami parser", () => {
423
446
  [
424
447
  ops.lambda,
425
448
  [[ops.literal, "_"]],
426
- [
427
- ops.template,
428
- [ops.literal, ["<li>", "</li>"]],
429
- [ops.concat, [ops.scope, "_"]],
430
- ],
449
+ [ops.template, [ops.literal, ["<li>", "</li>"]], [ops.scope, "_"]],
431
450
  ],
432
451
  ]);
433
452
  assertParse("expression", `https://example.com/about/`, [
@@ -968,11 +987,7 @@ describe("Origami parser", () => {
968
987
  assertParse("shorthandFunction", "=`Hello, ${name}.`", [
969
988
  ops.lambda,
970
989
  [[ops.literal, "_"]],
971
- [
972
- ops.template,
973
- [ops.literal, ["Hello, ", "."]],
974
- [ops.concat, [ops.scope, "name"]],
975
- ],
990
+ [ops.template, [ops.literal, ["Hello, ", "."]], [ops.scope, "name"]],
976
991
  ]);
977
992
  assertParse("shorthandFunction", "=indent`hello`", [
978
993
  ops.lambda,
@@ -1007,17 +1022,17 @@ describe("Origami parser", () => {
1007
1022
  ]);
1008
1023
  });
1009
1024
 
1010
- test("templateDocument", () => {
1011
- assertParse("templateDocument", "hello${foo}world", [
1025
+ test("templateBody", () => {
1026
+ assertParse("templateBody", "hello${foo}world", [
1012
1027
  ops.lambda,
1013
1028
  [[ops.literal, "_"]],
1014
1029
  [
1015
1030
  ops.templateIndent,
1016
1031
  [ops.literal, ["hello", "world"]],
1017
- [ops.concat, [ops.scope, "foo"]],
1032
+ [ops.scope, "foo"],
1018
1033
  ],
1019
1034
  ]);
1020
- assertParse("templateDocument", "Documents can contain ` backticks", [
1035
+ assertParse("templateBody", "Documents can contain ` backticks", [
1021
1036
  ops.lambda,
1022
1037
  [[ops.literal, "_"]],
1023
1038
  [
@@ -1027,6 +1042,66 @@ describe("Origami parser", () => {
1027
1042
  ]);
1028
1043
  });
1029
1044
 
1045
+ test("templateDocument with no front matter", () => {
1046
+ assertParse("templateDocument", "Hello, world!", [
1047
+ ops.lambda,
1048
+ [[ops.literal, "_"]],
1049
+ [ops.templateIndent, [ops.literal, ["Hello, world!"]]],
1050
+ ]);
1051
+ });
1052
+
1053
+ test("templateDocument with YAML front matter", () => {
1054
+ assertParse(
1055
+ "templateDocument",
1056
+ `---
1057
+ title: Title goes here
1058
+ ---
1059
+ Body text`,
1060
+ [
1061
+ ops.document,
1062
+ [ops.literal, { title: "Title goes here" }],
1063
+ [
1064
+ ops.lambda,
1065
+ [[ops.literal, "_"]],
1066
+ [ops.templateIndent, [ops.literal, ["Body text"]]],
1067
+ ],
1068
+ ]
1069
+ );
1070
+ });
1071
+
1072
+ test("templateDocument with Origami front matter", () => {
1073
+ assertParse(
1074
+ "templateDocument",
1075
+ `---
1076
+ {
1077
+ title: "Title"
1078
+ @text: @template()
1079
+ }
1080
+ ---
1081
+ <h1>\${ title }</h1>
1082
+ `,
1083
+ [
1084
+ ops.object,
1085
+ ["title", [ops.literal, "Title"]],
1086
+ [
1087
+ "@text",
1088
+ [
1089
+ [
1090
+ ops.lambda,
1091
+ [[ops.literal, "_"]],
1092
+ [
1093
+ ops.templateIndent,
1094
+ [ops.literal, ["<h1>", "</h1>\n"]],
1095
+ [ops.scope, "title"],
1096
+ ],
1097
+ ],
1098
+ undefined,
1099
+ ],
1100
+ ],
1101
+ ]
1102
+ );
1103
+ });
1104
+
1030
1105
  test("templateLiteral", () => {
1031
1106
  assertParse("templateLiteral", "`Hello, world.`", [
1032
1107
  ops.template,
@@ -1035,30 +1110,23 @@ describe("Origami parser", () => {
1035
1110
  assertParse("templateLiteral", "`foo ${x} bar`", [
1036
1111
  ops.template,
1037
1112
  [ops.literal, ["foo ", " bar"]],
1038
- [ops.concat, [ops.scope, "x"]],
1113
+ [ops.scope, "x"],
1039
1114
  ]);
1040
1115
  assertParse("templateLiteral", "`${`nested`}`", [
1041
1116
  ops.template,
1042
1117
  [ops.literal, ["", ""]],
1043
- [ops.concat, [ops.template, [ops.literal, ["nested"]]]],
1118
+ [ops.template, [ops.literal, ["nested"]]],
1044
1119
  ]);
1045
1120
  assertParse("templateLiteral", "`${ map:(people, =`${name}`) }`", [
1046
1121
  ops.template,
1047
1122
  [ops.literal, ["", ""]],
1048
1123
  [
1049
- ops.concat,
1124
+ [ops.builtin, "map:"],
1125
+ [ops.scope, "people"],
1050
1126
  [
1051
- [ops.builtin, "map:"],
1052
- [ops.scope, "people"],
1053
- [
1054
- ops.lambda,
1055
- [[ops.literal, "_"]],
1056
- [
1057
- ops.template,
1058
- [ops.literal, ["", ""]],
1059
- [ops.concat, [ops.scope, "name"]],
1060
- ],
1061
- ],
1127
+ ops.lambda,
1128
+ [[ops.literal, "_"]],
1129
+ [ops.template, [ops.literal, ["", ""]], [ops.scope, "name"]],
1062
1130
  ],
1063
1131
  ],
1064
1132
  ]);
@@ -1106,7 +1174,7 @@ function assertParse(startRule, source, expected, checkLocation = true) {
1106
1174
  code.location.start.offset,
1107
1175
  code.location.end.offset
1108
1176
  );
1109
- assert.equal(resultSource, source.trim());
1177
+ assert.equal(resultSource, source);
1110
1178
  }
1111
1179
 
1112
1180
  assertCodeEqual(code, expected);
@@ -1121,7 +1189,7 @@ function assertCodeLocations(code) {
1121
1189
  }
1122
1190
  }
1123
1191
 
1124
- function assertThrows(startRule, source, message) {
1192
+ function assertThrows(startRule, source, message, position) {
1125
1193
  try {
1126
1194
  parse(source, {
1127
1195
  grammarSource: { text: source },
@@ -1132,6 +1200,10 @@ function assertThrows(startRule, source, message) {
1132
1200
  error.message.includes(message),
1133
1201
  `Error message incorrect:\n expected: "${message}"\n actual: "${error.message}"`
1134
1202
  );
1203
+ if (position) {
1204
+ assert.equal(error.location.start.line, position.line);
1205
+ assert.equal(error.location.start.column, position.column);
1206
+ }
1135
1207
  return;
1136
1208
  }
1137
1209
  assert.fail(`Expected error: ${message}`);
@@ -5,7 +5,7 @@ import { describe, test } from "node:test";
5
5
  import expressionObject from "../../src/runtime/expressionObject.js";
6
6
  import { ops } from "../../src/runtime/internal.js";
7
7
 
8
- describe.only("expressionObject", () => {
8
+ describe("expressionObject", () => {
9
9
  test("can instantiate an object", async () => {
10
10
  const scope = new ObjectTree({
11
11
  upper: (s) => s.toUpperCase(),
@@ -74,7 +74,7 @@ describe.only("expressionObject", () => {
74
74
  assert.equal(object["hidden"], "shh");
75
75
  });
76
76
 
77
- test.only("provides a symbols.keys method", async () => {
77
+ test("provides a symbols.keys method", async () => {
78
78
  const entries = [
79
79
  // Will return a tree, should have a slash
80
80
  ["getter", [ops.getter, [ops.object, ["b", [ops.literal, 2]]]]],
@@ -80,6 +80,29 @@ describe("ops", () => {
80
80
  assert.strictEqual(await ops.conditional(false, errorFn, trueFn), true);
81
81
  });
82
82
 
83
+ test("ops.documentFunction", async () => {
84
+ const code = createCode([
85
+ ops.document,
86
+ {
87
+ a: 1,
88
+ },
89
+ [
90
+ ops.lambda,
91
+ [["_"]],
92
+ [
93
+ ops.template,
94
+ [ops.literal, ["a = ", ""]],
95
+ [ops.concat, [ops.scope, "a"]],
96
+ ],
97
+ ],
98
+ ]);
99
+ const result = await evaluate.call(null, code);
100
+ assert.deepEqual(result, {
101
+ a: 1,
102
+ "@text": "a = 1",
103
+ });
104
+ });
105
+
83
106
  test("ops.division divides two numbers", async () => {
84
107
  assert.strictEqual(ops.division(12, 2), 6);
85
108
  assert.strictEqual(ops.division(3, 2), 1.5);