tjs-lang 0.8.2 → 0.8.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/CLAUDE.md +5 -2
- package/demo/autocomplete.test.ts +37 -0
- package/demo/docs.json +701 -11
- package/demo/src/autocomplete.ts +40 -2
- package/demo/src/introspection-bridge.ts +140 -0
- package/demo/src/introspection-doc.test.ts +63 -0
- package/demo/src/playground-shared.ts +53 -0
- package/demo/src/tjs-playground.ts +21 -0
- package/dist/experiments/predicates/css.predicates.d.ts +13 -0
- package/dist/experiments/predicates/verify.d.ts +39 -0
- package/dist/index.js +101 -97
- package/dist/index.js.map +4 -4
- package/dist/src/lang/index.d.ts +2 -0
- package/dist/src/lang/predicate-schema.d.ts +39 -0
- package/dist/src/lang/predicate.d.ts +103 -0
- package/dist/src/lang/transpiler.d.ts +2 -0
- package/dist/src/vm/runtime.d.ts +22 -0
- package/dist/tjs-eval.js +29 -29
- package/dist/tjs-eval.js.map +3 -3
- package/dist/tjs-from-ts.js +1 -1
- package/dist/tjs-from-ts.js.map +2 -2
- package/dist/tjs-lang.js +86 -82
- package/dist/tjs-lang.js.map +4 -4
- package/dist/tjs-vm.js +45 -45
- package/dist/tjs-vm.js.map +3 -3
- package/editors/codemirror/ajs-language.ts +130 -44
- package/editors/codemirror/completion-source.test.ts +114 -0
- package/editors/introspect-value.test.ts +61 -0
- package/editors/introspect-value.ts +86 -0
- package/editors/scope-symbols.test.ts +113 -0
- package/editors/scope-symbols.ts +173 -0
- package/package.json +2 -1
- package/src/lang/index.ts +22 -0
- package/src/lang/predicate-schema.test.ts +97 -0
- package/src/lang/predicate-schema.ts +168 -0
- package/src/lang/predicate.test.ts +184 -0
- package/src/lang/predicate.ts +550 -0
- package/src/lang/runtime.test.ts +46 -0
- package/src/lang/suggest.test.ts +84 -0
- package/src/lang/transpiler.ts +26 -0
- package/src/runtime.test.ts +6 -6
- package/src/vm/atom-effects.test.ts +72 -0
- package/src/vm/atoms/batteries.ts +12 -0
- package/src/vm/equality.test.ts +59 -0
- package/src/vm/runtime.ts +77 -59
|
@@ -50,6 +50,8 @@ import {
|
|
|
50
50
|
FORBIDDEN_PATTERN,
|
|
51
51
|
} from '../ajs-syntax'
|
|
52
52
|
import { FORBIDDEN_KEYWORDS as TJS_FORBIDDEN_LIST } from '../tjs-syntax'
|
|
53
|
+
import { collectScopeSymbols } from '../scope-symbols'
|
|
54
|
+
import type { IntrospectMember } from '../introspect-value'
|
|
53
55
|
|
|
54
56
|
/**
|
|
55
57
|
* Forbidden keywords in AsyncJS - these will be highlighted as errors
|
|
@@ -341,6 +343,26 @@ export interface AutocompleteConfig {
|
|
|
341
343
|
* e.g., { elements: Proxy, div: HTMLDivElement }
|
|
342
344
|
*/
|
|
343
345
|
getLiveBindings?: () => Record<string, any> | undefined
|
|
346
|
+
/**
|
|
347
|
+
* Async member completion from the introspection bridge: given a dotted path
|
|
348
|
+
* (`todoApp.items`), returns the value's REAL runtime members from the user's
|
|
349
|
+
* executed scope in a sandbox — including proxy-generated ones nothing static
|
|
350
|
+
* can see. Used as a fallback when the path doesn't resolve in sync bindings.
|
|
351
|
+
*/
|
|
352
|
+
getMembers?: (path: string) => Promise<IntrospectMember[] | undefined>
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Map a serializable introspection member into a CodeMirror completion. */
|
|
356
|
+
function memberToCompletion(m: IntrospectMember): CMCompletion {
|
|
357
|
+
if (m.type === 'method') {
|
|
358
|
+
const hasArgs = m.detail !== '()'
|
|
359
|
+
return snippetCompletion(`${m.label}(${hasArgs ? '${1}' : ''})`, {
|
|
360
|
+
label: m.label,
|
|
361
|
+
type: 'method',
|
|
362
|
+
detail: m.detail,
|
|
363
|
+
})
|
|
364
|
+
}
|
|
365
|
+
return { label: m.label, type: 'property', detail: m.detail }
|
|
344
366
|
}
|
|
345
367
|
|
|
346
368
|
// TJS keywords with snippets
|
|
@@ -616,6 +638,41 @@ function extractVariables(source: string, position: number): CMCompletion[] {
|
|
|
616
638
|
return completions
|
|
617
639
|
}
|
|
618
640
|
|
|
641
|
+
/**
|
|
642
|
+
* Scope-aware locals: replaces the regex `extractFunctions`/`extractVariables`
|
|
643
|
+
* (which missed ALL destructuring — and the tosijs examples bind everything via
|
|
644
|
+
* destructuring, so nothing was suggested). Parses with acorn (acorn-loose
|
|
645
|
+
* fallback for mid-edit source) and walks binding patterns. Falls back to the
|
|
646
|
+
* regex extractors only if parsing yields nothing, so the list never goes blank.
|
|
647
|
+
*/
|
|
648
|
+
function completionsFromScope(
|
|
649
|
+
source: string,
|
|
650
|
+
position: number
|
|
651
|
+
): CMCompletion[] {
|
|
652
|
+
const symbols = collectScopeSymbols(source, position)
|
|
653
|
+
if (symbols.length === 0) {
|
|
654
|
+
return [...extractFunctions(source), ...extractVariables(source, position)]
|
|
655
|
+
}
|
|
656
|
+
return symbols.map((s) => {
|
|
657
|
+
const detail =
|
|
658
|
+
s.origin?.member && s.origin.expr
|
|
659
|
+
? `∈ ${s.origin.expr}`
|
|
660
|
+
: s.kind === 'import' && s.origin?.module
|
|
661
|
+
? `import '${s.origin.module}'`
|
|
662
|
+
: s.kind === 'parameter'
|
|
663
|
+
? 'parameter'
|
|
664
|
+
: undefined
|
|
665
|
+
if (s.kind === 'function') {
|
|
666
|
+
return snippetCompletion(`${s.name}($1)`, {
|
|
667
|
+
label: s.name,
|
|
668
|
+
type: 'function',
|
|
669
|
+
detail,
|
|
670
|
+
})
|
|
671
|
+
}
|
|
672
|
+
return { label: s.name, type: 'variable', detail }
|
|
673
|
+
})
|
|
674
|
+
}
|
|
675
|
+
|
|
619
676
|
/**
|
|
620
677
|
* Curated property completions for common globals
|
|
621
678
|
* These are hand-picked for usefulness with proper type signatures
|
|
@@ -1203,7 +1260,7 @@ function introspectObject(obj: any): CMCompletion[] {
|
|
|
1203
1260
|
|
|
1204
1261
|
if (valueType === 'function') {
|
|
1205
1262
|
// Try to get function signature from length
|
|
1206
|
-
const fn = value as
|
|
1263
|
+
const fn = value as (...args: unknown[]) => unknown
|
|
1207
1264
|
const paramCount = fn.length
|
|
1208
1265
|
const params =
|
|
1209
1266
|
paramCount > 0
|
|
@@ -1263,32 +1320,9 @@ function getCompletionsFromLiveBinding(
|
|
|
1263
1320
|
name: string,
|
|
1264
1321
|
liveBindings?: Record<string, any>
|
|
1265
1322
|
): CMCompletion[] {
|
|
1266
|
-
// Debug: log what we're looking for and what we have
|
|
1267
|
-
console.debug(
|
|
1268
|
-
'[autocomplete] Looking for:',
|
|
1269
|
-
name,
|
|
1270
|
-
'in bindings:',
|
|
1271
|
-
liveBindings ? Object.keys(liveBindings) : 'none'
|
|
1272
|
-
)
|
|
1273
|
-
|
|
1274
1323
|
// First, check if we have a live binding for this name
|
|
1275
1324
|
if (liveBindings && name in liveBindings) {
|
|
1276
|
-
|
|
1277
|
-
console.debug(
|
|
1278
|
-
'[autocomplete] Found binding:',
|
|
1279
|
-
name,
|
|
1280
|
-
'=',
|
|
1281
|
-
value,
|
|
1282
|
-
'type:',
|
|
1283
|
-
typeof value
|
|
1284
|
-
)
|
|
1285
|
-
const completions = introspectObject(value)
|
|
1286
|
-
console.debug(
|
|
1287
|
-
'[autocomplete] Introspection returned',
|
|
1288
|
-
completions.length,
|
|
1289
|
-
'completions'
|
|
1290
|
-
)
|
|
1291
|
-
return completions
|
|
1325
|
+
return introspectObject(liveBindings[name])
|
|
1292
1326
|
}
|
|
1293
1327
|
|
|
1294
1328
|
// Special case: if the name looks like it could be an HTML element variable,
|
|
@@ -1328,14 +1362,54 @@ function getCompletionsFromLiveBinding(
|
|
|
1328
1362
|
}
|
|
1329
1363
|
|
|
1330
1364
|
/**
|
|
1331
|
-
* Extract the
|
|
1332
|
-
*
|
|
1365
|
+
* Extract the full dotted member path before a dot, e.g. `todoApp.items.` ->
|
|
1366
|
+
* `todoApp.items`. Only plain identifier chains (no calls / index access) — good
|
|
1367
|
+
* enough to walk a live value for member completion.
|
|
1333
1368
|
*/
|
|
1334
|
-
function
|
|
1335
|
-
// Look backwards from the dot to find the identifier
|
|
1369
|
+
function getPathBeforeDot(source: string, dotPos: number): string | null {
|
|
1336
1370
|
const before = source.slice(0, dotPos)
|
|
1337
|
-
const match = before.match(
|
|
1338
|
-
|
|
1371
|
+
const match = before.match(
|
|
1372
|
+
/([A-Za-z_$][\w$]*(?:\s*\.\s*[A-Za-z_$][\w$]*)*)\s*$/
|
|
1373
|
+
)
|
|
1374
|
+
return match ? match[1].replace(/\s+/g, '') : null
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
/** Walk a dotted path against a bindings object, returning the resolved value. */
|
|
1378
|
+
function resolvePath(path: string, bindings?: Record<string, any>): any {
|
|
1379
|
+
if (!bindings) return undefined
|
|
1380
|
+
const parts = path.split('.')
|
|
1381
|
+
let value: any = bindings[parts[0]]
|
|
1382
|
+
for (let i = 1; i < parts.length && value != null; i++) {
|
|
1383
|
+
try {
|
|
1384
|
+
value = value[parts[i]]
|
|
1385
|
+
} catch {
|
|
1386
|
+
return undefined
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
return value
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
/**
|
|
1393
|
+
* Member completions for a dotted path resolved against live bindings — the
|
|
1394
|
+
* core of introspection-driven completion. `todoApp.items.` resolves the array
|
|
1395
|
+
* and introspects it (push/map/…). Single-segment names that don't resolve fall
|
|
1396
|
+
* back to the element-type heuristic.
|
|
1397
|
+
*/
|
|
1398
|
+
function getCompletionsFromPath(
|
|
1399
|
+
path: string,
|
|
1400
|
+
liveBindings?: Record<string, any>
|
|
1401
|
+
): CMCompletion[] {
|
|
1402
|
+
const value = resolvePath(path, liveBindings)
|
|
1403
|
+
if (
|
|
1404
|
+
value != null &&
|
|
1405
|
+
(typeof value === 'object' || typeof value === 'function')
|
|
1406
|
+
) {
|
|
1407
|
+
return introspectObject(value)
|
|
1408
|
+
}
|
|
1409
|
+
if (!path.includes('.')) {
|
|
1410
|
+
return getCompletionsFromLiveBinding(path, liveBindings)
|
|
1411
|
+
}
|
|
1412
|
+
return []
|
|
1339
1413
|
}
|
|
1340
1414
|
|
|
1341
1415
|
/**
|
|
@@ -1395,10 +1469,13 @@ function getPlaceholderForParam(name: string, info: any): string {
|
|
|
1395
1469
|
}
|
|
1396
1470
|
|
|
1397
1471
|
/**
|
|
1398
|
-
* Create TJS/AJS completion source
|
|
1472
|
+
* Create TJS/AJS completion source.
|
|
1473
|
+
* Exported for headless testing — it touches only DOM-free CodeMirror APIs.
|
|
1399
1474
|
*/
|
|
1400
|
-
function tjsCompletionSource(config: AutocompleteConfig = {}) {
|
|
1401
|
-
return (
|
|
1475
|
+
export function tjsCompletionSource(config: AutocompleteConfig = {}) {
|
|
1476
|
+
return async (
|
|
1477
|
+
context: CMCompletionContext
|
|
1478
|
+
): Promise<CompletionResult | null> => {
|
|
1402
1479
|
try {
|
|
1403
1480
|
// Get word at cursor
|
|
1404
1481
|
const word = context.matchBefore(/[\w$]*/)
|
|
@@ -1434,16 +1511,26 @@ function tjsCompletionSource(config: AutocompleteConfig = {}) {
|
|
|
1434
1511
|
if (/expect\s*\([^)]*\)\s*\.$/.test(before)) {
|
|
1435
1512
|
options = EXPECT_MATCHERS
|
|
1436
1513
|
} else {
|
|
1437
|
-
//
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1514
|
+
// Resolve the full dotted path so `todoApp.items.` works, not just
|
|
1515
|
+
// `todoApp.`. Curated/global completions are keyed by a single name.
|
|
1516
|
+
const path = getPathBeforeDot(source, word.from - 1)
|
|
1517
|
+
if (path) {
|
|
1518
|
+
if (!path.includes('.')) {
|
|
1519
|
+
options = getPropertyCompletions(path)
|
|
1520
|
+
}
|
|
1521
|
+
// Then introspect the live value the path resolves to in the sync
|
|
1522
|
+
// bindings (resolved imports).
|
|
1444
1523
|
if (options.length === 0) {
|
|
1445
1524
|
const liveBindings = config.getLiveBindings?.()
|
|
1446
|
-
options =
|
|
1525
|
+
options = getCompletionsFromPath(path, liveBindings)
|
|
1526
|
+
}
|
|
1527
|
+
// Finally, the introspection bridge: the user's REAL executed scope
|
|
1528
|
+
// (`todoApp.items` → the live array's members), incl. proxy members.
|
|
1529
|
+
if (options.length === 0 && config.getMembers) {
|
|
1530
|
+
const members = await config.getMembers(path)
|
|
1531
|
+
if (members && members.length) {
|
|
1532
|
+
options = members.map(memberToCompletion)
|
|
1533
|
+
}
|
|
1447
1534
|
}
|
|
1448
1535
|
}
|
|
1449
1536
|
}
|
|
@@ -1462,8 +1549,7 @@ function tjsCompletionSource(config: AutocompleteConfig = {}) {
|
|
|
1462
1549
|
...TJS_COMPLETIONS,
|
|
1463
1550
|
...RUNTIME_COMPLETIONS,
|
|
1464
1551
|
...GLOBAL_COMPLETIONS,
|
|
1465
|
-
...
|
|
1466
|
-
...extractVariables(source, pos),
|
|
1552
|
+
...completionsFromScope(source, pos),
|
|
1467
1553
|
]
|
|
1468
1554
|
|
|
1469
1555
|
// Add metadata-based completions if available
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drives the REAL completion source the playground uses (tjsCompletionSource),
|
|
3
|
+
* headlessly — its APIs (matchBefore / state.doc / pos / explicit) are DOM-free.
|
|
4
|
+
*
|
|
5
|
+
* Regression for the bug where the regex extractor missed destructuring, so the
|
|
6
|
+
* tosijs todo example (everything bound via destructuring) suggested nothing.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, it, expect } from 'bun:test'
|
|
9
|
+
import { EditorState } from '@codemirror/state'
|
|
10
|
+
import { CompletionContext } from '@codemirror/autocomplete'
|
|
11
|
+
import { tjsCompletionSource } from './ajs-language'
|
|
12
|
+
import type { AutocompleteConfig } from './ajs-language'
|
|
13
|
+
|
|
14
|
+
const TODO = `import { elements, tosi } from 'tosijs'
|
|
15
|
+
const { todoApp } = tosi({ todoApp: { items: [], newItem: '' } })
|
|
16
|
+
const { h1, ul, template, li, label, input, button } = elements
|
|
17
|
+
`
|
|
18
|
+
|
|
19
|
+
async function optionsFor(
|
|
20
|
+
source: string,
|
|
21
|
+
pos: number = source.length,
|
|
22
|
+
config: AutocompleteConfig = {}
|
|
23
|
+
) {
|
|
24
|
+
const state = EditorState.create({ doc: source })
|
|
25
|
+
const ctx = new CompletionContext(state, pos, /* explicit */ true)
|
|
26
|
+
const result = await tjsCompletionSource(config)(ctx)
|
|
27
|
+
return result?.options ?? []
|
|
28
|
+
}
|
|
29
|
+
const labelsFor = async (...args: Parameters<typeof optionsFor>) =>
|
|
30
|
+
(await optionsFor(...args)).map((o) => o.label)
|
|
31
|
+
|
|
32
|
+
describe('tjsCompletionSource — scope-aware locals (live provider)', () => {
|
|
33
|
+
it('suggests destructured bindings from the real todo example', async () => {
|
|
34
|
+
const labels = await labelsFor(TODO)
|
|
35
|
+
expect(labels).toContain('todoApp')
|
|
36
|
+
expect(labels).toContain('h1')
|
|
37
|
+
expect(labels).toContain('button')
|
|
38
|
+
expect(labels).toContain('elements')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('completes `tod` to todoApp', async () => {
|
|
42
|
+
expect(await labelsFor(TODO + 'tod')).toContain('todoApp')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('annotates a destructured binding with its source (∈ elements)', async () => {
|
|
46
|
+
const opts = await optionsFor(TODO)
|
|
47
|
+
expect(opts.find((o) => o.label === 'h1')?.detail).toBe('∈ elements')
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
describe('tjsCompletionSource — member completion via live bindings (1b-i)', () => {
|
|
52
|
+
// Stand in for the user's executed scope, live (resolved imports path).
|
|
53
|
+
const liveBindings = {
|
|
54
|
+
todoApp: { items: ['bathe the cat'], newItem: '', addItem() {} },
|
|
55
|
+
}
|
|
56
|
+
const member = (tail: string) =>
|
|
57
|
+
labelsFor(`const x = 1\n${tail}`, undefined, {
|
|
58
|
+
getLiveBindings: () => liveBindings,
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('todoApp. → its real properties (items, newItem, addItem)', async () => {
|
|
62
|
+
const labels = await member('todoApp.')
|
|
63
|
+
expect(labels).toContain('items')
|
|
64
|
+
expect(labels).toContain('newItem')
|
|
65
|
+
expect(labels).toContain('addItem')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('todoApp.items. → array methods (push, map) — nested path resolves', async () => {
|
|
69
|
+
const labels = await member('todoApp.items.')
|
|
70
|
+
expect(labels).toContain('push')
|
|
71
|
+
expect(labels).toContain('map')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('unknown path resolves to nothing (no crash)', async () => {
|
|
75
|
+
expect(await member('todoApp.nope.')).toEqual([])
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
describe('tjsCompletionSource — member completion via the bridge (1b-ii)', () => {
|
|
80
|
+
// The introspection bridge: async, returns the REAL runtime members for a path
|
|
81
|
+
// the sync bindings don't know about (the user's own executed scope).
|
|
82
|
+
const getMembers = async (path: string) => {
|
|
83
|
+
if (path === 'todoApp')
|
|
84
|
+
return [
|
|
85
|
+
{ label: 'items', type: 'property' as const, detail: 'object' },
|
|
86
|
+
{ label: 'addItem', type: 'method' as const, detail: '()' },
|
|
87
|
+
]
|
|
88
|
+
if (path === 'todoApp.items')
|
|
89
|
+
return [{ label: 'push', type: 'method' as const, detail: '(arg1)' }]
|
|
90
|
+
return []
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
it('falls back to the bridge when sync bindings miss the path', async () => {
|
|
94
|
+
const labels = await labelsFor('const x = 1\ntodoApp.', undefined, {
|
|
95
|
+
getMembers,
|
|
96
|
+
})
|
|
97
|
+
expect(labels).toContain('items')
|
|
98
|
+
expect(labels).toContain('addItem')
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('bridge resolves a nested path (todoApp.items.push)', async () => {
|
|
102
|
+
const labels = await labelsFor('const x = 1\ntodoApp.items.', undefined, {
|
|
103
|
+
getMembers,
|
|
104
|
+
})
|
|
105
|
+
expect(labels).toContain('push')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('a method member becomes a callable completion', async () => {
|
|
109
|
+
const opts = await optionsFor('const x = 1\ntodoApp.', undefined, {
|
|
110
|
+
getMembers,
|
|
111
|
+
})
|
|
112
|
+
expect(opts.find((o) => o.label === 'addItem')?.type).toBe('method')
|
|
113
|
+
})
|
|
114
|
+
})
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, it, expect } from 'bun:test'
|
|
2
|
+
import { introspectValue, INTROSPECT_VALUE_SOURCE } from './introspect-value'
|
|
3
|
+
|
|
4
|
+
const labels = (v: unknown) => introspectValue(v).map((m) => m.label)
|
|
5
|
+
const member = (v: unknown, name: string) =>
|
|
6
|
+
introspectValue(v).find((m) => m.label === name)
|
|
7
|
+
|
|
8
|
+
describe('introspectValue — serializable runtime introspection', () => {
|
|
9
|
+
it('object → own properties and methods, typed', () => {
|
|
10
|
+
const todoApp = { items: [], newItem: '', addItem() {} }
|
|
11
|
+
const ls = labels(todoApp)
|
|
12
|
+
expect(ls).toContain('items')
|
|
13
|
+
expect(ls).toContain('newItem')
|
|
14
|
+
expect(ls).toContain('addItem')
|
|
15
|
+
expect(member(todoApp, 'addItem')?.type).toBe('method')
|
|
16
|
+
expect(member(todoApp, 'newItem')?.type).toBe('property')
|
|
17
|
+
expect(member(todoApp, 'newItem')?.detail).toBe('string')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('array → inherited methods from the prototype (push, map)', () => {
|
|
21
|
+
const ls = labels([1, 2, 3])
|
|
22
|
+
expect(ls).toContain('push')
|
|
23
|
+
expect(ls).toContain('map')
|
|
24
|
+
expect(ls).toContain('filter')
|
|
25
|
+
expect(member([1], 'push')?.type).toBe('method')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('method arity becomes an arg hint', () => {
|
|
29
|
+
const o = { f(a: unknown, b: unknown) {} }
|
|
30
|
+
void o.f
|
|
31
|
+
expect(member(o, 'f')?.detail).toBe('(arg1, arg2)')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('skips constructor and underscore-prefixed members', () => {
|
|
35
|
+
const ls = labels({ _private: 1, visible: 2 })
|
|
36
|
+
expect(ls).toContain('visible')
|
|
37
|
+
expect(ls).not.toContain('_private')
|
|
38
|
+
expect(ls).not.toContain('constructor')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('introspects a Proxy via own keys (tosijs `elements` shape)', () => {
|
|
42
|
+
// a proxy that materialises keys on access but DOES report own keys
|
|
43
|
+
const backing: Record<string, unknown> = { h1: () => {}, div: () => {} }
|
|
44
|
+
const proxy = new Proxy(backing, {})
|
|
45
|
+
const ls = labels(proxy)
|
|
46
|
+
expect(ls).toContain('h1')
|
|
47
|
+
expect(ls).toContain('div')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('non-objects → nothing; never throws', () => {
|
|
51
|
+
expect(introspectValue(null)).toEqual([])
|
|
52
|
+
expect(introspectValue(42)).toEqual([])
|
|
53
|
+
expect(introspectValue(undefined)).toEqual([])
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('is self-contained source (injectable into the sandbox iframe)', () => {
|
|
57
|
+
// no outer references that would break when eval-ed in another realm
|
|
58
|
+
expect(INTROSPECT_VALUE_SOURCE).toContain('function introspectValue')
|
|
59
|
+
expect(INTROSPECT_VALUE_SOURCE).not.toMatch(/\bimport\b/)
|
|
60
|
+
})
|
|
61
|
+
})
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serializable runtime introspection — the in-sandbox half of the introspection
|
|
3
|
+
* bridge. Given a live value, produce a flat list of member descriptors that
|
|
4
|
+
* survive `postMessage` (plain strings, no functions). The introspection bridge
|
|
5
|
+
* injects this function's SOURCE into the sandbox iframe (via `.toString()`), so
|
|
6
|
+
* it must be **self-contained** — no imports, no outer references.
|
|
7
|
+
*
|
|
8
|
+
* It mirrors the editor's own `introspectObject` (own keys first — important for
|
|
9
|
+
* proxies that cache accesses, like tosijs `elements` — then the prototype
|
|
10
|
+
* chain), but returns data instead of CodeMirror completions. The provider maps
|
|
11
|
+
* these descriptors back into completions on the parent side.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface IntrospectMember {
|
|
15
|
+
label: string
|
|
16
|
+
/** 'method' for callables, else 'property'. */
|
|
17
|
+
type: 'method' | 'property'
|
|
18
|
+
/** typeof for properties, an arg hint for methods. */
|
|
19
|
+
detail: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function introspectValue(value: unknown): IntrospectMember[] {
|
|
23
|
+
if (
|
|
24
|
+
value == null ||
|
|
25
|
+
(typeof value !== 'object' && typeof value !== 'function')
|
|
26
|
+
) {
|
|
27
|
+
return []
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const out: IntrospectMember[] = []
|
|
31
|
+
const seen = new Set<string>()
|
|
32
|
+
|
|
33
|
+
const push = (key: string, v: unknown) => {
|
|
34
|
+
if (key === 'constructor' || key.startsWith('_') || seen.has(key)) return
|
|
35
|
+
seen.add(key)
|
|
36
|
+
if (typeof v === 'function') {
|
|
37
|
+
const arity = (v as { length?: number }).length ?? 0
|
|
38
|
+
const params =
|
|
39
|
+
arity > 0
|
|
40
|
+
? Array.from({ length: arity }, (_, i) => `arg${i + 1}`).join(', ')
|
|
41
|
+
: ''
|
|
42
|
+
out.push({ label: key, type: 'method', detail: `(${params})` })
|
|
43
|
+
} else {
|
|
44
|
+
out.push({ label: key, type: 'property', detail: typeof v })
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Own enumerable keys first — handles Proxies that materialise on access.
|
|
49
|
+
try {
|
|
50
|
+
for (const key of Object.keys(value as object)) {
|
|
51
|
+
try {
|
|
52
|
+
push(key, (value as Record<string, unknown>)[key])
|
|
53
|
+
} catch {
|
|
54
|
+
/* getter threw — skip */
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
} catch {
|
|
58
|
+
/* Object.keys failed — continue with the prototype walk */
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Own properties + prototype chain (inherited methods like Array#push).
|
|
62
|
+
let current: any = value
|
|
63
|
+
while (
|
|
64
|
+
current &&
|
|
65
|
+
current !== Object.prototype &&
|
|
66
|
+
current !== Function.prototype
|
|
67
|
+
) {
|
|
68
|
+
for (const key of Object.getOwnPropertyNames(current)) {
|
|
69
|
+
if (key === 'constructor' || key.startsWith('_') || seen.has(key))
|
|
70
|
+
continue
|
|
71
|
+
try {
|
|
72
|
+
const d = Object.getOwnPropertyDescriptor(current, key)
|
|
73
|
+
const v = d?.value ?? (d?.get ? '[getter]' : undefined)
|
|
74
|
+
push(key, v)
|
|
75
|
+
} catch {
|
|
76
|
+
/* property threw on access — skip */
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
current = Object.getPrototypeOf(current)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return out
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The function source, for injection into the sandbox iframe. */
|
|
86
|
+
export const INTROSPECT_VALUE_SOURCE = introspectValue.toString()
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { describe, it, expect } from 'bun:test'
|
|
2
|
+
import { collectScopeSymbols, type ScopeSymbol } from './scope-symbols'
|
|
3
|
+
|
|
4
|
+
// `|` marks the cursor; returns source without it + the offset.
|
|
5
|
+
function at(src: string): { source: string; position: number } {
|
|
6
|
+
const position = src.indexOf('|')
|
|
7
|
+
if (position === -1) return { source: src, position: src.length }
|
|
8
|
+
return { source: src.slice(0, position) + src.slice(position + 1), position }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const names = (syms: ScopeSymbol[]) => syms.map((s) => s.name).sort()
|
|
12
|
+
const find = (syms: ScopeSymbol[], n: string) => syms.find((s) => s.name === n)
|
|
13
|
+
|
|
14
|
+
describe('collectScopeSymbols — destructuring-aware scope', () => {
|
|
15
|
+
it('object destructuring from a call (the bug: `const { todoApp } = tosi(...)`)', () => {
|
|
16
|
+
const { source } = at(`const { todoApp } = tosi({ items: [] })`)
|
|
17
|
+
const syms = collectScopeSymbols(source)
|
|
18
|
+
expect(names(syms)).toContain('todoApp')
|
|
19
|
+
expect(find(syms, 'todoApp')?.origin?.via).toBe('destructure')
|
|
20
|
+
expect(find(syms, 'todoApp')?.origin?.expr).toBe('tosi({ items: [] })')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('multiple names destructured from an identifier carry member + source', () => {
|
|
24
|
+
const { source } = at(`const { h1, ul, button } = elements`)
|
|
25
|
+
const syms = collectScopeSymbols(source)
|
|
26
|
+
expect(names(syms)).toEqual(['button', 'h1', 'ul'])
|
|
27
|
+
// origin is the introspection hook: `elements.h1`
|
|
28
|
+
expect(find(syms, 'h1')?.origin?.expr).toBe('elements')
|
|
29
|
+
expect(find(syms, 'h1')?.origin?.member).toBe('h1')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('array destructuring, holes skipped', () => {
|
|
33
|
+
const { source } = at(`const [first, , third] = items`)
|
|
34
|
+
expect(names(collectScopeSymbols(source))).toEqual(['first', 'third'])
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('rename binds the new name, records the source key', () => {
|
|
38
|
+
const { source } = at(`const { color: c } = theme`)
|
|
39
|
+
const syms = collectScopeSymbols(source)
|
|
40
|
+
expect(names(syms)).toEqual(['c'])
|
|
41
|
+
expect(find(syms, 'c')?.origin?.member).toBe('color')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('defaults and rest', () => {
|
|
45
|
+
const a = collectScopeSymbols(at(`const { x = 1, ...rest } = o`).source)
|
|
46
|
+
expect(names(a)).toEqual(['rest', 'x'])
|
|
47
|
+
const b = collectScopeSymbols(at(`const [head, ...tail] = xs`).source)
|
|
48
|
+
expect(names(b)).toEqual(['head', 'tail'])
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('imports (named, default, namespace, renamed)', () => {
|
|
52
|
+
const { source } = at(
|
|
53
|
+
`import def, { tosi, elements as els } from 'tosijs'\nimport * as ns from 'x'`
|
|
54
|
+
)
|
|
55
|
+
const syms = collectScopeSymbols(source)
|
|
56
|
+
expect(names(syms)).toEqual(['def', 'els', 'ns', 'tosi'])
|
|
57
|
+
expect(find(syms, 'tosi')?.origin?.module).toBe('tosijs')
|
|
58
|
+
expect(find(syms, 'els')?.kind).toBe('import')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('function name in scope; params only inside the body', () => {
|
|
62
|
+
const inside = at(`function ship(to, qty) {\n |\n}`)
|
|
63
|
+
const syms = collectScopeSymbols(inside.source, inside.position)
|
|
64
|
+
expect(names(syms)).toEqual(['qty', 'ship', 'to'])
|
|
65
|
+
|
|
66
|
+
const outside = at(`function ship(to, qty) {}\n|`)
|
|
67
|
+
const syms2 = collectScopeSymbols(outside.source, outside.position)
|
|
68
|
+
expect(names(syms2)).toContain('ship')
|
|
69
|
+
expect(names(syms2)).not.toContain('to')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('destructured params too', () => {
|
|
73
|
+
const inside = at(`function render({ title, items }) {\n |\n}`)
|
|
74
|
+
const syms = collectScopeSymbols(inside.source, inside.position)
|
|
75
|
+
expect(names(syms)).toEqual(['items', 'render', 'title'])
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('position-scoped: declarations after the cursor are excluded', () => {
|
|
79
|
+
const { source, position } = at(`const before = 1\n|\nconst after = 2`)
|
|
80
|
+
const syms = collectScopeSymbols(source, position)
|
|
81
|
+
expect(names(syms)).toContain('before')
|
|
82
|
+
expect(names(syms)).not.toContain('after')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('resilient to mid-edit / not-yet-valid source (acorn-loose)', () => {
|
|
86
|
+
// trailing incomplete line shouldn't blank out the earlier bindings
|
|
87
|
+
const { source, position } = at(`const { todoApp } = tosi({})\ntodoApp.i|`)
|
|
88
|
+
const syms = collectScopeSymbols(source, position)
|
|
89
|
+
expect(names(syms)).toContain('todoApp')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('the real tosijs todo example surfaces every binding', () => {
|
|
93
|
+
const src = `import { elements, tosi } from 'tosijs'
|
|
94
|
+
const { todoApp } = tosi({ todoApp: { items: [], newItem: '' } })
|
|
95
|
+
const { h1, ul, template, li, label, input, button } = elements
|
|
96
|
+
`
|
|
97
|
+
const got = names(collectScopeSymbols(src))
|
|
98
|
+
for (const n of [
|
|
99
|
+
'elements',
|
|
100
|
+
'tosi',
|
|
101
|
+
'todoApp',
|
|
102
|
+
'h1',
|
|
103
|
+
'ul',
|
|
104
|
+
'template',
|
|
105
|
+
'li',
|
|
106
|
+
'label',
|
|
107
|
+
'input',
|
|
108
|
+
'button',
|
|
109
|
+
]) {
|
|
110
|
+
expect(got).toContain(n)
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
})
|