diff --git a/ast/builtins.go b/ast/builtins.go index 0eca84c94a..bb0cb0c681 100644 --- a/ast/builtins.go +++ b/ast/builtins.go @@ -120,6 +120,7 @@ var DefaultBuiltins = [...]*Builtin{ Lower, Upper, Contains, + StringCount, StartsWith, EndsWith, Split, @@ -1109,6 +1110,19 @@ var Contains = &Builtin{ Categories: stringsCat, } +var StringCount = &Builtin{ + Name: "strings.count", + Description: "Returns the number of non-overlapping instances of a substring in a string.", + Decl: types.NewFunction( + types.Args( + types.Named("search", types.S).Description("string to search in"), + types.Named("substring", types.S).Description("substring to look for"), + ), + types.Named("output", types.N).Description("count of occurrences, `0` if not found"), + ), + Categories: stringsCat, +} + var StartsWith = &Builtin{ Name: "startswith", Description: "Returns true if the search string begins with the base string.", diff --git a/builtin_metadata.json b/builtin_metadata.json index 3cc8106ac3..e751046259 100644 --- a/builtin_metadata.json +++ b/builtin_metadata.json @@ -178,6 +178,7 @@ "startswith", "strings.any_prefix_match", "strings.any_suffix_match", + "strings.count", "strings.render_template", "strings.replace_n", "strings.reverse", @@ -17382,6 +17383,31 @@ }, "wasm": false }, + "strings.count": { + "args": [ + { + "description": "string to search in", + "name": "search", + "type": "string" + }, + { + "description": "substring to look for", + "name": "substring", + "type": "string" + } + ], + "available": [ + "edge" + ], + "description": "Returns the number of non-overlapping instances of a substring in a string.", + "introduced": "edge", + "result": { + "description": "count of occurrences, `0` if not found", + "name": "output", + "type": "number" + }, + "wasm": false + }, "strings.render_template": { "args": [ { diff --git a/capabilities.json b/capabilities.json index 06c04773c1..862a4555f9 100644 --- a/capabilities.json +++ b/capabilities.json @@ -3969,6 +3969,23 @@ "type": "function" } }, + { + "name": "strings.count", + "decl": { + "args": [ + { + "type": "string" + }, + { + "type": "string" + } + ], + "result": { + "type": "number" + }, + "type": "function" + } + }, { "name": "strings.render_template", "decl": { diff --git a/test/cases/testdata/strings/test-strings-0926.yaml b/test/cases/testdata/strings/test-strings-0926.yaml new file mode 100644 index 0000000000..6de1168345 --- /dev/null +++ b/test/cases/testdata/strings/test-strings-0926.yaml @@ -0,0 +1,26 @@ +--- +cases: + - note: "strings/count_single_word_match" + query: data.test.p = x + modules: + - | + package test + p := strings.count("cheese", "e") + want_result: + - x: 3 + - note: "strings/count_multiple_separate_matches" + query: data.test.p = x + modules: + - | + package test + p := strings.count("hello hello hello world", "hello") + want_result: + - x: 3 + - note: "strings/count_n_no_match" + query: data.test.p = x + modules: + - | + package test + p := strings.count("dummy", "x") + want_result: + - x: 0 diff --git a/topdown/strings.go b/topdown/strings.go index 57f8eab9ca..d9e4a55e58 100644 --- a/topdown/strings.go +++ b/topdown/strings.go @@ -310,6 +310,25 @@ func builtinContains(_ BuiltinContext, operands []*ast.Term, iter func(*ast.Term return iter(ast.BooleanTerm(strings.Contains(string(s), string(substr)))) } +func builtinStringCount(_ BuiltinContext, operands []*ast.Term, iter func(*ast.Term) error) error { + s, err := builtins.StringOperand(operands[0].Value, 1) + if err != nil { + return err + } + + substr, err := builtins.StringOperand(operands[1].Value, 2) + if err != nil { + return err + } + + baseTerm := string(s) + searchTerm := string(substr) + + count := strings.Count(baseTerm, searchTerm) + + return iter(ast.IntNumberTerm(count)) +} + func builtinStartsWith(_ BuiltinContext, operands []*ast.Term, iter func(*ast.Term) error) error { s, err := builtins.StringOperand(operands[0].Value, 1) if err != nil { @@ -570,6 +589,7 @@ func init() { RegisterBuiltinFunc(ast.IndexOfN.Name, builtinIndexOfN) RegisterBuiltinFunc(ast.Substring.Name, builtinSubstring) RegisterBuiltinFunc(ast.Contains.Name, builtinContains) + RegisterBuiltinFunc(ast.StringCount.Name, builtinStringCount) RegisterBuiltinFunc(ast.StartsWith.Name, builtinStartsWith) RegisterBuiltinFunc(ast.EndsWith.Name, builtinEndsWith) RegisterBuiltinFunc(ast.Upper.Name, builtinUpper)