summaryrefslogtreecommitdiff
path: root/caddyconfig/caddyfile
diff options
context:
space:
mode:
Diffstat (limited to 'caddyconfig/caddyfile')
-rw-r--r--caddyconfig/caddyfile/adapter.go2
-rw-r--r--caddyconfig/caddyfile/dispenser.go90
-rw-r--r--caddyconfig/caddyfile/importargs.go153
-rw-r--r--caddyconfig/caddyfile/importgraph.go3
-rw-r--r--caddyconfig/caddyfile/lexer.go246
-rw-r--r--caddyconfig/caddyfile/lexer_test.go227
-rw-r--r--caddyconfig/caddyfile/parse.go148
-rw-r--r--caddyconfig/caddyfile/parse_test.go120
-rw-r--r--caddyconfig/caddyfile/testdata/import_args0.txt2
-rw-r--r--caddyconfig/caddyfile/testdata/import_args1.txt2
10 files changed, 858 insertions, 135 deletions
diff --git a/caddyconfig/caddyfile/adapter.go b/caddyconfig/caddyfile/adapter.go
index b924325..d6ef602 100644
--- a/caddyconfig/caddyfile/adapter.go
+++ b/caddyconfig/caddyfile/adapter.go
@@ -88,7 +88,7 @@ func FormattingDifference(filename string, body []byte) (caddyconfig.Warning, bo
return caddyconfig.Warning{
File: filename,
Line: line,
- Message: "Caddyfile input is not formatted; run the 'caddy fmt' command to fix inconsistencies",
+ Message: "Caddyfile input is not formatted; run 'caddy fmt --overwrite' to fix inconsistencies",
}, true
}
diff --git a/caddyconfig/caddyfile/dispenser.go b/caddyconfig/caddyfile/dispenser.go
index 91bd9a5..215a164 100644
--- a/caddyconfig/caddyfile/dispenser.go
+++ b/caddyconfig/caddyfile/dispenser.go
@@ -101,12 +101,12 @@ func (d *Dispenser) nextOnSameLine() bool {
d.cursor++
return true
}
- if d.cursor >= len(d.tokens) {
+ if d.cursor >= len(d.tokens)-1 {
return false
}
- if d.cursor < len(d.tokens)-1 &&
- d.tokens[d.cursor].File == d.tokens[d.cursor+1].File &&
- d.tokens[d.cursor].Line+d.numLineBreaks(d.cursor) == d.tokens[d.cursor+1].Line {
+ curr := d.tokens[d.cursor]
+ next := d.tokens[d.cursor+1]
+ if !isNextOnNewLine(curr, next) {
d.cursor++
return true
}
@@ -122,12 +122,12 @@ func (d *Dispenser) NextLine() bool {
d.cursor++
return true
}
- if d.cursor >= len(d.tokens) {
+ if d.cursor >= len(d.tokens)-1 {
return false
}
- if d.cursor < len(d.tokens)-1 &&
- (d.tokens[d.cursor].File != d.tokens[d.cursor+1].File ||
- d.tokens[d.cursor].Line+d.numLineBreaks(d.cursor) < d.tokens[d.cursor+1].Line) {
+ curr := d.tokens[d.cursor]
+ next := d.tokens[d.cursor+1]
+ if isNextOnNewLine(curr, next) {
d.cursor++
return true
}
@@ -203,14 +203,17 @@ func (d *Dispenser) Val() string {
}
// ValRaw gets the raw text of the current token (including quotes).
+// If the token was a heredoc, then the delimiter is not included,
+// because that is not relevant to any unmarshaling logic at this time.
// If there is no token loaded, it returns empty string.
func (d *Dispenser) ValRaw() string {
if d.cursor < 0 || d.cursor >= len(d.tokens) {
return ""
}
quote := d.tokens[d.cursor].wasQuoted
- if quote > 0 {
- return string(quote) + d.tokens[d.cursor].Text + string(quote) // string literal
+ if quote > 0 && quote != '<' {
+ // string literal
+ return string(quote) + d.tokens[d.cursor].Text + string(quote)
}
return d.tokens[d.cursor].Text
}
@@ -388,22 +391,22 @@ func (d *Dispenser) Reset() {
// an argument.
func (d *Dispenser) ArgErr() error {
if d.Val() == "{" {
- return d.Err("Unexpected token '{', expecting argument")
+ return d.Err("unexpected token '{', expecting argument")
}
- return d.Errf("Wrong argument count or unexpected line ending after '%s'", d.Val())
+ return d.Errf("wrong argument count or unexpected line ending after '%s'", d.Val())
}
// SyntaxErr creates a generic syntax error which explains what was
// found and what was expected.
func (d *Dispenser) SyntaxErr(expected string) error {
- msg := fmt.Sprintf("%s:%d - Syntax error: Unexpected token '%s', expecting '%s'", d.File(), d.Line(), d.Val(), expected)
+ msg := fmt.Sprintf("syntax error: unexpected token '%s', expecting '%s', at %s:%d import chain: ['%s']", d.Val(), expected, d.File(), d.Line(), strings.Join(d.Token().imports, "','"))
return errors.New(msg)
}
// EOFErr returns an error indicating that the dispenser reached
// the end of the input when searching for the next token.
func (d *Dispenser) EOFErr() error {
- return d.Errf("Unexpected EOF")
+ return d.Errf("unexpected EOF")
}
// Err generates a custom parse-time error with a message of msg.
@@ -418,7 +421,10 @@ func (d *Dispenser) Errf(format string, args ...any) error {
// WrapErr takes an existing error and adds the Caddyfile file and line number.
func (d *Dispenser) WrapErr(err error) error {
- return fmt.Errorf("%s:%d - Error during parsing: %w", d.File(), d.Line(), err)
+ if len(d.Token().imports) > 0 {
+ return fmt.Errorf("%w, at %s:%d import chain ['%s']", err, d.File(), d.Line(), strings.Join(d.Token().imports, "','"))
+ }
+ return fmt.Errorf("%w, at %s:%d", err, d.File(), d.Line())
}
// Delete deletes the current token and returns the updated slice
@@ -438,14 +444,14 @@ func (d *Dispenser) Delete() []Token {
return d.tokens
}
-// numLineBreaks counts how many line breaks are in the token
-// value given by the token index tknIdx. It returns 0 if the
-// token does not exist or there are no line breaks.
-func (d *Dispenser) numLineBreaks(tknIdx int) int {
- if tknIdx < 0 || tknIdx >= len(d.tokens) {
- return 0
+// DeleteN is the same as Delete, but can delete many tokens at once.
+// If there aren't N tokens available to delete, none are deleted.
+func (d *Dispenser) DeleteN(amount int) []Token {
+ if amount > 0 && d.cursor >= (amount-1) && d.cursor <= len(d.tokens)-1 {
+ d.tokens = append(d.tokens[:d.cursor-(amount-1)], d.tokens[d.cursor+1:]...)
+ d.cursor -= amount
}
- return strings.Count(d.tokens[tknIdx].Text, "\n")
+ return d.tokens
}
// isNewLine determines whether the current token is on a different
@@ -461,25 +467,7 @@ func (d *Dispenser) isNewLine() bool {
prev := d.tokens[d.cursor-1]
curr := d.tokens[d.cursor]
-
- // If the previous token is from a different file,
- // we can assume it's from a different line
- if prev.File != curr.File {
- return true
- }
-
- // The previous token may contain line breaks if
- // it was quoted and spanned multiple lines. e.g:
- //
- // dir "foo
- // bar
- // baz"
- prevLineBreaks := d.numLineBreaks(d.cursor - 1)
-
- // If the previous token (incl line breaks) ends
- // on a line earlier than the current token,
- // then the current token is on a new line
- return prev.Line+prevLineBreaks < curr.Line
+ return isNextOnNewLine(prev, curr)
}
// isNextOnNewLine determines whether the current token is on a different
@@ -495,23 +483,5 @@ func (d *Dispenser) isNextOnNewLine() bool {
curr := d.tokens[d.cursor]
next := d.tokens[d.cursor+1]
-
- // If the next token is from a different file,
- // we can assume it's from a different line
- if curr.File != next.File {
- return true
- }
-
- // The current token may contain line breaks if
- // it was quoted and spanned multiple lines. e.g:
- //
- // dir "foo
- // bar
- // baz"
- currLineBreaks := d.numLineBreaks(d.cursor)
-
- // If the current token (incl line breaks) ends
- // on a line earlier than the next token,
- // then the next token is on a new line
- return curr.Line+currLineBreaks < next.Line
+ return isNextOnNewLine(curr, next)
}
diff --git a/caddyconfig/caddyfile/importargs.go b/caddyconfig/caddyfile/importargs.go
new file mode 100644
index 0000000..2e21a36
--- /dev/null
+++ b/caddyconfig/caddyfile/importargs.go
@@ -0,0 +1,153 @@
+// 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 (
+ "regexp"
+ "strconv"
+ "strings"
+
+ "go.uber.org/zap"
+
+ "github.com/caddyserver/caddy/v2"
+)
+
+// parseVariadic determines if the token is a variadic placeholder,
+// and if so, determines the index range (start/end) of args to use.
+// Returns a boolean signaling whether a variadic placeholder was found,
+// and the start and end indices.
+func parseVariadic(token Token, argCount int) (bool, int, int) {
+ if !strings.HasPrefix(token.Text, "{args[") {
+ return false, 0, 0
+ }
+ if !strings.HasSuffix(token.Text, "]}") {
+ return false, 0, 0
+ }
+
+ argRange := strings.TrimSuffix(strings.TrimPrefix(token.Text, "{args["), "]}")
+ if argRange == "" {
+ caddy.Log().Named("caddyfile").Warn(
+ "Placeholder "+token.Text+" cannot have an empty index",
+ zap.String("file", token.File+":"+strconv.Itoa(token.Line)), zap.Strings("import_chain", token.imports))
+ return false, 0, 0
+ }
+
+ start, end, found := strings.Cut(argRange, ":")
+
+ // If no ":" delimiter is found, this is not a variadic.
+ // The replacer will pick this up.
+ if !found {
+ return false, 0, 0
+ }
+
+ var (
+ startIndex = 0
+ endIndex = argCount
+ err error
+ )
+ if start != "" {
+ startIndex, err = strconv.Atoi(start)
+ if err != nil {
+ caddy.Log().Named("caddyfile").Warn(
+ "Variadic placeholder "+token.Text+" has an invalid start index",
+ zap.String("file", token.File+":"+strconv.Itoa(token.Line)), zap.Strings("import_chain", token.imports))
+ return false, 0, 0
+ }
+ }
+ if end != "" {
+ endIndex, err = strconv.Atoi(end)
+ if err != nil {
+ caddy.Log().Named("caddyfile").Warn(
+ "Variadic placeholder "+token.Text+" has an invalid end index",
+ zap.String("file", token.File+":"+strconv.Itoa(token.Line)), zap.Strings("import_chain", token.imports))
+ return false, 0, 0
+ }
+ }
+
+ // bound check
+ if startIndex < 0 || startIndex > endIndex || endIndex > argCount {
+ caddy.Log().Named("caddyfile").Warn(
+ "Variadic placeholder "+token.Text+" indices are out of bounds, only "+strconv.Itoa(argCount)+" argument(s) exist",
+ zap.String("file", token.File+":"+strconv.Itoa(token.Line)), zap.Strings("import_chain", token.imports))
+ return false, 0, 0
+ }
+ return true, startIndex, endIndex
+}
+
+// makeArgsReplacer prepares a Replacer which can replace
+// non-variadic args placeholders in imported tokens.
+func makeArgsReplacer(args []string) *caddy.Replacer {
+ repl := caddy.NewEmptyReplacer()
+ repl.Map(func(key string) (any, bool) {
+ // TODO: Remove the deprecated {args.*} placeholder
+ // support at some point in the future
+ if matches := argsRegexpIndexDeprecated.FindStringSubmatch(key); len(matches) > 0 {
+ // What's matched may be a substring of the key
+ if matches[0] != key {
+ return nil, false
+ }
+
+ value, err := strconv.Atoi(matches[1])
+ if err != nil {
+ caddy.Log().Named("caddyfile").Warn(
+ "Placeholder {args." + matches[1] + "} has an invalid index")
+ return nil, false
+ }
+ if value >= len(args) {
+ caddy.Log().Named("caddyfile").Warn(
+ "Placeholder {args." + matches[1] + "} index is out of bounds, only " + strconv.Itoa(len(args)) + " argument(s) exist")
+ return nil, false
+ }
+ caddy.Log().Named("caddyfile").Warn(
+ "Placeholder {args." + matches[1] + "} deprecated, use {args[" + matches[1] + "]} instead")
+ return args[value], true
+ }
+
+ // Handle args[*] form
+ if matches := argsRegexpIndex.FindStringSubmatch(key); len(matches) > 0 {
+ // What's matched may be a substring of the key
+ if matches[0] != key {
+ return nil, false
+ }
+
+ if strings.Contains(matches[1], ":") {
+ caddy.Log().Named("caddyfile").Warn(
+ "Variadic placeholder {args[" + matches[1] + "]} must be a token on its own")
+ return nil, false
+ }
+ value, err := strconv.Atoi(matches[1])
+ if err != nil {
+ caddy.Log().Named("caddyfile").Warn(
+ "Placeholder {args[" + matches[1] + "]} has an invalid index")
+ return nil, false
+ }
+ if value >= len(args) {
+ caddy.Log().Named("caddyfile").Warn(
+ "Placeholder {args[" + matches[1] + "]} index is out of bounds, only " + strconv.Itoa(len(args)) + " argument(s) exist")
+ return nil, false
+ }
+ return args[value], true
+ }
+
+ // Not an args placeholder, ignore
+ return nil, false
+ })
+ return repl
+}
+
+var (
+ argsRegexpIndexDeprecated = regexp.MustCompile(`args\.(.+)`)
+ argsRegexpIndex = regexp.MustCompile(`args\[(.+)]`)
+)
diff --git a/caddyconfig/caddyfile/importgraph.go b/caddyconfig/caddyfile/importgraph.go
index 659c368..d27f471 100644
--- a/caddyconfig/caddyfile/importgraph.go
+++ b/caddyconfig/caddyfile/importgraph.go
@@ -34,6 +34,7 @@ func (i *importGraph) addNode(name string) {
}
i.nodes[name] = true
}
+
func (i *importGraph) addNodes(names []string) {
for _, name := range names {
i.addNode(name)
@@ -43,6 +44,7 @@ func (i *importGraph) addNodes(names []string) {
func (i *importGraph) removeNode(name string) {
delete(i.nodes, name)
}
+
func (i *importGraph) removeNodes(names []string) {
for _, name := range names {
i.removeNode(name)
@@ -73,6 +75,7 @@ func (i *importGraph) addEdge(from, to string) error {
i.edges[from] = append(i.edges[from], to)
return nil
}
+
func (i *importGraph) addEdges(from string, tos []string) error {
for _, to := range tos {
err := i.addEdge(from, to)
diff --git a/caddyconfig/caddyfile/lexer.go b/caddyconfig/caddyfile/lexer.go
index 5605a6a..bfd6c0f 100644
--- a/caddyconfig/caddyfile/lexer.go
+++ b/caddyconfig/caddyfile/lexer.go
@@ -17,7 +17,10 @@ package caddyfile
import (
"bufio"
"bytes"
+ "fmt"
"io"
+ "regexp"
+ "strings"
"unicode"
)
@@ -35,15 +38,41 @@ type (
// Token represents a single parsable unit.
Token struct {
- File string
- Line int
- Text string
- wasQuoted rune // enclosing quote character, if any
- inSnippet bool
- snippetName string
+ File string
+ imports []string
+ Line int
+ Text string
+ wasQuoted rune // enclosing quote character, if any
+ heredocMarker string
+ snippetName string
}
)
+// Tokenize takes bytes as input and lexes it into
+// a list of tokens that can be parsed as a Caddyfile.
+// Also takes a filename to fill the token's File as
+// the source of the tokens, which is important to
+// determine relative paths for `import` directives.
+func Tokenize(input []byte, filename string) ([]Token, error) {
+ l := lexer{}
+ if err := l.load(bytes.NewReader(input)); err != nil {
+ return nil, err
+ }
+ var tokens []Token
+ for {
+ found, err := l.next()
+ if err != nil {
+ return nil, err
+ }
+ if !found {
+ break
+ }
+ l.token.File = filename
+ tokens = append(tokens, l.token)
+ }
+ return tokens, nil
+}
+
// load prepares the lexer to scan an input for tokens.
// It discards any leading byte order mark.
func (l *lexer) load(input io.Reader) error {
@@ -75,28 +104,107 @@ func (l *lexer) load(input io.Reader) error {
// may be escaped. The rest of the line is skipped
// if a "#" character is read in. Returns true if
// a token was loaded; false otherwise.
-func (l *lexer) next() bool {
+func (l *lexer) next() (bool, error) {
var val []rune
- var comment, quoted, btQuoted, escaped bool
+ var comment, quoted, btQuoted, inHeredoc, heredocEscaped, escaped bool
+ var heredocMarker string
makeToken := func(quoted rune) bool {
l.token.Text = string(val)
l.token.wasQuoted = quoted
+ l.token.heredocMarker = heredocMarker
return true
}
for {
+ // Read a character in; if err then if we had
+ // read some characters, make a token. If we
+ // reached EOF, then no more tokens to read.
+ // If no EOF, then we had a problem.
ch, _, err := l.reader.ReadRune()
if err != nil {
if len(val) > 0 {
- return makeToken(0)
+ if inHeredoc {
+ return false, fmt.Errorf("incomplete heredoc <<%s on line #%d, expected ending marker %s", heredocMarker, l.line+l.skippedLines, heredocMarker)
+ }
+
+ return makeToken(0), nil
}
if err == io.EOF {
- return false
+ return false, nil
+ }
+ return false, err
+ }
+
+ // detect whether we have the start of a heredoc
+ if !(quoted || btQuoted) && !(inHeredoc || heredocEscaped) &&
+ len(val) > 1 && string(val[:2]) == "<<" {
+ // a space means it's just a regular token and not a heredoc
+ if ch == ' ' {
+ return makeToken(0), nil
+ }
+
+ // skip CR, we only care about LF
+ if ch == '\r' {
+ continue
+ }
+
+ // after hitting a newline, we know that the heredoc marker
+ // is the characters after the two << and the newline.
+ // we reset the val because the heredoc is syntax we don't
+ // want to keep.
+ if ch == '\n' {
+ if len(val) == 2 {
+ return false, fmt.Errorf("missing opening heredoc marker on line #%d; must contain only alpha-numeric characters, dashes and underscores; got empty string", l.line)
+ }
+
+ // check if there's too many <
+ if string(val[:3]) == "<<<" {
+ return false, fmt.Errorf("too many '<' for heredoc on line #%d; only use two, for example <<END", l.line)
+ }
+
+ heredocMarker = string(val[2:])
+ if !heredocMarkerRegexp.Match([]byte(heredocMarker)) {
+ return false, fmt.Errorf("heredoc marker on line #%d must contain only alpha-numeric characters, dashes and underscores; got '%s'", l.line, heredocMarker)
+ }
+
+ inHeredoc = true
+ l.skippedLines++
+ val = nil
+ continue
+ }
+ val = append(val, ch)
+ continue
+ }
+
+ // if we're in a heredoc, all characters are read as-is
+ if inHeredoc {
+ val = append(val, ch)
+
+ if ch == '\n' {
+ l.skippedLines++
+ }
+
+ // check if we're done, i.e. that the last few characters are the marker
+ if len(val) > len(heredocMarker) && heredocMarker == string(val[len(val)-len(heredocMarker):]) {
+ // set the final value
+ val, err = l.finalizeHeredoc(val, heredocMarker)
+ if err != nil {
+ return false, err
+ }
+
+ // set the line counter, and make the token
+ l.line += l.skippedLines
+ l.skippedLines = 0
+ return makeToken('<'), nil
}
- panic(err)
+
+ // stay in the heredoc until we find the ending marker
+ continue
}
+ // track whether we found an escape '\' for the next
+ // iteration to be contextually aware
if !escaped && !btQuoted && ch == '\\' {
escaped = true
continue
@@ -111,26 +219,29 @@ func (l *lexer) next() bool {
}
escaped = false
} else {
- if quoted && ch == '"' {
- return makeToken('"')
- }
- if btQuoted && ch == '`' {
- return makeToken('`')
+ if (quoted && ch == '"') || (btQuoted && ch == '`') {
+ return makeToken(ch), nil
}
}
+ // allow quoted text to wrap continue on multiple lines
if ch == '\n' {
l.line += 1 + l.skippedLines
l.skippedLines = 0
}
+ // collect this character as part of the quoted token
val = append(val, ch)
continue
}
if unicode.IsSpace(ch) {
+ // ignore CR altogether, we only actually care about LF (\n)
if ch == '\r' {
continue
}
+ // end of the line
if ch == '\n' {
+ // newlines can be escaped to chain arguments
+ // onto multiple lines; else, increment the line count
if escaped {
l.skippedLines++
escaped = false
@@ -138,14 +249,18 @@ func (l *lexer) next() bool {
l.line += 1 + l.skippedLines
l.skippedLines = 0
}
+ // comments (#) are single-line only
comment = false
}
+ // any kind of space means we're at the end of this token
if len(val) > 0 {
- return makeToken(0)
+ return makeToken(0), nil
}
continue
}
+ // comments must be at the start of a token,
+ // in other words, preceded by space or newline
if ch == '#' && len(val) == 0 {
comment = true
}
@@ -166,7 +281,12 @@ func (l *lexer) next() bool {
}
if escaped {
- val = append(val, '\\')
+ // allow escaping the first < to skip the heredoc syntax
+ if ch == '<' {
+ heredocEscaped = true
+ } else {
+ val = append(val, '\\')
+ }
escaped = false
}
@@ -174,24 +294,86 @@ func (l *lexer) next() bool {
}
}
-// Tokenize takes bytes as input and lexes it into
-// a list of tokens that can be parsed as a Caddyfile.
-// Also takes a filename to fill the token's File as
-// the source of the tokens, which is important to
-// determine relative paths for `import` directives.
-func Tokenize(input []byte, filename string) ([]Token, error) {
- l := lexer{}
- if err := l.load(bytes.NewReader(input)); err != nil {
- return nil, err
+// finalizeHeredoc takes the runes read as the heredoc text and the marker,
+// and processes the text to strip leading whitespace, returning the final
+// value without the leading whitespace.
+func (l *lexer) finalizeHeredoc(val []rune, marker string) ([]rune, error) {
+ stringVal := string(val)
+
+ // find the last newline of the heredoc, which is where the contents end
+ lastNewline := strings.LastIndex(stringVal, "\n")
+
+ // collapse the content, then split into separate lines
+ lines := strings.Split(stringVal[:lastNewline+1], "\n")
+
+ // figure out how much whitespace we need to strip from the front of every line
+ // by getting the string that precedes the marker, on the last line
+ paddingToStrip := stringVal[lastNewline+1 : len(stringVal)-len(marker)]
+
+ // iterate over each line and strip the whitespace from the front
+ var out string
+ for lineNum, lineText := range lines[:len(lines)-1] {
+ // find an exact match for the padding
+ index := strings.Index(lineText, paddingToStrip)
+
+ // if the padding doesn't match exactly at the start then we can't safely strip
+ if index != 0 {
+ return nil, fmt.Errorf("mismatched leading whitespace in heredoc <<%s on line #%d [%s], expected whitespace [%s] to match the closing marker", marker, l.line+lineNum+1, lineText, paddingToStrip)
+ }
+
+ // strip, then append the line, with the newline, to the output.
+ // also removes all "\r" because Windows.
+ out += strings.ReplaceAll(lineText[len(paddingToStrip):]+"\n", "\r", "")
}
- var tokens []Token
- for l.next() {
- l.token.File = filename
- tokens = append(tokens, l.token)
+
+ // Remove the trailing newline from the loop
+ if len(out) > 0 && out[len(out)-1] == '\n' {
+ out = out[:len(out)-1]
}
- return tokens, nil
+
+ // return the final value
+ return []rune(out), nil
}
func (t Token) Quoted() bool {
return t.wasQuoted > 0
}
+
+// NumLineBreaks counts how many line breaks are in the token text.
+func (t Token) NumLineBreaks() int {
+ lineBreaks := strings.Count(t.Text, "\n")
+ if t.wasQuoted == '<' {
+ // heredocs have an extra linebreak because the opening
+ // delimiter is on its own line and is not included in the
+ // token Text itself, and the trailing newline is removed.
+ lineBreaks += 2
+ }
+ return lineBreaks
+}
+
+var heredocMarkerRegexp = regexp.MustCompile("^[A-Za-z0-9_-]+$")
+
+// isNextOnNewLine tests whether t2 is on a different line from t1
+func isNextOnNewLine(t1, t2 Token) bool {
+ // If the second token is from a different file,
+ // we can assume it's from a different line
+ if t1.File != t2.File {
+ return true
+ }
+
+ // If the second token is from a different import chain,
+ // we can assume it's from a different line
+ if len(t1.imports) != len(t2.imports) {
+ return true
+ }
+ for i, im := range t1.imports {
+ if im != t2.imports[i] {
+ return true
+ }
+ }
+
+ // If the first token (incl line breaks) ends
+ // on a line earlier than the next token,
+ // then the second token is on a new line
+ return t1.Line+t1.NumLineBreaks() < t2.Line
+}
diff --git a/caddyconfig/caddyfile/lexer_test.go b/caddyconfig/caddyfile/lexer_test.go
index 30ee0f6..92acc4d 100644
--- a/caddyconfig/caddyfile/lexer_test.go
+++ b/caddyconfig/caddyfile/lexer_test.go
@@ -18,13 +18,13 @@ import (
"testing"
)
-type lexerTestCase struct {
- input []byte
- expected []Token
-}
-
func TestLexer(t *testing.T) {
- testCases := []lexerTestCase{
+ testCases := []struct {
+ input []byte
+ expected []Token
+ expectErr bool
+ errorMessage string
+ }{
{
input: []byte(`host:123`),
expected: []Token{
@@ -249,12 +249,219 @@ func TestLexer(t *testing.T) {
{Line: 1, Text: `quotes`},
},
},
+ {
+ input: []byte(`heredoc <<EOF
+content
+EOF same-line-arg
+ `),
+ expected: []Token{
+ {Line: 1, Text: `heredoc`},
+ {Line: 1, Text: "content"},
+ {Line: 3, Text: `same-line-arg`},
+ },
+ },
+ {
+ input: []byte(`heredoc <<VERY-LONG-MARKER
+content
+VERY-LONG-MARKER same-line-arg
+ `),
+ expected: []Token{
+ {Line: 1, Text: `heredoc`},
+ {Line: 1, Text: "content"},
+ {Line: 3, Text: `same-line-arg`},
+ },
+ },
+ {
+ input: []byte(`heredoc <<EOF
+extra-newline
+
+EOF same-line-arg
+ `),
+ expected: []Token{
+ {Line: 1, Text: `heredoc`},
+ {Line: 1, Text: "extra-newline\n"},
+ {Line: 4, Text: `same-line-arg`},
+ },
+ },
+ {
+ input: []byte(`heredoc <<EOF
+ EOF same-line-arg
+ `),
+ expected: []Token{
+ {Line: 1, Text: `heredoc`},
+ {Line: 1, Text: ""},
+ {Line: 2, Text: `same-line-arg`},
+ },
+ },
+ {
+ input: []byte(`heredoc <<EOF
+ content
+ EOF same-line-arg
+ `),
+ expected: []Token{
+ {Line: 1, Text: `heredoc`},
+ {Line: 1, Text: "content"},
+ {Line: 3, Text: `same-line-arg`},
+ },
+ },
+ {
+ input: []byte(`prev-line
+ heredoc <<EOF
+ multi
+ line
+ content
+ EOF same-line-arg
+ next-line
+ `),
+ expected: []Token{
+ {Line: 1, Text: `prev-line`},
+ {Line: 2, Text: `heredoc`},
+ {Line: 2, Text: "\tmulti\n\tline\n\tcontent"},
+ {Line: 6, Text: `same-line-arg`},
+ {Line: 7, Text: `next-line`},
+ },
+ },
+ {
+ input: []byte(`escaped-heredoc \<< >>`),
+ expected: []Token{
+ {Line: 1, Text: `escaped-heredoc`},
+ {Line: 1, Text: `<<`},
+ {Line: 1, Text: `>>`},
+ },
+ },
+ {
+ input: []byte(`not-a-heredoc <EOF
+ content
+ `),
+ expected: []Token{
+ {Line: 1, Text: `not-a-heredoc`},
+ {Line: 1, Text: `<EOF`},
+ {Line: 2, Text: `content`},
+ },
+ },
+ {
+ input: []byte(`not-a-heredoc <<<EOF content`),
+ expected: []Token{
+ {Line: 1, Text: `not-a-heredoc`},
+ {Line: 1, Text: `<<<EOF`},
+ {Line: 1, Text: `content`},
+ },
+ },
+ {
+ input: []byte(`not-a-heredoc "<<" ">>"`),
+ expected: []Token{
+ {Line: 1, Text: `not-a-heredoc`},
+ {Line: 1, Text: `<<`},
+ {Line: 1, Text: `>>`},
+ },
+ },
+ {
+ input: []byte(`not-a-heredoc << >>`),
+ expected: []Token{
+ {Line: 1, Text: `not-a-heredoc`},
+ {Line: 1, Text: `<<`},
+ {Line: 1, Text: `>>`},
+ },
+ },
+ {
+ input: []byte(`not-a-heredoc <<HERE SAME LINE
+ content
+ HERE same-line-arg
+ `),
+ expected: []Token{
+ {Line: 1, Text: `not-a-heredoc`},
+ {Line: 1, Text: `<<HERE`},
+ {Line: 1, Text: `SAME`},
+ {Line: 1, Text: `LINE`},
+ {Line: 2, Text: `content`},
+ {Line: 3, Text: `HERE`},
+ {Line: 3, Text: `same-line-arg`},
+ },
+ },
+ {
+ input: []byte(`heredoc <<s
+ �
+ s
+ `),
+ expected: []Token{
+ {Line: 1, Text: `heredoc`},
+ {Line: 1, Text: "�"},
+ },
+ },
+ {
+ input: []byte("\u000Aheredoc \u003C\u003C\u0073\u0073\u000A\u00BF\u0057\u0001\u0000\u00FF\u00FF\u00FF\u00FF\u00FF\u00FF\u00FF\u003D\u001F\u000A\u0073\u0073\u000A\u00BF\u0057\u0001\u0000\u00FF\u00FF\u00FF\u00FF\u00FF\u00FF\u00FF\u003D\u001F\u000A\u00BF\u00BF\u0057\u0001\u0000\u00FF\u00FF\u00FF\u00FF\u00FF\u00FF\u00FF\u003D\u001F"),
+ expected: []Token{
+ {
+ Line: 2,
+ Text: "heredoc",
+ },
+ {
+ Line: 2,
+ Text: "\u00BF\u0057\u0001\u0000\u00FF\u00FF\u00FF\u00FF\u00FF\u00FF\u00FF\u003D\u001F",
+ },
+ {
+ Line: 5,
+ Text: "\u00BF\u0057\u0001\u0000\u00FF\u00FF\u00FF\u00FF\u00FF\u00FF\u00FF\u003D\u001F",
+ },
+ {
+ Line: 6,
+ Text: "\u00BF\u00BF\u0057\u0001\u0000\u00FF\u00FF\u00FF\u00FF\u00FF\u00FF\u00FF\u003D\u001F",
+ },
+ },
+ },
+ {
+ input: []byte("not-a-heredoc <<\n"),
+ expectErr: true,
+ errorMessage: "missing opening heredoc marker on line #1; must contain only alpha-numeric characters, dashes and underscores; got empty string",
+ },
+ {
+ input: []byte(`heredoc <<<EOF
+ content
+ EOF same-line-arg
+ `),
+ expectErr: true,
+ errorMessage: "too many '<' for heredoc on line #1; only use two, for example <<END",
+ },
+ {
+ input: []byte(`heredoc <<EOF
+ content
+ `),
+ expectErr: true,
+ errorMessage: "incomplete heredoc <<EOF on line #3, expected ending marker EOF",
+ },
+ {
+ input: []byte(`heredoc <<EOF
+ content
+ EOF
+ `),
+ expectErr: true,
+ errorMessage: "mismatched leading whitespace in heredoc <<EOF on line #2 [\tcontent], expected whitespace [\t\t] to match the closing marker",
+ },
+ {
+ input: []byte(`heredoc <<EOF
+ content
+ EOF
+ `),
+ expectErr: true,
+ errorMessage: "mismatched leading whitespace in heredoc <<EOF on line #2 [ content], expected whitespace [\t\t] to match the closing marker",
+ },
}
for i, testCase := range testCases {
actual, err := Tokenize(testCase.input, "")
+ if testCase.expectErr {
+ if err == nil {
+ t.Fatalf("expected error, got actual: %v", actual)
+ continue
+ }
+ if err.Error() != testCase.errorMessage {
+ t.Fatalf("expected error '%v', got: %v", testCase.errorMessage, err)
+ }
+ continue
+ }
+
if err != nil {
- t.Errorf("%v", err)
+ t.Fatalf("%v", err)
}
lexerCompare(t, i, testCase.expected, actual)
}
@@ -262,17 +469,17 @@ func TestLexer(t *testing.T) {
func lexerCompare(t *testing.T, n int, expected, actual []Token) {
if len(expected) != len(actual) {
- t.Errorf("Test case %d: expected %d token(s) but got %d", n, len(expected), len(actual))
+ t.Fatalf("Test case %d: expected %d token(s) but got %d", n, len(expected), len(actual))
}
for i := 0; i < len(actual) && i < len(expected); i++ {
if actual[i].Line != expected[i].Line {
- t.Errorf("Test case %d token %d ('%s'): expected line %d but was line %d",
+ t.Fatalf("Test case %d token %d ('%s'): expected line %d but was line %d",
n, i, expected[i].Text, expected[i].Line, actual[i].Line)
break
}
if actual[i].Text != expected[i].Text {
- t.Errorf("Test case %d token %d: expected text '%s' but was '%s'",
+ t.Fatalf("Test case %d token %d: expected text '%s' but was '%s'",
n, i, expected[i].Text, actual[i].Text)
break
}
diff --git a/caddyconfig/caddyfile/parse.go b/caddyconfig/caddyfile/parse.go
index edc86f2..65d6ee9 100644
--- a/caddyconfig/caddyfile/parse.go
+++ b/caddyconfig/caddyfile/parse.go
@@ -20,11 +20,11 @@ import (
"io"
"os"
"path/filepath"
- "strconv"
"strings"
- "github.com/caddyserver/caddy/v2"
"go.uber.org/zap"
+
+ "github.com/caddyserver/caddy/v2"
)
// Parse parses the input just enough to group tokens, in
@@ -149,7 +149,6 @@ func (p *parser) begin() error {
}
err := p.addresses()
-
if err != nil {
return err
}
@@ -160,6 +159,25 @@ func (p *parser) begin() error {
return nil
}
+ if ok, name := p.isNamedRoute(); ok {
+ // named routes only have one key, the route name
+ p.block.Keys = []string{name}
+ p.block.IsNamedRoute = true
+
+ // we just need a dummy leading token to ease parsing later
+ nameToken := p.Token()
+ nameToken.Text = name
+
+ // get all the tokens from the block, including the braces
+ tokens, err := p.blockTokens(true)
+ if err != nil {
+ return err
+ }
+ tokens = append([]Token{nameToken}, tokens...)
+ p.block.Segments = []Segment{tokens}
+ return nil
+ }
+
if ok, name := p.isSnippet(); ok {
if p.definedSnippets == nil {
p.definedSnippets = map[string][]Token{}
@@ -168,16 +186,15 @@ func (p *parser) begin() error {
return p.Errf("redeclaration of previously declared snippet %s", name)
}
// consume all tokens til matched close brace
- tokens, err := p.snippetTokens()
+ tokens, err := p.blockTokens(false)
if err != nil {
return err
}
// Just as we need to track which file the token comes from, we need to
- // keep track of which snippets do the tokens come from. This is helpful
- // in tracking import cycles across files/snippets by namespacing them. Without
- // this we end up with false-positives in cycle-detection.
+ // keep track of which snippet the token comes from. This is helpful
+ // in tracking import cycles across files/snippets by namespacing them.
+ // Without this, we end up with false-positives in cycle-detection.
for k, v := range tokens {
- v.inSnippet = true
v.snippetName = name
tokens[k] = v
}
@@ -198,7 +215,7 @@ func (p *parser) addresses() error {
// special case: import directive replaces tokens during parse-time
if tkn == "import" && p.isNewLine() {
- err := p.doImport()
+ err := p.doImport(0)
if err != nil {
return err
}
@@ -298,7 +315,7 @@ func (p *parser) directives() error {
// special case: import directive replaces tokens during parse-time
if p.Val() == "import" {
- err := p.doImport()
+ err := p.doImport(1)
if err != nil {
return err
}
@@ -324,7 +341,7 @@ func (p *parser) directives() error {
// is on the token before where the import directive was. In
// other words, call Next() to access the first token that was
// imported.
-func (p *parser) doImport() error {
+func (p *parser) doImport(nesting int) error {
// syntax checks
if !p.NextArg() {
return p.ArgErr()
@@ -337,11 +354,8 @@ func (p *parser) doImport() error {
// grab remaining args as placeholder replacements
args := p.RemainingArgs()
- // add args to the replacer
- repl := caddy.NewEmptyReplacer()
- for index, arg := range args {
- repl.Set("args."+strconv.Itoa(index), arg)
- }
+ // set up a replacer for non-variadic args replacement
+ repl := makeArgsReplacer(args)
// splice out the import directive and its arguments
// (2 tokens, plus the length of args)
@@ -417,7 +431,7 @@ func (p *parser) doImport() error {
}
nodeName := p.File()
- if p.Token().inSnippet {
+ if p.Token().snippetName != "" {
nodeName += fmt.Sprintf(":%s", p.Token().snippetName)
}
p.importGraph.addNode(nodeName)
@@ -428,13 +442,69 @@ func (p *parser) doImport() error {
}
// copy the tokens so we don't overwrite p.definedSnippets
- tokensCopy := make([]Token, len(importedTokens))
- copy(tokensCopy, importedTokens)
+ tokensCopy := make([]Token, 0, len(importedTokens))
+
+ var (
+ maybeSnippet bool
+ maybeSnippetId bool
+ index int
+ )
// run the argument replacer on the tokens
- for index, token := range tokensCopy {
- token.Text = repl.ReplaceKnown(token.Text, "")
- tokensCopy[index] = token
+ // golang for range slice return a copy of value
+ // similarly, append also copy value
+ for i, token := range importedTokens {
+ // update the token's imports to refer to import directive filename, line number and snippet name if there is one
+ if token.snippetName != "" {
+ token.imports = append(token.imports, fmt.Sprintf("%s:%d (import %s)", p.File(), p.Line(), token.snippetName))
+ } else {
+ token.imports = append(token.imports, fmt.Sprintf("%s:%d (import)", p.File(), p.Line()))
+ }
+
+ // naive way of determine snippets, as snippets definition can only follow name + block
+ // format, won't check for nesting correctness or any other error, that's what parser does.
+ if !maybeSnippet && nesting == 0 {
+ // first of the line
+ if i == 0 || isNextOnNewLine(tokensCopy[i-1], token) {
+ index = 0
+ } else {
+ index++
+ }
+
+ if index == 0 && len(token.Text) >= 3 && strings.HasPrefix(token.Text, "(") && strings.HasSuffix(token.Text, ")") {
+ maybeSnippetId = true
+ }
+ }
+
+ switch token.Text {
+ case "{":
+ nesting++
+ if index == 1 && maybeSnippetId && nesting == 1 {
+ maybeSnippet = true
+ maybeSnippetId = false
+ }
+ case "}":
+ nesting--
+ if nesting == 0 && maybeSnippet {
+ maybeSnippet = false
+ }
+ }
+
+ if maybeSnippet {
+ tokensCopy = append(tokensCopy, token)
+ continue
+ }
+
+ foundVariadic, startIndex, endIndex := parseVariadic(token, len(args))
+ if foundVariadic {
+ for _, arg := range args[startIndex:endIndex] {
+ token.Text = arg
+ tokensCopy = append(tokensCopy, token)
+ }
+ } else {
+ token.Text = repl.ReplaceKnown(token.Text, "")
+ tokensCopy = append(tokensCopy, token)
+ }
}
// splice the imported tokens in the place of the import statement
@@ -496,7 +566,6 @@ func (p *parser) doSingleImport(importFile string) ([]Token, error) {
// are loaded into the current server block for later use
// by directive setup functions.
func (p *parser) directive() error {
-
// a segment is a list of tokens associated with this directive
var segment Segment
@@ -509,6 +578,9 @@ func (p *parser) directive() error {
if !p.isNextOnNewLine() && p.Token().wasQuoted == 0 {
return p.Err("Unexpected next token after '{' on same line")
}
+ if p.isNewLine() {
+ return p.Err("Unexpected '{' on a new line; did you mean to place the '{' on the previous line?")
+ }
} else if p.Val() == "{}" {
if p.isNextOnNewLine() && p.Token().wasQuoted == 0 {
return p.Err("Unexpected '{}' at end of line")
@@ -521,7 +593,7 @@ func (p *parser) directive() error {
} else if p.Val() == "}" && p.nesting == 0 {
return p.Err("Unexpected '}' because no matching opening brace")
} else if p.Val() == "import" && p.isNewLine() {
- if err := p.doImport(); err != nil {
+ if err := p.doImport(1); err != nil {
return err
}
p.cursor-- // cursor is advanced when we continue, so roll back one more
@@ -562,6 +634,15 @@ func (p *parser) closeCurlyBrace() error {
return nil
}
+func (p *parser) isNamedRoute() (bool, string) {
+ keys := p.block.Keys
+ // A named route block is a single key with parens, prefixed with &.
+ if len(keys) == 1 && strings.HasPrefix(keys[0], "&(") && strings.HasSuffix(keys[0], ")") {
+ return true, strings.TrimSuffix(keys[0][2:], ")")
+ }
+ return false, ""
+}
+
func (p *parser) isSnippet() (bool, string) {
keys := p.block.Keys
// A snippet block is a single key with parens. Nothing else qualifies.
@@ -572,18 +653,24 @@ func (p *parser) isSnippet() (bool, string) {
}
// read and store everything in a block for later replay.
-func (p *parser) snippetTokens() ([]Token, error) {
- // snippet must have curlies.
+func (p *parser) blockTokens(retainCurlies bool) ([]Token, error) {
+ // block must have curlies.
err := p.openCurlyBrace()
if err != nil {
return nil, err
}
- nesting := 1 // count our own nesting in snippets
+ nesting := 1 // count our own nesting
tokens := []Token{}
+ if retainCurlies {
+ tokens = append(tokens, p.Token())
+ }
for p.Next() {
if p.Val() == "}" {
nesting--
if nesting == 0 {
+ if retainCurlies {
+ tokens = append(tokens, p.Token())
+ }
break
}
}
@@ -603,9 +690,10 @@ func (p *parser) snippetTokens() ([]Token, error) {
// head of the server block with tokens, which are
// grouped by segments.
type ServerBlock struct {
- HasBraces bool
- Keys []string
- Segments []Segment
+ HasBraces bool
+ Keys []string
+ Segments []Segment
+ IsNamedRoute bool
}
// DispenseDirective returns a dispenser that contains
diff --git a/caddyconfig/caddyfile/parse_test.go b/caddyconfig/caddyfile/parse_test.go
index 4d18cc4..b1104ed 100644
--- a/caddyconfig/caddyfile/parse_test.go
+++ b/caddyconfig/caddyfile/parse_test.go
@@ -21,6 +21,88 @@ import (
"testing"
)
+func TestParseVariadic(t *testing.T) {
+ var args = make([]string, 10)
+ for i, tc := range []struct {
+ input string
+ result bool
+ }{
+ {
+ input: "",
+ result: false,
+ },
+ {
+ input: "{args[1",
+ result: false,
+ },
+ {
+ input: "1]}",
+ result: false,
+ },
+ {
+ input: "{args[:]}aaaaa",
+ result: false,
+ },
+ {
+ input: "aaaaa{args[:]}",
+ result: false,
+ },
+ {
+ input: "{args.}",
+ result: false,
+ },
+ {
+ input: "{args.1}",
+ result: false,
+ },
+ {
+ input: "{args[]}",
+ result: false,
+ },
+ {
+ input: "{args[:]}",
+ result: true,
+ },
+ {
+ input: "{args[:]}",
+ result: true,
+ },
+ {
+ input: "{args[0:]}",
+ result: true,
+ },
+ {
+ input: "{args[:0]}",
+ result: true,
+ },
+ {
+ input: "{args[-1:]}",
+ result: false,
+ },
+ {
+ input: "{args[:11]}",
+ result: false,
+ },
+ {
+ input: "{args[10:0]}",
+ result: false,
+ },
+ {
+ input: "{args[0:10]}",
+ result: true,
+ },
+ } {
+ token := Token{
+ File: "test",
+ Line: 1,
+ Text: tc.input,
+ }
+ if v, _, _ := parseVariadic(token, len(args)); v != tc.result {
+ t.Errorf("Test %d error expectation failed Expected: %t, got %t", i, tc.result, v)
+ }
+ }
+}
+
func TestAllTokens(t *testing.T) {
input := []byte("a b c\nd e")
expected := []string{"a", "b", "c", "d", "e"}
@@ -211,6 +293,14 @@ func TestParseOneAndImport(t *testing.T) {
// Unexpected next token after '{' on same line
{`localhost
dir1 { a b }`, true, []string{"localhost"}, []int{}},
+
+ // Unexpected '{' on a new line
+ {`localhost
+ dir1
+ {
+ a b
+ }`, true, []string{"localhost"}, []int{}},
+
// Workaround with quotes
{`localhost
dir1 "{" a b "}"`, false, []string{"localhost"}, []int{5}},
@@ -628,6 +718,36 @@ func TestEnvironmentReplacement(t *testing.T) {
}
}
+func TestImportReplacementInJSONWithBrace(t *testing.T) {
+ for i, test := range []struct {
+ args []string
+ input string
+ expect string
+ }{
+ {
+ args: []string{"123"},
+ input: "{args[0]}",
+ expect: "123",
+ },
+ {
+ args: []string{"123"},
+ input: `{"key":"{args[0]}"}`,
+ expect: `{"key":"123"}`,
+ },
+ {
+ args: []string{"123", "123"},
+ input: `{"key":[{args[0]},{args[1]}]}`,
+ expect: `{"key":[123,123]}`,
+ },
+ } {
+ repl := makeArgsReplacer(test.args)
+ actual := repl.ReplaceKnown(test.input, "")
+ if actual != test.expect {
+ t.Errorf("Test %d: Expected: '%s' but got '%s'", i, test.expect, actual)
+ }
+ }
+}
+
func TestSnippets(t *testing.T) {
p := testParser(`
(common) {
diff --git a/caddyconfig/caddyfile/testdata/import_args0.txt b/caddyconfig/caddyfile/testdata/import_args0.txt
index af946fe..add211e 100644
--- a/caddyconfig/caddyfile/testdata/import_args0.txt
+++ b/caddyconfig/caddyfile/testdata/import_args0.txt
@@ -1 +1 @@
-{args.0} \ No newline at end of file
+{args[0]} \ No newline at end of file
diff --git a/caddyconfig/caddyfile/testdata/import_args1.txt b/caddyconfig/caddyfile/testdata/import_args1.txt
index 519a92d..422692a 100644
--- a/caddyconfig/caddyfile/testdata/import_args1.txt
+++ b/caddyconfig/caddyfile/testdata/import_args1.txt
@@ -1 +1 @@
-{args.0} {args.1} \ No newline at end of file
+{args[0]} {args[1]} \ No newline at end of file