diff --git a/src/lexer.ts b/src/lexer.ts index c8a6dfc..f2739c3 100644 --- a/src/lexer.ts +++ b/src/lexer.ts @@ -937,7 +937,7 @@ export class Lexer { if (c === CH_BACKSLASH) i++; // skip escaped char } if (!hasExpansion) return null; - return new WordImpl(body, bodyPos, bodyPos + body.length); + return new WordImpl(body, bodyPos, bodyPos + body.length, this.src, WordImpl._resolveHeredocBody); } private _wordText = ""; diff --git a/src/parser.ts b/src/parser.ts index 8df4d0d..65f2b33 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -40,10 +40,11 @@ import type { } from "./types.ts"; import { LexContext, Token, Lexer, TokenValue } from "./lexer.ts"; import { parseArithmeticExpression } from "./arithmetic.ts"; -import { computeWordParts } from "./parts.ts"; +import { computeWordParts, computeHereDocBodyParts } from "./parts.ts"; import { WordImpl } from "./word.ts"; -WordImpl._resolve = computeWordParts; +WordImpl._resolveWord = computeWordParts; +WordImpl._resolveHeredocBody = computeHereDocBodyParts; class ArithmeticCommandImpl implements ArithmeticCommand { type = "ArithmeticCommand" as const; diff --git a/src/parts.ts b/src/parts.ts index 0e4e704..3edf4b6 100644 --- a/src/parts.ts +++ b/src/parts.ts @@ -10,14 +10,25 @@ import { parse } from "./parser.ts"; */ export function computeWordParts(source: string, word: Word): WordPart[] | undefined { const lexer = new Lexer(source); - let parts: import("./types.ts").WordPart[] | null; + const parts = lexer.buildWordParts(word.pos); + if (!parts) return undefined; - // Heredoc bodies contain newlines — use dedicated scanning - if (word.text.includes("\n") && word.pos > 0) { - parts = lexer.buildHereDocParts(word.pos, word.end); - } else { - parts = lexer.buildWordParts(word.pos); + // Resolve command expansions: parse inner scripts + for (const exp of lexer.getCollectedExpansions()) { + resolveExpansion(exp); } + + return parts; +} + +/** + * Compute parts for an unquoted heredoc body. + * Heredoc bodies use different scanning rules than shell words: newlines are + * literal and single/double quotes have no special meaning. + */ +export function computeHereDocBodyParts(source: string, word: Word): WordPart[] | undefined { + const lexer = new Lexer(source); + const parts = lexer.buildHereDocParts(word.pos, word.end); if (!parts) return undefined; // Resolve command expansions: parse inner scripts diff --git a/src/word.ts b/src/word.ts index 243dc2d..3c371da 100644 --- a/src/word.ts +++ b/src/word.ts @@ -1,6 +1,6 @@ import type { DoubleQuotedChild, Word, WordPart } from "./types.ts"; -type PartsResolver = (source: string, word: Word) => WordPart[] | undefined; +export type PartsResolver = (source: string, word: Word) => WordPart[] | undefined; function dequoteValue(parts: DoubleQuotedChild[]): string { let s = ""; @@ -9,20 +9,23 @@ function dequoteValue(parts: DoubleQuotedChild[]): string { } export class WordImpl implements Word { - static _resolve: PartsResolver; + static _resolveWord: PartsResolver; + static _resolveHeredocBody: PartsResolver; text: string; pos: number; end: number; #source: string; + #resolver: PartsResolver; #parts: WordPart[] | undefined | null; #value: string | null = null; - constructor(text: string, pos: number, end: number, source?: string) { + constructor(text: string, pos: number, end: number, source?: string, resolver?: PartsResolver) { this.text = text; this.pos = pos; this.end = end; this.#source = source ?? ""; + this.#resolver = resolver ?? WordImpl._resolveWord; this.#parts = source !== undefined ? null : undefined; } @@ -57,7 +60,7 @@ export class WordImpl implements Word { get parts(): WordPart[] | undefined { if (this.#parts === null) { - this.#parts = WordImpl._resolve(this.#source, this) ?? undefined; + this.#parts = this.#resolver(this.#source, this) ?? undefined; } return this.#parts; } diff --git a/test/heredoc-expansion.test.ts b/test/heredoc-expansion.test.ts index 53f75b5..cb2a65c 100644 --- a/test/heredoc-expansion.test.ts +++ b/test/heredoc-expansion.test.ts @@ -2,9 +2,8 @@ import assert from "node:assert/strict"; import test from "node:test"; import { parse } from "../src/parser.ts"; import type { Command, Redirect } from "../src/types.ts"; -import { computeWordParts } from "../src/parts.ts"; -const wp = (s: string, w: import("../src/types.ts").Word) => computeWordParts(s, w); +const wp = (_s: string, w: import("../src/types.ts").Word) => w.parts; const getRedirect = (src: string, i = 0, ri = 0): Redirect => { const ast = parse(src); diff --git a/test/quoting.test.ts b/test/quoting.test.ts index 15afea5..0aade61 100644 --- a/test/quoting.test.ts +++ b/test/quoting.test.ts @@ -188,3 +188,55 @@ test("line continuation in whitespace between tokens", () => { const ast = parse("echo; \\\nls"); assert.equal(ast.commands.length, 2); }); + +// ── Single-quoted strings are fully literal ─────────────────────────── +// Everything inside single quotes is literal, including backticks, $(), +// ${}, $var, etc. No expansions of any kind occur. + +test("backticks inside single quotes are literal (not command substitution)", () => { + const ast = parse("echo '`cmd`'"); + const word = getCmd(ast).suffix[0]; + assert.equal(word.parts?.length, 1, "should have exactly one part"); + assert.equal(word.parts?.[0]?.type, "SingleQuoted", "should be SingleQuoted part"); + assert.equal(word.parts?.[0]?.value, "`cmd`", "backticks should be literal in value"); +}); + +test("$() inside single quotes is literal (not command substitution)", () => { + const ast = parse("echo '$(cmd)'"); + const word = getCmd(ast).suffix[0]; + assert.equal(word.parts?.length, 1, "should have exactly one part"); + assert.equal(word.parts?.[0]?.type, "SingleQuoted", "should be SingleQuoted part"); + assert.equal(word.parts?.[0]?.value, "$(cmd)", "$() should be literal in value"); +}); + +test("${} inside single quotes is literal (not parameter expansion)", () => { + const ast = parse("echo '${var}'"); + const word = getCmd(ast).suffix[0]; + assert.equal(word.parts?.length, 1, "should have exactly one part"); + assert.equal(word.parts?.[0]?.type, "SingleQuoted", "should be SingleQuoted part"); + assert.equal(word.parts?.[0]?.value, "${var}", "${} should be literal in value"); +}); + +test("multiline single-quoted string with backticks is one SingleQuoted part", () => { + const ast = parse(`echo ' +const x = \`hello\`; +console.log(x); +'`); + const word = getCmd(ast).suffix[0]; + assert.equal(word.parts?.length, 1, "should have exactly one part"); + assert.equal(word.parts?.[0]?.type, "SingleQuoted", "should be SingleQuoted part"); + // The value should contain the backticks literally + assert.ok(word.parts?.[0]?.value?.includes("`hello`"), "value should contain literal backticks"); +}); + +test("multiline single-quoted string with $() is one SingleQuoted part", () => { + const ast = parse(`echo ' +const src = "for (( i = $(start); i < $(limit); i++ )); do echo $i; done"; +'`); + const word = getCmd(ast).suffix[0]; + assert.equal(word.parts?.length, 1, "should have exactly one part"); + assert.equal(word.parts?.[0]?.type, "SingleQuoted", "should be SingleQuoted part"); + // The value should contain $() literally + assert.ok(word.parts?.[0]?.value?.includes("$(start)"), "value should contain literal $(start)"); + assert.ok(word.parts?.[0]?.value?.includes("$(limit)"), "value should contain literal $(limit)"); +});