Skip to content

Commit 99df5b0

Browse files
committed
feat: add executable code fence support
Add ability to execute code fences with shebangs inline. Code fences that start with a shebang (#!/usr/bin/env bun, #!/bin/bash, etc.) are written to a temp file, made executable, and run during import expansion. Changes: - Add ExecutableCodeFenceAction type to imports-types.ts - Update parser to handle variable-length fences (3+ backticks/tildes) - Add EXECUTABLE_FENCE_PATTERN regex for shebang detection - Implement processExecutableCodeFence in imports.ts - Integrate with parallel execution dashboard - Support dry-run mode for code fences - Add comprehensive parser and integration tests
1 parent 533224d commit 99df5b0

File tree

5 files changed

+332
-51
lines changed

5 files changed

+332
-51
lines changed

src/imports-parser.test.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -647,6 +647,97 @@ describe('parseSymbolExtraction', () => {
647647
});
648648
});
649649

650+
describe('executable code fence imports', () => {
651+
it('parses executable code fence with shebang', () => {
652+
const content = '```ts\n#!/usr/bin/env bun\nconsole.log("hello")\n```';
653+
const actions = parseImports(content);
654+
expect(actions).toHaveLength(1);
655+
expect(actions[0]!.type).toBe('executable_code_fence');
656+
const action = actions[0] as any;
657+
expect(action.shebang).toBe('#!/usr/bin/env bun');
658+
expect(action.language).toBe('ts');
659+
expect(action.code).toBe('console.log("hello")');
660+
});
661+
662+
it('parses executable code fence with sh shebang', () => {
663+
const content = '```sh\n#!/bin/bash\necho "hello"\n```';
664+
const actions = parseImports(content);
665+
expect(actions).toHaveLength(1);
666+
expect(actions[0]!.type).toBe('executable_code_fence');
667+
const action = actions[0] as any;
668+
expect(action.shebang).toBe('#!/bin/bash');
669+
expect(action.language).toBe('sh');
670+
expect(action.code).toBe('echo "hello"');
671+
});
672+
673+
it('parses executable code fence with python shebang', () => {
674+
const content = '```python\n#!/usr/bin/env python3\nprint("hello")\n```';
675+
const actions = parseImports(content);
676+
expect(actions).toHaveLength(1);
677+
expect(actions[0]!.type).toBe('executable_code_fence');
678+
const action = actions[0] as any;
679+
expect(action.shebang).toBe('#!/usr/bin/env python3');
680+
expect(action.language).toBe('python');
681+
});
682+
683+
it('does NOT parse code fence without shebang', () => {
684+
const content = '```ts\nconsole.log("hello")\n```';
685+
const actions = parseImports(content);
686+
// No shebang means it's just a regular code block, not executable
687+
expect(actions).toHaveLength(0);
688+
});
689+
690+
it('does NOT parse shebang that is not on first line of code', () => {
691+
const content = '```ts\n// comment\n#!/usr/bin/env bun\nconsole.log("hello")\n```';
692+
const actions = parseImports(content);
693+
// Shebang must be on the first line after the fence
694+
expect(actions).toHaveLength(0);
695+
});
696+
697+
it('parses multiline code in executable fence', () => {
698+
const content = '```ts\n#!/usr/bin/env bun\nconst x = 1;\nconsole.log(x);\nprocess.exit(0);\n```';
699+
const actions = parseImports(content);
700+
expect(actions).toHaveLength(1);
701+
const action = actions[0] as any;
702+
expect(action.code).toContain('const x = 1;');
703+
expect(action.code).toContain('console.log(x);');
704+
expect(action.code).toContain('process.exit(0);');
705+
});
706+
707+
it('handles variable-length fences (4+ backticks)', () => {
708+
const content = '````ts\n#!/usr/bin/env bun\nconsole.log("hello")\n````';
709+
const actions = parseImports(content);
710+
expect(actions).toHaveLength(1);
711+
expect(actions[0]!.type).toBe('executable_code_fence');
712+
});
713+
714+
it('preserves original match for replacement', () => {
715+
const content = 'before\n```ts\n#!/usr/bin/env bun\nconsole.log("hello")\n```\nafter';
716+
const actions = parseImports(content);
717+
expect(actions).toHaveLength(1);
718+
const action = actions[0] as any;
719+
expect(action.original).toBe('```ts\n#!/usr/bin/env bun\nconsole.log("hello")\n```');
720+
expect(action.index).toBe(7); // Position after "before\n"
721+
});
722+
723+
it('parses multiple executable fences', () => {
724+
const content = '```sh\n#!/bin/bash\necho 1\n```\n\n```ts\n#!/usr/bin/env bun\nconsole.log(2)\n```';
725+
const actions = parseImports(content);
726+
expect(actions).toHaveLength(2);
727+
expect(actions[0]!.type).toBe('executable_code_fence');
728+
expect(actions[1]!.type).toBe('executable_code_fence');
729+
});
730+
731+
it('mixes executable fences with file imports', () => {
732+
const content = '@./config.md\n\n```ts\n#!/usr/bin/env bun\nconsole.log("hello")\n```\n\n@./footer.md';
733+
const actions = parseImports(content);
734+
expect(actions).toHaveLength(3);
735+
expect(actions[0]!.type).toBe('file');
736+
expect(actions[1]!.type).toBe('executable_code_fence');
737+
expect(actions[2]!.type).toBe('file');
738+
});
739+
});
740+
650741
describe('findSafeRanges', () => {
651742
it('returns full range for plain text', () => {
652743
const content = 'plain text content';

src/imports-parser.ts

Lines changed: 93 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type {
1313
UrlImportAction,
1414
CommandImportAction,
1515
SymbolImportAction,
16+
ExecutableCodeFenceAction,
1617
} from './imports-types';
1718

1819
/**
@@ -32,27 +33,36 @@ export function findSafeRanges(content: string): Array<{ start: number; end: num
3233
let context: ScanContext = 'normal';
3334
let rangeStart = 0;
3435
let i = 0;
36+
let fenceChar = '';
37+
let fenceLen = 0;
3538

3639
while (i < content.length) {
3740
if (context === 'normal') {
38-
// Check for fenced code block start (``` or ~~~)
39-
if (
40-
(content[i] === '`' && content.slice(i, i + 3) === '```') ||
41-
(content[i] === '~' && content.slice(i, i + 3) === '~~~')
42-
) {
43-
// End current safe range before the fence
44-
if (i > rangeStart) {
45-
safeRanges.push({ start: rangeStart, end: i });
41+
// Check for fenced code block start (3+ backticks or tildes)
42+
if (content[i] === '`' || content[i] === '~') {
43+
const char = content[i];
44+
let len = 0;
45+
let j = i;
46+
while (j < content.length && content[j] === char) {
47+
len++;
48+
j++;
4649
}
47-
context = 'fenced_code';
48-
// Skip the opening fence and any language identifier on the same line
49-
const fenceChar = content[i];
50-
i += 3;
51-
// Skip to end of line (the info string after ```)
52-
while (i < content.length && content[i] !== '\n') {
53-
i++;
50+
51+
if (len >= 3) {
52+
// It's a fence
53+
if (i > rangeStart) {
54+
safeRanges.push({ start: rangeStart, end: i });
55+
}
56+
context = 'fenced_code';
57+
fenceChar = char;
58+
fenceLen = len;
59+
i += len;
60+
// Skip info string
61+
while (i < content.length && content[i] !== '\n') {
62+
i++;
63+
}
64+
continue;
5465
}
55-
continue;
5666
}
5767

5868
// Check for inline code start (single backtick, not followed by another)
@@ -68,26 +78,30 @@ export function findSafeRanges(content: string): Array<{ start: number; end: num
6878

6979
i++;
7080
} else if (context === 'fenced_code') {
71-
// Look for closing fence (``` or ~~~)
72-
// Must be at start of line (after newline or at start of content)
81+
// Look for closing fence
7382
const atLineStart = i === 0 || content[i - 1] === '\n';
74-
if (
75-
atLineStart &&
76-
((content[i] === '`' && content.slice(i, i + 3) === '```') ||
77-
(content[i] === '~' && content.slice(i, i + 3) === '~~~'))
78-
) {
79-
// Skip the closing fence
80-
i += 3;
81-
// Skip to end of line
82-
while (i < content.length && content[i] !== '\n') {
83-
i++;
83+
if (atLineStart && content[i] === fenceChar) {
84+
let len = 0;
85+
let j = i;
86+
while (j < content.length && content[j] === fenceChar) {
87+
len++;
88+
j++;
8489
}
85-
if (i < content.length) {
86-
i++; // Skip the newline
90+
91+
if (len >= fenceLen) {
92+
// Close it
93+
i += len;
94+
// Skip to end of line
95+
while (i < content.length && content[i] !== '\n') {
96+
i++;
97+
}
98+
if (i < content.length) {
99+
i++; // Skip the newline
100+
}
101+
context = 'normal';
102+
rangeStart = i;
103+
continue;
87104
}
88-
context = 'normal';
89-
rangeStart = i;
90-
continue;
91105
}
92106
i++;
93107
} else if (context === 'inline_code') {
@@ -152,6 +166,13 @@ const COMMAND_INLINE_PATTERN = /!(`+)([\s\S]+?)\1/g;
152166
*/
153167
const URL_IMPORT_PATTERN = /@(https?:\/\/[^\s]+)/g;
154168

169+
/**
170+
* Pattern to match executable code fences
171+
* Matches: ```lang\n#!shebang\ncode\n```
172+
* Supports variable length fences.
173+
*/
174+
const EXECUTABLE_FENCE_PATTERN = /(`{3,})(.*?)\n(#![^\n]+)\n([\s\S]*?)\1/g;
175+
155176
/**
156177
* Check if a path contains glob characters
157178
*/
@@ -258,6 +279,22 @@ export function parseImports(content: string): ImportAction[] {
258279
// Find safe ranges where imports should be parsed (outside code blocks)
259280
const safeRanges = findSafeRanges(content);
260281

282+
// Identify starts of unsafe blocks (gaps between safe ranges)
283+
// These are the only valid positions for executable code fences.
284+
// This prevents execution of fences nested inside documentation blocks.
285+
const unsafeStarts = new Set<number>();
286+
if (safeRanges.length > 0) {
287+
if (safeRanges[0].start > 0) unsafeStarts.add(0);
288+
for (const range of safeRanges) {
289+
if (range.end < content.length) {
290+
unsafeStarts.add(range.end);
291+
}
292+
}
293+
} else if (content.length > 0) {
294+
// If content exists but no safe ranges, the whole thing is unsafe (a code block)
295+
unsafeStarts.add(0);
296+
}
297+
261298
// Parse file imports (includes globs, line ranges, symbols)
262299
FILE_IMPORT_PATTERN.lastIndex = 0;
263300
let match;
@@ -302,6 +339,29 @@ export function parseImports(content: string): ImportAction[] {
302339
}
303340
}
304341

342+
// Parse executable code fences
343+
// Must match a top-level code block (start of an unsafe gap)
344+
EXECUTABLE_FENCE_PATTERN.lastIndex = 0;
345+
while ((match = EXECUTABLE_FENCE_PATTERN.exec(content)) !== null) {
346+
// Only process if the match aligns exactly with a known code block start
347+
if (unsafeStarts.has(match.index)) {
348+
const [fullMatch, fence, infoString, shebang, code] = match;
349+
const language = infoString.trim().split(/\s+/)[0]; // Extract first word as language
350+
351+
if (shebang && code !== undefined) {
352+
const action: ExecutableCodeFenceAction = {
353+
type: 'executable_code_fence',
354+
language: language || 'txt',
355+
shebang,
356+
code: code.trim(),
357+
original: fullMatch,
358+
index: match.index,
359+
};
360+
actions.push(action);
361+
}
362+
}
363+
}
364+
305365
// Sort by index to maintain order
306366
actions.sort((a, b) => a.index - b.index);
307367

src/imports-types.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,13 +57,24 @@ export interface SymbolImportAction {
5757
index: number;
5858
}
5959

60+
/** Executable Code Fence Action */
61+
export interface ExecutableCodeFenceAction {
62+
type: 'executable_code_fence';
63+
shebang: string; // "#!/usr/bin/env bun"
64+
language: string; // "ts", "js", "python"
65+
code: string; // Code content (without shebang)
66+
original: string; // Full match including fence markers
67+
index: number;
68+
}
69+
6070
/** Union of all import action types */
6171
export type ImportAction =
6272
| FileImportAction
6373
| GlobImportAction
6474
| UrlImportAction
6575
| CommandImportAction
66-
| SymbolImportAction;
76+
| SymbolImportAction
77+
| ExecutableCodeFenceAction;
6778

6879
/**
6980
* Resolved Import - Output of the resolver (Phase 2)

src/imports.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -644,3 +644,62 @@ describe("parallel import resolution", () => {
644644
expect(result).toBe("Parent1 Child end1 Parent2 Child end2");
645645
});
646646
});
647+
648+
// Executable code fence tests
649+
describe("executable code fences", () => {
650+
test("executes bash code fence with shebang", async () => {
651+
const content = '```sh\n#!/bin/bash\necho "hello from bash"\n```';
652+
const result = await expandImports(content, testDir);
653+
expect(result).toContain("hello from bash");
654+
expect(result).toContain("{% raw %}");
655+
});
656+
657+
test("executes bun/typescript code fence with shebang", async () => {
658+
const content = '```ts\n#!/usr/bin/env bun\nconsole.log("hello from bun")\n```';
659+
const result = await expandImports(content, testDir);
660+
expect(result).toContain("hello from bun");
661+
});
662+
663+
test("does NOT execute code fence without shebang", async () => {
664+
const content = '```ts\nconsole.log("should not run")\n```';
665+
const result = await expandImports(content, testDir);
666+
// The code fence should remain as-is since it has no shebang
667+
expect(result).toBe('```ts\nconsole.log("should not run")\n```');
668+
});
669+
670+
test("handles code fence failure gracefully", async () => {
671+
const content = '```sh\n#!/bin/bash\nexit 1\n```';
672+
await expect(expandImports(content, testDir)).rejects.toThrow("Code fence failed");
673+
});
674+
675+
test("code fence output is wrapped in raw block", async () => {
676+
const content = '```sh\n#!/bin/bash\necho "{{ template syntax }}"\n```';
677+
const result = await expandImports(content, testDir);
678+
// Output should be protected from LiquidJS interpretation
679+
expect(result).toContain("{% raw %}");
680+
expect(result).toContain("{% endraw %}");
681+
expect(result).toContain("{{ template syntax }}");
682+
});
683+
684+
test("respects dry-run mode for code fences", async () => {
685+
const content = '```sh\n#!/bin/bash\necho "should not run"\n```';
686+
const result = await expandImports(content, testDir, new Set(), false, {
687+
dryRun: true,
688+
});
689+
expect(result).toContain("Dry Run");
690+
expect(result).toContain("Code fence not executed");
691+
});
692+
693+
test("mixes code fence with file imports", async () => {
694+
await Bun.write(join(testDir, "fence-file.md"), "File content");
695+
696+
const content = '@./fence-file.md\n\n```sh\n#!/bin/bash\necho "Command output"\n```';
697+
const result = await expandImports(content, testDir);
698+
expect(result).toContain("File content");
699+
expect(result).toContain("Command output");
700+
});
701+
702+
test("hasImports detects executable code fences", () => {
703+
expect(hasImports('```sh\n#!/bin/bash\necho hi\n```')).toBe(true);
704+
});
705+
});

0 commit comments

Comments
 (0)