diff --git a/pkg/log/format/options/option.go b/pkg/log/format/options/option.go index 8ded8a60e4..a750ff6654 100644 --- a/pkg/log/format/options/option.go +++ b/pkg/log/format/options/option.go @@ -3,10 +3,23 @@ package options import ( "reflect" + "strings" + "unicode" + "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/pkg/log" ) +// Constants for parsing options. +const ( + OptNameValueSep = "=" + OptSep = "," + OptStartSign = "(" + OptEndSign = ")" +) + +const splitIntoNameAndValue = 2 + // OptionValue contains the value of the option. type OptionValue[T any] interface { // Parse parses and sets the value of the option. @@ -20,7 +33,7 @@ type Option interface { // Name returns the name of the option. Name() string // Format formats the given string. - Format(data *Data, val any) (any, error) + Format(data *Data, str any) (any, error) // ParseValue parses and sets the value of the option. ParseValue(str string) error } @@ -76,15 +89,102 @@ func (opts Options) Merge(withOpts ...Option) Options { } // Format returns the formatted value. -func (opts Options) Format(data *Data, val any) (string, error) { +func (opts Options) Format(data *Data, str any) (string, error) { var err error for _, opt := range opts { - val, err = opt.Format(data, val) - if val == "" || err != nil { + str, err = opt.Format(data, str) + if str == "" || err != nil { return "", err } } - return toString(val), nil + return toString(str), nil +} + +// Parse parsers the given `str` to configure the `opts` and returns the offset index. +func (opts Options) Parse(str string) (string, error) { + str, ok := isNext(str) + if !ok { + return str, nil + } + + parts := strings.SplitN(str, OptNameValueSep, splitIntoNameAndValue) + if len(parts) != splitIntoNameAndValue { + return "", errors.Errorf("invalid option %q", str) + } + + name := strings.TrimSpace(parts[0]) + if name == "" { + return "", errors.New("empty option name") + } + + opt := opts.Get(name) + if opt == nil { + return "", errors.Errorf("invalid option name %q, available names: %s", name, strings.Join(opts.Names(), ",")) + } + + str = parts[1] + + var quoted byte + + for index := range str { + // Skip quoted text, e.g. `%(content='level()')`. + if isQuoted(str[:index], "ed) { + continue + } + + if !strings.HasSuffix(str[:index+1], OptSep) && !strings.HasSuffix(str[:index+1], OptEndSign) { + continue + } + + val := strings.TrimSpace(str[:index]) + val = strings.Trim(val, "'") + val = strings.Trim(val, "\"") + + if err := opt.ParseValue(val); err != nil { + return "", errors.Errorf("invalid value %q for option %q: %w", val, opt.Name(), err) + } + + return opts.Parse(OptStartSign + str[index:]) + } + + return "", errors.Errorf("invalid option %q", str) +} + +func isNext(str string) (string, bool) { + if len(str) == 0 || !strings.HasPrefix(str, OptStartSign) { + return str, false + } + + str = strings.TrimLeftFunc(str[1:], unicode.IsSpace) + + switch { + case strings.HasPrefix(str, OptEndSign): + return str[1:], false + case strings.HasPrefix(str, OptSep): + return str[1:], true + } + + return str, true +} + +func isQuoted(str string, quoted *byte) bool { + strlen := len(str) + + if strlen == 0 { + return false + } + + char := str[strlen-1] + + if char == '"' || char == '\'' { + if *quoted == 0 { + *quoted = char + } else if *quoted == char && (strlen < 2 || str[strlen-2] != '\\') { + *quoted = 0 + } + } + + return *quoted != 0 } diff --git a/pkg/log/format/placeholders/common.go b/pkg/log/format/placeholders/common.go index 8b5a5afb3b..017ea82b5e 100644 --- a/pkg/log/format/placeholders/common.go +++ b/pkg/log/format/placeholders/common.go @@ -1,9 +1,6 @@ package placeholders import ( - "strings" - - "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/pkg/log/format/options" ) @@ -39,13 +36,9 @@ func (common *CommonPlaceholder) Name() string { return common.name } -// GetOption implements `Placeholder` interface. -func (common *CommonPlaceholder) GetOption(str string) (options.Option, error) { - if opt := common.opts.Get(str); opt != nil { - return opt, nil - } - - return nil, errors.Errorf("available values: %s", strings.Join(common.opts.Names(), ",")) +// Options implements `Placeholder` interface. +func (common *CommonPlaceholder) Options() options.Options { + return common.opts } // Format implements `Placeholder` interface. diff --git a/pkg/log/format/placeholders/placeholder.go b/pkg/log/format/placeholders/placeholder.go index 5be4130bf1..b65e02c743 100644 --- a/pkg/log/format/placeholders/placeholder.go +++ b/pkg/log/format/placeholders/placeholder.go @@ -8,14 +8,14 @@ import ( "github.com/gruntwork-io/terragrunt/pkg/log/format/options" ) -const placeholderSign = '%' +const placeholderSign = "%" // Placeholder is part of the log message, used to format different log values. type Placeholder interface { // Name returns a placeholder name. Name() string - // GetOption returns the option with the given option name. - GetOption(name string) (options.Option, error) + // Options returns the placeholder options. + Options() options.Options // Format returns the formatted value. Format(data *options.Data) (string, error) } @@ -23,6 +23,19 @@ type Placeholder interface { // Placeholders are a set of Placeholders. type Placeholders []Placeholder +func newPlaceholders() Placeholders { + return Placeholders{ + Interval(), + Time(), + Level(), + Message(), + Field(WorkDirKeyName, options.PathFormat(options.NonePath, options.RelativePath, options.ShortRelativePath, options.ShortPath)), + Field(TFPathKeyName, options.PathFormat(options.NonePath, options.FilenamePath, options.DirectoryPath)), + Field(TFCmdArgsKeyName), + Field(TFCmdKeyName), + } +} + // Get returns the placeholder by its name. func (phs Placeholders) Get(name string) Placeholder { for _, ph := range phs { @@ -64,176 +77,95 @@ func (phs Placeholders) Format(data *options.Data) (string, error) { // Parse parses the given string and returns a set of placeholders that are then used to format log data. func Parse(str string) (Placeholders, error) { var ( - registered = newPlaceholders() - placeholders Placeholders - next int + splitIntoTextAndPlaceholder = 2 + parts = strings.SplitN(str, placeholderSign, splitIntoTextAndPlaceholder) + plaintext = parts[0] + placeholders = Placeholders{PlainText(plaintext)} ) - for index := 0; index < len(str); index++ { - char := str[index] - - if char == placeholderSign { - if index+1 >= len(str) { - return nil, errors.Errorf("empty placeholder name") - } - - if str[index+1] == placeholderSign { - str = str[:index] + str[index+1:] - - continue - } - - if next != index { - placeholder := PlainText(str[next:index]) - placeholders = append(placeholders, placeholder) - } + if len(parts) == 1 { + return placeholders, nil + } - placeholder, num, err := parsePlaceholder(str[index+1:], registered) - if err != nil { - return nil, err - } + if strings.HasPrefix(parts[1], placeholderSign) { + // `%%` evaluates as `%`. + placeholders = append(placeholders, PlainText(placeholderSign)) - placeholders = append(placeholders, placeholder) - index += num + 1 - next = index + 1 + phs, err := Parse(str) + if err != nil { + return nil, err } - } - if next != len(str) { - placeholder := PlainText(str[next:]) - placeholders = append(placeholders, placeholder) + return append(placeholders, phs...), nil } - return placeholders, nil -} - -func newPlaceholders() Placeholders { - return Placeholders{ - Interval(), - Time(), - Level(), - Message(), - Field(WorkDirKeyName, options.PathFormat(options.NonePath, options.RelativePath, options.ShortRelativePath, options.ShortPath)), - Field(TFPathKeyName, options.PathFormat(options.NonePath, options.FilenamePath, options.DirectoryPath)), - Field(TFCmdArgsKeyName), - Field(TFCmdKeyName), + str = parts[1] + if str == "" { + return nil, errors.Errorf("empty placeholder name") } -} - -func parsePlaceholderOption(placeholder Placeholder, str string) (int, error) { - var ( - nextOptIndex int - quoted byte - option options.Option - ) - - for index := range len(str) { - char := str[index] - - if char == '"' || char == '\'' { - if quoted == 0 { - quoted = char - } else if quoted == char && index > 0 && str[index-1] != '\\' { - quoted = 0 - } - } - // Skip quoted text, e.g. `%(content='level()')`. - if quoted != 0 { - continue - } + registered := newPlaceholders() + placeholder, str := registered.parsePlaceholder(str) - if char != '=' && char != ',' && char != ')' { - continue - } + if placeholder == nil { + return nil, errors.Errorf("invalid placeholder name %q, available names: %s", str, strings.Join(registered.Names(), ",")) + } - val := str[nextOptIndex:index] - val = strings.TrimSpace(val) - val = strings.Trim(val, "'") - val = strings.Trim(val, "\"") - - if nextOptIndex > 0 && str[nextOptIndex-1] == '=' { - if option == nil { - return 0, errors.Errorf("empty option name for placeholder %q", placeholder.Name()) - } - - if err := option.ParseValue(val); err != nil { - return 0, errors.Errorf("invalid value %q for option %q, placeholder %q: %w", val, option.Name(), placeholder.Name(), err) - } - } else if val != "" { - opt, err := placeholder.GetOption(val) - if err != nil { - return 0, errors.Errorf("invalid option name %q for placeholder %q: %w", val, placeholder.Name(), err) - } - - option = opt - } + str, err := placeholder.Options().Parse(str) + if err != nil { + return nil, errors.Errorf("placeholder %q: %w", placeholder.Name(), err) + } - nextOptIndex = index + 1 + placeholders = append(placeholders, placeholder) - if char == ')' { - return index + 1, nil - } + phs, err := Parse(str) + if err != nil { + return nil, err } - return 0, errors.Errorf("invalid option %q for placeholder %q", str[nextOptIndex:], placeholder.Name()) + placeholders = append(placeholders, phs...) + + return placeholders, nil } -func parsePlaceholder(str string, registered Placeholders) (Placeholder, int, error) { +func (phs Placeholders) parsePlaceholder(str string) (Placeholder, string) { //nolint:ireturn var ( placeholder Placeholder optIndex int ) + // We don't stop at the first one we find, we look for the longest name. + // Of these two `%tf-command` `%tf-command-args` we need to find the second one. for index := range len(str) { - char := str[index] - - switch { - case index == 0 && char == '(': - // Unnamed placeholder, e.g. `%(content='...')`. - placeholder = PlainText("") - case isPlaceholderNameCharacter(char): - name := str[:index+1] - - if pl := registered.Get(name); pl != nil { - placeholder = pl - optIndex = index + 1 - } - - // We don't stop at the first one we find, we look for the longest name. - // Of these two `%tf-command` `%tf-command-args` we need to find the second one. - continue - } - - if placeholder == nil { + if !isPlaceholderNameCharacter(str[index]) { break } - optStr := str[optIndex:] - - if len(optStr) != 0 && optStr[0] == '(' { - optLen, err := parsePlaceholderOption(placeholder, optStr[1:]) + name := str[:index+1] - return placeholder, index + optLen, err + if pl := phs.Get(name); pl != nil { + placeholder = pl + optIndex = index + 1 } - - return placeholder, optIndex - 1, nil } - if placeholder != nil { - return placeholder, len(str) - 1, nil + if placeholder != nil || len(str) == 0 { + return placeholder, str[optIndex:] } - switch str[0] { - case 't': + switch { + case strings.HasPrefix(str, options.OptStartSign): + // Unnamed placeholder, e.g. `%(content='...')`. + return PlainText(""), str + case strings.HasPrefix(str, "t"): // Placeholder indent, e.g. `%t`. - return PlainText("\t"), 0, nil - case 'n': + return PlainText("\t"), str[1:] + case strings.HasPrefix(str, "n"): // Placeholder newline, e.g. `%n`. - return PlainText("\n"), 0, nil + return PlainText("\n"), str[1:] } - return nil, 0, errors.Errorf("invalid placeholder name %q, available values: %s", str, strings.Join(registered.Names(), ",")) + return nil, str } func isPlaceholderNameCharacter(c byte) bool {