@weborigami/language 0.2.4 → 0.2.6

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/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2024 Jan Miksovsky and other contributors
3
+ Copyright (c) 2025 Jan Miksovsky and other contributors
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/main.js CHANGED
@@ -10,6 +10,7 @@ export { default as HandleExtensionsTransform } from "./src/runtime/HandleExtens
10
10
  export * from "./src/runtime/handlers.js";
11
11
  export { default as ImportModulesMixin } from "./src/runtime/ImportModulesMixin.js";
12
12
  export { default as InvokeFunctionsTransform } from "./src/runtime/InvokeFunctionsTransform.js";
13
+ export * as moduleCache from "./src/runtime/moduleCache.js";
13
14
  export { default as OrigamiFiles } from "./src/runtime/OrigamiFiles.js";
14
15
  export * as symbols from "./src/runtime/symbols.js";
15
16
  export { default as taggedTemplate } from "./src/runtime/taggedTemplate.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@weborigami/language",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "description": "Web Origami expression language compiler and runtime",
5
5
  "type": "module",
6
6
  "main": "./main.js",
@@ -12,8 +12,8 @@
12
12
  "yaml": "2.6.1"
13
13
  },
14
14
  "dependencies": {
15
- "@weborigami/async-tree": "0.2.4",
16
- "@weborigami/types": "0.2.4",
15
+ "@weborigami/async-tree": "0.2.6",
16
+ "@weborigami/types": "0.2.6",
17
17
  "watcher": "2.3.1"
18
18
  },
19
19
  "scripts": {
@@ -1,8 +1,6 @@
1
- import { trailingSlash } from "@weborigami/async-tree";
2
1
  import { createExpressionFunction } from "../runtime/expressionFunction.js";
3
- import { ops } from "../runtime/internal.js";
2
+ import optimize from "./optimize.js";
4
3
  import { parse } from "./parse.js";
5
- import { annotate, undetermined } from "./parserHelpers.js";
6
4
 
7
5
  function compile(source, options) {
8
6
  const { macros, startRule } = options;
@@ -14,9 +12,8 @@ function compile(source, options) {
14
12
  grammarSource: source,
15
13
  startRule,
16
14
  });
17
- const cache = {};
18
- const modified = transformReferences(code, cache, enableCaching, macros);
19
- const fn = createExpressionFunction(modified);
15
+ const optimized = optimize(code, enableCaching, macros);
16
+ const fn = createExpressionFunction(optimized);
20
17
  return fn;
21
18
  }
22
19
 
@@ -40,100 +37,3 @@ export function templateDocument(source, options = {}) {
40
37
  startRule: "templateDocument",
41
38
  });
42
39
  }
43
-
44
- /**
45
- * Transform any remaining undetermined references to scope references.
46
- *
47
- * At the same time, transform those or explicit ops.scope calls to ops.external
48
- * calls unless they refer to local variables (variables defined by object
49
- * literals or lambda parameters).
50
- *
51
- * Also apply any macros to the code.
52
- */
53
- export function transformReferences(
54
- code,
55
- cache,
56
- enableCaching,
57
- macros,
58
- locals = {}
59
- ) {
60
- const [fn, ...args] = code;
61
-
62
- let additionalLocalNames;
63
- switch (fn) {
64
- case undetermined:
65
- case ops.scope:
66
- const key = args[0];
67
- const normalizedKey = trailingSlash.remove(key);
68
- if (macros?.[normalizedKey]) {
69
- // Apply macro
70
- const macroBody = macros[normalizedKey];
71
- const modified = transformReferences(
72
- macroBody,
73
- cache,
74
- enableCaching,
75
- macros,
76
- locals
77
- );
78
- // @ts-ignore
79
- annotate(modified, code.location);
80
- return modified;
81
- } else if (enableCaching && !locals[normalizedKey]) {
82
- // Upgrade to cached external reference
83
- const modified = [ops.external, key, cache];
84
- // @ts-ignore
85
- annotate(modified, code.location);
86
- return modified;
87
- } else if (fn === undetermined) {
88
- // Transform undetermined reference to regular scope call
89
- const modified = [ops.scope, key];
90
- // @ts-ignore
91
- annotate(modified, code.location);
92
- return modified;
93
- } else {
94
- // Internal ops.scope call; leave as is
95
- return code;
96
- }
97
-
98
- case ops.lambda:
99
- const parameters = args[0];
100
- additionalLocalNames = parameters;
101
- break;
102
-
103
- case ops.object:
104
- const entries = args;
105
- additionalLocalNames = entries.map(([key]) => trailingSlash.remove(key));
106
- break;
107
- }
108
-
109
- let updatedLocals = { ...locals };
110
- if (additionalLocalNames) {
111
- for (const key of additionalLocalNames) {
112
- updatedLocals[key] = true;
113
- }
114
- }
115
-
116
- const modified = code.map((child) => {
117
- if (Array.isArray(child)) {
118
- // Review: This currently descends into arrays that are not instructions,
119
- // such as the parameters of a lambda. This should be harmless, but it'd
120
- // be preferable to only descend into instructions. This would require
121
- // surrounding ops.lambda parameters with ops.literal, and ops.object
122
- // entries with ops.array.
123
- return transformReferences(
124
- child,
125
- cache,
126
- enableCaching,
127
- macros,
128
- updatedLocals
129
- );
130
- } else {
131
- return child;
132
- }
133
- });
134
-
135
- if (code.location) {
136
- annotate(modified, code.location);
137
- }
138
- return modified;
139
- }
@@ -0,0 +1,124 @@
1
+ import { pathFromKeys, trailingSlash } from "@weborigami/async-tree";
2
+ import { ops } from "../runtime/internal.js";
3
+ import { annotate, undetermined } from "./parserHelpers.js";
4
+
5
+ /**
6
+ * Optimize an Origami code instruction:
7
+ *
8
+ * - Transform any remaining undetermined references to scope references.
9
+ * - Transform those or explicit ops.scope calls to ops.external calls unless
10
+ * they refer to local variables (variables defined by object literals or
11
+ * lambda parameters).
12
+ * - Apply any macros to the code.
13
+ */
14
+ export default function optimize(
15
+ code,
16
+ enableCaching = true,
17
+ macros = {},
18
+ cache = {},
19
+ locals = {}
20
+ ) {
21
+ // See if we can optimize this level of the code
22
+ const [fn, ...args] = code;
23
+ let additionalLocalNames;
24
+ switch (fn) {
25
+ case ops.lambda:
26
+ const parameters = args[0];
27
+ additionalLocalNames = parameters;
28
+ break;
29
+
30
+ case ops.object:
31
+ const entries = args;
32
+ additionalLocalNames = entries.map(([key]) => trailingSlash.remove(key));
33
+ break;
34
+
35
+ // Both of these are handled the same way
36
+ case undetermined:
37
+ case ops.scope:
38
+ const key = args[0];
39
+ const normalizedKey = trailingSlash.remove(key);
40
+ if (macros?.[normalizedKey]) {
41
+ // Apply macro
42
+ const macro = macros?.[normalizedKey];
43
+ return applyMacro(macro, code, enableCaching, macros, cache, locals);
44
+ } else if (enableCaching && !locals[normalizedKey]) {
45
+ // Upgrade to cached external scope reference
46
+ const optimized = [ops.external, key, [ops.scope, key], cache];
47
+ // @ts-ignore
48
+ annotate(optimized, code.location);
49
+ return optimized;
50
+ } else if (fn === undetermined) {
51
+ // Transform undetermined reference to regular scope call
52
+ const optimized = [ops.scope, key];
53
+ // @ts-ignore
54
+ annotate(optimized, code.location);
55
+ return optimized;
56
+ } else {
57
+ // Internal ops.scope call; leave as is
58
+ return code;
59
+ }
60
+
61
+ case ops.traverse:
62
+ // Is the first argument a nonscope/undetermined reference?
63
+ const isScopeRef =
64
+ args[0]?.[0] === ops.scope || args[0]?.[0] === undetermined;
65
+ if (enableCaching && isScopeRef) {
66
+ // Is the first argument a nonlocal reference?
67
+ const normalizedKey = trailingSlash.remove(args[0][1]);
68
+ if (!locals[normalizedKey]) {
69
+ // Are the remaining arguments all literals?
70
+ const allLiterals = args
71
+ .slice(1)
72
+ .every((arg) => arg[0] === ops.literal);
73
+ if (allLiterals) {
74
+ // Convert to ops.external
75
+ const keys = args.map((arg) => arg[1]);
76
+ const path = pathFromKeys(keys);
77
+ const optimized = [ops.external, path, code, cache];
78
+ // @ts-ignore
79
+ annotate(optimized, code.location);
80
+ return optimized;
81
+ }
82
+ }
83
+ }
84
+ break;
85
+ }
86
+
87
+ // Add any locals introduced by this code to the list that will be consulted
88
+ // when we descend into child nodes.
89
+ let updatedLocals;
90
+ if (additionalLocalNames) {
91
+ updatedLocals = { ...locals };
92
+ for (const key of additionalLocalNames) {
93
+ updatedLocals[key] = true;
94
+ }
95
+ } else {
96
+ updatedLocals = locals;
97
+ }
98
+
99
+ // Optimize children
100
+ const optimized = code.map((child) => {
101
+ if (Array.isArray(child)) {
102
+ // Review: This currently descends into arrays that are not instructions,
103
+ // such as the parameters of a lambda. This should be harmless, but it'd
104
+ // be preferable to only descend into instructions. This would require
105
+ // surrounding ops.lambda parameters with ops.literal, and ops.object
106
+ // entries with ops.array.
107
+ return optimize(child, enableCaching, macros, cache, updatedLocals);
108
+ } else {
109
+ return child;
110
+ }
111
+ });
112
+
113
+ if (code.location) {
114
+ annotate(optimized, code.location);
115
+ }
116
+ return optimized;
117
+ }
118
+
119
+ function applyMacro(macro, code, enableCaching, macros, cache, locals) {
120
+ const optimized = optimize(macro, enableCaching, macros, cache, locals);
121
+ // @ts-ignore
122
+ annotate(optimized, code.location);
123
+ return optimized;
124
+ }
@@ -2,6 +2,7 @@ import * as fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { pathToFileURL } from "node:url";
4
4
  import { maybeOrigamiSourceCode } from "./errors.js";
5
+ import * as moduleCache from "./moduleCache.js";
5
6
 
6
7
  /**
7
8
  * @typedef {import("@weborigami/types").AsyncTree} AsyncTree
@@ -14,7 +15,9 @@ export default function ImportModulesMixin(Base) {
14
15
  const filePath = path.join(this.dirname, ...keys);
15
16
  // On Windows, absolute paths must be valid file:// URLs.
16
17
  const fileUrl = pathToFileURL(filePath);
17
- const modulePath = fileUrl.href;
18
+ // Add cache-busting timestamp
19
+ const modulePath =
20
+ fileUrl.href + `?cacheBust=${moduleCache.getTimestamp()}`;
18
21
 
19
22
  // Try to load the module.
20
23
  let obj;
@@ -20,7 +20,7 @@ export default async function evaluate(code) {
20
20
  }
21
21
 
22
22
  let evaluated;
23
- const unevaluatedFns = [ops.lambda, ops.object, ops.literal];
23
+ const unevaluatedFns = [ops.external, ops.lambda, ops.object, ops.literal];
24
24
  if (unevaluatedFns.includes(code[0])) {
25
25
  // Don't evaluate instructions, use as is.
26
26
  evaluated = code;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * This module is used to let the dev:watch builtin tell ImportModulesMixin when
3
+ * it should reset its module cache-busting timestamp.
4
+ */
5
+
6
+ let timestamp = Date.now();
7
+
8
+ export function getTimestamp() {
9
+ return timestamp;
10
+ }
11
+
12
+ export function resetTimestamp() {
13
+ timestamp = Date.now();
14
+ }
@@ -131,16 +131,27 @@ addOpLabel(exponentiation, "«ops.exponentiation»");
131
131
  *
132
132
  * @this {AsyncTree|null}
133
133
  */
134
- export async function external(key, cache) {
135
- if (key in cache) {
136
- return cache[key];
134
+ export async function external(path, code, cache) {
135
+ if (!this) {
136
+ throw new Error("Tried to get the scope of a null or undefined tree.");
137
+ }
138
+
139
+ if (path in cache) {
140
+ // Cache hit
141
+ return cache[path];
137
142
  }
138
- // First save a promise for the value
139
- const promise = scope.call(this, key);
140
- cache[key] = promise;
143
+
144
+ // Don't await: might get another request for this before promise resolves
145
+ const promise = evaluate.call(this, code);
146
+ // Save promise so another request will get the same promise
147
+ cache[path] = promise;
148
+
149
+ // Now wait for the value
141
150
  const value = await promise;
142
- // Now update with the actual value
143
- cache[key] = value;
151
+
152
+ // Update the cache with the actual value
153
+ cache[path] = value;
154
+
144
155
  return value;
145
156
  }
146
157
 
@@ -87,30 +87,6 @@ describe("compile", () => {
87
87
  assert.equal(bob, "Hello, Bob!");
88
88
  });
89
89
 
90
- test("converts non-local ops.scope calls to ops.external", async () => {
91
- const expression = `
92
- (name) => {
93
- a: 1
94
- b: a // local, should be left as ops.scope
95
- c: nonLocal // non-local, should be converted to ops.cache
96
- d: name // local, should be left as ops.scope
97
- }
98
- `;
99
- const fn = compile.expression(expression);
100
- const code = fn.code;
101
- assert.deepEqual(stripCodeLocations(code), [
102
- ops.lambda,
103
- ["name"],
104
- [
105
- ops.object,
106
- ["a", [ops.literal, 1]],
107
- ["b", [ops.scope, "a"]],
108
- ["c", [ops.external, "nonLocal", {}]],
109
- ["d", [ops.scope, "name"]],
110
- ],
111
- ]);
112
- });
113
-
114
90
  test("can apply a macro", async () => {
115
91
  const literal = [ops.literal, 1];
116
92
  const expression = `{ a: literal }`;
@@ -0,0 +1,44 @@
1
+ import assert from "node:assert";
2
+ import { describe, test } from "node:test";
3
+ import * as compile from "../../src/compiler/compile.js";
4
+ import optimize from "../../src/compiler/optimize.js";
5
+ import { ops } from "../../src/runtime/internal.js";
6
+ import { stripCodeLocations } from "./stripCodeLocations.js";
7
+
8
+ describe("optimize", () => {
9
+ test("optimize non-local ops.scope calls to ops.external", async () => {
10
+ const expression = `
11
+ (name) => {
12
+ a: 1
13
+ b: a // local, should be left as ops.scope
14
+ c: elsewhere // external, should be converted to ops.external
15
+ d: name // local, should be left as ops.scope
16
+ }
17
+ `;
18
+ const fn = compile.expression(expression);
19
+ const code = fn.code;
20
+ assert.deepEqual(stripCodeLocations(code), [
21
+ ops.lambda,
22
+ ["name"],
23
+ [
24
+ ops.object,
25
+ ["a", [ops.literal, 1]],
26
+ ["b", [ops.scope, "a"]],
27
+ ["c", [ops.external, "elsewhere", [ops.scope, "elsewhere"], {}]],
28
+ ["d", [ops.scope, "name"]],
29
+ ],
30
+ ]);
31
+ });
32
+
33
+ test("optimize scope traversals with all literal keys", async () => {
34
+ // Compilation of `x/y.js`
35
+ const code = [ops.traverse, [ops.scope, "x/"], [ops.literal, "y.js"]];
36
+ const optimized = optimize(code);
37
+ assert.deepEqual(stripCodeLocations(optimized), [
38
+ ops.external,
39
+ "x/y.js",
40
+ code,
41
+ {},
42
+ ]);
43
+ });
44
+ });
@@ -1,4 +1,4 @@
1
- import { ObjectTree } from "@weborigami/async-tree";
1
+ import { DeepObjectTree, ObjectTree } from "@weborigami/async-tree";
2
2
  import assert from "node:assert";
3
3
  import { describe, test } from "node:test";
4
4
 
@@ -99,14 +99,21 @@ describe("ops", () => {
99
99
  assert.strictEqual(ops.exponentiation(2, 0), 1);
100
100
  });
101
101
 
102
- test("ops.external looks up a value in scope and memoizes it", async () => {
102
+ test("ops.external evaluates code and cache its result", async () => {
103
103
  let count = 0;
104
- const tree = new ObjectTree({
105
- get count() {
106
- return ++count;
104
+ const tree = new DeepObjectTree({
105
+ group: {
106
+ get count() {
107
+ return ++count;
108
+ },
107
109
  },
108
110
  });
109
- const code = createCode([ops.external, "count", {}]);
111
+ const code = createCode([
112
+ ops.external,
113
+ "group/count",
114
+ [ops.traverse, [ops.scope, "group"], [ops.literal, "count"]],
115
+ {},
116
+ ]);
110
117
  const result = await evaluate.call(tree, code);
111
118
  assert.strictEqual(result, 1);
112
119
  const result2 = await evaluate.call(tree, code);