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.
Files changed (45) hide show
  1. package/CLAUDE.md +5 -2
  2. package/demo/autocomplete.test.ts +37 -0
  3. package/demo/docs.json +701 -11
  4. package/demo/src/autocomplete.ts +40 -2
  5. package/demo/src/introspection-bridge.ts +140 -0
  6. package/demo/src/introspection-doc.test.ts +63 -0
  7. package/demo/src/playground-shared.ts +53 -0
  8. package/demo/src/tjs-playground.ts +21 -0
  9. package/dist/experiments/predicates/css.predicates.d.ts +13 -0
  10. package/dist/experiments/predicates/verify.d.ts +39 -0
  11. package/dist/index.js +101 -97
  12. package/dist/index.js.map +4 -4
  13. package/dist/src/lang/index.d.ts +2 -0
  14. package/dist/src/lang/predicate-schema.d.ts +39 -0
  15. package/dist/src/lang/predicate.d.ts +103 -0
  16. package/dist/src/lang/transpiler.d.ts +2 -0
  17. package/dist/src/vm/runtime.d.ts +22 -0
  18. package/dist/tjs-eval.js +29 -29
  19. package/dist/tjs-eval.js.map +3 -3
  20. package/dist/tjs-from-ts.js +1 -1
  21. package/dist/tjs-from-ts.js.map +2 -2
  22. package/dist/tjs-lang.js +86 -82
  23. package/dist/tjs-lang.js.map +4 -4
  24. package/dist/tjs-vm.js +45 -45
  25. package/dist/tjs-vm.js.map +3 -3
  26. package/editors/codemirror/ajs-language.ts +130 -44
  27. package/editors/codemirror/completion-source.test.ts +114 -0
  28. package/editors/introspect-value.test.ts +61 -0
  29. package/editors/introspect-value.ts +86 -0
  30. package/editors/scope-symbols.test.ts +113 -0
  31. package/editors/scope-symbols.ts +173 -0
  32. package/package.json +2 -1
  33. package/src/lang/index.ts +22 -0
  34. package/src/lang/predicate-schema.test.ts +97 -0
  35. package/src/lang/predicate-schema.ts +168 -0
  36. package/src/lang/predicate.test.ts +184 -0
  37. package/src/lang/predicate.ts +550 -0
  38. package/src/lang/runtime.test.ts +46 -0
  39. package/src/lang/suggest.test.ts +84 -0
  40. package/src/lang/transpiler.ts +26 -0
  41. package/src/runtime.test.ts +6 -6
  42. package/src/vm/atom-effects.test.ts +72 -0
  43. package/src/vm/atoms/batteries.ts +12 -0
  44. package/src/vm/equality.test.ts +59 -0
  45. package/src/vm/runtime.ts +77 -59
@@ -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
- ...extractFunctions(source),
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
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The CSS torture set — predicates for the exact cases where TS structural
3
+ * types and JSON Schema cave to `string`/`any`:
4
+ * - open value grammars: var(), calc(), !important
5
+ * - order-flexible shorthands: the `animation` family (tokenize + classify)
6
+ * - open recursive structure: nested selectors / keyframes (recurse)
7
+ *
8
+ * Written in named-composition style — no inline arrows, no IO, no async —
9
+ * so it reads as "synchronous AJS" and verifies predicate-safe. `||` composition
10
+ * and `.every(namedPredicate)` are natural here because predicates run as native
11
+ * JS (the verified fast path), not through the sandboxed VM interpreter.
12
+ */
13
+ export declare const CSS_PREDICATE_SOURCE: string;
@@ -0,0 +1,39 @@
1
+ export interface PredicateDiagnostic {
2
+ predicate: string;
3
+ message: string;
4
+ line: number;
5
+ column: number;
6
+ }
7
+ export interface VerifyResult {
8
+ safe: boolean;
9
+ predicates: string[];
10
+ diagnostics: PredicateDiagnostic[];
11
+ }
12
+ /**
13
+ * Build the effectful-name set from a VM atom registry: every atom tagged
14
+ * `effects: 'io'` (plus the non-atom globals). This is how the predicate
15
+ * verifier consumes the atom-effects classification.
16
+ */
17
+ export declare function effectfulFrom(atoms: Record<string, {
18
+ op?: string;
19
+ effects?: 'pure' | 'io';
20
+ }>): Set<string>;
21
+ /**
22
+ * Verify every top-level function in `source` is predicate-safe. Because safety
23
+ * is a closure property, the whole cluster is safe iff each function is — so the
24
+ * transitive check is automatic: a call to another function in the set is fine,
25
+ * a call to anything effectful/unknown is not.
26
+ */
27
+ export declare function verifyPredicates(source: string, opts?: {
28
+ effectful?: Set<string>;
29
+ }): VerifyResult;
30
+ /** Format diagnostics for a thrown error / log. */
31
+ export declare function formatDiagnostics(d: PredicateDiagnostic[]): string;
32
+ /**
33
+ * Verify, then compile the predicate cluster to native synchronous JS functions.
34
+ * Throws (with located diagnostics) at "definition time" if not predicate-safe —
35
+ * so an IO-using "predicate" never even compiles.
36
+ */
37
+ export declare function compilePredicates(source: string, exports: string[], opts?: {
38
+ effectful?: Set<string>;
39
+ }): Record<string, (...args: any[]) => any>;