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
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scope-aware symbol collection for autocomplete.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the regex `const NAME =` scraping (which missed ALL destructuring —
|
|
5
|
+
* and the tosijs examples bind everything via destructuring, so nothing was
|
|
6
|
+
* suggested). This parses the source (acorn, falling back to acorn-loose for
|
|
7
|
+
* mid-edit / not-yet-valid source) and walks binding patterns properly:
|
|
8
|
+
* `const { todoApp } = tosi(...)`, `const { h1, ul } = elements`,
|
|
9
|
+
* `const [a, , b] = xs`, rename, defaults, rest — all yield their bound names.
|
|
10
|
+
*
|
|
11
|
+
* Each symbol also records its **origin** (the initializer expression text, and
|
|
12
|
+
* for object-destructuring the key it came from). That origin is the hook the
|
|
13
|
+
* runtime-introspection layer uses: to introspect `h1` it can evaluate
|
|
14
|
+
* `elements.h1` in the live scope. Names are plumbing; the *types* come from
|
|
15
|
+
* introspecting real values (see the introspection bridge), not from this parse.
|
|
16
|
+
*/
|
|
17
|
+
import * as acorn from 'acorn'
|
|
18
|
+
import * as acornLoose from 'acorn-loose'
|
|
19
|
+
import * as walk from 'acorn-walk'
|
|
20
|
+
|
|
21
|
+
export interface SymbolOrigin {
|
|
22
|
+
/** How the binding was produced. */
|
|
23
|
+
via: 'init' | 'destructure' | 'param' | 'import' | 'function'
|
|
24
|
+
/** Source text of the initializer expression (e.g. `elements`, `tosi({...})`). */
|
|
25
|
+
expr?: string
|
|
26
|
+
/** For object-destructuring: the key on `expr` this name came from (`elements.h1` → `h1`). */
|
|
27
|
+
member?: string
|
|
28
|
+
/** Module specifier, for imports. */
|
|
29
|
+
module?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ScopeSymbol {
|
|
33
|
+
name: string
|
|
34
|
+
kind: 'variable' | 'function' | 'parameter' | 'import'
|
|
35
|
+
origin?: SymbolOrigin
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function parse(source: string): any {
|
|
39
|
+
try {
|
|
40
|
+
return acorn.parse(source, { ecmaVersion: 'latest' })
|
|
41
|
+
} catch {
|
|
42
|
+
try {
|
|
43
|
+
// Best-effort AST from incomplete / not-yet-valid source.
|
|
44
|
+
return acornLoose.parse(source, { ecmaVersion: 'latest' })
|
|
45
|
+
} catch {
|
|
46
|
+
return null
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type NameSink = (name: string, member?: string) => void
|
|
52
|
+
|
|
53
|
+
/** Walk a binding pattern, emitting each bound name (and, one level deep for
|
|
54
|
+
* object patterns, the source key it came from). */
|
|
55
|
+
function collectPattern(pat: any, onName: NameSink, member?: string): void {
|
|
56
|
+
if (!pat) return
|
|
57
|
+
switch (pat.type) {
|
|
58
|
+
case 'Identifier':
|
|
59
|
+
onName(pat.name, member)
|
|
60
|
+
return
|
|
61
|
+
case 'ObjectPattern':
|
|
62
|
+
for (const p of pat.properties) {
|
|
63
|
+
if (p.type === 'RestElement') {
|
|
64
|
+
collectPattern(p.argument, onName)
|
|
65
|
+
} else {
|
|
66
|
+
const key = p.key && (p.key.name ?? p.key.value)
|
|
67
|
+
collectPattern(
|
|
68
|
+
p.value,
|
|
69
|
+
onName,
|
|
70
|
+
typeof key === 'string' ? key : undefined
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return
|
|
75
|
+
case 'ArrayPattern':
|
|
76
|
+
for (const el of pat.elements) collectPattern(el, onName)
|
|
77
|
+
return
|
|
78
|
+
case 'AssignmentPattern':
|
|
79
|
+
collectPattern(pat.left, onName, member)
|
|
80
|
+
return
|
|
81
|
+
case 'RestElement':
|
|
82
|
+
collectPattern(pat.argument, onName)
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const inRange = (node: any, position: number) =>
|
|
88
|
+
typeof node.start === 'number' &&
|
|
89
|
+
typeof node.end === 'number' &&
|
|
90
|
+
node.start <= position &&
|
|
91
|
+
position <= node.end
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Collect the symbols in scope at `position` (defaults to end of source):
|
|
95
|
+
* variable/function/import bindings declared before the cursor, plus the
|
|
96
|
+
* parameters of any function whose body the cursor sits inside. Destructuring
|
|
97
|
+
* is fully handled; results are de-duplicated by name (last declaration wins).
|
|
98
|
+
*/
|
|
99
|
+
export function collectScopeSymbols(
|
|
100
|
+
source: string,
|
|
101
|
+
position: number = source.length
|
|
102
|
+
): ScopeSymbol[] {
|
|
103
|
+
const ast = parse(source)
|
|
104
|
+
if (!ast) return []
|
|
105
|
+
|
|
106
|
+
const byName = new Map<string, ScopeSymbol>()
|
|
107
|
+
const add = (s: ScopeSymbol) => byName.set(s.name, s)
|
|
108
|
+
|
|
109
|
+
walk.full(ast, (node: any) => {
|
|
110
|
+
switch (node.type) {
|
|
111
|
+
case 'VariableDeclaration': {
|
|
112
|
+
// Only count declarations that appear before the cursor.
|
|
113
|
+
if (node.start >= position) return
|
|
114
|
+
for (const decl of node.declarations) {
|
|
115
|
+
const initText =
|
|
116
|
+
decl.init && typeof decl.init.start === 'number'
|
|
117
|
+
? source.slice(decl.init.start, decl.init.end)
|
|
118
|
+
: undefined
|
|
119
|
+
collectPattern(decl.id, (name, member) =>
|
|
120
|
+
add({
|
|
121
|
+
name,
|
|
122
|
+
kind: 'variable',
|
|
123
|
+
origin: {
|
|
124
|
+
via: member != null ? 'destructure' : 'init',
|
|
125
|
+
expr: initText,
|
|
126
|
+
member,
|
|
127
|
+
},
|
|
128
|
+
})
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
case 'FunctionDeclaration': {
|
|
134
|
+
if (node.id && node.start < position)
|
|
135
|
+
add({
|
|
136
|
+
name: node.id.name,
|
|
137
|
+
kind: 'function',
|
|
138
|
+
origin: { via: 'function' },
|
|
139
|
+
})
|
|
140
|
+
// params only in scope when the cursor is inside the function
|
|
141
|
+
if (inRange(node, position))
|
|
142
|
+
for (const p of node.params)
|
|
143
|
+
collectPattern(p, (name) =>
|
|
144
|
+
add({ name, kind: 'parameter', origin: { via: 'param' } })
|
|
145
|
+
)
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
case 'FunctionExpression':
|
|
149
|
+
case 'ArrowFunctionExpression': {
|
|
150
|
+
if (inRange(node, position))
|
|
151
|
+
for (const p of node.params)
|
|
152
|
+
collectPattern(p, (name) =>
|
|
153
|
+
add({ name, kind: 'parameter', origin: { via: 'param' } })
|
|
154
|
+
)
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
case 'ImportDeclaration': {
|
|
158
|
+
const module = String(node.source?.value ?? '')
|
|
159
|
+
for (const spec of node.specifiers) {
|
|
160
|
+
// local name is spec.local.name for all specifier kinds
|
|
161
|
+
add({
|
|
162
|
+
name: spec.local.name,
|
|
163
|
+
kind: 'import',
|
|
164
|
+
origin: { via: 'import', module },
|
|
165
|
+
})
|
|
166
|
+
}
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
return [...byName.values()]
|
|
173
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tjs-lang",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.3",
|
|
4
4
|
"description": "Type-safe JavaScript dialect with runtime validation, sandboxed VM execution, and AI agent orchestration. Transpiles TypeScript to validated JS with fuel-metered execution for untrusted code.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"typescript",
|
|
@@ -161,6 +161,7 @@
|
|
|
161
161
|
},
|
|
162
162
|
"dependencies": {
|
|
163
163
|
"acorn": "^8.15.0",
|
|
164
|
+
"acorn-loose": "^8.5.2",
|
|
164
165
|
"acorn-walk": "^8.3.4",
|
|
165
166
|
"tosijs-schema": "^1.3.0"
|
|
166
167
|
}
|
package/src/lang/index.ts
CHANGED
|
@@ -44,6 +44,28 @@ export {
|
|
|
44
44
|
type Dialect,
|
|
45
45
|
type SourceKind,
|
|
46
46
|
} from './dialect'
|
|
47
|
+
export {
|
|
48
|
+
verifyPredicate,
|
|
49
|
+
compilePredicate,
|
|
50
|
+
suggest,
|
|
51
|
+
effectfulFromAtoms,
|
|
52
|
+
formatPredicateDiagnostics,
|
|
53
|
+
PredicateFuelExhausted,
|
|
54
|
+
type PredicateDiagnostic,
|
|
55
|
+
type PredicateVerifyResult,
|
|
56
|
+
type VerifyPredicateOptions,
|
|
57
|
+
type CompilePredicateOptions,
|
|
58
|
+
type Suggestion,
|
|
59
|
+
type SuggestOptions,
|
|
60
|
+
} from './predicate'
|
|
61
|
+
export {
|
|
62
|
+
compilePredicateSchema,
|
|
63
|
+
validatePredicateSchema,
|
|
64
|
+
type PredicateSchema,
|
|
65
|
+
type SchemaError,
|
|
66
|
+
type SchemaValidationResult,
|
|
67
|
+
type PredicateSchemaOptions,
|
|
68
|
+
} from './predicate-schema'
|
|
47
69
|
export { transformFunction } from './emitters/ast'
|
|
48
70
|
export {
|
|
49
71
|
transpileToJS,
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, it, expect } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
compilePredicateSchema,
|
|
4
|
+
validatePredicateSchema,
|
|
5
|
+
type PredicateSchema,
|
|
6
|
+
} from './predicate-schema'
|
|
7
|
+
|
|
8
|
+
// A composable color predicate cluster (entry = last function, takes the value).
|
|
9
|
+
const COLOR = `
|
|
10
|
+
function isHex(v){ return typeof v == 'string' && /^#[0-9a-f]{3,8}$/i.test(v) }
|
|
11
|
+
function isVar(v){ return typeof v == 'string' && v.startsWith('var(--') && v.endsWith(')') }
|
|
12
|
+
function isCalc(v){ return typeof v == 'string' && v.startsWith('calc(') && v.endsWith(')') }
|
|
13
|
+
function isColor(v){ return isHex(v) || isVar(v) || isCalc(v) }
|
|
14
|
+
`
|
|
15
|
+
|
|
16
|
+
// A recursive predicate cluster — validates a tree of string/number leaves.
|
|
17
|
+
// Note the `!Array.isArray(o)` guard: arrays are objects in JS, so a node check
|
|
18
|
+
// must exclude them explicitly (a real predicate-authoring nuance).
|
|
19
|
+
const TREE = `
|
|
20
|
+
function isLeaf(v){ return typeof v == 'string' || typeof v == 'number' }
|
|
21
|
+
function isEntry(pair){ var val = pair[1]; return isLeaf(val) || isNode(val) }
|
|
22
|
+
function isNode(o){ return typeof o == 'object' && o != null && !Array.isArray(o) && Object.entries(o).every(isEntry) }
|
|
23
|
+
`
|
|
24
|
+
|
|
25
|
+
const colorSchema: PredicateSchema = {
|
|
26
|
+
type: 'object',
|
|
27
|
+
required: ['color'],
|
|
28
|
+
properties: {
|
|
29
|
+
color: { type: 'string', description: 'a CSS color', $predicate: COLOR },
|
|
30
|
+
},
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('predicate-schema — the computational half of JSON-Schema', () => {
|
|
34
|
+
it('a predicate-aware validator validates the value grammar', () => {
|
|
35
|
+
const validate = compilePredicateSchema(colorSchema)
|
|
36
|
+
expect(validate({ color: '#3a3' }).valid).toBe(true)
|
|
37
|
+
expect(validate({ color: 'var(--brand)' }).valid).toBe(true) // TS/JSON-Schema can't
|
|
38
|
+
expect(validate({ color: 'calc(1px + 1em)' }).valid).toBe(true)
|
|
39
|
+
|
|
40
|
+
const bad = validate({ color: 'notacolor' })
|
|
41
|
+
expect(bad.valid).toBe(false)
|
|
42
|
+
expect(bad.errors[0].path).toBe('/color')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('still does structural validation (type / required)', () => {
|
|
46
|
+
const validate = compilePredicateSchema(colorSchema)
|
|
47
|
+
expect(validate({ color: 123 }).errors[0].message).toMatch(
|
|
48
|
+
/expected string/
|
|
49
|
+
)
|
|
50
|
+
expect(validate({}).errors[0].message).toMatch(/missing required 'color'/)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('progressive enhancement: a naive validator ignores `$predicate`', () => {
|
|
54
|
+
// ignorePredicates models any standard JSON-Schema validator that doesn't
|
|
55
|
+
// know the keyword — it sees only `type: string`.
|
|
56
|
+
const naive = compilePredicateSchema(colorSchema, {
|
|
57
|
+
ignorePredicates: true,
|
|
58
|
+
})
|
|
59
|
+
expect(naive({ color: 'notacolor' }).valid).toBe(true) // string → passes
|
|
60
|
+
// …while the aware validator catches it:
|
|
61
|
+
expect(
|
|
62
|
+
compilePredicateSchema(colorSchema)({ color: 'notacolor' }).valid
|
|
63
|
+
).toBe(false)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('the schema is plain serializable JSON — code travels as data', () => {
|
|
67
|
+
const roundTripped: PredicateSchema = JSON.parse(
|
|
68
|
+
JSON.stringify(colorSchema)
|
|
69
|
+
)
|
|
70
|
+
expect(roundTripped).toEqual(colorSchema)
|
|
71
|
+
// and it still validates after a serialize/deserialize round-trip
|
|
72
|
+
const validate = compilePredicateSchema(roundTripped)
|
|
73
|
+
expect(validate({ color: '#abc' }).valid).toBe(true)
|
|
74
|
+
expect(validate({ color: 'nope' }).valid).toBe(false)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('a `$predicate` can recurse into open nested structure', () => {
|
|
78
|
+
const schema: PredicateSchema = { type: 'object', $predicate: TREE }
|
|
79
|
+
const validate = compilePredicateSchema(schema)
|
|
80
|
+
expect(validate({ a: 1, b: { c: 'x', d: { e: 2 } } }).valid).toBe(true)
|
|
81
|
+
expect(validate({ a: 1, bad: { nope: [] } }).valid).toBe(false) // array leaf
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('rejects an unsafe predicate at compile time (IO never embeds)', () => {
|
|
85
|
+
const evil: PredicateSchema = {
|
|
86
|
+
type: 'string',
|
|
87
|
+
$predicate: `function check(v){ return fetch(v) }`,
|
|
88
|
+
}
|
|
89
|
+
expect(() => compilePredicateSchema(evil)).toThrow(/Not predicate-safe/)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('one-shot helper works too', () => {
|
|
93
|
+
expect(validatePredicateSchema(colorSchema, { color: '#fff' }).valid).toBe(
|
|
94
|
+
true
|
|
95
|
+
)
|
|
96
|
+
})
|
|
97
|
+
})
|