16
17
import (
18
"bytes"
19
- "io"
20
- "slices"
19
"strings"
20
"unicode"
21
)
22
25
-// Format formats the input Caddyfile to a standard, nice-looking
26
-// appearance. It works by reading each rune of the input and taking
27
-// control over all the bracing and whitespace that is written; otherwise,
28
-// words, comments, placeholders, and escaped characters are all treated
29
-// literally and written as they appear in the input.
23
+const maxIndent = 10
24
+
25
+// Format formats the input Caddyfile to a standard, nice-looking appearance.
26
+// It tokenizes the input in format mode and renders the token stream, taking
27
+// control over bracing, indentation, and whitespace; token bodies (words,
28
+// comments, placeholders, quoted/backtick strings, heredocs, and escaped
29
+// characters) are emitted verbatim.
30
func Format(input []byte) []byte {
31
- input = bytes.TrimSpace(input)
31
+ return FormatWithOptions(input, FormatOptions{})
32
+}
33
33
- out := new(bytes.Buffer)
34
- rdr := bytes.NewReader(input)
34
+// FormatOptions configures optional formatting behavior.
35
+type FormatOptions struct {
36
+ // WrapUnbracedSite, when true, wraps a single unbraced site block in braces
37
+ // (Phase 3). Default false.
38
+ WrapUnbracedSite bool
39
+}
40
36
- type heredocState int
41
+// FormatWithOptions formats the input Caddyfile with the given options.
42
+func FormatWithOptions(input []byte, opts FormatOptions) []byte {
43
+ tokens, err := lexFormat(input)
44
+ if err != nil {
45
+ // On a lex error, fall back to the trimmed input with a trailing newline;
46
+ // Format never panics (Invariant 3).
47
+ return trimmedWithNewline(input)
48
+ }
49
+ // Some token shapes cannot be rendered without changing what they lex back
50
+ // to, which would break idempotency. When one is present, preserve the
51
+ // trimmed input verbatim instead of re-rendering; this is trivially
52
+ // idempotent since a second pass hits the same condition.
53
+ //
54
+ // The mandatory trailing newline is the subtle case: if appending it changes
55
+ // how the input tokenizes (an unterminated or escaped quote, or a dangling
56
+ // escape, that swallows the newline into a token), then no rendering is a
57
+ // fixed point. Detect that here so Format falls back to the trimmed input;
58
+ // trimming removes the trailing newline again, so the fallback is stable.
59
+ if hasUnformattableToken(tokens) || trailingNewlineChangesTokens(input) {
60
+ return trimmedWithNewline(input)
61
+ }
62
+ parseTokens, parseErr := Tokenize(input, "")
63
+ wrapped := false
64
+ if opts.WrapUnbracedSite {
65
+ // Decide whether to wrap on the NORMALIZED token stream, not the raw one.
66
+ // isSingleUnbracedSite uses source-line gaps (a blank-line gap => ambiguous
67
+ // multi-site => don't wrap), but formatTokens removes blank lines and folds
68
+ // dangling braces, so a shape that reads as "not a single site" on the raw
69
+ // stream can read as a single unbraced site on a second pass over the
70
+ // already-formatted output. Deciding on the normalized stream makes the
71
+ // decision stable across passes (idempotent). Once wrapped, the output is
72
+ // braced, so isSingleUnbracedSite is false on any re-run and there is no
73
+ // double-wrap.
74
+ normalized := formatTokens(tokens)
75
+ normToks, nerr := lexFormat(normalized)
76
+ if nerr == nil && isSingleUnbracedSite(normToks) {
77
+ // Prefer the original token positions when they are already
78
+ // unambiguous. Normalization may insert top-level block spacing that
79
+ // would become an unwanted interior blank line after wrapping.
80
+ if isSingleUnbracedSite(tokens) {
81
+ tokens = wrapUnbracedSite(tokens)
82
+ } else {
83
+ tokens = wrapUnbracedSite(normToks)
84
+ }
85
+ wrapped = true
86
+ }
87
+ }
88
+ out := formatTokens(tokens)
89
+
90
+ // Backstop: formatting must be idempotent and must not change the token
91
+ // stream (only structural whitespace). Re-lex the output and require both
92
+ // that its token texts match the input's and that re-rendering it reproduces
93
+ // it exactly (a fixed point). If some pathological escape/whitespace
94
+ // combination the checks above did not anticipate slips through — changing a
95
+ // token or leaving the vertical spacing unstable — fall back to preserving
96
+ // the trimmed input verbatim. Well-formed input is a fixed point on the first
97
+ // pass, so this only affects degenerate input. A lex error on the output is
98
+ // likewise treated as a divergence.
99
+ reToks, rerr := lexFormat(out)
100
+ outParseTokens, outParseErr := Tokenize(out, "")
101
+ parseChanged := !wrapped && parseErr == nil && (outParseErr != nil || !sameTokenTexts(parseTokens, outParseTokens))
102
+ if rerr != nil || parseChanged || !sameTokenTexts(tokens, reToks) ||
103
+ !bytes.Equal(out, formatTokens(reToks)) {
104
+ return trimmedWithNewline(input)
105
+ }
106
+ return out
107
+}
108
38
- const (
39
- heredocClosed heredocState = 0
40
- heredocOpening heredocState = 1
41
- heredocOpened heredocState = 2
42
- )
109
+// lexFormat captures comments and raw source while only normalizing braces the
110
+// parser already recognized as standalone structural tokens. Public Lex options
111
+// intentionally do not change ordinary token boundaries.
112
+func lexFormat(input []byte) ([]Token, error) {
113
+ tokens, err := Lex(input, "", LexOptions{Comments: true, Raw: true})
114
+ if err != nil {
115
+ return nil, err
116
+ }
117
+ for i := range tokens {
118
+ if isOpenCurlyBrace(tokens[i]) || isCloseCurlyBrace(tokens[i]) {
119
+ tokens[i].raw = tokens[i].Text
120
+ tokens[i].continuation = false
121
+ }
122
+ }
123
+ return tokens, nil
124
+}
125
126
+// isSingleUnbracedSite reports whether the format-mode token stream is exactly
127
+// one top-level server block written WITHOUT wrapping braces — i.e. a single
128
+// site whose address line is followed directly by directives, with no outer
129
+// "{"..."}" around them. This is the shape the (default-off, Phase 3)
130
+// braced-wrap feature may wrap in braces, so detection must be conservative:
131
+// anything ambiguous, already-braced, or multi-site returns false.
132
+//
133
+// The analysis is purely structural (no filesystem, no import expansion, no env
134
+// substitution). It mirrors the parser's address-boundary logic
135
+// (parser.addresses, isSnippet, isNamedRoute in parse.go): the ADDRESS LIST is
136
+// the run of leading non-comment tokens up to the first newline, honoring
137
+// trailing-comma continuation across lines (a token ending in "," means another
138
+// address follows, possibly on the next line).
139
+//
140
+// It returns true iff:
141
+// - the address list is non-empty, and
142
+// - the first address token is not a snippet "(...)" or named route "&(...)",
143
+// and
144
+// - no structural "{" appears on the address line (which would make it an
145
+// already-braced site block, or, with no address before it, a
146
+// global-options block), and
147
+// - top-level directive groups are not separated by an ambiguous blank line.
148
+//
149
+// Interior directive braces (nesting >= 1, e.g. "reverse_proxy {"..."}") are
150
+// allowed and ignored. Comments are ignored for the boundary decision.
151
+func isSingleUnbracedSite(tokens []Token) bool {
152
+ // Collect the leading address list: non-comment tokens up to the first
153
+ // newline, extending across newlines while the previous address token ends
154
+ // in a trailing comma (comma-continuation).
155
var (
45
- last rune // the last character that was written to the result
46
-
47
- space = true // whether current/previous character was whitespace (beginning of input counts as space)
48
- beginningOfLine = true // whether we are at beginning of line
49
-
50
- openBrace bool // whether current word/token is or started with open curly brace
51
- openBraceWritten bool // if openBrace, whether that brace was written or not
52
- openBraceSpace bool // whether there was a non-newline space before open brace
53
-
54
- newLines int // count of newlines consumed
55
-
56
- comment bool // whether we're in a comment
57
- quotes string // encountered quotes ('', '`', '"', '"`', '`"')
58
- escaped bool // whether current char is escaped
59
-
60
- heredoc heredocState // whether we're in a heredoc
61
- heredocEscaped bool // whether heredoc is escaped
62
- heredocMarker []rune
63
- heredocClosingMarker []rune
64
-
65
- nesting int // indentation level
66
-
67
- currentToken strings.Builder
68
- currentLineFirstToken string
69
- previousLineWasTopLevelImport bool
70
- openBraceOwnLine bool
156
+ firstAddrIdx = -1 // index of the first address token
157
+ addrLineEndIdx = -1 // index of the last address token
158
+ expectAnother bool // previous address token ended in ","
159
)
72
-
73
- finishToken := func() {
74
- if currentToken.Len() == 0 {
75
- return
160
+ for i := range len(tokens) {
161
+ tk := tokens[i]
162
+ if tk.isComment {
163
+ continue
164
}
77
- if currentLineFirstToken == "" {
78
- currentLineFirstToken = currentToken.String()
165
+ // A structural "{" on the address line ends the address list AND means
166
+ // the block is braced (a real site block) or, with no address seen yet,
167
+ // a global-options block. Either way, not a single unbraced site.
168
+ if isOpenCurlyBrace(tk) {
169
+ return false
170
+ }
171
+ // If this token begins a new line and we're not waiting for another
172
+ // comma-continued address, the address list ended on the previous token.
173
+ if firstAddrIdx >= 0 && !expectAnother && fmtNextOnNewLine(tokens[i-1], tk) {
174
+ break
175
}
80
- currentToken.Reset()
81
- }
176
83
- finishLine := func() {
84
- finishToken()
85
- if currentLineFirstToken != "" {
86
- previousLineWasTopLevelImport = nesting == 0 && currentLineFirstToken == "import"
87
- } else if !openBrace || !openBraceOwnLine || openBraceWritten {
88
- previousLineWasTopLevelImport = false
177
+ // This token is part of the address list.
178
+ if firstAddrIdx < 0 {
179
+ firstAddrIdx = i
180
}
90
- currentLineFirstToken = ""
181
+ addrLineEndIdx = i
182
+ expectAnother = strings.HasSuffix(tk.Text, ",")
183
}
184
93
- write := func(ch rune) {
94
- out.WriteRune(ch)
95
- last = ch
185
+ // Zero address tokens: not a site.
186
+ if firstAddrIdx < 0 {
187
+ return false
188
}
97
-
98
- indent := func() {
99
- for tabs := nesting; tabs > 0; tabs-- {
100
- write('\t')
101
- }
189
+ if tokens[firstAddrIdx].Text == "import" {
190
+ return false
191
}
192
104
- nextLine := func() {
105
- write('\n')
106
- beginningOfLine = true
193
+ // Snippet "(...)" or named route "&(...)" first address: not a site.
194
+ if firstText := tokens[firstAddrIdx].Text; strings.HasPrefix(firstText, "&(") || strings.HasPrefix(firstText, "(") {
195
+ return false
196
}
197
109
- flushOpenBrace := func() {
110
- if nesting == 0 && last == '}' {
111
- nextLine()
112
- nextLine()
198
+ // Walk the remaining tokens (the block body) tracking structural nesting.
199
+ // A blank line (a gap of >= 2 source lines) between two top-level token
200
+ // groups is the ambiguous multi-site shape and disqualifies wrapping. The
201
+ // renderer itself inserts such a gap after an interior directive block, so a
202
+ // gap immediately following its closing brace is allowed.
203
+ nesting := 0
204
+ prevIdx := -1
205
+ for j := addrLineEndIdx + 1; j < len(tokens); j++ {
206
+ tk := tokens[j]
207
+ if tk.isComment {
208
+ continue
209
}
114
-
115
- openBrace = false
116
- if openBraceOwnLine && previousLineWasTopLevelImport {
117
- if last != '\n' {
118
- nextLine()
210
+ if nesting == 0 && prevIdx >= 0 {
211
+ // Blank line at top level between directive groups: ambiguous
212
+ // multi-site. A gap of two or more lines is a blank line.
213
+ prev := tokens[prevIdx]
214
+ if !isCloseCurlyBrace(prev) && tk.Line-(prev.Line+fmtNumLineBreaks(prev)) >= 2 {
215
+ return false
216
}
120
- indent()
121
- } else if beginningOfLine {
122
- indent()
123
- } else if !openBraceSpace || !unicode.IsSpace(last) {
124
- write(' ')
125
- }
126
- write('{')
127
- openBraceWritten = true
128
- openBraceOwnLine = false
129
- nextLine()
130
- newLines = 0
131
- // prevent infinite nesting from ridiculous inputs (issue #4169)
132
- if nesting < 10 {
217
+ }
218
+ switch {
219
+ case isOpenCurlyBrace(tk):
220
nesting++
221
+ case isCloseCurlyBrace(tk) && nesting > 0:
222
+ nesting--
223
}
224
+ prevIdx = j
225
}
226
137
- for {
138
- ch, _, err := rdr.ReadRune()
139
- if err != nil {
140
- if err == io.EOF {
141
- break
142
- }
143
- panic(err)
144
- }
145
- // detect whether we have the start of a heredoc
146
- if quotes == "" && (heredoc == heredocClosed && !heredocEscaped) &&
147
- space && last == '<' && ch == '<' {
148
- write(ch)
149
- heredoc = heredocOpening
150
- space = false
227
+ return true
228
+}
229
+
230
+// wrapUnbracedSite transforms a single unbraced site block into a braced one by
231
+// inserting a synthetic structural "{" after the address list and appending a
232
+// synthetic structural "}" at the end; the renderer (formatTokens) then indents
233
+// the body and emits the braces. If the token stream is not a single unbraced
234
+// site (per isSingleUnbracedSite), the tokens are returned unchanged.
235
+//
236
+// The synthetic braces are structural (Text/Raw "{"/"}", wasQuoted 0). The
237
+// opening brace's Line is the last address token's Line so it folds onto the
238
+// address line; the closing brace's Line follows the last body token so it lands
239
+// on its own line. Because the wrapped output is a genuinely braced site,
240
+// isSingleUnbracedSite returns false for it, so re-formatting is a fixed point
241
+// and does not re-wrap (idempotency), and the FormatWithOptions backstop — which
242
+// requires the output to re-lex to the same tokens and re-render identically —
243
+// does not fire.
244
+func wrapUnbracedSite(tokens []Token) []Token {
245
+ if !isSingleUnbracedSite(tokens) {
246
+ return tokens
247
+ }
248
+
249
+ // Recompute the address-list boundary the same way isSingleUnbracedSite does:
250
+ // the leading run of non-comment tokens up to the first newline, extended
251
+ // across newlines while the previous address token ends in a trailing comma.
252
+ addrLineEndIdx := -1
253
+ firstAddrIdx := -1
254
+ expectAnother := false
255
+ for i := range len(tokens) {
256
+ tk := tokens[i]
257
+ if tk.isComment {
258
continue
259
}
153
-
154
- if heredoc == heredocOpening {
155
- if ch == '\n' {
156
- if len(heredocMarker) > 0 && heredocMarkerRegexp.MatchString(string(heredocMarker)) {
157
- heredoc = heredocOpened
158
- } else {
159
- heredocMarker = nil
160
- heredoc = heredocClosed
161
- nextLine()
162
- continue
163
- }
164
- write(ch)
165
- continue
166
- }
167
- if unicode.IsSpace(ch) {
168
- // a space means it's just a regular token and not a heredoc
169
- heredocMarker = nil
170
- heredoc = heredocClosed
171
- } else {
172
- heredocMarker = append(heredocMarker, ch)
173
- write(ch)
174
- continue
175
- }
260
+ if firstAddrIdx >= 0 && !expectAnother && fmtNextOnNewLine(tokens[i-1], tk) {
261
+ break
262
}
177
- // if we're in a heredoc, all characters are read&write as-is
178
- if heredoc == heredocOpened {
179
- heredocClosingMarker = append(heredocClosingMarker, ch)
180
- if len(heredocClosingMarker) > len(heredocMarker)+1 { // We assert that the heredocClosingMarker is followed by a unicode.Space
181
- heredocClosingMarker = heredocClosingMarker[1:]
182
- }
183
- // check if we're done
184
- if unicode.IsSpace(ch) && slices.Equal(heredocClosingMarker[:len(heredocClosingMarker)-1], heredocMarker) {
185
- heredocMarker = nil
186
- heredocClosingMarker = nil
187
- heredoc = heredocClosed
188
- } else {
189
- write(ch)
190
- if ch == '\n' {
191
- heredocClosingMarker = heredocClosingMarker[:0]
192
- }
193
- continue
194
- }
263
+ if firstAddrIdx < 0 {
264
+ firstAddrIdx = i
265
}
266
+ addrLineEndIdx = i
267
+ expectAnother = strings.HasSuffix(tk.Text, ",")
268
+ }
269
197
- if last == '<' && space {
198
- space = false
199
- }
270
+ openBrace := Token{
271
+ File: tokens[addrLineEndIdx].File,
272
+ Line: tokens[addrLineEndIdx].Line,
273
+ Text: "{",
274
+ raw: "{",
275
+ }
276
+ closeBrace := Token{
277
+ File: tokens[len(tokens)-1].File,
278
+ Line: tokens[len(tokens)-1].Line + fmtNumLineBreaks(tokens[len(tokens)-1]) + 1,
279
+ Text: "}",
280
+ raw: "}",
281
+ }
282
201
- if comment {
202
- if ch == '\n' {
203
- comment = false
204
- space = true
205
- nextLine()
206
- continue
207
- } else {
208
- write(ch)
209
- continue
210
- }
211
- }
283
+ wrapped := make([]Token, 0, len(tokens)+2)
284
+ wrapped = append(wrapped, tokens[:addrLineEndIdx+1]...)
285
+ wrapped = append(wrapped, openBrace)
286
+ wrapped = append(wrapped, tokens[addrLineEndIdx+1:]...)
287
+ wrapped = append(wrapped, closeBrace)
288
+ return wrapped
289
+}
290
213
- if !escaped && ch == '\\' {
214
- if space {
215
- write(' ')
216
- space = false
217
- }
218
- write(ch)
219
- escaped = true
220
- continue
291
+// trimmedWithNewline trims surrounding whitespace (except significant leading
292
+// whitespace before a backslash) and adds a single trailing newline, in a
293
+// freshly allocated buffer. The copy matters:
294
+// bytes.TrimSpace returns a subslice that shares input's backing array, so
295
+// appending to it can write into the caller's buffer (os.ReadFile hands back a
296
+// slice with spare capacity), corrupting the input a caller still holds — for
297
+// example the "caddy fmt --diff" comparison of input against output.
298
+func trimmedWithNewline(input []byte) []byte {
299
+ trimmed := bytes.TrimSpace(input)
300
+ // Leading whitespace can make a backslash a line continuation. Removing
301
+ // it would change the token stream and make this fallback unstable.
302
+ if len(trimmed) > 0 && trimmed[0] == '\\' {
303
+ trimmed = bytes.TrimRightFunc(input, unicode.IsSpace)
304
+ }
305
+ out := make([]byte, 0, len(trimmed)+1)
306
+ out = append(out, trimmed...)
307
+ return append(out, '\n')
308
+}
309
+
310
+// trailingNewlineChangesTokens reports whether appending a newline to input
311
+// changes its format-mode token-text sequence. This is true for inputs whose
312
+// final token would swallow Format's mandatory trailing newline — an
313
+// unterminated or escaped quote/backtick, or a dangling escape at EOF — for
314
+// which no rendering can be a fixed point. Inputs already ending in a newline,
315
+// and well-formed inputs, are unaffected.
316
+func trailingNewlineChangesTokens(input []byte) bool {
317
+ if n := len(input); n > 0 && input[n-1] == '\n' {
318
+ return false
319
+ }
320
+ a, erra := lexFormat(input)
321
+ withNL := make([]byte, len(input)+1)
322
+ copy(withNL, input)
323
+ withNL[len(input)] = '\n'
324
+ b, errb := lexFormat(withNL)
325
+ if erra != nil || errb != nil {
326
+ return erra != errb
327
+ }
328
+ return !sameTokenTexts(a, b)
329
+}
330
+
331
+// sameTokenTexts reports whether two token streams have the same sequence of
332
+// token texts, ignoring comment tokens (comment whitespace is intentionally
333
+// normalized by the formatter).
334
+func sameTokenTexts(a, b []Token) bool {
335
+ ai, bi := 0, 0
336
+ for {
337
+ for ai < len(a) && a[ai].isComment {
338
+ ai++
339
}
340
+ for bi < len(b) && b[bi].isComment {
341
+ bi++
342
+ }
343
+ if ai >= len(a) || bi >= len(b) {
344
+ return ai >= len(a) && bi >= len(b)
345
+ }
346
+ if a[ai].Text != b[bi].Text {
347
+ return false
348
+ }
349
+ ai++
350
+ bi++
351
+ }
352
+}
353
223
- if escaped {
224
- if ch == '<' {
225
- heredocEscaped = true
226
- }
227
- write(ch)
228
- escaped = false
354
+// hasUnformattableToken reports whether the token stream contains a token whose
355
+// rendering cannot be made idempotent, so Format falls back to preserving the
356
+// input verbatim. Two shapes qualify:
357
+//
358
+// - An ambiguous heredoc opener: a regular (unquoted, non-comment) token whose
359
+// verbatim source is a valid heredoc opener shape ("<<MARKER") and which is
360
+// the last token on its source line. It stays literal in the source only
361
+// because trailing space separated it from the newline; the formatter drops
362
+// that space, so the rendered output would re-lex as a real heredoc opener
363
+// and glue in the following line. (An escaped opener "\<<MARKER" re-lexes as
364
+// a regular token and is unaffected, so only a bare "<<MARKER" qualifies.)
365
+// - An unterminated quote/backtick: a token whose verbatim source begins with
366
+// a quote or backtick but which is not marked Quoted (the closing delimiter
367
+// was never seen). It swallowed the rest of the input; rendering it and
368
+// appending the mandatory trailing newline changes what it lexes back to.
369
+// - A leading line continuation: the first token carries continuation framing
370
+// ("\"+newline) even though there is no preceding token to continue from.
371
+// Its anchoring Line sits a line before its rendered content, so the
372
+// vertical-spacing math for the following token is unstable across passes.
373
+func hasUnformattableToken(tokens []Token) bool {
374
+ for i, tk := range tokens {
375
+ if tk.isComment {
376
continue
377
}
231
-
232
- if ch == '`' {
233
- switch quotes {
234
- case "\"`":
235
- quotes = "\""
236
- case "`":
237
- quotes = ""
238
- case "\"":
239
- quotes = "\"`"
240
- default:
241
- quotes = "`"
242
- }
378
+ // Leading dangling line continuation on the first non-comment token.
379
+ if !anyNonCommentBefore(tokens, i) && tk.continuation {
380
+ return true
381
}
244
-
245
- if quotes == "\"" {
246
- if ch == '"' {
247
- quotes = ""
248
- }
249
- write(ch)
382
+ // Messy line continuation: continuation framing whose backslash is not
383
+ // immediately followed by the newline (e.g. "\"+vertical-tab+newline). The
384
+ // formatter's continuation normalization cannot cleanly strip the stray
385
+ // whitespace, so the token text shifts on re-lex.
386
+ if tk.continuation && hasMessyContinuationFraming(tk.Raw()) {
387
+ return true
388
+ }
389
+ raw := tk.Raw()
390
+ // A non-quoted token whose verbatim source ends in a newline swallowed it:
391
+ // an unterminated quote/backtick, or an escaped quote ("\\"") that ran to
392
+ // end-of-input. A newline otherwise terminates a token, so no well-formed
393
+ // token ends in one. Format emits the source verbatim and then trims
394
+ // trailing whitespace, so such a token changes on re-lex and no rendering
395
+ // is a fixed point. This has to be decided from the token itself rather
396
+ // than from whether appending a newline changes the stream: once the
397
+ // mandatory trailing newline has been appended it sits inside the
398
+ // swallowing token, and appending another one no longer changes anything,
399
+ // so only the token shape still reveals the problem. A lone "\\r" is kept
400
+ // in raw but does not terminate a token, so it is deliberately not
401
+ // treated as swallowed; heredoc tokens carry wasQuoted == '<'.
402
+ if tk.wasQuoted == 0 && strings.HasSuffix(raw, "\n") {
403
+ return true
404
+ }
405
+ if tk.wasQuoted != 0 {
406
continue
407
}
252
-
253
- if ch == '"' {
254
- switch quotes {
255
- case "":
256
- if space {
257
- quotes = "\""
258
- }
259
- case "`\"":
260
- quotes = "`"
261
- case "\"`":
262
- quotes = ""
263
- }
408
+ // Ambiguous heredoc opener.
409
+ if !strings.HasPrefix(raw, "<<") || len(raw) <= 2 {
410
+ continue
411
}
265
-
266
- if strings.Contains(quotes, "`") {
267
- if ch == '`' && space && !beginningOfLine {
268
- write(' ')
269
- }
270
- write(ch)
271
- space = false
412
+ marker := raw[2:]
413
+ if !heredocMarkerRegexp.MatchString(marker) {
414
continue
415
}
416
+ if i+1 >= len(tokens) || fmtNextOnNewLine(tk, tokens[i+1]) {
417
+ return true
418
+ }
419
+ }
420
+ return false
421
+}
422
275
- if unicode.IsSpace(ch) {
276
- finishToken()
277
- space = true
278
- heredocEscaped = false
279
- if ch == '\n' {
280
- finishLine()
281
- newLines++
282
- }
283
- continue
423
+// anyNonCommentBefore reports whether any token before index i is not a comment.
424
+func anyNonCommentBefore(tokens []Token, i int) bool {
425
+ for j := range i {
426
+ if !tokens[j].isComment {
427
+ return true
428
}
285
- spacePrior := space
286
- space = false
429
+ }
430
+ return false
431
+}
432
288
- //////////////////////////////////////////////////////////
289
- // I find it helpful to think of the formatting loop in two
290
- // main sections; by the time we reach this point, we
291
- // know we are in a "regular" part of the file: we know
292
- // the character is not a space, not in a literal segment
293
- // like a comment or quoted, it's not escaped, etc.
294
- //////////////////////////////////////////////////////////
433
+// hasMessyContinuationFraming reports whether raw begins (after any leading
434
+// whitespace) with a line-continuation backslash that is not immediately
435
+// followed by a newline — i.e. there is stray whitespace between the "\" and the
436
+// newline (e.g. "\"+vertical-tab+newline). A clean continuation has "\" directly
437
+// before the newline.
438
+func hasMessyContinuationFraming(raw string) bool {
439
+ s := strings.TrimLeft(raw, " \t\r\n\v\f")
440
+ if !strings.HasPrefix(s, `\`) {
441
+ return false
442
+ }
443
+ rest := s[1:]
444
+ return len(rest) > 0 && rest[0] != '\n' && rest[0] != '\r'
445
+}
446
296
- if ch == '#' {
297
- comment = true
447
+// fmtNumLineBreaks returns how many physical line breaks a token spans, for use
448
+// in format-mode vertical-spacing decisions. For quoted tokens (double-quote,
449
+// backtick, and heredoc) it counts the newlines in the verbatim source (Raw)
450
+// rather than deriving the count from the processed Text. Raw is the exact
451
+// physical span, so this correctly handles an empty-bodied heredoc ("<<E\nE",
452
+// which Token.NumLineBreaks over-counts by always adding two) and a quoted token
453
+// whose Raw carries a leading line-continuation newline (e.g. "\\\n`...`", which
454
+// Token.NumLineBreaks under-counts); both otherwise place a following token on
455
+// the wrong line and break idempotency. Unquoted tokens keep Token.NumLineBreaks
456
+// (their Raw may carry "\"+newline continuation framing whose newline belongs to
457
+// the token's anchoring Line, not to its span).
458
+func fmtNumLineBreaks(t Token) int {
459
+ if t.wasQuoted != 0 {
460
+ return strings.Count(t.Raw(), "\n")
461
+ }
462
+ return t.NumLineBreaks()
463
+}
464
+
465
+// fmtNextOnNewLine mirrors isNextOnNewLine but uses fmtNumLineBreaks so that
466
+// empty-bodied heredocs are measured by their true line span in format mode.
467
+func fmtNextOnNewLine(t1, t2 Token) bool {
468
+ if t1.File != t2.File {
469
+ return true
470
+ }
471
+ if len(t1.imports) != len(t2.imports) {
472
+ return true
473
+ }
474
+ for i, im := range t1.imports {
475
+ if im != t2.imports[i] {
476
+ return true
477
}
478
+ }
479
+ return t1.Line+fmtNumLineBreaks(t1) < t2.Line
480
+}
481
300
- if openBrace && spacePrior && !openBraceWritten {
301
- flushOpenBrace()
482
+// formatTokens renders a format-mode token stream (as produced by
483
+// Lex(..., LexOptions{Comments: true, Raw: true})) into a normalized
484
+// Caddyfile. It controls all indentation and structural whitespace; token
485
+// bodies are emitted verbatim via Token.Raw().
486
+func formatTokens(tokens []Token) []byte {
487
+ var out bytes.Buffer
488
+
489
+ nesting := 0 // current indentation level
490
+ atLineStart := true // whether the cursor is at the start of a fresh line
491
+ wrote := false // whether any non-whitespace has been written yet
492
+ prevTopClose := false
493
+
494
+ writeIndent := func() {
495
+ for range min(nesting, maxIndent) {
496
+ out.WriteByte('\t')
497
}
498
+ }
499
304
- switch {
305
- case ch == '{':
306
- finishToken()
307
- if openBrace && !openBraceWritten {
308
- flushOpenBrace()
309
- }
310
- openBrace = true
311
- openBraceSpace = spacePrior && !beginningOfLine
312
- openBraceOwnLine = newLines > 0
313
- if openBraceSpace && newLines == 0 {
314
- write(' ')
500
+ newline := func() {
501
+ out.WriteByte('\n')
502
+ atLineStart = true
503
+ }
504
+
505
+ // prevWasComment tracks whether the last non-whitespace token emitted was a
506
+ // comment. A structural "{" must never fold onto a line whose last token is
507
+ // a comment, since "#" runs to end of line and would comment the brace out.
508
+ prevWasComment := false
509
+ // prevWasClose tracks whether the last non-whitespace token emitted was a
510
+ // structural "}". A token that shared the source line with that "}" (other
511
+ // than another "}" or a trailing comment) must break to its own indented
512
+ // line rather than folding after the brace (I4).
513
+ prevWasClose := false
514
+ // foldedOpenAt, when >= 0, is the index of a structural "{" that was already
515
+ // emitted early (before a trailing comment) as part of the address/comment/
516
+ // brace fold; when the loop reaches that index it is skipped.
517
+ foldedOpenAt := -1
518
+ // lineStartsWithImport tracks whether the first token of the current
519
+ // nesting-zero source line is "import". A standalone "{" that opens a
520
+ // global-options block immediately after such a line must stay on its own
521
+ // line instead of folding onto the import line (the "import" directive and a
522
+ // global-options "{" are unrelated statements). See the import exception in
523
+ // the isOpen case below.
524
+ lineStartsWithImport := false
525
+
526
+ for i := range tokens {
527
+ if i == foldedOpenAt {
528
+ // This "{" was already emitted ahead of a trailing comment.
529
+ continue
530
+ }
531
+ tk := tokens[i]
532
+ // Track whether the first token of the current nesting-zero source line
533
+ // is "import". A token starts a new source line when it is the very
534
+ // first token or is on a later line than its predecessor. This is
535
+ // computed against the pre-token nesting so that a top-level "import"
536
+ // line is recognized before any brace on a following line dedents.
537
+ // Braces are not directives, so a line beginning with "{" or "}" leaves
538
+ // the tracker untouched: the isOpen import exception below needs to see
539
+ // the state established by the preceding import line.
540
+ if !isCloseCurlyBrace(tk) && !isOpenCurlyBrace(tk) {
541
+ startsNewLine := !wrote || fmtNextOnNewLine(tokens[i-1], tk)
542
+ if startsNewLine {
543
+ lineStartsWithImport = nesting == 0 && !tk.isComment && tk.Text == "import"
544
}
316
- openBraceWritten = false
317
- if quotes == "`" {
318
- write('{')
319
- openBraceWritten = true
320
- openBraceOwnLine = false
321
- continue
545
+ }
546
+ isOpen := isOpenCurlyBrace(tk)
547
+ // A structural close brace only closes a block when one is open. When
548
+ // nesting is zero there is nothing to close (e.g. a "}" glued after a
549
+ // backtick/quoted argument), so it is treated as an inline literal and
550
+ // emitted verbatim at its source position.
551
+ isClose := isCloseCurlyBrace(tk) && nesting > 0
552
+
553
+ // A trailing comment shares its source line with the previous token
554
+ // (e.g. after "}", after "{", or after a directive) and renders inline
555
+ // on that line; a standalone comment sits alone on its own line.
556
+ trailingComment := tk.isComment && wrote && !fmtNextOnNewLine(tokens[i-1], tk)
557
+
558
+ // breaks is the number of newlines to emit before this token:
559
+ // 0 = stay on the current line, 1 = new line, 2 = one blank line.
560
+ breaks := 0
561
+ if wrote {
562
+ prev := tokens[i-1]
563
+ if fmtNextOnNewLine(prev, tk) {
564
+ breaks = 1
565
+ // A gap of two or more lines yields at most one blank line.
566
+ if tk.Line-(prev.Line+fmtNumLineBreaks(prev)) >= 2 {
567
+ breaks = 2
568
+ }
569
}
323
- continue
570
+ }
571
325
- case ch == '}' && (spacePrior || !openBrace):
326
- finishToken()
327
- if quotes == "`" {
328
- write('}')
329
- continue
572
+ switch {
573
+ case isOpen:
574
+ // An opening brace attaches to the current line: never break to a
575
+ // new line before it (it joins the preceding token with a space).
576
+ // EXCEPTION 1: a standalone comment line must keep the following "{"
577
+ // on its own line, otherwise the "#" would comment the brace out.
578
+ // EXCEPTION 2: a standalone "{" (one that begins a new source line)
579
+ // that immediately follows a top-level "import" line, with no
580
+ // intervening blank line, opens an unrelated global-options block
581
+ // and must stay on its own line rather than folding onto the import.
582
+ standaloneBrace := wrote && fmtNextOnNewLine(tokens[i-1], tk)
583
+ if prevWasComment {
584
+ breaks = 1
585
+ } else if lineStartsWithImport && nesting == 0 && standaloneBrace {
586
+ breaks = max(breaks, 1)
587
+ } else {
588
+ breaks = 0
589
}
331
- if last != '\n' {
332
- nextLine()
590
+ case isClose:
591
+ // A closing brace always goes on its own line, and dedents.
592
+ if wrote && !atLineStart {
593
+ breaks = 1
594
}
595
if nesting > 0 {
596
nesting--
597
}
337
- indent()
338
- write('}')
339
- newLines = 0
340
- continue
598
+ case trailingComment:
599
+ // A trailing comment stays on the current line.
600
+ breaks = 0
601
}
602
343
- if newLines > 2 {
344
- newLines = 2
603
+ // A token that shared the source line with a preceding structural "}"
604
+ // (other than another "}" or a trailing comment) breaks to its own
605
+ // indented line instead of folding after the brace (I4).
606
+ if prevWasClose && breaks == 0 && !isClose && !trailingComment {
607
+ breaks = 1
608
}
346
- for i := 0; i < newLines; i++ {
347
- nextLine()
609
+
610
+ // The content following an opening brace always begins on its own
611
+ // indented line, regardless of how the source was laid out. This
612
+ // applies to any token, including a nested opening brace. A trailing
613
+ // comment after "{" is the exception: it stays inline on the "{" line.
614
+ if wrote && isOpenCurlyBrace(tokens[i-1]) && breaks == 0 && !trailingComment {
615
+ breaks = 1
616
}
349
- newLines = 0
350
- if beginningOfLine {
351
- indent()
617
+
618
+ // A blank line always follows a top-level closing brace, unless this
619
+ // token is a comment trailing that brace on the same source line.
620
+ if prevTopClose && breaks < 2 && !trailingComment {
621
+ breaks = 2
622
}
353
- if nesting == 0 && last == '}' && beginningOfLine {
354
- nextLine()
355
- nextLine()
623
+ prevTopClose = false
624
+
625
+ // Address/comment/brace fold: when this is a trailing comment whose very
626
+ // next token is a structural "{" that opens this line's block, emit the
627
+ // "{" on the head line before the comment, so "addr # c"⏎"{" renders as
628
+ // "addr { # c". This must happen before any newline is emitted.
629
+ // It only applies when the comment trails the block's head line (a
630
+ // directive/address). If the comment trails an opening "{" (i.e. the
631
+ // previous token is itself a "{"), the following "{" opens a separate
632
+ // nested block on its own line and must NOT be folded up onto the comment
633
+ // line; doing so is non-idempotent (a second pass sees the two "{" already
634
+ // adjacent and breaks them apart again).
635
+ if trailingComment && i+1 < len(tokens) && isOpenCurlyBrace(tokens[i+1]) && breaks == 0 && !atLineStart &&
636
+ !isOpenCurlyBrace(tokens[i-1]) {
637
+ out.WriteByte(' ')
638
+ out.WriteString(tokens[i+1].Raw())
639
+ nesting++
640
+ foldedOpenAt = i + 1
641
}
642
358
- if !beginningOfLine && spacePrior {
359
- write(' ')
643
+ // Emit the vertical spacing.
644
+ for breaks > 0 {
645
+ newline()
646
+ breaks--
647
}
648
362
- if openBrace && !openBraceWritten {
363
- write('{')
364
- openBraceWritten = true
649
+ // Emit indentation at the start of a line.
650
+ if atLineStart {
651
+ writeIndent()
652
}
653
367
- if spacePrior && ch == '<' {
368
- space = true
654
+ // A line continuation renders as "\", a newline, then a hanging indent
655
+ // of nesting+1 tabs before the token (instead of a single space). The
656
+ // token's Raw() carries the source continuation framing ("\"+newline+
657
+ // indentation) as a prefix, so it is stripped and re-emitted normalized.
658
+ body := tk.Raw()
659
+ if tk.continuation && !atLineStart {
660
+ out.WriteString(" \\\n")
661
+ for range min(nesting+1, maxIndent) {
662
+ out.WriteByte('\t')
663
+ }
664
+ body = strings.TrimLeft(strings.TrimPrefix(body, "\\"), " \t\r\n")
665
+ atLineStart = false
666
+ } else if !atLineStart {
667
+ // Emit horizontal separation for same-line tokens: exactly one space
668
+ // separates two tokens that share a line (including an attaching "{").
669
+ // A structural close brace at nesting zero (an inline literal) stays
670
+ // glued to its predecessor when the source had no space. A comment
671
+ // glues to a preceding quoted/backtick/heredoc token only when no
672
+ // space separated them ("x"#c); otherwise it takes a leading space.
673
+ // precededBySpace is only reliable after a quoted/backtick/heredoc
674
+ // token (the delimiting space of an unquoted predecessor is consumed
675
+ // by its own scan), so gluing an inline close brace is allowed only
676
+ // when the previous token is quoted; otherwise the separator must be
677
+ // preserved so two distinct tokens (e.g. "0" then "}") do not merge.
678
+ inlineClose := isCloseCurlyBrace(tk) && !isClose && tokens[i-1].Quoted()
679
+ gluedComment := tk.isComment && tokens[i-1].Quoted() && !tk.precededBySpace
680
+ if (!inlineClose || tk.precededBySpace) && !gluedComment {
681
+ out.WriteByte(' ')
682
+ }
683
}
684
371
- currentToken.WriteRune(ch)
372
- write(ch)
373
-
374
- beginningOfLine = false
375
- }
376
-
377
- if openBrace && !openBraceWritten {
378
- flushOpenBrace()
685
+ // Emit the token body verbatim.
686
+ out.WriteString(body)
687
+ wrote = true
688
+ atLineStart = false
689
+ prevWasComment = tk.isComment
690
+ prevWasClose = isClose
691
+
692
+ // Structural bookkeeping after emitting. Vertical spacing after a
693
+ // brace is produced by the next token's break calculation, so we don't
694
+ // emit trailing newlines here.
695
+ if isOpen {
696
+ nesting++
697
+ } else if isClose && nesting == 0 {
698
+ prevTopClose = true
699
+ }
700
}
701
381
- // the Caddyfile does not need any leading or trailing spaces, but...
382
- trimmedResult := bytes.TrimSpace(out.Bytes())
383
-
384
- // ...Caddyfiles should, however, end with a newline because
385
- // newlines are significant to the syntax of the file
386
- return append(trimmedResult, '\n')
702
+ // Trim leading/trailing whitespace, then ensure exactly one trailing
703
+ // newline (newlines are significant to Caddyfile syntax).
704
+ trimmed := bytes.TrimSpace(out.Bytes())
705
+ return append(trimmed, '\n')
706
}