Skip to content

Commit 1ec5eec

Browse files
authored
fix(interpreter): preserve leading whitespace in multi-line quoted strings (#259) (#276)
normalizeScript() trimStart()s leading indentation from script lines so indented template literals parse, but it ran line-by-line and also stripped leading whitespace inside multi-line single/double-quoted strings (e.g. python3 -c with an indented body -> IndentationError). Make it quote-aware, mirroring the earlier heredoc-aware fix: track open quote state across lines and only strip indentation from lines that begin outside any quote; lines that begin inside an unterminated quote are preserved verbatim. Un-skips four sed spec tests whose indented stdin was being corrupted.
1 parent aec5643 commit 1ec5eec

4 files changed

Lines changed: 139 additions & 20 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
"just-bash": patch
3+
---
4+
5+
interpreter: preserve leading whitespace in multi-line quoted strings (fixes #259)
6+
7+
`exec()` runs each script through `normalizeScript()`, which `trimStart()`s
8+
leading indentation from lines so indented template-literal scripts parse. It
9+
was applied line-by-line and stripped the leading whitespace inside multi-line
10+
single- and double-quoted strings too. The visible symptom was `python3 -c
11+
'...'` (and `node -e`, `awk`, etc.) with an indented body failing with
12+
`IndentationError`, while the same code via heredoc or pipe worked.
13+
14+
`normalizeScript()` is now quote-aware (mirroring the earlier heredoc-aware
15+
fix): it only strips indentation from lines that begin outside any quote, and
16+
preserves lines that begin inside an unterminated single- or double-quoted
17+
string verbatim. This also un-skips four sed spec tests whose indented stdin
18+
was previously being corrupted.

packages/just-bash/src/Bash.ts

Lines changed: 65 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -861,9 +861,51 @@ export class Bash {
861861
}
862862
}
863863

864+
type QuoteScanState = "none" | "single" | "double";
865+
866+
/**
867+
* Track open single/double-quote state across one physical line, starting from
868+
* `start` (the state carried over from the previous line). Used by
869+
* normalizeScript to know whether a line begins inside a multi-line quoted
870+
* string, where leading whitespace is literal and must not be trimmed.
871+
*
872+
* Only single and double quotes matter: those are the contexts where leading
873+
* whitespace is significant. Backslash escapes and `#` comments are honored so
874+
* a quote inside a comment (e.g. `# don't`) doesn't desync the state.
875+
*/
876+
function scanLineQuoteState(
877+
line: string,
878+
start: QuoteScanState,
879+
): QuoteScanState {
880+
let state = start;
881+
for (let i = 0; i < line.length; i++) {
882+
const ch = line[i];
883+
if (state === "single") {
884+
// Inside single quotes only a closing quote is special (no escapes).
885+
if (ch === "'") state = "none";
886+
} else if (state === "double") {
887+
if (ch === "\\") {
888+
i++; // backslash escapes the next char inside double quotes
889+
} else if (ch === '"') {
890+
state = "none";
891+
}
892+
} else if (ch === "'") {
893+
state = "single";
894+
} else if (ch === '"') {
895+
state = "double";
896+
} else if (ch === "\\") {
897+
i++; // backslash escapes the next char (e.g. \" or \')
898+
} else if (ch === "#" && (i === 0 || /\s/.test(line[i - 1]))) {
899+
break; // start of a comment: the rest of the line is not shell-significant
900+
}
901+
}
902+
return state;
903+
}
904+
864905
/**
865-
* Normalize a script by stripping leading whitespace from lines,
866-
* while preserving whitespace inside heredoc content.
906+
* Normalize a script by stripping leading whitespace from lines, while
907+
* preserving whitespace inside heredoc content and inside multi-line quoted
908+
* strings.
867909
*
868910
* This allows writing indented bash scripts in template literals:
869911
* ```
@@ -874,6 +916,11 @@ export class Bash {
874916
* `);
875917
* ```
876918
*
919+
* Leading whitespace is only stripped from lines that begin outside any quote.
920+
* A line that begins inside an unterminated single- or double-quoted string is
921+
* literal quoted content (e.g. the body of `python3 -c "..."`), so it is kept
922+
* verbatim.
923+
*
877924
* Heredocs are detected by looking for << or <<- operators and their delimiters.
878925
*/
879926
function normalizeScript(script: string): string {
@@ -883,6 +930,10 @@ function normalizeScript(script: string): string {
883930
// Stack of pending heredoc delimiters (for nested heredocs)
884931
const pendingDelimiters: { delimiter: string; stripTabs: boolean }[] = [];
885932

933+
// Open single/double-quote state carried across lines. When a line begins
934+
// inside an open quote, its leading whitespace is literal and preserved.
935+
let quoteState: QuoteScanState = "none";
936+
886937
for (let i = 0; i < lines.length; i++) {
887938
const line = lines[i];
888939

@@ -903,9 +954,19 @@ function normalizeScript(script: string): string {
903954
continue;
904955
}
905956

906-
// Not inside a heredoc - normalize the line and check for heredoc starts
907-
const normalizedLine = line.trimStart();
957+
// Not inside a heredoc. Lines that begin inside an open quote are literal
958+
// quoted content (leading whitespace is significant), so preserve them
959+
// verbatim; otherwise strip leading indentation.
960+
const startState = quoteState;
961+
const normalizedLine = startState === "none" ? line.trimStart() : line;
908962
result.push(normalizedLine);
963+
quoteState = scanLineQuoteState(line, startState);
964+
965+
// Only detect heredoc operators on lines that begin outside any quote;
966+
// a `<<WORD` inside quoted content is not a heredoc.
967+
if (startState !== "none") {
968+
continue;
969+
}
909970

910971
// Check for heredoc operators in this line
911972
// Match: <<DELIM, <<-DELIM, << 'DELIM', <<- "DELIM", etc.

packages/just-bash/src/spec-tests/sed/skips.ts

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -126,14 +126,6 @@ const SKIP_TESTS: Map<string, string> = new Map<string, string>([
126126
// ============================================================
127127
// PythonSed chang.suite - complex N/D/P scripts
128128
// ============================================================
129-
[
130-
"pythonsed-chang.suite:Delete two consecutive lines if the first one contains PAT1 and the second one contains PAT2.",
131-
"N/P/D commands",
132-
],
133-
[
134-
"pythonsed-chang.suite:Get the line following a line containing PAT - Case 1 - 1.",
135-
"N/D commands",
136-
],
137129
[
138130
"pythonsed-chang.suite:Remove comments (/* ... */, maybe multi-line) of a C program. - 1",
139131
"N command behavior",
@@ -171,10 +163,6 @@ const SKIP_TESTS: Map<string, string> = new Map<string, string>([
171163
"pythonsed-chang.suite:Join every N lines to one - 1.",
172164
"complex N/D branching",
173165
],
174-
[
175-
'pythonsed-chang.suite:Extract "Received:" header(s) from a mailbox.',
176-
"complex N/D branching",
177-
],
178166
[
179167
"pythonsed-chang.suite:Extract every IMG elements from an HTML file.",
180168
"complex branching",
@@ -183,10 +171,6 @@ const SKIP_TESTS: Map<string, string> = new Map<string, string>([
183171
"pythonsed-chang.suite:Find failed instances without latter successful ones.",
184172
"complex N/D branching",
185173
],
186-
[
187-
"pythonsed-chang.suite:Change the first quote of every single-quoted string to backquote(`). - 1",
188-
"complex pattern manipulation",
189-
],
190174
["pythonsed-chang.suite:1 cat chicken", "test name parsing issue"],
191175
[
192176
"pythonsed-chang.suite:First number 1111 Second <2222>",
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { describe, expect, it } from "vitest";
2+
import { Bash } from "../Bash.js";
3+
4+
// Regression tests: script normalization (which strips leading indentation so
5+
// indented template literals parse) must NOT trim lines that begin inside a
6+
// multi-line single- or double-quoted string. There the leading whitespace is
7+
// literal (POSIX) and must be preserved verbatim, e.g. the body of
8+
// `python3 -c '...'`.
9+
describe("multi-line quoted string whitespace", () => {
10+
it("preserves leading indentation inside a single-quoted string", async () => {
11+
const env = new Bash();
12+
const result = await env.exec(
13+
"printf '%s' 'import sys\nfor p in [1]:\n print(p)\n'",
14+
);
15+
expect(result.stdout).toBe("import sys\nfor p in [1]:\n print(p)\n");
16+
expect(result.exitCode).toBe(0);
17+
});
18+
19+
it("preserves leading indentation inside a double-quoted string", async () => {
20+
const env = new Bash();
21+
const result = await env.exec(
22+
'printf "%s" "first\n second\n third\n"',
23+
);
24+
expect(result.stdout).toBe("first\n second\n third\n");
25+
expect(result.exitCode).toBe(0);
26+
});
27+
28+
it("preserves indentation through a variable assignment and expansion", async () => {
29+
const env = new Bash();
30+
const result = await env.exec(
31+
"v='a\n b\n c'\nprintf '%s' \"$v\"",
32+
);
33+
expect(result.stdout).toBe("a\n b\n c");
34+
expect(result.exitCode).toBe(0);
35+
});
36+
37+
it("still strips indentation from the surrounding (unquoted) script", async () => {
38+
const env = new Bash();
39+
const result = await env.exec(
40+
" if true; then\n printf '%s' ' keep'\n fi",
41+
);
42+
expect(result.stdout).toBe(" keep");
43+
expect(result.exitCode).toBe(0);
44+
});
45+
46+
it("is not confused by an apostrophe inside a comment", async () => {
47+
const env = new Bash();
48+
// The `'` in the comment must not open a quote that swallows the next
49+
// line's indentation handling.
50+
const result = await env.exec(
51+
"echo start # don't trip on this\n printf '%s' 'x\n y'",
52+
);
53+
expect(result.stdout).toBe("start\nx\n y");
54+
expect(result.exitCode).toBe(0);
55+
});
56+
});

0 commit comments

Comments
 (0)