diff --git a/.changeset/dockerfile-heredoc-parsing.md b/.changeset/dockerfile-heredoc-parsing.md new file mode 100644 index 0000000..860cfb2 --- /dev/null +++ b/.changeset/dockerfile-heredoc-parsing.md @@ -0,0 +1,5 @@ +--- +"@docker-doctor/cli": patch +--- + +The Dockerfile parser now understands heredoc (`<[A-Za-z]+)\s+(?.*)$/u; + +// Matches a heredoc opener like <['"]?)(?\w+)\k/gu; + +interface ParserState { + instructions: DockerfileInstruction[]; + currentInstruction: string; + currentArgs: string; + startLine: number; + rawAccumulator: string[]; + // FIFO of heredoc delimiters still open for the current instruction, in + // the order they were opened (closed in the same order). + heredocQueue: string[]; +} + +const createParserState = (): ParserState => ({ + currentArgs: "", + currentInstruction: "", + heredocQueue: [], + instructions: [], + rawAccumulator: [], + startLine: 0, +}); + +const closeInstruction = (state: ParserState): void => { + if (state.currentInstruction) { + state.instructions.push({ + args: state.currentArgs, + instruction: state.currentInstruction, + line: state.startLine, + raw: state.rawAccumulator.join("\n"), + }); + } + state.currentInstruction = ""; + state.currentArgs = ""; + state.rawAccumulator = []; +}; + +const matchInstructionKeyword = ( + lineContent: string +): { instruction: string; args: string } | null => { + const match = lineContent.match(INSTRUCTION_LINE_RE); + const matchedWord = match?.groups?.inst.toUpperCase(); + if (matchedWord && DOCKERFILE_KEYWORDS.has(matchedWord)) { + return { args: match?.groups?.args ?? "", instruction: matchedWord }; + } + + const word = lineContent.trim().toUpperCase(); + if (DOCKERFILE_KEYWORDS.has(word)) { + return { args: "", instruction: word }; + } + + return null; +}; + +const findHeredocDelimiters = (lineContent: string): string[] => + [...lineContent.matchAll(HEREDOC_OPENER_RE)].map( + (m) => m.groups?.delim ?? "" + ); + +// Heredoc body lines are never treated as instructions (not even comment +// lines, which are shell-comment content here) — they are folded verbatim +// into the owning instruction's args until the delimiter line closes the +// (possibly multiple) open heredoc(s), in the order they were opened. +const processHeredocLine = (state: ParserState, trimmed: string): void => { + if (trimmed === state.heredocQueue[0]) { + state.heredocQueue.shift(); + } else { + state.currentArgs += (state.currentArgs ? " " : "") + trimmed; + } + + if (state.heredocQueue.length === 0) { + // Last open heredoc just closed: the instruction ends here. + closeInstruction(state); + } +}; + +const processInstructionLine = ( + state: ParserState, + trimmed: string, + lineNum: number +): void => { + let lineContent = trimmed; + + // Inside a multi-line run, comment lines are ignored by docker parser + if (lineContent.startsWith("#")) { + return; + } + + const hasContinuation = lineContent.endsWith("\\"); + if (hasContinuation) { + lineContent = lineContent.slice(0, -1).trim(); + } + + if (state.currentInstruction) { + state.currentArgs += (state.currentArgs ? " " : "") + lineContent; + } else { + state.startLine = lineNum; + // Match the first instruction word (e.g. FROM, RUN, COPY) + const matched = matchInstructionKeyword(lineContent); + if (matched) { + state.currentInstruction = matched.instruction; + state.currentArgs = matched.args; + } + } + + if (state.currentInstruction) { + state.heredocQueue.push(...findHeredocDelimiters(lineContent)); + } + + if (state.heredocQueue.length > 0) { + // A heredoc opener implies continuation even without a trailing + // backslash — keep accumulating until every opened delimiter closes. + return; + } + + if (!hasContinuation) { + closeInstruction(state); + } +}; + export const parseDockerfile = (content: string): DockerfileInstruction[] => { - const instructions: DockerfileInstruction[] = []; + const state = createParserState(); const lines = content.split(/\r?\n/u); - let currentInstruction = ""; - let currentArgs = ""; - let startLine = 0; - let rawAccumulator: string[] = []; - for (const [i, rawLine] of lines.entries()) { const trimmed = rawLine.trim(); const lineNum = i + 1; + const insideHeredoc = state.heredocQueue.length > 0; // Skip empty lines or comment lines if not in multi-line block - if (!currentInstruction && (trimmed === "" || trimmed.startsWith("#"))) { + if ( + !state.currentInstruction && + !insideHeredoc && + (trimmed === "" || trimmed.startsWith("#")) + ) { continue; } - rawAccumulator.push(rawLine); - - let lineContent = trimmed; - - // Inside a multi-line run, comment lines are ignored by docker parser - if (lineContent.startsWith("#")) { - continue; - } + state.rawAccumulator.push(rawLine); - const hasContinuation = lineContent.endsWith("\\"); - if (hasContinuation) { - lineContent = lineContent.slice(0, -1).trim(); - } - - if (currentInstruction) { - currentArgs += (currentArgs ? " " : "") + lineContent; + if (insideHeredoc) { + processHeredocLine(state, trimmed); } else { - startLine = lineNum; - // Match the first instruction word (e.g. FROM, RUN, COPY) - const match = lineContent.match(/^(?[A-Z]+)\s+(?.*)$/iu); - if (match?.groups) { - currentInstruction = match.groups.inst.toUpperCase(); - currentArgs = match.groups.args; - } else { - const word = lineContent.trim().toUpperCase(); - if ( - ["RUN", "CMD", "ENTRYPOINT", "EXPOSE", "USER", "WORKDIR"].includes( - word - ) - ) { - currentInstruction = word; - currentArgs = ""; - } - } - } - - if (!hasContinuation) { - if (currentInstruction) { - instructions.push({ - args: currentArgs, - instruction: currentInstruction, - line: startLine, - raw: rawAccumulator.join("\n"), - }); - } - currentInstruction = ""; - currentArgs = ""; - rawAccumulator = []; + processInstructionLine(state, trimmed, lineNum); } } - if (currentInstruction) { - instructions.push({ - args: currentArgs, - instruction: currentInstruction, - line: startLine, - raw: rawAccumulator.join("\n"), - }); - } + // EOF: an unterminated heredoc (or a trailing backslash continuation) + // still emits whatever was accumulated so far, rather than dropping it. + closeInstruction(state); - return instructions; + return state.instructions; }; diff --git a/packages/core/test/parser.test.ts b/packages/core/test/parser.test.ts index a4487af..787a187 100644 --- a/packages/core/test/parser.test.ts +++ b/packages/core/test/parser.test.ts @@ -39,6 +39,79 @@ describe("Dockerfile Parser", () => { "apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*" ); }); + + test("ignores non-instruction lines", () => { + const parsed = parseDockerfile(` + FROM node:22 + this is not an instruction + `); + expect(parsed).toHaveLength(1); + expect(parsed[0].instruction).toBe("FROM"); + }); + + test("accepts lowercase instructions", () => { + const parsed = parseDockerfile(` + from node:22 + run echo hi + `); + expect(parsed).toHaveLength(2); + expect(parsed[0].instruction).toBe("FROM"); + expect(parsed[1].instruction).toBe("RUN"); + }); + + test("parses heredoc bodies into the owning instruction", () => { + const parsed = parseDockerfile(` +FROM node:22 +RUN < { + const parsed = parseDockerfile(` +FROM node:22 +RUN < i.instruction === "USER")).toBe(false); + }); + + test("handles quoted and dash heredoc delimiters", () => { + const parsed = parseDockerfile(` +FROM node:22 +RUN <<-'EOT' +echo hello +EOT + `); + expect(parsed).toHaveLength(2); + expect(parsed[1].args).toContain("echo hello"); + }); + + test("preserves heredoc body in raw", () => { + const parsed = parseDockerfile(` +FROM node:22 +RUN < { + const parsed = parseDockerfile("FROM node:22\r\nUSER node\r\n"); + expect(parsed).toHaveLength(2); + expect(parsed[1].instruction).toBe("USER"); + }); }); describe("Compose Parser", () => { diff --git a/packages/docker-doctor/test/cli.test.ts b/packages/docker-doctor/test/cli.test.ts index f6f056b..db15a18 100644 --- a/packages/docker-doctor/test/cli.test.ts +++ b/packages/docker-doctor/test/cli.test.ts @@ -117,3 +117,11 @@ describe("empty project", () => { expect(report.diagnostics).toEqual([]); }); }); + +describe("heredoc fixture", () => { + test("heredoc fixture with --json yields at least one diagnostic", async () => { + const { stdout } = await runCli([fixture("heredoc"), "--json"]); + const report = JSON.parse(stdout); + expect(report.diagnostics.length).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/packages/docker-doctor/test/fixtures/heredoc/Dockerfile b/packages/docker-doctor/test/fixtures/heredoc/Dockerfile new file mode 100644 index 0000000..325ca17 --- /dev/null +++ b/packages/docker-doctor/test/fixtures/heredoc/Dockerfile @@ -0,0 +1,7 @@ +FROM node:22.2.0-alpine +RUN <