Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions pkg/exprhelpers/expr_lib.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
249 changes: 249 additions & 0 deletions pkg/exprhelpers/exprlib_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
}
}
72 changes: 72 additions & 0 deletions pkg/exprhelpers/strings.go
Original file line number Diff line number Diff line change
@@ -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 = &regexCache{
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) {
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}