caddyconfig: unify Caddyfile lexer/parser/formatter (token-based formatter)#7880
caddyconfig: unify Caddyfile lexer/parser/formatter (token-based formatter)#7880francislavoie wants to merge 26 commits into
Conversation
Design for unifying the Caddyfile lexer/parser and the standalone formatter by making the token pipeline drive formatting: raw-source-span capture, comment tokens, separator-kind metadata, follow-imports and (default-off) braced-wrap modes. Includes adversarial-review hardening and empirically-found intentional improvements over the legacy formatter. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Task-by-task TDD plan: Phase 1 (token model + formatter rewrite + fuzz invariants + legacy oracle), Phase 2 (follow-imports mode + caddy fmt --imports), Phase 3 (default-off braced-wrap). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the rune-by-rune state machine with a renderer that consumes the
format-mode token stream (Lex with Comments+Raw). Adds FormatWithOptions and
FormatOptions{WrapUnbracedSite}; Format keeps its signature. Comment
placement, continuations, empty-block expansion, and the import/}-glue rules
land in Tasks 9-11; the six dependent TestFormatter cases are temporarily
skipped with annotations.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fix two fuzz-found bugs plus several related idempotency issues in the
token-based Caddyfile formatter, and retire the legacy byte-scanner
formatter used as a test oracle.
Bug B (formatter.go): a stray "}" at nesting 0 was glued to an unquoted
predecessor because precededBySpace is unreliable after an unquoted token.
Only suppress the separating space for an inline close brace when the
previous token is quoted (same guard as comment gluing), so "0 }" no longer
merges into "0}".
Bug A (lexer.go): an empty-bodied heredoc hitting EOF with no closing marker
was silently dropped, breaking Format idempotency. Surface it as an
"incomplete heredoc" error ONLY in format mode (opts.Comments || opts.Raw);
Tokenize/Parse keep the exact prior silent-drop behavior. Format then falls
back to preserving the trimmed input.
Additional format-mode-only fixes surfaced by property fuzzing: correct
line-break accounting for empty-bodied heredocs and continuation-framed
quoted tokens; normalize the raw slice / clear continuation on split and
standalone structural braces; guard the address/comment/brace fold so a
next-line "{" is not folded onto a comment trailing an opening brace; and a
fallback + idempotency backstop in FormatWithOptions for degenerate tokens
(ambiguous heredoc openers, unterminated/escaped quotes, dangling escapes,
messy/leading line continuations). The parse path is unchanged.
Property fuzz targets (formatter_test.go): keep FuzzFormatIdempotent,
FuzzFormatNoPanic, and a redesigned FuzzFormatSemanticPreserve that asserts
format-mode token-text preservation (robust to the parser's lenient grouping
of ambiguous brace layouts). Add regression tests
TestFormatStrayCloseBraceKeepsTokens and TestFormatIncompleteHeredocIdempotent.
Remove FuzzFormatParity, containsParityExclusion, maxNesting and delete
formatter_legacy_test.go (legacyFormat oracle + TestLegacyOracleMatchesCurrent).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a --imports flag to 'caddy fmt' that calls caddyfile.FormatImports to format the root Caddyfile and all files it references via import directives. Rejects --imports with stdin (no source directory to resolve against). With --overwrite each file is written back; otherwise each file's output is printed to stdout with a '# <path>' header, with optional per-file diff via --diff. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…otency fix) FormatWithOptions wrapped before formatting, deciding on the raw token stream. isSingleUnbracedSite uses source-line gaps, but formatTokens removes blank lines and folds dangling braces, so a shape read as "not a single site" on pass 1 could read as a single unbraced site on pass 2 and get wrapped, breaking Format(Format(x)) == Format(x). Decide the wrap on the already-normalized stream instead: normalize, re-lex, and wrap only if isSingleUnbracedSite holds there. tokens becomes the wrapped stream so the backstop still compares like-for-like; once wrapped the output is braced, so no double-wrap on re-runs. Format (zero options) still never wraps. Adds a regression reproducer and FuzzWrapUnbracedSiteIdempotent. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Modernize three index loops to range-over-int and simplify a negated boolean per staticcheck QF1001. No behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Convert the remaining for-i index loops to range-over-int per golangci-lint modernize. No behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
mohammed90
left a comment
There was a problem hiding this comment.
Disclosure: This review was produced by an AI agent (GitHub Copilot, Claude Fable 5), operated and posted at the request of its human user, who has read and verified it. Findings were verified by building and running the code, not just reading it.
Verification performed
- ✅
go build ./...,go test -race -shortoncaddyconfig/...,cmd/...,caddytest/...— all pass - ✅
golangci-lint runon changed packages — 0 issues - ✅ All 4 formatter fuzzers (
FuzzFormatIdempotent,FuzzFormatNoPanic,FuzzFormatSemanticPreserve,FuzzWrapUnbracedSiteIdempotent) ran 20s each — no failures - ✅ Differential check: master vs. this branch produce byte-identical output on a realistic multi-site Caddyfile
- ✅ Parse path unaffected:
Tokenizedelegates toLexwith zero options; format-mode state is unreachable from the parser
Strengths
- Defensive design is excellent. The
FormatWithOptionsbackstop (re-lex the output, verify token texts match and the output is a fixed point, else fall back to the trimmed input) makes the idempotency and semantic-preservation invariants enforced at runtime, not just tested. Lex errors also fall back rather than panic. - The legacy formatter was vendored as a differential oracle during development, then retired — good methodology — and divergences (D1–D4) / improvements (I1–I4) are explicitly classified and individually tested.
resolveImportGlobextracted fromparser.doImportcorrectly preserves the #867/#2096/#5295 behaviors and is shared with import discovery.- Comments in the tricky parts (brace folding, continuation framing, heredoc-at-EOF) explain why, which this code badly needs.
Findings
1. --imports skips the FormattingDifference exit-code check (moderate). Plain caddy fmt returns ExitCodeFailedStartup when input is unformatted (commonly used as a CI gate). The new --imports branch in cmd/commandfuncs.go returns success unconditionally, so caddy fmt --diff --imports can't gate CI. Suggest applying the same check across all returned files.
2. Performance regression (~6.5×): 3.0 ms → 19.6 ms per Format of a 75 KB file (measured on this branch vs. master). Cause: format-mode lexing with raw capture, plus the backstop's second lex + second render. Absolute cost is fine for a CLI, but FormattingDifference runs Format on every Caddyfile adaptation. Not a blocker; a BenchmarkFormat would help track it.
3. Planning artifacts committed in-tree. docs/superpowers/plans/… and docs/superpowers/specs/… (~2,200 lines) look like AI-workflow planning docs. Caddy keeps documentation in the website repo; these should probably be dropped from the merge.
4. Minor behavior changes in doImport worth calling out in the PR description:
- Error strings changed casing/shape ("Failed to get absolute path…" → wrapped lowercase; the glob-limit error now goes through
p.WrapErr). - A glob matching only dotfiles previously proceeded silently; now the dotfile filter runs before the empty-match check, so it logs "No files matching import glob pattern". Arguably an improvement, but user-visible.
5. Small --imports nits: the diff path re-reads each file from disk (double read; races with concurrent edits) and hard-fails on read error, while FormatImports itself warns-and-skips — inconsistent failure policy. --overwrite also rewrites unchanged files (mtime churn).
Verdict
Solid, carefully engineered work. Finding 1 is the only one I'd consider merge-blocking; finding 3 needs a maintainer decision; the rest are nits/documentation.
|
here is the review from my agent: PR 7880 reviewI am an AI coding agent (OpenCode, GPT-5.6 Sol). I reviewed this change with targeted parser/formatter reproductions and CodeRabbit CLI assistance. The human posting this review should verify and understand every finding before submission. Reproduced findingsHigh: Format mode splits braces that are literal parser argumentsLocations:
This input parses successfully before formatting: localhost
respond foo{It formats to Likewise, this valid directive segment: localhost
respond {} 200formats to: localhost
respond {
}
200That changes one directive's arguments into a block followed by a new directive. The semantic backstop does not catch either case because it compares format-mode token streams rather than the normal parse-mode token stream. High: A blank line after
|
* caddyfile: fix formatter and import correctness issues * noot * noot * caddyfile: document parser-driven import discovery
What / why
Unifies the Caddyfile lexer/parser and the standalone formatter. The formatter was a separate rune-by-rune state machine (
formatter.go) that re-implemented heredoc/quote/backtick/escape/comment/brace handling independently of the lexer. This replaces it with a token-based formatter driven by the existing lexer, so there is a single source of truth for Caddyfile syntax.The lexer previously dropped whitespace and comments and transformed token text, so it couldn't drive formatting. It now has an opt-in format mode that captures what a formatter needs.
No breaking changes to the parse path
The parser/
Tokenize/Dispenserpath is byte-for-byte unchanged. All new token state is populated only in format mode. New public surface only:Lex(input, filename, LexOptions{Comments, Raw})—Tokenizedelegates to it with the zero valueToken.Raw(),Token.IsComment()FormatWithOptions(input, FormatOptions{...}), andFormatImports(filename, opts) ([]FormattedFile, error)Highlights
Formatrewrite: same signature/behavior; renders layout from the token stream instead of scanning runes. The old implementation was retained as a test-only differential oracle during development and deleted at the end.caddy fmt --importsformats the target Caddyfile plus every file reachable viaimport(recursive, cycle-safe, cross-file snippet aware, env-substituted globs). Rejected with-(stdin) since there's no base directory.} # end,site { # note); empty blocks canonicalize to the expanded form; the max-one-blank-line rule applies uniformly after comment lines; a token glued after}breaks to its own line instead of emitting a stray tab. Two latent correctness bugs are fixed for free (a lone\rno longer changes config meaning; a backtick token after{is no longer mangled).WrapUnbracedSite(aFormatOptionsflag, default off, not wired to the CLI): wraps a single unbraced site block in braces.<-eats-following-space quirk is dropped; the 10-level indentation cap is dropped.Testing
go build ./...,go vet, and the fullcaddyconfig/...+cmd/...suites pass.Assistance Disclosure
This change was implemented end-to-end with AI assistance. Claude Code (Anthropic, Opus 4.8) produced the design spec, the implementation plan, and the code/tests, executed task-by-task via a subagent-driven workflow with per-task and whole-branch code review. I (the human author) directed the effort: I set the goals and constraints, made the idiomatic-Caddyfile formatting decisions and signed off on each behavioral divergence from the old formatter, reviewed the spec/plan, and steered scope. Adversarial AI review and fuzzing were used to find defects (two real bugs were caught and fixed this way). I have vetted the contribution for correctness.
🤖 Generated with Claude Code