Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
2 changes: 1 addition & 1 deletion src/lexer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "";
Expand Down
5 changes: 3 additions & 2 deletions src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
23 changes: 17 additions & 6 deletions src/parts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 7 additions & 4 deletions src/word.ts
Original file line number Diff line number Diff line change
@@ -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 = "";
Expand All @@ -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;
}

Expand Down Expand Up @@ -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;
}
Expand Down
3 changes: 1 addition & 2 deletions test/heredoc-expansion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
52 changes: 52 additions & 0 deletions test/quoting.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)");
});