@weborigami/language 0.6.9 → 0.6.10

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 (39) hide show
  1. package/index.ts +19 -4
  2. package/main.js +5 -2
  3. package/package.json +2 -2
  4. package/src/compiler/compile.js +11 -3
  5. package/src/compiler/origami.pegjs +4 -2
  6. package/src/compiler/parse.js +77 -68
  7. package/src/compiler/parserHelpers.js +13 -13
  8. package/src/handlers/getPackedPath.js +17 -0
  9. package/src/handlers/jpeg_handler.js +5 -0
  10. package/src/handlers/js_handler.js +3 -3
  11. package/src/handlers/json_handler.js +3 -1
  12. package/src/handlers/tsv_handler.js +1 -1
  13. package/src/handlers/yaml_handler.js +1 -1
  14. package/src/project/jsGlobals.js +3 -3
  15. package/src/protocols/package.js +3 -3
  16. package/src/runtime/asyncStorage.js +7 -0
  17. package/src/runtime/codeFragment.js +4 -3
  18. package/src/runtime/errors.js +82 -129
  19. package/src/runtime/evaluate.js +8 -77
  20. package/src/runtime/execute.js +82 -0
  21. package/src/runtime/explainReferenceError.js +248 -0
  22. package/src/runtime/explainTraverseError.js +77 -0
  23. package/src/runtime/expressionFunction.js +8 -7
  24. package/src/runtime/expressionObject.js +4 -3
  25. package/src/runtime/handleExtension.js +22 -8
  26. package/src/runtime/internal.js +1 -1
  27. package/src/runtime/interop.js +15 -0
  28. package/src/runtime/ops.js +24 -19
  29. package/src/runtime/symbols.js +0 -1
  30. package/src/runtime/typos.js +22 -3
  31. package/test/compiler/compile.test.js +7 -103
  32. package/test/compiler/parse.test.js +38 -31
  33. package/test/project/fixtures/withPackageJson/subfolder/README.md +1 -0
  34. package/test/runtime/errors.test.js +296 -0
  35. package/test/runtime/evaluate.test.js +110 -34
  36. package/test/runtime/execute.test.js +41 -0
  37. package/test/runtime/expressionObject.test.js +3 -3
  38. package/test/runtime/ops.test.js +36 -35
  39. package/test/runtime/typos.test.js +2 -0
@@ -12,7 +12,7 @@ export default {
12
12
  const { key, parent } = options;
13
13
  if (!(parent && "import" in parent)) {
14
14
  throw new TypeError(
15
- "The parent tree must support importing modules to unpack JavaScript files."
15
+ "The parent tree must support importing modules to unpack JavaScript files.",
16
16
  );
17
17
  }
18
18
 
@@ -35,8 +35,8 @@ export default {
35
35
  };
36
36
 
37
37
  // If the value is a function, bind it to the parent so that the function can,
38
- // e.g., find local files. Note: evaluate() supports a related but separate
39
- // mechanism called `containerAsTarget`. We want to use binding here so that, if
38
+ // e.g., find local files. Note: execute() supports a related but separate
39
+ // mechanism called `parentAsTarget`. We want to use binding here so that, if
40
40
  // a function is handed to another to be called later, it still has the correct
41
41
  // `this`.
42
42
  function bindToParent(value, parent) {
@@ -12,9 +12,11 @@ export default {
12
12
  unpack(packed) {
13
13
  const json = toString(packed);
14
14
  if (!json) {
15
- throw new Error("Tried to parse something as JSON but it wasn't text.");
15
+ throw new Error("JSON handler can only unpack text.");
16
16
  }
17
+
17
18
  const data = JSON.parse(json);
19
+
18
20
  if (data && typeof data === "object" && Object.isExtensible(data)) {
19
21
  Object.defineProperty(data, symbols.deep, {
20
22
  enumerable: false,
@@ -7,7 +7,7 @@ export default {
7
7
  const parent = options.parent ?? null;
8
8
  const text = toString(packed);
9
9
  if (text === null) {
10
- throw new TypeError(".tsv handler can only unpack text");
10
+ throw new TypeError("TSV handler can only unpack text.");
11
11
  }
12
12
  const data = tsvParse(text);
13
13
  // Define `parent` as non-enumerable property
@@ -39,7 +39,7 @@ export default {
39
39
  async unpack(packed, options = {}) {
40
40
  const yaml = toString(packed);
41
41
  if (!yaml) {
42
- throw new Error("Tried to parse something as YAML but it wasn't text.");
42
+ throw new Error("YAML handler can only unpack text.");
43
43
  }
44
44
  const parent = getParent(packed, options);
45
45
  const oriCallTag = await oriCallTagForParent(parent, options, yaml);
@@ -162,7 +162,7 @@ Object.defineProperty(globals, "globalThis", {
162
162
 
163
163
  async function fetchWrapper(resource, options) {
164
164
  console.warn(
165
- "Warning: A plain `fetch` reference will eventually call the standard JavaScript fetch() function. For Origami's fetch behavior, update your code to call Origami.fetch()."
165
+ "Warning: A plain `fetch` reference will eventually call the standard JavaScript fetch() function. For Origami's fetch behavior, update your code to call Origami.fetch().",
166
166
  );
167
167
  const response = await fetch(resource, options);
168
168
  return response.ok ? await response.arrayBuffer() : undefined;
@@ -182,12 +182,12 @@ async function importWrapper(modulePath, options = {}) {
182
182
  }
183
183
  if (!current) {
184
184
  throw new TypeError(
185
- "Modules can only be imported from a folder or other object with a path property."
185
+ "Modules can only be imported from a folder or other object with a path property.",
186
186
  );
187
187
  }
188
188
  const filePath = path.resolve(current.path, modulePath);
189
189
  return import(filePath, options);
190
190
  }
191
- importWrapper.containerAsTarget = true;
191
+ importWrapper.parentAsTarget = true;
192
192
 
193
193
  export default globals;
@@ -27,11 +27,11 @@ export default async function packageProtocol(...args) {
27
27
 
28
28
  // Identify the main entry point
29
29
  const mainPath = await Tree.traverse(packageRoot, "package.json", "main");
30
- if (!mainPath) {
30
+ if (mainPath === undefined) {
31
31
  throw new Error(
32
32
  `${packageRootPath.join(
33
- "/"
34
- )} doesn't contain a package.json with a "main" entry.`
33
+ "/",
34
+ )} doesn't contain a package.json with a "main" entry.`,
35
35
  );
36
36
  }
37
37
 
@@ -0,0 +1,7 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+
3
+ /**
4
+ * Storage maintained by the execute() function and accessible to async
5
+ * functions called during evaluation.
6
+ */
7
+ export default new AsyncLocalStorage();
@@ -1,11 +1,12 @@
1
1
  export default function codeFragment(location) {
2
2
  const { source, start, end } = location;
3
+ const sourceText = source.text ?? source;
3
4
 
4
5
  let fragment =
5
- start.offset < end.offset
6
- ? source.text.slice(start.offset, end.offset)
6
+ start && end && start.offset < end.offset
7
+ ? sourceText.slice(start.offset, end.offset)
7
8
  : // Use entire source
8
- source.text;
9
+ sourceText;
9
10
 
10
11
  // Replace newlines and whitespace runs with a single space.
11
12
  fragment = fragment.replace(/(\n|\s\s+)+/g, " ");
@@ -1,148 +1,111 @@
1
- // Text we look for in an error stack to guess whether a given line represents a
2
-
3
- import {
4
- box,
5
- trailingSlash,
6
- TraverseError,
7
- Tree,
8
- } from "@weborigami/async-tree";
1
+ import { TraverseError } from "@weborigami/async-tree";
9
2
  import path from "node:path";
10
3
  import { fileURLToPath } from "node:url";
11
4
  import codeFragment from "./codeFragment.js";
12
- import * as symbols from "./symbols.js";
13
- import { typos } from "./typos.js";
5
+ import explainReferenceError from "./explainReferenceError.js";
6
+ import explainTraverseError from "./explainTraverseError.js";
14
7
 
8
+ // Text we look for in an error stack to guess whether a given line represents a
15
9
  // function in the Origami source code.
16
10
  const origamiSourceSignals = [
17
11
  "async-tree/src/",
18
12
  "language/src/",
19
13
  "origami/src/",
20
- "at Scope.evaluate",
14
+ "at Scope.execute",
21
15
  ];
22
16
 
23
- const displayedWarnings = new Set();
24
-
25
- export function attachWarning(value, message) {
26
- if (value == null) {
27
- return value;
28
- }
29
-
30
- if (typeof value === "object" && value?.[symbols.warningSymbol]) {
31
- // Already has a warning, don't overwrite it
32
- return value;
33
- }
34
-
35
- const boxed = box(value);
36
- boxed[symbols.warningSymbol] = message;
37
- return boxed;
38
- }
39
-
40
- export async function builtinReferenceError(tree, builtins, key) {
41
- // See if the key is in scope (but not as a builtin)
42
- const scope = await Tree.scope(tree);
43
- const value = await scope.get(key);
44
- let message;
45
- if (value === undefined) {
46
- const messages = [
47
- `"${key}" is being called as if it were a builtin function, but it's not.`,
48
- ];
49
- const typos = await formatScopeTypos(builtins, key);
50
- messages.push(typos);
51
- message = messages.join(" ");
52
- } else {
53
- const messages = [
54
- `To call a function like "${key}" that's not a builtin, include a slash: ${key}/( )`,
55
- `Details: https://weborigami.org/language/syntax.html#shorthand-for-builtin-functions`,
56
- ];
57
- message = messages.join("\n");
58
- }
59
- return new ReferenceError(message);
60
- }
61
-
62
- // Display a warning message in the console, but only once for each unique
63
- // message and location.
64
- export function displayWarning(message, location) {
65
- const warning = "Warning: " + message + lineInfo(location);
66
- if (!displayedWarnings.has(warning)) {
67
- displayedWarnings.add(warning);
68
- console.warn(warning);
69
- }
70
- }
71
-
72
17
  /**
73
18
  * Format an error for display in the console.
74
19
  *
75
20
  * @param {Error} error
76
21
  */
77
- export function formatError(error) {
78
- let message;
79
-
80
- let location = /** @type {any} */ (error).location;
81
- const fragment = location ? codeFragment(location) : null;
82
- let fragmentInMessage = false;
83
-
84
- if (error.stack) {
85
- // Display the stack only until we reach the Origami source code.
86
- message = "";
87
- let lines = error.stack.split("\n");
88
- for (let i = 0; i < lines.length; i++) {
89
- let line = lines[i];
90
- if (maybeOrigamiSourceCode(line)) {
91
- break;
92
- }
93
- if (
94
- error instanceof TraverseError &&
95
- error.message === "A null or undefined value can't be traversed"
96
- ) {
97
- // Provide more meaningful message for TraverseError
98
- line = `TraverseError: This part of the path is null or undefined: ${highlightError(
99
- fragment
100
- )}`;
101
- fragmentInMessage = true;
22
+ export async function formatError(error) {
23
+ // Get the original error message
24
+ let originalMessage;
25
+ // If the first line of the stack is just the error message, use that as the message
26
+ let lines = error.stack?.split("\n") ?? [];
27
+ if (!lines[0].startsWith(" at")) {
28
+ originalMessage = lines[0];
29
+ lines.shift();
30
+ } else {
31
+ originalMessage = error.message ?? error.toString();
32
+ }
33
+ let message = originalMessage;
34
+
35
+ // See if we can identify the Origami location that caused the error
36
+ let location;
37
+ const context = /** @type {any} */ (error).context;
38
+ let code = context?.code;
39
+ if (code) {
40
+ // Use the code being evaluated when the error occurred
41
+ let position = /** @type {any} */ (error).position;
42
+ const argCode =
43
+ position !== undefined ? context.code[position] : context.code;
44
+ if (argCode instanceof Array) {
45
+ code = argCode;
46
+ location = /** @type {any} */ (argCode).location;
47
+ }
48
+ }
49
+ const fragment = location ? codeFragment(location) : (code?.source ?? code);
50
+
51
+ // See if we can explain the error message
52
+ try {
53
+ if (error instanceof ReferenceError && code && context) {
54
+ const explanation = await explainReferenceError(code, context.state);
55
+ if (explanation) {
56
+ message += "\n" + explanation;
102
57
  }
103
- if (message) {
104
- message += "\n";
58
+ } else if (error instanceof TraverseError) {
59
+ const explanation = await explainTraverseError(error);
60
+ if (explanation) {
61
+ message += "\n" + explanation;
105
62
  }
106
- message += line;
107
63
  }
108
- } else {
109
- message = error.toString();
64
+ } catch (internalError) {
65
+ // Ignore; won't modify the message
66
+ }
67
+
68
+ // If the error's `message` starts with a qualified method name like `Tree.map`
69
+ // and a colon, extract the method name and link to the docs.
70
+ const match = error.message?.match(/^(?<namespace>\w+).(?<method>\w+):/);
71
+ if (match) {
72
+ /** @type {any} */
73
+ const { namespace, method } = match.groups;
74
+ if (["Dev", "Origami", "Tree"].includes(namespace)) {
75
+ message += `\nFor documentation, see https://weborigami.org/builtins/${namespace}/${method}`;
76
+ }
110
77
  }
111
78
 
79
+ // If the error has a stack trace, only include the portion until we reach
80
+ // Origami source code.
81
+ for (let i = 0; i < lines.length; i++) {
82
+ const line = lines[i];
83
+ if (maybeOrigamiSourceCode(line)) {
84
+ break;
85
+ }
86
+ message += "\n" + line;
87
+ }
88
+
89
+ message += `\nevaluating: ${highlightError(fragment)}`;
90
+
112
91
  // Add location
113
92
  if (location) {
114
- if (!fragmentInMessage) {
115
- message += `\nevaluating: ${highlightError(fragment)}`;
93
+ const lineInformation = lineInfo(location);
94
+ if (lineInformation) {
95
+ message += "\n" + lineInformation;
116
96
  }
117
- message += lineInfo(location);
118
97
  }
119
98
 
120
99
  return message;
121
100
  }
122
101
 
123
- export async function formatScopeTypos(scope, key) {
124
- const keys = await scopeTypos(scope, key);
125
- // Don't match deprecated keys
126
- const filtered = keys.filter((key) => !key.startsWith("@"));
127
- if (filtered.length === 0) {
128
- return "";
129
- }
130
- const quoted = filtered.map((key) => `"${key}"`);
131
- const list = quoted.join(", ");
132
- return `Maybe you meant ${list}?`;
133
- }
134
-
135
102
  export function highlightError(text) {
136
103
  // ANSI escape sequence to highlight text in red
137
104
  return `\x1b[31m${text}\x1b[0m`;
138
105
  }
139
106
 
140
- export function maybeOrigamiSourceCode(text) {
141
- return origamiSourceSignals.some((signal) => text.includes(signal));
142
- }
143
-
144
107
  // Return user-friendly line information for the error location
145
- function lineInfo(location) {
108
+ export function lineInfo(location) {
146
109
  let { source, start } = location;
147
110
 
148
111
  let line;
@@ -162,7 +125,10 @@ function lineInfo(location) {
162
125
  }
163
126
 
164
127
  if (typeof source === "object" && source.url) {
165
- const { url } = source;
128
+ let { url } = source;
129
+ if (typeof url === "string") {
130
+ url = new URL(url);
131
+ }
166
132
  let fileRef;
167
133
  // If URL is a file: URL, change to a relative path
168
134
  if (url.protocol === "file:") {
@@ -175,28 +141,15 @@ function lineInfo(location) {
175
141
  // Not a file: URL, use as is
176
142
  fileRef = url.href;
177
143
  }
178
- return `\n at ${fileRef}:${line}:${column}`;
144
+ return ` at ${fileRef}:${line}:${column}`;
179
145
  } else if (source.text.includes("\n")) {
180
146
  // Don't know the URL, but has multiple lines so add line number
181
- return `\n at line ${line}, column ${column}`;
147
+ return ` at line ${line}, column ${column}`;
182
148
  } else {
183
- return "";
149
+ return null;
184
150
  }
185
151
  }
186
152
 
187
- export async function scopeReferenceError(scope, key) {
188
- const messages = [
189
- `"${key}" is not in scope or is undefined.`,
190
- await formatScopeTypos(scope, key),
191
- ];
192
- const message = messages.join(" ");
193
- return new ReferenceError(message);
194
- }
195
-
196
- // Return all possible typos for `key` in scope
197
- async function scopeTypos(scope, key) {
198
- const scopeKeys = [...(await scope.keys())];
199
- const normalizedScopeKeys = scopeKeys.map((key) => trailingSlash.remove(key));
200
- const normalizedKey = trailingSlash.remove(key);
201
- return typos(normalizedKey, normalizedScopeKeys);
153
+ export function maybeOrigamiSourceCode(text) {
154
+ return origamiSourceSignals.some((signal) => text.includes(signal));
202
155
  }
@@ -1,83 +1,14 @@
1
- import { Tree, isUnpackable } from "@weborigami/async-tree";
2
- import codeFragment from "./codeFragment.js";
3
- import { displayWarning } from "./errors.js";
4
- import * as symbols from "./symbols.js";
1
+ import * as compile from "../compiler/compile.js";
5
2
 
6
3
  /**
7
- * Evaluate the given code and return the result.
4
+ * Compile the given source code and evaluate it.
8
5
  *
9
- * `this` should be the tree used as the context for the evaluation.
10
- *
11
- * @param {import("../../index.ts").AnnotatedCode} code
12
- * @param {import("../../index.ts").RuntimeState} [state]
6
+ * @typedef {import("../../index.ts").Source} Source
7
+ * @param {Source|string} source
8
+ * @param {any} [options]
13
9
  */
14
- export default async function evaluate(code, state = {}) {
15
- if (!(code instanceof Array)) {
16
- // Simple scalar; return as is.
17
- return code;
18
- }
19
-
20
- let evaluated;
21
- if (code[0]?.unevaluatedArgs) {
22
- // Don't evaluate instructions, use as is.
23
- evaluated = code;
24
- } else {
25
- // Evaluate each instruction in the code.
26
- evaluated = await Promise.all(
27
- code.map((instruction) => evaluate(instruction, state)),
28
- );
29
- }
30
-
31
- // The head of the array is a function or a tree; the rest are args or keys.
32
- let [fn, ...args] = evaluated;
33
-
34
- if (!fn) {
35
- // The code wants to invoke something that's couldn't be found in scope.
36
- const error = ReferenceError(
37
- `${codeFragment(code[0].location)} is not defined`,
38
- );
39
- /** @type {any} */ (error).location = code[0].location;
40
- throw error;
41
- }
42
-
43
- if (isUnpackable(fn)) {
44
- // Unpack the object and use the result as the function or tree.
45
- fn = await fn.unpack();
46
- }
47
-
48
- if (fn.needsState) {
49
- // The function is an op that wants the runtime state
50
- args.push(state);
51
- } else if (fn.containerAsTarget && state.parent) {
52
- // The function wants the code's container as the `this` target
53
- fn = fn.bind(state.parent);
54
- }
55
-
56
- // Execute the function or traverse the tree.
57
- let result;
58
- try {
59
- result =
60
- fn instanceof Function
61
- ? await fn(...args) // Invoke the function
62
- : await Tree.traverseOrThrow(fn, ...args); // Traverse the tree.
63
- } catch (/** @type {any} */ error) {
64
- if (!error.location) {
65
- // Attach the location of the code we tried to evaluate.
66
- error.location =
67
- error.position !== undefined && code[error.position + 1]?.location
68
- ? // Use location of the argument with the given position (need to
69
- // offset by 1 to skip the function).
70
- code[error.position + 1]?.location
71
- : // Use overall location.
72
- code.location;
73
- }
74
- throw error;
75
- }
76
-
77
- if (result?.[symbols.warningSymbol]) {
78
- displayWarning(result[symbols.warningSymbol], code.location);
79
- delete result[symbols.warningSymbol];
80
- }
81
-
10
+ export default async function evaluate(source, options = {}) {
11
+ const fn = compile.expression(source, options);
12
+ const result = await fn();
82
13
  return result;
83
14
  }
@@ -0,0 +1,82 @@
1
+ import { isUnpackable, Tree } from "@weborigami/async-tree";
2
+ import asyncStorage from "./asyncStorage.js";
3
+ import "./interop.js";
4
+
5
+ /**
6
+ * Execute the given code and return the result.
7
+ *
8
+ * `this` should be the map used as the context for the evaluation.
9
+ *
10
+ * @typedef {import("../../index.ts").AnnotatedCode} AnnotatedCode
11
+ * @typedef {import("../../index.ts").RuntimeState} RuntimeState
12
+ *
13
+ * @param {AnnotatedCode} code
14
+ * @param {RuntimeState} [state]
15
+ */
16
+ export default async function execute(code, state = {}) {
17
+ if (!(code instanceof Array)) {
18
+ // Simple scalar; return as is.
19
+ return code;
20
+ }
21
+
22
+ let evaluated;
23
+ if (code[0]?.unevaluatedArgs) {
24
+ // Don't evaluate instructions, use as is.
25
+ evaluated = code;
26
+ } else {
27
+ // Evaluate each instruction in the code.
28
+ evaluated = await Promise.all(
29
+ code.map((instruction) => execute(instruction, state)),
30
+ );
31
+ }
32
+
33
+ // Add the code to the runtime state
34
+ /** @type {import("../../index.ts").CodeContext} */
35
+ const context = { state, code };
36
+
37
+ // The head of the array is a function or a map; the rest are args or keys.
38
+ let [fn, ...args] = evaluated;
39
+
40
+ if (!fn) {
41
+ // The code wants to invoke something that's couldn't be found in scope.
42
+ /** @type {any} */
43
+ const error = new ReferenceError(
44
+ "Couldn't find the function or map to execute.",
45
+ );
46
+ error.context = context; // For error formatting
47
+ error.position = 0; // Problem was at function position
48
+ throw error;
49
+ }
50
+
51
+ if (isUnpackable(fn)) {
52
+ // Unpack the object and use the result as the function or map.
53
+ fn = await fn.unpack();
54
+ }
55
+
56
+ if (fn.needsState) {
57
+ // The function is an op that wants the runtime state
58
+ args.push(state);
59
+ } else if (fn.parentAsTarget && state.parent) {
60
+ // The function wants the code's parent as the `this` target
61
+ fn = fn.bind(state.parent);
62
+ }
63
+
64
+ // Execute the function or traverse the map.
65
+ let result;
66
+ try {
67
+ result = await asyncStorage.run(
68
+ context,
69
+ async () =>
70
+ fn instanceof Function
71
+ ? await fn(...args) // Invoke the function
72
+ : await Tree.traverseOrThrow(fn, ...args), // Traverse the map.
73
+ );
74
+ } catch (/** @type {any} */ error) {
75
+ if (!error.context) {
76
+ error.context = context; // For error formatting
77
+ }
78
+ throw error;
79
+ }
80
+
81
+ return result;
82
+ }