@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,30 +1,25 @@
1
1
  /**
2
2
  * @typedef {import("@weborigami/types").AsyncTree} AsyncTree
3
3
  * @typedef {import("@weborigami/async-tree").PlainObject} PlainObject
4
+ * @typedef {import("@weborigami/async-tree").Treelike} Treelike
4
5
  */
5
6
 
6
7
  import {
7
- ExplorableSiteTree,
8
8
  ObjectTree,
9
- SiteTree,
10
9
  Tree,
11
10
  isUnpackable,
12
- pathFromKeys,
13
11
  scope as scopeFn,
14
- trailingSlash,
15
12
  concat as treeConcat,
16
13
  } from "@weborigami/async-tree";
14
+ import os from "node:os";
15
+ import { builtinReferenceError, scopeReferenceError } from "./errors.js";
17
16
  import expressionObject from "./expressionObject.js";
18
- import { handleExtension } from "./extensions.js";
19
- import HandleExtensionsTransform from "./HandleExtensionsTransform.js";
20
17
  import { evaluate } from "./internal.js";
21
18
  import mergeTrees from "./mergeTrees.js";
22
19
  import OrigamiFiles from "./OrigamiFiles.js";
20
+ import { codeSymbol } from "./symbols.js";
23
21
  import taggedTemplate from "./taggedTemplate.js";
24
22
 
25
- // For memoizing lambda functions
26
- const lambdaFnMap = new Map();
27
-
28
23
  function addOpLabel(op, label) {
29
24
  Object.defineProperty(op, "toString", {
30
25
  value: () => label,
@@ -44,21 +39,22 @@ export async function array(...items) {
44
39
  addOpLabel(array, "«ops.array»");
45
40
 
46
41
  /**
47
- * Look up the given key in the scope for the current tree the first time
48
- * the key is requested, holding on to the value for future requests.
42
+ * Like ops.scope, but only searches for a builtin at the top of the scope
43
+ * chain.
49
44
  *
50
45
  * @this {AsyncTree|null}
51
46
  */
52
- export async function cache(key, cache) {
53
- if (key in cache) {
54
- return cache[key];
47
+ export async function builtin(key) {
48
+ if (!this) {
49
+ throw new Error("Tried to get the scope of a null or undefined tree.");
55
50
  }
56
- // First save a promise for the value
57
- const promise = scope.call(this, key);
58
- cache[key] = promise;
59
- const value = await promise;
60
- // Now update with the actual value
61
- cache[key] = value;
51
+
52
+ const builtins = Tree.root(this);
53
+ const value = await builtins.get(key);
54
+ if (value === undefined) {
55
+ throw await builtinReferenceError(this, builtins, key);
56
+ }
57
+
62
58
  return value;
63
59
  }
64
60
 
@@ -73,104 +69,31 @@ export async function concat(...args) {
73
69
  }
74
70
  addOpLabel(concat, "«ops.concat»");
75
71
 
76
- /**
77
- * Find the indicated constructor in scope, then return a function which invokes
78
- * it with `new`.
79
- *
80
- * @this {AsyncTree}
81
- * @param {...any} keys
82
- */
83
- export async function constructor(...keys) {
84
- const tree = this;
85
- const scope = scopeFn(tree);
86
- let constructor = await Tree.traverseOrThrow(scope, ...keys);
87
- if (isUnpackable(constructor)) {
88
- constructor = await constructor.unpack();
89
- }
90
- // Origami may pass `undefined` as the first argument to the constructor. We
91
- // don't pass that along, because constructors like `Date` don't like it.
92
- return (...args) =>
93
- args.length === 1 && args[0] === undefined
94
- ? new constructor()
95
- : new constructor(...args);
96
- }
97
- addOpLabel(constructor, "«ops.constructor»");
98
-
99
- /**
100
- * Given a protocol, a host, and a list of keys, construct an href.
101
- *
102
- * @param {string} protocol
103
- * @param {string} host
104
- * @param {string[]} keys
105
- */
106
- function constructHref(protocol, host, ...keys) {
107
- const path = pathFromKeys(keys);
108
- let href = [host, path].join("/");
109
- if (!href.startsWith(protocol)) {
110
- if (!href.startsWith("//")) {
111
- href = `//${href}`;
112
- }
113
- href = `${protocol}${href}`;
114
- }
115
- return href;
116
- }
117
-
118
- /**
119
- * Given a protocol, a host, and a list of keys, construct an href.
120
- *
121
- * @param {string} protocol
122
- * @param {import("../../index.ts").Constructor<AsyncTree>} treeClass
123
- * @param {AsyncTree|null} parent
124
- * @param {string} host
125
- * @param {string[]} keys
126
- */
127
- async function constructSiteTree(protocol, treeClass, parent, host, ...keys) {
128
- // If the last key doesn't end in a slash, remove it for now.
129
- let lastKey;
130
- if (keys.length > 0 && keys.at(-1) && !trailingSlash.has(keys.at(-1))) {
131
- lastKey = keys.pop();
132
- }
133
-
134
- const href = constructHref(protocol, host, ...keys);
135
- let result = new (HandleExtensionsTransform(treeClass))(href);
136
- result.parent = parent;
137
-
138
- return lastKey ? result.get(lastKey) : result;
72
+ export async function conditional(condition, truthy, falsy) {
73
+ return condition ? truthy() : falsy();
139
74
  }
140
75
 
141
- /**
142
- * A site tree with JSON Keys via HTTPS.
143
- *
144
- * @this {AsyncTree|null}
145
- * @param {string} host
146
- * @param {...string} keys
147
- */
148
- export function explorableSite(host, ...keys) {
149
- return constructSiteTree("https:", ExplorableSiteTree, this, host, ...keys);
76
+ export async function equal(a, b) {
77
+ return a == b;
150
78
  }
151
- addOpLabel(explorableSite, "«ops.explorableSite»");
152
79
 
153
80
  /**
154
- * Fetch the resource at the given href.
81
+ * Look up the given key as an external reference and cache the value for future
82
+ * requests.
155
83
  *
156
84
  * @this {AsyncTree|null}
157
- * @param {string} href
158
85
  */
159
- async function fetchResponse(href) {
160
- const response = await fetch(href);
161
- if (!response.ok) {
162
- return undefined;
163
- }
164
- let buffer = await response.arrayBuffer();
165
-
166
- // Attach any loader defined for the file type.
167
- const url = new URL(href);
168
- const filename = url.pathname.split("/").pop();
169
- if (this && filename) {
170
- buffer = await handleExtension(this, buffer, filename);
86
+ export async function external(key, cache) {
87
+ if (key in cache) {
88
+ return cache[key];
171
89
  }
172
-
173
- return buffer;
90
+ // First save a promise for the value
91
+ const promise = scope.call(this, key);
92
+ cache[key] = promise;
93
+ const value = await promise;
94
+ // Now update with the actual value
95
+ cache[key] = value;
96
+ return value;
174
97
  }
175
98
 
176
99
  /**
@@ -180,47 +103,16 @@ async function fetchResponse(href) {
180
103
  export const getter = new String("«ops.getter»");
181
104
 
182
105
  /**
183
- * Construct a files tree for the filesystem root.
106
+ * Files tree for the user's home directory.
184
107
  *
185
108
  * @this {AsyncTree|null}
186
109
  */
187
- export async function filesRoot() {
188
- let root = new OrigamiFiles("/");
189
-
190
- // The root itself needs a parent so that expressions evaluated within it
191
- // (e.g., Origami expressions loaded from .ori files) will have access to
192
- // things like the built-in functions.
193
- root.parent = this;
194
-
195
- return root;
110
+ export async function homeDirectory() {
111
+ const tree = new OrigamiFiles(os.homedir());
112
+ tree.parent = this ? Tree.root(this) : null;
113
+ return tree;
196
114
  }
197
115
 
198
- /**
199
- * Retrieve a web resource via HTTP.
200
- *
201
- * @this {AsyncTree|null}
202
- * @param {string} host
203
- * @param {...string} keys
204
- */
205
- export async function http(host, ...keys) {
206
- const href = constructHref("http:", host, ...keys);
207
- return fetchResponse.call(this, href);
208
- }
209
- addOpLabel(http, "«ops.http»");
210
-
211
- /**
212
- * Retrieve a web resource via HTTPS.
213
- *
214
- * @this {AsyncTree|null}
215
- * @param {string} host
216
- * @param {...string} keys
217
- */
218
- export function https(host, ...keys) {
219
- const href = constructHref("https:", host, ...keys);
220
- return fetchResponse.call(this, href);
221
- }
222
- addOpLabel(https, "«ops.https»");
223
-
224
116
  /**
225
117
  * Search the parent's scope -- i.e., exclude the current tree -- for the given
226
118
  * key.
@@ -245,29 +137,37 @@ addOpLabel(inherited, "«ops.inherited»");
245
137
  * @param {string[]} parameters
246
138
  * @param {Code} code
247
139
  */
248
-
249
140
  export function lambda(parameters, code) {
250
- if (lambdaFnMap.has(code)) {
251
- return lambdaFnMap.get(code);
252
- }
141
+ const context = this;
253
142
 
254
- /** @this {AsyncTree|null} */
143
+ /** @this {Treelike|null} */
255
144
  async function invoke(...args) {
256
- // Add arguments to scope.
257
- const ambients = {};
258
- for (const parameter of parameters) {
259
- ambients[parameter] = args.shift();
145
+ let target;
146
+ if (parameters.length === 0) {
147
+ // No parameters
148
+ target = context;
149
+ } else {
150
+ // Add arguments to scope.
151
+ const ambients = {};
152
+ for (const parameter of parameters) {
153
+ ambients[parameter] = args.shift();
154
+ }
155
+ Object.defineProperty(ambients, codeSymbol, {
156
+ value: code,
157
+ enumerable: false,
158
+ });
159
+ const ambientTree = new ObjectTree(ambients);
160
+ ambientTree.parent = context;
161
+ target = ambientTree;
260
162
  }
261
- const ambientTree = new ObjectTree(ambients);
262
- ambientTree.parent = this;
263
163
 
264
- let result = await evaluate.call(ambientTree, code);
164
+ let result = await evaluate.call(target, code);
265
165
 
266
166
  // Bind a function result to the ambients so that it has access to the
267
167
  // parameter values -- i.e., like a closure.
268
168
  if (result instanceof Function) {
269
169
  const resultCode = result.code;
270
- result = result.bind(ambientTree);
170
+ result = result.bind(target);
271
171
  if (code) {
272
172
  // Copy over Origami code
273
173
  result.code = resultCode;
@@ -286,7 +186,6 @@ export function lambda(parameters, code) {
286
186
  });
287
187
 
288
188
  invoke.code = code;
289
- lambdaFnMap.set(code, invoke);
290
189
  return invoke;
291
190
  }
292
191
  addOpLabel(lambda, "«ops.lambda");
@@ -299,6 +198,53 @@ export async function literal(value) {
299
198
  }
300
199
  addOpLabel(literal, "«ops.literal»");
301
200
 
201
+ /**
202
+ * Logical AND operator
203
+ */
204
+ export async function logicalAnd(head, ...tail) {
205
+ if (!head) {
206
+ return head;
207
+ }
208
+ // Evaluate the tail arguments in order, short-circuiting if any are falsy.
209
+ let lastValue;
210
+ for (const arg of tail) {
211
+ lastValue = arg instanceof Function ? await arg() : arg;
212
+ if (!lastValue) {
213
+ return lastValue;
214
+ }
215
+ }
216
+
217
+ // Return the last value (not `true`)
218
+ return lastValue;
219
+ }
220
+
221
+ /**
222
+ * Logical NOT operator
223
+ */
224
+ export async function logicalNot(value) {
225
+ return !value;
226
+ }
227
+
228
+ /**
229
+ * Logical OR operator
230
+ */
231
+ export async function logicalOr(head, ...tail) {
232
+ if (head) {
233
+ return head;
234
+ }
235
+
236
+ // Evaluate the tail arguments in order, short-circuiting if any are truthy.
237
+ let lastValue;
238
+ for (const arg of tail) {
239
+ lastValue = arg instanceof Function ? await arg() : arg;
240
+ if (lastValue) {
241
+ return lastValue;
242
+ }
243
+ }
244
+
245
+ return lastValue;
246
+ }
247
+
302
248
  /**
303
249
  * Merge the given trees. If they are all plain objects, return a plain object.
304
250
  *
@@ -323,6 +269,47 @@ export async function object(...entries) {
323
269
  }
324
270
  addOpLabel(object, "«ops.object»");
325
271
 
272
+ export async function notEqual(a, b) {
273
+ return a != b;
274
+ }
275
+
276
+ export async function notStrictEqual(a, b) {
277
+ return a !== b;
278
+ }
279
+
280
+ /**
281
+ * Nullish coalescing operator
282
+ */
283
+ export async function nullishCoalescing(head, ...tail) {
284
+ if (head != null) {
285
+ return head;
286
+ }
287
+
288
+ let lastValue;
289
+ for (const arg of tail) {
290
+ lastValue = arg instanceof Function ? await arg() : arg;
291
+ if (lastValue != null) {
292
+ return lastValue;
293
+ }
294
+ }
295
+
296
+ return lastValue;
297
+ }
298
+
299
+ /**
300
+ * Files tree for the filesystem root.
301
+ *
302
+ * @this {AsyncTree|null}
303
+ */
304
+ export async function rootDirectory(key) {
305
+ let tree = new OrigamiFiles("/");
306
+ // We set the builtins as the parent because logically the filesystem root is
307
+ // outside the project. This ignores the edge case where the project itself is
308
+ // the root of the filesystem and has a config file.
309
+ tree.parent = this ? Tree.root(this) : null;
310
+ return key ? tree.get(key) : tree;
311
+ }
312
+
326
313
  /**
327
314
  * Look up the given key in the scope for the current tree.
328
315
  *
@@ -333,7 +320,11 @@ export async function scope(key) {
333
320
  throw new Error("Tried to get the scope of a null or undefined tree.");
334
321
  }
335
322
  const scope = scopeFn(this);
336
- return scope.get(key);
323
+ const value = await scope.get(key);
324
+ if (value === undefined && key !== "undefined") {
325
+ throw await scopeReferenceError(scope, key);
326
+ }
327
+ return value;
337
328
  }
338
329
  addOpLabel(scope, "«ops.scope»");
339
330
 
@@ -343,11 +334,15 @@ addOpLabel(scope, "«ops.scope»");
343
334
  */
344
335
  export function spread(...args) {
345
336
  throw new Error(
346
- "A compile-time spread operator wasn't converted to an object merge."
337
+ "Internal error: a spread operation wasn't compiled correctly."
347
338
  );
348
339
  }
349
340
  addOpLabel(spread, "«ops.spread»");
350
341
 
342
+ export async function strictEqual(a, b) {
343
+ return a === b;
344
+ }
345
+
351
346
  /**
352
347
  * Apply the default tagged template function.
353
348
  */
@@ -361,30 +356,6 @@ addOpLabel(template, "«ops.template»");
361
356
  */
362
357
  export const traverse = Tree.traverseOrThrow;
363
358
 
364
- /**
365
- * A website tree via HTTP.
366
- *
367
- * @this {AsyncTree|null}
368
- * @param {string} host
369
- * @param {...string} keys
370
- */
371
- export function treeHttp(host, ...keys) {
372
- return constructSiteTree("http:", SiteTree, this, host, ...keys);
373
- }
374
- addOpLabel(treeHttp, "«ops.treeHttp»");
375
-
376
- /**
377
- * A website tree via HTTPS.
378
- *
379
- * @this {AsyncTree|null}
380
- * @param {string} host
381
- * @param {...string} keys
382
- */
383
- export function treeHttps(host, ...keys) {
384
- return constructSiteTree("https:", SiteTree, this, host, ...keys);
385
- }
386
- addOpLabel(treeHttps, "«ops.treeHttps»");
387
-
388
359
  /**
389
360
  * If the value is packed but has an unpack method, call it and return that as
390
361
  * the result; otherwise, return the value as is.
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Returns true if the two strings have a Damerau-Levenshtein distance of 1.
3
+ * This will be true if the strings differ by a single insertion, deletion,
4
+ * substitution, or transposition.
5
+ *
6
+ * @param {string} s1
7
+ * @param {string} s2
8
+ */
9
+ export function isTypo(s1, s2) {
10
+ const length1 = s1.length;
11
+ const length2 = s2.length;
12
+
13
+ // If the strings are identical, distance is 0
14
+ if (s1 === s2) {
15
+ return false;
16
+ }
17
+
18
+ // If length difference is more than 1, distance can't be 1
19
+ if (Math.abs(length1 - length2) > 1) {
20
+ return false;
21
+ }
22
+
23
+ if (length1 === length2) {
24
+ // Check for one substitution
25
+ let differences = 0;
26
+ for (let i = 0; i < length1; i++) {
27
+ if (s1[i] !== s2[i]) {
28
+ differences++;
29
+ if (differences > 1) {
30
+ break;
31
+ }
32
+ }
33
+ }
34
+ if (differences === 1) {
35
+ return true;
36
+ }
37
+
38
+ // Check for one transposition
39
+ for (let i = 0; i < length1 - 1; i++) {
40
+ if (s1[i] !== s2[i]) {
41
+ // Check if swapping s1[i] and s1[i+1] matches s2
42
+ if (s1[i] === s2[i + 1] && s1[i + 1] === s2[i]) {
43
+ return s1.slice(i + 2) === s2.slice(i + 2);
44
+ } else {
45
+ return false;
46
+ }
47
+ }
48
+ }
49
+ }
50
+
51
+ // Check for one insertion/deletion
52
+ const longer = length1 > length2 ? s1 : s2;
53
+ const shorter = length1 > length2 ? s2 : s1;
54
+ for (let i = 0; i < shorter.length; i++) {
55
+ if (shorter[i] !== longer[i]) {
56
+ // If we skip this character, do the rest match?
57
+ return shorter.slice(i) === longer.slice(i + 1);
58
+ }
59
+ }
60
+ return shorter === longer.slice(0, shorter.length);
61
+ }
62
+
63
+ /**
64
+ * Return any strings that could be a typo of s
65
+ *
66
+ * @param {string} s
67
+ * @param {string[]} strings
68
+ */
69
+ export function typos(s, strings) {
70
+ return strings.filter((str) => isTypo(s, str));
71
+ }
@@ -0,0 +1 @@
1
+ This folder defines expression tests in YAML files so that we can programmatically test the evaluation of the expressions in both JavaScript and Origami.
@@ -0,0 +1,101 @@
1
+ # Conditional (ternary) expression tests
2
+
3
+ - source: "true ? 42 : 0"
4
+ expected: 42
5
+ description: "Condition is true, evaluates and returns the first operand"
6
+
7
+ - source: "false ? 42 : 0"
8
+ expected: 0
9
+ description: "Condition is false, evaluates and returns the second operand"
10
+
11
+ - source: "1 ? 'yes' : 'no'"
12
+ expected: "yes"
13
+ description: "Truthy condition with string operands"
14
+
15
+ - source: "0 ? 'yes' : 'no'"
16
+ expected: "no"
17
+ description: "Falsy condition with string operands"
18
+
19
+ - source: "'non-empty' ? 1 : 2"
20
+ expected: 1
21
+ description: "Truthy string condition with numeric operands"
22
+
23
+ - source: "'' ? 1 : 2"
24
+ expected: 2
25
+ description: "Falsy string condition with numeric operands"
26
+
27
+ - source: "null ? 'a' : 'b'"
28
+ expected: "b"
29
+ description: "Falsy null condition"
30
+
31
+ - source: "undefined ? 'a' : 'b'"
32
+ expected: "b"
33
+ description: "Falsy undefined condition"
34
+
35
+ - source: "NaN ? 'a' : 'b'"
36
+ expected: "b"
37
+ description: "Falsy NaN condition"
38
+
39
+ - source: "42 ? true : false"
40
+ expected: true
41
+ description: "Truthy numeric condition with boolean operands"
42
+
43
+ - source: "0 ? true : false"
44
+ expected: false
45
+ description: "Falsy numeric condition with boolean operands"
46
+
47
+ - source: "[] ? 'array' : 'no array'"
48
+ expected: "array"
49
+ description: "Truthy array condition"
50
+
51
+ - source: "{} ? 'object' : 'no object'"
52
+ expected: "object"
53
+ description: "Truthy object condition"
54
+
55
+ - source: "false ? null : undefined"
56
+ expected: __undefined__
57
+ description: "Condition is false, returns undefined"
58
+
59
+ - source: "null ? null : null"
60
+ expected: __null__
61
+ description: "Condition is falsy, returns null"
62
+
63
+ - source: "true ? NaN : 42"
64
+ expected: __NaN__
65
+ description: "Condition is true, evaluates and returns NaN"
66
+
67
+ - source: "(true ? 1 : 2) ? 3 : 4"
68
+ expected: 3
69
+ description: "Nested ternary where first expression evaluates to 1, which is truthy"
70
+
71
+ - source: "(false ? 1 : 2) ? 3 : 4"
72
+ expected: 3
73
+ description: "Nested ternary where first expression evaluates to 2, which is truthy"
74
+
75
+ - source: "(false ? 1 : 0) ? 3 : 4"
76
+ expected: 4
77
+ description: "Nested ternary where first expression evaluates to 0, which is falsy"
78
+
79
+ - source: "true ? (false ? 10 : 20) : 30"
80
+ expected: 20
81
+ description: "Nested ternary in the true branch of outer ternary"
82
+
83
+ - source: "false ? (false ? 10 : 20) : 30"
84
+ expected: 30
85
+ description: "Nested ternary in the false branch of outer ternary"
86
+
87
+ # - source: "'truthy' ? 1 + 2 : 3 + 4"
88
+ # expected: 3
89
+ # description: "Evaluates and returns the true branch with an arithmetic expression"
90
+
91
+ # - source: "'' ? 1 + 2 : 3 + 4"
92
+ # expected: 7
93
+ # description: "Evaluates and returns the false branch with an arithmetic expression"
94
+
95
+ - source: "undefined ? undefined : null"
96
+ expected: __null__
97
+ description: "Condition is falsy, returns null"
98
+
99
+ - source: "null ? undefined : undefined"
100
+ expected: __undefined__
101
+ description: "Condition is falsy, returns undefined"