From 713b7f5504291a542ba924794498462046805667 Mon Sep 17 00:00:00 2001 From: Nyaarium Date: Sat, 5 Sep 2026 18:49:43 -0700 Subject: [PATCH] Place every heredoc body and say whether it terminated Co-Authored-By: Claude Fable 5.1 --- README.md | 5 +++ src/lexer.ts | 30 ++++++++++----- src/parser.ts | 3 ++ src/types.ts | 5 +++ test/heredoc-positions.test.ts | 70 ++++++++++++++++++++++++++++++++++ 5 files changed, 104 insertions(+), 9 deletions(-) create mode 100644 test/heredoc-positions.test.ts diff --git a/README.md b/README.md index afe8185..d05e606 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,11 @@ root: check `errors` on every nested `script` while traversing. A consumer that only reads the root `errors` array cannot tell that a substitution body failed to parse. +A here-document's `content` is its raw body. `contentPos` and `contentEnd` +bound that body in the source, and `heredocTerminated` is false when end of +input ended the body before its delimiter line, which Bash accepts with a +warning. + ### Print Basic opinionated printer, does not preserve whitespace or comments (except diff --git a/src/lexer.ts b/src/lexer.ts index 6f8d404..98b4364 100644 --- a/src/lexer.ts +++ b/src/lexer.ts @@ -419,11 +419,20 @@ function scanAssignmentPrefix(src: string, start: number, end: number, initialSt return state; } +interface HereDocTarget { + content?: string; + heredocQuoted?: boolean; + body?: Word; + contentPos?: number; + contentEnd?: number; + heredocTerminated?: boolean; +} + interface PendingHereDoc { delimiter: string; strip: boolean; quoted: boolean; - target?: { content?: string; heredocQuoted?: boolean; body?: Word }; + target?: HereDocTarget; } const NO_EXPANSIONS: [DeferredCommandExpansion, number][] = []; @@ -544,6 +553,8 @@ export class Lexer { private hasPeek: boolean; private pendingHereDocs: PendingHereDoc[] | null; private collectedExpansions: [DeferredCommandExpansion, number][] | null; + // Whether the last heredoc body scan found its delimiter line, or ran to end of input. + private _hereDocTerminated = false; _errors: ParseError[] | null = null; _buildParts = false; // Build processed text while scanning. Off on the normal token path (values @@ -1166,7 +1177,7 @@ export class Lexer { return parts.length > 1 || (parts.length === 1 && parts[0].type !== "Literal") ? parts : null; } - registerHereDocTarget(target: { content?: string; heredocQuoted?: boolean; body?: Word }): void { + registerHereDocTarget(target: HereDocTarget): void { if (this.pendingHereDocs === null) return; for (const hd of this.pendingHereDocs) { if (!hd.target) { @@ -1693,9 +1704,13 @@ export class Lexer { if (pending === null || pending.length === 0) return; for (const hd of pending) { const bodyPos = this.pos; - const body = this.readHereDocBody(hd.delimiter, hd.strip); + const bodyEnd = this.skipHereDocBody(hd.delimiter, hd.strip); + const body = this.src.slice(bodyPos, bodyEnd); if (hd.target) { hd.target.content = body; + hd.target.contentPos = bodyPos; + hd.target.contentEnd = bodyEnd; + hd.target.heredocTerminated = this._hereDocTerminated; if (hd.quoted) { hd.target.heredocQuoted = true; } else if (body) { @@ -1707,12 +1722,6 @@ export class Lexer { pending.length = 0; } - private readHereDocBody(delimiter: string, strip: boolean): string { - const bodyStart = this.pos; - const bodyEnd = this.skipHereDocBody(delimiter, strip); - return this.src.slice(bodyStart, bodyEnd); - } - // Advance past the heredoc body and its delimiter line; return the body end // (start of the delimiter line, or srcEnd when delimited by end-of-input). // With parenEnds (inside $(...)), a line starting with the delimiter directly @@ -1762,6 +1771,7 @@ export class Lexer { if (lineEnd - lineStart === dLen && src.startsWith(delimiter, lineStart)) { const bodyEnd = this.pos; this.pos = lineEnd < len ? lineEnd + 1 : lineEnd; + this._hereDocTerminated = true; return bodyEnd; } @@ -1774,6 +1784,7 @@ export class Lexer { if (paren !== -1 && paren < this.logicalLineEnd(lineStart, len, !quoted)) { const bodyEnd = this.pos; this.pos = afterDelim; + this._hereDocTerminated = true; return bodyEnd; } } @@ -1781,6 +1792,7 @@ export class Lexer { this.pos = lineEnd < len ? lineEnd + 1 : lineEnd; } + this._hereDocTerminated = false; return this.pos; } diff --git a/src/parser.ts b/src/parser.ts index 1a4d76b..3bc6a22 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -1437,6 +1437,9 @@ class Parser { content: t.content, heredocQuoted: undefined, body: undefined, + contentPos: undefined, + contentEnd: undefined, + heredocTerminated: undefined, }; if (t.targetEnd > t.targetPos) { const heredoc = t.value === "<<" || t.value === "<<-"; diff --git a/src/types.ts b/src/types.ts index 4ad1be1..e0bdfba 100644 --- a/src/types.ts +++ b/src/types.ts @@ -202,6 +202,11 @@ export interface Redirect { content: string | undefined; heredocQuoted: boolean | undefined; body: Word | undefined; + /** The span `content` was sliced from; present whenever `content` is. */ + contentPos: number | undefined; + contentEnd: number | undefined; + /** False when end of input ended the body before its delimiter line. */ + heredocTerminated: boolean | undefined; } export interface Command { diff --git a/test/heredoc-positions.test.ts b/test/heredoc-positions.test.ts new file mode 100644 index 0000000..7bf0559 --- /dev/null +++ b/test/heredoc-positions.test.ts @@ -0,0 +1,70 @@ +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"; + +const redirects = (src: string): Redirect[] => (parse(src).commands[0].command as Command).redirects; +const body = (src: string, r: Redirect) => src.slice(r.contentPos!, r.contentEnd!); + +test("a heredoc body's span slices its content", () => { + for (const src of [ + "cat < { + const [r] = redirects("cat < { + const src = "cat < { + for (const src of ["cat < { + for (const src of ["cat < { + const src = "echo $(cat < p.type === "CommandExpansion")!; + assert.equal(part.type, "CommandExpansion"); + const inner = part.script!.commands[0].command as Command; + const [r] = inner.redirects; + assert.equal(body(src, r), "inner\n"); + assert.equal(r.heredocTerminated, true); +}); + +test("a redirect that is not a heredoc has no span", () => { + const [r] = redirects("cat