tjs-lang 0.7.7 → 0.7.8
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 +90 -33
- package/bin/docs.js +4 -1
- package/demo/docs.json +45 -11
- package/demo/src/examples.test.ts +1 -0
- package/demo/src/imports.test.ts +16 -4
- package/demo/src/imports.ts +60 -15
- package/demo/src/playground-shared.ts +9 -8
- package/demo/src/tfs-worker.js +205 -147
- package/demo/src/tjs-playground.ts +34 -10
- package/demo/src/ts-playground.ts +24 -8
- package/dist/index.js +118 -101
- package/dist/index.js.map +4 -4
- package/dist/src/lang/bool-coercion.d.ts +50 -0
- package/dist/src/lang/docs.d.ts +31 -6
- package/dist/src/lang/linter.d.ts +8 -0
- package/dist/src/lang/parser-transforms.d.ts +18 -0
- package/dist/src/lang/parser-types.d.ts +2 -0
- package/dist/src/lang/parser.d.ts +3 -0
- package/dist/src/lang/runtime.d.ts +34 -0
- package/dist/src/lang/types.d.ts +9 -1
- package/dist/src/rbac/index.d.ts +1 -1
- package/dist/src/vm/runtime.d.ts +1 -1
- package/dist/tjs-eval.js +38 -36
- package/dist/tjs-eval.js.map +4 -4
- package/dist/tjs-from-ts.js +20 -20
- package/dist/tjs-from-ts.js.map +3 -3
- package/dist/tjs-lang.js +85 -83
- package/dist/tjs-lang.js.map +4 -4
- package/dist/tjs-vm.js +47 -45
- package/dist/tjs-vm.js.map +4 -4
- package/llms.txt +79 -0
- package/package.json +3 -2
- package/src/cli/commands/convert.test.ts +16 -21
- package/src/lang/bool-coercion.test.ts +203 -0
- package/src/lang/bool-coercion.ts +314 -0
- package/src/lang/codegen.test.ts +137 -0
- package/src/lang/docs.test.ts +328 -1
- package/src/lang/docs.ts +424 -24
- package/src/lang/emitters/ast.ts +11 -12
- package/src/lang/emitters/dts.test.ts +41 -0
- package/src/lang/emitters/dts.ts +9 -0
- package/src/lang/emitters/js-tests.ts +9 -4
- package/src/lang/emitters/js.ts +182 -2
- package/src/lang/inference.ts +54 -0
- package/src/lang/linter.test.ts +104 -1
- package/src/lang/linter.ts +124 -1
- package/src/lang/parser-params.ts +31 -0
- package/src/lang/parser-transforms.ts +304 -0
- package/src/lang/parser-types.ts +2 -0
- package/src/lang/parser.test.ts +73 -1
- package/src/lang/parser.ts +34 -1
- package/src/lang/runtime.ts +98 -0
- package/src/lang/types.ts +6 -0
- package/src/rbac/index.ts +2 -2
- package/src/rbac/rules.tjs.d.ts +9 -0
- package/src/vm/atoms/batteries.ts +2 -2
- package/src/vm/runtime.ts +10 -3
- package/dist/src/rbac/rules.d.ts +0 -184
- package/src/rbac/rules.js +0 -338
package/src/lang/docs.ts
CHANGED
|
@@ -4,11 +4,73 @@
|
|
|
4
4
|
* Dead simple: walk source in order, emit what you find.
|
|
5
5
|
* - Doc blocks render as markdown
|
|
6
6
|
* - Function signatures render as code blocks
|
|
7
|
+
* - Inline `test 'name' { ... }` blocks render as "Test Cases" section
|
|
8
|
+
* with `expect(...).toBe(...)` style assertions translated to comments
|
|
7
9
|
*
|
|
8
10
|
* No magic pairing. No attachment logic. The signature IS the docs.
|
|
9
11
|
* Doc blocks are just editorial commentary when you need it.
|
|
10
12
|
*/
|
|
11
13
|
|
|
14
|
+
import { extractTests } from './tests'
|
|
15
|
+
import { typeDescriptorToTS } from './emitters/dts'
|
|
16
|
+
import type { TypeDescriptor } from './types'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Build a per-character boolean indicating whether the position is inside
|
|
20
|
+
* a block comment or line comment. Used so that class/function patterns
|
|
21
|
+
* don't match prose text inside doc blocks (e.g. `class Point { ... }`
|
|
22
|
+
* shown as an illustrative snippet).
|
|
23
|
+
*/
|
|
24
|
+
function computeInComment(source: string): boolean[] {
|
|
25
|
+
const inComment = new Array<boolean>(source.length).fill(false)
|
|
26
|
+
let i = 0
|
|
27
|
+
while (i < source.length) {
|
|
28
|
+
const c = source[i]
|
|
29
|
+
const n = source[i + 1]
|
|
30
|
+
// Skip string literals so // and /* inside them are ignored
|
|
31
|
+
if (c === '"' || c === "'" || c === '`') {
|
|
32
|
+
const q = c
|
|
33
|
+
i++
|
|
34
|
+
while (i < source.length) {
|
|
35
|
+
if (source[i] === '\\') {
|
|
36
|
+
i += 2
|
|
37
|
+
continue
|
|
38
|
+
}
|
|
39
|
+
if (source[i] === q) {
|
|
40
|
+
i++
|
|
41
|
+
break
|
|
42
|
+
}
|
|
43
|
+
i++
|
|
44
|
+
}
|
|
45
|
+
continue
|
|
46
|
+
}
|
|
47
|
+
if (c === '/' && n === '/') {
|
|
48
|
+
while (i < source.length && source[i] !== '\n') {
|
|
49
|
+
inComment[i] = true
|
|
50
|
+
i++
|
|
51
|
+
}
|
|
52
|
+
continue
|
|
53
|
+
}
|
|
54
|
+
if (c === '/' && n === '*') {
|
|
55
|
+
const start = i
|
|
56
|
+
i += 2
|
|
57
|
+
while (
|
|
58
|
+
i < source.length - 1 &&
|
|
59
|
+
!(source[i] === '*' && source[i + 1] === '/')
|
|
60
|
+
) {
|
|
61
|
+
i++
|
|
62
|
+
}
|
|
63
|
+
// include closing `*/`
|
|
64
|
+
const end = Math.min(source.length, i + 2)
|
|
65
|
+
for (let k = start; k < end; k++) inComment[k] = true
|
|
66
|
+
i = end
|
|
67
|
+
continue
|
|
68
|
+
}
|
|
69
|
+
i++
|
|
70
|
+
}
|
|
71
|
+
return inComment
|
|
72
|
+
}
|
|
73
|
+
|
|
12
74
|
/**
|
|
13
75
|
* Compute brace depth at each position in source.
|
|
14
76
|
* Used to filter out constructs inside function bodies.
|
|
@@ -51,6 +113,12 @@ export interface DocResult {
|
|
|
51
113
|
export type DocItem =
|
|
52
114
|
| { type: 'doc'; content: string }
|
|
53
115
|
| { type: 'function'; name: string; signature: string }
|
|
116
|
+
| {
|
|
117
|
+
type: 'class'
|
|
118
|
+
name: string
|
|
119
|
+
extendsName?: string
|
|
120
|
+
members: string[] // constructor / method signatures, no bodies
|
|
121
|
+
}
|
|
54
122
|
|
|
55
123
|
/**
|
|
56
124
|
* Generate documentation from TJS source
|
|
@@ -64,15 +132,20 @@ export function generateDocs(source: string): DocResult {
|
|
|
64
132
|
// Build brace depth map to identify top-level constructs
|
|
65
133
|
// This filters out doc blocks inside function bodies
|
|
66
134
|
const braceDepthAt = computeBraceDepths(source)
|
|
135
|
+
// Track positions inside /* */ and // comments so we don't extract
|
|
136
|
+
// illustrative `class Foo { ... }` / `function bar() { ... }` text
|
|
137
|
+
// shown in `/*# ... */` doc blocks as real declarations.
|
|
138
|
+
const isInComment = computeInComment(source)
|
|
67
139
|
|
|
68
|
-
// Find all doc blocks
|
|
140
|
+
// Find all doc blocks, functions, and classes; sort by position
|
|
69
141
|
const docPattern = /\/\*#([\s\S]*?)\*\//g
|
|
70
|
-
// Match
|
|
71
|
-
//
|
|
72
|
-
|
|
73
|
-
|
|
142
|
+
// Match the START of a function declaration. Params (which can contain
|
|
143
|
+
// nested parens like `fn = (x) => x`) are captured by balanced-paren
|
|
144
|
+
// scanning below, NOT by this regex.
|
|
145
|
+
const funcPattern = /\bfunction\s+(\w+)\s*\(/g
|
|
146
|
+
const classPattern = /\bclass\s+(\w+)(?:\s+extends\s+(\w+))?\s*\{/g
|
|
74
147
|
|
|
75
|
-
type Match = { type: 'doc' | 'function'; index: number; data: any }
|
|
148
|
+
type Match = { type: 'doc' | 'function' | 'class'; index: number; data: any }
|
|
76
149
|
const matches: Match[] = []
|
|
77
150
|
|
|
78
151
|
let match
|
|
@@ -104,16 +177,53 @@ export function generateDocs(source: string): DocResult {
|
|
|
104
177
|
}
|
|
105
178
|
|
|
106
179
|
while ((match = funcPattern.exec(source)) !== null) {
|
|
180
|
+
if (isInComment[match.index]) continue
|
|
181
|
+
if (braceDepthAt[match.index] !== 0) continue
|
|
107
182
|
const name = match[1]
|
|
108
|
-
const
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
183
|
+
const parenOpen = match.index + match[0].length - 1 // position of `(`
|
|
184
|
+
const parenClose = findMatchingParen(source, parenOpen + 1)
|
|
185
|
+
if (parenClose === -1) continue
|
|
186
|
+
const params = source.slice(parenOpen + 1, parenClose)
|
|
187
|
+
// Optional return-type annotation between `)` and `{`:
|
|
188
|
+
// ): T / ):? T / ):! T
|
|
189
|
+
// T can be: primitive (`0`, `''`), object (`{ x: 0 }`), array (`[0]`),
|
|
190
|
+
// or string literal (`'Hello, World!'`). Use depth-tracking with a
|
|
191
|
+
// "started" flag so the FIRST `{` inside the type opens the type
|
|
192
|
+
// block (not the body) and the `{` AFTER depth returns to 0 is the body.
|
|
193
|
+
let after = parenClose + 1
|
|
194
|
+
let returnAnnotation = ''
|
|
195
|
+
while (after < source.length && /\s/.test(source[after])) after++
|
|
196
|
+
if (source[after] === ':') {
|
|
197
|
+
const annoStart = after
|
|
198
|
+
after++ // past `:`
|
|
199
|
+
let depth = 0
|
|
200
|
+
let inStr: string | null = null
|
|
201
|
+
let started = false
|
|
202
|
+
while (after < source.length) {
|
|
203
|
+
const c = source[after]
|
|
204
|
+
const prev = after > 0 ? source[after - 1] : ''
|
|
205
|
+
if (inStr) {
|
|
206
|
+
if (c === inStr && prev !== '\\') inStr = null
|
|
207
|
+
} else if (c === '"' || c === "'" || c === '`') {
|
|
208
|
+
inStr = c
|
|
209
|
+
started = true
|
|
210
|
+
} else if (c === '{') {
|
|
211
|
+
if (depth === 0 && started) break // body opens here
|
|
212
|
+
depth++
|
|
213
|
+
started = true
|
|
214
|
+
} else if (c === '(' || c === '[') {
|
|
215
|
+
depth++
|
|
216
|
+
started = true
|
|
217
|
+
} else if (c === '}' || c === ')' || c === ']') {
|
|
218
|
+
depth--
|
|
219
|
+
} else if (!/\s/.test(c)) {
|
|
220
|
+
started = true
|
|
221
|
+
}
|
|
222
|
+
after++
|
|
223
|
+
}
|
|
224
|
+
returnAnnotation = source.slice(annoStart, after).trimEnd()
|
|
115
225
|
}
|
|
116
|
-
|
|
226
|
+
const signature = `function ${name}(${params})${returnAnnotation}`
|
|
117
227
|
matches.push({
|
|
118
228
|
type: 'function',
|
|
119
229
|
index: match.index,
|
|
@@ -121,6 +231,23 @@ export function generateDocs(source: string): DocResult {
|
|
|
121
231
|
})
|
|
122
232
|
}
|
|
123
233
|
|
|
234
|
+
while ((match = classPattern.exec(source)) !== null) {
|
|
235
|
+
if (braceDepthAt[match.index] !== 0) continue
|
|
236
|
+
if (isInComment[match.index]) continue
|
|
237
|
+
const name = match[1]
|
|
238
|
+
const extendsName = match[2] || undefined
|
|
239
|
+
const bodyStart = match.index + match[0].length // just past `{`
|
|
240
|
+
const bodyEnd = findMatchingBrace(source, bodyStart - 1)
|
|
241
|
+
if (bodyEnd === -1) continue
|
|
242
|
+
const body = source.slice(bodyStart, bodyEnd)
|
|
243
|
+
const members = extractClassMembers(body)
|
|
244
|
+
matches.push({
|
|
245
|
+
type: 'class',
|
|
246
|
+
index: match.index,
|
|
247
|
+
data: { name, extendsName, members },
|
|
248
|
+
})
|
|
249
|
+
}
|
|
250
|
+
|
|
124
251
|
// Sort by position in source
|
|
125
252
|
matches.sort((a, b) => a.index - b.index)
|
|
126
253
|
|
|
@@ -128,34 +255,137 @@ export function generateDocs(source: string): DocResult {
|
|
|
128
255
|
for (const m of matches) {
|
|
129
256
|
if (m.type === 'doc') {
|
|
130
257
|
items.push({ type: 'doc', content: m.data })
|
|
131
|
-
} else {
|
|
258
|
+
} else if (m.type === 'function') {
|
|
132
259
|
items.push({
|
|
133
260
|
type: 'function',
|
|
134
261
|
name: m.data.name,
|
|
135
262
|
signature: m.data.signature,
|
|
136
263
|
})
|
|
264
|
+
} else if (m.type === 'class') {
|
|
265
|
+
items.push({
|
|
266
|
+
type: 'class',
|
|
267
|
+
name: m.data.name,
|
|
268
|
+
extendsName: m.data.extendsName,
|
|
269
|
+
members: m.data.members,
|
|
270
|
+
})
|
|
137
271
|
}
|
|
138
272
|
}
|
|
139
273
|
|
|
140
274
|
// Generate markdown
|
|
141
275
|
const markdown = items
|
|
142
276
|
.map((item) => {
|
|
143
|
-
if (item.type === 'doc')
|
|
144
|
-
|
|
145
|
-
} else {
|
|
277
|
+
if (item.type === 'doc') return item.content
|
|
278
|
+
if (item.type === 'function') {
|
|
146
279
|
return `\`\`\`tjs\n${item.signature}\n\`\`\``
|
|
147
280
|
}
|
|
281
|
+
// class
|
|
282
|
+
return `\`\`\`tjs\n${formatClassSignature(item)}\n\`\`\``
|
|
148
283
|
})
|
|
149
284
|
.join('\n\n')
|
|
150
285
|
|
|
151
286
|
return { items, markdown }
|
|
152
287
|
}
|
|
153
288
|
|
|
289
|
+
/**
|
|
290
|
+
* Format a class as a signature-only block:
|
|
291
|
+
*
|
|
292
|
+
* class Color extends Hue {
|
|
293
|
+
* constructor(r: +0, g: +0, b: +0)
|
|
294
|
+
* constructor(hex: '#000000')
|
|
295
|
+
* toString()
|
|
296
|
+
* }
|
|
297
|
+
*/
|
|
298
|
+
function formatClassSignature(item: {
|
|
299
|
+
name: string
|
|
300
|
+
extendsName?: string
|
|
301
|
+
members: string[]
|
|
302
|
+
}): string {
|
|
303
|
+
const head = item.extendsName
|
|
304
|
+
? `class ${item.name} extends ${item.extendsName} {`
|
|
305
|
+
: `class ${item.name} {`
|
|
306
|
+
if (item.members.length === 0) return `${head}\n}`
|
|
307
|
+
const body = item.members.map((m) => ` ${m}`).join('\n')
|
|
308
|
+
return `${head}\n${body}\n}`
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Find the index of the `}` matching the `{` at position `open`.
|
|
313
|
+
* Returns -1 if no match. Aware of strings and template literals so
|
|
314
|
+
* braces inside them don't confuse the count.
|
|
315
|
+
*/
|
|
316
|
+
function findMatchingBrace(s: string, open: number): number {
|
|
317
|
+
let depth = 0
|
|
318
|
+
let i = open
|
|
319
|
+
let inStr: string | null = null
|
|
320
|
+
while (i < s.length) {
|
|
321
|
+
const c = s[i]
|
|
322
|
+
const prev = i > 0 ? s[i - 1] : ''
|
|
323
|
+
if (inStr) {
|
|
324
|
+
if (c === inStr && prev !== '\\') inStr = null
|
|
325
|
+
} else {
|
|
326
|
+
if (c === '"' || c === "'" || c === '`') inStr = c
|
|
327
|
+
else if (c === '{') depth++
|
|
328
|
+
else if (c === '}') {
|
|
329
|
+
depth--
|
|
330
|
+
if (depth === 0) return i
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
i++
|
|
334
|
+
}
|
|
335
|
+
return -1
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Extract member signatures from a class body. Handles:
|
|
340
|
+
* - constructors (including multiple)
|
|
341
|
+
* - regular methods: `name(params) { ... }`
|
|
342
|
+
* - async / static / get / set modifiers
|
|
343
|
+
* - private fields with `#` prefix
|
|
344
|
+
* - return-type annotations: `name(p): ReturnType { ... }`
|
|
345
|
+
*
|
|
346
|
+
* Returns the bare signature without the body, like
|
|
347
|
+
* `static load(path: '')`
|
|
348
|
+
* `get magnitude(): 0.0`
|
|
349
|
+
*/
|
|
350
|
+
function extractClassMembers(body: string): string[] {
|
|
351
|
+
const members: string[] = []
|
|
352
|
+
// Build brace depth WITHIN the body so we only pick top-level members
|
|
353
|
+
const depthInBody = computeBraceDepths(body)
|
|
354
|
+
// Match: optional modifier(s) + name + `(`
|
|
355
|
+
// Modifiers can chain: `static async`, `static get`
|
|
356
|
+
const memberPattern =
|
|
357
|
+
/(?:^|\n)\s*((?:(?:static|async|get|set)\s+)*)(constructor|#?\w+)\s*\(/g
|
|
358
|
+
let match
|
|
359
|
+
while ((match = memberPattern.exec(body)) !== null) {
|
|
360
|
+
// Skip if not at depth 0 of the body (i.e., inside a method body)
|
|
361
|
+
if (depthInBody[match.index] !== 0) continue
|
|
362
|
+
const modifiers = match[1].trim()
|
|
363
|
+
const name = match[2]
|
|
364
|
+
const parenOpen = match.index + match[0].length - 1
|
|
365
|
+
const parenClose = findMatchingParen(body, parenOpen + 1)
|
|
366
|
+
if (parenClose === -1) continue
|
|
367
|
+
const params = body.slice(parenOpen, parenClose + 1)
|
|
368
|
+
// Optional return-type annotation between `)` and `{`
|
|
369
|
+
let after = parenClose + 1
|
|
370
|
+
let returnAnnotation = ''
|
|
371
|
+
while (after < body.length && /\s/.test(body[after])) after++
|
|
372
|
+
if (body[after] === ':') {
|
|
373
|
+
// Capture `: <type>` or `:?<type>` or `:!<type>` — stop at `{`
|
|
374
|
+
const annoStart = after
|
|
375
|
+
while (after < body.length && body[after] !== '{') after++
|
|
376
|
+
returnAnnotation = body.slice(annoStart, after).trimEnd()
|
|
377
|
+
}
|
|
378
|
+
const prefix = modifiers ? `${modifiers} ` : ''
|
|
379
|
+
members.push(`${prefix}${name}${params}${returnAnnotation}`)
|
|
380
|
+
}
|
|
381
|
+
return members
|
|
382
|
+
}
|
|
383
|
+
|
|
154
384
|
/**
|
|
155
385
|
* Type metadata for a function parameter
|
|
156
386
|
*/
|
|
157
387
|
export interface ParamTypeInfo {
|
|
158
|
-
type?:
|
|
388
|
+
type?: TypeDescriptor
|
|
159
389
|
required?: boolean
|
|
160
390
|
example?: any
|
|
161
391
|
}
|
|
@@ -165,7 +395,7 @@ export interface ParamTypeInfo {
|
|
|
165
395
|
*/
|
|
166
396
|
export interface FunctionTypeInfo {
|
|
167
397
|
params?: Record<string, ParamTypeInfo>
|
|
168
|
-
returns?:
|
|
398
|
+
returns?: TypeDescriptor
|
|
169
399
|
}
|
|
170
400
|
|
|
171
401
|
/**
|
|
@@ -206,11 +436,16 @@ export function generateDocsMarkdown(
|
|
|
206
436
|
markdown += '**Parameters:**\n'
|
|
207
437
|
for (const [paramName, paramInfo] of Object.entries(info.params)) {
|
|
208
438
|
const required = paramInfo.required ? '' : ' *(optional)*'
|
|
209
|
-
const typeStr = paramInfo.type
|
|
210
|
-
|
|
439
|
+
const typeStr = paramInfo.type
|
|
440
|
+
? typeDescriptorToTS(paramInfo.type)
|
|
441
|
+
: 'any'
|
|
442
|
+
// Skip the `(e.g. ...)` for non-serializable example values
|
|
443
|
+
// (functions JSON.stringify to undefined, which renders ugly).
|
|
444
|
+
const exampleStr =
|
|
211
445
|
paramInfo.example !== undefined
|
|
212
|
-
?
|
|
213
|
-
:
|
|
446
|
+
? safeJsonExample(paramInfo.example)
|
|
447
|
+
: null
|
|
448
|
+
const example = exampleStr ? ` (e.g. \`${exampleStr}\`)` : ''
|
|
214
449
|
markdown += `- \`${paramName}\`: ${typeStr}${required}${example}\n`
|
|
215
450
|
}
|
|
216
451
|
markdown += '\n'
|
|
@@ -219,8 +454,173 @@ export function generateDocsMarkdown(
|
|
|
219
454
|
if (info?.returns) {
|
|
220
455
|
markdown += `**Returns:** ${info.returns.kind || 'void'}\n\n`
|
|
221
456
|
}
|
|
457
|
+
} else if (item.type === 'class') {
|
|
458
|
+
markdown += `## ${item.name}\n\n`
|
|
459
|
+
const head = item.extendsName
|
|
460
|
+
? `class ${item.name} extends ${item.extendsName} {`
|
|
461
|
+
: `class ${item.name} {`
|
|
462
|
+
const body =
|
|
463
|
+
item.members.length === 0
|
|
464
|
+
? ''
|
|
465
|
+
: '\n' + item.members.map((m) => ` ${m}`).join('\n') + '\n'
|
|
466
|
+
markdown += '```tjs\n' + head + body + '}\n```\n\n'
|
|
222
467
|
}
|
|
223
468
|
}
|
|
224
469
|
|
|
470
|
+
// Append test cases as documentation. Each test's description names
|
|
471
|
+
// what it asserts; the body is rendered with expect(...).toBe(...) etc.
|
|
472
|
+
// translated to inline `// → ...` comments. Anonymous tests
|
|
473
|
+
// (auto-named `test 1`, `test 2`, ...) are skipped — they read as
|
|
474
|
+
// smoke tests, not documentation.
|
|
475
|
+
const { tests } = extractTests(source)
|
|
476
|
+
for (const test of tests) {
|
|
477
|
+
if (!test.description) continue
|
|
478
|
+
if (/^test \d+$/.test(test.description)) continue
|
|
479
|
+
markdown += `### ${test.description} (test cases)\n\n`
|
|
480
|
+
markdown += '```tjs\n'
|
|
481
|
+
markdown += prettifyTestBody(test.body).trim() + '\n'
|
|
482
|
+
markdown += '```\n\n'
|
|
483
|
+
}
|
|
484
|
+
|
|
225
485
|
return markdown.trim() || '*No documentation available*'
|
|
226
486
|
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Translate `expect(actual).matcher(expected)` calls into inline comments
|
|
490
|
+
* for documentation rendering. Other lines (setup, console.log, etc.) are
|
|
491
|
+
* preserved as-is.
|
|
492
|
+
*
|
|
493
|
+
* expect(x).toBe(y) → x // → y
|
|
494
|
+
* expect(x).toEqual(y) → x // ≡ y (deep equality)
|
|
495
|
+
* expect(x).toBeTruthy() → x // → truthy
|
|
496
|
+
* expect(x).toBeFalsy() → x // → falsy
|
|
497
|
+
* expect(x).toBeNull() → x // → null
|
|
498
|
+
* expect(x).toBeUndefined() → x // → undefined
|
|
499
|
+
* expect(x).toContain(y) → x // → contains y
|
|
500
|
+
* expect(x).toThrow() → x // → throws
|
|
501
|
+
* expect(x).toBeGreaterThan(n) → x // → > n
|
|
502
|
+
* expect(x).toBeLessThan(n) → x // → < n
|
|
503
|
+
* expect(x).toBeNaN() → x // → NaN
|
|
504
|
+
*
|
|
505
|
+
* Uses balanced-paren scanning so nested calls (`expect(f(a, b)).toBe(c)`)
|
|
506
|
+
* work correctly.
|
|
507
|
+
*/
|
|
508
|
+
export function prettifyTestBody(body: string): string {
|
|
509
|
+
let i = 0
|
|
510
|
+
let out = ''
|
|
511
|
+
let inStr: string | null = null
|
|
512
|
+
while (i < body.length) {
|
|
513
|
+
const c = body[i]
|
|
514
|
+
const prev = i > 0 ? body[i - 1] : ''
|
|
515
|
+
// Track string-literal state so `"expect(fake).toBe(...)"` inside a
|
|
516
|
+
// string is preserved verbatim.
|
|
517
|
+
if (inStr) {
|
|
518
|
+
out += c
|
|
519
|
+
if (c === inStr && prev !== '\\') inStr = null
|
|
520
|
+
i++
|
|
521
|
+
continue
|
|
522
|
+
}
|
|
523
|
+
if (c === '"' || c === "'" || c === '`') {
|
|
524
|
+
inStr = c
|
|
525
|
+
out += c
|
|
526
|
+
i++
|
|
527
|
+
continue
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if (body.slice(i).startsWith('expect(')) {
|
|
531
|
+
const argStart = i + 'expect('.length
|
|
532
|
+
const argEnd = findMatchingParen(body, argStart)
|
|
533
|
+
if (argEnd > argStart) {
|
|
534
|
+
const after = body.slice(argEnd + 1)
|
|
535
|
+
const matcherMatch = after.match(/^\.(\w+)(\()?/)
|
|
536
|
+
if (matcherMatch && matcherMatch[2] === '(') {
|
|
537
|
+
const matcherStart = argEnd + 1 + matcherMatch[0].length
|
|
538
|
+
const matcherEnd = findMatchingParen(body, matcherStart)
|
|
539
|
+
if (matcherEnd >= matcherStart) {
|
|
540
|
+
const actual = body.slice(argStart, argEnd)
|
|
541
|
+
const matcherName = matcherMatch[1]
|
|
542
|
+
const expected = body.slice(matcherStart, matcherEnd)
|
|
543
|
+
out += renderMatcher(actual, matcherName, expected)
|
|
544
|
+
i = matcherEnd + 1
|
|
545
|
+
continue
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
out += c
|
|
551
|
+
i++
|
|
552
|
+
}
|
|
553
|
+
return out
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** Find the index of the `)` that matches the open paren at position `open-1`. */
|
|
557
|
+
function findMatchingParen(s: string, open: number): number {
|
|
558
|
+
let depth = 1
|
|
559
|
+
let i = open
|
|
560
|
+
let inStr: string | null = null
|
|
561
|
+
while (i < s.length) {
|
|
562
|
+
const c = s[i]
|
|
563
|
+
const prev = i > 0 ? s[i - 1] : ''
|
|
564
|
+
if (inStr) {
|
|
565
|
+
if (c === inStr && prev !== '\\') inStr = null
|
|
566
|
+
} else {
|
|
567
|
+
if (c === '"' || c === "'" || c === '`') inStr = c
|
|
568
|
+
else if (c === '(') depth++
|
|
569
|
+
else if (c === ')') {
|
|
570
|
+
depth--
|
|
571
|
+
if (depth === 0) return i
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
i++
|
|
575
|
+
}
|
|
576
|
+
return -1
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* JSON-stringify an example value, returning null if the result wouldn't
|
|
581
|
+
* be useful (e.g. functions stringify to `undefined`, which renders ugly).
|
|
582
|
+
*/
|
|
583
|
+
function safeJsonExample(value: unknown): string | null {
|
|
584
|
+
if (typeof value === 'function') return null
|
|
585
|
+
try {
|
|
586
|
+
const s = JSON.stringify(value)
|
|
587
|
+
return s === undefined ? null : s
|
|
588
|
+
} catch {
|
|
589
|
+
return null
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function renderMatcher(
|
|
594
|
+
actual: string,
|
|
595
|
+
matcher: string,
|
|
596
|
+
expected: string
|
|
597
|
+
): string {
|
|
598
|
+
const a = actual.trim()
|
|
599
|
+
const e = expected.trim()
|
|
600
|
+
switch (matcher) {
|
|
601
|
+
case 'toBe':
|
|
602
|
+
return `${a} // → ${e}`
|
|
603
|
+
case 'toEqual':
|
|
604
|
+
return `${a} // ≡ ${e}`
|
|
605
|
+
case 'toBeTruthy':
|
|
606
|
+
return `${a} // → truthy`
|
|
607
|
+
case 'toBeFalsy':
|
|
608
|
+
return `${a} // → falsy`
|
|
609
|
+
case 'toBeNull':
|
|
610
|
+
return `${a} // → null`
|
|
611
|
+
case 'toBeUndefined':
|
|
612
|
+
return `${a} // → undefined`
|
|
613
|
+
case 'toContain':
|
|
614
|
+
return `${a} // → contains ${e}`
|
|
615
|
+
case 'toThrow':
|
|
616
|
+
return `${a} // → throws`
|
|
617
|
+
case 'toBeGreaterThan':
|
|
618
|
+
return `${a} // → > ${e}`
|
|
619
|
+
case 'toBeLessThan':
|
|
620
|
+
return `${a} // → < ${e}`
|
|
621
|
+
case 'toBeNaN':
|
|
622
|
+
return `${a} // → NaN`
|
|
623
|
+
default:
|
|
624
|
+
return `${a} // .${matcher}(${e})`
|
|
625
|
+
}
|
|
626
|
+
}
|
package/src/lang/emitters/ast.ts
CHANGED
|
@@ -1420,13 +1420,14 @@ function expressionToExprNode(
|
|
|
1420
1420
|
...(isOptional && { optional: true }),
|
|
1421
1421
|
}
|
|
1422
1422
|
}
|
|
1423
|
-
//
|
|
1424
|
-
|
|
1425
|
-
'
|
|
1426
|
-
|
|
1427
|
-
ctx
|
|
1428
|
-
|
|
1429
|
-
|
|
1423
|
+
// Computed with expression (e.g. arr[i]) — emit as member with expr property
|
|
1424
|
+
return {
|
|
1425
|
+
$expr: 'member',
|
|
1426
|
+
object: obj,
|
|
1427
|
+
property: expressionToExprNode(prop, ctx),
|
|
1428
|
+
computed: true,
|
|
1429
|
+
...(isOptional && { optional: true }),
|
|
1430
|
+
}
|
|
1430
1431
|
}
|
|
1431
1432
|
|
|
1432
1433
|
const propName = (mem.property as Identifier).name
|
|
@@ -1641,11 +1642,9 @@ function expressionToValue(expr: Expression, ctx: TransformContext): any {
|
|
|
1641
1642
|
}
|
|
1642
1643
|
|
|
1643
1644
|
if (mem.computed) {
|
|
1644
|
-
// arr[0]
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
ctx
|
|
1648
|
-
)}]`
|
|
1645
|
+
// Computed member (arr[i] or arr[0]) — always emit as $expr node so the
|
|
1646
|
+
// runtime evaluates the index rather than treating it as a string path.
|
|
1647
|
+
return expressionToExprNode(expr, ctx)
|
|
1649
1648
|
}
|
|
1650
1649
|
|
|
1651
1650
|
const prop = (mem.property as Identifier).name
|
|
@@ -96,6 +96,47 @@ describe('typeDescriptorToTS', () => {
|
|
|
96
96
|
}
|
|
97
97
|
expect(typeDescriptorToTS(td)).toBe('{ x: number } | null')
|
|
98
98
|
})
|
|
99
|
+
|
|
100
|
+
it('renders function kind with named params and return type', () => {
|
|
101
|
+
expect(
|
|
102
|
+
typeDescriptorToTS({
|
|
103
|
+
kind: 'function',
|
|
104
|
+
params: [],
|
|
105
|
+
returns: { kind: 'any' },
|
|
106
|
+
})
|
|
107
|
+
).toBe('() => any')
|
|
108
|
+
expect(
|
|
109
|
+
typeDescriptorToTS({
|
|
110
|
+
kind: 'function',
|
|
111
|
+
params: [{ name: 'x', type: { kind: 'any' } }],
|
|
112
|
+
returns: { kind: 'any' },
|
|
113
|
+
})
|
|
114
|
+
).toBe('(x: any) => any')
|
|
115
|
+
expect(
|
|
116
|
+
typeDescriptorToTS({
|
|
117
|
+
kind: 'function',
|
|
118
|
+
params: [
|
|
119
|
+
{ name: 'a', type: { kind: 'integer' } },
|
|
120
|
+
{ name: 'b', type: { kind: 'integer' } },
|
|
121
|
+
],
|
|
122
|
+
returns: { kind: 'integer' },
|
|
123
|
+
})
|
|
124
|
+
).toBe('(a: number, b: number) => number')
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('renders function kind with no fields (defaults)', () => {
|
|
128
|
+
expect(typeDescriptorToTS({ kind: 'function' })).toBe('() => any')
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('renders function with object return type', () => {
|
|
132
|
+
expect(
|
|
133
|
+
typeDescriptorToTS({
|
|
134
|
+
kind: 'function',
|
|
135
|
+
params: [],
|
|
136
|
+
returns: { kind: 'object', shape: { a: { kind: 'integer' } } },
|
|
137
|
+
})
|
|
138
|
+
).toBe('() => { a: number }')
|
|
139
|
+
})
|
|
99
140
|
})
|
|
100
141
|
|
|
101
142
|
describe('generateDTS', () => {
|
package/src/lang/emitters/dts.ts
CHANGED
|
@@ -81,6 +81,15 @@ export function typeDescriptorToTS(td: TypeDescriptor): string {
|
|
|
81
81
|
}
|
|
82
82
|
base = 'any'
|
|
83
83
|
break
|
|
84
|
+
case 'function': {
|
|
85
|
+
const params = td.params ?? []
|
|
86
|
+
const returns = td.returns ? typeDescriptorToTS(td.returns) : 'any'
|
|
87
|
+
const args = params
|
|
88
|
+
.map((p) => `${p.name}: ${typeDescriptorToTS(p.type)}`)
|
|
89
|
+
.join(', ')
|
|
90
|
+
base = `(${args}) => ${returns}`
|
|
91
|
+
break
|
|
92
|
+
}
|
|
84
93
|
default:
|
|
85
94
|
base = 'any'
|
|
86
95
|
}
|
|
@@ -876,6 +876,13 @@ export function runAllTests(
|
|
|
876
876
|
// skip tests gracefully rather than marking them as failures
|
|
877
877
|
const isUnresolvedRef = hasUnresolvedImports && e instanceof ReferenceError
|
|
878
878
|
|
|
879
|
+
// The error came from module-level code (e.g. an undefined identifier
|
|
880
|
+
// in `console.log(... x ...)`), NOT from the function/test under test.
|
|
881
|
+
// Don't attribute a line — otherwise the editor would mark the function
|
|
882
|
+
// declaration's line as the error site, misleading the user about where
|
|
883
|
+
// the actual problem is. The test still appears as failed in the test
|
|
884
|
+
// list with the explanatory message; the user finds the real error
|
|
885
|
+
// through the runtime console.
|
|
879
886
|
for (const test of tests) {
|
|
880
887
|
results.push({
|
|
881
888
|
description: test.description,
|
|
@@ -883,7 +890,6 @@ export function runAllTests(
|
|
|
883
890
|
error: isUnresolvedRef
|
|
884
891
|
? undefined
|
|
885
892
|
: `Module execution failed: ${e.message}`,
|
|
886
|
-
line: test.line,
|
|
887
893
|
})
|
|
888
894
|
}
|
|
889
895
|
for (const info of syncSigTestInfos) {
|
|
@@ -897,7 +903,6 @@ export function runAllTests(
|
|
|
897
903
|
? undefined
|
|
898
904
|
: `Module execution failed: ${e.message}`,
|
|
899
905
|
isSignatureTest: true,
|
|
900
|
-
line: info.line,
|
|
901
906
|
})
|
|
902
907
|
}
|
|
903
908
|
}
|
|
@@ -949,7 +954,7 @@ function runTestBlocks(
|
|
|
949
954
|
const tjsStub = `
|
|
950
955
|
const __saved_tjs = globalThis.__tjs;
|
|
951
956
|
class __MonadicError extends Error { constructor(m,p,e,a,c){super(m);this.name='MonadicError';this.path=p;this.expected=e;this.actual=a;this.callStack=c;} }
|
|
952
|
-
const __stub_tjs = { version: '0.0.0', MonadicError: __MonadicError, pushStack: () => {}, popStack: () => {}, getStack: () => [], typeError: (path, expected, value) => new __MonadicError(\`Type error at \${path}: expected \${expected}\`, path, expected, typeof value), createRuntime: function() { return this; } };
|
|
957
|
+
const __stub_tjs = { version: '0.0.0', MonadicError: __MonadicError, pushStack: () => {}, popStack: () => {}, getStack: () => [], typeError: (path, expected, value) => new __MonadicError(\`Type error at \${path}: expected \${expected}\`, path, expected, typeof value), toBool: (v) => (v instanceof Boolean || v instanceof Number || v instanceof String) ? Boolean(v.valueOf()) : Boolean(v), createRuntime: function() { return this; } };
|
|
953
958
|
globalThis.__tjs = __stub_tjs;
|
|
954
959
|
`
|
|
955
960
|
const tjsRestore = `globalThis.__tjs = __saved_tjs;`
|
|
@@ -1308,7 +1313,7 @@ function runSignatureTest(
|
|
|
1308
1313
|
const tjsStub = `
|
|
1309
1314
|
const __saved_tjs = globalThis.__tjs;
|
|
1310
1315
|
class __MonadicError extends Error { constructor(m,p,e,a,c){super(m);this.name='MonadicError';this.path=p;this.expected=e;this.actual=a;this.callStack=c;} }
|
|
1311
|
-
const __stub_tjs = { version: '0.0.0', MonadicError: __MonadicError, pushStack: () => {}, popStack: () => {}, getStack: () => [], typeError: (path, expected, value) => new __MonadicError(\`Type error at \${path}: expected \${expected}\`, path, expected, typeof value), createRuntime: function() { return this; } };
|
|
1316
|
+
const __stub_tjs = { version: '0.0.0', MonadicError: __MonadicError, pushStack: () => {}, popStack: () => {}, getStack: () => [], typeError: (path, expected, value) => new __MonadicError(\`Type error at \${path}: expected \${expected}\`, path, expected, typeof value), toBool: (v) => (v instanceof Boolean || v instanceof Number || v instanceof String) ? Boolean(v.valueOf()) : Boolean(v), createRuntime: function() { return this; } };
|
|
1312
1317
|
globalThis.__tjs = __stub_tjs;
|
|
1313
1318
|
`
|
|
1314
1319
|
const tjsRestore = `globalThis.__tjs = __saved_tjs;`
|