Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Adding %tf-command log placeholder for custom format #3709

Merged
merged 12 commits into from
Dec 27, 2024
8 changes: 5 additions & 3 deletions docs/_docs/04_reference/log-formatting.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,15 @@ Placeholders have preset names:

* `%tf-path` - Path to the OpenTofu/Terraform executable (as defined by [terragrunt-tfpath](https://terragrunt.gruntwork.io/docs/reference/cli-options/#terragrunt-tfpath)).

* `%tf-command-args` - Arguments of the executed OpenTofu/Terraform command.
* `%tf-command` - Executed OpenTofu/Terraform command, e.g. `apply`.

* `%t` - Tab.
* `%tf-command-args` - Arguments of the executed OpenTofu/Terraform command, e.g. `apply -auto-approve`.

* `%t` - Indent.

* `%n` - Newline.

Any other text is considered plain text.
Any other text is considered plain text. The parser always tries to find the longest name. For example, tofu command "apply -auto-approve" with format "%tf-command-args" will be replaced with "apply -auto-approve", but not "apply-args". If you need to replace it with "apply-args", use empty brackets "%tf-command()-args". More examples: "%tf-path" will be replaced with "tofu", `%t()-path` will be replaced with " -path".

e.g.

Expand Down
5 changes: 5 additions & 0 deletions pkg/log/format/options/color.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package options
import (
"strconv"
"strings"
"sync"

"github.com/gruntwork-io/terragrunt/internal/errors"
"github.com/gruntwork-io/terragrunt/pkg/log"
Expand Down Expand Up @@ -212,6 +213,7 @@ type gradientColor struct {
// We use [xsync.MapOf](https://github.com/puzpuzpuz/xsync?tab=readme-ov-file#map) instead of standard `sync.Map` since it's faster and has generic types.
cache *xsync.MapOf[string, ColorValue]
values []ColorValue
mu sync.Mutex

// nextStyleIndex is used to get the next style from the `codes` list for a newly discovered text.
nextStyleIndex int
Expand All @@ -225,6 +227,9 @@ func newGradientColor() *gradientColor {
}

func (color *gradientColor) Value(text string) ColorValue {
color.mu.Lock()
defer color.mu.Unlock()

if colorCode, ok := color.cache.Load(text); ok {
return colorCode
}
Expand Down
6 changes: 5 additions & 1 deletion pkg/log/format/options/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package options

import (
"fmt"
"sort"
"strconv"
"strings"

Expand Down Expand Up @@ -110,7 +111,10 @@ func (val *MapValue[T]) Parse(str string) error {
}
}

return errors.Errorf("available values: %s", strings.Join(maps.Values(val.list), ","))
list := maps.Values(val.list)
sort.Strings(list)

return errors.Errorf("available values: %s", strings.Join(list, ","))
}

func (val *MapValue[T]) Filter(vals ...T) MapValue[T] {
Expand Down
80 changes: 80 additions & 0 deletions pkg/log/format/options/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package options

import (
"fmt"
"strings"
)

// InvalidOptionError is an invalid `option` syntax error.
type InvalidOptionError struct {
str string
}

// NewInvalidOptionError returns a new `InvalidOptionError` instance.
func NewInvalidOptionError(str string) *InvalidOptionError {
return &InvalidOptionError{
str: str,
}
}

func (err InvalidOptionError) Error() string {
return fmt.Sprintf("invalid option syntax %q", err.str)
}

// EmptyOptionNameError is an empty `option` name error.
type EmptyOptionNameError struct {
str string
}

// NewEmptyOptionNameError returns a new `EmptyOptionNameError` instance.
func NewEmptyOptionNameError(str string) *EmptyOptionNameError {
return &EmptyOptionNameError{
str: str,
}
}

func (err EmptyOptionNameError) Error() string {
return fmt.Sprintf("empty option name %q", err.str)
}

// InvalidOptionNameError is an invalid `option` name error.
type InvalidOptionNameError struct {
name string
opts Options
}

// NewInvalidOptionNameError returns a new `InvalidOptionNameError` instance.
func NewInvalidOptionNameError(name string, opts Options) *InvalidOptionNameError {
return &InvalidOptionNameError{
name: name,
opts: opts,
}
}

func (err InvalidOptionNameError) Error() string {
return fmt.Sprintf("invalid option name %q, available names: %s", err.name, strings.Join(err.opts.Names(), ","))
}

// InvalidOptionValueError is an invalid `option` value error.
type InvalidOptionValueError struct {
val string
opt Option
err error
}

// NewInvalidOptionValueError returns a new `InvalidOptionValueError` instance.
func NewInvalidOptionValueError(opt Option, val string, err error) *InvalidOptionValueError {
return &InvalidOptionValueError{
val: val,
opt: opt,
err: err,
}
}

func (err InvalidOptionValueError) Error() string {
return fmt.Sprintf("option %q, invalid value %q, %v", err.opt.Name(), err.val, err.err)
}

func (err InvalidOptionValueError) Unwrap() error {
return err.err
}
142 changes: 136 additions & 6 deletions pkg/log/format/options/option.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,26 @@ 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.
// Configure parses and sets the value of the option.
Parse(str string) error
// Get returns the value of the option.
Get() T
Expand All @@ -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
}
Expand Down Expand Up @@ -76,15 +89,132 @@ 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
}

// Configure parsers the given `str` to configure the `opts` and returns the rest of the given `str`.
//
// e.g. (color=green, case=upper) some-text" sets `color` option to `green`, `case` option to `upper` and returns " some-text".
func (opts Options) Configure(str string) (string, error) {
if len(str) == 0 || !strings.HasPrefix(str, OptStartSign) {
return str, nil
}

str = str[1:]

for {
var (
ok bool
err error
)

if str, ok = nextOption(str); !ok {
return str, nil
}

parts := strings.SplitN(str, OptNameValueSep, splitIntoNameAndValue)
if len(parts) != splitIntoNameAndValue {
return "", errors.New(NewInvalidOptionError(str))
}

name := strings.TrimSpace(parts[0])

if name == "" {
return "", errors.New(NewEmptyOptionNameError(str))
}

opt := opts.Get(name)
if opt == nil {
return "", errors.New(NewInvalidOptionNameError(name, opts))
}

if str, err = setOptionValue(opt, parts[1]); err != nil {
return "", err
}
}
}

// setOptionValue parses the given `str` and sets the value for the given `opt` and returns the rest of the given `str`.
//
// e.g. "green, case=upper) some-text" sets "green" to the option and returns ", case=upper) some-text".
// e.g. "' quoted value ') some-text" sets " quoted value " to the option and returns ") some-text".
func setOptionValue(opt Option, str string) (string, error) {
var quoteChar byte

for index := range str {
if quoteOpened(str[:index], &quoteChar) {
continue
}

lastSign := str[index : index+1]
if !strings.HasSuffix(lastSign, OptSep) && !strings.HasSuffix(lastSign, OptEndSign) {
continue
}

val := strings.TrimSpace(str[:index])
val = strings.Trim(val, "'")
val = strings.Trim(val, "\"")

if err := opt.ParseValue(val); err != nil {
return "", errors.New(NewInvalidOptionValueError(opt, val, err))
}

return str[index:], nil
}

return str, nil
}

// nextOption returns true if the given `str` contains one more option
// and returns the given `str` without separator sign "," or ")".
//
// e.g. ",color=green) some-text" returns "color=green) some-text" and `true`.
// e.g. "(color=green) some-text" returns "color=green) some-text" and `true`.
// e.g. ") some-text" returns " some-text" and `false`.
func nextOption(str string) (string, bool) {
str = strings.TrimLeftFunc(str, unicode.IsSpace)

switch {
case strings.HasPrefix(str, OptEndSign):
return str[1:], false
case strings.HasPrefix(str, OptSep):
return str[1:], true
}

return str, true
}

// quoteOpened returns true if the given `str` contains an unclosed quote.
//
// e.g. "%(content=' level" return `true`.
// e.g. "%(content=' level '" return `false`.
// e.g. "%(content=\" level" return `true`.
func quoteOpened(str string, quoteChar *byte) bool {
strlen := len(str)

if strlen == 0 {
return false
}

char := str[strlen-1]

if char == '"' || char == '\'' {
if *quoteChar == 0 {
*quoteChar = char
} else if *quoteChar == char && (strlen < 2 || str[strlen-2] != '\\') {
*quoteChar = 0
}
}

return *quoteChar != 0
}
13 changes: 3 additions & 10 deletions pkg/log/format/placeholders/common.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
package placeholders

import (
"strings"

"github.com/gruntwork-io/terragrunt/internal/errors"
"github.com/gruntwork-io/terragrunt/pkg/log/format/options"
)

Expand Down Expand Up @@ -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.
Expand Down
Loading