@weborigami/language 0.0.73 → 0.2.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.
Files changed (35) hide show
  1. package/index.ts +1 -0
  2. package/main.js +2 -2
  3. package/package.json +6 -4
  4. package/src/compiler/compile.js +42 -17
  5. package/src/compiler/origami.pegjs +248 -182
  6. package/src/compiler/parse.js +1569 -1231
  7. package/src/compiler/parserHelpers.js +180 -48
  8. package/src/runtime/HandleExtensionsTransform.js +1 -1
  9. package/src/runtime/ImportModulesMixin.js +1 -1
  10. package/src/runtime/codeFragment.js +2 -2
  11. package/src/runtime/errors.js +104 -0
  12. package/src/runtime/evaluate.js +3 -3
  13. package/src/runtime/expressionObject.js +8 -5
  14. package/src/runtime/{extensions.js → handlers.js} +6 -24
  15. package/src/runtime/internal.js +1 -0
  16. package/src/runtime/ops.js +156 -185
  17. package/src/runtime/typos.js +71 -0
  18. package/test/cases/ReadMe.md +1 -0
  19. package/test/cases/conditionalExpression.yaml +101 -0
  20. package/test/cases/logicalAndExpression.yaml +146 -0
  21. package/test/cases/logicalOrExpression.yaml +145 -0
  22. package/test/cases/nullishCoalescingExpression.yaml +105 -0
  23. package/test/compiler/compile.test.js +7 -7
  24. package/test/compiler/parse.test.js +506 -294
  25. package/test/generated/conditionalExpression.test.js +58 -0
  26. package/test/generated/logicalAndExpression.test.js +80 -0
  27. package/test/generated/logicalOrExpression.test.js +78 -0
  28. package/test/generated/nullishCoalescingExpression.test.js +64 -0
  29. package/test/generator/generateTests.js +80 -0
  30. package/test/generator/oriEval.js +15 -0
  31. package/test/runtime/fixtures/templates/greet.orit +1 -1
  32. package/test/runtime/{extensions.test.js → handlers.test.js} +2 -2
  33. package/test/runtime/ops.test.js +129 -26
  34. package/test/runtime/typos.test.js +21 -0
  35. package/src/runtime/formatError.js +0 -56
@@ -1,45 +1,81 @@
1
1
  import { trailingSlash } from "@weborigami/async-tree";
2
+ import codeFragment from "../runtime/codeFragment.js";
2
3
  import * as ops from "../runtime/ops.js";
3
4
 
4
5
  // Parser helpers
5
6
 
7
+ /** @typedef {import("../../index.ts").Code} Code */
8
+
9
+ // Marker for a reference that may be a builtin or a scope reference
10
+ export const undetermined = Symbol("undetermined");
11
+
12
+ const builtinRegex = /^[A-Za-z][A-Za-z0-9]*$/;
13
+
6
14
  /**
7
15
  * If a parse result is an object that will be evaluated at runtime, attach the
8
16
  * location of the source code that produced it for debugging and error messages.
17
+ *
18
+ * @param {Code} code
19
+ * @param {any} location
9
20
  */
10
- export function annotate(parseResult, location) {
11
- if (typeof parseResult === "object" && parseResult !== null) {
12
- parseResult.location = location;
21
+ export function annotate(code, location) {
22
+ if (typeof code === "object" && code !== null && location) {
23
+ code.location = location;
24
+ code.source = codeFragment(location);
13
25
  }
14
- return parseResult;
26
+ return code;
15
27
  }
16
28
 
17
- // The indicated code is being used to define a property named by the given key.
18
- // Rewrite any [ops.scope, key] calls to be [ops.inherited, key] to avoid
19
- // infinite recursion.
29
+ /**
30
+ * The indicated code is being used to define a property named by the given key.
31
+ * Rewrite any [ops.scope, key] calls to be [ops.inherited, key] to avoid
32
+ * infinite recursion.
33
+ *
34
+ * @param {Code} code
35
+ * @param {string} key
36
+ */
20
37
  function avoidRecursivePropertyCalls(code, key) {
21
38
  if (!(code instanceof Array)) {
22
39
  return code;
23
40
  }
41
+ /** @type {Code} */
24
42
  let modified;
25
43
  if (
26
44
  code[0] === ops.scope &&
27
45
  trailingSlash.remove(code[1]) === trailingSlash.remove(key)
28
46
  ) {
29
47
  // Rewrite to avoid recursion
48
+ // @ts-ignore
30
49
  modified = [ops.inherited, code[1]];
31
50
  } else if (code[0] === ops.lambda && code[1].includes(key)) {
32
51
  // Lambda that defines the key; don't rewrite
33
52
  return code;
34
53
  } else {
35
54
  // Process any nested code
55
+ // @ts-ignore
36
56
  modified = code.map((value) => avoidRecursivePropertyCalls(value, key));
37
57
  }
38
- // @ts-ignore
39
- modified.location = code.location;
58
+ annotate(modified, code.location);
40
59
  return modified;
41
60
  }
42
61
 
62
+ /**
63
+ * Downgrade a potential builtin reference to a scope reference.
64
+ *
65
+ * @param {Code} code
66
+ */
67
+ export function downgradeReference(code) {
68
+ if (code && code.length === 2 && code[0] === undetermined) {
69
+ /** @type {Code} */
70
+ // @ts-ignore
71
+ const result = [ops.scope, code[1]];
72
+ annotate(result, code.location);
73
+ return result;
74
+ } else {
75
+ return code;
76
+ }
77
+ }
78
+
43
79
  // Return true if the code will generate an async object.
44
80
  function isCodeForAsyncObject(code) {
45
81
  if (!(code instanceof Array)) {
@@ -89,56 +125,111 @@ export function makeArray(entries) {
89
125
  }
90
126
 
91
127
  /**
92
- * @typedef {import("../../index.ts").Code} Code
128
+ * Create a chain of binary operators. The head is the first value, and the tail
129
+ * is an array of [operator, value] pairs.
93
130
  *
131
+ * @param {Code} head
132
+ * @param {[any, Code][]} tail
133
+ */
134
+ export function makeBinaryOperatorChain(head, tail) {
135
+ /** @type {Code} */
136
+ let value = head;
137
+ for (const [operatorToken, right] of tail) {
138
+ const left = value;
139
+ const operators = {
140
+ "===": ops.strictEqual,
141
+ "!==": ops.notStrictEqual,
142
+ "==": ops.equal,
143
+ "!=": ops.notEqual,
144
+ };
145
+ const op = operators[operatorToken];
146
+ // @ts-ignore
147
+ value = [op, left, right];
148
+ value.location = {
149
+ source: left.location.source,
150
+ start: left.location.start,
151
+ end: right.location.end,
152
+ };
153
+ }
154
+ return value;
155
+ }
156
+
157
+ /**
94
158
  * @param {Code} target
95
- * @param {Code[]} chain
96
- * @returns
159
+ * @param {any[]} args
97
160
  */
98
- export function makeFunctionCall(target, chain, location) {
161
+ export function makeCall(target, args) {
99
162
  if (!(target instanceof Array)) {
100
163
  const error = new SyntaxError(`Can't call this like a function: ${target}`);
101
- /** @type {any} */ (error).location = location;
164
+ /** @type {any} */ (error).location = /** @type {any} */ (target).location;
102
165
  throw error;
103
166
  }
104
167
 
105
- let value = target;
106
168
  const source = target.location.source;
107
- // The chain is an array of arguments (which are themselves arrays). We
108
- // successively apply the top-level elements of that chain to build up the
109
- // function composition.
110
169
  let start = target.location.start;
111
170
  let end = target.location.end;
112
- for (const args of chain) {
113
- /** @type {Code} */
114
- let fnCall;
115
171
 
116
- // @ts-ignore
117
- fnCall =
118
- args[0] !== ops.traverse
119
- ? // Function call
120
- [value, ...args]
121
- : args.length > 1
122
- ? // Traverse
123
- [ops.traverse, value, ...args.slice(1)]
124
- : // Traverse without arguments equates to unpack
125
- [ops.unpack, value];
126
-
127
- // Create a location spanning the newly-constructed function call.
128
- if (args instanceof Array) {
129
- if (args.location) {
130
- end = args.location.end;
131
- } else {
132
- throw "Internal parser error: no location for function call argument";
172
+ let fnCall;
173
+ if (args[0] === ops.traverse) {
174
+ let tree = target;
175
+
176
+ if (tree[0] === undetermined) {
177
+ // In a traversal, downgrade ops.builtin references to ops.scope
178
+ tree = downgradeReference(tree);
179
+ if (tree[0] === ops.scope && !trailingSlash.has(tree[1])) {
180
+ // Target didn't parse with a trailing slash; add one
181
+ tree[1] = trailingSlash.add(tree[1]);
133
182
  }
134
183
  }
135
184
 
136
- fnCall.location = { start, source, end };
185
+ if (args.length > 1) {
186
+ // Regular traverse
187
+ const keys = args.slice(1);
188
+ fnCall = [ops.traverse, tree, ...keys];
189
+ } else {
190
+ // Traverse without arguments equates to unpack
191
+ fnCall = [ops.unpack, tree];
192
+ }
193
+ } else if (args[0] === ops.template) {
194
+ // Tagged template
195
+ fnCall = [upgradeReference(target), ...args.slice(1)];
196
+ } else {
197
+ // Function call with explicit or implicit parentheses
198
+ fnCall = [upgradeReference(target), ...args];
199
+ }
137
200
 
138
- value = fnCall;
201
+ // Create a location spanning the newly-constructed function call.
202
+ if (args instanceof Array) {
203
+ // @ts-ignore
204
+ end = args.location?.end ?? args.at(-1)?.location?.end;
205
+ if (end === undefined) {
206
+ throw "Internal parser error: no location for function call argument";
207
+ }
139
208
  }
140
209
 
141
- return value;
210
+ // @ts-ignore
211
+ annotate(fnCall, { start, source, end });
212
+
213
+ return fnCall;
214
+ }
215
+
216
+ /**
217
+ * For functions that short-circuit arguments, we need to defer evaluation of
218
+ * the arguments until the function is called. Exception: if the argument is a
219
+ * literal, we leave it alone.
220
+ *
221
+ * @param {any[]} args
222
+ */
223
+ export function makeDeferredArguments(args) {
224
+ return args.map((arg) => {
225
+ if (arg instanceof Array && arg[0] === ops.literal) {
226
+ return arg;
227
+ }
228
+ const fn = [ops.lambda, [], arg];
229
+ // @ts-ignore
230
+ annotate(fn, arg.location);
231
+ return fn;
232
+ });
142
233
  }
143
234
 
144
235
  export function makeObject(entries, op) {
@@ -189,13 +280,15 @@ export function makeObject(entries, op) {
189
280
  }
190
281
 
191
282
  // Similar to a function call, but the order is reversed.
192
- export function makePipeline(steps) {
193
- const [first, ...rest] = steps;
194
- let value = first;
195
- for (const args of rest) {
196
- value = [args, value];
197
- }
198
- return value;
283
+ export function makePipeline(arg, fn) {
284
+ const upgraded = upgradeReference(fn);
285
+ const result = makeCall(upgraded, [arg]);
286
+ const source = fn.location.source;
287
+ let start = arg.location.start;
288
+ let end = fn.location.end;
289
+ // @ts-ignore
290
+ annotate(result, { start, source, end });
291
+ return result;
199
292
  }
200
293
 
201
294
  // Define a property on an object.
@@ -204,6 +297,21 @@ export function makeProperty(key, value) {
204
297
  return [key, modified];
205
298
  }
206
299
 
300
+ export function makeReference(identifier) {
301
+ // We can't know for sure that an identifier is a builtin reference until we
302
+ // see whether it's being called as a function.
303
+ let op;
304
+ if (builtinRegex.test(identifier)) {
305
+ op = identifier.endsWith(":")
306
+ ? // Namespace is always a builtin reference
307
+ ops.builtin
308
+ : undetermined;
309
+ } else {
310
+ op = ops.scope;
311
+ }
312
+ return [op, identifier];
313
+ }
314
+
207
315
  export function makeTemplate(op, head, tail) {
208
316
  const strings = [head];
209
317
  const values = [];
@@ -213,3 +321,27 @@ export function makeTemplate(op, head, tail) {
213
321
  }
214
322
  return [op, [ops.literal, strings], ...values];
215
323
  }
324
+
325
+ export function makeUnaryOperatorCall(operator, value) {
326
+ const operators = {
327
+ "!": ops.logicalNot,
328
+ };
329
+ return [operators[operator], value];
330
+ }
331
+
332
+ /**
333
+ * Upgrade a potential builtin reference to an actual builtin reference.
334
+ *
335
+ * @param {Code} code
336
+ */
337
+ export function upgradeReference(code) {
338
+ if (code.length === 2 && code[0] === undetermined) {
339
+ /** @type {Code} */
340
+ // @ts-ignore
341
+ const result = [ops.builtin, code[1]];
342
+ annotate(result, code.location);
343
+ return result;
344
+ } else {
345
+ return code;
346
+ }
347
+ }
@@ -1,4 +1,4 @@
1
- import { handleExtension } from "./extensions.js";
1
+ import { handleExtension } from "./handlers.js";
2
2
 
3
3
  /**
4
4
  * @typedef {import("@weborigami/types").AsyncTree} AsyncTree
@@ -1,7 +1,7 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { pathToFileURL } from "node:url";
4
- import { maybeOrigamiSourceCode } from "./formatError.js";
4
+ import { maybeOrigamiSourceCode } from "./errors.js";
5
5
 
6
6
  /**
7
7
  * @typedef {import("@weborigami/types").AsyncTree} AsyncTree
@@ -7,8 +7,8 @@ export default function codeFragment(location) {
7
7
  : // Use entire source
8
8
  source.text;
9
9
 
10
- // Remove newlines and whitespace runs.
11
- fragment = fragment.replace(/(\n|\s\s+)+/g, "");
10
+ // Replace newlines and whitespace runs with a single space.
11
+ fragment = fragment.replace(/(\n|\s\s+)+/g, " ");
12
12
 
13
13
  // If longer than 80 characters, truncate with an ellipsis.
14
14
  if (fragment.length > 80) {
@@ -0,0 +1,104 @@
1
+ // Text we look for in an error stack to guess whether a given line represents a
2
+
3
+ import { scope as scopeFn, trailingSlash } from "@weborigami/async-tree";
4
+ import codeFragment from "./codeFragment.js";
5
+ import { typos } from "./typos.js";
6
+
7
+ // function in the Origami source code.
8
+ const origamiSourceSignals = [
9
+ "async-tree/src/",
10
+ "language/src/",
11
+ "origami/src/",
12
+ "at Scope.evaluate",
13
+ ];
14
+
15
+ export async function builtinReferenceError(tree, builtins, key) {
16
+ const messages = [
17
+ `"${key}" is being called as if it were a builtin function, but it's not.`,
18
+ ];
19
+ // See if the key is in scope (but not as a builtin)
20
+ const scope = scopeFn(tree);
21
+ const value = await scope.get(key);
22
+ if (value === undefined) {
23
+ const typos = await formatScopeTypos(builtins, key);
24
+ messages.push(typos);
25
+ } else {
26
+ messages.push(`Use "${key}/" instead.`);
27
+ }
28
+ const message = messages.join(" ");
29
+ return new ReferenceError(message);
30
+ }
31
+
32
+ /**
33
+ * Format an error for display in the console.
34
+ *
35
+ * @param {Error} error
36
+ */
37
+ export function formatError(error) {
38
+ let message;
39
+ if (error.stack) {
40
+ // Display the stack only until we reach the Origami source code.
41
+ message = "";
42
+ let lines = error.stack.split("\n");
43
+ for (let i = 0; i < lines.length; i++) {
44
+ const line = lines[i];
45
+ if (maybeOrigamiSourceCode(line)) {
46
+ break;
47
+ }
48
+ if (message) {
49
+ message += "\n";
50
+ }
51
+ message += lines[i];
52
+ }
53
+ } else {
54
+ message = error.toString();
55
+ }
56
+
57
+ // Add location
58
+ let location = /** @type {any} */ (error).location;
59
+ if (location) {
60
+ const fragment = codeFragment(location);
61
+ let { source, start } = location;
62
+
63
+ message += `\nevaluating: ${fragment}`;
64
+ if (typeof source === "object" && source.url) {
65
+ message += `\n at ${source.url.href}:${start.line}:${start.column}`;
66
+ } else if (source.text.includes("\n")) {
67
+ message += `\n at line ${start.line}, column ${start.column}`;
68
+ }
69
+ }
70
+ return message;
71
+ }
72
+
73
+ export async function formatScopeTypos(scope, key) {
74
+ const keys = await scopeTypos(scope, key);
75
+ // Don't match deprecated keys
76
+ const filtered = keys.filter((key) => !key.startsWith("@"));
77
+ if (filtered.length === 0) {
78
+ return "";
79
+ }
80
+ const quoted = filtered.map((key) => `"${key}"`);
81
+ const list = quoted.join(", ");
82
+ return `Maybe you meant ${list}?`;
83
+ }
84
+
85
+ export function maybeOrigamiSourceCode(text) {
86
+ return origamiSourceSignals.some((signal) => text.includes(signal));
87
+ }
88
+
89
+ export async function scopeReferenceError(scope, key) {
90
+ const messages = [
91
+ `"${key}" is not in scope.`,
92
+ await formatScopeTypos(scope, key),
93
+ ];
94
+ const message = messages.join(" ");
95
+ return new ReferenceError(message);
96
+ }
97
+
98
+ // Return all possible typos for `key` in scope
99
+ async function scopeTypos(scope, key) {
100
+ const scopeKeys = [...(await scope.keys())];
101
+ const normalizedScopeKeys = scopeKeys.map((key) => trailingSlash.remove(key));
102
+ const normalizedKey = trailingSlash.remove(key);
103
+ return typos(normalizedKey, normalizedScopeKeys);
104
+ }
@@ -68,10 +68,10 @@ export default async function evaluate(code) {
68
68
  if (!error.location) {
69
69
  // Attach the location of the code we tried to evaluate.
70
70
  error.location =
71
- error.position !== undefined
71
+ error.position !== undefined && code[error.position + 1]?.location
72
72
  ? // Use location of the argument with the given position (need to
73
73
  // offset by 1 to skip the function).
74
- code[error.position + 1].location
74
+ code[error.position + 1]?.location
75
75
  : // Use overall location.
76
76
  code.location;
77
77
  }
@@ -85,7 +85,7 @@ export default async function evaluate(code) {
85
85
  }
86
86
 
87
87
  // To aid debugging, add the code to the result.
88
- if (Object.isExtensible(result) /* && !isPlainObject(result) */) {
88
+ if (Object.isExtensible(result)) {
89
89
  try {
90
90
  if (code.location && !result[sourceSymbol]) {
91
91
  Object.defineProperty(result, sourceSymbol, {
@@ -1,5 +1,5 @@
1
- import { ObjectTree, symbols } from "@weborigami/async-tree";
2
- import { extname, handleExtension } from "./extensions.js";
1
+ import { extension, ObjectTree, symbols, Tree } from "@weborigami/async-tree";
2
+ import { handleExtension } from "./handlers.js";
3
3
  import { evaluate, ops } from "./internal.js";
4
4
 
5
5
  /**
@@ -22,6 +22,9 @@ import { evaluate, ops } from "./internal.js";
22
22
  export default async function expressionObject(entries, parent) {
23
23
  // Create the object and set its parent
24
24
  const object = {};
25
+ if (parent !== null && !Tree.isAsyncTree(parent)) {
26
+ throw new TypeError(`Parent must be an AsyncTree or null`);
27
+ }
25
28
  Object.defineProperty(object, symbols.parent, {
26
29
  configurable: true,
27
30
  enumerable: false,
@@ -37,8 +40,8 @@ export default async function expressionObject(entries, parent) {
37
40
  // array), we need to define a getter -- but if that code takes the form
38
41
  // [ops.getter, <primitive>], we can define a regular property.
39
42
  let defineProperty;
40
- const extension = extname(key);
41
- if (extension) {
43
+ const extname = extension.extname(key);
44
+ if (extname) {
42
45
  defineProperty = false;
43
46
  } else if (!(value instanceof Array)) {
44
47
  defineProperty = true;
@@ -76,7 +79,7 @@ export default async function expressionObject(entries, parent) {
76
79
  }
77
80
 
78
81
  let get;
79
- if (extension) {
82
+ if (extname) {
80
83
  // Key has extension, getter will invoke code then attach unpack method
81
84
  get = async () => {
82
85
  tree ??= new ObjectTree(object);
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  box,
3
+ extension,
3
4
  isPacked,
4
5
  isStringLike,
5
6
  isUnpackable,
@@ -13,27 +14,6 @@ import {
13
14
  // Track extensions handlers for a given containing tree.
14
15
  const handlersForContainer = new Map();
15
16
 
16
- /**
17
- * If the given path ends in an extension, return it. Otherwise, return the
18
- * empty string.
19
- *
20
- * This is meant as a basic replacement for the standard Node `path.extname`.
21
- * That standard function inaccurately returns an extension for a path that
22
- * includes a near-final extension but ends in a final slash, like `foo.txt/`.
23
- * Node thinks that path has a ".txt" extension, but for our purposes it
24
- * doesn't.
25
- *
26
- * @param {string} path
27
- */
28
- export function extname(path) {
29
- // We want at least one character before the dot, then a dot, then a non-empty
30
- // sequence of characters after the dot that aren't slahes or dots.
31
- const extnameRegex = /[^/](?<ext>\.[^/\.]+)$/;
32
- const match = String(path).match(extnameRegex);
33
- const extension = match?.groups?.ext.toLowerCase() ?? "";
34
- return extension;
35
- }
36
-
37
17
  /**
38
18
  * Find an extension handler for a file in the given container.
39
19
  *
@@ -95,9 +75,11 @@ export async function handleExtension(parent, value, key) {
95
75
  }
96
76
 
97
77
  // Special case: `.ori.<ext>` extensions are Origami documents.
98
- const extension = key.match(/\.ori\.\S+$/) ? ".oridocument" : extname(key);
99
- if (extension) {
100
- const handler = await getExtensionHandler(parent, extension);
78
+ const extname = key.match(/\.ori\.\S+$/)
79
+ ? ".oridocument"
80
+ : extension.extname(key);
81
+ if (extname) {
82
+ const handler = await getExtensionHandler(parent, extname);
101
83
  if (handler) {
102
84
  if (hasSlash && handler.unpack) {
103
85
  // Key like `data.json/` ends in slash -- unpack immediately
@@ -6,6 +6,7 @@
6
6
  //
7
7
  // About this pattern: https://medium.com/visual-development/how-to-fix-nasty-circular-dependency-issues-once-and-for-all-in-javascript-typescript-a04c987cf0de
8
8
  //
9
+ // Note: to avoid having VS Code auto-sort the imports, keep lines between them.
9
10
 
10
11
  export * as ops from "./ops.js";
11
12