From ce74be2b113da0d6b66ecf434758dc087291f3db Mon Sep 17 00:00:00 2001 From: Thomas Karpiniec Date: Wed, 11 Feb 2026 15:01:59 +1100 Subject: [PATCH] appview/searchquery: add search query parser with negation support Signed-off-by: Thomas Karpiniec --- appview/searchquery/searchquery.go | 204 +++++++++++++++++++++++ appview/searchquery/searchquery_test.go | 206 ++++++++++++++++++++++++ 2 files changed, 410 insertions(+) create mode 100644 appview/searchquery/searchquery.go create mode 100644 appview/searchquery/searchquery_test.go diff --git a/appview/searchquery/searchquery.go b/appview/searchquery/searchquery.go new file mode 100644 index 00000000..497a0b95 --- /dev/null +++ b/appview/searchquery/searchquery.go @@ -0,0 +1,204 @@ +package searchquery + +import ( + "strings" + "unicode" +) + +type ItemKind int + +const ( + KindKeyword ItemKind = iota + KindQuoted + KindTagValue +) + +type Item struct { + Kind ItemKind + Negated bool + Raw string + Key string + Value string +} + +type Query struct { + items []Item +} + +func Parse(input string) *Query { + q := &Query{} + runes := []rune(strings.TrimSpace(input)) + if len(runes) == 0 { + return q + } + + i := 0 + for i < len(runes) { + for i < len(runes) && unicode.IsSpace(runes[i]) { + i++ + } + if i >= len(runes) { + break + } + + negated := false + if runes[i] == '-' && i+1 < len(runes) && runes[i+1] == '"' { + negated = true + i++ // skip '-' + } + + if runes[i] == '"' { + start := i + if negated { + start-- // include the '-' in Raw + } + i++ // skip opening quote + inner := i + for i < len(runes) && runes[i] != '"' { + if runes[i] == '\\' && i+1 < len(runes) { + i++ + } + i++ + } + value := unescapeQuoted(runes[inner:i]) + if i < len(runes) { + i++ // skip closing quote + } + q.items = append(q.items, Item{ + Kind: KindQuoted, + Negated: negated, + Raw: string(runes[start:i]), + Value: value, + }) + continue + } + + start := i + for i < len(runes) && !unicode.IsSpace(runes[i]) && runes[i] != '"' { + i++ + } + token := string(runes[start:i]) + + negated = false + subject := token + if len(subject) > 1 && subject[0] == '-' { + negated = true + subject = subject[1:] + } + + colonIdx := strings.Index(subject, ":") + if colonIdx > 0 { + key := subject[:colonIdx] + value := subject[colonIdx+1:] + q.items = append(q.items, Item{ + Kind: KindTagValue, + Negated: negated, + Raw: token, + Key: key, + Value: value, + }) + } else { + q.items = append(q.items, Item{ + Kind: KindKeyword, + Negated: negated, + Raw: token, + Value: subject, + }) + } + } + + return q +} + +func unescapeQuoted(runes []rune) string { + var b strings.Builder + for i := 0; i < len(runes); i++ { + if runes[i] == '\\' && i+1 < len(runes) { + i++ + } + b.WriteRune(runes[i]) + } + return b.String() +} + +func (q *Query) Items() []Item { + return q.items +} + +func (q *Query) Get(key string) *string { + for i, item := range q.items { + if item.Kind == KindTagValue && !item.Negated && item.Key == key { + return &q.items[i].Value + } + } + return nil +} + +func (q *Query) GetAll(key string) []string { + var result []string + for _, item := range q.items { + if item.Kind == KindTagValue && !item.Negated && item.Key == key { + result = append(result, item.Value) + } + } + return result +} + +func (q *Query) GetAllNegated(key string) []string { + var result []string + for _, item := range q.items { + if item.Kind == KindTagValue && item.Negated && item.Key == key { + result = append(result, item.Value) + } + } + return result +} + +func (q *Query) Has(key string) bool { + return q.Get(key) != nil +} + +func (q *Query) Set(key, value string) { + raw := key + ":" + value + found := false + newItems := make([]Item, 0, len(q.items)) + + for _, item := range q.items { + if item.Kind == KindTagValue && !item.Negated && item.Key == key { + if !found { + newItems = append(newItems, Item{ + Kind: KindTagValue, + Raw: raw, + Key: key, + Value: value, + }) + found = true + } + } else { + newItems = append(newItems, item) + } + } + + if !found { + newItems = append(newItems, Item{ + Kind: KindTagValue, + Raw: raw, + Key: key, + Value: value, + }) + } + + q.items = newItems +} + +func (q *Query) String() string { + if len(q.items) == 0 { + return "" + } + + parts := make([]string, len(q.items)) + for i, item := range q.items { + parts[i] = item.Raw + } + return strings.Join(parts, " ") +} diff --git a/appview/searchquery/searchquery_test.go b/appview/searchquery/searchquery_test.go new file mode 100644 index 00000000..c3f49dd6 --- /dev/null +++ b/appview/searchquery/searchquery_test.go @@ -0,0 +1,206 @@ +package searchquery + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseMixed(t *testing.T) { + q := Parse(`state:open bug "critical issue" label:good-first-issue fix`) + items := q.Items() + assert.Equal(t, 5, len(items)) + + assert.Equal(t, KindTagValue, items[0].Kind) + assert.Equal(t, "state", items[0].Key) + assert.Equal(t, "open", items[0].Value) + + assert.Equal(t, KindKeyword, items[1].Kind) + assert.Equal(t, "bug", items[1].Raw) + + assert.Equal(t, KindQuoted, items[2].Kind) + assert.Equal(t, `"critical issue"`, items[2].Raw) + + assert.Equal(t, KindTagValue, items[3].Kind) + assert.Equal(t, "label", items[3].Key) + assert.Equal(t, "good-first-issue", items[3].Value) + + assert.Equal(t, KindKeyword, items[4].Kind) + + assert.Equal(t, `state:open bug "critical issue" label:good-first-issue fix`, q.String()) +} + +func TestGetSetLifecycle(t *testing.T) { + q := Parse("label:bug state:open keyword label:feature label:urgent") + + // Get returns first match + val := q.Get("state") + assert.NotNil(t, val) + assert.Equal(t, "open", *val) + + // Get returns nil for missing key + assert.Nil(t, q.Get("author")) + + // Has + assert.True(t, q.Has("state")) + assert.False(t, q.Has("author")) + + // GetAll + assert.Equal(t, []string{"bug", "feature", "urgent"}, q.GetAll("label")) + assert.Equal(t, 0, len(q.GetAll("missing"))) + + // Set updates existing, preserving position + q.Set("state", "closed") + assert.Equal(t, "label:bug state:closed keyword label:feature label:urgent", q.String()) + + // Set deduplicates + q.Set("label", "single") + assert.Equal(t, "label:single state:closed keyword", q.String()) + + // Set appends new tag + q.Set("author", "bob") + assert.Equal(t, "label:single state:closed keyword author:bob", q.String()) +} + +func TestParseEmpty(t *testing.T) { + q := Parse(" ") + assert.Equal(t, 0, len(q.Items())) + assert.Equal(t, "", q.String()) +} + +func TestParseUnclosedQuote(t *testing.T) { + q := Parse(`"hello world`) + items := q.Items() + assert.Equal(t, 1, len(items)) + assert.Equal(t, KindQuoted, items[0].Kind) + assert.Equal(t, `"hello world`, items[0].Raw) + assert.Equal(t, "hello world", items[0].Value) +} + +func TestParseLeadingColon(t *testing.T) { + q := Parse(":value") + items := q.Items() + assert.Equal(t, 1, len(items)) + assert.Equal(t, KindKeyword, items[0].Kind) + assert.Equal(t, ":value", items[0].Raw) +} + +func TestParseColonInValue(t *testing.T) { + q := Parse("key:value:with:colons") + items := q.Items() + assert.Equal(t, 1, len(items)) + assert.Equal(t, "key", items[0].Key) + assert.Equal(t, "value:with:colons", items[0].Value) +} + +func TestParseEmptyValue(t *testing.T) { + q := Parse("state:") + items := q.Items() + assert.Equal(t, 1, len(items)) + assert.Equal(t, KindTagValue, items[0].Kind) + assert.Equal(t, "state", items[0].Key) + assert.Equal(t, "", items[0].Value) +} + +func TestQuotedKeyValueIsNotTag(t *testing.T) { + q := Parse(`"state:open"`) + items := q.Items() + assert.Equal(t, 1, len(items)) + assert.Equal(t, KindQuoted, items[0].Kind) + assert.Equal(t, "state:open", items[0].Value) + assert.False(t, q.Has("state")) +} + +func TestConsecutiveQuotes(t *testing.T) { + q := Parse(`"one""two"`) + items := q.Items() + assert.Equal(t, 2, len(items)) + assert.Equal(t, `"one"`, items[0].Raw) + assert.Equal(t, `"two"`, items[1].Raw) +} + +func TestEscapedQuotes(t *testing.T) { + q := Parse(`"hello \"world\""`) + items := q.Items() + assert.Equal(t, 1, len(items)) + assert.Equal(t, KindQuoted, items[0].Kind) + assert.Equal(t, `"hello \"world\""`, items[0].Raw) + assert.Equal(t, `hello "world"`, items[0].Value) +} + +func TestEscapedBackslash(t *testing.T) { + q := Parse(`"hello\\"`) + items := q.Items() + assert.Equal(t, 1, len(items)) + assert.Equal(t, KindQuoted, items[0].Kind) + assert.Equal(t, `hello\`, items[0].Value) +} + +func TestNegatedTag(t *testing.T) { + q := Parse("state:open -label:bug keyword -label:wontfix") + items := q.Items() + assert.Equal(t, 4, len(items)) + + assert.False(t, items[0].Negated) + assert.Equal(t, "state", items[0].Key) + + assert.True(t, items[1].Negated) + assert.Equal(t, KindTagValue, items[1].Kind) + assert.Equal(t, "label", items[1].Key) + assert.Equal(t, "bug", items[1].Value) + assert.Equal(t, "-label:bug", items[1].Raw) + + assert.True(t, items[3].Negated) + assert.Equal(t, "wontfix", items[3].Value) + + // Get/GetAll/Has skip negated tags + assert.False(t, q.Has("label")) + assert.Equal(t, 0, len(q.GetAll("label"))) + + // Set doesn't touch negated tags + q.Set("label", "feature") + assert.Equal(t, "state:open -label:bug keyword -label:wontfix label:feature", q.String()) +} + +func TestNegatedBareWordIsKeyword(t *testing.T) { + q := Parse("-keyword") + items := q.Items() + assert.Equal(t, 1, len(items)) + assert.Equal(t, KindKeyword, items[0].Kind) + assert.Equal(t, "-keyword", items[0].Raw) +} + +func TestNegatedQuotedPhrase(t *testing.T) { + q := Parse(`-"critical bug" state:open`) + items := q.Items() + assert.Equal(t, 2, len(items)) + + assert.Equal(t, KindQuoted, items[0].Kind) + assert.True(t, items[0].Negated) + assert.Equal(t, `-"critical bug"`, items[0].Raw) + assert.Equal(t, "critical bug", items[0].Value) + + assert.Equal(t, KindTagValue, items[1].Kind) + assert.Equal(t, "state", items[1].Key) +} + +func TestNegatedQuotedPhraseAmongOthers(t *testing.T) { + q := Parse(`"good phrase" -"bad phrase" keyword`) + items := q.Items() + assert.Equal(t, 3, len(items)) + + assert.Equal(t, KindQuoted, items[0].Kind) + assert.False(t, items[0].Negated) + assert.Equal(t, "good phrase", items[0].Value) + + assert.Equal(t, KindQuoted, items[1].Kind) + assert.True(t, items[1].Negated) + assert.Equal(t, "bad phrase", items[1].Value) + + assert.Equal(t, KindKeyword, items[2].Kind) +} + +func TestWhitespaceNormalization(t *testing.T) { + q := Parse(" state:open keyword ") + assert.Equal(t, "state:open keyword", q.String()) +} -- 2.51.2