tjs-lang 0.7.7 → 0.8.0
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 +99 -33
- package/bin/docs.js +4 -1
- package/demo/docs.json +104 -22
- 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-examples.ts +8 -8
- 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 +9 -4
- 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 +476 -1
- package/src/lang/docs.ts +471 -37
- 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-wasm.ts +57 -65
- package/src/lang/emitters/js.ts +198 -3
- package/src/lang/features.test.ts +4 -3
- package/src/lang/index.ts +9 -0
- 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/module-loader.test.ts +318 -0
- package/src/lang/module-loader.ts +419 -0
- package/src/lang/parser-params.ts +31 -0
- package/src/lang/parser-transforms.ts +640 -0
- package/src/lang/parser-types.ts +35 -0
- package/src/lang/parser.test.ts +73 -1
- package/src/lang/parser.ts +77 -3
- package/src/lang/runtime.ts +98 -0
- package/src/lang/types.ts +6 -0
- package/src/lang/wasm.test.ts +1293 -2
- package/src/lang/wasm.ts +470 -87
- package/src/linalg/index.tjs +119 -0
- package/src/linalg/linalg.test.ts +294 -0
- package/src/linalg/vector-search.bench.test.ts +395 -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,40 @@ 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
|
+
}
|
|
122
|
+
|
|
123
|
+
// Dedent a block of text by the smallest leading-whitespace indent
|
|
124
|
+
// found across non-empty lines.
|
|
125
|
+
function dedent(content: string): string {
|
|
126
|
+
const lines = content.split('\n')
|
|
127
|
+
const minIndent = lines
|
|
128
|
+
.filter((line) => line.trim().length > 0)
|
|
129
|
+
.reduce((min, line) => {
|
|
130
|
+
const indent = line.match(/^(\s*)/)?.[1].length || 0
|
|
131
|
+
return Math.min(min, indent)
|
|
132
|
+
}, Infinity)
|
|
133
|
+
if (minIndent === 0 || minIndent === Infinity) return content
|
|
134
|
+
return lines.map((line) => line.slice(minIndent)).join('\n')
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Strip the leading ` * ` from each line of a JSDoc comment body.
|
|
138
|
+
// The first line (between `/**` and the first newline) is left alone —
|
|
139
|
+
// content can appear there directly. Empty `*`-only lines become blank.
|
|
140
|
+
function stripJSDocAsterisks(content: string): string {
|
|
141
|
+
const lines = content.split('\n')
|
|
142
|
+
return lines
|
|
143
|
+
.map((line, i) => {
|
|
144
|
+
if (i === 0) return line
|
|
145
|
+
// Remove optional leading whitespace, then `*`, then optional single space
|
|
146
|
+
return line.replace(/^[ \t]*\*[ \t]?/, '')
|
|
147
|
+
})
|
|
148
|
+
.join('\n')
|
|
149
|
+
}
|
|
54
150
|
|
|
55
151
|
/**
|
|
56
152
|
* Generate documentation from TJS source
|
|
@@ -64,15 +160,24 @@ export function generateDocs(source: string): DocResult {
|
|
|
64
160
|
// Build brace depth map to identify top-level constructs
|
|
65
161
|
// This filters out doc blocks inside function bodies
|
|
66
162
|
const braceDepthAt = computeBraceDepths(source)
|
|
163
|
+
// Track positions inside /* */ and // comments so we don't extract
|
|
164
|
+
// illustrative `class Foo { ... }` / `function bar() { ... }` text
|
|
165
|
+
// shown in `/*# ... */` doc blocks as real declarations.
|
|
166
|
+
const isInComment = computeInComment(source)
|
|
67
167
|
|
|
68
|
-
// Find all doc blocks
|
|
168
|
+
// Find all doc blocks, functions, and classes; sort by position.
|
|
169
|
+
// Two doc-block flavors are recognized:
|
|
170
|
+
// /*# ... */ — TJS native: content is markdown verbatim
|
|
171
|
+
// /** ... */ — JSDoc: each line's leading ` * ` is stripped, then markdown
|
|
69
172
|
const docPattern = /\/\*#([\s\S]*?)\*\//g
|
|
70
|
-
|
|
71
|
-
//
|
|
72
|
-
|
|
73
|
-
|
|
173
|
+
const jsdocPattern = /\/\*\*([\s\S]*?)\*\//g
|
|
174
|
+
// Match the START of a function declaration. Params (which can contain
|
|
175
|
+
// nested parens like `fn = (x) => x`) are captured by balanced-paren
|
|
176
|
+
// scanning below, NOT by this regex.
|
|
177
|
+
const funcPattern = /\bfunction\s+(\w+)\s*\(/g
|
|
178
|
+
const classPattern = /\bclass\s+(\w+)(?:\s+extends\s+(\w+))?\s*\{/g
|
|
74
179
|
|
|
75
|
-
type Match = { type: 'doc' | 'function'; index: number; data: any }
|
|
180
|
+
type Match = { type: 'doc' | 'function' | 'class'; index: number; data: any }
|
|
76
181
|
const matches: Match[] = []
|
|
77
182
|
|
|
78
183
|
let match
|
|
@@ -82,38 +187,77 @@ export function generateDocs(source: string): DocResult {
|
|
|
82
187
|
continue
|
|
83
188
|
}
|
|
84
189
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
const lines = content.split('\n')
|
|
88
|
-
const minIndent = lines
|
|
89
|
-
.filter((line) => line.trim().length > 0)
|
|
90
|
-
.reduce((min, line) => {
|
|
91
|
-
const indent = line.match(/^(\s*)/)?.[1].length || 0
|
|
92
|
-
return Math.min(min, indent)
|
|
93
|
-
}, Infinity)
|
|
190
|
+
const content = dedent(match[1]).trim()
|
|
191
|
+
if (!content) continue
|
|
94
192
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
193
|
+
matches.push({
|
|
194
|
+
type: 'doc',
|
|
195
|
+
index: match.index,
|
|
196
|
+
data: content,
|
|
197
|
+
})
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
while ((match = jsdocPattern.exec(source)) !== null) {
|
|
201
|
+
if (braceDepthAt[match.index] !== 0) continue
|
|
202
|
+
|
|
203
|
+
const content = dedent(stripJSDocAsterisks(match[1])).trim()
|
|
204
|
+
if (!content) continue
|
|
98
205
|
|
|
99
206
|
matches.push({
|
|
100
207
|
type: 'doc',
|
|
101
208
|
index: match.index,
|
|
102
|
-
data: content
|
|
209
|
+
data: content,
|
|
103
210
|
})
|
|
104
211
|
}
|
|
105
212
|
|
|
106
213
|
while ((match = funcPattern.exec(source)) !== null) {
|
|
214
|
+
if (isInComment[match.index]) continue
|
|
215
|
+
if (braceDepthAt[match.index] !== 0) continue
|
|
107
216
|
const name = match[1]
|
|
108
|
-
const
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
217
|
+
const parenOpen = match.index + match[0].length - 1 // position of `(`
|
|
218
|
+
const parenClose = findMatchingParen(source, parenOpen + 1)
|
|
219
|
+
if (parenClose === -1) continue
|
|
220
|
+
const params = source.slice(parenOpen + 1, parenClose)
|
|
221
|
+
// Optional return-type annotation between `)` and `{`:
|
|
222
|
+
// ): T / ):? T / ):! T
|
|
223
|
+
// T can be: primitive (`0`, `''`), object (`{ x: 0 }`), array (`[0]`),
|
|
224
|
+
// or string literal (`'Hello, World!'`). Use depth-tracking with a
|
|
225
|
+
// "started" flag so the FIRST `{` inside the type opens the type
|
|
226
|
+
// block (not the body) and the `{` AFTER depth returns to 0 is the body.
|
|
227
|
+
let after = parenClose + 1
|
|
228
|
+
let returnAnnotation = ''
|
|
229
|
+
while (after < source.length && /\s/.test(source[after])) after++
|
|
230
|
+
if (source[after] === ':') {
|
|
231
|
+
const annoStart = after
|
|
232
|
+
after++ // past `:`
|
|
233
|
+
let depth = 0
|
|
234
|
+
let inStr: string | null = null
|
|
235
|
+
let started = false
|
|
236
|
+
while (after < source.length) {
|
|
237
|
+
const c = source[after]
|
|
238
|
+
const prev = after > 0 ? source[after - 1] : ''
|
|
239
|
+
if (inStr) {
|
|
240
|
+
if (c === inStr && prev !== '\\') inStr = null
|
|
241
|
+
} else if (c === '"' || c === "'" || c === '`') {
|
|
242
|
+
inStr = c
|
|
243
|
+
started = true
|
|
244
|
+
} else if (c === '{') {
|
|
245
|
+
if (depth === 0 && started) break // body opens here
|
|
246
|
+
depth++
|
|
247
|
+
started = true
|
|
248
|
+
} else if (c === '(' || c === '[') {
|
|
249
|
+
depth++
|
|
250
|
+
started = true
|
|
251
|
+
} else if (c === '}' || c === ')' || c === ']') {
|
|
252
|
+
depth--
|
|
253
|
+
} else if (!/\s/.test(c)) {
|
|
254
|
+
started = true
|
|
255
|
+
}
|
|
256
|
+
after++
|
|
257
|
+
}
|
|
258
|
+
returnAnnotation = source.slice(annoStart, after).trimEnd()
|
|
115
259
|
}
|
|
116
|
-
|
|
260
|
+
const signature = `function ${name}(${params})${returnAnnotation}`
|
|
117
261
|
matches.push({
|
|
118
262
|
type: 'function',
|
|
119
263
|
index: match.index,
|
|
@@ -121,6 +265,23 @@ export function generateDocs(source: string): DocResult {
|
|
|
121
265
|
})
|
|
122
266
|
}
|
|
123
267
|
|
|
268
|
+
while ((match = classPattern.exec(source)) !== null) {
|
|
269
|
+
if (braceDepthAt[match.index] !== 0) continue
|
|
270
|
+
if (isInComment[match.index]) continue
|
|
271
|
+
const name = match[1]
|
|
272
|
+
const extendsName = match[2] || undefined
|
|
273
|
+
const bodyStart = match.index + match[0].length // just past `{`
|
|
274
|
+
const bodyEnd = findMatchingBrace(source, bodyStart - 1)
|
|
275
|
+
if (bodyEnd === -1) continue
|
|
276
|
+
const body = source.slice(bodyStart, bodyEnd)
|
|
277
|
+
const members = extractClassMembers(body)
|
|
278
|
+
matches.push({
|
|
279
|
+
type: 'class',
|
|
280
|
+
index: match.index,
|
|
281
|
+
data: { name, extendsName, members },
|
|
282
|
+
})
|
|
283
|
+
}
|
|
284
|
+
|
|
124
285
|
// Sort by position in source
|
|
125
286
|
matches.sort((a, b) => a.index - b.index)
|
|
126
287
|
|
|
@@ -128,34 +289,137 @@ export function generateDocs(source: string): DocResult {
|
|
|
128
289
|
for (const m of matches) {
|
|
129
290
|
if (m.type === 'doc') {
|
|
130
291
|
items.push({ type: 'doc', content: m.data })
|
|
131
|
-
} else {
|
|
292
|
+
} else if (m.type === 'function') {
|
|
132
293
|
items.push({
|
|
133
294
|
type: 'function',
|
|
134
295
|
name: m.data.name,
|
|
135
296
|
signature: m.data.signature,
|
|
136
297
|
})
|
|
298
|
+
} else if (m.type === 'class') {
|
|
299
|
+
items.push({
|
|
300
|
+
type: 'class',
|
|
301
|
+
name: m.data.name,
|
|
302
|
+
extendsName: m.data.extendsName,
|
|
303
|
+
members: m.data.members,
|
|
304
|
+
})
|
|
137
305
|
}
|
|
138
306
|
}
|
|
139
307
|
|
|
140
308
|
// Generate markdown
|
|
141
309
|
const markdown = items
|
|
142
310
|
.map((item) => {
|
|
143
|
-
if (item.type === 'doc')
|
|
144
|
-
|
|
145
|
-
} else {
|
|
311
|
+
if (item.type === 'doc') return item.content
|
|
312
|
+
if (item.type === 'function') {
|
|
146
313
|
return `\`\`\`tjs\n${item.signature}\n\`\`\``
|
|
147
314
|
}
|
|
315
|
+
// class
|
|
316
|
+
return `\`\`\`tjs\n${formatClassSignature(item)}\n\`\`\``
|
|
148
317
|
})
|
|
149
318
|
.join('\n\n')
|
|
150
319
|
|
|
151
320
|
return { items, markdown }
|
|
152
321
|
}
|
|
153
322
|
|
|
323
|
+
/**
|
|
324
|
+
* Format a class as a signature-only block:
|
|
325
|
+
*
|
|
326
|
+
* class Color extends Hue {
|
|
327
|
+
* constructor(r: +0, g: +0, b: +0)
|
|
328
|
+
* constructor(hex: '#000000')
|
|
329
|
+
* toString()
|
|
330
|
+
* }
|
|
331
|
+
*/
|
|
332
|
+
function formatClassSignature(item: {
|
|
333
|
+
name: string
|
|
334
|
+
extendsName?: string
|
|
335
|
+
members: string[]
|
|
336
|
+
}): string {
|
|
337
|
+
const head = item.extendsName
|
|
338
|
+
? `class ${item.name} extends ${item.extendsName} {`
|
|
339
|
+
: `class ${item.name} {`
|
|
340
|
+
if (item.members.length === 0) return `${head}\n}`
|
|
341
|
+
const body = item.members.map((m) => ` ${m}`).join('\n')
|
|
342
|
+
return `${head}\n${body}\n}`
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Find the index of the `}` matching the `{` at position `open`.
|
|
347
|
+
* Returns -1 if no match. Aware of strings and template literals so
|
|
348
|
+
* braces inside them don't confuse the count.
|
|
349
|
+
*/
|
|
350
|
+
function findMatchingBrace(s: string, open: number): number {
|
|
351
|
+
let depth = 0
|
|
352
|
+
let i = open
|
|
353
|
+
let inStr: string | null = null
|
|
354
|
+
while (i < s.length) {
|
|
355
|
+
const c = s[i]
|
|
356
|
+
const prev = i > 0 ? s[i - 1] : ''
|
|
357
|
+
if (inStr) {
|
|
358
|
+
if (c === inStr && prev !== '\\') inStr = null
|
|
359
|
+
} else {
|
|
360
|
+
if (c === '"' || c === "'" || c === '`') inStr = c
|
|
361
|
+
else if (c === '{') depth++
|
|
362
|
+
else if (c === '}') {
|
|
363
|
+
depth--
|
|
364
|
+
if (depth === 0) return i
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
i++
|
|
368
|
+
}
|
|
369
|
+
return -1
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Extract member signatures from a class body. Handles:
|
|
374
|
+
* - constructors (including multiple)
|
|
375
|
+
* - regular methods: `name(params) { ... }`
|
|
376
|
+
* - async / static / get / set modifiers
|
|
377
|
+
* - private fields with `#` prefix
|
|
378
|
+
* - return-type annotations: `name(p): ReturnType { ... }`
|
|
379
|
+
*
|
|
380
|
+
* Returns the bare signature without the body, like
|
|
381
|
+
* `static load(path: '')`
|
|
382
|
+
* `get magnitude(): 0.0`
|
|
383
|
+
*/
|
|
384
|
+
function extractClassMembers(body: string): string[] {
|
|
385
|
+
const members: string[] = []
|
|
386
|
+
// Build brace depth WITHIN the body so we only pick top-level members
|
|
387
|
+
const depthInBody = computeBraceDepths(body)
|
|
388
|
+
// Match: optional modifier(s) + name + `(`
|
|
389
|
+
// Modifiers can chain: `static async`, `static get`
|
|
390
|
+
const memberPattern =
|
|
391
|
+
/(?:^|\n)\s*((?:(?:static|async|get|set)\s+)*)(constructor|#?\w+)\s*\(/g
|
|
392
|
+
let match
|
|
393
|
+
while ((match = memberPattern.exec(body)) !== null) {
|
|
394
|
+
// Skip if not at depth 0 of the body (i.e., inside a method body)
|
|
395
|
+
if (depthInBody[match.index] !== 0) continue
|
|
396
|
+
const modifiers = match[1].trim()
|
|
397
|
+
const name = match[2]
|
|
398
|
+
const parenOpen = match.index + match[0].length - 1
|
|
399
|
+
const parenClose = findMatchingParen(body, parenOpen + 1)
|
|
400
|
+
if (parenClose === -1) continue
|
|
401
|
+
const params = body.slice(parenOpen, parenClose + 1)
|
|
402
|
+
// Optional return-type annotation between `)` and `{`
|
|
403
|
+
let after = parenClose + 1
|
|
404
|
+
let returnAnnotation = ''
|
|
405
|
+
while (after < body.length && /\s/.test(body[after])) after++
|
|
406
|
+
if (body[after] === ':') {
|
|
407
|
+
// Capture `: <type>` or `:?<type>` or `:!<type>` — stop at `{`
|
|
408
|
+
const annoStart = after
|
|
409
|
+
while (after < body.length && body[after] !== '{') after++
|
|
410
|
+
returnAnnotation = body.slice(annoStart, after).trimEnd()
|
|
411
|
+
}
|
|
412
|
+
const prefix = modifiers ? `${modifiers} ` : ''
|
|
413
|
+
members.push(`${prefix}${name}${params}${returnAnnotation}`)
|
|
414
|
+
}
|
|
415
|
+
return members
|
|
416
|
+
}
|
|
417
|
+
|
|
154
418
|
/**
|
|
155
419
|
* Type metadata for a function parameter
|
|
156
420
|
*/
|
|
157
421
|
export interface ParamTypeInfo {
|
|
158
|
-
type?:
|
|
422
|
+
type?: TypeDescriptor
|
|
159
423
|
required?: boolean
|
|
160
424
|
example?: any
|
|
161
425
|
}
|
|
@@ -165,7 +429,7 @@ export interface ParamTypeInfo {
|
|
|
165
429
|
*/
|
|
166
430
|
export interface FunctionTypeInfo {
|
|
167
431
|
params?: Record<string, ParamTypeInfo>
|
|
168
|
-
returns?:
|
|
432
|
+
returns?: TypeDescriptor
|
|
169
433
|
}
|
|
170
434
|
|
|
171
435
|
/**
|
|
@@ -206,11 +470,16 @@ export function generateDocsMarkdown(
|
|
|
206
470
|
markdown += '**Parameters:**\n'
|
|
207
471
|
for (const [paramName, paramInfo] of Object.entries(info.params)) {
|
|
208
472
|
const required = paramInfo.required ? '' : ' *(optional)*'
|
|
209
|
-
const typeStr = paramInfo.type
|
|
210
|
-
|
|
473
|
+
const typeStr = paramInfo.type
|
|
474
|
+
? typeDescriptorToTS(paramInfo.type)
|
|
475
|
+
: 'any'
|
|
476
|
+
// Skip the `(e.g. ...)` for non-serializable example values
|
|
477
|
+
// (functions JSON.stringify to undefined, which renders ugly).
|
|
478
|
+
const exampleStr =
|
|
211
479
|
paramInfo.example !== undefined
|
|
212
|
-
?
|
|
213
|
-
:
|
|
480
|
+
? safeJsonExample(paramInfo.example)
|
|
481
|
+
: null
|
|
482
|
+
const example = exampleStr ? ` (e.g. \`${exampleStr}\`)` : ''
|
|
214
483
|
markdown += `- \`${paramName}\`: ${typeStr}${required}${example}\n`
|
|
215
484
|
}
|
|
216
485
|
markdown += '\n'
|
|
@@ -219,8 +488,173 @@ export function generateDocsMarkdown(
|
|
|
219
488
|
if (info?.returns) {
|
|
220
489
|
markdown += `**Returns:** ${info.returns.kind || 'void'}\n\n`
|
|
221
490
|
}
|
|
491
|
+
} else if (item.type === 'class') {
|
|
492
|
+
markdown += `## ${item.name}\n\n`
|
|
493
|
+
const head = item.extendsName
|
|
494
|
+
? `class ${item.name} extends ${item.extendsName} {`
|
|
495
|
+
: `class ${item.name} {`
|
|
496
|
+
const body =
|
|
497
|
+
item.members.length === 0
|
|
498
|
+
? ''
|
|
499
|
+
: '\n' + item.members.map((m) => ` ${m}`).join('\n') + '\n'
|
|
500
|
+
markdown += '```tjs\n' + head + body + '}\n```\n\n'
|
|
222
501
|
}
|
|
223
502
|
}
|
|
224
503
|
|
|
504
|
+
// Append test cases as documentation. Each test's description names
|
|
505
|
+
// what it asserts; the body is rendered with expect(...).toBe(...) etc.
|
|
506
|
+
// translated to inline `// → ...` comments. Anonymous tests
|
|
507
|
+
// (auto-named `test 1`, `test 2`, ...) are skipped — they read as
|
|
508
|
+
// smoke tests, not documentation.
|
|
509
|
+
const { tests } = extractTests(source)
|
|
510
|
+
for (const test of tests) {
|
|
511
|
+
if (!test.description) continue
|
|
512
|
+
if (/^test \d+$/.test(test.description)) continue
|
|
513
|
+
markdown += `### ${test.description} (test cases)\n\n`
|
|
514
|
+
markdown += '```tjs\n'
|
|
515
|
+
markdown += prettifyTestBody(test.body).trim() + '\n'
|
|
516
|
+
markdown += '```\n\n'
|
|
517
|
+
}
|
|
518
|
+
|
|
225
519
|
return markdown.trim() || '*No documentation available*'
|
|
226
520
|
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Translate `expect(actual).matcher(expected)` calls into inline comments
|
|
524
|
+
* for documentation rendering. Other lines (setup, console.log, etc.) are
|
|
525
|
+
* preserved as-is.
|
|
526
|
+
*
|
|
527
|
+
* expect(x).toBe(y) → x // → y
|
|
528
|
+
* expect(x).toEqual(y) → x // ≡ y (deep equality)
|
|
529
|
+
* expect(x).toBeTruthy() → x // → truthy
|
|
530
|
+
* expect(x).toBeFalsy() → x // → falsy
|
|
531
|
+
* expect(x).toBeNull() → x // → null
|
|
532
|
+
* expect(x).toBeUndefined() → x // → undefined
|
|
533
|
+
* expect(x).toContain(y) → x // → contains y
|
|
534
|
+
* expect(x).toThrow() → x // → throws
|
|
535
|
+
* expect(x).toBeGreaterThan(n) → x // → > n
|
|
536
|
+
* expect(x).toBeLessThan(n) → x // → < n
|
|
537
|
+
* expect(x).toBeNaN() → x // → NaN
|
|
538
|
+
*
|
|
539
|
+
* Uses balanced-paren scanning so nested calls (`expect(f(a, b)).toBe(c)`)
|
|
540
|
+
* work correctly.
|
|
541
|
+
*/
|
|
542
|
+
export function prettifyTestBody(body: string): string {
|
|
543
|
+
let i = 0
|
|
544
|
+
let out = ''
|
|
545
|
+
let inStr: string | null = null
|
|
546
|
+
while (i < body.length) {
|
|
547
|
+
const c = body[i]
|
|
548
|
+
const prev = i > 0 ? body[i - 1] : ''
|
|
549
|
+
// Track string-literal state so `"expect(fake).toBe(...)"` inside a
|
|
550
|
+
// string is preserved verbatim.
|
|
551
|
+
if (inStr) {
|
|
552
|
+
out += c
|
|
553
|
+
if (c === inStr && prev !== '\\') inStr = null
|
|
554
|
+
i++
|
|
555
|
+
continue
|
|
556
|
+
}
|
|
557
|
+
if (c === '"' || c === "'" || c === '`') {
|
|
558
|
+
inStr = c
|
|
559
|
+
out += c
|
|
560
|
+
i++
|
|
561
|
+
continue
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
if (body.slice(i).startsWith('expect(')) {
|
|
565
|
+
const argStart = i + 'expect('.length
|
|
566
|
+
const argEnd = findMatchingParen(body, argStart)
|
|
567
|
+
if (argEnd > argStart) {
|
|
568
|
+
const after = body.slice(argEnd + 1)
|
|
569
|
+
const matcherMatch = after.match(/^\.(\w+)(\()?/)
|
|
570
|
+
if (matcherMatch && matcherMatch[2] === '(') {
|
|
571
|
+
const matcherStart = argEnd + 1 + matcherMatch[0].length
|
|
572
|
+
const matcherEnd = findMatchingParen(body, matcherStart)
|
|
573
|
+
if (matcherEnd >= matcherStart) {
|
|
574
|
+
const actual = body.slice(argStart, argEnd)
|
|
575
|
+
const matcherName = matcherMatch[1]
|
|
576
|
+
const expected = body.slice(matcherStart, matcherEnd)
|
|
577
|
+
out += renderMatcher(actual, matcherName, expected)
|
|
578
|
+
i = matcherEnd + 1
|
|
579
|
+
continue
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
out += c
|
|
585
|
+
i++
|
|
586
|
+
}
|
|
587
|
+
return out
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/** Find the index of the `)` that matches the open paren at position `open-1`. */
|
|
591
|
+
function findMatchingParen(s: string, open: number): number {
|
|
592
|
+
let depth = 1
|
|
593
|
+
let i = open
|
|
594
|
+
let inStr: string | null = null
|
|
595
|
+
while (i < s.length) {
|
|
596
|
+
const c = s[i]
|
|
597
|
+
const prev = i > 0 ? s[i - 1] : ''
|
|
598
|
+
if (inStr) {
|
|
599
|
+
if (c === inStr && prev !== '\\') inStr = null
|
|
600
|
+
} else {
|
|
601
|
+
if (c === '"' || c === "'" || c === '`') inStr = c
|
|
602
|
+
else if (c === '(') depth++
|
|
603
|
+
else if (c === ')') {
|
|
604
|
+
depth--
|
|
605
|
+
if (depth === 0) return i
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
i++
|
|
609
|
+
}
|
|
610
|
+
return -1
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* JSON-stringify an example value, returning null if the result wouldn't
|
|
615
|
+
* be useful (e.g. functions stringify to `undefined`, which renders ugly).
|
|
616
|
+
*/
|
|
617
|
+
function safeJsonExample(value: unknown): string | null {
|
|
618
|
+
if (typeof value === 'function') return null
|
|
619
|
+
try {
|
|
620
|
+
const s = JSON.stringify(value)
|
|
621
|
+
return s === undefined ? null : s
|
|
622
|
+
} catch {
|
|
623
|
+
return null
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function renderMatcher(
|
|
628
|
+
actual: string,
|
|
629
|
+
matcher: string,
|
|
630
|
+
expected: string
|
|
631
|
+
): string {
|
|
632
|
+
const a = actual.trim()
|
|
633
|
+
const e = expected.trim()
|
|
634
|
+
switch (matcher) {
|
|
635
|
+
case 'toBe':
|
|
636
|
+
return `${a} // → ${e}`
|
|
637
|
+
case 'toEqual':
|
|
638
|
+
return `${a} // ≡ ${e}`
|
|
639
|
+
case 'toBeTruthy':
|
|
640
|
+
return `${a} // → truthy`
|
|
641
|
+
case 'toBeFalsy':
|
|
642
|
+
return `${a} // → falsy`
|
|
643
|
+
case 'toBeNull':
|
|
644
|
+
return `${a} // → null`
|
|
645
|
+
case 'toBeUndefined':
|
|
646
|
+
return `${a} // → undefined`
|
|
647
|
+
case 'toContain':
|
|
648
|
+
return `${a} // → contains ${e}`
|
|
649
|
+
case 'toThrow':
|
|
650
|
+
return `${a} // → throws`
|
|
651
|
+
case 'toBeGreaterThan':
|
|
652
|
+
return `${a} // → > ${e}`
|
|
653
|
+
case 'toBeLessThan':
|
|
654
|
+
return `${a} // → < ${e}`
|
|
655
|
+
case 'toBeNaN':
|
|
656
|
+
return `${a} // → NaN`
|
|
657
|
+
default:
|
|
658
|
+
return `${a} // .${matcher}(${e})`
|
|
659
|
+
}
|
|
660
|
+
}
|
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', () => {
|