@weborigami/language 0.2.5 → 0.2.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@weborigami/language",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
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.5",
16
- "@weborigami/types": "0.2.5",
15
+ "@weborigami/async-tree": "0.2.7",
16
+ "@weborigami/types": "0.2.7",
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
+ }
@@ -235,12 +235,17 @@ export function makeObject(entries, op) {
235
235
 
236
236
  for (let [key, value] of entries) {
237
237
  if (key === ops.spread) {
238
- // Spread entry; accumulate
239
- if (currentEntries.length > 0) {
240
- spreads.push([op, ...currentEntries]);
241
- currentEntries = [];
238
+ if (value[0] === ops.object) {
239
+ // Spread of an object; fold into current object
240
+ currentEntries.push(...value.slice(1));
241
+ } else {
242
+ // Spread of a tree; accumulate
243
+ if (currentEntries.length > 0) {
244
+ spreads.push([op, ...currentEntries]);
245
+ currentEntries = [];
246
+ }
247
+ spreads.push(value);
242
248
  }
243
- spreads.push(value);
244
249
  continue;
245
250
  }
246
251
 
@@ -253,15 +258,6 @@ export function makeObject(entries, op) {
253
258
  // Optimize a getter for a primitive value to a regular property
254
259
  value = value[1];
255
260
  }
256
- // else if (
257
- // value[0] === ops.object ||
258
- // (value[0] === ops.getter &&
259
- // value[1] instanceof Array &&
260
- // (value[1][0] === ops.object || value[1][0] === ops.merge))
261
- // ) {
262
- // // Add a trailing slash to key to indicate value is a subtree
263
- // key = trailingSlash.add(key);
264
- // }
265
261
  }
266
262
 
267
263
  currentEntries.push([key, value]);
@@ -20,7 +20,13 @@ 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 = [
24
+ ops.external,
25
+ ops.lambda,
26
+ ops.merge,
27
+ ops.object,
28
+ ops.literal,
29
+ ];
24
30
  if (unevaluatedFns.includes(code[0])) {
25
31
  // Don't evaluate instructions, use as is.
26
32
  evaluated = code;
@@ -34,11 +34,13 @@ export default async function expressionObject(entries, parent) {
34
34
 
35
35
  let tree;
36
36
  const eagerProperties = [];
37
+ const propertyIsEnumerable = {};
37
38
  for (let [key, value] of entries) {
38
39
  // Determine if we need to define a getter or a regular property. If the key
39
40
  // has an extension, we need to define a getter. If the value is code (an
40
41
  // array), we need to define a getter -- but if that code takes the form
41
- // [ops.getter, <primitive>], we can define a regular property.
42
+ // [ops.getter, <primitive>] or [ops.literal, <value>], we can define a
43
+ // regular property.
42
44
  let defineProperty;
43
45
  const extname = extension.extname(key);
44
46
  if (extname) {
@@ -48,6 +50,9 @@ export default async function expressionObject(entries, parent) {
48
50
  } else if (value[0] === ops.getter && !(value[1] instanceof Array)) {
49
51
  defineProperty = true;
50
52
  value = value[1];
53
+ } else if (value[0] === ops.literal) {
54
+ defineProperty = true;
55
+ value = value[1];
51
56
  } else {
52
57
  defineProperty = false;
53
58
  }
@@ -58,6 +63,7 @@ export default async function expressionObject(entries, parent) {
58
63
  key = key.slice(1, -1);
59
64
  enumerable = false;
60
65
  }
66
+ propertyIsEnumerable[key] = enumerable;
61
67
 
62
68
  if (defineProperty) {
63
69
  // Define simple property
@@ -105,7 +111,7 @@ export default async function expressionObject(entries, parent) {
105
111
  Object.defineProperty(object, symbols.keys, {
106
112
  configurable: true,
107
113
  enumerable: false,
108
- value: () => keys(object, eagerProperties, entries),
114
+ value: () => keys(object, eagerProperties, propertyIsEnumerable, entries),
109
115
  writable: true,
110
116
  });
111
117
 
@@ -158,6 +164,8 @@ function entryKey(object, eagerProperties, entry) {
158
164
  return trailingSlash.toggle(key, entryCreatesSubtree);
159
165
  }
160
166
 
161
- function keys(object, eagerProperties, entries) {
162
- return entries.map((entry) => entryKey(object, eagerProperties, entry));
167
+ function keys(object, eagerProperties, propertyIsEnumerable, entries) {
168
+ return entries
169
+ .filter(([key]) => propertyIsEnumerable[key])
170
+ .map((entry) => entryKey(object, eagerProperties, entry));
163
171
  }
@@ -2,6 +2,7 @@ import {
2
2
  isPlainObject,
3
3
  isUnpackable,
4
4
  merge,
5
+ setParent,
5
6
  Tree,
6
7
  } from "@weborigami/async-tree";
7
8
 
@@ -59,5 +60,6 @@ export default async function mergeTrees(...trees) {
59
60
 
60
61
  // Merge the trees.
61
62
  const result = merge(...unpacked);
63
+ setParent(result, this);
62
64
  return result;
63
65
  }
@@ -33,6 +33,17 @@ export function addition(a, b) {
33
33
  }
34
34
  addOpLabel(addition, "«ops.addition»");
35
35
 
36
+ /**
37
+ * Construct an array.
38
+ *
39
+ * @this {AsyncTree|null}
40
+ * @param {any[]} items
41
+ */
42
+ export async function array(...items) {
43
+ return items;
44
+ }
45
+ addOpLabel(array, "«ops.array»");
46
+
36
47
  export function bitwiseAnd(a, b) {
37
48
  return a & b;
38
49
  }
@@ -53,17 +64,6 @@ export function bitwiseXor(a, b) {
53
64
  }
54
65
  addOpLabel(bitwiseXor, "«ops.bitwiseXor»");
55
66
 
56
- /**
57
- * Construct an array.
58
- *
59
- * @this {AsyncTree|null}
60
- * @param {any[]} items
61
- */
62
- export async function array(...items) {
63
- return items;
64
- }
65
- addOpLabel(array, "«ops.array»");
66
-
67
67
  /**
68
68
  * Like ops.scope, but only searches for a builtin at the top of the scope
69
69
  * chain.
@@ -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
 
@@ -317,9 +328,41 @@ export async function logicalOr(head, ...tail) {
317
328
  * Merge the given trees. If they are all plain objects, return a plain object.
318
329
  *
319
330
  * @this {AsyncTree|null}
320
- * @param {import("@weborigami/async-tree").Treelike[]} trees
331
+ * @param {Code[]} codes
321
332
  */
322
- export async function merge(...trees) {
333
+ export async function merge(...codes) {
334
+ // First pass: evaluate the direct property entries in a single object
335
+ let treeSpreads = false;
336
+ const directEntries = [];
337
+ for (const code of codes) {
338
+ if (code[0] === object) {
339
+ directEntries.push(...code.slice(1));
340
+ } else {
341
+ treeSpreads = true;
342
+ }
343
+ }
344
+
345
+ const directObject = directEntries
346
+ ? await expressionObject(directEntries, this)
347
+ : null;
348
+ if (!treeSpreads) {
349
+ // No tree spreads, we're done
350
+ return directObject;
351
+ }
352
+
353
+ // Second pass: evaluate the trees with the direct properties object in scope
354
+ let context;
355
+ if (directObject) {
356
+ context = Tree.from(directObject);
357
+ context.parent = this;
358
+ } else {
359
+ context = this;
360
+ }
361
+
362
+ const trees = await Promise.all(
363
+ codes.map(async (code) => evaluate.call(context, code))
364
+ );
365
+
323
366
  return mergeTrees.call(this, ...trees);
324
367
  }
325
368
  addOpLabel(merge, "«ops.merge»");
@@ -427,7 +470,7 @@ addOpLabel(shiftRightUnsigned, "«ops.shiftRightUnsigned»");
427
470
  * The spread operator is a placeholder during parsing. It should be replaced
428
471
  * with an object merge.
429
472
  */
430
- export function spread(...args) {
473
+ export function spread(arg) {
431
474
  throw new Error(
432
475
  "Internal error: a spread operation wasn't compiled correctly."
433
476
  );
@@ -15,6 +15,12 @@ export function isTypo(s1, s2) {
15
15
  return false;
16
16
  }
17
17
 
18
+ // If strings are both a single character, we don't want to consider them
19
+ // typos.
20
+ if (length1 === 1 && length2 === 1) {
21
+ return false;
22
+ }
23
+
18
24
  // If length difference is more than 1, distance can't be 1
19
25
  if (Math.abs(length1 - length2) > 1) {
20
26
  return false;
@@ -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
+ });
@@ -701,6 +701,11 @@ describe("Origami parser", () => {
701
701
  [ops.object, ["a", [ops.literal, 1]]],
702
702
  [ops.scope, "b"],
703
703
  ]);
704
+ assertParse("objectLiteral", "{ a: 1, ...{ b: 2 } }", [
705
+ ops.object,
706
+ ["a", [ops.literal, 1]],
707
+ ["b", [ops.literal, 2]],
708
+ ]);
704
709
  assertParse("objectLiteral", "{ (a): 1 }", [
705
710
  ops.object,
706
711
  ["(a)", [ops.literal, 1]],
@@ -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);
@@ -213,6 +220,45 @@ describe("ops", () => {
213
220
  assert.strictEqual(await ops.logicalOr(true, errorFn), true);
214
221
  });
215
222
 
223
+ test("ops.merge", async () => {
224
+ // {
225
+ // a: 1
226
+ // …fn(a)
227
+ // }
228
+ const scope = new ObjectTree({
229
+ fn: (a) => ({ b: 2 * a }),
230
+ });
231
+ const code = createCode([
232
+ ops.merge,
233
+ [ops.object, ["a", [ops.literal, 1]]],
234
+ [
235
+ [ops.builtin, "fn"],
236
+ [ops.scope, "a"],
237
+ ],
238
+ ]);
239
+ const result = await evaluate.call(scope, code);
240
+ assert.deepEqual(result, { a: 1, b: 2 });
241
+ });
242
+
243
+ test("ops.merge lets all direct properties see each other", async () => {
244
+ // {
245
+ // a: 1
246
+ // ...more
247
+ // c: a
248
+ // }
249
+ const scope = new ObjectTree({
250
+ more: { b: 2 },
251
+ });
252
+ const code = createCode([
253
+ ops.merge,
254
+ [ops.object, ["a", [ops.literal, 1]]],
255
+ [ops.scope, "more"],
256
+ [ops.object, ["c", [ops.scope, "a"]]],
257
+ ]);
258
+ const result = await evaluate.call(scope, code);
259
+ assert.deepEqual(result, { a: 1, b: 2, c: 1 });
260
+ });
261
+
216
262
  test("ops.multiplication multiplies two numbers", async () => {
217
263
  assert.strictEqual(ops.multiplication(3, 4), 12);
218
264
  assert.strictEqual(ops.multiplication(-3, 4), -12);
@@ -12,6 +12,7 @@ describe("typos", () => {
12
12
  assert(isTypo("cat", "cta")); // transposition
13
13
  assert(isTypo("cat", "act")); // transposition
14
14
  assert(!isTypo("cat", "dog")); // more than 1 edit
15
+ assert(!isTypo("a", "b")); // single character
15
16
  });
16
17
 
17
18
  test("typos", () => {