@weborigami/language 0.0.70 → 0.0.71-beta.1

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-beta.1",
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-beta.1",
15
+ "@weborigami/types": "0.0.71-beta.1",
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());
@@ -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,6 +43,25 @@ export async function array(...items) {
43
43
  }
44
44
  addOpLabel(array, "«ops.array»");
45
45
 
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
+ }
64
+
46
65
  // The assign op is a placeholder for an assignment declaration.
47
66
  // It is only used during parsing -- it shouldn't be executed.
48
67
  export const assign = "«ops.assign»";
@@ -236,9 +255,6 @@ export function lambda(parameters, code) {
236
255
  return lambdaFnMap.get(code);
237
256
  }
238
257
 
239
- // By default, the first input argument is named `_`.
240
- parameters ??= ["_"];
241
-
242
258
  /** @this {AsyncTree|null} */
243
259
  async function invoke(...args) {
244
260
  // Add arguments and @recurse to scope.
@@ -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, ", "."]],
@@ -609,7 +609,7 @@ describe("Origami parser", () => {
609
609
  test("templateDocument", () => {
610
610
  assertParse("templateDocument", "hello${foo}world", [
611
611
  ops.lambda,
612
- null,
612
+ ["_"],
613
613
  [
614
614
  ops.template,
615
615
  [ops.literal, ["hello", "world"]],
@@ -618,7 +618,7 @@ describe("Origami parser", () => {
618
618
  ]);
619
619
  assertParse("templateDocument", "Documents can contain ` backticks", [
620
620
  ops.lambda,
621
- null,
621
+ ["_"],
622
622
  [ops.template, [ops.literal, ["Documents can contain ` backticks"]]],
623
623
  ]);
624
624
  });
@@ -648,7 +648,7 @@ describe("Origami parser", () => {
648
648
  [ops.scope, "people"],
649
649
  [
650
650
  ops.lambda,
651
- null,
651
+ ["_"],
652
652
  [
653
653
  ops.template,
654
654
  [ops.literal, ["", ""]],
@@ -695,23 +695,6 @@ function assertParse(startRule, source, expected, checkLocation = true) {
695
695
  assert.equal(resultSource, source.trim());
696
696
  }
697
697
 
698
- const actual = stripLocations(code);
698
+ const actual = stripCodeLocations(code);
699
699
  assert.deepEqual(actual, expected);
700
700
  }
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,
@@ -73,7 +80,7 @@ describe("ops", () => {
73
80
  });
74
81
 
75
82
  test("ops.lambda function can reference itself with @recurse", async () => {
76
- const code = createCode([ops.lambda, null, [ops.scope, "@recurse"]]);
83
+ const code = createCode([ops.lambda, ["_"], [ops.scope, "@recurse"]]);
77
84
  const fn = await evaluate.call(null, code);
78
85
  const result = await fn();
79
86
  // We're expecting the function to return itself, but testing recursion is