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
41 changes: 41 additions & 0 deletions packages/docker-parser/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,47 @@ const output = deparse(ast);
console.log(output); // FROM node:18-alpine
```

### Comments

A comment belongs to the instruction below it, as `leadingComments`, and a blank
line above a node is `blankBefore`. Both are emitted by the deparser, so a
parsed Dockerfile keeps its comments and section spacing through a round-trip,
and a generated one can carry its own:

```typescript
deparse({
type: 'Dockerfile',
directives: [],
comments: [],
stages: [
{
type: 'Stage',
from: { type: 'FromInstruction', instruction: 'FROM', image: 'node:22-alpine' },
instructions: [
{
type: 'CopyInstruction',
instruction: 'COPY',
sources: ['package.json'],
destination: './',
blankBefore: true,
leadingComments: [{ type: 'Comment', value: 'manifests only: cache the install layer' }],
},
],
},
],
});
// FROM node:22-alpine
// # manifests only: cache the install layer
//
// COPY package.json ./
```

`Dockerfile.comments` holds every comment in source order, and comments below
the last instruction land in `Dockerfile.trailingComments`.

Line continuations are not preserved: a `RUN` written across several lines with
`\` deparses as one line.

### AST Comparison

```typescript
Expand Down
80 changes: 80 additions & 0 deletions packages/docker-parser/__tests__/deparser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,4 +148,84 @@ FROM alpine`;
expect(result).toContain('# escape=`');
});
});

describe('mount flags', () => {
it('should keep a cache mount out of the command', () => {
const ast = parse(
'FROM alpine\nRUN --mount=type=cache,id=pnpm-store,target=/store pnpm install'
);
const run = ast.stages[0].instructions[0];

expect(run).toMatchObject({
type: 'RunInstruction',
command: 'pnpm install',
mount: [{ type: 'cache', id: 'pnpm-store', target: '/store' }]
});
expect(deparse(ast)).toBe(
'FROM alpine\nRUN --mount=type=cache,target=/store,id=pnpm-store pnpm install'
);
});
});

describe('comments', () => {
it('should emit a comment above the instruction it leads', () => {
const source = 'FROM alpine\n# why this copy is separate\nCOPY a.json ./';

expect(deparse(parse(source))).toBe(source);
});

it('should emit a comment block and keep the blank line below it', () => {
const source = 'FROM alpine\n\n# section header\n\nCOPY a.json ./\nCOPY b.json ./';

expect(deparse(parse(source))).toBe(source);
});

it('should emit comments that lead a later stage', () => {
const source = 'FROM alpine AS build\n\n# the runtime image\nFROM alpine\nCOPY --from=build /out /out';

expect(deparse(parse(source))).toBe(source);
});

it('should emit a trailing comment that leads nothing', () => {
const source = 'FROM alpine\nCOPY a.json ./\n# trailing note';

expect(deparse(parse(source))).toBe(source);
});

it('should attach comments to the node below, not the one above', () => {
const ast = parse('FROM alpine\nCOPY a.json ./\n# about b\nCOPY b.json ./');
const [copyA, copyB] = ast.stages[0].instructions;

expect(copyA.leadingComments).toBeUndefined();
expect(copyB.leadingComments).toEqual([
expect.objectContaining({ type: 'Comment', value: 'about b' })
]);
});

it('should emit comments built by hand, without a parse', () => {
const result = deparse({
type: 'Dockerfile',
directives: [],
comments: [],
stages: [
{
type: 'Stage',
from: { type: 'FromInstruction', instruction: 'FROM', image: 'alpine' },
instructions: [
{
type: 'CopyInstruction',
instruction: 'COPY',
sources: ['a.json'],
destination: './',
blankBefore: true,
leadingComments: [{ type: 'Comment', value: 'generated: the handler manifest' }]
}
]
}
]
});

expect(result).toBe('FROM alpine\n# generated: the handler manifest\n\nCOPY a.json ./');
});
});
});
52 changes: 52 additions & 0 deletions packages/docker-parser/__tests__/roundtrip.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,58 @@ FROM alpine`);
});
});

describe('RUN flags', () => {
it('should round-trip a cache mount', () => {
expectRoundTrip(
'FROM alpine\nRUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store pnpm install'
);
});

it('should round-trip a bind mount with a source', () => {
expectRoundTrip('FROM alpine\nRUN --mount=type=bind,source=/src,target=/dst make');
});

it('should round-trip a secret mount', () => {
expectRoundTrip('FROM alpine\nRUN --mount=type=secret,id=npmrc,required npm ci');
});

it('should round-trip two mounts on one RUN', () => {
expectRoundTrip(
'FROM alpine\nRUN --mount=type=cache,target=/cache --mount=type=bind,target=/src build.sh'
);
});

it('should round-trip --network and --security', () => {
expectRoundTrip('FROM alpine\nRUN --network=none --security=insecure build.sh');
});
});

describe('comments and blank lines', () => {
it('should round-trip a comment above an instruction', () => {
expectRoundTrip('FROM alpine\n# why this copy is separate\nCOPY a.json ./');
});

it('should round-trip a block of consecutive comments', () => {
expectRoundTrip('FROM alpine\n# first line\n# second line\n# third line\nCOPY a.json ./');
});

it('should round-trip a blank line between a comment and its instruction', () => {
expectRoundTrip('FROM alpine\n# section header\n\nCOPY a.json ./');
});

it('should round-trip comments leading a stage', () => {
expectRoundTrip('FROM alpine AS build\nRUN build.sh\n\n# the runtime image\nFROM alpine\nCOPY --from=build /out /out');
});

it('should round-trip a comment after the last instruction', () => {
expectRoundTrip('FROM alpine\nCOPY a.json ./\n# trailing note');
});

it('should round-trip an empty comment line', () => {
expectRoundTrip('FROM alpine\n# heading\n#\n# body\nCOPY a.json ./');
});
});

describe('complete Dockerfiles', () => {
it('should round-trip a typical Node.js Dockerfile', () => {
expectRoundTrip(`FROM node:18-alpine
Expand Down
54 changes: 47 additions & 7 deletions packages/docker-parser/src/deparser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,17 +116,56 @@ export class Deparser {
lines.push(this.deparseDirective(directive));
}

// Then stages
for (const stage of dockerfile.stages) {
if (lines.length > 0) {
// Then stages, separated by a blank line — unless the stage already places
// that separator itself via blankBefore (on the stage, or on the first of
// its leading comments). A separator is never inserted where the AST does
// not ask for one: an invented blank line would come back as blankBefore on
// the next parse and break the round-trip.
dockerfile.stages.forEach((stage, index) => {
if (index > 0 && !this.leadsWithBlank(stage)) {
lines.push('');
}
lines.push(this.deparseStage(stage));
});

for (const comment of dockerfile.trailingComments ?? []) {
if (comment.blankBefore) {
lines.push('');
}
lines.push(this.deparseComment(comment));
}

return lines.join(this.options.newline);
}

/**
* Prefix a node's rendered lines with its blank line and leading comments
*/
private withLeading(node: Node, rendered: string): string {
const lines: string[] = [];
// Each comment carries its own blank line, so a blank above the comment
// block and a blank between the block and the instruction stay distinct.
for (const comment of node.leadingComments ?? []) {
if (comment.blankBefore) {
lines.push('');
}
lines.push(this.deparseComment(comment));
}
if (node.blankBefore) {
lines.push('');
}
lines.push(rendered);
return lines.join(this.options.newline);
}

/**
* Whether a node already renders a blank line above itself
*/
private leadsWithBlank(node: Node): boolean {
const first = node.leadingComments?.[0];
return first ? Boolean(first.blankBefore) : Boolean(node.blankBefore);
}

/**
* Deparse parser directive
*/
Expand All @@ -141,21 +180,22 @@ export class Deparser {
const lines: string[] = [];

// FROM instruction
lines.push(this.deparseFrom(stage.from));
lines.push(this.withLeading(stage.from, this.deparseFrom(stage.from)));

// Other instructions
for (const instruction of stage.instructions) {
lines.push(this.deparseInstruction(instruction));
lines.push(this.withLeading(instruction, this.deparseInstruction(instruction)));
}

return lines.join(this.options.newline);
return this.withLeading(stage, lines.join(this.options.newline));
}

/**
* Deparse comment
*/
private deparseComment(comment: Comment): string {
return `# ${comment.value}`;
// An empty comment is a bare `#`; `# ` would add trailing whitespace.
return comment.value ? `# ${comment.value}` : '#';
}

/**
Expand Down
31 changes: 29 additions & 2 deletions packages/docker-parser/src/lexer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,33 @@ export class Lexer {
return result;
}

/**
* Read a flag, including its `=value` if present
*
* Unlike readWord, this does not stop at `=`: a flag's value is part of the
* flag (`--mount=type=cache,id=store`, `--from=builder`). Stopping at the
* first `=` leaves the value behind as free text, where an instruction parser
* reads it as the start of the command.
*/
private readFlag(): string {
let result = '';
while (!this.isAtEnd()) {
const char = this.peek();
if (char === ' ' || char === '\t' || char === '\n' || char === '\r') {
break;
}
if (char === this.escapeChar && (this.peek(1) === '\n' || this.peek(1) === '\r')) {
// Line continuation
this.advance();
if (this.peek() === '\r') this.advance();
if (this.peek() === '\n') this.advance();
continue;
}
result += this.advance();
}
return result;
}

/**
* Read a comment or directive
*/
Expand Down Expand Up @@ -389,9 +416,9 @@ export class Lexer {
};
}

// Flag (--something)
// Flag (--something, or --something=value)
if (char === '-' && this.peek(1) === '-') {
const value = this.readWord();
const value = this.readFlag();
return {
type: TokenType.FLAG,
value,
Expand Down
Loading
Loading