From: Dan Fuhry Date: Sat, 22 Mar 2025 01:11:51 +0000 (-0400) Subject: stringmatch: support serialization, add tests, etc. X-Git-Url: https://go.fuhry.dev/?a=commitdiff_plain;h=9572ac4fb82fa47b3ee9cd71b8e9151f10e5fcea;p=runtime.git stringmatch: support serialization, add tests, etc. --- diff --git a/utils/slices2/map.go b/utils/slices2/map.go new file mode 100644 index 0000000..cae39a7 --- /dev/null +++ b/utils/slices2/map.go @@ -0,0 +1,9 @@ +package slices2 + +func Map[S any, V any](in []S, transform func(S) V) []V { + out := make([]V, len(in)) + for i, val := range in { + out[i] = transform(val) + } + return out +} diff --git a/utils/stringmatch/stringmatch.go b/utils/stringmatch/matchers.go similarity index 58% rename from utils/stringmatch/stringmatch.go rename to utils/stringmatch/matchers.go index 3a7a83f..20ebe25 100644 --- a/utils/stringmatch/stringmatch.go +++ b/utils/stringmatch/matchers.go @@ -1,12 +1,16 @@ package stringmatch import ( + "fmt" "regexp" "strings" + + "go.fuhry.dev/runtime/utils/slices2" ) type StringMatcher interface { Match(input string) bool + String() string } type Prefix string @@ -19,23 +23,43 @@ func (s Prefix) Match(input string) bool { return strings.HasPrefix(input, string(s)) } +func (s Prefix) String() string { + return fmt.Sprintf("%T(%s)", s, string(s)) +} + func (s Suffix) Match(input string) bool { return strings.HasSuffix(input, string(s)) } +func (s Suffix) String() string { + return fmt.Sprintf("%T(%s)", s, string(s)) +} + func (s Exact) Match(input string) bool { return input == string(s) } +func (s Exact) String() string { + return fmt.Sprintf("%T(%s)", s, string(s)) +} + func (s Contains) Match(input string) bool { return strings.Contains(input, string(s)) } +func (s Contains) String() string { + return fmt.Sprintf("%T(%s)", s, string(s)) +} + func (s Regexp) Match(input string) bool { re := regexp.MustCompile(string(s)) return re.MatchString(input) } +func (s Regexp) String() string { + return fmt.Sprintf("%T(%s)", s, string(s)) +} + type andMatcher struct { matchers []StringMatcher } @@ -50,6 +74,14 @@ func (mm *andMatcher) Match(input string) bool { return true } +func (mm *andMatcher) String() string { + matcherStrs := slices2.Map( + mm.matchers, + func(sm StringMatcher) string { return sm.String() }, + ) + return fmt.Sprintf("And(%s)", strings.Join(matcherStrs, " & ")) +} + func And(matchers ...StringMatcher) StringMatcher { return &andMatcher{ matchers: matchers, @@ -70,6 +102,14 @@ func (mm *orMatcher) Match(input string) bool { return false } +func (mm *orMatcher) String() string { + matcherStrs := slices2.Map( + mm.matchers, + func(sm StringMatcher) string { return sm.String() }, + ) + return fmt.Sprintf("Or(%s)", strings.Join(matcherStrs, " & ")) +} + func Or(matchers ...StringMatcher) StringMatcher { return &orMatcher{ matchers: matchers, diff --git a/utils/stringmatch/matchers_test.go b/utils/stringmatch/matchers_test.go new file mode 100644 index 0000000..3663a57 --- /dev/null +++ b/utils/stringmatch/matchers_test.go @@ -0,0 +1,98 @@ +package stringmatch + +import ( + "fmt" + "os" + "testing" +) + +type testCase struct { + matcher StringMatcher + input string + expect bool +} + +func (tc *testCase) Run(t *testing.T) { + if tc.matcher.Match(tc.input) != tc.expect { + fmt.Fprintf(os.Stderr, "FAILED: %s on %q: expect: %t, got %t\n", + tc.matcher.String(), tc.input, tc.expect, !tc.expect) + t.Fail() + } +} + +func TestPrefix(t *testing.T) { + cases := []*testCase{ + {Prefix("/foo"), "/foo/bar", true}, + {Prefix("/foo"), "/bar", false}, + } + + for _, tc := range cases { + tc.Run(t) + } +} + +func TestSuffix(t *testing.T) { + cases := []*testCase{ + {Suffix("/foo"), "/foo/bar", false}, + {Suffix("/bar"), "/bar", true}, + } + + for _, tc := range cases { + tc.Run(t) + } +} + +func TestExact(t *testing.T) { + cases := []*testCase{ + {Exact("/foo"), "/foo", true}, + {Exact("/foo"), "/bar", false}, + } + + for _, tc := range cases { + tc.Run(t) + } +} + +func TestContains(t *testing.T) { + cases := []*testCase{ + {Contains("o"), "/foo", true}, + {Contains("a"), "/foo", false}, + } + + for _, tc := range cases { + tc.Run(t) + } +} + +func TestRegexp(t *testing.T) { + cases := []*testCase{ + {Regexp("^/foo"), "/foo/bar", true}, + {Regexp("/foo$"), "/bar", false}, + } + + for _, tc := range cases { + tc.Run(t) + } +} + +func TestAnd(t *testing.T) { + cases := []*testCase{ + {And(Prefix("/foo"), Suffix("/bar")), "/foo/bar", true}, + {And(Prefix("/foo"), Suffix("/baz")), "/foo/bar", false}, + } + + for _, tc := range cases { + tc.Run(t) + } +} + +func TestOr(t *testing.T) { + cases := []*testCase{ + {Or(Prefix("/foo"), Suffix("/bar")), "/foo/bar", true}, + {Or(Contains("/baz"), Contains("/quux")), "/foo/bar", false}, + } + + for _, tc := range cases { + tc.Run(t) + } +} diff --git a/utils/stringmatch/serialization.go b/utils/stringmatch/serialization.go new file mode 100644 index 0000000..83935a1 --- /dev/null +++ b/utils/stringmatch/serialization.go @@ -0,0 +1,100 @@ +package stringmatch + +import ( + "errors" + "fmt" + "strings" +) + +type MatchRule struct { + Mode string `yaml:"mode" json:"mode"` + Value string `yaml:"value" json:"value"` + Rules []*MatchRule `yaml:"rules" json:"rules"` +} + +func (m *MatchRule) Matcher() (StringMatcher, error) { + switch strings.ToLower(m.Mode) { + case "prefix", "startswith": + if m.Value == "" { + return nil, errors.New("prefix matcher is missing value") + } + if len(m.Rules) != 0 { + return nil, errors.New("prefix matcher may not have sub-rules") + } + return Prefix(m.Value), nil + case "suffix", "endswith": + if m.Value == "" { + return nil, errors.New("suffix matcher is missing value") + } + if len(m.Rules) != 0 { + return nil, errors.New("suffix matcher may not have sub-rules") + } + return Suffix(m.Value), nil + case "exact", "equals", "eq": + if m.Value == "" { + return nil, errors.New("exact matcher is missing value") + } + if len(m.Rules) != 0 { + return nil, errors.New("exact matcher may not have sub-rules") + } + return Exact(m.Value), nil + case "contains", "in": + if m.Value == "" { + return nil, errors.New("contains matcher is missing value") + } + if len(m.Rules) != 0 { + return nil, errors.New("contains matcher may not have sub-rules") + } + return Contains(m.Value), nil + case "regexp", "regex": + if m.Value == "" { + return nil, errors.New("regexp matcher is missing value") + } + if len(m.Rules) != 0 { + return nil, errors.New("regexp matcher may not have sub-rules") + } + return Regexp(m.Value), nil + case "and": + if m.Value != "" { + return nil, errors.New("\"and\" matcher may not have a unary value") + } + if len(m.Rules) < 1 { + return nil, errors.New("no rules present in \"and\" matcher") + } + and := &andMatcher{} + for i, r := range m.Rules { + matcher, err := r.Matcher() + if err != nil { + return nil, fmt.Errorf("while processing rule %d: %v", i, err) + } + and.matchers = append(and.matchers, matcher) + } + return and, nil + case "or": + if m.Value != "" { + return nil, errors.New("\"or\" matcher may not have a unary value") + } + if len(m.Rules) < 1 { + return nil, errors.New("no rules present in \"or\" matcher") + } + or := &orMatcher{} + for i, r := range m.Rules { + matcher, err := r.Matcher() + if err != nil { + return nil, fmt.Errorf("while processing rule %d: %v", i, err) + } + or.matchers = append(or.matchers, matcher) + } + return or, nil + } + + return nil, fmt.Errorf("unknown matching mode: %q", m.Mode) +} + +func (m *MatchRule) Match(s string) bool { + matcher, err := m.Matcher() + if err != nil { + panic(err) + } + return matcher.Match(s) +} diff --git a/utils/stringmatch/serialization_test.go b/utils/stringmatch/serialization_test.go new file mode 100644 index 0000000..9b7f251 --- /dev/null +++ b/utils/stringmatch/serialization_test.go @@ -0,0 +1,161 @@ +package stringmatch + +import ( + "fmt" + "os" + "strings" + "testing" + + "encoding/json" + + "gopkg.in/yaml.v3" +) + +type serializationTestCase struct { + json string + yaml string + expect StringMatcher + expectErr StringMatcher +} + +func (tc *serializationTestCase) Test(t *testing.T) { + if tc.json != "" { + tc.testJson(t) + } else if tc.yaml != "" { + tc.testYaml(t) + } else { + fmt.Fprintf(os.Stderr, "test case json or yaml is unset\n") + t.FailNow() + } +} + +func (tc *serializationTestCase) testJson(t *testing.T) { + rule := &MatchRule{} + err := json.Unmarshal([]byte(strings.TrimSpace(tc.json)), rule) + if err != nil { + fmt.Fprintf(os.Stderr, "json unmarshal failed for %q: %v\n", tc.json, err) + t.Fail() + return + } + + tc.testRule(t, rule) +} + +func (tc *serializationTestCase) testYaml(t *testing.T) { + rule := &MatchRule{} + err := yaml.Unmarshal([]byte(strings.TrimSpace(tc.yaml)), rule) + if err != nil { + fmt.Fprintf(os.Stderr, "yaml unmarshal failed for %q: %v\n", tc.json, err) + t.Fail() + return + } + + tc.testRule(t, rule) +} + +func (tc *serializationTestCase) testRule(t *testing.T, rule *MatchRule) { + matcher, err := rule.Matcher() + if err != nil { + if tc.expectErr == nil { + fmt.Fprintf( + os.Stderr, + "serialization rule %q matcher compilation unexpectedly failed: %v\n", + tc.json, err) + + t.Fail() + return + } + + if !tc.expectErr.Match(err.Error()) { + fmt.Fprintf( + os.Stderr, + "serialization rule %q matcher compilation failed with an unexpected error: %v\n", + tc.json, err) + + t.Fail() + return + } + + // error matches expected error + return + } + + if matcher.String() != tc.expect.String() { + fmt.Fprintf( + os.Stderr, + "json serialization rule %q matcher compilation failed with an unexpected error: %v\n", + tc.json, err) + + t.Fail() + } +} + +func TestJson(t *testing.T) { + testCases := []*serializationTestCase{ + { + json: `{"mode":"prefix","value":"test"}`, + expect: Prefix("test"), + }, + { + json: `{"mode":"prefix","value":""}`, + expectErr: Exact("prefix matcher is missing value"), + }, + { + json: `{"mode":"prefix","value":"x","rules":[{"mode":"exact"}]}`, + expectErr: Exact("prefix matcher may not have sub-rules"), + }, + { + json: `{ + "mode":"and", + "rules":[ + {"mode":"contains","value":"a"}, + {"mode":"contains","value":"b"} + ]}`, + expect: And(Contains("a"), Contains("b")), + }, + } + + for _, tc := range testCases { + tc.Test(t) + } +} + +func TestYaml(t *testing.T) { + testCases := []*serializationTestCase{ + { + yaml: `--- +mode: prefix +value: test +`, + expect: Prefix("test"), + }, + { + yaml: `--- +mode: prefix +value: ""`, + expectErr: Exact("prefix matcher is missing value"), + }, + { + yaml: `--- +mode: prefix +value: x +rules: + - mode: exact`, + expectErr: Exact("prefix matcher may not have sub-rules"), + }, + { + yaml: `--- +mode: and +rules: + - mode: contains + value: a + - mode: contains + value: b`, + expect: And(Contains("a"), Contains("b")), + }, + } + + for _, tc := range testCases { + tc.Test(t) + } +}