caddy/caddyconfig/caddyfile/formatter_test.go

1122 lines
28 KiB
Go

// Copyright 2015 Matthew Holt and The Caddy Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package caddyfile
import (
"bytes"
"os"
"reflect"
"strings"
"testing"
)
func TestMain(m *testing.M) {
// Pin an empty environment so replaceEnvVars is deterministic for fuzzing.
os.Clearenv()
os.Exit(m.Run())
}
func TestFormatter(t *testing.T) {
for i, tc := range []struct {
description string
input string
expect string
}{
{
description: "very simple",
input: `abc def
g hi jkl
mn`,
expect: `abc def
g hi jkl
mn`,
},
{
description: "basic indentation, line breaks, and nesting",
input: ` a
b
c {
d
}
e { f
}
g {
h {
i
}
}
j { k {
l
}
}
m {
n { o
}
p { q r
s }
}
{
{ t
u
v
w
}
}`,
expect: `a
b
c {
d
}
e {
f
}
g {
h {
i
}
}
j {
k {
l
}
}
m {
n {
o
}
p {
q r
s
}
}
{
{
t
u
v
w
}
}`,
},
{
description: "block spacing",
input: `a{
b
}
c{ d
}`,
expect: `a{
b
}
c{ d
}`,
},
{
// sanctioned divergence: invalid glued braces left literal (design decision)
description: "advanced spacing",
input: `abc {
def
}ghi{
jkl mno
pqr}`,
expect: `abc {
def
}ghi{
jkl mno
pqr}`,
},
{
description: "env var placeholders",
input: `{$A}
b {
{$C}
}
d { {$E}
}
{ {$F}
}
`,
expect: `{$A}
b {
{$C}
}
d {
{$E}
}
{
{$F}
}`,
},
{
description: "env var placeholders with port",
input: `:{$PORT}`,
expect: `:{$PORT}`,
},
{
description: "comments",
input: `#a "\n"
#b {
c
}
d {
e#f
# g
}
h { # i
}`,
expect: `#a "\n"
#b {
c
}
d {
e#f
# g
}
h { # i
}`,
},
{
description: "quotes and escaping",
input: `"a \"b\" "#c
d
e {
"f"
}
g { "h"
}
i {
"foo
bar"
}
j {
"\"k\" l m"
}`,
expect: `"a \"b\" "#c
d
e {
"f"
}
g {
"h"
}
i {
"foo
bar"
}
j {
"\"k\" l m"
}`,
},
{
description: "bad nesting (too many open)",
input: `a
{
{
}`,
expect: `a {
{
}
`,
},
{
// sanctioned divergence: invalid glued braces left literal (design decision)
description: "bad nesting (too many close)",
input: `a
{
{
}}}`,
expect: `a {
{
}}}
`,
},
{
description: "json",
input: `foo
bar "{\"key\":34}"
`,
expect: `foo
bar "{\"key\":34}"`,
},
{
description: "escaping after spaces",
input: `foo \"literal\"`,
expect: `foo \"literal\"`,
},
{
description: "simple placeholders as standalone tokens",
input: `foo {bar}`,
expect: `foo {bar}`,
},
{
description: "simple placeholders within tokens",
input: `foo{bar} foo{bar}baz`,
expect: `foo{bar} foo{bar}baz`,
},
{
// sanctioned divergence: invalid glued braces left literal (design decision)
description: "placeholders and malformed braces",
input: `foo{bar} foo{ bar}baz`,
expect: `foo{bar} foo{ bar}baz`,
},
{
description: "hash within string is not a comment",
input: `redir / /some/#/path`,
expect: `redir / /some/#/path`,
},
{
description: "brace does not fold into comment above",
input: `# comment
{
foo
}`,
expect: `# comment
{
foo
}`,
},
{
description: "matthewpi/vscode-caddyfile-support#13",
input: `{
email {$ACMEEMAIL}
#debug
}
block {
}
`,
expect: `{
email {$ACMEEMAIL}
#debug
}
block {
}
`,
},
{
description: "matthewpi/vscode-caddyfile-support#13 - bad formatting",
input: `{
email {$ACMEEMAIL}
#debug
}
block {
}
`,
expect: `{
email {$ACMEEMAIL}
#debug
}
block {
}
`,
},
{
description: "keep heredoc as-is",
input: `block {
heredoc <<HEREDOC
Here's more than one space Here's more than one space
HEREDOC
}
`,
expect: `block {
heredoc <<HEREDOC
Here's more than one space Here's more than one space
HEREDOC
}
`,
},
{
description: "Mixing heredoc with regular part",
input: `block {
heredoc <<HEREDOC
Here's more than one space Here's more than one space
HEREDOC
respond "More than one space will be eaten" 200
}
block2 {
heredoc <<HEREDOC
Here's more than one space Here's more than one space
HEREDOC
respond "More than one space will be eaten" 200
}
`,
expect: `block {
heredoc <<HEREDOC
Here's more than one space Here's more than one space
HEREDOC
respond "More than one space will be eaten" 200
}
block2 {
heredoc <<HEREDOC
Here's more than one space Here's more than one space
HEREDOC
respond "More than one space will be eaten" 200
}
`,
},
{
description: "Heredoc as regular token",
input: `block {
heredoc <<HEREDOC "More than one space will be eaten"
}
`,
expect: `block {
heredoc <<HEREDOC "More than one space will be eaten"
}
`,
},
{
description: "Escape heredoc",
input: `block {
heredoc \<<HEREDOC
respond "More than one space will be eaten" 200
}
`,
expect: `block {
heredoc \<<HEREDOC
respond "More than one space will be eaten" 200
}
`,
},
{
description: "Preserve braces wrapped by backquotes",
input: "block {respond `All braces should remain: {{now | date \"2006\"}}`}",
expect: "block {respond `All braces should remain: {{now | date \"2006\"}}`}",
},
{
description: "Preserve braces wrapped by quotes",
input: "block {respond \"All braces should remain: {{now | date `2006`}}\"}",
expect: "block {respond \"All braces should remain: {{now | date `2006`}}\"}",
},
{
description: "Preserve quoted brace arguments",
input: "block {\n\trespond \"{\"\n\trespond \"}\"\n}",
expect: "block {\n\trespond \"{\"\n\trespond \"}\"\n}",
},
{
description: "Preserve quoted backticks and backticked quotes",
input: "block { respond \"`\" } block { respond `\"`}",
expect: "block {\n\trespond \"`\"\n}\n\nblock {\n\trespond `\"`\n}",
},
{
description: "No trailing space on line before env variable",
input: `{
a
{$ENV_VAR}
}
`,
expect: `{
a
{$ENV_VAR}
}
`,
},
{
description: "issue #7425: multiline backticked string indentation",
input: `https://localhost:8953 {
respond ` + "`" + `Here are some random numbers:
{{randNumeric 16}}
Hope this helps.` + "`" + `
}`,
expect: "https://localhost:8953 {\n\trespond `Here are some random numbers:\n\n{{randNumeric 16}}\n\nHope this helps.`\n}",
},
{
description: "imports before global options block keep standalone brace",
input: `import ./conf.d/matcher_my_subnet.caddy
import ./conf.d/matcher_not_my_subnet.caddy
{
order crowdsec first
order appsec after crowdsec
}`,
expect: `import ./conf.d/matcher_my_subnet.caddy
import ./conf.d/matcher_not_my_subnet.caddy
{
order crowdsec first
order appsec after crowdsec
}`,
},
} {
// the formatter should output a trailing newline,
// even if the tests aren't written to expect that
if !strings.HasSuffix(tc.expect, "\n") {
tc.expect += "\n"
}
actual := Format([]byte(tc.input))
if string(actual) != tc.expect {
t.Errorf("\n[TEST %d: %s]\n====== EXPECTED ======\n%s\n====== ACTUAL ======\n%s^^^^^^^^^^^^^^^^^^^^^",
i, tc.description, string(tc.expect), string(actual))
}
}
}
func TestFormatImportStandaloneBrace(t *testing.T) {
// immediately after a top-level import line: keep the brace standalone
in1 := "import a.caddy\nimport b.caddy\n{\n\torder x first\n}\n"
if got := string(Format([]byte(in1))); got != in1 {
t.Errorf("standalone brace after import not preserved:\n got %q\nwant %q", got, in1)
}
// An intervening blank line must not turn the global options block into an
// import block mapping.
in2 := "import a.caddy\n\n{\n\torder x first\n}\n"
want2 := in2
if got := string(Format([]byte(in2))); got != want2 {
t.Errorf("blank-line case:\n got %q\nwant %q", got, want2)
}
}
func TestFormatDeepNestingCapsIndent(t *testing.T) {
// Brace matching remains unbounded, but rendered indentation is capped to
// keep linear-size malicious input from producing quadratic output.
var b strings.Builder
const depth = 1000
for range depth {
b.WriteString("l {\n")
}
b.WriteString("x\n")
for range depth {
b.WriteString("}\n")
}
out := string(Format([]byte(b.String())))
if !strings.Contains(out, "\n"+strings.Repeat("\t", maxIndent)+"x\n") {
t.Errorf("expected %d-tab indent for deepest token", maxIndent)
}
if len(out) > b.Len()+(2*depth+1)*maxIndent {
t.Fatalf("formatted output grew unexpectedly: input=%d output=%d", b.Len(), len(out))
}
}
func TestFormatPreservesLiteralBraceArguments(t *testing.T) {
for _, tc := range []struct {
in string
want string
}{
{"localhost\nrespond foo{\n", "localhost\nrespond foo{\n"},
{"localhost\nrespond {} 200\n", "localhost\nrespond {} 200\n"},
} {
before, err := Tokenize([]byte(tc.in), "")
if err != nil {
t.Fatalf("input %q: %v", tc.in, err)
}
out := Format([]byte(tc.in))
if string(out) != tc.want {
t.Errorf("Format(%q) = %q, want %q", tc.in, out, tc.want)
}
after, err := Tokenize(out, "")
if err != nil {
t.Fatalf("formatted input %q no longer parses: %v\nout=%q", tc.in, err, out)
}
if !sameTokenTexts(before, after) {
t.Errorf("formatting changed parser tokens for %q:\nbefore=%v\nafter=%v\nout=%q", tc.in, before, after, out)
}
}
}
func TestFormatEscapedDelimitersDoesNotDisableFormatting(t *testing.T) {
in := "localhost {\nrespond foo\\\"bar\nrespond foo\\`bar\n}\n"
want := "localhost {\n\trespond foo\\\"bar\n\trespond foo\\`bar\n}\n"
if got := string(Format([]byte(in))); got != want {
t.Errorf("got %q, want %q", got, want)
}
}
func TestFinalizeHeredocLargeBody(t *testing.T) {
line := strings.Repeat("x", 1024)
var val strings.Builder
for range 1024 {
val.WriteByte('\t')
val.WriteString(line)
val.WriteByte('\n')
}
val.WriteString("\tEND")
got, err := new(lexer).finalizeHeredoc([]rune(val.String()), "END")
if err != nil {
t.Fatal(err)
}
wantLen := 1024*(len(line)+1) - 1
if len(string(got)) != wantLen {
t.Fatalf("large heredoc length = %d, want %d", len(string(got)), wantLen)
}
}
func TestWrapUnbracedSiteRejectsLeadingImport(t *testing.T) {
in := "import common.caddy\nlocalhost\nrespond 200\n"
want := string(Format([]byte(in)))
if got := string(FormatWithOptions([]byte(in), FormatOptions{WrapUnbracedSite: true})); got != want {
t.Errorf("leading import was wrapped:\n got %q\nwant %q", got, want)
}
}
func TestWrapUnbracedSiteAfterNestedDirective(t *testing.T) {
in := "localhost\nroute {\nrespond ok\n}\nfile_server\n"
want := "localhost {\n\troute {\n\t\trespond ok\n\t}\n\tfile_server\n}\n"
if got := string(FormatWithOptions([]byte(in), FormatOptions{WrapUnbracedSite: true})); got != want {
t.Errorf("got %q, want %q", got, want)
}
}
func TestFormatAngleNotQuirked(t *testing.T) {
if got := string(Format([]byte("foo < bar"))); got != "foo < bar\n" {
t.Errorf("got %q, want %q", got, "foo < bar\n")
}
}
func TestFormatEmptyBlocksExpand(t *testing.T) {
cases := []struct{ in, want string }{
{"route {}", "route {}\n"},
{"route { }", "route {\n}\n"},
{"a { b {} }", "a {\n\tb {}\n}\n"},
}
for _, c := range cases {
if got := string(Format([]byte(c.in))); got != c.want {
t.Errorf("in %q: got %q, want %q", c.in, got, c.want)
}
}
}
func TestFormatTokenAfterCloseBraceBreaks(t *testing.T) {
in := "a {\n\tb {\n\t\tc\n\t} d\n}"
want := "a {\n\tb {\n\t\tc\n\t}\n\td\n}\n"
if got := string(Format([]byte(in))); got != want {
t.Errorf("got %q, want %q", got, want)
}
}
func TestFormatCommentsOnBraceLines(t *testing.T) {
cases := []struct{ in, want string }{
{"site {\n\tfoo\n} # after close\n", "site {\n\tfoo\n} # after close\n"},
{"site { # note\n\tfoo\n}\n", "site { # note\n\tfoo\n}\n"},
{"site # note\n{\n\tfoo\n}\n", "site { # note\n\tfoo\n}\n"},
}
for _, c := range cases {
if got := string(Format([]byte(c.in))); got != c.want {
t.Errorf("in %q:\n got %q\nwant %q", c.in, got, c.want)
}
}
}
func TestFormatBlankLineCapAfterComment(t *testing.T) {
got := string(Format([]byte("foo # inline\n\n\nbar\n")))
want := "foo # inline\n\nbar\n"
if got != want {
t.Errorf("got %q, want %q", got, want)
}
}
func TestFormatContinuationHangingIndent(t *testing.T) {
in := "route {\n\treverse_proxy \\\n\ta \\\n\tb\n}\n"
want := "route {\n\treverse_proxy \\\n\t\ta \\\n\t\tb\n}\n"
if got := string(Format([]byte(in))); got != want {
t.Errorf("got %q, want %q", got, want)
}
}
func TestFormatStrayCloseBraceKeepsTokens(t *testing.T) {
for _, in := range []string{"0 }", "a }"} {
once := Format([]byte(in))
twice := Format(once)
if !bytes.Equal(once, twice) {
t.Errorf("not idempotent for %q:\n once=%q\ntwice=%q", in, once, twice)
}
a, err1 := Parse("Caddyfile", []byte(in))
if err1 != nil {
t.Fatalf("input %q did not parse: %v", in, err1)
}
b, err2 := Parse("Caddyfile", once)
if err2 != nil {
t.Fatalf("formatted %q did not parse: %v", in, err2)
}
if !sameStructure(a, b) {
t.Errorf("stray close brace merged tokens for %q: got %q", in, once)
}
}
}
func TestFormatIncompleteHeredocIdempotent(t *testing.T) {
for _, in := range []string{"0 <<0", "x <<E"} {
once := Format([]byte(in))
twice := Format(once)
if !bytes.Equal(once, twice) {
t.Errorf("not idempotent for %q:\n once=%q\ntwice=%q", in, once, twice)
}
}
}
// formatTokenText returns the format-mode non-comment token-text sequence for
// input, or nil and false on a lex error. It is the basis of the semantic-
// preservation invariant: Format must reproduce this exact sequence. Comment
// tokens are omitted because the formatter intentionally normalizes comment
// whitespace (e.g. trimming a trailing space, "# " -> "#"), which is not a
// semantic change.
func formatTokenText(in []byte) ([]string, bool) {
toks, err := Lex(in, "", LexOptions{Comments: true, Raw: true})
if err != nil {
return nil, false
}
var texts []string
for _, t := range toks {
if !t.isComment {
texts = append(texts, t.Text)
}
}
return texts, true
}
// formatAndParseLexersDisagree reports whether the format-mode lexer and the
// parse-path lexer produce different token-text sequences for the input (the
// parse baseline is newline-terminated to match Format's mandatory trailing
// newline, and comments — which the parse path discards — are ignored on the
// format side). When the two lexers already disagree before any formatting, the
// input is degenerate or structurally ambiguous — an unterminated or lone
// quote/backtick, an escaped quote swallowing to EOF, a "{}" or a "{"/"}" glued
// to a literal word that only one path splits — and has no well-defined
// formatting. Such inputs are a sanctioned divergence excluded from the
// invariant. Inputs on which both lexers agree, including the stray-close-brace
// bug's "0 }" and standalone braces in argument position, stay in scope.
func formatAndParseLexersDisagree(in []byte) bool {
fmtToks, err := Lex(in, "", LexOptions{Comments: true, Raw: true})
if err != nil {
return true
}
var fmtText []string
for _, t := range fmtToks {
if t.isComment {
continue
}
fmtText = append(fmtText, t.Text)
// A non-quoted token whose verbatim source ends in whitespace, contains an
// escaped quote, or whose text carries a newline results from an escaped
// quote ("\"") swallowing trailing whitespace to EOF. The format-mode and
// parse-path lexers can agree on such a token, but Format emits the source
// verbatim and then trims trailing whitespace, changing the token on
// re-lex. Treat these degenerate escaped-quote tokens as a disagreement.
if t.wasQuoted == 0 {
r := t.Raw()
if n := len(r); n > 0 && (r[n-1] == ' ' || r[n-1] == '\t' || r[n-1] == '\v' || r[n-1] == '\f' || r[n-1] == '\r' || r[n-1] == '\n') {
return true
}
if strings.Contains(r, `\"`) || strings.Contains(r, "\\`") {
return true
}
if strings.ContainsRune(t.Text, '\n') {
return true
}
}
}
base := in
if n := len(base); n == 0 || base[n-1] != '\n' {
base = append(append([]byte{}, base...), '\n')
}
parseToks, err := Tokenize(base, "")
if err != nil {
return true
}
parseText := make([]string, len(parseToks))
for i, t := range parseToks {
parseText[i] = t.Text
}
return !reflect.DeepEqual(fmtText, parseText)
}
// hasHeredocOpenerShapedToken reports whether the format-mode lexer produces a
// token whose text begins with "<<" (a heredoc opener shape). Such a token is a
// literal word only because no newline follows it (or a separating space breaks
// the heredoc), but Format's mandatory trailing newline can turn it into a
// heredoc opener, so the formatted output re-lexes to a different token stream.
// This degenerate opener is a sanctioned divergence excluded from the invariant;
// valid heredocs (whose token text does not begin with "<<") stay in scope. It
// is detected by token text, so a "<<" split by a stripped carriage return
// ("<\r<") is caught too.
func hasHeredocOpenerShapedToken(in []byte) bool {
toks, err := Lex(in, "", LexOptions{Comments: true, Raw: true})
if err != nil {
return true
}
for _, t := range toks {
if t.wasQuoted == 0 && !t.isComment && strings.HasPrefix(t.Text, "<<") {
return true
}
}
return false
}
// TestFormatDoesNotMutateInput guards the fallback paths, which trim the input
// and append the mandatory newline. bytes.TrimSpace returns a subslice sharing
// the caller's backing array, so appending to it without copying writes into
// the caller's buffer. os.ReadFile returns a slice with spare capacity, so this
// corrupted the input that "caddy fmt --diff" compares its output against.
func TestFormatDoesNotMutateInput(t *testing.T) {
cases := []string{
"foo \"unterminated \n\n", // unterminated quote + trailing whitespace
"a b\\ \n\n", // dangling escape + trailing whitespace
" site {\n\tfoo\n} \n\n", // ordinary input with surrounding whitespace
}
for _, in := range cases {
// Spare capacity, as os.ReadFile and pooled buffers provide.
buf := append(make([]byte, 0, len(in)+8), in...)
out := Format(buf)
if string(buf) != in {
t.Errorf("Format mutated its input:\n in %q\n after %q", in, string(buf))
}
if len(out) > 0 && len(buf) > 0 && &out[0] == &buf[0] {
t.Errorf("Format output aliases its input for %q", in)
}
}
}
func FuzzFormatIdempotent(f *testing.F) {
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) {
once := Format(in)
twice := Format(once)
if !bytes.Equal(once, twice) {
t.Errorf("not idempotent:\n once=%q\ntwice=%q", once, twice)
}
})
}
func FuzzFormatNoPanic(f *testing.F) {
f.Add([]byte("\x00\x00"))
f.Add([]byte("`unterminated"))
f.Add([]byte("x <<E\nno end marker"))
f.Fuzz(func(t *testing.T, in []byte) {
_ = Format(in) // must not panic
})
}
func FuzzFormatSemanticPreserve(f *testing.F) {
for _, s := range []string{"site {\n\troot * /srv\n\tfile_server\n}\n"} {
f.Add([]byte(s))
}
f.Fuzz(func(t *testing.T, in []byte) {
// The semantic-preservation invariant: formatting only changes structural
// whitespace, so the format-mode token-text sequence of Format(in) must
// equal that of the input. This is stronger and more robust than comparing
// parse structures, which the parse path's lenient/quirky grouping of
// ambiguous brace layouts (e.g. standalone braces in argument position)
// would spuriously flag even though every token is preserved.
//
// One class is genuinely out of scope: a heredoc-opener-shaped token
// ("<<MARKER") that stays a literal word only because no newline follows
// it. Format's mandatory trailing newline turns it into a real heredoc
// opener, so the output legitimately re-lexes to a different token stream.
// Valid heredocs (whose token text does not begin with "<<") stay in scope.
// Also out of scope: any input on which the format-mode and parse-path
// lexers already disagree (unterminated/lone quotes, escaped quotes at EOF,
// "{}" or a brace glued to a literal word) — these are degenerate or
// structurally ambiguous and have no well-defined formatting.
if hasHeredocOpenerShapedToken(in) || formatAndParseLexersDisagree(in) {
return
}
// Compare the format-mode token text of a newline-terminated baseline
// against that of Format's output. Format always ends its output in a
// single newline (Invariant); normalizing the baseline the same way keeps
// the comparison about token content, not the mandatory trailing newline.
base := in
if n := len(base); n == 0 || base[n-1] != '\n' {
base = append(append([]byte{}, base...), '\n')
}
before, ok := formatTokenText(base)
if !ok {
return // input does not lex; Format falls back and there is nothing to preserve
}
out := Format(in)
after, ok := formatTokenText(out)
if !ok {
t.Fatalf("formatted output no longer lexes\ninput=%q\nout=%q", in, out)
}
if !reflect.DeepEqual(before, after) {
t.Errorf("Format changed the token stream\ninput=%q\nout=%q\nbefore=%q\nafter=%q", in, out, before, after)
}
})
}
// TestFormatFuzzerAngles covers tricky / pathological inputs that a fuzzer would
// exercise. Every case asserts idempotency (Format(Format(x)) == Format(x)) and
// that Format does not panic. The subset of clearly-idiomatic inputs also asserts
// an exact expected output.
func TestFormatFuzzerAngles(t *testing.T) {
type tc struct {
name string
input string
exactExpect string // non-empty → assert this exact output in addition to idempotency
}
cases := []tc{
// ---- clearly-idiomatic: assert exact output ----
{
// Empty input: Format always emits a single trailing newline.
name: "empty input",
input: "",
exactExpect: "\n",
},
{
// Whitespace-only input collapses to the same single newline.
name: "whitespace-only input",
input: " \n\t\n",
exactExpect: "\n",
},
{
// A bare env-var placeholder is a literal token and must be preserved.
name: "env var {$X}",
input: "{$X}",
exactExpect: "{$X}\n",
},
{
// Default-value env var.
name: "env var {$X:def}",
input: "{$X:def}",
exactExpect: "{$X:def}\n",
},
{
// Minimal env var with only the sigil.
name: "env var {$}",
input: "{$}",
exactExpect: "{$}\n",
},
{
// Backtick token as the first token inside a block must indent correctly.
name: "backtick as first token after {",
input: "a {\n\t`foo`\n}\n",
exactExpect: "a {\n\t`foo`\n}\n",
},
{
// { } expands to a proper block.
name: "{ } empty block",
input: "route { }",
exactExpect: "route {\n}\n",
},
{
// Hash inside a double-quoted string is not a comment.
name: "hash inside double-quoted string",
input: `foo "bar#baz" quux`,
exactExpect: "foo \"bar#baz\" quux\n",
},
{
// Hash inside a heredoc body is not a comment.
name: "hash inside heredoc body",
input: "x <<END\nfoo # not a comment\nEND\n",
exactExpect: "x <<END\nfoo # not a comment\nEND\n",
},
{
// Escaped heredoc opener (\<<) is a regular token, not a real heredoc.
name: "escaped heredoc \\<<",
input: "block {\n\theredoc \\<<HEREDOC\n\trespond hello 200\n}\n",
exactExpect: "block {\n\theredoc \\<<HEREDOC\n\trespond hello 200\n}\n",
},
{
// Heredoc whose marker appears as a substring of a body line (fooEND ≠ END).
name: "heredoc marker as substring of body line",
input: "x <<END\nfooEND\nEND\n",
exactExpect: "x <<END\nfooEND\nEND\n",
},
// ---- pathological: idempotency + no-panic only ----
{
// Unbalanced: one more opening brace than closing braces.
name: "unbalanced braces (too many open)",
input: "a {\n\tb {\n\t\tc\n\t}\n",
},
{
// Unbalanced: stray closing brace.
name: "unbalanced braces (too many close)",
input: "a }\n",
},
{
// CRLF line endings must not break idempotency.
name: "CRLF line endings",
input: "site {\r\n\tfoo\r\n}\r\n",
},
{
// Unterminated double-quoted string.
name: "unterminated double-quote",
input: "foo \"unterminated",
},
{
// Unterminated backtick string.
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",
input: "a b\\",
},
{
// UTF-8 BOM at the start of the file; the BOM should not survive into
// the formatted output (it is part of the first token's raw source but
// the formatter strips leading/trailing whitespace).
name: "UTF-8 BOM prefix",
input: "\xef\xbb\xbfsite {\n\tfoo\n}\n",
},
{
// NUL byte embedded in a token.
name: "NUL byte in token",
input: "site {\nfoo\x00bar\n}\n",
},
{
// Arbitrary control bytes in a token.
name: "control bytes in token",
input: "a\x01b\x02c",
},
{
// Lone carriage-return (CR without LF). The lexer treats \r as part of
// the token raw text but strips it from the token's text field; the raw
// bytes are emitted verbatim by the formatter.
name: "lone CR (respond hello\\rworld)",
input: "respond hello\rworld",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
// No-panic: the call itself must complete.
once := Format([]byte(c.input))
// Idempotency: a second pass must reproduce the first pass exactly.
twice := Format(once)
if !bytes.Equal(once, twice) {
t.Errorf("not idempotent:\n once=%q\ntwice=%q", once, twice)
}
// Exact-output assertion for clearly-idiomatic cases.
if c.exactExpect != "" && string(once) != c.exactExpect {
t.Errorf("unexpected output:\n got %q\n want %q", string(once), c.exactExpect)
}
})
}
}
// TestFormattingDifferenceStableOnFormatted verifies that an already-formatted
// config produces no diff from FormattingDifference (i.e. the formatter and the
// diff checker agree that a well-formed file needs no changes).
func TestFormattingDifferenceStableOnFormatted(t *testing.T) {
in := []byte("site {\n\troot * /srv\n\tfile_server\n}\n")
formatted := Format(in)
if _, diff := FormattingDifference("Caddyfile", formatted); diff {
t.Error("FormattingDifference reported a diff on already-formatted input")
}
}
// sameStructure compares two parses by their per-segment token Text sequences.
func sameStructure(a, b []ServerBlock) bool {
seq := func(blocks []ServerBlock) []string {
var s []string
for _, blk := range blocks {
s = append(s, "K")
for _, k := range blk.Keys {
s = append(s, k.Text)
}
for _, seg := range blk.Segments {
s = append(s, "S")
for _, tk := range seg {
s = append(s, tk.Text)
}
}
}
return s
}
return reflect.DeepEqual(seq(a), seq(b))
}