Monorepo for Tangled

appview/searchquery: add search query parser with negation support

Signed-off-by: Thomas Karpiniec <tkarpiniec@icloud.com>

authored by

Thomas Karpiniec and committed by tangled.org ce74be2b c4a4e93d

+410
+204
appview/searchquery/searchquery.go
··· 1 + package searchquery 2 + 3 + import ( 4 + "strings" 5 + "unicode" 6 + ) 7 + 8 + type ItemKind int 9 + 10 + const ( 11 + KindKeyword ItemKind = iota 12 + KindQuoted 13 + KindTagValue 14 + ) 15 + 16 + type Item struct { 17 + Kind ItemKind 18 + Negated bool 19 + Raw string 20 + Key string 21 + Value string 22 + } 23 + 24 + type Query struct { 25 + items []Item 26 + } 27 + 28 + func Parse(input string) *Query { 29 + q := &Query{} 30 + runes := []rune(strings.TrimSpace(input)) 31 + if len(runes) == 0 { 32 + return q 33 + } 34 + 35 + i := 0 36 + for i < len(runes) { 37 + for i < len(runes) && unicode.IsSpace(runes[i]) { 38 + i++ 39 + } 40 + if i >= len(runes) { 41 + break 42 + } 43 + 44 + negated := false 45 + if runes[i] == '-' && i+1 < len(runes) && runes[i+1] == '"' { 46 + negated = true 47 + i++ // skip '-' 48 + } 49 + 50 + if runes[i] == '"' { 51 + start := i 52 + if negated { 53 + start-- // include the '-' in Raw 54 + } 55 + i++ // skip opening quote 56 + inner := i 57 + for i < len(runes) && runes[i] != '"' { 58 + if runes[i] == '\\' && i+1 < len(runes) { 59 + i++ 60 + } 61 + i++ 62 + } 63 + value := unescapeQuoted(runes[inner:i]) 64 + if i < len(runes) { 65 + i++ // skip closing quote 66 + } 67 + q.items = append(q.items, Item{ 68 + Kind: KindQuoted, 69 + Negated: negated, 70 + Raw: string(runes[start:i]), 71 + Value: value, 72 + }) 73 + continue 74 + } 75 + 76 + start := i 77 + for i < len(runes) && !unicode.IsSpace(runes[i]) && runes[i] != '"' { 78 + i++ 79 + } 80 + token := string(runes[start:i]) 81 + 82 + negated = false 83 + subject := token 84 + if len(subject) > 1 && subject[0] == '-' { 85 + negated = true 86 + subject = subject[1:] 87 + } 88 + 89 + colonIdx := strings.Index(subject, ":") 90 + if colonIdx > 0 { 91 + key := subject[:colonIdx] 92 + value := subject[colonIdx+1:] 93 + q.items = append(q.items, Item{ 94 + Kind: KindTagValue, 95 + Negated: negated, 96 + Raw: token, 97 + Key: key, 98 + Value: value, 99 + }) 100 + } else { 101 + q.items = append(q.items, Item{ 102 + Kind: KindKeyword, 103 + Negated: negated, 104 + Raw: token, 105 + Value: subject, 106 + }) 107 + } 108 + } 109 + 110 + return q 111 + } 112 + 113 + func unescapeQuoted(runes []rune) string { 114 + var b strings.Builder 115 + for i := 0; i < len(runes); i++ { 116 + if runes[i] == '\\' && i+1 < len(runes) { 117 + i++ 118 + } 119 + b.WriteRune(runes[i]) 120 + } 121 + return b.String() 122 + } 123 + 124 + func (q *Query) Items() []Item { 125 + return q.items 126 + } 127 + 128 + func (q *Query) Get(key string) *string { 129 + for i, item := range q.items { 130 + if item.Kind == KindTagValue && !item.Negated && item.Key == key { 131 + return &q.items[i].Value 132 + } 133 + } 134 + return nil 135 + } 136 + 137 + func (q *Query) GetAll(key string) []string { 138 + var result []string 139 + for _, item := range q.items { 140 + if item.Kind == KindTagValue && !item.Negated && item.Key == key { 141 + result = append(result, item.Value) 142 + } 143 + } 144 + return result 145 + } 146 + 147 + func (q *Query) GetAllNegated(key string) []string { 148 + var result []string 149 + for _, item := range q.items { 150 + if item.Kind == KindTagValue && item.Negated && item.Key == key { 151 + result = append(result, item.Value) 152 + } 153 + } 154 + return result 155 + } 156 + 157 + func (q *Query) Has(key string) bool { 158 + return q.Get(key) != nil 159 + } 160 + 161 + func (q *Query) Set(key, value string) { 162 + raw := key + ":" + value 163 + found := false 164 + newItems := make([]Item, 0, len(q.items)) 165 + 166 + for _, item := range q.items { 167 + if item.Kind == KindTagValue && !item.Negated && item.Key == key { 168 + if !found { 169 + newItems = append(newItems, Item{ 170 + Kind: KindTagValue, 171 + Raw: raw, 172 + Key: key, 173 + Value: value, 174 + }) 175 + found = true 176 + } 177 + } else { 178 + newItems = append(newItems, item) 179 + } 180 + } 181 + 182 + if !found { 183 + newItems = append(newItems, Item{ 184 + Kind: KindTagValue, 185 + Raw: raw, 186 + Key: key, 187 + Value: value, 188 + }) 189 + } 190 + 191 + q.items = newItems 192 + } 193 + 194 + func (q *Query) String() string { 195 + if len(q.items) == 0 { 196 + return "" 197 + } 198 + 199 + parts := make([]string, len(q.items)) 200 + for i, item := range q.items { 201 + parts[i] = item.Raw 202 + } 203 + return strings.Join(parts, " ") 204 + }
+206
appview/searchquery/searchquery_test.go
··· 1 + package searchquery 2 + 3 + import ( 4 + "testing" 5 + 6 + "github.com/stretchr/testify/assert" 7 + ) 8 + 9 + func TestParseMixed(t *testing.T) { 10 + q := Parse(`state:open bug "critical issue" label:good-first-issue fix`) 11 + items := q.Items() 12 + assert.Equal(t, 5, len(items)) 13 + 14 + assert.Equal(t, KindTagValue, items[0].Kind) 15 + assert.Equal(t, "state", items[0].Key) 16 + assert.Equal(t, "open", items[0].Value) 17 + 18 + assert.Equal(t, KindKeyword, items[1].Kind) 19 + assert.Equal(t, "bug", items[1].Raw) 20 + 21 + assert.Equal(t, KindQuoted, items[2].Kind) 22 + assert.Equal(t, `"critical issue"`, items[2].Raw) 23 + 24 + assert.Equal(t, KindTagValue, items[3].Kind) 25 + assert.Equal(t, "label", items[3].Key) 26 + assert.Equal(t, "good-first-issue", items[3].Value) 27 + 28 + assert.Equal(t, KindKeyword, items[4].Kind) 29 + 30 + assert.Equal(t, `state:open bug "critical issue" label:good-first-issue fix`, q.String()) 31 + } 32 + 33 + func TestGetSetLifecycle(t *testing.T) { 34 + q := Parse("label:bug state:open keyword label:feature label:urgent") 35 + 36 + // Get returns first match 37 + val := q.Get("state") 38 + assert.NotNil(t, val) 39 + assert.Equal(t, "open", *val) 40 + 41 + // Get returns nil for missing key 42 + assert.Nil(t, q.Get("author")) 43 + 44 + // Has 45 + assert.True(t, q.Has("state")) 46 + assert.False(t, q.Has("author")) 47 + 48 + // GetAll 49 + assert.Equal(t, []string{"bug", "feature", "urgent"}, q.GetAll("label")) 50 + assert.Equal(t, 0, len(q.GetAll("missing"))) 51 + 52 + // Set updates existing, preserving position 53 + q.Set("state", "closed") 54 + assert.Equal(t, "label:bug state:closed keyword label:feature label:urgent", q.String()) 55 + 56 + // Set deduplicates 57 + q.Set("label", "single") 58 + assert.Equal(t, "label:single state:closed keyword", q.String()) 59 + 60 + // Set appends new tag 61 + q.Set("author", "bob") 62 + assert.Equal(t, "label:single state:closed keyword author:bob", q.String()) 63 + } 64 + 65 + func TestParseEmpty(t *testing.T) { 66 + q := Parse(" ") 67 + assert.Equal(t, 0, len(q.Items())) 68 + assert.Equal(t, "", q.String()) 69 + } 70 + 71 + func TestParseUnclosedQuote(t *testing.T) { 72 + q := Parse(`"hello world`) 73 + items := q.Items() 74 + assert.Equal(t, 1, len(items)) 75 + assert.Equal(t, KindQuoted, items[0].Kind) 76 + assert.Equal(t, `"hello world`, items[0].Raw) 77 + assert.Equal(t, "hello world", items[0].Value) 78 + } 79 + 80 + func TestParseLeadingColon(t *testing.T) { 81 + q := Parse(":value") 82 + items := q.Items() 83 + assert.Equal(t, 1, len(items)) 84 + assert.Equal(t, KindKeyword, items[0].Kind) 85 + assert.Equal(t, ":value", items[0].Raw) 86 + } 87 + 88 + func TestParseColonInValue(t *testing.T) { 89 + q := Parse("key:value:with:colons") 90 + items := q.Items() 91 + assert.Equal(t, 1, len(items)) 92 + assert.Equal(t, "key", items[0].Key) 93 + assert.Equal(t, "value:with:colons", items[0].Value) 94 + } 95 + 96 + func TestParseEmptyValue(t *testing.T) { 97 + q := Parse("state:") 98 + items := q.Items() 99 + assert.Equal(t, 1, len(items)) 100 + assert.Equal(t, KindTagValue, items[0].Kind) 101 + assert.Equal(t, "state", items[0].Key) 102 + assert.Equal(t, "", items[0].Value) 103 + } 104 + 105 + func TestQuotedKeyValueIsNotTag(t *testing.T) { 106 + q := Parse(`"state:open"`) 107 + items := q.Items() 108 + assert.Equal(t, 1, len(items)) 109 + assert.Equal(t, KindQuoted, items[0].Kind) 110 + assert.Equal(t, "state:open", items[0].Value) 111 + assert.False(t, q.Has("state")) 112 + } 113 + 114 + func TestConsecutiveQuotes(t *testing.T) { 115 + q := Parse(`"one""two"`) 116 + items := q.Items() 117 + assert.Equal(t, 2, len(items)) 118 + assert.Equal(t, `"one"`, items[0].Raw) 119 + assert.Equal(t, `"two"`, items[1].Raw) 120 + } 121 + 122 + func TestEscapedQuotes(t *testing.T) { 123 + q := Parse(`"hello \"world\""`) 124 + items := q.Items() 125 + assert.Equal(t, 1, len(items)) 126 + assert.Equal(t, KindQuoted, items[0].Kind) 127 + assert.Equal(t, `"hello \"world\""`, items[0].Raw) 128 + assert.Equal(t, `hello "world"`, items[0].Value) 129 + } 130 + 131 + func TestEscapedBackslash(t *testing.T) { 132 + q := Parse(`"hello\\"`) 133 + items := q.Items() 134 + assert.Equal(t, 1, len(items)) 135 + assert.Equal(t, KindQuoted, items[0].Kind) 136 + assert.Equal(t, `hello\`, items[0].Value) 137 + } 138 + 139 + func TestNegatedTag(t *testing.T) { 140 + q := Parse("state:open -label:bug keyword -label:wontfix") 141 + items := q.Items() 142 + assert.Equal(t, 4, len(items)) 143 + 144 + assert.False(t, items[0].Negated) 145 + assert.Equal(t, "state", items[0].Key) 146 + 147 + assert.True(t, items[1].Negated) 148 + assert.Equal(t, KindTagValue, items[1].Kind) 149 + assert.Equal(t, "label", items[1].Key) 150 + assert.Equal(t, "bug", items[1].Value) 151 + assert.Equal(t, "-label:bug", items[1].Raw) 152 + 153 + assert.True(t, items[3].Negated) 154 + assert.Equal(t, "wontfix", items[3].Value) 155 + 156 + // Get/GetAll/Has skip negated tags 157 + assert.False(t, q.Has("label")) 158 + assert.Equal(t, 0, len(q.GetAll("label"))) 159 + 160 + // Set doesn't touch negated tags 161 + q.Set("label", "feature") 162 + assert.Equal(t, "state:open -label:bug keyword -label:wontfix label:feature", q.String()) 163 + } 164 + 165 + func TestNegatedBareWordIsKeyword(t *testing.T) { 166 + q := Parse("-keyword") 167 + items := q.Items() 168 + assert.Equal(t, 1, len(items)) 169 + assert.Equal(t, KindKeyword, items[0].Kind) 170 + assert.Equal(t, "-keyword", items[0].Raw) 171 + } 172 + 173 + func TestNegatedQuotedPhrase(t *testing.T) { 174 + q := Parse(`-"critical bug" state:open`) 175 + items := q.Items() 176 + assert.Equal(t, 2, len(items)) 177 + 178 + assert.Equal(t, KindQuoted, items[0].Kind) 179 + assert.True(t, items[0].Negated) 180 + assert.Equal(t, `-"critical bug"`, items[0].Raw) 181 + assert.Equal(t, "critical bug", items[0].Value) 182 + 183 + assert.Equal(t, KindTagValue, items[1].Kind) 184 + assert.Equal(t, "state", items[1].Key) 185 + } 186 + 187 + func TestNegatedQuotedPhraseAmongOthers(t *testing.T) { 188 + q := Parse(`"good phrase" -"bad phrase" keyword`) 189 + items := q.Items() 190 + assert.Equal(t, 3, len(items)) 191 + 192 + assert.Equal(t, KindQuoted, items[0].Kind) 193 + assert.False(t, items[0].Negated) 194 + assert.Equal(t, "good phrase", items[0].Value) 195 + 196 + assert.Equal(t, KindQuoted, items[1].Kind) 197 + assert.True(t, items[1].Negated) 198 + assert.Equal(t, "bad phrase", items[1].Value) 199 + 200 + assert.Equal(t, KindKeyword, items[2].Kind) 201 + } 202 + 203 + func TestWhitespaceNormalization(t *testing.T) { 204 + q := Parse(" state:open keyword ") 205 + assert.Equal(t, "state:open keyword", q.String()) 206 + }