Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/dockerfile-heredoc-parsing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@docker-doctor/cli": patch
---

The Dockerfile parser now understands heredoc (`<<EOF`) syntax — bodies are folded into the owning instruction instead of being mis-parsed as separate instructions — so content-based rules now inspect heredoc commands and phantom instructions no longer appear.
221 changes: 159 additions & 62 deletions packages/core/src/parsers/dockerfile-parser.ts
Original file line number Diff line number Diff line change
@@ -1,82 +1,179 @@
import type { DockerfileInstruction } from "../types/index";

const DOCKERFILE_KEYWORDS = new Set([
"ADD",
"ARG",
"CMD",
"COPY",
"ENTRYPOINT",
"ENV",
"EXPOSE",
"FROM",
"HEALTHCHECK",
"LABEL",
"MAINTAINER",
"ONBUILD",
"RUN",
"SHELL",
"STOPSIGNAL",
"USER",
"VOLUME",
"WORKDIR",
]);

const INSTRUCTION_LINE_RE = /^(?<inst>[A-Za-z]+)\s+(?<args>.*)$/u;

// Matches a heredoc opener like <<EOF, <<-EOF, <<'EOF', <<"EOF". Global so a
// single line (e.g. `COPY <<FILE1 <<FILE2 /dest/`) can open more than one.
const HEREDOC_OPENER_RE = /<<-?\s*(?<quote>['"]?)(?<delim>\w+)\k<quote>/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(/^(?<inst>[A-Z]+)\s+(?<args>.*)$/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;
};
73 changes: 73 additions & 0 deletions packages/core/test/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <<EOF
apt-get update
apt-get install -y curl
EOF
USER node
`);
expect(parsed).toHaveLength(3);
expect(parsed[0].instruction).toBe("FROM");
expect(parsed[1].instruction).toBe("RUN");
expect(parsed[1].args).toContain("apt-get install -y curl");
expect(parsed[2].instruction).toBe("USER");
});

test("does not treat heredoc body lines as instructions", () => {
const parsed = parseDockerfile(`
FROM node:22
RUN <<EOF
USER root
EOF
`);
expect(parsed).toHaveLength(2);
expect(parsed.some((i) => 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 <<EOF
echo hello
EOF
`);
expect(parsed[1].raw).toContain("echo hello");
});

test("handles CRLF line endings", () => {
const parsed = parseDockerfile("FROM node:22\r\nUSER node\r\n");
expect(parsed).toHaveLength(2);
expect(parsed[1].instruction).toBe("USER");
});
});

describe("Compose Parser", () => {
Expand Down
8 changes: 8 additions & 0 deletions packages/docker-doctor/test/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
7 changes: 7 additions & 0 deletions packages/docker-doctor/test/fixtures/heredoc/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
FROM node:22.2.0-alpine
RUN <<EOF
apt-get update
apt-get install -y curl
EOF
USER node
CMD ["node", "index.js"]