@barefootjs/jsx 0.16.0 → 0.17.0
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/adapters/env-signal.d.ts +38 -15
- package/dist/adapters/env-signal.d.ts.map +1 -1
- package/dist/adapters/jsx-adapter.d.ts.map +1 -1
- package/dist/adapters/parsed-expr-emitter.d.ts +7 -6
- package/dist/adapters/parsed-expr-emitter.d.ts.map +1 -1
- package/dist/analyzer-context.d.ts +29 -1
- package/dist/analyzer-context.d.ts.map +1 -1
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/builtin-lowering-plugins.d.ts +34 -0
- package/dist/builtin-lowering-plugins.d.ts.map +1 -0
- package/dist/expression-parser.d.ts +219 -163
- package/dist/expression-parser.d.ts.map +1 -1
- package/dist/index.d.ts +7 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6754 -6129
- package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
- package/dist/ir-to-client-js/plan/build-declaration-emit.d.ts.map +1 -1
- package/dist/ir-to-client-js/plan/declaration-emit.d.ts +9 -0
- package/dist/ir-to-client-js/plan/declaration-emit.d.ts.map +1 -1
- package/dist/jsx-to-ir.d.ts.map +1 -1
- package/dist/lowering-registry.d.ts +122 -0
- package/dist/lowering-registry.d.ts.map +1 -0
- package/dist/query-href-lowering.d.ts +63 -0
- package/dist/query-href-lowering.d.ts.map +1 -0
- package/dist/ssr-defaults.d.ts.map +1 -1
- package/dist/types.d.ts +169 -11
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +68 -3
- package/src/__tests__/analyzer.test.ts +53 -0
- package/src/__tests__/expression-parser.test.ts +703 -391
- package/src/__tests__/ir-reduce-op.test.ts +18 -21
- package/src/__tests__/ir-sort-comparator.test.ts +19 -20
- package/src/__tests__/lowering-registry.test.ts +141 -0
- package/src/__tests__/primitive-resolver-alias.test.ts +23 -0
- package/src/__tests__/query-href-recognition.test.ts +58 -0
- package/src/__tests__/serialize-parsed-expr.test.ts +204 -0
- package/src/__tests__/unsupported-expression.test.ts +98 -4
- package/src/adapters/env-signal.ts +60 -21
- package/src/adapters/jsx-adapter.ts +17 -0
- package/src/adapters/parsed-expr-emitter.ts +39 -41
- package/src/analyzer-context.ts +72 -27
- package/src/analyzer.ts +226 -9
- package/src/builtin-lowering-plugins.ts +54 -0
- package/src/expression-parser.ts +1183 -927
- package/src/index.ts +26 -3
- package/src/ir-to-client-js/csr-substitute.ts +5 -0
- package/src/ir-to-client-js/plan/build-declaration-emit.ts +16 -0
- package/src/ir-to-client-js/plan/declaration-emit.ts +9 -0
- package/src/ir-to-client-js/stringify/declaration-emit.ts +11 -0
- package/src/jsx-to-ir.ts +182 -43
- package/src/lowering-registry.ts +160 -0
- package/src/query-href-lowering.ts +147 -0
- package/src/ssr-defaults.ts +5 -1
- package/src/types.ts +171 -12
- package/src/__tests__/flatmap-support.test.ts +0 -218
- package/src/__tests__/reduce-op.test.ts +0 -201
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
import { describe, test, expect } from 'bun:test'
|
|
2
2
|
import { analyzeComponent } from '../analyzer'
|
|
3
3
|
import { jsxToIR } from '../jsx-to-ir'
|
|
4
|
-
import { parseExpression } from '../expression-parser'
|
|
4
|
+
import { parseExpression, asCallbackMethodCall } from '../expression-parser'
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* IR-level verification for `.reduce(fn, init)` (#
|
|
8
|
-
*
|
|
9
|
-
* `
|
|
10
|
-
*
|
|
11
|
-
*
|
|
7
|
+
* IR-level verification for `.reduce(fn, init)` (#2018 P5). The parser no
|
|
8
|
+
* longer folds the reducer into a structured `ReduceOp`; `.reduce` arrives as a
|
|
9
|
+
* generic `call` whose callee is `<recv>.reduce` and whose args are the
|
|
10
|
+
* comparator `arrow` + the init literal. The adapter serializes the arrow body
|
|
11
|
+
* to the runtime evaluator. This test pins that the generic callback shape
|
|
12
|
+
* survives the analyzer → IR boundary.
|
|
12
13
|
*
|
|
13
|
-
* Hydration
|
|
14
|
-
* conformance
|
|
15
|
-
* fixtures/methods/`).
|
|
14
|
+
* Hydration / template-emit / Go==Perl==JS fold correctness are pinned at the
|
|
15
|
+
* adapter conformance + eval-vectors layers.
|
|
16
16
|
*/
|
|
17
17
|
describe('reduce(fn, init) IR shape', () => {
|
|
18
|
-
test('sum over a struct field
|
|
18
|
+
test('sum over a struct field parses to a generic reduce callback', () => {
|
|
19
19
|
const source = `
|
|
20
20
|
'use client'
|
|
21
21
|
import { createSignal } from '@barefootjs/client'
|
|
@@ -35,17 +35,14 @@ describe('reduce(fn, init) IR shape', () => {
|
|
|
35
35
|
expect(exprNode?.type).toBe('expression')
|
|
36
36
|
if (exprNode?.type !== 'expression') return
|
|
37
37
|
|
|
38
|
-
// The IR carries the expression as a source string (the same shape
|
|
39
|
-
// adapters re-parse at emit time). Round-trip it to confirm the
|
|
40
|
-
// ReduceOp catalogue match survives the analyzer → IR boundary.
|
|
41
38
|
const parsed = parseExpression(exprNode.expr)
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
expect(
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
expect(
|
|
48
|
-
|
|
49
|
-
expect(
|
|
39
|
+
const cb = asCallbackMethodCall(parsed)
|
|
40
|
+
expect(cb).not.toBeNull()
|
|
41
|
+
expect(cb!.method).toBe('reduce')
|
|
42
|
+
expect(cb!.arrow.params).toEqual(['sum', 't'])
|
|
43
|
+
// Reducer body: `sum + t.duration`.
|
|
44
|
+
expect(cb!.arrow.body.kind).toBe('binary')
|
|
45
|
+
// The init literal travels as the trailing argument.
|
|
46
|
+
expect(cb!.args).toEqual([{ kind: 'literal', value: 0, literalType: 'number', raw: '0' }])
|
|
50
47
|
})
|
|
51
48
|
})
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, test, expect } from 'bun:test'
|
|
2
2
|
import { analyzeComponent } from '../analyzer'
|
|
3
3
|
import { jsxToIR } from '../jsx-to-ir'
|
|
4
|
+
import { sortComparatorFromArrow } from '../expression-parser'
|
|
4
5
|
|
|
5
6
|
describe('sort().map() / toSorted().map()', () => {
|
|
6
7
|
test('sort((a, b) => a.price - b.price).map() produces sortComparator (asc)', () => {
|
|
@@ -31,11 +32,10 @@ describe('sort().map() / toSorted().map()', () => {
|
|
|
31
32
|
expect(loop).toBeDefined()
|
|
32
33
|
if (loop?.type === 'loop') {
|
|
33
34
|
expect(loop.sortComparator).toBeDefined()
|
|
34
|
-
expect(loop.sortComparator!.keys).toHaveLength(1)
|
|
35
|
-
expect(loop.sortComparator!.keys[0].key).toEqual({ kind: 'field', field: 'price' })
|
|
36
|
-
expect(loop.sortComparator!.keys[0].type).toBe('numeric')
|
|
37
|
-
expect(loop.sortComparator!.keys[0].direction).toBe('asc')
|
|
38
|
-
expect(loop.sortComparator!.method).toBe('sort')
|
|
35
|
+
expect(sortComparatorFromArrow(loop.sortComparator!.arrow)!.keys).toHaveLength(1)
|
|
36
|
+
expect(sortComparatorFromArrow(loop.sortComparator!.arrow)!.keys[0].key).toEqual({ kind: 'field', field: 'price' })
|
|
37
|
+
expect(sortComparatorFromArrow(loop.sortComparator!.arrow)!.keys[0].type).toBe('numeric')
|
|
38
|
+
expect(sortComparatorFromArrow(loop.sortComparator!.arrow)!.keys[0].direction).toBe('asc')
|
|
39
39
|
expect(loop.sortComparator!.paramA).toBe('a')
|
|
40
40
|
expect(loop.sortComparator!.paramB).toBe('b')
|
|
41
41
|
expect(loop.array).toBe('products()')
|
|
@@ -70,11 +70,10 @@ describe('sort().map() / toSorted().map()', () => {
|
|
|
70
70
|
expect(loop).toBeDefined()
|
|
71
71
|
if (loop?.type === 'loop') {
|
|
72
72
|
expect(loop.sortComparator).toBeDefined()
|
|
73
|
-
expect(loop.sortComparator!.keys).toHaveLength(1)
|
|
74
|
-
expect(loop.sortComparator!.keys[0].key).toEqual({ kind: 'field', field: 'price' })
|
|
75
|
-
expect(loop.sortComparator!.keys[0].type).toBe('numeric')
|
|
76
|
-
expect(loop.sortComparator!.keys[0].direction).toBe('desc')
|
|
77
|
-
expect(loop.sortComparator!.method).toBe('toSorted')
|
|
73
|
+
expect(sortComparatorFromArrow(loop.sortComparator!.arrow)!.keys).toHaveLength(1)
|
|
74
|
+
expect(sortComparatorFromArrow(loop.sortComparator!.arrow)!.keys[0].key).toEqual({ kind: 'field', field: 'price' })
|
|
75
|
+
expect(sortComparatorFromArrow(loop.sortComparator!.arrow)!.keys[0].type).toBe('numeric')
|
|
76
|
+
expect(sortComparatorFromArrow(loop.sortComparator!.arrow)!.keys[0].direction).toBe('desc')
|
|
78
77
|
}
|
|
79
78
|
}
|
|
80
79
|
})
|
|
@@ -107,10 +106,10 @@ describe('sort().map() / toSorted().map()', () => {
|
|
|
107
106
|
expect(loop.filterPredicate).toBeDefined()
|
|
108
107
|
expect(loop.filterPredicate!.param).toBe('t')
|
|
109
108
|
expect(loop.sortComparator).toBeDefined()
|
|
110
|
-
expect(loop.sortComparator!.keys).toHaveLength(1)
|
|
111
|
-
expect(loop.sortComparator!.keys[0].key).toEqual({ kind: 'field', field: 'priority' })
|
|
112
|
-
expect(loop.sortComparator!.keys[0].type).toBe('numeric')
|
|
113
|
-
expect(loop.sortComparator!.keys[0].direction).toBe('asc')
|
|
109
|
+
expect(sortComparatorFromArrow(loop.sortComparator!.arrow)!.keys).toHaveLength(1)
|
|
110
|
+
expect(sortComparatorFromArrow(loop.sortComparator!.arrow)!.keys[0].key).toEqual({ kind: 'field', field: 'priority' })
|
|
111
|
+
expect(sortComparatorFromArrow(loop.sortComparator!.arrow)!.keys[0].type).toBe('numeric')
|
|
112
|
+
expect(sortComparatorFromArrow(loop.sortComparator!.arrow)!.keys[0].direction).toBe('asc')
|
|
114
113
|
expect(loop.chainOrder).toBe('filter-sort')
|
|
115
114
|
expect(loop.array).toBe('todos()')
|
|
116
115
|
}
|
|
@@ -176,7 +175,7 @@ describe('sort().map() / toSorted().map()', () => {
|
|
|
176
175
|
expect(loop?.type).toBe('loop')
|
|
177
176
|
if (loop?.type === 'loop') {
|
|
178
177
|
expect(loop.sortComparator).toBeDefined()
|
|
179
|
-
expect(loop.sortComparator!.keys).toEqual([
|
|
178
|
+
expect(sortComparatorFromArrow(loop.sortComparator!.arrow)!.keys).toEqual([
|
|
180
179
|
{ key: { kind: 'field', field: 'price' }, type: 'numeric', direction: 'desc' },
|
|
181
180
|
{ key: { kind: 'field', field: 'name' }, type: 'string', direction: 'asc' },
|
|
182
181
|
])
|
|
@@ -209,7 +208,7 @@ describe('sort().map() / toSorted().map()', () => {
|
|
|
209
208
|
const loop = ir!.children.find(c => c.type === 'loop')
|
|
210
209
|
if (loop?.type === 'loop') {
|
|
211
210
|
expect(loop.sortComparator).toBeDefined()
|
|
212
|
-
expect(loop.sortComparator!.keys).toEqual([
|
|
211
|
+
expect(sortComparatorFromArrow(loop.sortComparator!.arrow)!.keys).toEqual([
|
|
213
212
|
{ key: { kind: 'field', field: 'rank' }, type: 'auto', direction: 'asc' },
|
|
214
213
|
])
|
|
215
214
|
}
|
|
@@ -241,7 +240,7 @@ describe('sort().map() / toSorted().map()', () => {
|
|
|
241
240
|
const loop = ir!.children.find(c => c.type === 'loop')
|
|
242
241
|
if (loop?.type === 'loop') {
|
|
243
242
|
expect(loop.sortComparator).toBeDefined()
|
|
244
|
-
expect(loop.sortComparator!.keys).toEqual([
|
|
243
|
+
expect(sortComparatorFromArrow(loop.sortComparator!.arrow)!.keys).toEqual([
|
|
245
244
|
{ key: { kind: 'self' }, type: 'auto', direction: 'asc' },
|
|
246
245
|
])
|
|
247
246
|
}
|
|
@@ -273,7 +272,7 @@ describe('sort().map() / toSorted().map()', () => {
|
|
|
273
272
|
const loop = ir!.children.find(c => c.type === 'loop')
|
|
274
273
|
if (loop?.type === 'loop') {
|
|
275
274
|
expect(loop.sortComparator).toBeDefined()
|
|
276
|
-
expect(loop.sortComparator!.keys).toEqual([
|
|
275
|
+
expect(sortComparatorFromArrow(loop.sortComparator!.arrow)!.keys).toEqual([
|
|
277
276
|
{ key: { kind: 'field', field: 'price' }, type: 'numeric', direction: 'asc' },
|
|
278
277
|
])
|
|
279
278
|
// block body unwraps to the returned expression, keeping the
|
|
@@ -310,7 +309,7 @@ describe('sort().map() / toSorted().map()', () => {
|
|
|
310
309
|
expect(loop.sortComparator).toBeDefined()
|
|
311
310
|
// The `=== ? 0` arm is a tie; direction comes from the inner
|
|
312
311
|
// relational ternary (a.rank > b.rank ? 1 : -1 → ascending).
|
|
313
|
-
expect(loop.sortComparator!.keys).toEqual([
|
|
312
|
+
expect(sortComparatorFromArrow(loop.sortComparator!.arrow)!.keys).toEqual([
|
|
314
313
|
{ key: { kind: 'field', field: 'rank' }, type: 'auto', direction: 'asc' },
|
|
315
314
|
])
|
|
316
315
|
}
|
|
@@ -342,7 +341,7 @@ describe('sort().map() / toSorted().map()', () => {
|
|
|
342
341
|
const loop = ir!.children.find(c => c.type === 'loop')
|
|
343
342
|
if (loop?.type === 'loop') {
|
|
344
343
|
expect(loop.sortComparator).toBeDefined()
|
|
345
|
-
expect(loop.sortComparator!.keys).toEqual([
|
|
344
|
+
expect(sortComparatorFromArrow(loop.sortComparator!.arrow)!.keys).toEqual([
|
|
346
345
|
{ key: { kind: 'field', field: 'price' }, type: 'numeric', direction: 'asc' },
|
|
347
346
|
{ key: { kind: 'field', field: 'rank' }, type: 'auto', direction: 'asc' },
|
|
348
347
|
])
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lowering-plugin registry (#2057) — the one seam every lowering flows through.
|
|
3
|
+
* These tests guarantee the mechanism works: a registered plugin's matcher is
|
|
4
|
+
* bound per-component and produces a backend-neutral node, and the registry gates
|
|
5
|
+
* on the plugin's own import recognition. They use a standalone SAMPLE plugin so
|
|
6
|
+
* they exercise the mechanism itself, independent of the built-in plugins (like
|
|
7
|
+
* `queryHref`) the compiler registers by default — see `builtin-lowering-plugins`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, test, expect, afterEach } from 'bun:test'
|
|
11
|
+
import {
|
|
12
|
+
registerLoweringPlugin,
|
|
13
|
+
getLoweringPlugins,
|
|
14
|
+
prepareLoweringMatchers,
|
|
15
|
+
matchLoweringCall,
|
|
16
|
+
__resetLoweringPluginsForTest,
|
|
17
|
+
type LoweringPlugin,
|
|
18
|
+
type IRMetadata,
|
|
19
|
+
type ParsedExpr,
|
|
20
|
+
} from '../index'
|
|
21
|
+
|
|
22
|
+
// A minimal sample plugin: active only when the component imports anything from
|
|
23
|
+
// `@sample/pkg`, and lowers a `demo(...)` call to a neutral `helper-call` node.
|
|
24
|
+
// This is exactly the shape a first-party / userland package would register.
|
|
25
|
+
const samplePlugin: LoweringPlugin = {
|
|
26
|
+
name: 'sample-demo',
|
|
27
|
+
prepare(metadata) {
|
|
28
|
+
const active = metadata.imports.some(i => i.source === '@sample/pkg' && !i.isTypeOnly)
|
|
29
|
+
if (!active) return null
|
|
30
|
+
return (callee, args) =>
|
|
31
|
+
callee.kind === 'identifier' && callee.name === 'demo'
|
|
32
|
+
? { kind: 'helper-call', helper: 'demo', args }
|
|
33
|
+
: null
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function metadataImporting(source: string): IRMetadata {
|
|
38
|
+
return {
|
|
39
|
+
imports: [{ source, isTypeOnly: false, specifiers: [] }],
|
|
40
|
+
} as unknown as IRMetadata
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const demoCall = { kind: 'identifier', name: 'demo' } as ParsedExpr
|
|
44
|
+
const otherCall = { kind: 'identifier', name: 'other' } as ParsedExpr
|
|
45
|
+
const arg = { kind: 'literal', value: 'x', literalType: 'string' } as ParsedExpr
|
|
46
|
+
|
|
47
|
+
// Keep the global registry clean between tests (the sample must not leak into
|
|
48
|
+
// other suites that assert on the registered set).
|
|
49
|
+
afterEach(() => {
|
|
50
|
+
const remaining = getLoweringPlugins().filter(p => p.name !== 'sample-demo')
|
|
51
|
+
__resetLoweringPluginsForTest(remaining)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
describe('lowering-plugin registry', () => {
|
|
55
|
+
test('registerLoweringPlugin adds the plugin; getLoweringPlugins returns a copy', () => {
|
|
56
|
+
registerLoweringPlugin(samplePlugin)
|
|
57
|
+
const plugins = getLoweringPlugins()
|
|
58
|
+
expect(plugins.some(p => p.name === 'sample-demo')).toBe(true)
|
|
59
|
+
// Mutating the returned array can't corrupt the registry.
|
|
60
|
+
;(plugins as LoweringPlugin[]).length = 0
|
|
61
|
+
expect(getLoweringPlugins().some(p => p.name === 'sample-demo')).toBe(true)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('re-registering the same name replaces, not duplicates (idempotent)', () => {
|
|
65
|
+
registerLoweringPlugin(samplePlugin)
|
|
66
|
+
registerLoweringPlugin(samplePlugin)
|
|
67
|
+
expect(getLoweringPlugins().filter(p => p.name === 'sample-demo')).toHaveLength(1)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test('prepare gates on the plugin recognising its own import', () => {
|
|
71
|
+
registerLoweringPlugin(samplePlugin)
|
|
72
|
+
// Active: component imports from @sample/pkg.
|
|
73
|
+
expect(prepareLoweringMatchers(metadataImporting('@sample/pkg'))).toHaveLength(1)
|
|
74
|
+
// Inactive: unrelated import → no matcher, so the adapter skips it entirely.
|
|
75
|
+
expect(prepareLoweringMatchers(metadataImporting('react'))).toHaveLength(0)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('a bound matcher lowers a recognised call to its neutral node', () => {
|
|
79
|
+
registerLoweringPlugin(samplePlugin)
|
|
80
|
+
const [matcher] = prepareLoweringMatchers(metadataImporting('@sample/pkg'))
|
|
81
|
+
expect(matcher(demoCall, [arg])).toEqual({ kind: 'helper-call', helper: 'demo', args: [arg] })
|
|
82
|
+
// A call the plugin doesn't recognise is declined (→ generic lowering).
|
|
83
|
+
expect(matcher(otherCall, [arg])).toBeNull()
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test('matchLoweringCall tries all registered plugins for the metadata', () => {
|
|
87
|
+
registerLoweringPlugin(samplePlugin)
|
|
88
|
+
expect(matchLoweringCall(demoCall, [arg], metadataImporting('@sample/pkg'))).toEqual({
|
|
89
|
+
kind: 'helper-call',
|
|
90
|
+
helper: 'demo',
|
|
91
|
+
args: [arg],
|
|
92
|
+
})
|
|
93
|
+
// No plugin recognises this metadata → null (the built-in queryHref plugin
|
|
94
|
+
// is inactive here — a `react` import isn't `@barefootjs/client`).
|
|
95
|
+
expect(matchLoweringCall(demoCall, [arg], metadataImporting('react'))).toBeNull()
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
describe('built-in plugins are applied by default', () => {
|
|
100
|
+
// `queryHref` is a built-in plugin the compiler registers on load — no
|
|
101
|
+
// `registerLoweringPlugin` call in the test. Importing `@barefootjs/jsx`
|
|
102
|
+
// (the import above) is what registers it, so it's present here for free.
|
|
103
|
+
const queryHrefCall = { kind: 'identifier', name: 'queryHref' } as ParsedExpr
|
|
104
|
+
const base = { kind: 'literal', value: '/x', literalType: 'string' } as ParsedExpr
|
|
105
|
+
const tagValue = { kind: 'literal', value: 'a', literalType: 'string' } as ParsedExpr
|
|
106
|
+
const paramsObj = {
|
|
107
|
+
kind: 'object-literal',
|
|
108
|
+
raw: '{ tag: "a" }',
|
|
109
|
+
properties: [{ key: 'tag', shorthand: false, value: tagValue }],
|
|
110
|
+
} as ParsedExpr
|
|
111
|
+
|
|
112
|
+
function importingQueryHref(): IRMetadata {
|
|
113
|
+
return {
|
|
114
|
+
imports: [
|
|
115
|
+
{
|
|
116
|
+
source: '@barefootjs/client',
|
|
117
|
+
isTypeOnly: false,
|
|
118
|
+
specifiers: [{ name: 'queryHref', alias: null, isDefault: false, isNamespace: false }],
|
|
119
|
+
},
|
|
120
|
+
],
|
|
121
|
+
} as unknown as IRMetadata
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
test('queryHref is registered without any explicit registerLoweringPlugin call', () => {
|
|
125
|
+
expect(getLoweringPlugins().some(p => p.name === 'queryHref')).toBe(true)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test('a queryHref(base, { … }) call lowers to a neutral guard-list on the `query` helper', () => {
|
|
129
|
+
const node = matchLoweringCall(queryHrefCall, [base, paramsObj], importingQueryHref())
|
|
130
|
+
expect(node).toEqual({
|
|
131
|
+
kind: 'guard-list',
|
|
132
|
+
helper: 'query',
|
|
133
|
+
base,
|
|
134
|
+
triples: [{ guard: null, key: 'tag', value: tagValue }],
|
|
135
|
+
})
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('the built-in stays inert when the component does not import queryHref', () => {
|
|
139
|
+
expect(matchLoweringCall(queryHrefCall, [base, paramsObj], metadataImporting('react'))).toBeNull()
|
|
140
|
+
})
|
|
141
|
+
})
|
|
@@ -100,6 +100,29 @@ describe('reactive primitive resolver — alias fidelity (checker path)', () =>
|
|
|
100
100
|
expect(ctx.signals[0].initialValue).toBe('0')
|
|
101
101
|
})
|
|
102
102
|
|
|
103
|
+
test('import { createSearchParams as csp }: csp() is recognized as an env signal', () => {
|
|
104
|
+
// #2057: the env-signal factory resolves through the same alias path as
|
|
105
|
+
// createSignal. The signal must be tagged `envReader` and carry the
|
|
106
|
+
// as-written callee (`csp`) so client/SSR emit `csp()`, not the canonical
|
|
107
|
+
// name — and validateReactiveFactoryCalls must NOT raise BF110 for it.
|
|
108
|
+
const filePath = path.resolve(CLIENT_DIR, '../.bench-env-alias-test.tsx')
|
|
109
|
+
const source = `
|
|
110
|
+
import { createSearchParams as csp } from '@barefootjs/client'
|
|
111
|
+
export function SortLabel() {
|
|
112
|
+
const [sp] = csp()
|
|
113
|
+
return <p>{sp().get('sort') ?? 'none'}</p>
|
|
114
|
+
}
|
|
115
|
+
`
|
|
116
|
+
const program = programFor(filePath, source)
|
|
117
|
+
const ctx = analyzeComponent(source, filePath, undefined, program)
|
|
118
|
+
expect(ctx.signals).toHaveLength(1)
|
|
119
|
+
expect(ctx.signals[0].getter).toBe('sp')
|
|
120
|
+
expect(ctx.signals[0].envReader).toBe('search')
|
|
121
|
+
expect(ctx.signals[0].envFactory).toBe('csp')
|
|
122
|
+
// No spurious BF110 (unrecognized reactive factory) for the aliased call.
|
|
123
|
+
expect(ctx.errors.some(e => e.code === 'BF110')).toBe(false)
|
|
124
|
+
})
|
|
125
|
+
|
|
103
126
|
test('user-defined function named createSignal is NOT misclassified without checker', () => {
|
|
104
127
|
// Without a checker, the fast path still matches by name. This
|
|
105
128
|
// documents the limitation — the fast path is optimistic. A shared
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `queryHrefLocalNames` — recognises the `queryHref` import (incl. aliases) so
|
|
3
|
+
* adapters can gate the `queryHref(base, { … })` → query-helper lowering (#2042).
|
|
4
|
+
*/
|
|
5
|
+
import { describe, test, expect } from 'bun:test'
|
|
6
|
+
import { compileJSX, queryHrefLocalNames, type ComponentIR } from '../index'
|
|
7
|
+
import { TestAdapter } from '../adapters/test-adapter'
|
|
8
|
+
|
|
9
|
+
function metadata(src: string): ComponentIR['metadata'] {
|
|
10
|
+
const result = compileJSX(src.trimStart(), 'T.tsx', { adapter: new TestAdapter(), outputIR: true })
|
|
11
|
+
const ir = JSON.parse(result.files.find(f => f.type === 'ir')!.content) as ComponentIR
|
|
12
|
+
return ir.metadata
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('queryHrefLocalNames (#2042)', () => {
|
|
16
|
+
test('recognises a plain queryHref import', () => {
|
|
17
|
+
const md = metadata(`
|
|
18
|
+
'use client'
|
|
19
|
+
import { queryHref } from '@barefootjs/client'
|
|
20
|
+
export function P(props: { base: string }) {
|
|
21
|
+
return <a href={queryHref(props.base, {})}>x</a>
|
|
22
|
+
}
|
|
23
|
+
`)
|
|
24
|
+
expect([...queryHrefLocalNames(md)]).toEqual(['queryHref'])
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('binds to the local alias', () => {
|
|
28
|
+
const md = metadata(`
|
|
29
|
+
'use client'
|
|
30
|
+
import { queryHref as qh } from '@barefootjs/client'
|
|
31
|
+
export function P(props: { base: string }) {
|
|
32
|
+
return <a href={qh(props.base, {})}>x</a>
|
|
33
|
+
}
|
|
34
|
+
`)
|
|
35
|
+
expect([...queryHrefLocalNames(md)]).toEqual(['qh'])
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('recognises the @barefootjs/client/runtime re-export too', () => {
|
|
39
|
+
// `queryHref` is exported from both entries; importing from the runtime entry
|
|
40
|
+
// must still enable SSR lowering, else the call hits BF101.
|
|
41
|
+
const md = metadata(`
|
|
42
|
+
'use client'
|
|
43
|
+
import { queryHref } from '@barefootjs/client/runtime'
|
|
44
|
+
export function P(props: { base: string }) {
|
|
45
|
+
return <a href={queryHref(props.base, {})}>x</a>
|
|
46
|
+
}
|
|
47
|
+
`)
|
|
48
|
+
expect([...queryHrefLocalNames(md)]).toEqual(['queryHref'])
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('is empty when not imported', () => {
|
|
52
|
+
const md = metadata(`
|
|
53
|
+
'use client'
|
|
54
|
+
export function P() { return <a href="/x">x</a> }
|
|
55
|
+
`)
|
|
56
|
+
expect(queryHrefLocalNames(md).size).toBe(0)
|
|
57
|
+
})
|
|
58
|
+
})
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { parseExpression, serializeParsedExpr, freeVarsInBody } from '../expression-parser'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `serializeParsedExpr` emits the minimal JSON the runtime evaluator (Go
|
|
6
|
+
* `eval.go` `EvalNode` / Perl `Evaluator.pm` `evaluate`) reads. These tests pin
|
|
7
|
+
* the field contract (only evaluator-read fields per kind) and the purity gate
|
|
8
|
+
* (folded / non-expression kinds → null). `freeVarsInBody` pins the captured
|
|
9
|
+
* free-var set used to build the evaluator's `base_env`. (#2018)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Parse a callback-body source and serialize it to the evaluator JSON object. */
|
|
13
|
+
function evalJSON(src: string): unknown {
|
|
14
|
+
const s = serializeParsedExpr(parseExpression(src))
|
|
15
|
+
return s === null ? null : JSON.parse(s)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('serializeParsedExpr', () => {
|
|
19
|
+
test('literal carries only `value` (no literalType / raw)', () => {
|
|
20
|
+
expect(evalJSON('42')).toEqual({ kind: 'literal', value: 42 })
|
|
21
|
+
expect(evalJSON("'hi'")).toEqual({ kind: 'literal', value: 'hi' })
|
|
22
|
+
expect(evalJSON('true')).toEqual({ kind: 'literal', value: true })
|
|
23
|
+
expect(evalJSON('null')).toEqual({ kind: 'literal', value: null })
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('identifier carries `name`', () => {
|
|
27
|
+
expect(evalJSON('acc')).toEqual({ kind: 'identifier', name: 'acc' })
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test('reducer body: binary over member projection', () => {
|
|
31
|
+
expect(evalJSON('acc + item.price')).toEqual({
|
|
32
|
+
kind: 'binary',
|
|
33
|
+
op: '+',
|
|
34
|
+
left: { kind: 'identifier', name: 'acc' },
|
|
35
|
+
// member carries object + property only (no `computed`).
|
|
36
|
+
right: {
|
|
37
|
+
kind: 'member',
|
|
38
|
+
object: { kind: 'identifier', name: 'item' },
|
|
39
|
+
property: 'price',
|
|
40
|
+
},
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('comparator body: 3-way ternary', () => {
|
|
45
|
+
expect(evalJSON('a > b ? 1 : a < b ? -1 : 0')).toEqual({
|
|
46
|
+
kind: 'conditional',
|
|
47
|
+
test: { kind: 'binary', op: '>', left: { kind: 'identifier', name: 'a' }, right: { kind: 'identifier', name: 'b' } },
|
|
48
|
+
consequent: { kind: 'literal', value: 1 },
|
|
49
|
+
alternate: {
|
|
50
|
+
kind: 'conditional',
|
|
51
|
+
test: { kind: 'binary', op: '<', left: { kind: 'identifier', name: 'a' }, right: { kind: 'identifier', name: 'b' } },
|
|
52
|
+
consequent: { kind: 'unary', op: '-', argument: { kind: 'literal', value: 1 } },
|
|
53
|
+
alternate: { kind: 'literal', value: 0 },
|
|
54
|
+
},
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('filter body: logical over member access', () => {
|
|
59
|
+
expect(evalJSON('item.done && item.priority > 3')).toEqual({
|
|
60
|
+
kind: 'logical',
|
|
61
|
+
op: '&&',
|
|
62
|
+
left: { kind: 'member', object: { kind: 'identifier', name: 'item' }, property: 'done' },
|
|
63
|
+
right: {
|
|
64
|
+
kind: 'binary',
|
|
65
|
+
op: '>',
|
|
66
|
+
left: { kind: 'member', object: { kind: 'identifier', name: 'item' }, property: 'priority' },
|
|
67
|
+
right: { kind: 'literal', value: 3 },
|
|
68
|
+
},
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('index-access carries object + index', () => {
|
|
73
|
+
expect(evalJSON('item[i]')).toEqual({
|
|
74
|
+
kind: 'index-access',
|
|
75
|
+
object: { kind: 'identifier', name: 'item' },
|
|
76
|
+
index: { kind: 'identifier', name: 'i' },
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('builtin call carries callee + args', () => {
|
|
81
|
+
expect(evalJSON('Math.max(a, b)')).toEqual({
|
|
82
|
+
kind: 'call',
|
|
83
|
+
callee: { kind: 'member', object: { kind: 'identifier', name: 'Math' }, property: 'max' },
|
|
84
|
+
args: [
|
|
85
|
+
{ kind: 'identifier', name: 'a' },
|
|
86
|
+
{ kind: 'identifier', name: 'b' },
|
|
87
|
+
],
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test('template literal: string + expression parts', () => {
|
|
92
|
+
expect(evalJSON('`n=${acc + 1}`')).toEqual({
|
|
93
|
+
kind: 'template-literal',
|
|
94
|
+
parts: [
|
|
95
|
+
{ type: 'string', value: 'n=' },
|
|
96
|
+
{ type: 'expression', expr: { kind: 'binary', op: '+', left: { kind: 'identifier', name: 'acc' }, right: { kind: 'literal', value: 1 } } },
|
|
97
|
+
],
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test('map body: object literal carries key + value (no keyKind / shorthand)', () => {
|
|
102
|
+
expect(evalJSON('({ id: item.id, n: item.n })')).toEqual({
|
|
103
|
+
kind: 'object-literal',
|
|
104
|
+
properties: [
|
|
105
|
+
{ key: 'id', value: { kind: 'member', object: { kind: 'identifier', name: 'item' }, property: 'id' } },
|
|
106
|
+
{ key: 'n', value: { kind: 'member', object: { kind: 'identifier', name: 'item' }, property: 'n' } },
|
|
107
|
+
],
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test('array literal', () => {
|
|
112
|
+
expect(evalJSON('[item.a, item.b]')).toEqual({
|
|
113
|
+
kind: 'array-literal',
|
|
114
|
+
elements: [
|
|
115
|
+
{ kind: 'member', object: { kind: 'identifier', name: 'item' }, property: 'a' },
|
|
116
|
+
{ kind: 'member', object: { kind: 'identifier', name: 'item' }, property: 'b' },
|
|
117
|
+
],
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('purity gate: a folded method call in the body → null', () => {
|
|
122
|
+
// `.toUpperCase()` folds to `array-method`, outside the evaluator surface.
|
|
123
|
+
expect(serializeParsedExpr(parseExpression('item.name.toUpperCase()'))).toBeNull()
|
|
124
|
+
// A higher-order call (`.filter`) likewise.
|
|
125
|
+
expect(serializeParsedExpr(parseExpression('item.tags.filter(t => t)'))).toBeNull()
|
|
126
|
+
// An unsupported shape.
|
|
127
|
+
expect(serializeParsedExpr(parseExpression('a instanceof B'))).toBeNull()
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test('purity gate: a folded subtree anywhere poisons the whole body', () => {
|
|
131
|
+
expect(serializeParsedExpr(parseExpression('acc + item.name.toUpperCase()'))).toBeNull()
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
test('purity gate: a non-builtin call is refused (evaluator would read it as nil)', () => {
|
|
135
|
+
// Bare function call — not on the evaluator allowlist.
|
|
136
|
+
expect(serializeParsedExpr(parseExpression('foo(item)'))).toBeNull()
|
|
137
|
+
// Method call on a value — generic `call` with a non-Math member callee.
|
|
138
|
+
expect(serializeParsedExpr(parseExpression('item.compute(2)'))).toBeNull()
|
|
139
|
+
// `parseInt` etc. are not the allowlisted `Number`/`String`/`Boolean`.
|
|
140
|
+
expect(serializeParsedExpr(parseExpression('parseInt(item.s)'))).toBeNull()
|
|
141
|
+
// A computed builtin reference the evaluator rejects (`Math['max']`).
|
|
142
|
+
expect(serializeParsedExpr(parseExpression("Math['max'](a, b)"))).toBeNull()
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
test('allowlisted builtins serialize (Math.* / String / Number / Boolean)', () => {
|
|
146
|
+
expect(serializeParsedExpr(parseExpression('Math.floor(item.x)'))).not.toBeNull()
|
|
147
|
+
expect(serializeParsedExpr(parseExpression('String(item.n)'))).not.toBeNull()
|
|
148
|
+
expect(serializeParsedExpr(parseExpression('Number(item.s)'))).not.toBeNull()
|
|
149
|
+
expect(serializeParsedExpr(parseExpression('Boolean(item.s)'))).not.toBeNull()
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
test('a computed member value carries `computed: true` (plain access omits it)', () => {
|
|
153
|
+
// `row['price']` folds to a computed `member`; the flag is preserved so a
|
|
154
|
+
// computed member stays distinguishable. (`row.price` carries no `computed`.)
|
|
155
|
+
expect(evalJSON("row['price']")).toEqual({
|
|
156
|
+
kind: 'member',
|
|
157
|
+
object: { kind: 'identifier', name: 'row' },
|
|
158
|
+
property: 'price',
|
|
159
|
+
computed: true,
|
|
160
|
+
})
|
|
161
|
+
expect(evalJSON('row.price')).toEqual({
|
|
162
|
+
kind: 'member',
|
|
163
|
+
object: { kind: 'identifier', name: 'row' },
|
|
164
|
+
property: 'price',
|
|
165
|
+
})
|
|
166
|
+
})
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
describe('freeVarsInBody', () => {
|
|
170
|
+
test('collects refs minus the callback params, sorted & deduped', () => {
|
|
171
|
+
const body = parseExpression('acc + item.price + acc')
|
|
172
|
+
expect(freeVarsInBody(body, new Set(['acc', 'item']))).toEqual([])
|
|
173
|
+
expect(freeVarsInBody(body, new Set(['acc']))).toEqual(['item'])
|
|
174
|
+
expect(freeVarsInBody(body, new Set())).toEqual(['acc', 'item'])
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
test('captures an outer free var referenced in the body (base_env source)', () => {
|
|
178
|
+
// `taxRate` is neither param — it must travel as a captured free var.
|
|
179
|
+
const body = parseExpression('acc + item.price * taxRate')
|
|
180
|
+
expect(freeVarsInBody(body, new Set(['acc', 'item']))).toEqual(['taxRate'])
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
test('member property names are not references; object-literal values are', () => {
|
|
184
|
+
// `price` (property) is not a free var; `item` and `factor` are.
|
|
185
|
+
const body = parseExpression('({ total: item.price * factor })')
|
|
186
|
+
expect(freeVarsInBody(body, new Set()).sort()).toEqual(['factor', 'item'])
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
test('template + index + call cover their value positions', () => {
|
|
190
|
+
// `Math` is a builtin callee resolved syntactically by the evaluator — it
|
|
191
|
+
// is NOT captured (it would emit an undefined `$Math` / `.Math` base_env
|
|
192
|
+
// entry). The real refs `k`, `n`, `row` are.
|
|
193
|
+
const body = parseExpression('`${row[k]}-${Math.abs(n)}`')
|
|
194
|
+
expect(freeVarsInBody(body, new Set())).toEqual(['k', 'n', 'row'])
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
test('builtin call callees (Math.<fn> / String / Number / Boolean) are not captured', () => {
|
|
198
|
+
// Each builtin is resolved syntactically by the evaluator, so its
|
|
199
|
+
// identifier must not enter base_env (Copilot review #2031). The
|
|
200
|
+
// arguments, however, ARE real references.
|
|
201
|
+
const body = parseExpression('Math.max(a, factor) + Number(label) + String(x) + Boolean(flag)')
|
|
202
|
+
expect(freeVarsInBody(body, new Set(['a']))).toEqual(['factor', 'flag', 'label', 'x'])
|
|
203
|
+
})
|
|
204
|
+
})
|