diff --git a/pkg/exprhelpers/expr_lib.go b/pkg/exprhelpers/expr_lib.go index 40ec27d6af2..eb8bee59bbe 100644 --- a/pkg/exprhelpers/expr_lib.go +++ b/pkg/exprhelpers/expr_lib.go @@ -363,6 +363,20 @@ var exprFuncs = []exprCustomFunc{ new(func(string, string, string) string), }, }, + { + name: "ReplaceRegexp", + function: ReplaceRegexp, + signature: []any{ + new(func(string, string, string) string), + }, + }, + { + name: "ReplaceAllRegex", + function: ReplaceAllRegex, + signature: []any{ + new(func(string, string, string) string), + }, + }, { name: "Trim", function: Trim, @@ -440,6 +454,13 @@ var exprFuncs = []exprCustomFunc{ new(func(string, ...any) bool), }, }, + { + name: "AnsiRegex", + function: AnsiRegex, + signature: []any{ + new(func() string), + }, + }, { name: "B64Decode", function: B64Decode, diff --git a/pkg/exprhelpers/exprlib_test.go b/pkg/exprhelpers/exprlib_test.go index cdcf440074d..6dae4745255 100644 --- a/pkg/exprhelpers/exprlib_test.go +++ b/pkg/exprhelpers/exprlib_test.go @@ -2201,3 +2201,252 @@ func TestParseKv(t *testing.T) { }) } } + +func TestReplaceRegexp(t *testing.T) { + err := Init(nil) + require.NoError(t, err) + + tests := []struct { + name string + env map[string]any + code string + result string + err string + }{ + { + name: "ReplaceRegexp() test: replace first occurrence", + env: map[string]any{ + "pattern": "foo", + "source": "foobar foobaz", + "repl": "qux", + }, + code: "ReplaceRegexp(pattern, source, repl)", + result: "quxbar foobaz", + }, + { + name: "ReplaceRegexp() test: no match", + env: map[string]any{ + "pattern": "xyz", + "source": "foobar foobaz", + "repl": "qux", + }, + code: "ReplaceRegexp(pattern, source, repl)", + result: "foobar foobaz", + }, + { + name: "ReplaceRegexp() test: regex with special chars", + env: map[string]any{ + "pattern": "\\d+", + "source": "abc123def456", + "repl": "X", + }, + code: "ReplaceRegexp(pattern, source, repl)", + result: "abcXdef456", + }, + { + name: "ReplaceRegexp() test: case insensitive", + env: map[string]any{ + "pattern": "(?i)FOO", + "source": "foobar FOOBAZ", + "repl": "qux", + }, + code: "ReplaceRegexp(pattern, source, repl)", + result: "quxbar FOOBAZ", + }, + { + name: "ReplaceRegexp() test: invalid regex", + env: map[string]any{ + "pattern": "[", + "source": "foobar", + "repl": "qux", + }, + code: "ReplaceRegexp(pattern, source, repl)", + err: "error parsing regexp", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + program, err := expr.Compile(test.code, GetExprOptions(test.env)...) + require.NoError(t, err) + output, err := expr.Run(program, test.env) + + if test.err != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), test.err) + return + } + + require.NoError(t, err) + require.Equal(t, test.result, output) + }) + } +} + +func TestReplaceAllRegex(t *testing.T) { + err := Init(nil) + require.NoError(t, err) + + tests := []struct { + name string + env map[string]any + code string + result string + err string + }{ + { + name: "ReplaceAllRegex() test: replace all occurrences", + env: map[string]any{ + "pattern": "foo", + "source": "foobar foobaz", + "repl": "qux", + }, + code: "ReplaceAllRegex(pattern, source, repl)", + result: "quxbar quxbaz", + }, + { + name: "ReplaceAllRegex() test: no match", + env: map[string]any{ + "pattern": "xyz", + "source": "foobar foobaz", + "repl": "qux", + }, + code: "ReplaceAllRegex(pattern, source, repl)", + result: "foobar foobaz", + }, + { + name: "ReplaceAllRegex() test: regex with special chars", + env: map[string]any{ + "pattern": "\\d+", + "source": "abc123def456", + "repl": "X", + }, + code: "ReplaceAllRegex(pattern, source, repl)", + result: "abcXdefX", + }, + { + name: "ReplaceAllRegex() test: case insensitive", + env: map[string]any{ + "pattern": "(?i)FOO", + "source": "foobar FOOBAZ", + "repl": "qux", + }, + code: "ReplaceAllRegex(pattern, source, repl)", + result: "quxbar quxBAZ", + }, + { + name: "ReplaceAllRegex() test: multiple matches with capture groups", + env: map[string]any{ + "pattern": "(\\w+)@(\\w+)", + "source": "user1@domain1 user2@domain2", + "repl": "$1[at]$2", + }, + code: "ReplaceAllRegex(pattern, source, repl)", + result: "user1[at]domain1 user2[at]domain2", + }, + { + name: "ReplaceAllRegex() test: invalid regex", + env: map[string]any{ + "pattern": "[", + "source": "foobar", + "repl": "qux", + }, + code: "ReplaceAllRegex(pattern, source, repl)", + err: "error parsing regexp", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + program, err := expr.Compile(test.code, GetExprOptions(test.env)...) + require.NoError(t, err) + output, err := expr.Run(program, test.env) + + if test.err != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), test.err) + return + } + + require.NoError(t, err) + require.Equal(t, test.result, output) + }) + } +} + +func TestAnsiRegex(t *testing.T) { + err := Init(nil) + require.NoError(t, err) + + tests := []struct { + name string + env map[string]any + code string + result string + }{ + { + name: "AnsiRegex() test: returns ANSI regex pattern", + env: map[string]any{}, + code: "AnsiRegex()", + result: `\x1b\[[0-9;]*m|\033\[[0-9;]*m`, + }, + { + name: "AnsiRegex() test: can be used with ReplaceAllRegex", + env: map[string]any{ + "coloredText": "\x1b[31mHello\x1b[0m \x1b[32mWorld\x1b[0m", + }, + code: "ReplaceAllRegex(AnsiRegex(), coloredText, '')", + result: "Hello World", + }, + { + name: "AnsiRegex() test: can be used with ReplaceRegexp (first occurrence only)", + env: map[string]any{ + "coloredText": "\x1b[31mHello\x1b[0m \x1b[32mWorld\x1b[0m", + }, + code: "ReplaceRegexp(AnsiRegex(), coloredText, '')", + result: "Hello\x1b[0m \x1b[32mWorld\x1b[0m", + }, + { + name: "AnsiRegex() test: handles complex ANSI sequences", + env: map[string]any{ + "complexText": "\x1b[38;5;208mOrange\x1b[0m \x1b[38;5;119mLightGreen\x1b[0m", + }, + code: "ReplaceAllRegex(AnsiRegex(), complexText, '')", + result: "Orange LightGreen", + }, + { + name: "AnsiRegex() test: handles background colors", + env: map[string]any{ + "bgText": "\x1b[41mRed Background\x1b[0m \x1b[42mGreen Background\x1b[0m", + }, + code: "ReplaceAllRegex(AnsiRegex(), bgText, '')", + result: "Red Background Green Background", + }, + { + name: "AnsiRegex() test: handles mixed hex and octal formats", + env: map[string]any{ + "mixedText": "\x1b[31mRed\x1b[0m \033[32mGreen\033[0m \x1b[33mYellow\x1b[0m", + }, + code: "ReplaceAllRegex(AnsiRegex(), mixedText, '')", + result: "Red Green Yellow", + }, + { + name: "AnsiRegex() test: handles both hex and octal formats", + env: map[string]any{ + "bothFormats": "\x1b[31mRed\x1b[0m \033[32mGreen\033[0m \x1b[33mYellow\x1b[0m", + }, + code: "ReplaceAllRegex(AnsiRegex(), bothFormats, '')", + result: "Red Green Yellow", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + program, err := expr.Compile(test.code, GetExprOptions(test.env)...) + require.NoError(t, err) + output, err := expr.Run(program, test.env) + require.NoError(t, err) + require.Equal(t, test.result, output) + }) + } +} diff --git a/pkg/exprhelpers/strings.go b/pkg/exprhelpers/strings.go index 61804bcfa5f..5d313e69f3e 100644 --- a/pkg/exprhelpers/strings.go +++ b/pkg/exprhelpers/strings.go @@ -1,11 +1,54 @@ package exprhelpers import ( + "regexp" "strings" + "sync" log "github.com/sirupsen/logrus" ) +// regexCache stores compiled regex patterns for reuse +type regexCache struct { + mu sync.RWMutex + cache map[string]*regexp.Regexp +} + +var ( + regexCacheInstance = ®exCache{ + cache: make(map[string]*regexp.Regexp), + } +) + +// getCompiledRegex returns a compiled regex pattern from cache or compiles and caches it +func (rc *regexCache) getCompiledRegex(pattern string) (*regexp.Regexp, error) { + // Try to get from cache first (read lock) + rc.mu.RLock() + if re, exists := rc.cache[pattern]; exists { + rc.mu.RUnlock() + return re, nil + } + rc.mu.RUnlock() + + // Compile the regex (write lock) + rc.mu.Lock() + defer rc.mu.Unlock() + + // Double-check after acquiring write lock + if re, exists := rc.cache[pattern]; exists { + return re, nil + } + + // Compile and cache + re, err := regexp.Compile(pattern) + if err != nil { + return nil, err + } + + rc.cache[pattern] = re + return re, nil +} + //Wrappers for stdlib strings function exposed in expr func Fields(params ...any) (any, error) { @@ -48,6 +91,28 @@ func ReplaceAll(params ...any) (any, error) { return strings.ReplaceAll(params[0].(string), params[1].(string), params[2].(string)), nil } +func ReplaceRegexp(params ...any) (any, error) { + re, err := regexCacheInstance.getCompiledRegex(params[0].(string)) + if err != nil { + return nil, err + } + // Replace only the first occurrence + loc := re.FindStringIndex(params[1].(string)) + if loc == nil { + return params[1].(string), nil // No match found, return original string + } + start, end := loc[0], loc[1] + return params[1].(string)[:start] + params[2].(string) + params[1].(string)[end:], nil +} + +func ReplaceAllRegex(params ...any) (any, error) { + re, err := regexCacheInstance.getCompiledRegex(params[0].(string)) + if err != nil { + return nil, err + } + return re.ReplaceAllString(params[1].(string), params[2].(string)), nil +} + func Trim(params ...any) (any, error) { return strings.Trim(params[0].(string), params[1].(string)), nil } @@ -76,3 +141,10 @@ func LogInfo(params ...any) (any, error) { log.Infof(params[0].(string), params[1:]...) return true, nil } + +func AnsiRegex(params ...any) (any, error) { + // Returns the regex pattern for ANSI escape sequences + // This can be used with ReplaceRegexp() or ReplaceAllRegex() functions + // Matches \x1b (hex) and \033 (octal) representations + return `\x1b\[[0-9;]*m|\033\[[0-9;]*m`, nil +}