Skip to content

Commit 433a184

Browse files
committed
Add regex helper builtins
1 parent 38a4fa8 commit 433a184

5 files changed

Lines changed: 146 additions & 1 deletion

File tree

ROADMAP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -282,7 +282,7 @@ Goal: reduce host-side boilerplate for common scripting tasks.
282282
### Core Utilities
283283

284284
- [x] Add JSON parse/stringify built-ins.
285-
- [ ] Add regex matching/replacement helpers.
285+
- [x] Add regex matching/replacement helpers.
286286
- [x] Add UUID/random identifier utilities with deterministic test hooks.
287287
- [ ] Add richer date/time parsing helpers for common layouts.
288288
- [ ] Add safer numeric conversions and clamp/round helpers.

docs/builtins.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,27 @@ a JSON string:
9696
raw = JSON.stringify({ id: "p-1", score: 10, tags: ["a", "b"] })
9797
```
9898

99+
## Regex
100+
101+
### `Regex.match(pattern, text)`
102+
103+
Returns the first match string or `nil` when no match exists.
104+
105+
### `Regex.replace(text, pattern, replacement)`
106+
107+
Replaces the first regex match in `text`.
108+
109+
### `Regex.replace_all(text, pattern, replacement)`
110+
111+
Replaces all regex matches in `text`.
112+
113+
```vibe
114+
Regex.match("ID-[0-9]+", "ID-12 ID-34") # "ID-12"
115+
Regex.replace("ID-12 ID-34", "ID-[0-9]+", "X") # "X ID-34"
116+
Regex.replace_all("ID-12 ID-34", "ID-[0-9]+", "X") # "X X"
117+
Regex.replace("ID-12", "ID-([0-9]+)", "X-$1") # "X-12"
118+
```
119+
99120
## Module Loading
100121

101122
### `require(module_name, as: alias?)`

vibes/builtins.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"fmt"
77
"io"
88
"reflect"
9+
"regexp"
910
"strings"
1011
"time"
1112
)
@@ -297,3 +298,77 @@ func vibeValueToJSONValue(val Value, state *jsonStringifyState) (any, error) {
297298
return nil, fmt.Errorf("JSON.stringify unsupported value type %s", val.Kind())
298299
}
299300
}
301+
302+
func builtinRegexMatch(exec *Execution, receiver Value, args []Value, kwargs map[string]Value, block Value) (Value, error) {
303+
if len(args) != 2 {
304+
return NewNil(), fmt.Errorf("Regex.match expects pattern and text")
305+
}
306+
if len(kwargs) > 0 {
307+
return NewNil(), fmt.Errorf("Regex.match does not accept keyword arguments")
308+
}
309+
if !block.IsNil() {
310+
return NewNil(), fmt.Errorf("Regex.match does not accept blocks")
311+
}
312+
if args[0].Kind() != KindString || args[1].Kind() != KindString {
313+
return NewNil(), fmt.Errorf("Regex.match expects string pattern and text")
314+
}
315+
316+
re, err := regexp.Compile(args[0].String())
317+
if err != nil {
318+
return NewNil(), fmt.Errorf("Regex.match invalid regex: %v", err)
319+
}
320+
match := re.FindString(args[1].String())
321+
if match == "" {
322+
return NewNil(), nil
323+
}
324+
return NewString(match), nil
325+
}
326+
327+
func builtinRegexReplace(exec *Execution, receiver Value, args []Value, kwargs map[string]Value, block Value) (Value, error) {
328+
return builtinRegexReplaceInternal(args, kwargs, block, false)
329+
}
330+
331+
func builtinRegexReplaceAll(exec *Execution, receiver Value, args []Value, kwargs map[string]Value, block Value) (Value, error) {
332+
return builtinRegexReplaceInternal(args, kwargs, block, true)
333+
}
334+
335+
func builtinRegexReplaceInternal(args []Value, kwargs map[string]Value, block Value, replaceAll bool) (Value, error) {
336+
method := "Regex.replace"
337+
if replaceAll {
338+
method = "Regex.replace_all"
339+
}
340+
341+
if len(args) != 3 {
342+
return NewNil(), fmt.Errorf("%s expects text, pattern, replacement", method)
343+
}
344+
if len(kwargs) > 0 {
345+
return NewNil(), fmt.Errorf("%s does not accept keyword arguments", method)
346+
}
347+
if !block.IsNil() {
348+
return NewNil(), fmt.Errorf("%s does not accept blocks", method)
349+
}
350+
if args[0].Kind() != KindString || args[1].Kind() != KindString || args[2].Kind() != KindString {
351+
return NewNil(), fmt.Errorf("%s expects string text, pattern, replacement", method)
352+
}
353+
354+
text := args[0].String()
355+
pattern := args[1].String()
356+
replacement := args[2].String()
357+
358+
re, err := regexp.Compile(pattern)
359+
if err != nil {
360+
return NewNil(), fmt.Errorf("%s invalid regex: %v", method, err)
361+
}
362+
363+
if replaceAll {
364+
return NewString(re.ReplaceAllString(text, replacement)), nil
365+
}
366+
367+
loc := re.FindStringIndex(text)
368+
if loc == nil {
369+
return NewString(text), nil
370+
}
371+
segment := text[loc[0]:loc[1]]
372+
replaced := re.ReplaceAllString(segment, replacement)
373+
return NewString(text[:loc[0]] + replaced + text[loc[1]:]), nil
374+
}

vibes/interpreter.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,11 @@ func NewEngine(cfg Config) (*Engine, error) {
8181
"parse": NewBuiltin("JSON.parse", builtinJSONParse),
8282
"stringify": NewBuiltin("JSON.stringify", builtinJSONStringify),
8383
})
84+
engine.builtins["Regex"] = NewObject(map[string]Value{
85+
"match": NewBuiltin("Regex.match", builtinRegexMatch),
86+
"replace": NewBuiltin("Regex.replace", builtinRegexReplace),
87+
"replace_all": NewBuiltin("Regex.replace_all", builtinRegexReplaceAll),
88+
})
8489
engine.builtins["Duration"] = NewObject(map[string]Value{
8590
"build": NewBuiltin("Duration.build", func(exec *Execution, receiver Value, args []Value, kwargs map[string]Value, block Value) (Value, error) {
8691
if len(args) == 1 && len(kwargs) == 0 {

vibes/runtime_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1710,6 +1710,50 @@ func TestJSONBuiltins(t *testing.T) {
17101710
}
17111711
}
17121712

1713+
func TestRegexBuiltins(t *testing.T) {
1714+
script := compileScript(t, `
1715+
def helpers()
1716+
{
1717+
match_hit: Regex.match("ID-[0-9]+", "ID-12 ID-34"),
1718+
match_miss: Regex.match("Z+", "ID-12"),
1719+
replace_one: Regex.replace("ID-12 ID-34", "ID-[0-9]+", "X"),
1720+
replace_all: Regex.replace_all("ID-12 ID-34", "ID-[0-9]+", "X"),
1721+
replace_capture: Regex.replace("ID-12 ID-34", "ID-([0-9]+)", "X-$1")
1722+
}
1723+
end
1724+
1725+
def invalid_regex()
1726+
Regex.match("[", "abc")
1727+
end
1728+
`)
1729+
1730+
result := callFunc(t, script, "helpers", nil)
1731+
if result.Kind() != KindHash {
1732+
t.Fatalf("expected hash, got %v", result.Kind())
1733+
}
1734+
out := result.Hash()
1735+
if !out["match_hit"].Equal(NewString("ID-12")) {
1736+
t.Fatalf("match_hit mismatch: %v", out["match_hit"])
1737+
}
1738+
if out["match_miss"].Kind() != KindNil {
1739+
t.Fatalf("expected match_miss nil, got %v", out["match_miss"])
1740+
}
1741+
if !out["replace_one"].Equal(NewString("X ID-34")) {
1742+
t.Fatalf("replace_one mismatch: %v", out["replace_one"])
1743+
}
1744+
if !out["replace_all"].Equal(NewString("X X")) {
1745+
t.Fatalf("replace_all mismatch: %v", out["replace_all"])
1746+
}
1747+
if !out["replace_capture"].Equal(NewString("X-12 ID-34")) {
1748+
t.Fatalf("replace_capture mismatch: %v", out["replace_capture"])
1749+
}
1750+
1751+
_, err := script.Call(context.Background(), "invalid_regex", nil, CallOptions{})
1752+
if err == nil || !strings.Contains(err.Error(), "Regex.match invalid regex") {
1753+
t.Fatalf("expected invalid regex error, got %v", err)
1754+
}
1755+
}
1756+
17131757
func TestRandomIdentifierBuiltins(t *testing.T) {
17141758
engine := MustNewEngine(Config{
17151759
RandomReader: bytes.NewReader(bytes.Repeat([]byte{0xAB}, 128)),

0 commit comments

Comments
 (0)