Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 66 additions & 4 deletions src/Bash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -351,12 +351,11 @@ export class Bash {
};

// Normalize indented multi-line scripts (unless rawScript is true)
// This allows writing indented bash scripts in template literals
// BUT we must preserve whitespace inside heredoc content
let normalized = commandLine;
if (!options?.rawScript) {
const normalizedLines = commandLine
.split("\n")
.map((line) => line.trimStart());
normalized = normalizedLines.join("\n");
normalized = normalizeScript(commandLine);
}

try {
Expand Down Expand Up @@ -449,3 +448,66 @@ export class Bash {
return { ...this.state.env };
}
}

/**
* Normalize a script by stripping leading whitespace from lines,
* while preserving whitespace inside heredoc content.
*
* This allows writing indented bash scripts in template literals:
* ```
* await bash.exec(`
* if [ -f foo ]; then
* echo "yes"
* fi
* `);
* ```
*
* Heredocs are detected by looking for << or <<- operators and their delimiters.
*/
function normalizeScript(script: string): string {
const lines = script.split("\n");
const result: string[] = [];

// Stack of pending heredoc delimiters (for nested heredocs)
const pendingDelimiters: { delimiter: string; stripTabs: boolean }[] = [];

for (let i = 0; i < lines.length; i++) {
const line = lines[i];

// If we're inside a heredoc, check if this line ends it
if (pendingDelimiters.length > 0) {
const current = pendingDelimiters[pendingDelimiters.length - 1];
// For <<-, strip leading tabs when checking delimiter
// For <<, require exact match (no leading whitespace allowed)
const lineToCheck = current.stripTabs
? line.replace(/^\t+/, "")
: line;
if (lineToCheck === current.delimiter) {
// End of heredoc - this line can be normalized
result.push(line.trimStart());
pendingDelimiters.pop();
continue;
}
// Inside heredoc - preserve the line exactly as-is
result.push(line);
continue;
}

// Not inside a heredoc - normalize the line and check for heredoc starts
const normalizedLine = line.trimStart();
result.push(normalizedLine);

// Check for heredoc operators in this line
// Match: <<DELIM, <<-DELIM, << 'DELIM', <<- "DELIM", etc.
// Multiple heredocs on one line are possible: cmd <<EOF1 <<EOF2
const heredocPattern = /<<(-?)\s*(['"]?)([\w-]+)\2/g;
let match;
while ((match = heredocPattern.exec(normalizedLine)) !== null) {
const stripTabs = match[1] === "-";
const delimiter = match[3];
pendingDelimiters.push({ delimiter, stripTabs });
}
}

return result.join("\n");
}
11 changes: 6 additions & 5 deletions src/parser/lexer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,11 +244,8 @@ export class Lexer {
const pendingHeredocs = this.pendingHeredocs;

while (this.pos < len) {
this.skipWhitespace();

if (this.pos >= len) break;

// Check for pending here-documents after newline
// Check for pending here-documents after newline BEFORE skipping whitespace
// to preserve leading whitespace in heredoc content
if (
pendingHeredocs.length > 0 &&
tokens.length > 0 &&
Expand All @@ -258,6 +255,10 @@ export class Lexer {
continue;
}

this.skipWhitespace();

if (this.pos >= len) break;

const token = this.nextToken();
if (token) {
tokens.push(token);
Expand Down
100 changes: 100 additions & 0 deletions src/syntax/here-document.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,4 +125,104 @@ echo done`);
expect(result.stdout).toBe("hello\ndone\n");
expect(result.exitCode).toBe(0);
});

describe("whitespace preservation", () => {
it("should preserve leading spaces in here document content", async () => {
const env = new Bash();
const result = await env.exec(`cat <<EOF
four spaces at start
two spaces at start
no spaces
EOF`);
expect(result.stdout).toBe(" four spaces at start\n two spaces at start\nno spaces\n");
expect(result.exitCode).toBe(0);
});

it("should preserve leading tabs in here document content (not <<-)", async () => {
const env = new Bash();
const result = await env.exec(`cat <<EOF
\tleading tab
no tab
EOF`);
expect(result.stdout).toBe("\tleading tab\nno tab\n");
expect(result.exitCode).toBe(0);
});

it("should preserve mixed whitespace in here document", async () => {
const env = new Bash();
const result = await env.exec(`cat <<EOF
spaces
\ttab
\tmixed
EOF`);
expect(result.stdout).toBe(" spaces\n\ttab\n \tmixed\n");
expect(result.exitCode).toBe(0);
});

it("should preserve whitespace even when script is indented", async () => {
const env = new Bash();
// This tests that the script normalization doesn't strip heredoc content
const result = await env.exec(`
cat <<EOF
indented content
more indented
EOF
`);
expect(result.stdout).toBe(" indented content\n more indented\n");
expect(result.exitCode).toBe(0);
});

it("should preserve ASCII art triangle with leading spaces", async () => {
const env = new Bash();
const result = await env.exec(`cat <<'EOF'
*
* *
* *
* *
* *
* *
* *
* *
* *
* *
* *
*********************
EOF`);
expect(result.stdout).toBe(` *
* *
* *
* *
* *
* *
* *
* *
* *
* *
* *
*********************
`);
expect(result.exitCode).toBe(0);
});

it("should not treat indented delimiter as end of heredoc", async () => {
const env = new Bash();
// A line with " EOF" (spaces before EOF) should be content, not delimiter
const result = await env.exec(`cat <<EOF
line 1
EOF
line 2
EOF`);
expect(result.stdout).toBe("line 1\n EOF\nline 2\n");
expect(result.exitCode).toBe(0);
});

it("should handle delimiter with hyphen", async () => {
const env = new Bash();
const result = await env.exec(`cat <<END-TEST
content with spaces
END-TEST`);
expect(result.stdout).toBe(" content with spaces\n");
expect(result.exitCode).toBe(0);
});
});
});
Loading