From bdf5254457d15ab42bd1cd2bdb5557fd4372aa6e Mon Sep 17 00:00:00 2001 From: NawaMan Date: Wed, 4 Feb 2026 04:10:39 -0500 Subject: [PATCH 01/11] Add Boothfile parser foundation Implement the core parser for Boothfile DSL that: - Validates # syntax=codingbooth/boothfile:1 header - Parses all Phase 1-3 commands (run, copy, env, setup, install, etc.) - Supports heredoc syntax with three modes (verbatim, &&-join, ;-join) - Handles comments (full-line and inline) - Provides error messages with line numbers and suggestions - Includes DOCKER escape hatch for unsupported directives --- cli/src/pkg/boothfile/parser.go | 504 ++++++++++++++++++++++++ cli/src/pkg/boothfile/parser_test.go | 562 +++++++++++++++++++++++++++ 2 files changed, 1066 insertions(+) create mode 100644 cli/src/pkg/boothfile/parser.go create mode 100644 cli/src/pkg/boothfile/parser_test.go diff --git a/cli/src/pkg/boothfile/parser.go b/cli/src/pkg/boothfile/parser.go new file mode 100644 index 00000000..d3637305 --- /dev/null +++ b/cli/src/pkg/boothfile/parser.go @@ -0,0 +1,504 @@ +// Copyright 2025-2026 : Nawa Manusitthipol +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. + +// Package boothfile provides parsing and compilation of Boothfiles into Dockerfiles. +// +// Boothfile is a higher-level DSL that compiles to Dockerfiles, aimed at simplifying +// CodingBooth configuration by hiding boilerplate and providing intent-based syntax. +// +// Example Boothfile: +// +// # syntax=codingbooth/boothfile:1 +// setup python 3.12 +// install pip django +// +// This compiles to a full Dockerfile with the required CodingBooth prologue. +package boothfile + +import ( + "bufio" + "fmt" + "io" + "regexp" + "strings" +) + +// SyntaxVersion is the expected syntax directive version. +const SyntaxVersion = "codingbooth/boothfile:1" + +// SyntaxDirective is the full syntax line expected at the start of a Boothfile. +const SyntaxDirective = "# syntax=" + SyntaxVersion + +// CommandType represents the type of a parsed command. +type CommandType int + +const ( + CommandUnknown CommandType = iota + CommandComment + CommandBlank + CommandRun + CommandRunHeredoc + CommandCopy + CommandEnv + CommandWorkdir + CommandExpose + CommandLabel + CommandArg + CommandSetup + CommandInstall + CommandDocker // Escape hatch +) + +// String returns a string representation of the command type. +func (ct CommandType) String() string { + switch ct { + case CommandComment: + return "comment" + case CommandBlank: + return "blank" + case CommandRun: + return "run" + case CommandRunHeredoc: + return "run-heredoc" + case CommandCopy: + return "copy" + case CommandEnv: + return "env" + case CommandWorkdir: + return "workdir" + case CommandExpose: + return "expose" + case CommandLabel: + return "label" + case CommandArg: + return "arg" + case CommandSetup: + return "setup" + case CommandInstall: + return "install" + case CommandDocker: + return "DOCKER" + default: + return "unknown" + } +} + +// HeredocMode represents how heredoc content should be joined. +type HeredocMode int + +const ( + HeredocVerbatim HeredocMode = iota // Pass through as Docker heredoc + HeredocAndJoin // Join lines with && + HeredocSemiJoin // Join lines with ; +) + +// String returns a string representation of the heredoc mode. +func (hm HeredocMode) String() string { + switch hm { + case HeredocVerbatim: + return "verbatim" + case HeredocAndJoin: + return "and-join" + case HeredocSemiJoin: + return "semi-join" + default: + return "unknown" + } +} + +// Command represents a parsed Boothfile command. +type Command struct { + Type CommandType + LineNumber int + Raw string // Original line(s) from the file + Args []string // Parsed arguments + + // For heredoc commands + HeredocMode HeredocMode + HeredocDelimiter string + HeredocContent []string +} + +// ParseError represents an error that occurred during parsing. +type ParseError struct { + LineNumber int + Message string + Hint string +} + +// Error implements the error interface. +func (e ParseError) Error() string { + if e.Hint != "" { + return fmt.Sprintf("Boothfile:%d: %s\nHint: %s", e.LineNumber, e.Message, e.Hint) + } + return fmt.Sprintf("Boothfile:%d: %s", e.LineNumber, e.Message) +} + +// ParseResult contains the result of parsing a Boothfile. +type ParseResult struct { + Commands []Command + Errors []ParseError + Warnings []ParseError +} + +// HasErrors returns true if there were parsing errors. +func (pr ParseResult) HasErrors() bool { + return len(pr.Errors) > 0 +} + +// HasWarnings returns true if there were parsing warnings. +func (pr ParseResult) HasWarnings() bool { + return len(pr.Warnings) > 0 +} + +// Parser parses Boothfiles into structured commands. +type Parser struct { + strict bool +} + +// NewParser creates a new Boothfile parser. +func NewParser() *Parser { + return &Parser{strict: false} +} + +// NewStrictParser creates a new Boothfile parser in strict mode. +// In strict mode, warnings are treated as errors. +func NewStrictParser() *Parser { + return &Parser{strict: true} +} + +// Regex patterns for parsing +var ( + // Matches: run < 0 { + // Check if # is inside quotes (simple heuristic) + beforeHash := trimmed[:idx] + if strings.Count(beforeHash, `"`)%2 == 0 && strings.Count(beforeHash, `'`)%2 == 0 { + withoutComment = strings.TrimSpace(beforeHash) + } + } + + match := commandPattern.FindStringSubmatch(withoutComment) + if match == nil { + return nil, &ParseError{ + LineNumber: lineNumber, + Message: fmt.Sprintf("Invalid command syntax: %s", trimmed), + } + } + + cmdName := strings.ToLower(match[1]) + argsStr := match[2] + + // Map command name to type + cmdType := p.mapCommandType(cmdName) + if cmdType == CommandUnknown { + // Check for common typos + suggestion := p.suggestCommand(cmdName) + hint := "" + if suggestion != "" { + hint = fmt.Sprintf("Did you mean '%s'?", suggestion) + } + return nil, &ParseError{ + LineNumber: lineNumber, + Message: fmt.Sprintf("Unknown command: %s", cmdName), + Hint: hint, + } + } + + // Parse arguments based on command type + args := p.parseArgs(argsStr, cmdType) + + return &Command{ + Type: cmdType, + LineNumber: lineNumber, + Raw: raw, + Args: args, + }, nil +} + +// mapCommandType maps a command name to its type. +func (p *Parser) mapCommandType(name string) CommandType { + switch name { + case "run": + return CommandRun + case "copy": + return CommandCopy + case "env": + return CommandEnv + case "workdir": + return CommandWorkdir + case "expose": + return CommandExpose + case "label": + return CommandLabel + case "arg": + return CommandArg + case "setup": + return CommandSetup + case "install": + return CommandInstall + default: + return CommandUnknown + } +} + +// suggestCommand returns a suggestion for a misspelled command. +func (p *Parser) suggestCommand(name string) string { + commands := []string{"run", "copy", "env", "workdir", "expose", "label", "arg", "setup", "install"} + + for _, cmd := range commands { + // Simple Levenshtein-like check: if most characters match + if len(name) > 0 && len(cmd) > 0 { + if strings.HasPrefix(cmd, name[:1]) && abs(len(cmd)-len(name)) <= 2 { + // Check character overlap + matches := 0 + for i := 0; i < len(name) && i < len(cmd); i++ { + if name[i] == cmd[i] { + matches++ + } + } + if matches >= len(name)-2 || matches >= len(cmd)-2 { + return cmd + } + } + } + } + return "" +} + +// parseArgs parses the argument string for a command. +func (p *Parser) parseArgs(argsStr string, cmdType CommandType) []string { + argsStr = strings.TrimSpace(argsStr) + if argsStr == "" { + return []string{} + } + + // For most commands, split on whitespace + // But preserve quoted strings + return splitArgs(argsStr) +} + +// splitArgs splits an argument string, respecting quotes. +func splitArgs(s string) []string { + var args []string + var current strings.Builder + inQuote := false + quoteChar := rune(0) + + for _, r := range s { + switch { + case (r == '"' || r == '\'') && !inQuote: + inQuote = true + quoteChar = r + current.WriteRune(r) + case r == quoteChar && inQuote: + inQuote = false + quoteChar = 0 + current.WriteRune(r) + case (r == ' ' || r == '\t') && !inQuote: + if current.Len() > 0 { + args = append(args, current.String()) + current.Reset() + } + default: + current.WriteRune(r) + } + } + + if current.Len() > 0 { + args = append(args, current.String()) + } + + return args +} + +func abs(n int) int { + if n < 0 { + return -n + } + return n +} diff --git a/cli/src/pkg/boothfile/parser_test.go b/cli/src/pkg/boothfile/parser_test.go new file mode 100644 index 00000000..cae6a7fa --- /dev/null +++ b/cli/src/pkg/boothfile/parser_test.go @@ -0,0 +1,562 @@ +// Copyright 2025-2026 : Nawa Manusitthipol +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. + +package boothfile + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParser_SyntaxDirective(t *testing.T) { + t.Run("valid syntax directive", func(t *testing.T) { + content := `# syntax=codingbooth/boothfile:1 +` + p := NewParser() + result := p.ParseString(content) + + assert.False(t, result.HasErrors(), "should not have errors: %v", result.Errors) + require.Len(t, result.Commands, 1) + assert.Equal(t, CommandComment, result.Commands[0].Type) + }) + + t.Run("syntax directive with blank lines before", func(t *testing.T) { + content := ` + +# syntax=codingbooth/boothfile:1 +` + p := NewParser() + result := p.ParseString(content) + + assert.False(t, result.HasErrors(), "should not have errors: %v", result.Errors) + require.Len(t, result.Commands, 3) + assert.Equal(t, CommandBlank, result.Commands[0].Type) + assert.Equal(t, CommandBlank, result.Commands[1].Type) + assert.Equal(t, CommandComment, result.Commands[2].Type) + }) + + t.Run("missing syntax directive", func(t *testing.T) { + content := `run echo hello +` + p := NewParser() + result := p.ParseString(content) + + assert.True(t, result.HasErrors()) + assert.Contains(t, result.Errors[0].Message, "Missing syntax directive") + }) + + t.Run("invalid syntax version", func(t *testing.T) { + content := `# syntax=codingbooth/boothfile:2 +` + p := NewParser() + result := p.ParseString(content) + + assert.True(t, result.HasErrors()) + assert.Contains(t, result.Errors[0].Message, "Invalid syntax directive") + }) +} + +func TestParser_Comments(t *testing.T) { + t.Run("full line comment", func(t *testing.T) { + content := `# syntax=codingbooth/boothfile:1 +# This is a comment +` + p := NewParser() + result := p.ParseString(content) + + assert.False(t, result.HasErrors()) + require.Len(t, result.Commands, 2) + assert.Equal(t, CommandComment, result.Commands[1].Type) + }) + + t.Run("inline comment", func(t *testing.T) { + content := `# syntax=codingbooth/boothfile:1 +run echo hello # this is inline +` + p := NewParser() + result := p.ParseString(content) + + assert.False(t, result.HasErrors()) + require.Len(t, result.Commands, 2) + assert.Equal(t, CommandRun, result.Commands[1].Type) + assert.Equal(t, []string{"echo", "hello"}, result.Commands[1].Args) + }) + + t.Run("hash in quoted string not treated as comment", func(t *testing.T) { + content := `# syntax=codingbooth/boothfile:1 +run echo "hello#world" +` + p := NewParser() + result := p.ParseString(content) + + assert.False(t, result.HasErrors()) + require.Len(t, result.Commands, 2) + assert.Equal(t, CommandRun, result.Commands[1].Type) + assert.Contains(t, result.Commands[1].Args, `"hello#world"`) + }) +} + +func TestParser_BlankLines(t *testing.T) { + content := `# syntax=codingbooth/boothfile:1 + +run echo hello + +run echo world +` + p := NewParser() + result := p.ParseString(content) + + assert.False(t, result.HasErrors()) + blankCount := 0 + for _, cmd := range result.Commands { + if cmd.Type == CommandBlank { + blankCount++ + } + } + assert.Equal(t, 2, blankCount) +} + +func TestParser_RunCommand(t *testing.T) { + t.Run("simple run", func(t *testing.T) { + content := `# syntax=codingbooth/boothfile:1 +run apt-get update +` + p := NewParser() + result := p.ParseString(content) + + assert.False(t, result.HasErrors()) + require.Len(t, result.Commands, 2) + cmd := result.Commands[1] + assert.Equal(t, CommandRun, cmd.Type) + assert.Equal(t, []string{"apt-get", "update"}, cmd.Args) + }) + + t.Run("run with && chain", func(t *testing.T) { + content := `# syntax=codingbooth/boothfile:1 +run apt-get update && apt-get install -y curl +` + p := NewParser() + result := p.ParseString(content) + + assert.False(t, result.HasErrors()) + cmd := result.Commands[1] + assert.Equal(t, CommandRun, cmd.Type) + assert.Contains(t, cmd.Args, "&&") + }) +} + +func TestParser_HeredocVerbatim(t *testing.T) { + content := `# syntax=codingbooth/boothfile:1 +run < Date: Wed, 4 Feb 2026 04:10:47 -0500 Subject: [PATCH 02/11] Add Boothfile compiler with Dockerfile generation Implement the compiler that converts parsed Boothfile commands to Dockerfile: - Generate fixed CodingBooth prologue (syntax, ARG, FROM, SHELL, USER, WORKDIR) - Compile all Phase 1 commands (run, copy, env, workdir, expose, label, arg) - Compile heredoc with three modes (verbatim, &&-join, ;-join) - Compile Phase 2 setup command (setup python 3.12 -> RUN python--setup.sh 3.12) - Compile Phase 3 install command (install pip django -> RUN pip--install.sh django) - Support custom setup script detection with automatic COPY injection - Handle DOCKER escape hatch (pass-through to Dockerfile) --- cli/src/pkg/boothfile/compiler.go | 424 +++++++++++++++++++++ cli/src/pkg/boothfile/compiler_test.go | 493 +++++++++++++++++++++++++ 2 files changed, 917 insertions(+) create mode 100644 cli/src/pkg/boothfile/compiler.go create mode 100644 cli/src/pkg/boothfile/compiler_test.go diff --git a/cli/src/pkg/boothfile/compiler.go b/cli/src/pkg/boothfile/compiler.go new file mode 100644 index 00000000..a7042283 --- /dev/null +++ b/cli/src/pkg/boothfile/compiler.go @@ -0,0 +1,424 @@ +// Copyright 2025-2026 : Nawa Manusitthipol +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. + +package boothfile + +import ( + "fmt" + "strings" +) + +// DefaultRepo is the default Docker repository for CodingBooth images. +const DefaultRepo = "nawaman/codingbooth" + +// DefaultVariant is the default variant when not specified. +const DefaultVariant = "base" + +// DefaultVersion is the default version tag when not specified. +const DefaultVersion = "latest" + +// CompilerOptions contains options for the Boothfile compiler. +type CompilerOptions struct { + // CustomSetupsDir is the path to check for custom setup scripts (e.g., ".booth/setups") + // If empty, custom setup detection is disabled. + CustomSetupsDir string + + // CheckCustomSetupExists is a function that checks if a custom setup script exists. + // If nil, no custom setup detection is performed. + CheckCustomSetupExists func(name string) bool +} + +// Compiler compiles parsed Boothfile commands into a Dockerfile. +type Compiler struct { + options CompilerOptions +} + +// NewCompiler creates a new Boothfile compiler with default options. +func NewCompiler() *Compiler { + return &Compiler{ + options: CompilerOptions{}, + } +} + +// NewCompilerWithOptions creates a new Boothfile compiler with the given options. +func NewCompilerWithOptions(options CompilerOptions) *Compiler { + return &Compiler{ + options: options, + } +} + +// CompileResult contains the result of compiling a Boothfile. +type CompileResult struct { + Dockerfile string + Errors []ParseError + Warnings []ParseError +} + +// HasErrors returns true if there were compilation errors. +func (cr CompileResult) HasErrors() bool { + return len(cr.Errors) > 0 +} + +// Compile compiles a ParseResult into a Dockerfile string. +func (c *Compiler) Compile(parseResult ParseResult) CompileResult { + result := CompileResult{ + Errors: append([]ParseError{}, parseResult.Errors...), + Warnings: append([]ParseError{}, parseResult.Warnings...), + } + + // If there were parse errors, don't compile + if parseResult.HasErrors() { + return result + } + + var sb strings.Builder + + // Write prologue + c.writePrologue(&sb) + + // Compile each command + for _, cmd := range parseResult.Commands { + line, err := c.compileCommand(cmd) + if err != nil { + result.Errors = append(result.Errors, *err) + continue + } + if line != "" { + sb.WriteString(line) + sb.WriteString("\n") + } + } + + result.Dockerfile = sb.String() + return result +} + +// writePrologue writes the fixed Dockerfile prologue. +func (c *Compiler) writePrologue(sb *strings.Builder) { + prologue := `# syntax=docker/dockerfile:1 +ARG BOOTH_VARIANT_TAG=base +ARG BOOTH_VERSION_TAG=latest +FROM nawaman/codingbooth:${BOOTH_VARIANT_TAG}-${BOOTH_VERSION_TAG} + +SHELL ["/bin/bash","-o","pipefail","-lc"] +USER root + +ARG BOOTH_VARIANT_TAG=base +ARG BOOTH_VERSION_TAG=latest + +WORKDIR /opt/codingbooth/setups + +` + sb.WriteString(prologue) +} + +// compileCommand compiles a single command to Dockerfile instruction(s). +func (c *Compiler) compileCommand(cmd Command) (string, *ParseError) { + switch cmd.Type { + case CommandComment, CommandBlank: + // Comments and blank lines are stripped + return "", nil + + case CommandRun: + return c.compileRun(cmd) + + case CommandRunHeredoc: + return c.compileRunHeredoc(cmd) + + case CommandCopy: + return c.compileCopy(cmd) + + case CommandEnv: + return c.compileEnv(cmd) + + case CommandWorkdir: + return c.compileWorkdir(cmd) + + case CommandExpose: + return c.compileExpose(cmd) + + case CommandLabel: + return c.compileLabel(cmd) + + case CommandArg: + return c.compileArg(cmd) + + case CommandSetup: + return c.compileSetup(cmd) + + case CommandInstall: + return c.compileInstall(cmd) + + case CommandDocker: + return c.compileDocker(cmd) + + default: + return "", &ParseError{ + LineNumber: cmd.LineNumber, + Message: fmt.Sprintf("Unknown command type: %v", cmd.Type), + } + } +} + +// compileRun compiles a simple run command. +func (c *Compiler) compileRun(cmd Command) (string, *ParseError) { + if len(cmd.Args) == 0 { + return "", &ParseError{ + LineNumber: cmd.LineNumber, + Message: "run command requires arguments", + } + } + return "RUN " + strings.Join(cmd.Args, " "), nil +} + +// compileRunHeredoc compiles a heredoc run command. +func (c *Compiler) compileRunHeredoc(cmd Command) (string, *ParseError) { + switch cmd.HeredocMode { + case HeredocVerbatim: + return c.compileHeredocVerbatim(cmd) + case HeredocAndJoin: + return c.compileHeredocJoin(cmd, " && ") + case HeredocSemiJoin: + return c.compileHeredocJoin(cmd, "; ") + default: + return "", &ParseError{ + LineNumber: cmd.LineNumber, + Message: fmt.Sprintf("Unknown heredoc mode: %v", cmd.HeredocMode), + } + } +} + +// compileHeredocVerbatim compiles a verbatim heredoc (pass-through to Docker). +func (c *Compiler) compileHeredocVerbatim(cmd Command) (string, *ParseError) { + var sb strings.Builder + sb.WriteString("RUN <<") + sb.WriteString(cmd.HeredocDelimiter) + sb.WriteString("\n") + for _, line := range cmd.HeredocContent { + sb.WriteString(line) + sb.WriteString("\n") + } + sb.WriteString(cmd.HeredocDelimiter) + return sb.String(), nil +} + +// compileHeredocJoin compiles a heredoc with line joining. +func (c *Compiler) compileHeredocJoin(cmd Command, joiner string) (string, *ParseError) { + // Process content: collapse continuations, skip blanks/comments, join + lines := c.processHeredocContent(cmd.HeredocContent) + + if len(lines) == 0 { + return "", &ParseError{ + LineNumber: cmd.LineNumber, + Message: "heredoc block is empty after processing", + } + } + + // Format with continuation backslashes for readability + if len(lines) == 1 { + return "RUN " + lines[0], nil + } + + var sb strings.Builder + sb.WriteString("RUN ") + for i, line := range lines { + sb.WriteString(line) + if i < len(lines)-1 { + sb.WriteString(" \\") + sb.WriteString("\n ") + sb.WriteString(strings.TrimSuffix(joiner, " ")) + sb.WriteString(" ") + } + } + return sb.String(), nil +} + +// processHeredocContent processes heredoc lines for && or ; joining. +func (c *Compiler) processHeredocContent(content []string) []string { + // Step 1: Collapse line continuations + collapsed := make([]string, 0) + var current strings.Builder + + for _, line := range content { + trimmed := strings.TrimRight(line, " \t") + if strings.HasSuffix(trimmed, "\\") { + // Continuation - append without the backslash + current.WriteString(strings.TrimSuffix(trimmed, "\\")) + current.WriteString(" ") + } else { + current.WriteString(trimmed) + if current.Len() > 0 { + collapsed = append(collapsed, current.String()) + } + current.Reset() + } + } + // Don't forget any remaining content + if current.Len() > 0 { + collapsed = append(collapsed, current.String()) + } + + // Step 2: Filter out blank lines and comments + result := make([]string, 0) + for _, line := range collapsed { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + if strings.HasPrefix(trimmed, "#") { + continue + } + result = append(result, trimmed) + } + + return result +} + +// compileCopy compiles a copy command. +func (c *Compiler) compileCopy(cmd Command) (string, *ParseError) { + if len(cmd.Args) < 2 { + return "", &ParseError{ + LineNumber: cmd.LineNumber, + Message: "copy command requires source and destination", + } + } + return "COPY " + strings.Join(cmd.Args, " "), nil +} + +// compileEnv compiles an env command. +func (c *Compiler) compileEnv(cmd Command) (string, *ParseError) { + if len(cmd.Args) == 0 { + return "", &ParseError{ + LineNumber: cmd.LineNumber, + Message: "env command requires KEY=value", + } + } + return "ENV " + strings.Join(cmd.Args, " "), nil +} + +// compileWorkdir compiles a workdir command. +func (c *Compiler) compileWorkdir(cmd Command) (string, *ParseError) { + if len(cmd.Args) == 0 { + return "", &ParseError{ + LineNumber: cmd.LineNumber, + Message: "workdir command requires a path", + } + } + return "WORKDIR " + cmd.Args[0], nil +} + +// compileExpose compiles an expose command. +func (c *Compiler) compileExpose(cmd Command) (string, *ParseError) { + if len(cmd.Args) == 0 { + return "", &ParseError{ + LineNumber: cmd.LineNumber, + Message: "expose command requires a port number", + } + } + return "EXPOSE " + strings.Join(cmd.Args, " "), nil +} + +// compileLabel compiles a label command. +func (c *Compiler) compileLabel(cmd Command) (string, *ParseError) { + if len(cmd.Args) == 0 { + return "", &ParseError{ + LineNumber: cmd.LineNumber, + Message: "label command requires key=value", + } + } + return "LABEL " + strings.Join(cmd.Args, " "), nil +} + +// compileArg compiles an arg command. +func (c *Compiler) compileArg(cmd Command) (string, *ParseError) { + if len(cmd.Args) == 0 { + return "", &ParseError{ + LineNumber: cmd.LineNumber, + Message: "arg command requires NAME or NAME=default", + } + } + return "ARG " + strings.Join(cmd.Args, " "), nil +} + +// compileSetup compiles a setup command. +func (c *Compiler) compileSetup(cmd Command) (string, *ParseError) { + if len(cmd.Args) == 0 { + return "", &ParseError{ + LineNumber: cmd.LineNumber, + Message: "setup command requires a tool name", + } + } + + toolName := cmd.Args[0] + scriptArgs := cmd.Args[1:] + + var lines []string + + // Check for custom setup script + if c.options.CheckCustomSetupExists != nil && c.options.CheckCustomSetupExists(toolName) { + // Add COPY for custom script + srcPath := fmt.Sprintf("%s/%s--setup.sh", c.options.CustomSetupsDir, toolName) + dstPath := fmt.Sprintf("/opt/codingbooth/setups/%s--setup.sh", toolName) + lines = append(lines, fmt.Sprintf("COPY %s %s", srcPath, dstPath)) + } + + // Build RUN command + runCmd := fmt.Sprintf("RUN %s--setup.sh", toolName) + if len(scriptArgs) > 0 { + runCmd += " " + strings.Join(scriptArgs, " ") + } + lines = append(lines, runCmd) + + return strings.Join(lines, "\n"), nil +} + +// compileInstall compiles an install command. +func (c *Compiler) compileInstall(cmd Command) (string, *ParseError) { + if len(cmd.Args) < 2 { + return "", &ParseError{ + LineNumber: cmd.LineNumber, + Message: "install command requires a tool and at least one package", + } + } + + toolName := cmd.Args[0] + packages := cmd.Args[1:] + + var lines []string + + // Check for custom install script + if c.options.CheckCustomSetupExists != nil && c.options.CheckCustomSetupExists(toolName) { + // Add COPY for custom script + srcPath := fmt.Sprintf("%s/%s--install.sh", c.options.CustomSetupsDir, toolName) + dstPath := fmt.Sprintf("/opt/codingbooth/setups/%s--install.sh", toolName) + lines = append(lines, fmt.Sprintf("COPY %s %s", srcPath, dstPath)) + } + + // Build RUN command + runCmd := fmt.Sprintf("RUN %s--install.sh %s", toolName, strings.Join(packages, " ")) + lines = append(lines, runCmd) + + return strings.Join(lines, "\n"), nil +} + +// compileDocker compiles a DOCKER escape hatch command. +func (c *Compiler) compileDocker(cmd Command) (string, *ParseError) { + if len(cmd.Args) == 0 { + return "", &ParseError{ + LineNumber: cmd.LineNumber, + Message: "DOCKER escape hatch requires a Dockerfile instruction", + } + } + // Pass through verbatim (DOCKER prefix already stripped by parser) + return cmd.Args[0], nil +} + +// CompileString is a convenience function to parse and compile a Boothfile string. +func CompileString(content string) CompileResult { + parser := NewParser() + parseResult := parser.ParseString(content) + + compiler := NewCompiler() + return compiler.Compile(parseResult) +} diff --git a/cli/src/pkg/boothfile/compiler_test.go b/cli/src/pkg/boothfile/compiler_test.go new file mode 100644 index 00000000..756e5fbc --- /dev/null +++ b/cli/src/pkg/boothfile/compiler_test.go @@ -0,0 +1,493 @@ +// Copyright 2025-2026 : Nawa Manusitthipol +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. + +package boothfile + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCompiler_Prologue(t *testing.T) { + content := `# syntax=codingbooth/boothfile:1 +` + result := CompileString(content) + + assert.False(t, result.HasErrors(), "errors: %v", result.Errors) + + // Check prologue components + assert.Contains(t, result.Dockerfile, "# syntax=docker/dockerfile:1") + assert.Contains(t, result.Dockerfile, "ARG BOOTH_VARIANT_TAG=base") + assert.Contains(t, result.Dockerfile, "ARG BOOTH_VERSION_TAG=latest") + assert.Contains(t, result.Dockerfile, "FROM nawaman/codingbooth:${BOOTH_VARIANT_TAG}-${BOOTH_VERSION_TAG}") + assert.Contains(t, result.Dockerfile, `SHELL ["/bin/bash","-o","pipefail","-lc"]`) + assert.Contains(t, result.Dockerfile, "USER root") + assert.Contains(t, result.Dockerfile, "WORKDIR /opt/codingbooth/setups") +} + +func TestCompiler_Run(t *testing.T) { + t.Run("simple run", func(t *testing.T) { + content := `# syntax=codingbooth/boothfile:1 +run apt-get update +` + result := CompileString(content) + + assert.False(t, result.HasErrors()) + assert.Contains(t, result.Dockerfile, "RUN apt-get update") + }) + + t.Run("run with chain", func(t *testing.T) { + content := `# syntax=codingbooth/boothfile:1 +run apt-get update && apt-get install -y curl +` + result := CompileString(content) + + assert.False(t, result.HasErrors()) + assert.Contains(t, result.Dockerfile, "RUN apt-get update && apt-get install -y curl") + }) +} + +func TestCompiler_HeredocVerbatim(t *testing.T) { + content := `# syntax=codingbooth/boothfile:1 +run < Date: Wed, 4 Feb 2026 04:10:54 -0500 Subject: [PATCH 03/11] Add CLI integration for Boothfile support Integrate Boothfile compiler into the booth CLI: - Add --boothfile flag to specify Boothfile path - Add --emit-dockerfile flag to output generated Dockerfile without building - Add --strict flag to treat warnings as errors - Add Boothfile/EmitDockerfile/Strict fields to AppConfig with env vars - Auto-detect .booth/Boothfile (takes precedence over .booth/Dockerfile) - Compile Boothfile to .booth/.Dockerfile.generated for Docker build - Support custom setup script detection from .booth/setups/ File selection precedence: 1. --dockerfile (explicit) - use directly 2. --boothfile (explicit) - compile and use 3. .booth/Boothfile (auto-detect) - compile and use 4. .booth/Dockerfile (auto-detect) - use directly --- cli/src/pkg/appctx/app_config.go | 12 +- cli/src/pkg/appctx/app_context.go | 9 +- cli/src/pkg/booth/ensure_docker_image.go | 109 ++++++++++++++++-- .../pkg/booth/init/initialize_app_context.go | 16 +++ 4 files changed, 133 insertions(+), 13 deletions(-) diff --git a/cli/src/pkg/appctx/app_config.go b/cli/src/pkg/appctx/app_config.go index 57358598..b28bdd53 100644 --- a/cli/src/pkg/appctx/app_config.go +++ b/cli/src/pkg/appctx/app_config.go @@ -37,9 +37,12 @@ type AppConfig struct { // -------------------- // Image configuration // -------------------- - Dockerfile string `toml:"dockerfile,omitempty" envconfig:"CB_DOCKERFILE"` - Image string `toml:"image,omitempty" envconfig:"CB_IMAGE"` - Variant string `toml:"variant,omitempty" envconfig:"CB_VARIANT" default:"default"` + Dockerfile string `toml:"dockerfile,omitempty" envconfig:"CB_DOCKERFILE"` + Boothfile string `toml:"boothfile,omitempty" envconfig:"CB_BOOTHFILE"` + Image string `toml:"image,omitempty" envconfig:"CB_IMAGE"` + Variant string `toml:"variant,omitempty" envconfig:"CB_VARIANT" default:"default"` + EmitDockerfile bool `toml:"emit-dockerfile,omitempty" envconfig:"CB_EMIT_DOCKERFILE" default:"false"` + Strict bool `toml:"strict,omitempty" envconfig:"CB_STRICT" default:"false"` // -------------------- // Runtime values @@ -113,8 +116,11 @@ func (config AppConfig) String() string { fmt.Fprintf(&str, "# Image Configuration -----------\n") fmt.Fprintf(&str, " Dockerfile: %q\n", config.Dockerfile) + fmt.Fprintf(&str, " Boothfile: %q\n", config.Boothfile) fmt.Fprintf(&str, " Image: %q\n", config.Image) fmt.Fprintf(&str, " Variant: %q\n", config.Variant) + fmt.Fprintf(&str, " EmitDockerfile: %t\n", config.EmitDockerfile) + fmt.Fprintf(&str, " Strict: %t\n", config.Strict) fmt.Fprintf(&str, "# Runtime values ----------------\n") fmt.Fprintf(&str, " ProjectName: %q\n", config.ProjectName) diff --git a/cli/src/pkg/appctx/app_context.go b/cli/src/pkg/appctx/app_context.go index 6a228aba..a57600b4 100644 --- a/cli/src/pkg/appctx/app_context.go +++ b/cli/src/pkg/appctx/app_context.go @@ -86,9 +86,12 @@ func (ctx AppContext) Pull() bool { return ctx.values.Config.Pull } func (ctx AppContext) Dind() bool { return ctx.values.Config.Dind } // Image Configuration -func (ctx AppContext) Dockerfile() string { return ctx.values.Config.Dockerfile } -func (ctx AppContext) Image() string { return ctx.values.Config.Image } -func (ctx AppContext) Variant() string { return ctx.values.Config.Variant } +func (ctx AppContext) Dockerfile() string { return ctx.values.Config.Dockerfile } +func (ctx AppContext) Boothfile() string { return ctx.values.Config.Boothfile } +func (ctx AppContext) Image() string { return ctx.values.Config.Image } +func (ctx AppContext) Variant() string { return ctx.values.Config.Variant } +func (ctx AppContext) EmitDockerfile() bool { return ctx.values.Config.EmitDockerfile } +func (ctx AppContext) Strict() bool { return ctx.values.Config.Strict } // Runtime values func (ctx AppContext) ProjectName() string { return ctx.values.Config.ProjectName } diff --git a/cli/src/pkg/booth/ensure_docker_image.go b/cli/src/pkg/booth/ensure_docker_image.go index 67350dc0..f44a3c8b 100644 --- a/cli/src/pkg/booth/ensure_docker_image.go +++ b/cli/src/pkg/booth/ensure_docker_image.go @@ -10,6 +10,7 @@ import ( "path/filepath" "github.com/nawaman/codingbooth/src/pkg/appctx" + "github.com/nawaman/codingbooth/src/pkg/boothfile" "github.com/nawaman/codingbooth/src/pkg/docker" "github.com/nawaman/codingbooth/src/pkg/ilist" ) @@ -74,12 +75,12 @@ func EnsureDockerImage(ctx appctx.AppContext) appctx.AppContext { return ctx } -// normalizeDockerFile normalizes the DOCKER_FILE path. +// normalizeDockerFile normalizes the DOCKER_FILE path, handling Boothfile compilation. +// Returns the Dockerfile path (either existing or generated from Boothfile). func normalizeDockerFile(ctx appctx.AppContext) string { - dockerFile := ctx.Dockerfile() - - // If DOCKER_FILE is set - if dockerFile != "" { + // If --dockerfile is explicitly set, use it (takes precedence over Boothfile detection) + if ctx.Dockerfile() != "" { + dockerFile := ctx.Dockerfile() // If it's a directory, check for Dockerfile in .booth/ first if isDir(dockerFile) { dockerfile := filepath.Join(dockerFile, ".booth", "Dockerfile") @@ -90,9 +91,24 @@ func normalizeDockerFile(ctx appctx.AppContext) string { return dockerFile } - // If DOCKER_FILE is unset, check code path for Dockerfile + // If --boothfile is explicitly set, compile it + if ctx.Boothfile() != "" { + return compileBoothfile(ctx, ctx.Boothfile()) + } + + // Auto-detect: check code path for Boothfile first, then Dockerfile if ctx.Code() != "" && isDir(ctx.Code()) { - // Prefer new location (.booth/Dockerfile) + // Check for Boothfile first (higher precedence) + boothfilePath := filepath.Join(ctx.Code(), ".booth", "Boothfile") + if isFile(boothfilePath) { + if ctx.Dockerfile() != "" { + // Both exist and --dockerfile wasn't explicit - warn + fmt.Fprintf(os.Stderr, "Warning: Both Boothfile and Dockerfile exist. Using Boothfile.\n") + } + return compileBoothfile(ctx, boothfilePath) + } + + // Fall back to Dockerfile dockerfile := filepath.Join(ctx.Code(), ".booth", "Dockerfile") if isFile(dockerfile) { return dockerfile @@ -102,6 +118,85 @@ func normalizeDockerFile(ctx appctx.AppContext) string { return "" } +// compileBoothfile compiles a Boothfile to a Dockerfile. +// If --emit-dockerfile is set, it prints the Dockerfile and exits. +// Returns the path to the generated Dockerfile. +func compileBoothfile(ctx appctx.AppContext, boothfilePath string) string { + if !ctx.SilenceBuild() { + fmt.Fprintf(os.Stderr, "Info: compiling Boothfile '%s'...\n", boothfilePath) + } + + // Read Boothfile + content, err := os.ReadFile(boothfilePath) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to read Boothfile '%s': %v\n", boothfilePath, err) + os.Exit(1) + } + + // Parse + parser := boothfile.NewParser() + if ctx.Strict() { + parser = boothfile.NewStrictParser() + } + parseResult := parser.ParseString(string(content)) + + // Check for parse errors + if parseResult.HasErrors() { + fmt.Fprintf(os.Stderr, "Error: Boothfile compilation failed:\n") + for _, e := range parseResult.Errors { + fmt.Fprintf(os.Stderr, " %s\n", e.Error()) + } + os.Exit(1) + } + + // Show warnings + if parseResult.HasWarnings() { + for _, w := range parseResult.Warnings { + fmt.Fprintf(os.Stderr, "Warning: %s\n", w.Error()) + } + } + + // Compile with custom setup detection + compilerOpts := boothfile.CompilerOptions{ + CustomSetupsDir: ".booth/setups", + CheckCustomSetupExists: func(name string) bool { + setupPath := filepath.Join(ctx.Code(), ".booth", "setups", name+"--setup.sh") + return isFile(setupPath) + }, + } + compiler := boothfile.NewCompilerWithOptions(compilerOpts) + compileResult := compiler.Compile(parseResult) + + // Check for compile errors + if compileResult.HasErrors() { + fmt.Fprintf(os.Stderr, "Error: Boothfile compilation failed:\n") + for _, e := range compileResult.Errors { + fmt.Fprintf(os.Stderr, " %s\n", e.Error()) + } + os.Exit(1) + } + + // If --emit-dockerfile, print and exit + if ctx.EmitDockerfile() { + fmt.Print(compileResult.Dockerfile) + os.Exit(0) + } + + // Write to a temporary file in .booth/ + generatedPath := filepath.Join(ctx.Code(), ".booth", ".Dockerfile.generated") + err = os.WriteFile(generatedPath, []byte(compileResult.Dockerfile), 0644) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to write generated Dockerfile: %v\n", err) + os.Exit(1) + } + + if ctx.Verbose() { + fmt.Fprintf(os.Stderr, "Generated Dockerfile: %s\n", generatedPath) + } + + return generatedPath +} + // buildLocalImage builds a local Docker image. func buildLocalImage(ctx appctx.AppContext) { if !ctx.SilenceBuild() { diff --git a/cli/src/pkg/booth/init/initialize_app_context.go b/cli/src/pkg/booth/init/initialize_app_context.go index 73344d77..2f6d62ae 100644 --- a/cli/src/pkg/booth/init/initialize_app_context.go +++ b/cli/src/pkg/booth/init/initialize_app_context.go @@ -251,6 +251,22 @@ func parseArgs(args ilist.List[string], cfg *appctx.AppConfig) error { cfg.Dockerfile = v i += 2 + case "--boothfile": + v, err := needValue(args, i, arg) + if err != nil { + return err + } + cfg.Boothfile = v + i += 2 + + case "--emit-dockerfile": + cfg.EmitDockerfile = true + i++ + + case "--strict": + cfg.Strict = true + i++ + // Build case "--build-arg": v, err := needValue(args, i, arg) From 44e80aa29cee4225f04d385eec3c98e779158f71 Mon Sep 17 00:00:00 2001 From: NawaMan Date: Wed, 4 Feb 2026 04:11:00 -0500 Subject: [PATCH 04/11] Add comprehensive Boothfile integration tests Test full parse->compile pipeline with realistic scenarios: - Django/Python project Boothfile - Java/Maven project Boothfile - Node.js project Boothfile - Data science environment with scientific stack - Go microservices environment Test heredoc modes: - Verbatim mode for complex shell scripts - And-join mode (&&) for package installation - Semi-join mode (;) for optional commands Test edge cases: - Custom setup script detection - Error recovery and multiple error reporting - Empty Boothfile, comments-only Boothfile - Long command lines, special characters --- cli/src/pkg/boothfile/integration_test.go | 406 ++++++++++++++++++++++ 1 file changed, 406 insertions(+) create mode 100644 cli/src/pkg/boothfile/integration_test.go diff --git a/cli/src/pkg/boothfile/integration_test.go b/cli/src/pkg/boothfile/integration_test.go new file mode 100644 index 00000000..22eb79d2 --- /dev/null +++ b/cli/src/pkg/boothfile/integration_test.go @@ -0,0 +1,406 @@ +// Copyright 2025-2026 : Nawa Manusitthipol +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. + +package boothfile + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestIntegration_FullPipeline tests the full parse → compile pipeline. +func TestIntegration_FullPipeline(t *testing.T) { + t.Run("realistic Django project Boothfile", func(t *testing.T) { + content := `# syntax=codingbooth/boothfile:1 + +# Django development environment + +# System dependencies +run apt-get update && apt-get install -y libpq-dev + +# Python setup +setup python 3.12 + +# Python packages +install pip django djangorestframework psycopg2-binary gunicorn + +# Project configuration +copy ./requirements.txt /tmp/requirements.txt +env DJANGO_SETTINGS_MODULE=myproject.settings +env DEBUG=true + +# Expose Django port +expose 8000 +` + result := CompileString(content) + + require.False(t, result.HasErrors(), "errors: %v", result.Errors) + + df := result.Dockerfile + + // Check prologue + assert.Contains(t, df, "# syntax=docker/dockerfile:1") + assert.Contains(t, df, "FROM nawaman/codingbooth:${BOOTH_VARIANT_TAG}-${BOOTH_VERSION_TAG}") + assert.Contains(t, df, "WORKDIR /opt/codingbooth/setups") + + // Check commands in order + lines := strings.Split(df, "\n") + var commands []string + for _, line := range lines { + line = strings.TrimSpace(line) + if line != "" && !strings.HasPrefix(line, "#") { + commands = append(commands, line) + } + } + + // Verify order and content + foundAptGet := false + foundPython := false + foundPip := false + foundCopy := false + foundEnv := false + foundExpose := false + + for i, cmd := range commands { + if strings.Contains(cmd, "apt-get update") { + foundAptGet = true + } + if strings.Contains(cmd, "python--setup.sh 3.12") { + foundPython = true + assert.True(t, foundAptGet, "apt-get should come before python setup") + } + if strings.Contains(cmd, "pip--install.sh django") { + foundPip = true + assert.True(t, foundPython, "python setup should come before pip install") + } + if strings.Contains(cmd, "COPY ./requirements.txt") { + foundCopy = true + } + if strings.Contains(cmd, "ENV DJANGO_SETTINGS_MODULE") { + foundEnv = true + } + if strings.Contains(cmd, "EXPOSE 8000") { + foundExpose = true + } + _ = i + } + + assert.True(t, foundAptGet, "should have apt-get command") + assert.True(t, foundPython, "should have python setup") + assert.True(t, foundPip, "should have pip install") + assert.True(t, foundCopy, "should have copy command") + assert.True(t, foundEnv, "should have env command") + assert.True(t, foundExpose, "should have expose command") + }) + + t.Run("Java/Maven project Boothfile", func(t *testing.T) { + content := `# syntax=codingbooth/boothfile:1 + +# Java development environment +arg JAVA_VERSION=21 + +setup jdk ${JAVA_VERSION} temurin +setup mvn 3.9.6 + +copy pom.xml /tmp/pom.xml +env MAVEN_OPTS=-Xmx1024m +` + result := CompileString(content) + + require.False(t, result.HasErrors()) + + df := result.Dockerfile + + assert.Contains(t, df, "ARG JAVA_VERSION=21") + assert.Contains(t, df, "RUN jdk--setup.sh ${JAVA_VERSION} temurin") + assert.Contains(t, df, "RUN mvn--setup.sh 3.9.6") + assert.Contains(t, df, "COPY pom.xml /tmp/pom.xml") + assert.Contains(t, df, "ENV MAVEN_OPTS=-Xmx1024m") + }) + + t.Run("Node.js project Boothfile", func(t *testing.T) { + content := `# syntax=codingbooth/boothfile:1 + +# Node.js development environment +setup nodejs 20 + +install npm express lodash typescript + +copy package.json /app/package.json +workdir /app +` + result := CompileString(content) + + require.False(t, result.HasErrors()) + + df := result.Dockerfile + assert.Contains(t, df, "RUN nodejs--setup.sh 20") + assert.Contains(t, df, "RUN npm--install.sh express lodash typescript") + assert.Contains(t, df, "COPY package.json /app/package.json") + assert.Contains(t, df, "WORKDIR /app") + }) +} + +// TestIntegration_HeredocModes tests all heredoc modes in realistic scenarios. +func TestIntegration_HeredocModes(t *testing.T) { + t.Run("verbatim mode for complex script", func(t *testing.T) { + content := `# syntax=codingbooth/boothfile:1 + +run <