From 668ec4ddd6dcd6238a67d283295e0335d1b1d947 Mon Sep 17 00:00:00 2001 From: Christopher LaPointe Date: Mon, 2 Feb 2026 20:27:02 -0500 Subject: [PATCH 1/2] Add pick, index, lastindex --- docs/usage/expressions.md | 21 ++++++- pkg/expressions/stdlib/funcs.go | 23 ++++--- pkg/expressions/stdlib/funcsStrings.go | 68 +++++++++++++++++++++ pkg/expressions/stdlib/funcsStrings_test.go | 22 +++++++ 4 files changed, 123 insertions(+), 11 deletions(-) diff --git a/docs/usage/expressions.md b/docs/usage/expressions.md index d50926c..a2d8d8d 100644 --- a/docs/usage/expressions.md +++ b/docs/usage/expressions.md @@ -100,10 +100,19 @@ Evaluates arguments in-order, choosing the first non-empty result. Syntax: `{select {0} 1}` -Assuming that `{0}` is a whitespace-separated value, split the values and select the item at index `1` +Assuming that `{0}` is a whitespace-separated value, split the values and select the item at index `1`, +respecting quotes. Eg. `{select "ab cd ef" 1}` will result in `cd` +#### Pick + +Syntax: `{pick {0} "delim" {index}}` + +Given a string, and a delimiter, select the string at a given index. + +Eg. `{pick "a,b,c" , 1}` will return `b` + #### Bucket Syntax: `{bucket intVal "bucketSize"}` @@ -259,6 +268,16 @@ Syntax: `{substr {0} pos length}` Takes the substring of the first argument starting at `pos` for `length` +#### Index, LastIndex + +Syntax: `{index {0} {of}}`, `{lastindex {0} {of}}` + +Returns the numeric index of a substring within a string. + +*lastindex* returns the index of the last occurrence of a substring. + +Example: `{index abcdef c}` returns `2` + #### Upper, Lower Syntax: `{upper val}`, `{lower val}` diff --git a/pkg/expressions/stdlib/funcs.go b/pkg/expressions/stdlib/funcs.go index b249bfa..f1066a3 100644 --- a/pkg/expressions/stdlib/funcs.go +++ b/pkg/expressions/stdlib/funcs.go @@ -86,16 +86,19 @@ var StandardFunctions = map[string]KeyBuilderFunction{ "or": KeyBuilderFunction(kfOr), // Strings - "len": KeyBuilderFunction(kfLen), - "like": KeyBuilderFunction(kfLike), - "prefix": KeyBuilderFunction(kfPrefix), - "suffix": KeyBuilderFunction(kfSuffix), - "format": KeyBuilderFunction(kfFormat), - "substr": KeyBuilderFunction(kfSubstr), - "select": KeyBuilderFunction(kfSelect), - "upper": KeyBuilderFunction(kfUpper), - "lower": KeyBuilderFunction(kfLower), - "replace": KeyBuilderFunction(kfReplace), + "len": KeyBuilderFunction(kfLen), + "like": KeyBuilderFunction(kfLike), + "prefix": KeyBuilderFunction(kfPrefix), + "suffix": KeyBuilderFunction(kfSuffix), + "format": KeyBuilderFunction(kfFormat), + "substr": KeyBuilderFunction(kfSubstr), + "select": KeyBuilderFunction(kfSelect), + "index": kfIndexOf, + "lastindex": kfLastIndexOf, + "pick": kfPick, + "upper": KeyBuilderFunction(kfUpper), + "lower": KeyBuilderFunction(kfLower), + "replace": KeyBuilderFunction(kfReplace), // Separation (Join) "tab": kfJoin('\t'), diff --git a/pkg/expressions/stdlib/funcsStrings.go b/pkg/expressions/stdlib/funcsStrings.go index 6125f6e..8cfe38e 100644 --- a/pkg/expressions/stdlib/funcsStrings.go +++ b/pkg/expressions/stdlib/funcsStrings.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/zix99/rare/pkg/humanize" + "github.com/zix99/rare/pkg/stringSplitter" . "github.com/zix99/rare/pkg/expressions" //lint:ignore ST1001 Legacy ) @@ -110,6 +111,73 @@ func kfSubstr(args []KeyBuilderStage) (KeyBuilderStage, error) { }), nil } +// {index {0} {search}} +func kfIndexOf(args []KeyBuilderStage) (KeyBuilderStage, error) { + if len(args) != 2 { + return stageErrArgCount(args, 2) + } + + return func(context KeyBuilderContext) string { + s := args[0](context) + search := args[1](context) + + idx := strings.Index(s, search) + + return strconv.Itoa(idx) + }, nil +} + +// {lastindex {0} {search}} +func kfLastIndexOf(args []KeyBuilderStage) (KeyBuilderStage, error) { + if len(args) != 2 { + return stageErrArgCount(args, 2) + } + + return func(context KeyBuilderContext) string { + s := args[0](context) + search := args[1](context) + + idx := strings.LastIndex(s, search) + + return strconv.Itoa(idx) + }, nil +} + +// {pick {0} "delim" idx} +func kfPick(args []KeyBuilderStage) (KeyBuilderStage, error) { + if len(args) != 3 { + return stageErrArgCount(args, 3) + } + + delim, delimOk := EvalStaticStage(args[1]) + if !delimOk { + return stageArgError(ErrConst, 1) + } + + idx, idxOk := evalTypedStage(args[2], typedParserInt) + if !idxOk { + return stageArgError(ErrNum, 2) + } + + return func(context KeyBuilderContext) string { + splitter := stringSplitter.Splitter{ + S: args[0](context), + Delim: delim, + } + + offset, offsetOk := idx(context) + if !offsetOk { + return ErrorNum + } + + item := splitter.Next() + for range offset { + item = splitter.Next() + } + return item + }, nil +} + // {select {0} 1} func kfSelect(args []KeyBuilderStage) (KeyBuilderStage, error) { if len(args) != 2 { diff --git a/pkg/expressions/stdlib/funcsStrings_test.go b/pkg/expressions/stdlib/funcsStrings_test.go index 6909b28..0b0611b 100644 --- a/pkg/expressions/stdlib/funcsStrings_test.go +++ b/pkg/expressions/stdlib/funcsStrings_test.go @@ -27,6 +27,28 @@ func TestReplace(t *testing.T) { testExpressionErr(t, mockContext(), "{replace a} {replace a b} {replace a b c d}", " ", ErrArgCount) } +func TestIndexOf(t *testing.T) { + testExpression(t, mockContext(), "{index abcdeaba b} {index abc x}", "1 -1") + testExpressionErr(t, mockContext(), "{index a} {index a b c}", " ", ErrArgCount) +} + +func TestLastIndexOf(t *testing.T) { + testExpression(t, mockContext(), "{lastindex abcdeaba b} {lastindex abc x}", "6 -1") + testExpressionErr(t, mockContext(), "{lastindex a} {lastindex a b c}", " ", ErrArgCount) +} + +func TestPick(t *testing.T) { + testExpression(t, + mockContext("apple,banana,cherry", "one|two|three|four"), + "{pick {0} , 0} {pick {0} , 1} {pick {0} , 2} {pick {1} | 2} {pick {1} | 10}", + "apple banana cherry three ") + testExpressionErr(t, mockContext(), "{pick a}", "", ErrArgCount) + testExpressionErr(t, mockContext(","), "{pick {0} , a}", "", ErrNum) + testExpressionErr(t, mockContext("abc", ","), "{pick {0} {1} 0}", "", ErrConst) + + testExpression(t, mockContext("abc", ","), "{pick {0} , {0}}", "") +} + func TestSubstring(t *testing.T) { testExpression(t, mockContext("abcd"), From 99e3e65410fb49d3bf58d5a1ef2e71248f9fda61 Mon Sep 17 00:00:00 2001 From: Christopher LaPointe Date: Mon, 2 Feb 2026 21:33:02 -0500 Subject: [PATCH 2/2] All substr to omit length --- docs/usage/expressions.md | 6 +++- pkg/expressions/stdlib/funcsStrings.go | 36 +++++++++++++++------ pkg/expressions/stdlib/funcsStrings_test.go | 12 +++++-- 3 files changed, 41 insertions(+), 13 deletions(-) diff --git a/docs/usage/expressions.md b/docs/usage/expressions.md index a2d8d8d..cd9b8e4 100644 --- a/docs/usage/expressions.md +++ b/docs/usage/expressions.md @@ -264,10 +264,14 @@ Formats a string based on `fmt.Sprintf`: [Go Docs](https://pkg.go.dev/fmt) #### Substring -Syntax: `{substr {0} pos length}` +Syntax: `{substr {0} pos [length]}` Takes the substring of the first argument starting at `pos` for `length` +If `pos` is `< 0`, will wrap-around at the length of the string. + +If `length` is omitted or `< 0`, will return up to the end of the string. + #### Index, LastIndex Syntax: `{index {0} {of}}`, `{lastindex {0} {of}}` diff --git a/pkg/expressions/stdlib/funcsStrings.go b/pkg/expressions/stdlib/funcsStrings.go index 8cfe38e..55c2c55 100644 --- a/pkg/expressions/stdlib/funcsStrings.go +++ b/pkg/expressions/stdlib/funcsStrings.go @@ -71,10 +71,26 @@ func kfLower(args []KeyBuilderStage) (KeyBuilderStage, error) { }, nil } -// {substr {0} left len} +// {substr {0} left [len]} func kfSubstr(args []KeyBuilderStage) (KeyBuilderStage, error) { - if len(args) != 3 { - return stageErrArgCount(args, 3) + if !isArgCountBetween(args, 2, 3) { + return stageErrArgRange(args, "2-3") + } + + leftArg, leftOk := evalTypedStage(args[1], typedParserInt) + if !leftOk { + return stageArgError(ErrNum, 1) + } + + var lengthArg typedStage[int] + if len(args) >= 3 { + var lengthOk bool + lengthArg, lengthOk = evalTypedStage(args[2], typedParserInt) + if !lengthOk { + return stageArgError(ErrNum, 2) + } + } else { + lengthArg = typedLiteral(-1) } return KeyBuilderStage(func(context KeyBuilderContext) string { @@ -84,15 +100,12 @@ func kfSubstr(args []KeyBuilderStage) (KeyBuilderStage, error) { return "" } - left, err1 := strconv.Atoi(args[1](context)) - length, err2 := strconv.Atoi(args[2](context)) - if err1 != nil || err2 != nil { + left, leftOk := leftArg(context) + length, lengthOk := lengthArg(context) + if !leftOk || !lengthOk { return ErrorNum } - if length < 0 { - length = 0 - } if left < 0 { // negative number wrap-around left += lenS if left < 0 { @@ -102,6 +115,11 @@ func kfSubstr(args []KeyBuilderStage) (KeyBuilderStage, error) { left = lenS } + // length wrap-around + if length < 0 { + length = lenS - left + } + right := left + length if right > lenS { diff --git a/pkg/expressions/stdlib/funcsStrings_test.go b/pkg/expressions/stdlib/funcsStrings_test.go index 0b0611b..600cde5 100644 --- a/pkg/expressions/stdlib/funcsStrings_test.go +++ b/pkg/expressions/stdlib/funcsStrings_test.go @@ -54,9 +54,9 @@ func TestSubstring(t *testing.T) { mockContext("abcd"), "{substr {0} 0 2} {substr {0} 0 10} {substr {0} 3 2} {substr {0} 3 1}", "ab abcd d d") - testExpressionErr(t, - mockContext("abcd"), - "{substr 0}", "", ErrArgCount) + testExpression(t, mockContext("abcd"), "{substr {0} 1} {substr {0} 3} {substr {0} 4} {substr {0} 10} {substr {0} 1 -1}", "bcd d bcd") + testExpressionErr(t, mockContext("abcd"), "{substr 0}", "", ErrArgCount) + testExpressionErr(t, mockContext(), "{substr abc 0 a}", "", ErrNum) } func TestSubstringOutOfBounds(t *testing.T) { @@ -165,3 +165,9 @@ func BenchmarkSelectItem(b *testing.B) { func BenchmarkPercent(b *testing.B) { benchmarkExpression(b, mockContext(), "{percent 50 1 0 100}", "50.0%") } + +// BenchmarkSubstring/{substr_{0}_6_2}-4 30473634 36.31 ns/op 0 B/op 0 allocs/op +// BenchmarkSubstring/{substr_{0}_6_2}-4 45346548 22.51 ns/op 0 B/op 0 allocs/op +func BenchmarkSubstring(b *testing.B) { + benchmarkExpression(b, mockContext("hello to you"), "{substr {0} 6 2}", "to") +}