@weborigami/language 0.0.70 → 0.0.71

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@weborigami/language",
3
- "version": "0.0.70",
3
+ "version": "0.0.71",
4
4
  "description": "Web Origami expression language compiler and runtime",
5
5
  "type": "module",
6
6
  "main": "./main.js",
@@ -11,8 +11,8 @@
11
11
  "typescript": "5.6.2"
12
12
  },
13
13
  "dependencies": {
14
- "@weborigami/async-tree": "0.0.70",
15
- "@weborigami/types": "0.0.70",
14
+ "@weborigami/async-tree": "0.0.71",
15
+ "@weborigami/types": "0.0.71",
16
16
  "watcher": "2.3.1"
17
17
  },
18
18
  "scripts": {
@@ -1,15 +1,20 @@
1
+ import { trailingSlash } from "@weborigami/async-tree";
1
2
  import { createExpressionFunction } from "../runtime/expressionFunction.js";
3
+ import { ops } from "../runtime/internal.js";
2
4
  import { parse } from "./parse.js";
3
5
 
4
6
  function compile(source, startRule) {
5
7
  if (typeof source === "string") {
6
8
  source = { text: source };
7
9
  }
8
- const parseResult = parse(source.text, {
10
+ const code = parse(source.text, {
9
11
  grammarSource: source,
10
12
  startRule,
11
13
  });
12
- const fn = createExpressionFunction(parseResult);
14
+ const cache = {};
15
+ const modified = cacheNonLocalScopeReferences(code, cache);
16
+ // const modified = code;
17
+ const fn = createExpressionFunction(modified);
13
18
  return fn;
14
19
  }
15
20
 
@@ -17,6 +22,63 @@ export function expression(source) {
17
22
  return compile(source, "expression");
18
23
  }
19
24
 
25
+ // Given code containing ops.scope calls, upgrade them to ops.cache calls unless
26
+ // they refer to local variables: variables defined by object literals or lambda
27
+ // parameters.
28
+ export function cacheNonLocalScopeReferences(code, cache, locals = {}) {
29
+ const [fn, ...args] = code;
30
+
31
+ let additionalLocalNames;
32
+ switch (fn) {
33
+ case ops.scope:
34
+ const key = args[0];
35
+ const normalizedKey = trailingSlash.remove(key);
36
+ if (locals[normalizedKey]) {
37
+ return code;
38
+ } else {
39
+ // Upgrade to cached scope lookup
40
+ const modified = [ops.cache, key, cache];
41
+ /** @type {any} */ (modified).location = code.location;
42
+ return modified;
43
+ }
44
+
45
+ case ops.lambda:
46
+ const parameters = args[0];
47
+ additionalLocalNames = parameters;
48
+ break;
49
+
50
+ case ops.object:
51
+ const entries = args;
52
+ additionalLocalNames = entries.map(([key]) => trailingSlash.remove(key));
53
+ break;
54
+ }
55
+
56
+ let updatedLocals = { ...locals };
57
+ if (additionalLocalNames) {
58
+ for (const key of additionalLocalNames) {
59
+ updatedLocals[key] = true;
60
+ }
61
+ }
62
+
63
+ const modified = code.map((child) => {
64
+ if (Array.isArray(child)) {
65
+ // Review: This currently descends into arrays that are not instructions,
66
+ // such as the parameters of a lambda. This should be harmless, but it'd
67
+ // be preferable to only descend into instructions. This would require
68
+ // surrounding ops.lambda parameters with ops.literal, and ops.object
69
+ // entries with ops.array.
70
+ return cacheNonLocalScopeReferences(child, cache, updatedLocals);
71
+ } else {
72
+ return child;
73
+ }
74
+ });
75
+
76
+ if (code.location) {
77
+ modified.location = code.location;
78
+ }
79
+ return modified;
80
+ }
81
+
20
82
  export function templateDocument(source) {
21
83
  return compile(source, "templateDocument");
22
84
  }
@@ -209,7 +209,7 @@ integer "integer"
209
209
  // A lambda expression: `=foo()`
210
210
  lambda "lambda function"
211
211
  = "=" __ expr:expr {
212
- return annotate([ops.lambda, null, expr], location());
212
+ return annotate([ops.lambda, ["_"], expr], location());
213
213
  }
214
214
 
215
215
  // A path that begins with a slash: `/foo/bar`
@@ -435,7 +435,7 @@ taggedTemplate
435
435
  // literal, but can contain backticks at the top level.
436
436
  templateDocument "template"
437
437
  = contents:templateDocumentContents {
438
- return annotate([ops.lambda, null, contents], location());
438
+ return annotate([ops.lambda, ["_"], contents], location());
439
439
  }
440
440
 
441
441
  // Template documents can contain backticks at the top level.
@@ -416,7 +416,7 @@ function peg$parse(input, options) {
416
416
  return annotate([ops.literal, parseInt(text())], location());
417
417
  };
418
418
  var peg$f26 = function(expr) {
419
- return annotate([ops.lambda, null, expr], location());
419
+ return annotate([ops.lambda, ["_"], expr], location());
420
420
  };
421
421
  var peg$f27 = function(path) {
422
422
  return annotate(path ?? [], location());
@@ -500,7 +500,7 @@ function peg$parse(input, options) {
500
500
  return annotate(makeTemplate(tag, contents[0], contents[1]), location());
501
501
  };
502
502
  var peg$f60 = function(contents) {
503
- return annotate([ops.lambda, null, contents], location());
503
+ return annotate([ops.lambda, ["_"], contents], location());
504
504
  };
505
505
  var peg$f61 = function(head, tail) {
506
506
  return annotate(makeTemplate(ops.template, head, tail), location());
@@ -1,3 +1,4 @@
1
+ import { trailingSlash } from "@weborigami/async-tree";
1
2
  import * as ops from "../runtime/ops.js";
2
3
 
3
4
  // Parser helpers
@@ -21,9 +22,15 @@ function avoidRecursivePropertyCalls(code, key) {
21
22
  return code;
22
23
  }
23
24
  let modified;
24
- if (code[0] === ops.scope && code[1] === key) {
25
+ if (
26
+ code[0] === ops.scope &&
27
+ trailingSlash.remove(code[1]) === trailingSlash.remove(key)
28
+ ) {
25
29
  // Rewrite to avoid recursion
26
- modified = [ops.inherited, key];
30
+ modified = [ops.inherited, code[1]];
31
+ } else if (code[0] === ops.lambda && code[1].includes(key)) {
32
+ // Lambda that defines the key; don't rewrite
33
+ return code;
27
34
  } else {
28
35
  // Process any nested code
29
36
  modified = code.map((value) => avoidRecursivePropertyCalls(value, key));
@@ -8,6 +8,11 @@ import {
8
8
  trailingSlash,
9
9
  } from "@weborigami/async-tree";
10
10
 
11
+ /** @typedef {import("../../index.ts").ExtensionHandler} ExtensionHandler */
12
+
13
+ // Track extensions handlers for a given containing tree.
14
+ const handlersForContainer = new Map();
15
+
11
16
  /**
12
17
  * If the given path ends in an extension, return it. Otherwise, return the
13
18
  * empty string.
@@ -38,18 +43,39 @@ export function extname(path) {
38
43
  * @param {string} extension
39
44
  */
40
45
  export async function getExtensionHandler(parent, extension) {
46
+ let handlers = handlersForContainer.get(parent);
47
+ if (handlers) {
48
+ if (handlers[extension]) {
49
+ return handlers[extension];
50
+ }
51
+ } else {
52
+ handlers = {};
53
+ handlersForContainer.set(parent, handlers);
54
+ }
55
+
41
56
  const handlerName = `${extension.slice(1)}_handler`;
42
57
  const parentScope = scope(parent);
43
- /** @type {import("../../index.ts").ExtensionHandler} */
44
- let extensionHandler = await parentScope?.get(handlerName);
45
- if (isUnpackable(extensionHandler)) {
46
- // The extension handler itself needs to be unpacked. E.g., if it's a
47
- // buffer containing JavaScript file, we need to unpack it to get its
48
- // default export.
49
- // @ts-ignore
50
- extensionHandler = await extensionHandler.unpack();
51
- }
52
- return extensionHandler;
58
+
59
+ /** @type {Promise<ExtensionHandler>} */
60
+ let handlerPromise = parentScope
61
+ ?.get(handlerName)
62
+ .then(async (extensionHandler) => {
63
+ if (isUnpackable(extensionHandler)) {
64
+ // The extension handler itself needs to be unpacked. E.g., if it's a
65
+ // buffer containing JavaScript file, we need to unpack it to get its
66
+ // default export.
67
+ // @ts-ignore
68
+ extensionHandler = await extensionHandler.unpack();
69
+ }
70
+ // Update cache with actual handler
71
+ handlers[extension] = extensionHandler;
72
+ return extensionHandler;
73
+ });
74
+
75
+ // Cache handler even if it's undefined so we don't look it up again
76
+ handlers[extension] = handlerPromise;
77
+
78
+ return handlerPromise;
53
79
  }
54
80
 
55
81
  /**
@@ -62,7 +88,7 @@ export async function getExtensionHandler(parent, extension) {
62
88
  * @param {any} key
63
89
  */
64
90
  export async function handleExtension(parent, value, key) {
65
- if (isPacked(value) && isStringLike(key)) {
91
+ if (isPacked(value) && isStringLike(key) && value.unpack === undefined) {
66
92
  const hasSlash = trailingSlash.has(key);
67
93
  if (hasSlash) {
68
94
  key = trailingSlash.remove(key);
@@ -89,10 +115,10 @@ export async function handleExtension(parent, value, key) {
89
115
  const unpack = handler.unpack;
90
116
  if (unpack) {
91
117
  // Wrap the unpack function so its only called once per value.
92
- let loaded;
118
+ let loadPromise;
93
119
  value.unpack = async () => {
94
- loaded ??= await unpack(value, { key, parent });
95
- return loaded;
120
+ loadPromise ??= unpack(value, { key, parent });
121
+ return loadPromise;
96
122
  };
97
123
  }
98
124
  }
@@ -43,9 +43,24 @@ export async function array(...items) {
43
43
  }
44
44
  addOpLabel(array, "«ops.array»");
45
45
 
46
- // The assign op is a placeholder for an assignment declaration.
47
- // It is only used during parsing -- it shouldn't be executed.
48
- export const assign = "«ops.assign»";
46
+ /**
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.
49
+ *
50
+ * @this {AsyncTree|null}
51
+ */
52
+ export async function cache(key, cache) {
53
+ if (key in cache) {
54
+ return cache[key];
55
+ }
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;
62
+ return value;
63
+ }
49
64
 
50
65
  /**
51
66
  * Concatenate the given arguments.
@@ -123,6 +138,18 @@ async function constructSiteTree(protocol, treeClass, parent, host, ...keys) {
123
138
  return lastKey ? result.get(lastKey) : result;
124
139
  }
125
140
 
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);
150
+ }
151
+ addOpLabel(explorableSite, "«ops.explorableSite»");
152
+
126
153
  /**
127
154
  * Fetch the resource at the given href.
128
155
  *
@@ -152,18 +179,6 @@ async function fetchResponse(href) {
152
179
  */
153
180
  export const getter = new String("«ops.getter»");
154
181
 
155
- /**
156
- * A site tree with JSON Keys via HTTPS.
157
- *
158
- * @this {AsyncTree|null}
159
- * @param {string} host
160
- * @param {...string} keys
161
- */
162
- export function explorableSite(host, ...keys) {
163
- return constructSiteTree("https:", ExplorableSiteTree, this, host, ...keys);
164
- }
165
- addOpLabel(explorableSite, "«ops.explorableSite»");
166
-
167
182
  /**
168
183
  * Construct a files tree for the filesystem root.
169
184
  *
@@ -236,17 +251,13 @@ export function lambda(parameters, code) {
236
251
  return lambdaFnMap.get(code);
237
252
  }
238
253
 
239
- // By default, the first input argument is named `_`.
240
- parameters ??= ["_"];
241
-
242
254
  /** @this {AsyncTree|null} */
243
255
  async function invoke(...args) {
244
- // Add arguments and @recurse to scope.
256
+ // Add arguments to scope.
245
257
  const ambients = {};
246
258
  for (const parameter of parameters) {
247
259
  ambients[parameter] = args.shift();
248
260
  }
249
- ambients["@recurse"] = invoke;
250
261
  const ambientTree = new ObjectTree(ambients);
251
262
  ambientTree.parent = this;
252
263
 
@@ -2,6 +2,8 @@ import { ObjectTree, symbols, Tree } from "@weborigami/async-tree";
2
2
  import assert from "node:assert";
3
3
  import { describe, test } from "node:test";
4
4
  import * as compile from "../../src/compiler/compile.js";
5
+ import { ops } from "../../src/runtime/internal.js";
6
+ import { stripCodeLocations } from "./stripCodeLocations.js";
5
7
 
6
8
  const shared = new ObjectTree({
7
9
  greet: (name) => `Hello, ${name}!`,
@@ -82,6 +84,30 @@ describe("compile", () => {
82
84
  const bob = await templateFn.call(scope, "Bob");
83
85
  assert.equal(bob, "Hello, Bob!");
84
86
  });
87
+
88
+ test("converts non-local ops.scope calls to ops.cache", async () => {
89
+ const expression = `
90
+ (name) => {
91
+ a: 1
92
+ b: a // local, should be left as ops.scope
93
+ c: nonLocal // non-local, should be converted to ops.cache
94
+ d: name // local, should be left as ops.scope
95
+ }
96
+ `;
97
+ const fn = compile.expression(expression);
98
+ const code = fn.code;
99
+ assert.deepEqual(stripCodeLocations(code), [
100
+ ops.lambda,
101
+ ["name"],
102
+ [
103
+ ops.object,
104
+ ["a", [ops.literal, 1]],
105
+ ["b", [ops.scope, "a"]],
106
+ ["c", [ops.cache, "nonLocal", {}]],
107
+ ["d", [ops.scope, "name"]],
108
+ ],
109
+ ]);
110
+ });
85
111
  });
86
112
 
87
113
  async function assertCompile(text, expected) {
@@ -1,8 +1,8 @@
1
- import { isPlainObject } from "@weborigami/async-tree";
2
1
  import assert from "node:assert";
3
2
  import { describe, test } from "node:test";
4
3
  import { parse } from "../../src/compiler/parse.js";
5
4
  import * as ops from "../../src/runtime/ops.js";
5
+ import { stripCodeLocations } from "./stripCodeLocations.js";
6
6
 
7
7
  describe("Origami parser", () => {
8
8
  test("absoluteFilePath", () => {
@@ -63,7 +63,7 @@ describe("Origami parser", () => {
63
63
  ]);
64
64
  assertParse("expr", "fn =`x`", [
65
65
  [ops.scope, "fn"],
66
- [ops.lambda, null, [ops.template, [ops.literal, ["x"]]]],
66
+ [ops.lambda, ["_"], [ops.template, [ops.literal, ["x"]]]],
67
67
  ]);
68
68
  assertParse("expr", "copy app(formulas), files 'snapshot'", [
69
69
  [ops.scope, "copy"],
@@ -80,7 +80,7 @@ describe("Origami parser", () => {
80
80
  [ops.scope, "@map"],
81
81
  [
82
82
  ops.lambda,
83
- null,
83
+ ["_"],
84
84
  [
85
85
  ops.template,
86
86
  [ops.literal, ["<li>", "</li>"]],
@@ -271,12 +271,12 @@ describe("Origami parser", () => {
271
271
  test("lambda", () => {
272
272
  assertParse("lambda", "=message", [
273
273
  ops.lambda,
274
- null,
274
+ ["_"],
275
275
  [ops.scope, "message"],
276
276
  ]);
277
277
  assertParse("lambda", "=`Hello, ${name}.`", [
278
278
  ops.lambda,
279
- null,
279
+ ["_"],
280
280
  [
281
281
  ops.template,
282
282
  [ops.literal, ["Hello, ", "."]],
@@ -406,6 +406,18 @@ describe("Origami parser", () => {
406
406
  assertParse("objectEntry", "foo", ["foo", [ops.inherited, "foo"]]);
407
407
  assertParse("objectEntry", "x: y", ["x", [ops.scope, "y"]]);
408
408
  assertParse("objectEntry", "a: a", ["a", [ops.inherited, "a"]]);
409
+ assertParse("objectEntry", "a: (a) => a", [
410
+ "a",
411
+ [ops.lambda, ["a"], [ops.scope, "a"]],
412
+ ]);
413
+ assertParse("objectEntry", "posts/: @map(posts, post.ori)", [
414
+ "posts/",
415
+ [
416
+ [ops.scope, "@map"],
417
+ [ops.inherited, "posts"],
418
+ [ops.scope, "post.ori"],
419
+ ],
420
+ ]);
409
421
  });
410
422
 
411
423
  test("objectGetter", () => {
@@ -609,7 +621,7 @@ describe("Origami parser", () => {
609
621
  test("templateDocument", () => {
610
622
  assertParse("templateDocument", "hello${foo}world", [
611
623
  ops.lambda,
612
- null,
624
+ ["_"],
613
625
  [
614
626
  ops.template,
615
627
  [ops.literal, ["hello", "world"]],
@@ -618,7 +630,7 @@ describe("Origami parser", () => {
618
630
  ]);
619
631
  assertParse("templateDocument", "Documents can contain ` backticks", [
620
632
  ops.lambda,
621
- null,
633
+ ["_"],
622
634
  [ops.template, [ops.literal, ["Documents can contain ` backticks"]]],
623
635
  ]);
624
636
  });
@@ -648,7 +660,7 @@ describe("Origami parser", () => {
648
660
  [ops.scope, "people"],
649
661
  [
650
662
  ops.lambda,
651
- null,
663
+ ["_"],
652
664
  [
653
665
  ops.template,
654
666
  [ops.literal, ["", ""]],
@@ -695,23 +707,6 @@ function assertParse(startRule, source, expected, checkLocation = true) {
695
707
  assert.equal(resultSource, source.trim());
696
708
  }
697
709
 
698
- const actual = stripLocations(code);
710
+ const actual = stripCodeLocations(code);
699
711
  assert.deepEqual(actual, expected);
700
712
  }
701
-
702
- // For comparison purposes, strip the `location` property added by the parser.
703
- function stripLocations(parseResult) {
704
- if (Array.isArray(parseResult)) {
705
- return parseResult.map(stripLocations);
706
- } else if (isPlainObject(parseResult)) {
707
- const result = {};
708
- for (const key in parseResult) {
709
- if (key !== "location") {
710
- result[key] = stripLocations(parseResult[key]);
711
- }
712
- }
713
- return result;
714
- } else {
715
- return parseResult;
716
- }
717
- }
@@ -0,0 +1,18 @@
1
+ import { isPlainObject } from "@weborigami/async-tree";
2
+
3
+ // For comparison purposes, strip the `location` property added by the parser.
4
+ export function stripCodeLocations(parseResult) {
5
+ if (Array.isArray(parseResult)) {
6
+ return parseResult.map(stripCodeLocations);
7
+ } else if (isPlainObject(parseResult)) {
8
+ const result = {};
9
+ for (const key in parseResult) {
10
+ if (key !== "location") {
11
+ result[key] = stripCodeLocations(parseResult[key]);
12
+ }
13
+ }
14
+ return result;
15
+ } else {
16
+ return parseResult;
17
+ }
18
+ }
@@ -5,6 +5,20 @@ import { describe, test } from "node:test";
5
5
  import { evaluate, ops } from "../../src/runtime/internal.js";
6
6
 
7
7
  describe("ops", () => {
8
+ test("ops.cache looks up a value in scope and memoizes it", async () => {
9
+ let count = 0;
10
+ const tree = new ObjectTree({
11
+ get count() {
12
+ return ++count;
13
+ },
14
+ });
15
+ const code = createCode([ops.cache, "count", {}]);
16
+ const result = await evaluate.call(tree, code);
17
+ assert.equal(result, 1);
18
+ const result2 = await evaluate.call(tree, code);
19
+ assert.equal(result2, 1);
20
+ });
21
+
8
22
  test("ops.concat concatenates tree value text", async () => {
9
23
  const scope = new ObjectTree({
10
24
  name: "world",
@@ -47,20 +61,13 @@ describe("ops", () => {
47
61
  message: "Hello",
48
62
  });
49
63
 
50
- const code = createCode([ops.lambda, null, [ops.scope, "message"]]);
64
+ const code = createCode([ops.lambda, ["_"], [ops.scope, "message"]]);
51
65
 
52
66
  const fn = await evaluate.call(scope, code);
53
67
  const result = await fn.call(scope);
54
68
  assert.equal(result, "Hello");
55
69
  });
56
70
 
57
- test("ops.lambda adds input to scope as `_`", async () => {
58
- const code = createCode([ops.lambda, null, [ops.scope, "_"]]);
59
- const fn = await evaluate.call(null, code);
60
- const result = await fn("Hello");
61
- assert.equal(result, "Hello");
62
- });
63
-
64
71
  test("ops.lambda adds input parameters to scope", async () => {
65
72
  const code = createCode([
66
73
  ops.lambda,
@@ -72,15 +79,6 @@ describe("ops", () => {
72
79
  assert.equal(result, "yx");
73
80
  });
74
81
 
75
- test("ops.lambda function can reference itself with @recurse", async () => {
76
- const code = createCode([ops.lambda, null, [ops.scope, "@recurse"]]);
77
- const fn = await evaluate.call(null, code);
78
- const result = await fn();
79
- // We're expecting the function to return itself, but testing recursion is
80
- // messy. We just confirm that the result has the same code as the original.
81
- assert.equal(result.code, fn.code);
82
- });
83
-
84
82
  test("ops.object instantiates an object", async () => {
85
83
  const scope = new ObjectTree({
86
84
  upper: (s) => s.toUpperCase(),