@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/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
+ }