Skip to content

Commit a4e5812

Browse files
committed
Treat here-strings as inline arguments, never heredoc openers
1 parent 94d7cc4 commit a4e5812

4 files changed

Lines changed: 46 additions & 0 deletions

File tree

‎src/permission/permission.test.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,23 @@ describe("splitChainedCommand", () => {
176176
expect(splitChainedCommand(cmd)).toHaveLength(2);
177177
});
178178

179+
test("does not treat a here-string (<<<) as a heredoc opener", () => {
180+
expect(splitChainedCommand('cat <<< "word" && echo hi')).toEqual([
181+
'cat <<< "word"',
182+
"echo hi",
183+
]);
184+
expect(splitChainedCommand("cmd <<<EOF")).toEqual(["cmd <<<EOF"]);
185+
expect(splitChainedCommand("<<< EOF && echo done")).toEqual([
186+
"<<< EOF",
187+
"echo done",
188+
]);
189+
});
190+
191+
test("still treats <<- as a heredoc opener", () => {
192+
const cmd = "cat <<-EOF\nbody\nEOF";
193+
expect(splitChainedCommand(cmd)).toHaveLength(1);
194+
});
195+
179196
test("treats shell line continuation (backslash + newline) as glue, not a chain split", () => {
180197
// Common pattern from agents emitting readable multi-line shell calls.
181198
expect(splitChainedCommand("cd foo && \\\nbun test")).toEqual([
@@ -3962,6 +3979,11 @@ describe("stripCommentLines", () => {
39623979
expect(stripCommentLines(command)).toBe(command);
39633980
});
39643981

3982+
test("a here-string never swallows a later line into a heredoc body", () => {
3983+
const command = 'cat <<< "word"\n# a real comment';
3984+
expect(stripCommentLines(command)).toBe('cat <<< "word"\n');
3985+
});
3986+
39653987
test("leaves a real command with a trailing inline comment untouched", () => {
39663988
expect(stripCommentLines("ls -la # list files")).toBe(
39673989
"ls -la # list files",

‎src/shell/command-segments.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,15 @@ export function parseHeredocOpener(
152152
i: number,
153153
): { marker: string; lineEnd: number } | null {
154154
if (command[i] !== "<" || command[i + 1] !== "<") return null;
155+
// `<<<` is a here-string, not a heredoc: its word is an inline argument,
156+
// so there is no marker line to wait for.
157+
if (command[i + 2] === "<") return null;
158+
// A `<<` opener cannot start in the middle of a `<` run: when the scan
159+
// reaches the second `<` of a `<<<` here-string, the character ahead is no
160+
// longer `<`, so only this backward guard stops it from parsing the
161+
// here-string word as a heredoc marker and swallowing the rest of the
162+
// command as body.
163+
if (command[i - 1] === "<") return null;
155164
let j = i + 2;
156165
if (command[j] === "-") j++; // <<- strips leading tabs
157166
// Skip whitespace between << and the marker word.

‎src/tui/command-display.test.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,18 @@ test("heredoc bodies are not enumerated as segments", () => {
6161
]);
6262
});
6363

64+
test("a here-string never opens a pending heredoc", () => {
65+
expect(groupChainSegmentsForDisplay('cat <<< "word" && echo hi')).toEqual([
66+
'cat <<< "word"',
67+
"echo hi",
68+
]);
69+
expect(groupChainSegmentsForDisplay("cmd <<<EOF")).toEqual(["cmd <<<EOF"]);
70+
expect(verbatimCommandLines('cat <<< "word"\n# a real comment')).toEqual([
71+
{ text: 'cat <<< "word"', isComment: false },
72+
{ text: "# a real comment", isComment: true },
73+
]);
74+
});
75+
6476
test("top-level newlines become verbatim lines; quoted newlines stay marked inline", () => {
6577
expect(verbatimCommandLines('echo "a\nb"\necho two')).toEqual([
6678
{ text: 'echo "a↵b"', isComment: false },

‎src/tui/command-display.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ import { sliceTailToWidth, sliceToWidth, stringWidth } from "./view/height.js";
55
function parseHeredocMarker(command: string, i: number): string | null {
66
if (command[i] !== "<" || command[i + 1] !== "<" || command[i + 2] === "<")
77
return null;
8+
// Same `<`-run rule as parseHeredocOpener: the second `<` of a `<<<`
9+
// here-string must not parse the here-string word as a heredoc marker.
10+
if (command[i - 1] === "<") return null;
811
let j = i + 2;
912
if (command[j] === "-") j++;
1013
while (command[j] === " " || command[j] === "\t") j++;

0 commit comments

Comments
 (0)