@ndp-software/lit-md 0.3.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/LICENSE.md +3 -0
- package/README.md +125 -0
- package/dist/cli.js +1273 -0
- package/dist/cli.js.map +7 -0
- package/docs/cli.md +74 -0
- package/docs/how-wait-mode-works.md +381 -0
- package/docs/shell-examples.md +254 -0
- package/package.json +58 -0
- package/src/cli.ts +327 -0
- package/src/describe-format.ts +53 -0
- package/src/docs/README.lit-md.ts +126 -0
- package/src/docs/cli.lit-md.ts +44 -0
- package/src/docs/shell-examples.lit-md.ts +243 -0
- package/src/index.ts +7 -0
- package/src/parser.ts +970 -0
- package/src/renderer.ts +130 -0
- package/src/resolver.ts +65 -0
- package/src/shell.ts +362 -0
- package/src/typecheck.ts +51 -0
package/src/parser.ts
ADDED
|
@@ -0,0 +1,970 @@
|
|
|
1
|
+
import ts from 'typescript'
|
|
2
|
+
import { spawnSync } from 'node:child_process'
|
|
3
|
+
import { writeFileSync, mkdtempSync, rmSync, readFileSync } from 'node:fs'
|
|
4
|
+
import { join, isAbsolute } from 'node:path'
|
|
5
|
+
import { tmpdir } from 'node:os'
|
|
6
|
+
|
|
7
|
+
export type ProseNode = { kind: 'prose'; text: string; terminal?: true; noBlankAfter?: true; noBlankBefore?: true }
|
|
8
|
+
export type CodeNode = { kind: 'code'; lang: string; text: string; title?: string }
|
|
9
|
+
export type DescribeNode = { kind: 'describe'; name: string; depth: number }
|
|
10
|
+
|
|
11
|
+
export type ShellCommandExecution = {
|
|
12
|
+
stdout: string
|
|
13
|
+
outputFiles: Map<string, string>
|
|
14
|
+
exitCode: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type InputFileInfo = { path: string, content: string }
|
|
18
|
+
export type OutputFileDisplayNode = {
|
|
19
|
+
kind: 'output-file-display'
|
|
20
|
+
path: string
|
|
21
|
+
lang: string
|
|
22
|
+
cmd: string
|
|
23
|
+
inputFiles: Array<InputFileInfo>
|
|
24
|
+
execution?: ShellCommandExecution
|
|
25
|
+
}
|
|
26
|
+
export type DocNode = ProseNode | CodeNode | OutputFileDisplayNode | DescribeNode
|
|
27
|
+
|
|
28
|
+
export function parse(src: string, lang = 'typescript'): DocNode[] {
|
|
29
|
+
if (!src.trim()) return []
|
|
30
|
+
|
|
31
|
+
const sf = ts.createSourceFile('input.ts', src, ts.ScriptTarget.Latest, true)
|
|
32
|
+
const nodes: DocNode[] = []
|
|
33
|
+
const processedCommentRanges = new Set<number>()
|
|
34
|
+
let pendingFileLabel: string | undefined = undefined
|
|
35
|
+
let lastCommentEnd = 0
|
|
36
|
+
let pendingNewParagraph = false
|
|
37
|
+
|
|
38
|
+
function extractLeadingComments(pos: number): void {
|
|
39
|
+
const ranges = ts.getLeadingCommentRanges(src, pos) ?? []
|
|
40
|
+
for (const r of ranges) {
|
|
41
|
+
if (processedCommentRanges.has(r.pos)) continue
|
|
42
|
+
processedCommentRanges.add(r.pos)
|
|
43
|
+
const raw = src.slice(r.pos, r.end)
|
|
44
|
+
|
|
45
|
+
const gap = lastCommentEnd > 0 ? src.slice(lastCommentEnd, r.pos) : ''
|
|
46
|
+
const hasBlankLineBefore = gap !== '' && /^[ \t\n]*$/.test(gap) && /\n[ \t]*\n/.test(gap)
|
|
47
|
+
lastCommentEnd = r.end
|
|
48
|
+
|
|
49
|
+
// Check for // file: directive first
|
|
50
|
+
if (r.kind === ts.SyntaxKind.SingleLineCommentTrivia) {
|
|
51
|
+
const fileMatch = raw.match(/^\/\/\s*file:\s*(.+)$/)
|
|
52
|
+
if (fileMatch) {
|
|
53
|
+
pendingFileLabel = fileMatch[1]!.trim()
|
|
54
|
+
continue
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const prose = commentToProse(raw, r.kind)
|
|
59
|
+
if (prose !== null) {
|
|
60
|
+
if (hasBlankLineBefore && prose === '') {
|
|
61
|
+
pendingNewParagraph = true
|
|
62
|
+
} else if ((hasBlankLineBefore || pendingNewParagraph) && prose !== '') {
|
|
63
|
+
nodes.push({ kind: 'prose', text: prose })
|
|
64
|
+
pendingNewParagraph = false
|
|
65
|
+
} else {
|
|
66
|
+
mergeOrPushProse(nodes, prose)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function visitStatements(statements: ts.NodeArray<ts.Statement>, depth: number = 0, parentBlock?: ts.Block): void {
|
|
73
|
+
for (const stmt of statements) {
|
|
74
|
+
extractLeadingComments(stmt.getFullStart())
|
|
75
|
+
processStatement(stmt, depth)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Extract trailing comments after the last statement
|
|
79
|
+
if (statements.length > 0 && parentBlock) {
|
|
80
|
+
const lastStatement = statements[statements.length - 1]!
|
|
81
|
+
extractLeadingComments(lastStatement.end)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function processStatement(stmt: ts.Statement, depth: number = 0): void {
|
|
86
|
+
// Check for // keep:full (multi-line statements)
|
|
87
|
+
const fullStmt = getFullStatement(stmt, src)
|
|
88
|
+
if (fullStmt !== null) {
|
|
89
|
+
const title = pendingFileLabel
|
|
90
|
+
pendingFileLabel = undefined
|
|
91
|
+
mergeOrPushCode(nodes, fullStmt, lang, title)
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Check if this statement has // keep comment (single-line)
|
|
96
|
+
const lineText = getStatementLine(stmt, src)
|
|
97
|
+
if (lineText !== null && hasKeepComment(lineText)) {
|
|
98
|
+
const title = pendingFileLabel
|
|
99
|
+
pendingFileLabel = undefined
|
|
100
|
+
const cleanedLine = lineText.replace(/\s*\/\/\s*keep\b.*$/, '')
|
|
101
|
+
mergeOrPushCode(nodes, cleanedLine, lang, title)
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Handle import declarations (legacy code path, now handled above)
|
|
106
|
+
if (ts.isImportDeclaration(stmt)) {
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Detect test('name', () => { ... }) calls
|
|
111
|
+
if (ts.isExpressionStatement(stmt)) {
|
|
112
|
+
const expr = stmt.expression
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
if (ts.isCallExpression(expr) && ts.isIdentifier(expr.expression)) {
|
|
117
|
+
const name = expr.expression.text
|
|
118
|
+
if (name === 'test' || name === 'it' || name === 'example') {
|
|
119
|
+
const testName = getStringArg(expr, 0)
|
|
120
|
+
const body = getFnBody(expr, 1)
|
|
121
|
+
if (body) {
|
|
122
|
+
const code = extractBodyCode(src, body)
|
|
123
|
+
if (code.trim()) {
|
|
124
|
+
const title = pendingFileLabel
|
|
125
|
+
pendingFileLabel = undefined
|
|
126
|
+
// Check if we can find a prose node with a trailing code fence to merge with
|
|
127
|
+
// (looking past any describe nodes)
|
|
128
|
+
let proseNodeIdx = -1
|
|
129
|
+
for (let i = nodes.length - 1; i >= 0; i--) {
|
|
130
|
+
if (nodes[i]!.kind === 'prose') {
|
|
131
|
+
proseNodeIdx = i
|
|
132
|
+
break
|
|
133
|
+
} else if (nodes[i]!.kind !== 'describe') {
|
|
134
|
+
// Stop if we hit a non-prose, non-describe node
|
|
135
|
+
break
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (proseNodeIdx >= 0) {
|
|
140
|
+
const proseNode = nodes[proseNodeIdx]!
|
|
141
|
+
const fenceMatch = extractTrailingFence((proseNode as any).text)
|
|
142
|
+
if (fenceMatch) {
|
|
143
|
+
(proseNode as any).text = fenceMatch.prose;
|
|
144
|
+
(proseNode as any).noBlankAfter = true
|
|
145
|
+
// Remove any describe nodes between the prose and here
|
|
146
|
+
nodes.splice(proseNodeIdx + 1)
|
|
147
|
+
const mergedCode = fenceMatch.fenceCode + '\n' + code
|
|
148
|
+
nodes.push(codeNode(lang, mergedCode, title))
|
|
149
|
+
} else if (proseNodeIdx === nodes.length - 1) {
|
|
150
|
+
// Prose directly precedes this code block (no describe nodes in between), suppress blank line
|
|
151
|
+
(proseNode as any).noBlankAfter = true
|
|
152
|
+
nodes.push(codeNode(lang, code, title))
|
|
153
|
+
} else {
|
|
154
|
+
// There are describe nodes between prose and code, don't suppress blank line
|
|
155
|
+
nodes.push(codeNode(lang, code, title))
|
|
156
|
+
}
|
|
157
|
+
} else {
|
|
158
|
+
nodes.push(codeNode(lang, code, title))
|
|
159
|
+
}
|
|
160
|
+
} else if (body && !ts.isBlock(body)) {
|
|
161
|
+
// Expression body that extracted to empty/whitespace - warn about this
|
|
162
|
+
const lines = src.slice(body.getFullStart(), body.getEnd()).split('\n').length
|
|
163
|
+
if (lines > 2) {
|
|
164
|
+
console.warn(`⚠ Warning: ${name}('${testName}') expression body (${lines} lines) did not produce output`)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (name === 'describe') {
|
|
171
|
+
const descName = getStringArg(expr, 0)
|
|
172
|
+
const body = getFnBody(expr, 1)
|
|
173
|
+
if (body && ts.isBlock(body) && descName !== null) {
|
|
174
|
+
nodes.push({ kind: 'describe', name: descName, depth })
|
|
175
|
+
visitStatements(body.statements, depth + 1, body)
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (name === 'metaExample') {
|
|
180
|
+
const body = getFnBody(expr, 1)
|
|
181
|
+
if (body) {
|
|
182
|
+
const code = extractBodyCode(src, body)
|
|
183
|
+
if (code.trim()) {
|
|
184
|
+
const title = pendingFileLabel
|
|
185
|
+
pendingFileLabel = undefined
|
|
186
|
+
// 1. Raw example call (original source, assertions not rewritten)
|
|
187
|
+
const rawCall = dedentCallSource(src.slice(expr.getStart(), expr.getEnd()))
|
|
188
|
+
.replace(/^metaExample\b/, 'example')
|
|
189
|
+
nodes.push(codeNode(lang, rawCall, title))
|
|
190
|
+
// 2. "becomes" prose
|
|
191
|
+
nodes.push({ kind: 'prose', text: 'becomes', noBlankBefore: true, noBlankAfter: true })
|
|
192
|
+
// 3. Rendered output as an md code block
|
|
193
|
+
const langAlias = lang === 'typescript' ? 'ts' : lang === 'javascript' ? 'js' : lang
|
|
194
|
+
const innerFence = `\`\`\`${langAlias}\n${code}\n\`\`\``
|
|
195
|
+
nodes.push(codeNode('md', innerFence))
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return
|
|
199
|
+
}
|
|
200
|
+
if (name === 'shellExample') {
|
|
201
|
+
const cmd = getStringArg(expr, 0)
|
|
202
|
+
if (cmd !== null) {
|
|
203
|
+
const title = pendingFileLabel
|
|
204
|
+
pendingFileLabel = undefined
|
|
205
|
+
const optsArg = expr.arguments[1]
|
|
206
|
+
const opts = optsArg && ts.isObjectLiteralExpression(optsArg) ? optsArg : undefined
|
|
207
|
+
|
|
208
|
+
// Check if meta: true is explicitly set
|
|
209
|
+
const metaProp = opts ? getProp(opts, 'meta') : undefined
|
|
210
|
+
const hasMeta = metaProp && metaProp.initializer.kind === ts.SyntaxKind.TrueKeyword
|
|
211
|
+
|
|
212
|
+
// If meta is true, add a code block showing the reconstructed shellExample call
|
|
213
|
+
if (hasMeta) {
|
|
214
|
+
const reconstructed = reconstructShellExampleWithoutMeta(src, expr, cmd, opts)
|
|
215
|
+
nodes.push(codeNode('ts', reconstructed))
|
|
216
|
+
// Add "becomes" separator
|
|
217
|
+
nodes.push({ kind: 'prose', text: 'becomes', noBlankBefore: true, noBlankAfter: true })
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (opts) processShellExampleInputFiles(opts, nodes)
|
|
221
|
+
|
|
222
|
+
const inputFiles = opts ? extractStaticInputFiles(opts) : []
|
|
223
|
+
|
|
224
|
+
const exitCodeProp = opts ? getProp(opts, 'exitCode') : undefined
|
|
225
|
+
const expectedExitCode = exitCodeProp && ts.isNumericLiteral(exitCodeProp.initializer)
|
|
226
|
+
? parseInt(exitCodeProp.initializer.text, 10)
|
|
227
|
+
: 0
|
|
228
|
+
|
|
229
|
+
let execution: ShellCommandExecution | null = null
|
|
230
|
+
if (opts && isExecutionNeeded(opts)) {
|
|
231
|
+
const outputPaths = extractOutputFilePaths(opts)
|
|
232
|
+
execution = executeShellCommand(cmd, inputFiles, outputPaths, expectedExitCode)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const displayCommand = opts ? readBoolOption(getProp(opts, 'displayCommand')) : true
|
|
236
|
+
|
|
237
|
+
const lines: string[] = displayCommand ? [`$ ${cmd}`] : []
|
|
238
|
+
if (opts) appendShellExampleAnnotations(opts, lines, execution)
|
|
239
|
+
if (lines.length > 0) {
|
|
240
|
+
nodes.push(codeNode('sh', lines.join('\n'), title))
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (opts) processShellExampleOutputFiles(src, opts, nodes, cmd, inputFiles, execution)
|
|
244
|
+
}
|
|
245
|
+
return
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
visitStatements(sf.statements)
|
|
252
|
+
|
|
253
|
+
// Capture comments before EOF token
|
|
254
|
+
extractLeadingComments(sf.endOfFileToken.getFullStart())
|
|
255
|
+
|
|
256
|
+
return nodes
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Typed helper: find a PropertyAssignment by name in an ObjectLiteralExpression. */
|
|
260
|
+
function getProp(obj: ts.ObjectLiteralExpression, name: string): ts.PropertyAssignment | undefined {
|
|
261
|
+
return obj.properties.find(
|
|
262
|
+
(p): p is ts.PropertyAssignment => ts.isPropertyAssignment(p) && ts.isIdentifier(p.name) && p.name.text === name
|
|
263
|
+
)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Returns false if prop's initializer is `false` or the string `'hidden'`, true otherwise. */
|
|
267
|
+
function readBoolOption(prop: ts.PropertyAssignment | undefined): boolean {
|
|
268
|
+
if (!prop) return true
|
|
269
|
+
const init = prop.initializer
|
|
270
|
+
if (init.kind === ts.SyntaxKind.FalseKeyword) return false
|
|
271
|
+
if (ts.isStringLiteralLike(init) && init.text === 'hidden') return false
|
|
272
|
+
return true
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Returns false if prop's initializer is `false`, true otherwise. */
|
|
276
|
+
function readFlag(prop: ts.PropertyAssignment | undefined): boolean {
|
|
277
|
+
if (!prop) return true
|
|
278
|
+
return prop.initializer.kind !== ts.SyntaxKind.FalseKeyword
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Escapes a string value for safe embedding in a TypeScript single-quoted string literal. */
|
|
282
|
+
function escapeForSingleQuotedString(s: string): string {
|
|
283
|
+
return s
|
|
284
|
+
.replace(/\\/g, '\\\\') // backslashes must be escaped first
|
|
285
|
+
.replace(/'/g, "\\'") // then single quotes
|
|
286
|
+
.replace(/\n/g, '\\n') // newlines
|
|
287
|
+
.replace(/\r/g, '\\r') // carriage returns
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Reconstructs shellExample call without the meta option. */
|
|
291
|
+
function reconstructShellExampleWithoutMeta(src: string, expr: ts.CallExpression, cmd: string, opts: ts.ObjectLiteralExpression | undefined): string {
|
|
292
|
+
const escapedCmd = escapeForSingleQuotedString(cmd)
|
|
293
|
+
if (!opts) {
|
|
294
|
+
return `shellExample('${escapedCmd}')`
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Extract options text and remove meta: true
|
|
298
|
+
const optsText = src.slice(opts.getStart(), opts.getEnd())
|
|
299
|
+
|
|
300
|
+
// Remove "meta: true," or "meta: true" variations
|
|
301
|
+
let cleanedOpts = optsText
|
|
302
|
+
.replace(/,?\s*meta:\s*true\s*,?/g, ',') // Remove meta: true with surrounding commas
|
|
303
|
+
.replace(/^\{\s*,/, '{') // Remove leading comma after {
|
|
304
|
+
.replace(/,\s*\}$/, '}') // Remove trailing comma before }
|
|
305
|
+
|
|
306
|
+
return `shellExample('${escapedCmd}'${cleanedOpts !== '{}' ? `, ${cleanedOpts}` : ''})`
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Create a CodeNode, omitting the `title` key entirely when undefined. */
|
|
310
|
+
function codeNode(lang: string, text: string, title?: string): CodeNode {
|
|
311
|
+
return title !== undefined ? { kind: 'code', lang, text, title } : { kind: 'code', lang, text }
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function getStringArg(call: ts.CallExpression, index: number): string | null {
|
|
315
|
+
const arg = call.arguments[index]
|
|
316
|
+
if (arg && ts.isStringLiteralLike(arg)) return arg.text
|
|
317
|
+
return null
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function getFnBody(call: ts.CallExpression, index: number): ts.Block | ts.Expression | null {
|
|
321
|
+
const arg = call.arguments[index]
|
|
322
|
+
if (!arg) return null
|
|
323
|
+
if (ts.isArrowFunction(arg) || ts.isFunctionExpression(arg)) {
|
|
324
|
+
return arg.body
|
|
325
|
+
}
|
|
326
|
+
return null
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function extractBodyCode(src: string, bodyOrBlock: ts.Block | ts.Expression): string {
|
|
330
|
+
if (!ts.isBlock(bodyOrBlock)) {
|
|
331
|
+
// Expression body - extract the expression as a single "statement"
|
|
332
|
+
return src.slice(bodyOrBlock.getFullStart(), bodyOrBlock.getEnd()).trim()
|
|
333
|
+
}
|
|
334
|
+
const stmts = bodyOrBlock.statements
|
|
335
|
+
if (!stmts.length) return ''
|
|
336
|
+
|
|
337
|
+
// Compute block indentation from the column of the first statement token
|
|
338
|
+
const firstStart = stmts[0]!.getStart()
|
|
339
|
+
const lineStart = src.lastIndexOf('\n', firstStart - 1) + 1
|
|
340
|
+
const indent = firstStart - lineStart
|
|
341
|
+
|
|
342
|
+
// Include trailing // comment on the last statement's line
|
|
343
|
+
const lastStmt = stmts[stmts.length - 1]!
|
|
344
|
+
const lastEnd = lastStmt.getEnd()
|
|
345
|
+
const lastLineEnd = src.indexOf('\n', lastEnd)
|
|
346
|
+
const textAfterLast = src.slice(lastEnd, lastLineEnd === -1 ? src.length : lastLineEnd)
|
|
347
|
+
const extractEnd = /^\s*\/\//.test(textAfterLast)
|
|
348
|
+
? (lastLineEnd === -1 ? src.length : lastLineEnd)
|
|
349
|
+
: lastEnd
|
|
350
|
+
|
|
351
|
+
// Extract from the full start of the first statement (includes leading whitespace/comments)
|
|
352
|
+
const base = stmts[0]!.getFullStart()
|
|
353
|
+
let raw = src.slice(base, extractEnd)
|
|
354
|
+
|
|
355
|
+
// Rewrite recognized assertion statements (end-to-start to preserve offsets)
|
|
356
|
+
const replacements: Array<{ start: number; end: number; text: string }> = []
|
|
357
|
+
for (const stmt of stmts) {
|
|
358
|
+
const rewritten = tryRewriteAssertion(src, stmt, indent)
|
|
359
|
+
if (rewritten !== null) {
|
|
360
|
+
replacements.push({ start: stmt.getStart() - base, end: stmt.getEnd() - base, text: rewritten })
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
replacements.sort((a, b) => b.start - a.start)
|
|
364
|
+
for (const r of replacements) {
|
|
365
|
+
raw = raw.slice(0, r.start) + r.text + raw.slice(r.end)
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Dedent: remove `indent` leading spaces from any line that starts with at least that many spaces.
|
|
369
|
+
// Lines with fewer leading spaces (e.g. template literal content) are kept as-is.
|
|
370
|
+
let result = raw
|
|
371
|
+
.split('\n')
|
|
372
|
+
.map(line => (line.length >= indent && line.slice(0, indent).trim() === '') ? line.slice(indent) : line)
|
|
373
|
+
.join('\n')
|
|
374
|
+
.trim()
|
|
375
|
+
|
|
376
|
+
// Clean up excessive blank lines from dropped statements (preserve intentional single blank lines)
|
|
377
|
+
result = result.replace(/\n\n\n+/g, '\n\n')
|
|
378
|
+
|
|
379
|
+
// Transform nested assert.ok(expr) → expr // OK
|
|
380
|
+
result = transformNestedAssertOk(result)
|
|
381
|
+
|
|
382
|
+
return result
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** Strip the shared indentation (determined from the closing line) from a raw call source. */
|
|
386
|
+
function dedentCallSource(callText: string): string {
|
|
387
|
+
const lines = callText.split('\n')
|
|
388
|
+
if (lines.length <= 1) return callText
|
|
389
|
+
const lastLine = lines[lines.length - 1]!
|
|
390
|
+
const closingIndent = lastLine.length - lastLine.trimStart().length
|
|
391
|
+
if (closingIndent === 0) return callText
|
|
392
|
+
return lines.map((line, i) => {
|
|
393
|
+
if (i === 0) return line
|
|
394
|
+
return line.length >= closingIndent && line.slice(0, closingIndent).trim() === ''
|
|
395
|
+
? line.slice(closingIndent)
|
|
396
|
+
: line
|
|
397
|
+
}).join('\n')
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function commentToProse(raw: string, kind: ts.CommentKind): string | null {
|
|
401
|
+
if (kind === ts.SyntaxKind.SingleLineCommentTrivia) {
|
|
402
|
+
return raw.replace(/^\/\/\s?/, '')
|
|
403
|
+
}
|
|
404
|
+
if (kind === ts.SyntaxKind.MultiLineCommentTrivia) {
|
|
405
|
+
const inner = raw
|
|
406
|
+
.replace(/^\/\*+/, '')
|
|
407
|
+
.replace(/\*+\/$/, '')
|
|
408
|
+
.split('\n')
|
|
409
|
+
.map(l => l.replace(/^\s*\*\s?/, ''))
|
|
410
|
+
.join('\n')
|
|
411
|
+
.trim()
|
|
412
|
+
return inner || null
|
|
413
|
+
}
|
|
414
|
+
return null
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function isKeptImport(text: string): boolean {
|
|
418
|
+
return /\/\/\s*keep\b/.test(text)
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function hasKeepComment(text: string): boolean {
|
|
422
|
+
return /\/\/\s*keep\b/.test(text)
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function isFullKeep(text: string): boolean {
|
|
426
|
+
return /\/\/\s*keep:full\b/.test(text)
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** Extract the full line text of a statement (from statement start to end of line).
|
|
430
|
+
* Returns null if the statement spans multiple lines or we can't extract it. */
|
|
431
|
+
function getStatementLine(stmt: ts.Statement, src: string): string | null {
|
|
432
|
+
const lineEnd = src.indexOf('\n', stmt.getEnd())
|
|
433
|
+
const lineText = src.slice(stmt.getStart(), lineEnd === -1 ? src.length : lineEnd).trimEnd()
|
|
434
|
+
|
|
435
|
+
// Check if statement fits on one line (heuristic: doesn't contain opening brace on different line)
|
|
436
|
+
const lines = lineText.split('\n')
|
|
437
|
+
if (lines.length > 1) {
|
|
438
|
+
// Multi-line statement - for now, only process if it's a simple case
|
|
439
|
+
return null
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
return lineText
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** Extract a full multi-line statement including body.
|
|
446
|
+
* Searches for // keep:full comment and extracts the entire statement.
|
|
447
|
+
* Returns null if // keep:full not found. */
|
|
448
|
+
function getFullStatement(stmt: ts.Statement, src: string): string | null {
|
|
449
|
+
const stmtStart = stmt.getStart()
|
|
450
|
+
const stmtEnd = stmt.getEnd()
|
|
451
|
+
|
|
452
|
+
// Only check the first line for // keep:full to avoid false positives
|
|
453
|
+
// from the directive appearing in nested comments or string content.
|
|
454
|
+
const firstLineEnd = src.indexOf('\n', stmtStart)
|
|
455
|
+
const firstLine = src.slice(stmtStart, firstLineEnd === -1 ? src.length : firstLineEnd)
|
|
456
|
+
if (!isFullKeep(firstLine)) {
|
|
457
|
+
return null
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Extract the entire statement
|
|
461
|
+
const stmtText = src.slice(stmtStart, stmtEnd)
|
|
462
|
+
|
|
463
|
+
// Compute the indentation of the first line to dedent
|
|
464
|
+
const firstLineMatch = stmtText.match(/^(\s*)/)
|
|
465
|
+
const baseIndent = firstLineMatch ? firstLineMatch[1]!.length : 0
|
|
466
|
+
|
|
467
|
+
// Dedent all lines by the base indentation
|
|
468
|
+
const dedented = stmtText
|
|
469
|
+
.split('\n')
|
|
470
|
+
.map(line => {
|
|
471
|
+
if (line.length >= baseIndent && line.slice(0, baseIndent).trim() === '') {
|
|
472
|
+
return line.slice(baseIndent)
|
|
473
|
+
}
|
|
474
|
+
return line
|
|
475
|
+
})
|
|
476
|
+
.join('\n')
|
|
477
|
+
.trimEnd()
|
|
478
|
+
|
|
479
|
+
// Strip the // keep:full comment
|
|
480
|
+
const cleaned = dedented.replace(/\s*\/\/\s*keep:full\b.*$/gm, '')
|
|
481
|
+
|
|
482
|
+
return cleaned
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** Rewrite a recognized assert.X(actual, expected) statement to a readable comment form.
|
|
486
|
+
* Returns the rewritten string, or null if the statement is not a recognized assertion.
|
|
487
|
+
* Special case: assert.ok() at statement level returns empty string (drops the line). */
|
|
488
|
+
function tryRewriteAssertion(src: string, stmt: ts.Statement, bodyIndent: number): string | null {
|
|
489
|
+
if (!ts.isExpressionStatement(stmt)) return null
|
|
490
|
+
const expr = stmt.expression
|
|
491
|
+
if (!ts.isCallExpression(expr)) return null
|
|
492
|
+
if (!ts.isPropertyAccessExpression(expr.expression)) return null
|
|
493
|
+
|
|
494
|
+
const obj = expr.expression.expression
|
|
495
|
+
const method = expr.expression.name.text
|
|
496
|
+
if (!ts.isIdentifier(obj) || obj.text !== 'assert') return null
|
|
497
|
+
|
|
498
|
+
// assert.ok(value) at statement level → drop the line (empty string)
|
|
499
|
+
if (method === 'ok') {
|
|
500
|
+
return ''
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// assert.throws(() => expr, pattern?) → expr // throws [pattern]
|
|
504
|
+
if (method === 'throws') {
|
|
505
|
+
const fn = expr.arguments[0]
|
|
506
|
+
if (!fn) return null
|
|
507
|
+
if (ts.isArrowFunction(fn) && !ts.isBlock(fn.body)) {
|
|
508
|
+
const exprText = src.slice(fn.body.getStart(), fn.body.getEnd())
|
|
509
|
+
const patternArg = expr.arguments[1]
|
|
510
|
+
const patternText = patternArg ? src.slice(patternArg.getStart(), patternArg.getEnd()) : null
|
|
511
|
+
return patternText ? `${exprText} // throws ${patternText}` : `${exprText} // throws`
|
|
512
|
+
}
|
|
513
|
+
return null
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const [actual, expected] = expr.arguments
|
|
517
|
+
if (!actual || !expected) return null
|
|
518
|
+
|
|
519
|
+
if (['equal', 'strictEqual', 'deepEqual', 'deepStrictEqual'].includes(method)) {
|
|
520
|
+
return formatComparison(src, actual, expected, '=>', bodyIndent)
|
|
521
|
+
}
|
|
522
|
+
if (['notEqual', 'notStrictEqual', 'notDeepEqual', 'notDeepStrictEqual'].includes(method)) {
|
|
523
|
+
return formatComparison(src, actual, expected, '!=', bodyIndent)
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
return null
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function formatComparison(
|
|
530
|
+
src: string,
|
|
531
|
+
actual: ts.Expression,
|
|
532
|
+
expected: ts.Expression,
|
|
533
|
+
op: string,
|
|
534
|
+
bodyIndent: number
|
|
535
|
+
): string {
|
|
536
|
+
const actualText = src.slice(actual.getStart(), actual.getEnd())
|
|
537
|
+
const expectedRaw = src.slice(expected.getStart(), expected.getEnd())
|
|
538
|
+
|
|
539
|
+
// Dedent continuation lines by their minimum indentation
|
|
540
|
+
const expectedText = dedentContinuationLines(expectedRaw)
|
|
541
|
+
|
|
542
|
+
const lines = expectedText.split('\n')
|
|
543
|
+
if (lines.length === 1) {
|
|
544
|
+
return `${actualText} // ${op} ${expectedText}`
|
|
545
|
+
}
|
|
546
|
+
// Multi-line: first line appended to actual, remaining lines become // comments
|
|
547
|
+
// Continuation lines are indented so that `//` aligns with the opening `//` after dedenting
|
|
548
|
+
const first = lines[0]!
|
|
549
|
+
const indent = ' '.repeat(actualText.length + 1 + bodyIndent)
|
|
550
|
+
const rest = lines.slice(1).map(l => `${indent}// ${l}`)
|
|
551
|
+
return [`${actualText} // ${op} ${first}`, ...rest].join('\n')
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** Dedent continuation lines (lines after the first) by their minimum indentation. */
|
|
555
|
+
function dedentContinuationLines(text: string): string {
|
|
556
|
+
const lines = text.split('\n')
|
|
557
|
+
if (lines.length === 1) return text
|
|
558
|
+
const contLines = lines.slice(1).filter(l => l.trim().length > 0)
|
|
559
|
+
if (!contLines.length) return text
|
|
560
|
+
const minInd = Math.min(...contLines.map(l => l.length - l.trimStart().length))
|
|
561
|
+
if (minInd === 0) return text
|
|
562
|
+
return [
|
|
563
|
+
lines[0]!,
|
|
564
|
+
...lines.slice(1).map(l => (l.length >= minInd && l.slice(0, minInd).trim() === '') ? l.slice(minInd) : l)
|
|
565
|
+
].join('\n')
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
/** If prose ends with a ```…``` fence, split it off. Returns null if no trailing fence. */
|
|
570
|
+
function extractTrailingFence(prose: string): { prose: string; fenceCode: string } | null {
|
|
571
|
+
// Match a trailing fenced code block: ```(lang)?\n...\n```
|
|
572
|
+
const match = prose.match(/^([\s\S]*?)\n?```[^\n]*\n([\s\S]*?)```\s*$/)
|
|
573
|
+
if (!match) return null
|
|
574
|
+
return {
|
|
575
|
+
prose: match[1]!.trimEnd(),
|
|
576
|
+
fenceCode: match[2]!.trimEnd()
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function mergeOrPushCode(nodes: DocNode[], text: string, lang: string, title: string | undefined): void {
|
|
581
|
+
const last = nodes[nodes.length - 1]
|
|
582
|
+
// Merge consecutive kept imports into one code block
|
|
583
|
+
if (last?.kind === 'code' && last.title === undefined && title === undefined) {
|
|
584
|
+
last.text = last.text + '\n' + text
|
|
585
|
+
} else {
|
|
586
|
+
nodes.push(codeNode(lang, text, title))
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function mergeOrPushProse(nodes: DocNode[], text: string): void {
|
|
591
|
+
const last = nodes[nodes.length - 1]
|
|
592
|
+
if (last?.kind === 'prose' && !last.terminal) {
|
|
593
|
+
last.text = last.text + '\n' + text
|
|
594
|
+
} else {
|
|
595
|
+
nodes.push({ kind: 'prose', text })
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/** Transform nested assert.ok(expr) calls to expr // OK */
|
|
600
|
+
function transformNestedAssertOk(code: string): string {
|
|
601
|
+
// Heuristic: replaces assert.ok(expr) → expr // OK; may miss complex cases
|
|
602
|
+
return code.replace(/assert\.ok\(([^)]+)\)/g, '$1 // OK')
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
/** Helper to detect language from file extension */
|
|
608
|
+
function getLanguageFromExtension(filePath: string): string {
|
|
609
|
+
const ext = filePath.split('.').pop()?.toLowerCase() || ''
|
|
610
|
+
const langMap: Record<string, string> = {
|
|
611
|
+
ts: 'typescript',
|
|
612
|
+
tsx: 'typescript',
|
|
613
|
+
js: 'javascript',
|
|
614
|
+
jsx: 'javascript',
|
|
615
|
+
json: 'json',
|
|
616
|
+
md: 'markdown',
|
|
617
|
+
yaml: 'yaml',
|
|
618
|
+
yml: 'yaml',
|
|
619
|
+
sh: 'sh',
|
|
620
|
+
bash: 'bash',
|
|
621
|
+
py: 'python',
|
|
622
|
+
rs: 'rust',
|
|
623
|
+
go: 'go',
|
|
624
|
+
java: 'java',
|
|
625
|
+
cs: 'csharp',
|
|
626
|
+
rb: 'ruby',
|
|
627
|
+
php: 'php',
|
|
628
|
+
html: 'html',
|
|
629
|
+
css: 'css',
|
|
630
|
+
xml: 'xml',
|
|
631
|
+
txt: 'text'
|
|
632
|
+
}
|
|
633
|
+
return langMap[ext] || ext || 'text'
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/** Helper to detect if a language supports C-style comments (// ...) */
|
|
637
|
+
function supportsCStyleComments(lang: string): boolean {
|
|
638
|
+
const cStyleLangs = new Set([
|
|
639
|
+
'typescript', 'javascript', 'java', 'csharp', 'go', 'rust',
|
|
640
|
+
'cpp', 'c', 'objc', 'swift', 'kotlin', 'scala', 'groovy'
|
|
641
|
+
])
|
|
642
|
+
return cStyleLangs.has(lang)
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/** Extracts input files from shellExample options and creates separate code blocks */
|
|
646
|
+
function processShellExampleInputFiles(opts: ts.ObjectLiteralExpression, nodes: DocNode[]): void {
|
|
647
|
+
const inputFilesProp = getProp(opts, 'inputFiles')
|
|
648
|
+
|
|
649
|
+
if (!inputFilesProp || !ts.isArrayLiteralExpression(inputFilesProp.initializer)) {
|
|
650
|
+
return
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
for (const el of inputFilesProp.initializer.elements) {
|
|
654
|
+
if (!ts.isObjectLiteralExpression(el)) continue
|
|
655
|
+
const pathProp = getProp(el, 'path')
|
|
656
|
+
const contentProp = getProp(el, 'content')
|
|
657
|
+
const displayPathProp = getProp(el, 'displayPath')
|
|
658
|
+
const summaryProp = getProp(el, 'summary')
|
|
659
|
+
const displayProp = getProp(el, 'display')
|
|
660
|
+
const displayContent = readBoolOption(displayProp)
|
|
661
|
+
|
|
662
|
+
if (!pathProp || !ts.isStringLiteralLike(pathProp.initializer)) continue
|
|
663
|
+
const filePath = pathProp.initializer.text
|
|
664
|
+
|
|
665
|
+
const displayPath = readBoolOption(displayPathProp)
|
|
666
|
+
const summary = readFlag(summaryProp)
|
|
667
|
+
|
|
668
|
+
if (contentProp && displayContent && ts.isStringLiteralLike(contentProp.initializer)) {
|
|
669
|
+
const content = contentProp.initializer.text
|
|
670
|
+
const lang = getLanguageFromExtension(filePath)
|
|
671
|
+
|
|
672
|
+
// Add label/prose based on language type (only if summary and displayPath are true)
|
|
673
|
+
if (!supportsCStyleComments(lang) && displayPath && summary) {
|
|
674
|
+
// Non-C-style: add prose label before code block
|
|
675
|
+
nodes.push({ kind: 'prose', text: `With input file \`${filePath}\`:`, noBlankAfter: true })
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// Create code block with label for C-style languages (only if summary and displayPath are true)
|
|
679
|
+
let blockText = content
|
|
680
|
+
if (supportsCStyleComments(lang) && displayPath && summary) {
|
|
681
|
+
blockText = `// Input file "${filePath}":\n${content}`
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
nodes.push({ kind: 'code', lang, text: blockText })
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const OUTPUT_FILE_INLINE_LIMIT = 60
|
|
690
|
+
|
|
691
|
+
/** Emits separate prose/code nodes for output file assertions, after the sh block */
|
|
692
|
+
function processShellExampleOutputFiles(
|
|
693
|
+
src: string,
|
|
694
|
+
opts: ts.ObjectLiteralExpression,
|
|
695
|
+
nodes: DocNode[],
|
|
696
|
+
cmd: string,
|
|
697
|
+
inputFiles: Array<InputFileInfo>,
|
|
698
|
+
execution: ShellCommandExecution | null
|
|
699
|
+
): void {
|
|
700
|
+
const outputFilesProp = getProp(opts, 'outputFiles')
|
|
701
|
+
|
|
702
|
+
if (!outputFilesProp || !ts.isArrayLiteralExpression(outputFilesProp.initializer)) {
|
|
703
|
+
return
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
for (const el of outputFilesProp.initializer.elements) {
|
|
707
|
+
if (!ts.isObjectLiteralExpression(el)) continue
|
|
708
|
+
const pathProp = getProp(el, 'path')
|
|
709
|
+
const containsProp = getProp(el, 'contains')
|
|
710
|
+
const matchesProp = getProp(el, 'matches')
|
|
711
|
+
const displayProp = getProp(el, 'display')
|
|
712
|
+
const displayPathProp = getProp(el, 'displayPath')
|
|
713
|
+
const summaryProp = getProp(el, 'summary')
|
|
714
|
+
|
|
715
|
+
const display = displayProp && ts.isStringLiteralLike(displayProp.initializer)
|
|
716
|
+
? displayProp.initializer.text : undefined
|
|
717
|
+
|
|
718
|
+
if (!pathProp || !ts.isStringLiteralLike(pathProp.initializer)) continue
|
|
719
|
+
const filePath = pathProp.initializer.text
|
|
720
|
+
const lang = getLanguageFromExtension(filePath)
|
|
721
|
+
|
|
722
|
+
const displayPath = readBoolOption(displayPathProp)
|
|
723
|
+
const summary = readFlag(summaryProp)
|
|
724
|
+
|
|
725
|
+
let emitDisplayNode = display !== 'none'
|
|
726
|
+
|
|
727
|
+
if (matchesProp && ts.isRegularExpressionLiteral(matchesProp.initializer)) {
|
|
728
|
+
if (summary) {
|
|
729
|
+
const regexText = src.slice(matchesProp.initializer.getStart(), matchesProp.initializer.getEnd())
|
|
730
|
+
const proseText = displayPath
|
|
731
|
+
? `Output file \`${filePath}\` matches \`${regexText}\`.`
|
|
732
|
+
: `Matches \`${regexText}\`.`
|
|
733
|
+
nodes.push({ kind: 'prose', text: proseText, terminal: true })
|
|
734
|
+
}
|
|
735
|
+
} else if (containsProp && ts.isStringLiteralLike(containsProp.initializer)) {
|
|
736
|
+
const text = containsProp.initializer.text
|
|
737
|
+
const isMultiLine = text.includes('\n')
|
|
738
|
+
if (!isMultiLine && text.length < OUTPUT_FILE_INLINE_LIMIT) {
|
|
739
|
+
// Short single-line: backtick format
|
|
740
|
+
if (summary) {
|
|
741
|
+
const proseText = displayPath
|
|
742
|
+
? `Output file \`${filePath}\` contains \`${text}\`.`
|
|
743
|
+
: `Contains \`${text}\`.`
|
|
744
|
+
nodes.push({ kind: 'prose', text: proseText, terminal: true })
|
|
745
|
+
}
|
|
746
|
+
} else {
|
|
747
|
+
// Truncate to 60 chars or first newline for the summary
|
|
748
|
+
const firstNewline = text.indexOf('\n')
|
|
749
|
+
const truncateAt = isMultiLine ? Math.min(firstNewline, OUTPUT_FILE_INLINE_LIMIT) : OUTPUT_FILE_INLINE_LIMIT
|
|
750
|
+
const truncated = text.slice(0, truncateAt)
|
|
751
|
+
if (isMultiLine) {
|
|
752
|
+
// Multi-line: colon + excerpt code block; no display node (excerpt IS the content spec)
|
|
753
|
+
if (summary) {
|
|
754
|
+
const proseText = displayPath
|
|
755
|
+
? `Output file \`${filePath}\` contains ${truncated}...:`
|
|
756
|
+
: `Contains ${truncated}...:`
|
|
757
|
+
nodes.push({ kind: 'prose', text: proseText, terminal: true, noBlankAfter: true })
|
|
758
|
+
}
|
|
759
|
+
nodes.push({ kind: 'code', lang, text: `...\n${text}\n...` })
|
|
760
|
+
emitDisplayNode = false
|
|
761
|
+
} else {
|
|
762
|
+
// Long single-line: truncated summary, period
|
|
763
|
+
if (summary) {
|
|
764
|
+
const proseText = displayPath
|
|
765
|
+
? `Output file \`${filePath}\` contains ${truncated}....`
|
|
766
|
+
: `Contains ${truncated}....`
|
|
767
|
+
nodes.push({ kind: 'prose', text: proseText, terminal: true })
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
} else {
|
|
772
|
+
// Neither contains nor matches: display the full file contents
|
|
773
|
+
if (summary) {
|
|
774
|
+
const proseText = displayPath
|
|
775
|
+
? `Output file \`${filePath}\`:`
|
|
776
|
+
: `Output:`
|
|
777
|
+
nodes.push({ kind: 'prose', text: proseText, terminal: true, noBlankAfter: true })
|
|
778
|
+
}
|
|
779
|
+
emitDisplayNode = true
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
if (emitDisplayNode) {
|
|
783
|
+
const node: OutputFileDisplayNode = { kind: 'output-file-display', path: filePath, lang, cmd, inputFiles }
|
|
784
|
+
if (execution) {
|
|
785
|
+
node.execution = execution
|
|
786
|
+
}
|
|
787
|
+
nodes.push(node)
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/** Extracts inputFiles entries statically from a shellExample opts AST node */
|
|
793
|
+
function extractStaticInputFiles(opts: ts.ObjectLiteralExpression): Array<InputFileInfo> {
|
|
794
|
+
const inputFilesProp = getProp(opts, 'inputFiles')
|
|
795
|
+
if (!inputFilesProp || !ts.isArrayLiteralExpression(inputFilesProp.initializer)) return []
|
|
796
|
+
const result: Array<InputFileInfo> = []
|
|
797
|
+
for (const el of inputFilesProp.initializer.elements) {
|
|
798
|
+
if (!ts.isObjectLiteralExpression(el)) continue
|
|
799
|
+
const pathProp = getProp(el, 'path')
|
|
800
|
+
const contentProp = getProp(el, 'content')
|
|
801
|
+
if (!pathProp || !ts.isStringLiteralLike(pathProp.initializer)) continue
|
|
802
|
+
if (!contentProp || !ts.isStringLiteralLike(contentProp.initializer)) continue
|
|
803
|
+
result.push({ path: pathProp.initializer.text, content: contentProp.initializer.text })
|
|
804
|
+
}
|
|
805
|
+
return result
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/** Determines if command execution is needed based on shellExample options */
|
|
809
|
+
function isExecutionNeeded(opts: ts.ObjectLiteralExpression): boolean {
|
|
810
|
+
// Check if stdout.display is true
|
|
811
|
+
const stdoutProp = getProp(opts, 'stdout')
|
|
812
|
+
if (stdoutProp && ts.isObjectLiteralExpression(stdoutProp.initializer)) {
|
|
813
|
+
const displayProp = getProp(stdoutProp.initializer as ts.ObjectLiteralExpression, 'display')
|
|
814
|
+
if (displayProp && displayProp.initializer.kind === ts.SyntaxKind.TrueKeyword) {
|
|
815
|
+
return true
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// Check if any outputFiles need display
|
|
820
|
+
const outputFilesProp = getProp(opts, 'outputFiles')
|
|
821
|
+
if (!outputFilesProp || !ts.isArrayLiteralExpression(outputFilesProp.initializer)) {
|
|
822
|
+
return false
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
for (const el of outputFilesProp.initializer.elements) {
|
|
826
|
+
if (!ts.isObjectLiteralExpression(el)) continue
|
|
827
|
+
const displayProp = getProp(el, 'display')
|
|
828
|
+
const containsProp = getProp(el, 'contains')
|
|
829
|
+
const matchesProp = getProp(el, 'matches')
|
|
830
|
+
|
|
831
|
+
// display !== 'none' means we need to execute
|
|
832
|
+
const display = displayProp && ts.isStringLiteralLike(displayProp.initializer)
|
|
833
|
+
? displayProp.initializer.text : undefined
|
|
834
|
+
|
|
835
|
+
// Need execution if display is not 'none' AND (no contains/matches OR they're multi-line)
|
|
836
|
+
if (display !== 'none') {
|
|
837
|
+
const hasMatches = matchesProp && ts.isRegularExpressionLiteral(matchesProp.initializer)
|
|
838
|
+
|
|
839
|
+
if (!containsProp && !hasMatches) {
|
|
840
|
+
// No inline assertion - need to execute to get full file content
|
|
841
|
+
return true
|
|
842
|
+
}
|
|
843
|
+
if (containsProp && ts.isStringLiteralLike(containsProp.initializer)) {
|
|
844
|
+
const text = containsProp.initializer.text
|
|
845
|
+
if (text.includes('\n') || text.length >= OUTPUT_FILE_INLINE_LIMIT) {
|
|
846
|
+
// Multi-line or long content - need to execute
|
|
847
|
+
return true
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
return false
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/** Extracts output file paths from shellExample options that need execution */
|
|
857
|
+
function extractOutputFilePaths(opts: ts.ObjectLiteralExpression): string[] {
|
|
858
|
+
const outputFilesProp = getProp(opts, 'outputFiles')
|
|
859
|
+
if (!outputFilesProp || !ts.isArrayLiteralExpression(outputFilesProp.initializer)) {
|
|
860
|
+
return []
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
const paths: string[] = []
|
|
864
|
+
for (const el of outputFilesProp.initializer.elements) {
|
|
865
|
+
if (!ts.isObjectLiteralExpression(el)) continue
|
|
866
|
+
const pathProp = getProp(el, 'path')
|
|
867
|
+
if (pathProp && ts.isStringLiteralLike(pathProp.initializer)) {
|
|
868
|
+
paths.push(pathProp.initializer.text)
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
return paths
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/** Executes a shell command with optional input files and captures stdout + output files */
|
|
875
|
+
function executeShellCommand(cmd: string, inputFiles: Array<InputFileInfo>, outputFilePaths: string[], expectedExitCode = 0): ShellCommandExecution | null {
|
|
876
|
+
const tmpDir = mkdtempSync(join(tmpdir(), 'lit-md-exec-'))
|
|
877
|
+
const resolvePath = (p: string) => isAbsolute(p) ? p : join(tmpDir, p)
|
|
878
|
+
try {
|
|
879
|
+
// Write input files
|
|
880
|
+
for (const f of inputFiles) {
|
|
881
|
+
writeFileSync(resolvePath(f.path), f.content, 'utf8')
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// Execute command
|
|
885
|
+
const result = spawnSync(cmd, { shell: true, encoding: 'utf8', cwd: tmpDir })
|
|
886
|
+
|
|
887
|
+
// Capture output files when exit code matches expectation
|
|
888
|
+
const outputFiles = new Map<string, string>()
|
|
889
|
+
if ((result.status ?? 1) === expectedExitCode) {
|
|
890
|
+
for (const filePath of outputFilePaths) {
|
|
891
|
+
try {
|
|
892
|
+
const content = readFileSync(resolvePath(filePath), 'utf8')
|
|
893
|
+
outputFiles.set(filePath, content)
|
|
894
|
+
} catch {
|
|
895
|
+
// File doesn't exist or can't be read - skip it
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
return {
|
|
901
|
+
stdout: result.stdout.trimEnd(),
|
|
902
|
+
outputFiles,
|
|
903
|
+
exitCode: result.status ?? 1
|
|
904
|
+
}
|
|
905
|
+
} catch (e) {
|
|
906
|
+
return null
|
|
907
|
+
} finally {
|
|
908
|
+
rmSync(tmpDir, { recursive: true, force: true })
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
/** Reads shellExample options and appends annotation lines (# => ..., single-line # input-file: ...) */
|
|
913
|
+
function appendShellExampleAnnotations(
|
|
914
|
+
opts: ts.ObjectLiteralExpression,
|
|
915
|
+
lines: string[],
|
|
916
|
+
execution: ShellCommandExecution | null
|
|
917
|
+
): void {
|
|
918
|
+
for (const prop of opts.properties) {
|
|
919
|
+
if (!ts.isPropertyAssignment(prop) || !ts.isIdentifier(prop.name)) continue
|
|
920
|
+
const key = prop.name.text
|
|
921
|
+
|
|
922
|
+
if (key === 'exitCode' && ts.isNumericLiteral(prop.initializer)) {
|
|
923
|
+
const code = parseInt(prop.initializer.text, 10)
|
|
924
|
+
if (code !== 0) {
|
|
925
|
+
lines.push(`# exits: ${code}`)
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
if (key === 'stdout' && ts.isObjectLiteralExpression(prop.initializer)) {
|
|
930
|
+
const containsProp = getProp(prop.initializer as ts.ObjectLiteralExpression, 'contains')
|
|
931
|
+
const displayProp = getProp(prop.initializer as ts.ObjectLiteralExpression, 'display')
|
|
932
|
+
|
|
933
|
+
// If display is true, use cached execution or show the contains assertion
|
|
934
|
+
if (displayProp && displayProp.initializer.kind === ts.SyntaxKind.TrueKeyword) {
|
|
935
|
+
if (execution && execution.exitCode === 0) {
|
|
936
|
+
lines.push(execution.stdout)
|
|
937
|
+
}
|
|
938
|
+
} else if (containsProp && ts.isStringLiteralLike(containsProp.initializer)) {
|
|
939
|
+
// Show contains assertion only if display is not true
|
|
940
|
+
lines.push(containsProp.initializer.text)
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
if (key === 'inputFiles' && ts.isArrayLiteralExpression(prop.initializer)) {
|
|
945
|
+
for (const el of prop.initializer.elements) {
|
|
946
|
+
if (!ts.isObjectLiteralExpression(el)) continue
|
|
947
|
+
const pathProp = getProp(el, 'path')
|
|
948
|
+
const contentProp = getProp(el, 'content')
|
|
949
|
+
const displayPathProp = getProp(el, 'displayPath')
|
|
950
|
+
const summaryProp = getProp(el, 'summary')
|
|
951
|
+
|
|
952
|
+
if (!pathProp || !ts.isStringLiteralLike(pathProp.initializer)) continue
|
|
953
|
+
const filePath = pathProp.initializer.text
|
|
954
|
+
|
|
955
|
+
const displayPath = readBoolOption(displayPathProp)
|
|
956
|
+
const summary = readFlag(summaryProp)
|
|
957
|
+
|
|
958
|
+
if (contentProp && ts.isStringLiteralLike(contentProp.initializer)) {
|
|
959
|
+
const content = contentProp.initializer.text
|
|
960
|
+
const lang = getLanguageFromExtension(filePath)
|
|
961
|
+
// Only add single-line annotation for C-style languages; others emit a separate code block
|
|
962
|
+
// Skip if displayPath or summary is false
|
|
963
|
+
if (!content.includes('\n') && supportsCStyleComments(lang) && displayPath && summary) {
|
|
964
|
+
lines.push(`# Input file \`${filePath}\` contains \`${content}\``)
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
}
|