jq79 0.6.4 → 0.6.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/README.md +1 -1
- package/dist/jq79.cjs +14 -13
- package/dist/jq79.cjs.map +1 -1
- package/dist/jq79.d.ts +1 -0
- package/dist/jq79.global.js +14 -13
- package/dist/jq79.global.js.map +1 -1
- package/dist/jq79.js +14 -13
- package/dist/jq79.js.map +1 -1
- package/dist/reactive.d.ts +22 -0
- package/dist/transform.d.ts +1 -0
- package/package.json +3 -1
- package/src/jq79.ts +765 -174
- package/src/reactive.ts +105 -31
- package/src/transform.ts +147 -0
package/src/reactive.ts
CHANGED
|
@@ -62,20 +62,69 @@ const walkLeaves = (obj: Record<string, any>, path: string, visit: (dotKey: stri
|
|
|
62
62
|
//
|
|
63
63
|
// Which of the two directions actually wakes an effect is the thing that makes
|
|
64
64
|
// this fast, and it is not symmetric - see effectsFor and
|
|
65
|
-
//
|
|
65
|
+
// RECORD/2026-08-23.narrow-the-wake-rule.md
|
|
66
66
|
//
|
|
67
67
|
// `children`/`own`/`deep` are allocated on first use and `parent`/`segment` let
|
|
68
68
|
// a removal walk back up without re-splitting the path: a 10,000-row table is
|
|
69
69
|
// ~40,000 nodes, and three eager allocations each (a Map and two Sets, almost
|
|
70
70
|
// all of them staying empty) cost more than the index saves
|
|
71
|
+
// The effects registered on one node, for one of the two channels. Almost every
|
|
72
|
+
// dep has exactly ONE effect - `rows.7.label` is read by one text binding and
|
|
73
|
+
// nothing else - so the first one lives in a slot of its own and a Set is
|
|
74
|
+
// allocated only for the rest. It is the mirror of what indexEffect does one
|
|
75
|
+
// layer up with soleDep/soleNode, and it is worth it because deleting an effect
|
|
76
|
+
// from a one-entry Set was 60% of disposing it
|
|
77
|
+
// (RECORD/2026-08-27.one-effect-per-node.md).
|
|
78
|
+
//
|
|
79
|
+
// TWO fields rather than one union, and that is the whole design: a field that
|
|
80
|
+
// holds either an Effect or a Set makes every read of it polymorphic, and these
|
|
81
|
+
// are read on the notify walk - the hot path of every write in the library. One
|
|
82
|
+
// field of one shape each keeps both reads monomorphic.
|
|
83
|
+
//
|
|
84
|
+
// No invariant that `one` fills before `many`: an effect removed from `one`
|
|
85
|
+
// leaves it null with `many` still populated, and nothing has to shuffle
|
|
71
86
|
type TrieNode = {
|
|
72
87
|
children: Map<string, TrieNode> | null
|
|
73
|
-
own:
|
|
74
|
-
|
|
88
|
+
own: Effect | null
|
|
89
|
+
ownMany: Set<Effect> | null
|
|
90
|
+
deep: Effect | null
|
|
91
|
+
deepMany: Set<Effect> | null
|
|
75
92
|
parent: TrieNode | null
|
|
76
93
|
segment: string
|
|
77
94
|
}
|
|
78
95
|
|
|
96
|
+
export const addOwn = (node: TrieNode, effect: Effect) => {
|
|
97
|
+
if (node.own === null) node.own = effect
|
|
98
|
+
else if (node.own !== effect) (node.ownMany ??= new Set()).add(effect)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export const addDeep = (node: TrieNode, effect: Effect) => {
|
|
102
|
+
if (node.deep === null) node.deep = effect
|
|
103
|
+
else if (node.deep !== effect) (node.deepMany ??= new Set()).add(effect)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// a node whose last effect leaves has to end up empty on both fields, or
|
|
107
|
+
// isEmptyNode stops pruning it and the trie grows over the dead rows
|
|
108
|
+
export const removeOwn = (node: TrieNode, effect: Effect) => {
|
|
109
|
+
if (node.own === effect) node.own = null
|
|
110
|
+
else if (node.ownMany?.delete(effect) && node.ownMany.size === 0) node.ownMany = null
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export const removeDeep = (node: TrieNode, effect: Effect) => {
|
|
114
|
+
if (node.deep === effect) node.deep = null
|
|
115
|
+
else if (node.deepMany?.delete(effect) && node.deepMany.size === 0) node.deepMany = null
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export const eachOwn = (node: TrieNode, visit: (effect: Effect) => void) => {
|
|
119
|
+
if (node.own !== null) visit(node.own)
|
|
120
|
+
node.ownMany?.forEach(visit)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export const eachDeep = (node: TrieNode, visit: (effect: Effect) => void) => {
|
|
124
|
+
if (node.deep !== null) visit(node.deep)
|
|
125
|
+
node.deepMany?.forEach(visit)
|
|
126
|
+
}
|
|
127
|
+
|
|
79
128
|
// the dep an `ownKeys` read records, as a reserved last segment on the
|
|
80
129
|
// enumerated object's own path: an ordinary trie child that no walk for a real
|
|
81
130
|
// key can reach, and that the subtree sweep still reaches when the whole object
|
|
@@ -92,10 +141,10 @@ const LENGTH_SUFFIX = ".length"
|
|
|
92
141
|
|
|
93
142
|
|
|
94
143
|
const createTrieNode = (parent: TrieNode | null, segment: string): TrieNode =>
|
|
95
|
-
({ children: null, own: null, deep: null, parent, segment })
|
|
144
|
+
({ children: null, own: null, ownMany: null, deep: null, deepMany: null, parent, segment })
|
|
96
145
|
|
|
97
146
|
const isEmptyNode = (node: TrieNode): boolean =>
|
|
98
|
-
!node.
|
|
147
|
+
node.own === null && node.deep === null && !node.ownMany?.size && !node.deepMany?.size && !node.children?.size
|
|
99
148
|
|
|
100
149
|
// reads the raw object behind a store proxy. Module-level (not per-store) so a
|
|
101
150
|
// value that is already reactive - in this store or in another one - can be
|
|
@@ -144,7 +193,7 @@ export const untracked = <T>(fn: () => T): T => {
|
|
|
144
193
|
// own, plus any it was attached to), each keeping that store's trie in step
|
|
145
194
|
// with the deps of the last settled run. It lives on the effect rather than
|
|
146
195
|
// in a per-store map because `run` has to reach it without a lookup
|
|
147
|
-
type Effect = { deps: Set<string>; run: () => void; reindex: Set<(deps: Set<string>) => void>; deep: boolean; order: number }
|
|
196
|
+
export type Effect = { deps: Set<string>; run: () => void; reindex: Set<(deps: Set<string>) => void>; deep: boolean; order: number }
|
|
148
197
|
|
|
149
198
|
// creation order, module-wide. The flat `effects` set used to give this for
|
|
150
199
|
// free - iterating it ran effects oldest-first, so a parent's bindings always
|
|
@@ -224,7 +273,8 @@ export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepD
|
|
|
224
273
|
}
|
|
225
274
|
node = child
|
|
226
275
|
})
|
|
227
|
-
|
|
276
|
+
if (effect.deep) addDeep(node, effect)
|
|
277
|
+
else addOwn(node, effect)
|
|
228
278
|
return node
|
|
229
279
|
}
|
|
230
280
|
|
|
@@ -234,7 +284,8 @@ export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepD
|
|
|
234
284
|
// table is 30,000 of these, and splitting each path again to find a node the
|
|
235
285
|
// caller was already holding is most of what that used to cost
|
|
236
286
|
const removeDep = (node: TrieNode, effect: Effect) => {
|
|
237
|
-
|
|
287
|
+
if (effect.deep) removeDeep(node, effect)
|
|
288
|
+
else removeOwn(node, effect)
|
|
238
289
|
let current: TrieNode | null = node
|
|
239
290
|
while (current?.parent && isEmptyNode(current)) {
|
|
240
291
|
current.parent.children!.delete(current.segment)
|
|
@@ -262,7 +313,7 @@ export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepD
|
|
|
262
313
|
// `data[5].label = x`: an effect that read the array on its way to row
|
|
263
314
|
// 7's label has no stake in row 5's, and waking all of them is what made
|
|
264
315
|
// 100 row writes cost 100,000 effect runs - see
|
|
265
|
-
//
|
|
316
|
+
// RECORD/2026-08-23.narrow-the-wake-rule.md
|
|
266
317
|
//
|
|
267
318
|
// Returned as a snapshot, so the effects it wakes can reindex themselves -
|
|
268
319
|
// or dispose each other - while it drains
|
|
@@ -272,8 +323,8 @@ export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepD
|
|
|
272
323
|
const pending = from.children ? [...from.children.values()] : []
|
|
273
324
|
while (pending.length) {
|
|
274
325
|
const next = pending.pop()!
|
|
275
|
-
next
|
|
276
|
-
next
|
|
326
|
+
eachOwn(next, effect => matched.add(effect))
|
|
327
|
+
eachDeep(next, effect => matched.add(effect))
|
|
277
328
|
next.children?.forEach(child => pending.push(child))
|
|
278
329
|
}
|
|
279
330
|
}
|
|
@@ -285,21 +336,21 @@ export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepD
|
|
|
285
336
|
node = node.children?.get(segments[depth])
|
|
286
337
|
if (!node) return matched
|
|
287
338
|
path = path ? `${path}.${segments[depth]}` : segments[depth]
|
|
288
|
-
node
|
|
339
|
+
eachDeep(node, effect => matched.add(effect))
|
|
289
340
|
// a nested store sits here: an effect that read through it holds this
|
|
290
341
|
// path and nothing below it, so its own set is the whole channel
|
|
291
|
-
if (bridges.has(path)) node
|
|
342
|
+
if (bridges.has(path)) eachOwn(node, effect => matched.add(effect))
|
|
292
343
|
// ...whereas an array's length stands for the array: everything that
|
|
293
344
|
// read an element has to hear a truncation, and those deps are below
|
|
294
345
|
if (depth === segments.length - 2 && segments[depth + 1] === "length") {
|
|
295
|
-
node
|
|
346
|
+
eachOwn(node, effect => matched.add(effect))
|
|
296
347
|
sweep(node)
|
|
297
348
|
}
|
|
298
349
|
}
|
|
299
350
|
node = node.children?.get(segments[segments.length - 1])
|
|
300
351
|
if (!node) return matched
|
|
301
|
-
node
|
|
302
|
-
node
|
|
352
|
+
eachOwn(node, effect => matched.add(effect))
|
|
353
|
+
eachDeep(node, effect => matched.add(effect))
|
|
303
354
|
sweep(node)
|
|
304
355
|
return matched
|
|
305
356
|
}
|
|
@@ -458,7 +509,12 @@ export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepD
|
|
|
458
509
|
// holding the same elements (see notifyReplaced)
|
|
459
510
|
const wakeExactly = (dep: string) => {
|
|
460
511
|
const node = nodeAt(dep)
|
|
461
|
-
if (node)
|
|
512
|
+
if (node) {
|
|
513
|
+
const matched = new Set<Effect>()
|
|
514
|
+
eachOwn(node, effect => matched.add(effect))
|
|
515
|
+
eachDeep(node, effect => matched.add(effect))
|
|
516
|
+
runMatched(matched)
|
|
517
|
+
}
|
|
462
518
|
}
|
|
463
519
|
|
|
464
520
|
// an object's key set changed. Effects only - a key set isn't a value, so
|
|
@@ -506,9 +562,6 @@ export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepD
|
|
|
506
562
|
isPlainData(previous) && isPlainData(next) &&
|
|
507
563
|
Array.isArray(previous) === Array.isArray(next)
|
|
508
564
|
|
|
509
|
-
const keyCount = (container: any): number =>
|
|
510
|
-
Array.isArray(container) ? container.length : Object.keys(container).length
|
|
511
|
-
|
|
512
565
|
// which keys hold a different value than they did, or null when so many do
|
|
513
566
|
// that notifying them one at a time would cost more than sweeping the
|
|
514
567
|
// container. Arrays - the case this exists for - are walked by index, so a
|
|
@@ -520,7 +573,12 @@ export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepD
|
|
|
520
573
|
// `exact` marks the keys as slots whose occupant *moved* rather than values
|
|
521
574
|
// that changed: nothing under them is different, so the subtree each one
|
|
522
575
|
// would otherwise sweep must be left alone (see splicedAt)
|
|
523
|
-
|
|
576
|
+
// `keysChanged` answers the `@keys` dep - what an effect records when it reads
|
|
577
|
+
// Object.keys(container). It is not `keys.length > 0`: a replacement can
|
|
578
|
+
// change every value and no key name at all, and it used to be decided by
|
|
579
|
+
// comparing key COUNTS, which misses swapping one name for another
|
|
580
|
+
// (RECORD/2026-08-25.two-defects-a-review-found.md)
|
|
581
|
+
type Difference = { keys: string[]; exact: boolean; keysChanged: boolean }
|
|
524
582
|
|
|
525
583
|
const NOT_SPLICED = -1
|
|
526
584
|
|
|
@@ -530,7 +588,7 @@ export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepD
|
|
|
530
588
|
// container differs" to the walk below, which gives up and has the whole
|
|
531
589
|
// subtree swept; this recognises the shift for what it is. O(n), no
|
|
532
590
|
// allocation, and O(1) to reject on any pair whose lengths differ by
|
|
533
|
-
// anything but one. See
|
|
591
|
+
// anything but one. See RECORD/2026-08-23.notify-a-splice.md
|
|
534
592
|
const splicedAt = (previous: any[], next: any[]): number => {
|
|
535
593
|
const grew = next.length > previous.length
|
|
536
594
|
const shorter = grew ? previous : next
|
|
@@ -547,6 +605,9 @@ export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepD
|
|
|
547
605
|
if (Array.isArray(next)) {
|
|
548
606
|
const before = previous.length
|
|
549
607
|
const after = next.length
|
|
608
|
+
// an array's keys are 0..length-1, so its length IS its key set - and
|
|
609
|
+
// nothing here walks the 10,000 elements a second time to learn that
|
|
610
|
+
const keysChanged = before !== after
|
|
550
611
|
// nothing on one side means nothing to reuse on the other
|
|
551
612
|
if (!before || !after) return GIVE_UP
|
|
552
613
|
const span = Math.max(before, after)
|
|
@@ -555,7 +616,7 @@ export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepD
|
|
|
555
616
|
if (start !== NOT_SPLICED) {
|
|
556
617
|
const slots: string[] = []
|
|
557
618
|
for (let index = start; index < span; index++) slots.push(String(index))
|
|
558
|
-
return { keys: slots, exact: true }
|
|
619
|
+
return { keys: slots, exact: true, keysChanged }
|
|
559
620
|
}
|
|
560
621
|
}
|
|
561
622
|
const changed: string[] = []
|
|
@@ -564,13 +625,22 @@ export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepD
|
|
|
564
625
|
changed.push(String(index))
|
|
565
626
|
if (changed.length * 2 >= span) return GIVE_UP
|
|
566
627
|
}
|
|
567
|
-
return { keys: changed, exact: false }
|
|
628
|
+
return { keys: changed, exact: false, keysChanged }
|
|
568
629
|
}
|
|
569
|
-
const
|
|
630
|
+
const previousKeys = Object.keys(previous)
|
|
631
|
+
const nextKeys = Object.keys(next)
|
|
632
|
+
const keys = new Set([...previousKeys, ...nextKeys])
|
|
570
633
|
if (!keys.size) return GIVE_UP
|
|
571
634
|
const changed: string[] = []
|
|
572
635
|
keys.forEach(key => { if (!Object.is($toRaw(previous[key]), $toRaw(next[key]))) changed.push(key) })
|
|
573
|
-
|
|
636
|
+
// Object.keys answers in insertion order, so the ORDER is part of the answer
|
|
637
|
+
// and a rebuild that sorts the same names - Object.fromEntries(entries.sort())
|
|
638
|
+
// - changes what an effect reading it sees. A union larger than either side
|
|
639
|
+
// would catch a swapped name; walking the two arrays catches that and the
|
|
640
|
+
// reorder both, off arrays this already had to build
|
|
641
|
+
const keysChanged =
|
|
642
|
+
previousKeys.length !== nextKeys.length || previousKeys.some((key, index) => key !== nextKeys[index])
|
|
643
|
+
return changed.length * 2 >= keys.size ? GIVE_UP : { keys: changed, exact: false, keysChanged }
|
|
574
644
|
}
|
|
575
645
|
|
|
576
646
|
// A container replaced by another container: notify the elements that
|
|
@@ -578,7 +648,7 @@ export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepD
|
|
|
578
648
|
// `data = [...data, ...more]` holds the very same row objects at every index
|
|
579
649
|
// it had before, so waking all thirty thousand of their bindings to re-render
|
|
580
650
|
// identical output is ~150ms of a 208ms append - see
|
|
581
|
-
//
|
|
651
|
+
// RECORD/2026-08-23.notify-the-difference.md
|
|
582
652
|
//
|
|
583
653
|
// One level deep on purpose: an element that differs is a changed value, and
|
|
584
654
|
// notifying it sweeps its own subtree, which is what a changed value deserves.
|
|
@@ -591,8 +661,10 @@ export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepD
|
|
|
591
661
|
const matched = new Set<Effect>()
|
|
592
662
|
const collectExact = (dep: string) => {
|
|
593
663
|
const node = nodeAt(dep)
|
|
594
|
-
|
|
595
|
-
|
|
664
|
+
if (node) {
|
|
665
|
+
eachOwn(node, effect => matched.add(effect))
|
|
666
|
+
eachDeep(node, effect => matched.add(effect))
|
|
667
|
+
}
|
|
596
668
|
}
|
|
597
669
|
|
|
598
670
|
const difference = whatChanged(previous, next)
|
|
@@ -631,7 +703,9 @@ export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepD
|
|
|
631
703
|
if (difference.exact) collectExact(child)
|
|
632
704
|
else effectsFor(child).forEach(effect => matched.add(effect))
|
|
633
705
|
})
|
|
634
|
-
|
|
706
|
+
// the key SET, not the count: replacing { a, b, c, d } with { a, b, c, e }
|
|
707
|
+
// leaves both at four and changes what Object.keys answers
|
|
708
|
+
if (difference.keysChanged) collectExact(keysPath(dotKey))
|
|
635
709
|
// an array's length is a real dep (a `:each` reads it on its way through
|
|
636
710
|
// list.map) and is not one of the keys walked above. Collected exactly:
|
|
637
711
|
// the sweep a length write normally carries is for a truncation, and the
|
|
@@ -939,7 +1013,7 @@ export type EffectScope = {
|
|
|
939
1013
|
// is 70,000 objects that exist to hold, on the common path, three disposers.
|
|
940
1014
|
// The prototype's methods are shared, and a row that registers nothing (a
|
|
941
1015
|
// static template) now allocates one object and no arrays at all.
|
|
942
|
-
// See
|
|
1016
|
+
// See RECORD/2026-08-23.where-the-create-time-goes.md
|
|
943
1017
|
//
|
|
944
1018
|
// Prototype methods need their receiver: a caller that hands one on as a bare
|
|
945
1019
|
// function (`runSetupScript(..., fx.effect, ...)` did) must wrap it instead
|
package/src/transform.ts
CHANGED
|
@@ -752,3 +752,150 @@ export const transformFactoryScript = (src: string): string | null => {
|
|
|
752
752
|
return isFactory ? out : null
|
|
753
753
|
}
|
|
754
754
|
|
|
755
|
+
|
|
756
|
+
// ---------------------------------------------------------------------------
|
|
757
|
+
// free identifiers in a template expression
|
|
758
|
+
//
|
|
759
|
+
// What a compiled expression reads from the scope, so it can be resolved by
|
|
760
|
+
// name instead of by `with ($scope)` - which is 60% of an evaluation over a
|
|
761
|
+
// store proxy, because it resolves every mention through [[HasProperty]] (the
|
|
762
|
+
// proxy's `has` trap as well as its `get`) and consults Symbol.unscopables
|
|
763
|
+
// besides. See RECORD/2026-08-27.name-resolution-without-with.md
|
|
764
|
+
//
|
|
765
|
+
// This is a scanner, not a parser, and it is deliberately conservative: `null`
|
|
766
|
+
// means "keep `with`", and every shape that binds a name, assigns to one or
|
|
767
|
+
// resolves one dynamically returns it. The caller falls back rather than
|
|
768
|
+
// guesses, so a name this misses costs a slower evaluation, never a wrong one.
|
|
769
|
+
// ---------------------------------------------------------------------------
|
|
770
|
+
|
|
771
|
+
// words that scan as an identifier and are never a free variable read
|
|
772
|
+
const NOT_A_FREE_NAME = new Set([
|
|
773
|
+
"true", "false", "null", "undefined", "this", "typeof", "instanceof", "in", "new", "void",
|
|
774
|
+
"delete", "of", "await", "yield", "NaN", "Infinity", "arguments", "case", "do", "else", "return",
|
|
775
|
+
])
|
|
776
|
+
|
|
777
|
+
// anything that introduces a binding or reaches the scope by another route.
|
|
778
|
+
// A dynamic `import(...)` is absent deliberately: naming it here would put the
|
|
779
|
+
// word next to a quote in the bundle, which is what tests/no-bundle.test.ts
|
|
780
|
+
// greps for. It needs no entry - `import` is collected as a free name, the
|
|
781
|
+
// prologue's `const import = …` will not compile, and compileScoped falls back
|
|
782
|
+
const BINDS_A_NAME = new Set(["function", "class", "let", "const", "var", "eval", "with"])
|
|
783
|
+
|
|
784
|
+
// the compiled function's own parameters and temporary: an expression that
|
|
785
|
+
// mentions one would be reading the codegen's variable, not the author's
|
|
786
|
+
const CODEGEN_NAMES = new Set(["$scope", "$r", "$t"])
|
|
787
|
+
|
|
788
|
+
// operators that write, or bind - any of them and the expression keeps `with`
|
|
789
|
+
const WRITES_RE = /^(?:\+\+|--|=>|\*\*=|<<=|>>>=|>>=|&&=|\|\|=|\?\?=|[+\-*/%&|^]=(?!=))/
|
|
790
|
+
// operators that merely read, longest first so `===` is never read as `==` + `=`
|
|
791
|
+
const READS_RE = /^(?:>>>|===|!==|\*\*|<<|>>|==|!=|<=|>=|&&|\|\||\?\?|\?\.)/
|
|
792
|
+
|
|
793
|
+
const isIdentifierStart = (ch: string): boolean => /[A-Za-z_$]/.test(ch)
|
|
794
|
+
|
|
795
|
+
// a numeric literal, consumed whole so `1e5` does not scan as the name `e5`
|
|
796
|
+
const NUMBER_RE = /^(?:0[xXbBoO][\da-fA-F_]+|\d[\d_]*(?:\.[\d_]*)?(?:[eE][+-]?\d+)?)n?/
|
|
797
|
+
|
|
798
|
+
export const freeIdentifiers = (expr: string): string[] | null => {
|
|
799
|
+
const names = new Set<string>()
|
|
800
|
+
// one entry per open bracket, counting the `?`s waiting for their `:` at that
|
|
801
|
+
// depth - which is what tells `{ danger: x }`'s key from `a ? b : c`'s colon
|
|
802
|
+
const ternaries: number[] = [0]
|
|
803
|
+
const brackets: string[] = []
|
|
804
|
+
let shortCircuits = false
|
|
805
|
+
let prev = "" // the last meaningful token: "name", "value", "." or "?." or a punctuator
|
|
806
|
+
let i = 0
|
|
807
|
+
|
|
808
|
+
while (i < expr.length) {
|
|
809
|
+
const ch = expr[i]
|
|
810
|
+
if (/\s/.test(ch)) { i++; continue }
|
|
811
|
+
if (ch === "/" && expr[i + 1] === "/") { i = skipLineComment(expr, i); continue }
|
|
812
|
+
if (ch === "/" && expr[i + 1] === "*") { i = skipBlockComment(expr, i); continue }
|
|
813
|
+
if (ch === "/" && regexAllowed(expr, i)) { i = skipRegex(expr, i); prev = "value"; continue }
|
|
814
|
+
if (ch === '"' || ch === "'") { i = skipString(expr, i); prev = "value"; continue }
|
|
815
|
+
|
|
816
|
+
if (ch === "`") {
|
|
817
|
+
// a template literal reads names inside every ${...}, and nothing else
|
|
818
|
+
let j = i + 1
|
|
819
|
+
while (j < expr.length) {
|
|
820
|
+
if (expr[j] === "\\") { j += 2; continue }
|
|
821
|
+
if (expr[j] === "`") { j++; break }
|
|
822
|
+
if (expr[j] === "$" && expr[j + 1] === "{") {
|
|
823
|
+
let depth = 1
|
|
824
|
+
const start = (j += 2)
|
|
825
|
+
while (j < expr.length && depth > 0) {
|
|
826
|
+
if (expr[j] === "{") depth++
|
|
827
|
+
else if (expr[j] === "}") depth--
|
|
828
|
+
j++
|
|
829
|
+
}
|
|
830
|
+
const inner = freeIdentifiers(expr.slice(start, j - 1))
|
|
831
|
+
if (inner === null) return null
|
|
832
|
+
inner.forEach(name => names.add(name))
|
|
833
|
+
continue
|
|
834
|
+
}
|
|
835
|
+
j++
|
|
836
|
+
}
|
|
837
|
+
i = j
|
|
838
|
+
prev = "value"
|
|
839
|
+
continue
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
if (/\d/.test(ch) && prev !== "." && prev !== "?.") {
|
|
843
|
+
const match = NUMBER_RE.exec(expr.slice(i))
|
|
844
|
+
i += match ? match[0].length : 1
|
|
845
|
+
prev = "value"
|
|
846
|
+
continue
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
if (isIdentifierStart(ch)) {
|
|
850
|
+
let end = i
|
|
851
|
+
while (end < expr.length && /[\w$]/.test(expr[end])) end++
|
|
852
|
+
const word = expr.slice(i, end)
|
|
853
|
+
i = end
|
|
854
|
+
if (BINDS_A_NAME.has(word)) return null
|
|
855
|
+
if (prev === "." || prev === "?.") { prev = "value"; continue } // a property, not a name
|
|
856
|
+
if (NOT_A_FREE_NAME.has(word)) { prev = word === "this" ? "value" : "op"; continue }
|
|
857
|
+
if (CODEGEN_NAMES.has(word)) return null
|
|
858
|
+
let after = i
|
|
859
|
+
while (after < expr.length && /\s/.test(expr[after])) after++
|
|
860
|
+
// A free name that is CALLED is called with the with-object as its
|
|
861
|
+
// receiver: `add()` inside `with ($scope)` runs with `this === $scope`,
|
|
862
|
+
// and a factory component's `{ items: [], add() { this.items… } }`
|
|
863
|
+
// depends on it. A `const` cannot reproduce that - the call would get
|
|
864
|
+
// `undefined` - so a called name keeps `with`. Measured at 11 of this
|
|
865
|
+
// repository's 239 resolvable expressions, and 8 of those are handlers
|
|
866
|
+
if (expr[after] === "(") return null
|
|
867
|
+
// `{ danger: … }` names a key; `a ? b : c` names a value
|
|
868
|
+
const isKey = expr[after] === ":" && expr[after + 1] !== ":" &&
|
|
869
|
+
brackets[brackets.length - 1] === "{" && ternaries[ternaries.length - 1] === 0
|
|
870
|
+
if (!isKey) names.add(word)
|
|
871
|
+
prev = "value"
|
|
872
|
+
continue
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
const rest = expr.slice(i)
|
|
876
|
+
if (WRITES_RE.test(rest)) return null
|
|
877
|
+
const reads = READS_RE.exec(rest)
|
|
878
|
+
if (reads) {
|
|
879
|
+
if (reads[0] === "&&" || reads[0] === "||" || reads[0] === "??") shortCircuits = true
|
|
880
|
+
prev = reads[0] === "?." ? "?." : "op"
|
|
881
|
+
i += reads[0].length
|
|
882
|
+
continue
|
|
883
|
+
}
|
|
884
|
+
if (ch === "=") return null // a bare `=` left after READS_RE is an assignment
|
|
885
|
+
|
|
886
|
+
if (ch === "(" || ch === "[" || ch === "{") { brackets.push(ch); ternaries.push(0) }
|
|
887
|
+
else if (ch === ")" || ch === "]" || ch === "}") { brackets.pop(); if (ternaries.length > 1) ternaries.pop() }
|
|
888
|
+
else if (ch === "?") { ternaries[ternaries.length - 1]++; shortCircuits = true }
|
|
889
|
+
else if (ch === ":" && ternaries[ternaries.length - 1] > 0) ternaries[ternaries.length - 1]--
|
|
890
|
+
|
|
891
|
+
prev = ch
|
|
892
|
+
i++
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// every name is resolved before the expression runs, so a branch that would
|
|
896
|
+
// not have been taken is read anyway: one more dep than `with` would track,
|
|
897
|
+
// and a ReferenceError for a name only the dead branch mentions. With one
|
|
898
|
+
// name there is no branch to be wrong about
|
|
899
|
+
if (shortCircuits && names.size > 1) return null
|
|
900
|
+
return [...names]
|
|
901
|
+
}
|