tjs-lang 0.8.2 → 0.8.3
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 +4 -1
- package/demo/autocomplete.test.ts +37 -0
- package/demo/docs.json +698 -8
- 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/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/suggest.test.ts +84 -0
- package/src/lang/transpiler.ts +26 -0
- package/src/vm/atom-effects.test.ts +72 -0
- package/src/vm/atoms/batteries.ts +12 -0
- package/src/vm/runtime.ts +51 -0
package/demo/src/autocomplete.ts
CHANGED
|
@@ -5,6 +5,11 @@
|
|
|
5
5
|
* Uses __tjs metadata for rich type information.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import {
|
|
9
|
+
collectScopeSymbols,
|
|
10
|
+
type ScopeSymbol,
|
|
11
|
+
} from '../../editors/scope-symbols'
|
|
12
|
+
|
|
8
13
|
export interface Completion {
|
|
9
14
|
/** The text to insert */
|
|
10
15
|
label: string
|
|
@@ -277,6 +282,40 @@ function isInTestBlock(source: string, position: number): boolean {
|
|
|
277
282
|
return testMatches.length > 0
|
|
278
283
|
}
|
|
279
284
|
|
|
285
|
+
/**
|
|
286
|
+
* Completions from the scope-aware symbol model (acorn-based, destructuring-aware).
|
|
287
|
+
* Subsumes the old regex `extractFunctions`/`extractVariables`, which missed all
|
|
288
|
+
* destructuring. Falls back to the regex extractors only if parsing yields
|
|
289
|
+
* nothing (so the list never goes blank on pathological input).
|
|
290
|
+
*/
|
|
291
|
+
function completionsFromScope(source: string, position: number): Completion[] {
|
|
292
|
+
const symbols = collectScopeSymbols(source, position)
|
|
293
|
+
if (symbols.length === 0) {
|
|
294
|
+
return [...extractFunctions(source), ...extractVariables(source, position)]
|
|
295
|
+
}
|
|
296
|
+
return symbols.map((s) => scopeSymbolToCompletion(s))
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function scopeSymbolToCompletion(s: ScopeSymbol): Completion {
|
|
300
|
+
const kind: Completion['kind'] =
|
|
301
|
+
s.kind === 'function' ? 'function' : 'variable'
|
|
302
|
+
// For a destructured binding, show where it came from — `h1 ∈ elements`.
|
|
303
|
+
const detail =
|
|
304
|
+
s.origin?.member && s.origin.expr
|
|
305
|
+
? `∈ ${s.origin.expr}`
|
|
306
|
+
: s.kind === 'import' && s.origin?.module
|
|
307
|
+
? `import '${s.origin.module}'`
|
|
308
|
+
: s.kind === 'parameter'
|
|
309
|
+
? 'parameter'
|
|
310
|
+
: undefined
|
|
311
|
+
return {
|
|
312
|
+
label: s.name,
|
|
313
|
+
kind,
|
|
314
|
+
detail,
|
|
315
|
+
snippet: kind === 'function' ? `${s.name}($1)` : undefined,
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
280
319
|
/**
|
|
281
320
|
* Extract function names from source
|
|
282
321
|
*/
|
|
@@ -418,8 +457,7 @@ export function getCompletions(ctx: CompletionContext): Completion[] {
|
|
|
418
457
|
completions = [
|
|
419
458
|
...TJS_KEYWORDS,
|
|
420
459
|
...RUNTIME_COMPLETIONS,
|
|
421
|
-
...
|
|
422
|
-
...extractVariables(source, position),
|
|
460
|
+
...completionsFromScope(source, position),
|
|
423
461
|
]
|
|
424
462
|
|
|
425
463
|
// Add metadata-based completions
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Introspection bridge — the runtime-truth half of the autocomplete plan.
|
|
3
|
+
*
|
|
4
|
+
* Chrome-console style: run the user's code in a hidden, disposable sandbox
|
|
5
|
+
* iframe and keep a live `eval` handle into its module scope. Member completion
|
|
6
|
+
* (`todoApp.items.` → the real array's members; `elements.` → tosijs's
|
|
7
|
+
* proxy-generated creators) then comes from the ACTUAL values, not a static
|
|
8
|
+
* model. The sandbox reuses the playground's run pipeline (transpile →
|
|
9
|
+
* rewriteImports → buildIframeDoc → SW-served URL) so imports resolve the same
|
|
10
|
+
* way the preview does.
|
|
11
|
+
*
|
|
12
|
+
* Lifecycle: `refresh(source)` rebuilds the sandbox (debounced by the caller, on
|
|
13
|
+
* statement boundaries) and caches the last good one — if the source doesn't
|
|
14
|
+
* transpile, the previous sandbox stays queryable. `members(path)` asks the
|
|
15
|
+
* sandbox to introspect a value, with a short timeout so the editor never hangs.
|
|
16
|
+
*/
|
|
17
|
+
import { tjs } from '../../src/lang'
|
|
18
|
+
import { rewriteImports, registerIframeContent } from './imports'
|
|
19
|
+
import { buildIframeDoc } from './playground-shared'
|
|
20
|
+
import type { IntrospectMember } from '../../editors/introspect-value'
|
|
21
|
+
|
|
22
|
+
export class IntrospectionBridge {
|
|
23
|
+
private iframe: HTMLIFrameElement
|
|
24
|
+
private ready = false
|
|
25
|
+
private lastSource: string | null = null
|
|
26
|
+
private pending = new Map<number, (members: IntrospectMember[]) => void>()
|
|
27
|
+
private nextId = 1
|
|
28
|
+
private readonly onMessage: (e: MessageEvent) => void
|
|
29
|
+
|
|
30
|
+
constructor() {
|
|
31
|
+
const iframe = document.createElement('iframe')
|
|
32
|
+
iframe.setAttribute('aria-hidden', 'true')
|
|
33
|
+
iframe.setAttribute('tabindex', '-1')
|
|
34
|
+
// Off-screen and inert, but still a real DOM so tosijs `elements` work.
|
|
35
|
+
iframe.style.cssText =
|
|
36
|
+
'position:absolute;width:1px;height:1px;left:-9999px;top:-9999px;border:0;visibility:hidden;pointer-events:none;'
|
|
37
|
+
document.body.appendChild(iframe)
|
|
38
|
+
this.iframe = iframe
|
|
39
|
+
|
|
40
|
+
this.onMessage = (e: MessageEvent) => {
|
|
41
|
+
const d = e.data
|
|
42
|
+
if (!d || e.source !== iframe.contentWindow) return
|
|
43
|
+
if (d.type === 'tjs-bridge-ready') {
|
|
44
|
+
this.ready = true
|
|
45
|
+
} else if (d.type === 'tjs-introspect-result') {
|
|
46
|
+
const resolve = this.pending.get(d.id)
|
|
47
|
+
if (resolve) {
|
|
48
|
+
this.pending.delete(d.id)
|
|
49
|
+
resolve(Array.isArray(d.members) ? d.members : [])
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
window.addEventListener('message', this.onMessage)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Rebuild the sandbox from the current source. No-op if unchanged or if the
|
|
58
|
+
* source doesn't transpile (keeps the last good sandbox). Caller should
|
|
59
|
+
* debounce this to statement boundaries.
|
|
60
|
+
*/
|
|
61
|
+
async refresh(source: string): Promise<void> {
|
|
62
|
+
if (source === this.lastSource) return
|
|
63
|
+
|
|
64
|
+
let code: string
|
|
65
|
+
try {
|
|
66
|
+
const result = tjs(source)
|
|
67
|
+
code = result.code
|
|
68
|
+
} catch {
|
|
69
|
+
return // not transpilable yet — keep the last good sandbox
|
|
70
|
+
}
|
|
71
|
+
this.lastSource = source
|
|
72
|
+
|
|
73
|
+
const rewritten = rewriteImports(code)
|
|
74
|
+
const importStatements: string[] = []
|
|
75
|
+
const body = rewritten.replace(
|
|
76
|
+
/^import\s+(?:.*?from\s+)?['"][^'"]+['"];?\s*$/gm,
|
|
77
|
+
(match) => {
|
|
78
|
+
importStatements.push(match)
|
|
79
|
+
return ''
|
|
80
|
+
}
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
const doc = buildIframeDoc({
|
|
84
|
+
cssContent: '',
|
|
85
|
+
htmlContent: '',
|
|
86
|
+
importMapScript: '',
|
|
87
|
+
jsCode: body,
|
|
88
|
+
importStatements,
|
|
89
|
+
introspectionBridge: true,
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
this.ready = false
|
|
93
|
+
// Reject in-flight queries against the old sandbox.
|
|
94
|
+
for (const resolve of this.pending.values()) resolve([])
|
|
95
|
+
this.pending.clear()
|
|
96
|
+
|
|
97
|
+
const sessionId = `tjs-introspect-${Date.now()}-${Math.random()
|
|
98
|
+
.toString(36)
|
|
99
|
+
.slice(2)}`
|
|
100
|
+
const registered = await registerIframeContent(sessionId, doc)
|
|
101
|
+
if (registered) {
|
|
102
|
+
this.iframe.src = `/iframe/${sessionId}`
|
|
103
|
+
} else {
|
|
104
|
+
const blob = new Blob([doc], { type: 'text/html' })
|
|
105
|
+
this.iframe.src = URL.createObjectURL(blob)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Introspect a dotted path in the live sandbox scope. Resolves to the value's
|
|
111
|
+
* real members, or `undefined` if the sandbox isn't ready / times out (so the
|
|
112
|
+
* caller can fall back to static completions).
|
|
113
|
+
*/
|
|
114
|
+
members(
|
|
115
|
+
path: string,
|
|
116
|
+
timeoutMs = 500
|
|
117
|
+
): Promise<IntrospectMember[] | undefined> {
|
|
118
|
+
const win = this.iframe.contentWindow
|
|
119
|
+
if (!this.ready || !win) return Promise.resolve(undefined)
|
|
120
|
+
|
|
121
|
+
const id = this.nextId++
|
|
122
|
+
return new Promise((resolve) => {
|
|
123
|
+
const timer = setTimeout(() => {
|
|
124
|
+
this.pending.delete(id)
|
|
125
|
+
resolve(undefined)
|
|
126
|
+
}, timeoutMs)
|
|
127
|
+
this.pending.set(id, (members) => {
|
|
128
|
+
clearTimeout(timer)
|
|
129
|
+
resolve(members)
|
|
130
|
+
})
|
|
131
|
+
win.postMessage({ type: 'tjs-introspect', id, path }, '*')
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
dispose(): void {
|
|
136
|
+
window.removeEventListener('message', this.onMessage)
|
|
137
|
+
this.iframe.remove()
|
|
138
|
+
this.pending.clear()
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, it, expect } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
// buildIframeDoc reads location.origin (browser global) — stub it for headless.
|
|
4
|
+
;(globalThis as any).location ??= { origin: 'http://test' }
|
|
5
|
+
|
|
6
|
+
import { buildIframeDoc } from './playground-shared'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Structural guard for the introspection sandbox document. The critical
|
|
10
|
+
* invariant: user code must run at MODULE TOP LEVEL (not inside a try block) so
|
|
11
|
+
* its top-level `const`/`let` are module-scoped and the bridge's direct `eval`
|
|
12
|
+
* can see them. A regression here silently breaks `todoApp.` completion.
|
|
13
|
+
*/
|
|
14
|
+
describe('buildIframeDoc — introspection bridge document', () => {
|
|
15
|
+
const doc = buildIframeDoc({
|
|
16
|
+
cssContent: '',
|
|
17
|
+
htmlContent: '',
|
|
18
|
+
importMapScript: '',
|
|
19
|
+
jsCode: 'const todoApp = { items: [] }',
|
|
20
|
+
importStatements: ["import { tosi } from 'https://cdn/tosijs'"],
|
|
21
|
+
introspectionBridge: true,
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('runs user code at module top level — NOT inside a try block', () => {
|
|
25
|
+
expect(doc).toContain('const todoApp = { items: [] }')
|
|
26
|
+
// The real invariant: after the bridge is installed, the user code runs with
|
|
27
|
+
// no try-wrap between (a block would re-scope its declarations away from
|
|
28
|
+
// eval). (The injected introspector has its own internal try/catch — that's
|
|
29
|
+
// fine and lives before the ready post.)
|
|
30
|
+
const readyIdx = doc.indexOf('tjs-bridge-ready')
|
|
31
|
+
const codeIdx = doc.indexOf('const todoApp')
|
|
32
|
+
expect(codeIdx).toBeGreaterThan(readyIdx)
|
|
33
|
+
expect(doc.slice(readyIdx, codeIdx)).not.toContain('try {')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('installs the message listener and the injected introspector', () => {
|
|
37
|
+
expect(doc).toContain("d.type !== 'tjs-introspect'")
|
|
38
|
+
expect(doc).toContain('eval(d.path)')
|
|
39
|
+
expect(doc).toContain('function introspectValue') // injected source
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('posts bridge-ready and puts imports first', () => {
|
|
43
|
+
expect(doc).toContain('tjs-bridge-ready')
|
|
44
|
+
const importIdx = doc.indexOf('import { tosi }')
|
|
45
|
+
const listenerIdx = doc.indexOf('tjs-introspect')
|
|
46
|
+
const codeIdx = doc.indexOf('const todoApp')
|
|
47
|
+
// imports → listener → user code
|
|
48
|
+
expect(importIdx).toBeGreaterThan(-1)
|
|
49
|
+
expect(importIdx).toBeLessThan(listenerIdx)
|
|
50
|
+
expect(listenerIdx).toBeLessThan(codeIdx)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('does not install the bridge for a normal preview doc', () => {
|
|
54
|
+
const preview = buildIframeDoc({
|
|
55
|
+
cssContent: '',
|
|
56
|
+
htmlContent: '',
|
|
57
|
+
importMapScript: '',
|
|
58
|
+
jsCode: 'console.log(1)',
|
|
59
|
+
})
|
|
60
|
+
expect(preview).not.toContain('tjs-introspect')
|
|
61
|
+
expect(preview).toContain('try {') // normal preview DOES wrap in try
|
|
62
|
+
})
|
|
63
|
+
})
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { CodeMirror } from '../../editors/codemirror/component'
|
|
9
|
+
import { INTROSPECT_VALUE_SOURCE } from '../../editors/introspect-value'
|
|
9
10
|
|
|
10
11
|
// ---------------------------------------------------------------------------
|
|
11
12
|
// TJS Runtime for iframe — loaded from a built file, not a hand-maintained stub.
|
|
@@ -48,6 +49,32 @@ export interface IframeDocOptions {
|
|
|
48
49
|
autoCallTjsFunction?: boolean
|
|
49
50
|
/** Whether parent is in dark mode — sets color-scheme on iframe */
|
|
50
51
|
darkMode?: boolean
|
|
52
|
+
/**
|
|
53
|
+
* Install the introspection bridge: after the user's code runs, a message
|
|
54
|
+
* listener answers `{type:'tjs-introspect', id, path}` by evaluating `path` in
|
|
55
|
+
* module scope and returning the value's real members. Used by the hidden
|
|
56
|
+
* autocomplete sandbox (IntrospectionBridge), not the visible preview.
|
|
57
|
+
*/
|
|
58
|
+
introspectionBridge?: boolean
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Script (injected at module top-level, after the user's code) that answers
|
|
63
|
+
* member-introspection queries from the parent. `eval(path)` is a DIRECT eval in
|
|
64
|
+
* the module's lexical scope, so it sees the user's top-level `const`/`let`
|
|
65
|
+
* bindings (e.g. `todoApp.items`). Runs only in the hidden sandbox.
|
|
66
|
+
*/
|
|
67
|
+
function introspectionBridgeScript(): string {
|
|
68
|
+
return `
|
|
69
|
+
const __introspectValue = ${INTROSPECT_VALUE_SOURCE};
|
|
70
|
+
window.addEventListener('message', (e) => {
|
|
71
|
+
const d = e.data;
|
|
72
|
+
if (!d || d.type !== 'tjs-introspect') return;
|
|
73
|
+
let members = [];
|
|
74
|
+
try { members = __introspectValue(eval(d.path)); } catch (_e) {}
|
|
75
|
+
parent.postMessage({ type: 'tjs-introspect-result', id: d.id, members }, '*');
|
|
76
|
+
});
|
|
77
|
+
parent.postMessage({ type: 'tjs-bridge-ready' }, '*');`
|
|
51
78
|
}
|
|
52
79
|
|
|
53
80
|
/**
|
|
@@ -68,8 +95,34 @@ export function buildIframeDoc(options: IframeDocOptions): string {
|
|
|
68
95
|
parentBindings = false,
|
|
69
96
|
autoCallTjsFunction = false,
|
|
70
97
|
darkMode = false,
|
|
98
|
+
introspectionBridge = false,
|
|
71
99
|
} = options
|
|
72
100
|
|
|
101
|
+
// Introspection sandbox: run user code at MODULE TOP LEVEL (no try-wrap) so
|
|
102
|
+
// top-level `const`/`let` are module-scoped and the bridge's direct `eval` can
|
|
103
|
+
// see them. The listener is installed first; it's invoked later (at query
|
|
104
|
+
// time), by when the module — and `todoApp` — has fully initialised. Ready is
|
|
105
|
+
// posted up front so globals/imports stay introspectable even if user code
|
|
106
|
+
// throws partway. No try/catch precisely because a block would re-scope the
|
|
107
|
+
// declarations away from `eval`.
|
|
108
|
+
if (introspectionBridge) {
|
|
109
|
+
return `<!DOCTYPE html>
|
|
110
|
+
<html>
|
|
111
|
+
<head>
|
|
112
|
+
<style>:root { color-scheme: ${darkMode ? 'dark' : 'light dark'} }</style>
|
|
113
|
+
${importMapScript}
|
|
114
|
+
</head>
|
|
115
|
+
<body>
|
|
116
|
+
${runtimeScriptTag}
|
|
117
|
+
<script type="module">
|
|
118
|
+
${importStatements.join('\n ')}
|
|
119
|
+
${introspectionBridgeScript()}
|
|
120
|
+
${jsCode}
|
|
121
|
+
</script>
|
|
122
|
+
</body>
|
|
123
|
+
</html>`
|
|
124
|
+
}
|
|
125
|
+
|
|
73
126
|
const colorScheme = darkMode ? 'dark' : 'light dark'
|
|
74
127
|
|
|
75
128
|
const parentBindingsScript = parentBindings
|
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
buildAutocompleteContext,
|
|
44
44
|
type AutocompleteContext,
|
|
45
45
|
} from './autocomplete-context'
|
|
46
|
+
import { IntrospectionBridge } from './introspection-bridge'
|
|
46
47
|
|
|
47
48
|
// Available modules for autocomplete introspection
|
|
48
49
|
const AVAILABLE_MODULES: Record<string, any> = {
|
|
@@ -166,6 +167,8 @@ export class TJSPlayground extends Component<TJSPlaygroundParts> {
|
|
|
166
167
|
private autocompleteContext: AutocompleteContext | null = null
|
|
167
168
|
private contextUpdateTimer: ReturnType<typeof setTimeout> | null = null
|
|
168
169
|
private contextBuildPromise: Promise<void> | null = null
|
|
170
|
+
// Hidden sandbox for runtime-truth member completion (todoApp.items, …)
|
|
171
|
+
private introspectionBridge: IntrospectionBridge | null = null
|
|
169
172
|
|
|
170
173
|
/**
|
|
171
174
|
* Get metadata for autocomplete - returns all discovered functions
|
|
@@ -185,6 +188,15 @@ export class TJSPlayground extends Component<TJSPlaygroundParts> {
|
|
|
185
188
|
return this.autocompleteContext?.bindings
|
|
186
189
|
}
|
|
187
190
|
|
|
191
|
+
/**
|
|
192
|
+
* Member completion from the introspection bridge — the user's REAL executed
|
|
193
|
+
* scope (`todoApp.items` → the live array). Returns undefined if the sandbox
|
|
194
|
+
* isn't ready, so the provider falls back to static completions.
|
|
195
|
+
*/
|
|
196
|
+
private getMembers = (path: string) => {
|
|
197
|
+
return this.introspectionBridge?.members(path) ?? Promise.resolve(undefined)
|
|
198
|
+
}
|
|
199
|
+
|
|
188
200
|
/**
|
|
189
201
|
* Update autocomplete context (debounced)
|
|
190
202
|
* Called when editor content changes.
|
|
@@ -198,6 +210,14 @@ export class TJSPlayground extends Component<TJSPlaygroundParts> {
|
|
|
198
210
|
const doBuild = async () => {
|
|
199
211
|
const source = this.parts.tjsEditor.value
|
|
200
212
|
|
|
213
|
+
// Refresh the introspection sandbox (runtime-truth member completion).
|
|
214
|
+
// Independent of import-binding resolution below; failures are swallowed
|
|
215
|
+
// inside the bridge (keeps the last good sandbox).
|
|
216
|
+
if (!this.introspectionBridge) {
|
|
217
|
+
this.introspectionBridge = new IntrospectionBridge()
|
|
218
|
+
}
|
|
219
|
+
void this.introspectionBridge.refresh(source)
|
|
220
|
+
|
|
201
221
|
try {
|
|
202
222
|
const newContext = await buildAutocompleteContext(source)
|
|
203
223
|
// Only update if we got bindings (keep last good context otherwise)
|
|
@@ -649,6 +669,7 @@ export class TJSPlayground extends Component<TJSPlaygroundParts> {
|
|
|
649
669
|
this.parts.tjsEditor.autocomplete = {
|
|
650
670
|
getMetadata: this.getMetadataForAutocomplete,
|
|
651
671
|
getLiveBindings: this.getLiveBindings,
|
|
672
|
+
getMembers: this.getMembers,
|
|
652
673
|
}
|
|
653
674
|
|
|
654
675
|
// Build initial autocomplete context immediately
|
|
@@ -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
|