caddyfile: detect end-of-input-swallowing tokens from token shape

hasUnformattableToken documented an unterminated quote/backtick as a shape
that cannot be rendered idempotently, but never implemented the check. The
condition was only caught dynamically by trailingNewlineChangesTokens, which
returns early for input that already ends in a newline. That early return is
wrong for a token that swallowed the newline: once Format's mandatory trailing
newline sits inside an unterminated quote, appending another one no longer
changes the stream, so the second pass rendered what the first pass had
preserved verbatim.

    Format(`"""`)   => `"""`     (fallback, preserved)
    Format(`"""` + newline) => `"" "` (rendered, space inserted)

Decide it from the token instead: a non-quoted token whose verbatim source
ends in a newline swallowed it, since a newline otherwise terminates a token.
This also covers an escaped quote ("\"") running to end-of-input, which has
the same shape. A lone "\r" is kept in raw but does not terminate a token, so
it is deliberately excluded; heredoc tokens carry wasQuoted == '<'.

Master's rune-based formatter was idempotent on these inputs, so this restores
parity. Found by FuzzFormatIdempotent; the inputs are added as portable seeds
and table cases rather than testdata/fuzz files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
caddyfile-formatter-unification
Francis Lavoie 2026-09-16 09:39:11 -04:00
parent aa0fb5034b
commit 93ecef86a4
No known key found for this signature in database
2 changed files with 50 additions and 1 deletions

View File

@ -378,6 +378,21 @@ func hasUnformattableToken(tokens []Token) bool {
if tk.wasQuoted == 0 && endsInDanglingBackslash(raw) {
return true
}
// A non-quoted token whose verbatim source ends in a newline swallowed it:
// an unterminated quote/backtick, or an escaped quote ("\\"") that ran to
// end-of-input. A newline otherwise terminates a token, so no well-formed
// token ends in one. Format emits the source verbatim and then trims
// trailing whitespace, so such a token changes on re-lex and no rendering
// is a fixed point. This has to be decided from the token itself rather
// than from whether appending a newline changes the stream: once the
// mandatory trailing newline has been appended it sits inside the
// swallowing token, and appending another one no longer changes anything,
// so only the token shape still reveals the problem. A lone "\\r" is kept
// in raw but does not terminate a token, so it is deliberately not
// treated as swallowed; heredoc tokens carry wasQuoted == '<'.
if tk.wasQuoted == 0 && strings.HasSuffix(raw, "\n") {
return true
}
if tk.wasQuoted != 0 {
continue
}

View File

@ -804,7 +804,11 @@ func hasHeredocOpenerShapedToken(in []byte) bool {
}
func FuzzFormatIdempotent(f *testing.F) {
for _, s := range []string{"", " ", "a{\nb\n}", "site {\n\tfoo # c\n}\n", "x <<E\nhi\nE\n"} {
for _, s := range []string{
"", " ", "a{\nb\n}", "site {\n\tfoo # c\n}\n", "x <<E\nhi\nE\n",
// Tokens that swallow end-of-input, and a lone CR that does not.
"\"\"\"", "\"\"`", "``\\\"", "0 0\r",
} {
f.Add([]byte(s))
}
f.Fuzz(func(t *testing.T, in []byte) {
@ -980,6 +984,36 @@ func TestFormatFuzzerAngles(t *testing.T) {
name: "unterminated backtick",
input: "foo `unterminated",
},
{
// An empty quoted token followed by a lone opening quote. The first
// pass falls back and appends the mandatory newline; on the second
// pass that newline is swallowed by the still-unterminated quote, so
// the unterminated token must be detected from the token itself, not
// from whether appending a newline changes the stream.
name: "empty quoted token followed by lone quote",
input: "\"\"\"",
},
{
// Same shape with a backtick opening the unterminated token.
name: "empty quoted token followed by lone backtick",
input: "\"\"`",
},
{
// An escaped quote running to end-of-input after a closed backtick
// token. Like the cases above, the second pass must still recognize
// the swallowing token once the mandatory newline sits inside it.
name: "empty backtick token followed by escaped quote",
input: "``\\\"",
},
{
// A lone CR does not terminate a token, so it stays in the token's
// verbatim source without meaning the token swallowed end-of-input.
// Treating it as swallowed would suppress normal rendering here and
// leave the double space uncollapsed on the first pass only.
name: "double space with trailing lone CR",
input: "0 0\r",
exactExpect: "0 0\n",
},
{
// Trailing backslash (dangling escape — not a line continuation).
name: "trailing backslash",