@rohal12/spindle 0.51.2 → 0.51.4
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/dist/pkg/format.js +1 -1
- package/dist/pkg/headless.js +1360 -774
- package/dist/pkg/story-variables.js +22 -12
- package/package.json +1 -1
- package/src/class-registry.ts +76 -0
- package/src/components/macros/Button.tsx +4 -1
- package/src/components/macros/Checkbox.tsx +5 -2
- package/src/components/macros/Dialog.tsx +4 -1
- package/src/components/macros/For.tsx +5 -2
- package/src/components/macros/Include.tsx +9 -19
- package/src/components/macros/MacroLink.tsx +2 -1
- package/src/components/macros/Meter.tsx +18 -40
- package/src/components/macros/Radiobutton.tsx +22 -0
- package/src/components/macros/SaveManager.tsx +19 -16
- package/src/components/macros/Watch.tsx +88 -40
- package/src/components/macros/WidgetInvocation.tsx +14 -94
- package/src/components/macros/arg-utils.ts +120 -0
- package/src/components/macros/option-utils.ts +9 -5
- package/src/define-macro.ts +13 -34
- package/src/execute-mutation.ts +294 -26
- package/src/expression.ts +22 -237
- package/src/js-lexer.ts +326 -0
- package/src/markup/render.tsx +139 -81
- package/src/markup/tokenizer.ts +23 -9
- package/src/saves/save-manager.ts +47 -8
- package/src/saves/storage.ts +8 -0
- package/src/saves/types.ts +18 -5
- package/src/store.ts +124 -48
- package/src/story-api.ts +51 -50
- package/src/triggers.ts +5 -2
- package/src/utils/object-path.ts +109 -0
- package/src/utils/stable-key.ts +77 -0
package/src/execute-mutation.ts
CHANGED
|
@@ -1,27 +1,287 @@
|
|
|
1
|
+
import { current as currentDraft, freeze, isDraft } from 'immer';
|
|
1
2
|
import { useStoryStore } from './store';
|
|
3
|
+
import type { VariableNamespaces } from './store';
|
|
2
4
|
import { execute } from './expression';
|
|
3
5
|
import { deepClone, deepEqual } from './class-registry';
|
|
6
|
+
import { deleteByPath, getByPath, setByPath } from './utils/object-path';
|
|
7
|
+
|
|
8
|
+
type NamespaceName = keyof VariableNamespaces;
|
|
9
|
+
|
|
10
|
+
const NAMESPACES: readonly NamespaceName[] = [
|
|
11
|
+
'variables',
|
|
12
|
+
'temporary',
|
|
13
|
+
'transient',
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
/** A write to one property path of a namespace, or its deletion. */
|
|
17
|
+
type PathChange =
|
|
18
|
+
| { path: string[]; deleted: false; value: unknown }
|
|
19
|
+
| { path: string[]; deleted: true };
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The state of one executing mutation. The code reads and writes `work`.
|
|
23
|
+
* `base` is what `work` started as, with every write made elsewhere during
|
|
24
|
+
* execution (Story.set, the commits of nested mutations) applied to both,
|
|
25
|
+
* so diffing `work` against `base` yields exactly the code's own changes,
|
|
26
|
+
* and a write made elsewhere after the code's own write to the same path
|
|
27
|
+
* leaves no difference there: the later write wins.
|
|
28
|
+
*/
|
|
29
|
+
interface MutationScope {
|
|
30
|
+
work: VariableNamespaces;
|
|
31
|
+
base: VariableNamespaces;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The mutations now executing, innermost last. */
|
|
35
|
+
const activeScopes: MutationScope[] = [];
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The working copies of the store namespaces that the innermost executing
|
|
39
|
+
* mutation reads and writes, or undefined outside of one. Story.get reads
|
|
40
|
+
* them so the code sees its own pending writes.
|
|
41
|
+
*/
|
|
42
|
+
export function getActiveMutationScope(): VariableNamespaces | undefined {
|
|
43
|
+
return activeScopes[activeScopes.length - 1]?.work;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const cloneValue = <T>(value: T): T =>
|
|
47
|
+
deepClone(value, { keepUnregistered: true });
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A frozen copy of a value read from a working copy, matching what reads
|
|
51
|
+
* from the (frozen) store return: writing to it fails instead of changing
|
|
52
|
+
* the code's pending state behind its back.
|
|
53
|
+
*/
|
|
54
|
+
export function frozenCopy<T>(value: T): T {
|
|
55
|
+
return freeze(cloneValue(value), true);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Objects merged property by property: plain objects and (registered)
|
|
60
|
+
* class instances. Arrays, Map, Set, Date and RegExp are values that are
|
|
61
|
+
* replaced as a whole, since their elements have no stable identity to
|
|
62
|
+
* merge by (a shift moves every index).
|
|
63
|
+
*/
|
|
64
|
+
function isMergeable(value: unknown): value is Record<string, unknown> {
|
|
65
|
+
return (
|
|
66
|
+
typeof value === 'object' &&
|
|
67
|
+
value !== null &&
|
|
68
|
+
!Array.isArray(value) &&
|
|
69
|
+
!(value instanceof Map) &&
|
|
70
|
+
!(value instanceof Set) &&
|
|
71
|
+
!(value instanceof Date) &&
|
|
72
|
+
!(value instanceof RegExp)
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const hasOwn = (obj: object, key: string): boolean =>
|
|
77
|
+
Object.prototype.hasOwnProperty.call(obj, key);
|
|
78
|
+
|
|
79
|
+
/** Collect the property paths where `work` differs from `base`. */
|
|
80
|
+
function diff(
|
|
81
|
+
base: Record<string, unknown>,
|
|
82
|
+
work: Record<string, unknown>,
|
|
83
|
+
path: string[],
|
|
84
|
+
changes: PathChange[],
|
|
85
|
+
ancestors: Set<object>,
|
|
86
|
+
): void {
|
|
87
|
+
// Stop at cycles; shared (non-cyclic) references are visited per path.
|
|
88
|
+
if (ancestors.has(work)) return;
|
|
89
|
+
ancestors.add(work);
|
|
90
|
+
for (const key of Object.keys(work)) {
|
|
91
|
+
const b = base[key];
|
|
92
|
+
const w = work[key];
|
|
93
|
+
if (!hasOwn(base, key)) {
|
|
94
|
+
changes.push({ path: [...path, key], deleted: false, value: w });
|
|
95
|
+
} else if (
|
|
96
|
+
isMergeable(b) &&
|
|
97
|
+
isMergeable(w) &&
|
|
98
|
+
Object.getPrototypeOf(b) === Object.getPrototypeOf(w)
|
|
99
|
+
) {
|
|
100
|
+
diff(b, w, [...path, key], changes, ancestors);
|
|
101
|
+
} else if (!deepEqual(b, w)) {
|
|
102
|
+
changes.push({ path: [...path, key], deleted: false, value: w });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
for (const key of Object.keys(base)) {
|
|
106
|
+
if (!hasOwn(work, key))
|
|
107
|
+
changes.push({ path: [...path, key], deleted: true });
|
|
108
|
+
}
|
|
109
|
+
ancestors.delete(work);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Whether `ns` already holds what `change` would write. */
|
|
113
|
+
function isApplied(ns: Record<string, unknown>, change: PathChange): boolean {
|
|
114
|
+
const parent = getByPath(ns, change.path.slice(0, -1));
|
|
115
|
+
if (parent === null || typeof parent !== 'object') return change.deleted;
|
|
116
|
+
const key = change.path[change.path.length - 1]!;
|
|
117
|
+
if (!hasOwn(parent, key)) return change.deleted;
|
|
118
|
+
return (
|
|
119
|
+
!change.deleted &&
|
|
120
|
+
deepEqual((parent as Record<string, unknown>)[key], change.value)
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function applyChange(ns: Record<string, unknown>, change: PathChange): void {
|
|
125
|
+
if (change.deleted) deleteByPath(ns, change.path);
|
|
126
|
+
else setByPath(ns, change.path, change.value);
|
|
127
|
+
}
|
|
4
128
|
|
|
5
129
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
130
|
+
* Apply a write that reached the store while mutations execute to their
|
|
131
|
+
* `work` and `base` copies (see MutationScope), so the code sees it and
|
|
132
|
+
* its commit neither drops it nor reverts it. Called from inside the store
|
|
133
|
+
* update that makes the write, before subscribers (watchers) run, so writes
|
|
134
|
+
* those make land in the copies after this one. When a copy cannot take the
|
|
135
|
+
* path (the code replaced or deleted an intermediate object), its root is
|
|
136
|
+
* replaced with the store's root as written.
|
|
9
137
|
*/
|
|
10
|
-
function
|
|
11
|
-
draft:
|
|
12
|
-
|
|
13
|
-
|
|
138
|
+
function mirror(
|
|
139
|
+
draft: VariableNamespaces,
|
|
140
|
+
ns: NamespaceName,
|
|
141
|
+
change: PathChange,
|
|
142
|
+
scopes: readonly MutationScope[] = activeScopes,
|
|
14
143
|
): void {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
144
|
+
const root = change.path[0]!;
|
|
145
|
+
for (const scope of scopes) {
|
|
146
|
+
for (const copy of [scope.work[ns], scope.base[ns]]) {
|
|
147
|
+
try {
|
|
148
|
+
applyChange(
|
|
149
|
+
copy,
|
|
150
|
+
change.deleted
|
|
151
|
+
? change
|
|
152
|
+
: { ...change, value: cloneValue(change.value) },
|
|
153
|
+
);
|
|
154
|
+
} catch {
|
|
155
|
+
const stored = draft[ns][root];
|
|
156
|
+
if (hasOwn(draft[ns], root)) {
|
|
157
|
+
copy[root] = cloneValue(
|
|
158
|
+
isDraft(stored) ? currentDraft(stored) : stored,
|
|
159
|
+
);
|
|
160
|
+
} else {
|
|
161
|
+
delete copy[root];
|
|
162
|
+
}
|
|
163
|
+
}
|
|
18
164
|
}
|
|
19
165
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Mirror a Story.set write into every executing mutation (see mirror()).
|
|
170
|
+
* Call it inside the store update that writes `value` at `path` of `ns`.
|
|
171
|
+
*/
|
|
172
|
+
export function mirrorWriteToActiveScopes(
|
|
173
|
+
draft: VariableNamespaces,
|
|
174
|
+
ns: 'variables' | 'transient',
|
|
175
|
+
path: string[],
|
|
176
|
+
value: unknown,
|
|
177
|
+
): void {
|
|
178
|
+
if (activeScopes.length === 0) return;
|
|
179
|
+
mirror(draft, ns, { path, deleted: false, value });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const cloneNamespaces = (from: VariableNamespaces): VariableNamespaces => ({
|
|
183
|
+
variables: deepClone(from.variables),
|
|
184
|
+
temporary: deepClone(from.temporary),
|
|
185
|
+
transient: deepClone(from.transient),
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Commit the property paths `scope`'s code changed (work vs base) on top of
|
|
190
|
+
* the current store state, so writes made elsewhere during execution to
|
|
191
|
+
* other paths of the same objects survive, and hand them to the `enclosing`
|
|
192
|
+
* mutations. With `keepRunning`, the code goes on running after the commit:
|
|
193
|
+
* its working copy must stay its own (the store freezes what it is given),
|
|
194
|
+
* and its base is moved up to the working copy so nothing commits twice.
|
|
195
|
+
*/
|
|
196
|
+
function commitScope(
|
|
197
|
+
scope: MutationScope,
|
|
198
|
+
enclosing: readonly MutationScope[],
|
|
199
|
+
keepRunning: boolean,
|
|
200
|
+
): void {
|
|
201
|
+
const changes = NAMESPACES.map((ns) => {
|
|
202
|
+
const list: PathChange[] = [];
|
|
203
|
+
diff(scope.base[ns], scope.work[ns], [], list, new Set());
|
|
204
|
+
return [ns, list] as const;
|
|
205
|
+
});
|
|
206
|
+
if (changes.every(([, list]) => list.length === 0)) return;
|
|
207
|
+
// Before the store update: watchers it fires may commit again (a goto)
|
|
208
|
+
if (keepRunning) scope.base = cloneNamespaces(scope.work);
|
|
209
|
+
const own = <T>(value: T): T => (keepRunning ? cloneValue(value) : value);
|
|
210
|
+
const current = useStoryStore.getState();
|
|
211
|
+
|
|
212
|
+
// One store update for everything: watchers must see the whole mutation,
|
|
213
|
+
// and a watcher's run action must not be overwritten by paths this commit
|
|
214
|
+
// writes after it fired.
|
|
215
|
+
current.updateVariables((draft) => {
|
|
216
|
+
for (const [ns, list] of changes) {
|
|
217
|
+
const replaced = new Set<string>();
|
|
218
|
+
for (const change of list) {
|
|
219
|
+
const root = change.path[0]!;
|
|
220
|
+
// A changed path already holding the value keeps its reference
|
|
221
|
+
if (replaced.has(root) || isApplied(current[ns], change)) continue;
|
|
222
|
+
try {
|
|
223
|
+
applyChange(
|
|
224
|
+
draft[ns],
|
|
225
|
+
change.deleted ? change : { ...change, value: own(change.value) },
|
|
226
|
+
);
|
|
227
|
+
} catch {
|
|
228
|
+
// An intermediate object the code wrote into is gone from the
|
|
229
|
+
// store: the code's view of the whole root wins.
|
|
230
|
+
draft[ns][root] = own(scope.work[ns][root]);
|
|
231
|
+
replaced.add(root);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
// Hand the changes to the mutations this one runs inside, before
|
|
235
|
+
// watchers fired by this update run.
|
|
236
|
+
for (const change of list) mirror(draft, ns, change, enclosing);
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Bring a suspended mutation's copies up to the store after an action
|
|
243
|
+
* replaced or changed state under it (navigation clears temporaries, back
|
|
244
|
+
* and restart replace variables). Roots that still match keep the objects
|
|
245
|
+
* the code may hold.
|
|
246
|
+
*/
|
|
247
|
+
function resync(scope: MutationScope): void {
|
|
248
|
+
const state = useStoryStore.getState();
|
|
249
|
+
for (const ns of NAMESPACES) {
|
|
250
|
+
const work = scope.work[ns];
|
|
251
|
+
const stored = state[ns];
|
|
252
|
+
for (const key of Object.keys(work)) {
|
|
253
|
+
if (!hasOwn(stored, key)) delete work[key];
|
|
254
|
+
}
|
|
255
|
+
for (const key of Object.keys(stored)) {
|
|
256
|
+
if (!hasOwn(work, key) || !deepEqual(work[key], stored[key])) {
|
|
257
|
+
work[key] = deepClone(stored[key]);
|
|
258
|
+
}
|
|
23
259
|
}
|
|
24
260
|
}
|
|
261
|
+
scope.base = cloneNamespaces(state);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Run an action that snapshots or replaces story state (saving, navigating,
|
|
266
|
+
* restarting) in program order with mutation code running now: the code's
|
|
267
|
+
* writes so far are committed first, the action runs against the store with
|
|
268
|
+
* no mutation active, and the code then continues from the resulting state.
|
|
269
|
+
* Outside mutation code the action just runs.
|
|
270
|
+
*/
|
|
271
|
+
export function runWithCommittedMutations<T>(action: () => T): T {
|
|
272
|
+
if (activeScopes.length === 0) return action();
|
|
273
|
+
const scopes = [...activeScopes];
|
|
274
|
+
// Innermost first: each commit hands its changes to the enclosing ones
|
|
275
|
+
for (let i = scopes.length - 1; i >= 0; i--) {
|
|
276
|
+
commitScope(scopes[i]!, scopes.slice(0, i), true);
|
|
277
|
+
}
|
|
278
|
+
const suspended = activeScopes.splice(0);
|
|
279
|
+
try {
|
|
280
|
+
return action();
|
|
281
|
+
} finally {
|
|
282
|
+
activeScopes.push(...suspended);
|
|
283
|
+
for (const scope of suspended) resync(scope);
|
|
284
|
+
}
|
|
25
285
|
}
|
|
26
286
|
|
|
27
287
|
export function executeMutation(
|
|
@@ -29,10 +289,14 @@ export function executeMutation(
|
|
|
29
289
|
mergedLocals: Record<string, unknown>,
|
|
30
290
|
scopeUpdate: (key: string, value: unknown) => void,
|
|
31
291
|
): void {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const
|
|
292
|
+
// A mutation started while another executes (a watcher run action fired
|
|
293
|
+
// by a Story.set in its code) continues from the enclosing code's pending
|
|
294
|
+
// state rather than the store, as a direct call at that point would.
|
|
295
|
+
const start = getActiveMutationScope() ?? useStoryStore.getState();
|
|
296
|
+
const scope: MutationScope = {
|
|
297
|
+
work: cloneNamespaces(start),
|
|
298
|
+
base: cloneNamespaces(start),
|
|
299
|
+
};
|
|
36
300
|
// Locals are deep-cloned like the store namespaces: a loop item or widget
|
|
37
301
|
// argument taken from story state is Immer-frozen, and an unfrozen object
|
|
38
302
|
// mutated in place would keep its reference, so a nested assignment
|
|
@@ -40,16 +304,20 @@ export function executeMutation(
|
|
|
40
304
|
// Unregistered class instances (DOM nodes etc.) stay shared by reference.
|
|
41
305
|
const localsClone = deepClone(mergedLocals, { keepUnregistered: true });
|
|
42
306
|
|
|
43
|
-
|
|
307
|
+
activeScopes.push(scope);
|
|
308
|
+
try {
|
|
309
|
+
execute(
|
|
310
|
+
code,
|
|
311
|
+
scope.work.variables,
|
|
312
|
+
scope.work.temporary,
|
|
313
|
+
localsClone,
|
|
314
|
+
scope.work.transient,
|
|
315
|
+
);
|
|
316
|
+
} finally {
|
|
317
|
+
activeScopes.pop();
|
|
318
|
+
}
|
|
44
319
|
|
|
45
|
-
|
|
46
|
-
// mutation, and a watcher's run action must not be overwritten by keys
|
|
47
|
-
// this commit writes after it fired.
|
|
48
|
-
state.updateVariables((draft) => {
|
|
49
|
-
commitNamespace(draft.variables, vars, state.variables);
|
|
50
|
-
commitNamespace(draft.temporary, temps, state.temporary);
|
|
51
|
-
commitNamespace(draft.transient, trans, state.transient);
|
|
52
|
-
});
|
|
320
|
+
commitScope(scope, activeScopes, false);
|
|
53
321
|
|
|
54
322
|
for (const key of Object.keys(localsClone)) {
|
|
55
323
|
if (!deepEqual(localsClone[key], mergedLocals[key])) {
|
package/src/expression.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { StoryState } from './store';
|
|
|
2
2
|
import { useStoryStore } from './store';
|
|
3
3
|
import type { Passage } from './parser';
|
|
4
4
|
import { random, randomInt } from './prng';
|
|
5
|
+
import { lexJs } from './js-lexer';
|
|
5
6
|
|
|
6
7
|
interface ExpressionFns {
|
|
7
8
|
currentPassage: () => Passage | undefined;
|
|
@@ -31,39 +32,15 @@ const fnCache = new Map<string, CompiledExpression>();
|
|
|
31
32
|
|
|
32
33
|
/**
|
|
33
34
|
* Transform expression: $var → variables["var"], _var → temporary["var"],
|
|
34
|
-
* @var → locals["var"]
|
|
35
|
-
* Only transforms when sigils appear as a word boundary
|
|
36
|
-
*
|
|
37
|
-
* `%var`
|
|
38
|
-
*
|
|
35
|
+
* @var → locals["var"], %var → transient["var"].
|
|
36
|
+
* Only transforms when sigils appear as a word boundary, and only in code:
|
|
37
|
+
* string, template and regex literal text and comments are left untouched.
|
|
38
|
+
* `%var` is recognised by the lexer (`lexJs`), because `%` is also the modulo
|
|
39
|
+
* operator and only an operand position makes it a sigil.
|
|
39
40
|
*/
|
|
40
41
|
const VAR_RE = /\$(\w+)/g;
|
|
41
42
|
const TEMP_RE = /(?<![.\w])_(\w+)/g;
|
|
42
43
|
const LOCAL_RE = /@(\w+)/g;
|
|
43
|
-
/** Transient name after `%`: an identifier, so `%3` is never a reference. */
|
|
44
|
-
const TRANS_NAME_RE = /[A-Za-z_]\w*/y;
|
|
45
|
-
/** Characters of identifiers, numbers and sigil variable references. */
|
|
46
|
-
const WORD_CHAR_RE = /[\w$@]/;
|
|
47
|
-
const SPACE_RE = /\s/;
|
|
48
|
-
/** Keywords followed by an operand rather than an operator. */
|
|
49
|
-
const OPERAND_KEYWORDS = new Set([
|
|
50
|
-
'await',
|
|
51
|
-
'case',
|
|
52
|
-
'delete',
|
|
53
|
-
'do',
|
|
54
|
-
'else',
|
|
55
|
-
'in',
|
|
56
|
-
'instanceof',
|
|
57
|
-
'new',
|
|
58
|
-
'of',
|
|
59
|
-
'return',
|
|
60
|
-
'throw',
|
|
61
|
-
'typeof',
|
|
62
|
-
'void',
|
|
63
|
-
'yield',
|
|
64
|
-
]);
|
|
65
|
-
/** Keywords whose parenthesised header is followed by a statement. */
|
|
66
|
-
const HEADER_KEYWORDS = new Set(['for', 'if', 'while', 'with']);
|
|
67
44
|
|
|
68
45
|
function transformSegment(segment: string): string {
|
|
69
46
|
return segment
|
|
@@ -73,26 +50,13 @@ function transformSegment(segment: string): string {
|
|
|
73
50
|
}
|
|
74
51
|
|
|
75
52
|
/**
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
* `${…}` interpolations — is transformed.
|
|
80
|
-
*
|
|
81
|
-
* It also tracks whether the next token is an operand or an operator, so that
|
|
82
|
-
* `%name` in operand position is a transient reference while `%` after an
|
|
83
|
-
* operand — `($n)%3`, `$a[i] %2`, `$a%$b` — stays the modulo operator.
|
|
53
|
+
* Rewrite sigil references in `expr`. The lexer passes literal text and
|
|
54
|
+
* comments through untouched; each run of code between them (including the
|
|
55
|
+
* code of template-literal `${…}` interpolations) is transformed as a whole.
|
|
84
56
|
*/
|
|
85
57
|
function transform(expr: string): string {
|
|
86
58
|
let result = '';
|
|
87
59
|
let code = ''; // accumulates code characters to be transformed
|
|
88
|
-
let i = 0;
|
|
89
|
-
|
|
90
|
-
let operandNext = true; // an operand (not an operator) comes next
|
|
91
|
-
let word = ''; // identifier/number currently being read
|
|
92
|
-
let afterDot = false; // `word` is a property name, never a keyword
|
|
93
|
-
let afterHeaderKeyword = false; // last token was if/while/for/with
|
|
94
|
-
let lastPunct = '';
|
|
95
|
-
const parenIsHeader: boolean[] = []; // per open `(`: closes a header?
|
|
96
60
|
|
|
97
61
|
function flushCode() {
|
|
98
62
|
if (code) {
|
|
@@ -101,199 +65,19 @@ function transform(expr: string): string {
|
|
|
101
65
|
}
|
|
102
66
|
}
|
|
103
67
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
lastPunct = '';
|
|
110
|
-
word = '';
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/** A string or template literal, or a transient reference, just ended. */
|
|
114
|
-
function endOperand() {
|
|
115
|
-
endWord();
|
|
116
|
-
operandNext = false;
|
|
117
|
-
afterHeaderKeyword = false;
|
|
118
|
-
afterDot = false;
|
|
119
|
-
lastPunct = '';
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
function trackCode(c: string) {
|
|
123
|
-
if (WORD_CHAR_RE.test(c)) {
|
|
124
|
-
word += c;
|
|
125
|
-
return;
|
|
126
|
-
}
|
|
127
|
-
endWord();
|
|
128
|
-
// A line may start a new statement such as `%x = 1`.
|
|
129
|
-
if (c === '\n') operandNext = true;
|
|
130
|
-
if (SPACE_RE.test(c)) return;
|
|
131
|
-
if (c === '(') parenIsHeader.push(afterHeaderKeyword);
|
|
132
|
-
if (c === ')') {
|
|
133
|
-
// `if (…) %x = 1` vs `($n)%3`
|
|
134
|
-
operandNext = parenIsHeader.pop() ?? false;
|
|
135
|
-
} else if (c === '.') {
|
|
136
|
-
// Property access, unless it is the spread `...`
|
|
137
|
-
operandNext = lastPunct === '.';
|
|
138
|
-
} else {
|
|
139
|
-
// `]` ends an operand; `}` closes a block, so a statement may follow.
|
|
140
|
-
operandNext = c !== ']';
|
|
141
|
-
}
|
|
142
|
-
afterDot = c === '.';
|
|
143
|
-
afterHeaderKeyword = false;
|
|
144
|
-
lastPunct = c;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
while (i < expr.length) {
|
|
148
|
-
const ch = expr.charAt(i);
|
|
149
|
-
|
|
150
|
-
// Single or double quoted string — skip entirely
|
|
151
|
-
if (ch === '"' || ch === "'") {
|
|
68
|
+
lexJs(expr, {
|
|
69
|
+
code(ch) {
|
|
70
|
+
code += ch;
|
|
71
|
+
},
|
|
72
|
+
literal(text) {
|
|
152
73
|
flushCode();
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
while (i < expr.length) {
|
|
157
|
-
const c = expr.charAt(i);
|
|
158
|
-
if (c === '\\' && i + 1 < expr.length) {
|
|
159
|
-
str += c + expr.charAt(i + 1);
|
|
160
|
-
i += 2;
|
|
161
|
-
} else if (c === quote) {
|
|
162
|
-
str += quote;
|
|
163
|
-
i++;
|
|
164
|
-
break;
|
|
165
|
-
} else {
|
|
166
|
-
str += c;
|
|
167
|
-
i++;
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
result += str;
|
|
171
|
-
endOperand();
|
|
172
|
-
continue;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
// Template literal — preserve literal parts, transform interpolations
|
|
176
|
-
if (ch === '`') {
|
|
74
|
+
result += text;
|
|
75
|
+
},
|
|
76
|
+
transient(name) {
|
|
177
77
|
flushCode();
|
|
178
|
-
result +=
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
const c = expr.charAt(i);
|
|
182
|
-
if (c === '\\' && i + 1 < expr.length) {
|
|
183
|
-
result += c + expr.charAt(i + 1);
|
|
184
|
-
i += 2;
|
|
185
|
-
} else if (c === '$' && expr.charAt(i + 1) === '{') {
|
|
186
|
-
// Template interpolation — collect the inner expression and
|
|
187
|
-
// recursively transform it
|
|
188
|
-
result += '${';
|
|
189
|
-
i += 2;
|
|
190
|
-
let depth = 1;
|
|
191
|
-
let inner = '';
|
|
192
|
-
while (i < expr.length && depth > 0) {
|
|
193
|
-
const ic = expr.charAt(i);
|
|
194
|
-
if (ic === '{') {
|
|
195
|
-
depth++;
|
|
196
|
-
inner += ic;
|
|
197
|
-
} else if (ic === '}') {
|
|
198
|
-
depth--;
|
|
199
|
-
if (depth === 0) break;
|
|
200
|
-
inner += ic;
|
|
201
|
-
} else if (ic === '\\' && i + 1 < expr.length) {
|
|
202
|
-
inner += ic + expr.charAt(i + 1);
|
|
203
|
-
i++;
|
|
204
|
-
} else if (ic === '"' || ic === "'") {
|
|
205
|
-
// Skip quoted strings — braces inside are not depth-relevant
|
|
206
|
-
inner += ic;
|
|
207
|
-
i++;
|
|
208
|
-
while (i < expr.length) {
|
|
209
|
-
const sc = expr.charAt(i);
|
|
210
|
-
if (sc === '\\' && i + 1 < expr.length) {
|
|
211
|
-
inner += sc + expr.charAt(i + 1);
|
|
212
|
-
i += 2;
|
|
213
|
-
} else if (sc === ic) {
|
|
214
|
-
inner += sc;
|
|
215
|
-
i++;
|
|
216
|
-
break;
|
|
217
|
-
} else {
|
|
218
|
-
inner += sc;
|
|
219
|
-
i++;
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
continue; // skip the i++ at the end
|
|
223
|
-
} else if (ic === '`') {
|
|
224
|
-
// Nested template literal — consume entirely
|
|
225
|
-
inner += ic;
|
|
226
|
-
i++;
|
|
227
|
-
while (i < expr.length) {
|
|
228
|
-
const tc = expr.charAt(i);
|
|
229
|
-
if (tc === '\\' && i + 1 < expr.length) {
|
|
230
|
-
inner += tc + expr.charAt(i + 1);
|
|
231
|
-
i += 2;
|
|
232
|
-
} else if (tc === '$' && expr.charAt(i + 1) === '{') {
|
|
233
|
-
// Nested interpolation — track brace depth
|
|
234
|
-
inner += '${';
|
|
235
|
-
i += 2;
|
|
236
|
-
let nestedDepth = 1;
|
|
237
|
-
while (i < expr.length && nestedDepth > 0) {
|
|
238
|
-
const nc = expr.charAt(i);
|
|
239
|
-
if (nc === '{') nestedDepth++;
|
|
240
|
-
else if (nc === '}') nestedDepth--;
|
|
241
|
-
if (nestedDepth > 0) inner += nc;
|
|
242
|
-
i++;
|
|
243
|
-
}
|
|
244
|
-
inner += '}';
|
|
245
|
-
} else if (tc === '`') {
|
|
246
|
-
inner += tc;
|
|
247
|
-
i++;
|
|
248
|
-
break;
|
|
249
|
-
} else {
|
|
250
|
-
inner += tc;
|
|
251
|
-
i++;
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
continue; // skip the i++ at the end
|
|
255
|
-
} else {
|
|
256
|
-
inner += ic;
|
|
257
|
-
}
|
|
258
|
-
i++;
|
|
259
|
-
}
|
|
260
|
-
result += transform(inner); // recursive transform
|
|
261
|
-
if (i < expr.length && expr.charAt(i) === '}') {
|
|
262
|
-
result += '}';
|
|
263
|
-
i++;
|
|
264
|
-
}
|
|
265
|
-
} else if (c === '`') {
|
|
266
|
-
result += '`';
|
|
267
|
-
i++;
|
|
268
|
-
break;
|
|
269
|
-
} else {
|
|
270
|
-
result += c;
|
|
271
|
-
i++;
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
endOperand();
|
|
275
|
-
continue;
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
// Transient reference — only where an operand is expected
|
|
279
|
-
if (ch === '%') {
|
|
280
|
-
endWord();
|
|
281
|
-
TRANS_NAME_RE.lastIndex = i + 1;
|
|
282
|
-
const name = operandNext ? TRANS_NAME_RE.exec(expr)?.[0] : undefined;
|
|
283
|
-
if (name) {
|
|
284
|
-
flushCode();
|
|
285
|
-
result += `transient["${name}"]`;
|
|
286
|
-
i += 1 + name.length;
|
|
287
|
-
endOperand();
|
|
288
|
-
continue;
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
// Regular code character
|
|
293
|
-
trackCode(ch);
|
|
294
|
-
code += ch;
|
|
295
|
-
i++;
|
|
296
|
-
}
|
|
78
|
+
result += `transient["${name}"]`;
|
|
79
|
+
},
|
|
80
|
+
});
|
|
297
81
|
flushCode();
|
|
298
82
|
return result;
|
|
299
83
|
}
|
|
@@ -401,7 +185,8 @@ export function evaluate(
|
|
|
401
185
|
transient: Record<string, unknown> = {},
|
|
402
186
|
): unknown {
|
|
403
187
|
const transformed = transform(expr);
|
|
404
|
-
|
|
188
|
+
// The line break keeps a trailing `// comment` from swallowing the `)`.
|
|
189
|
+
const body = `return (${transformed}\n);`;
|
|
405
190
|
const fn = getOrCompile(body, body);
|
|
406
191
|
return fn(variables, temporary, locals, buildExpressionFns(), transient);
|
|
407
192
|
}
|