| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later |
| 2 | |
| 3 | package search |
| 4 | |
| 5 | import "strings" |
| 6 | |
| 7 | // ParsedQuery is the result of running a raw user-typed query |
| 8 | // through ParseQuery. The free-text portion is what flows into |
| 9 | // `plainto_tsquery` / `phraseto_tsquery`; the operator fields are |
| 10 | // what compose the SQL `WHERE` filters. |
| 11 | // |
| 12 | // `RepoFilter` carries the `owner/name` pair when the user typed |
| 13 | // `repo:owner/name`; both halves must be present for the filter |
| 14 | // to take effect (a bare `repo:foo` without slash is treated as |
| 15 | // free text). |
| 16 | type ParsedQuery struct { |
| 17 | Text string // free-text query (what tsvector matches against) |
| 18 | Phrase string // when a quoted phrase was supplied; empty when not |
| 19 | RepoFilter *RepoFilter |
| 20 | StateFilter string // "open" | "closed" | "" |
| 21 | AuthorFilter string // username or empty |
| 22 | } |
| 23 | |
| 24 | // RepoFilter splits the `repo:owner/name` operator value. |
| 25 | type RepoFilter struct { |
| 26 | Owner string |
| 27 | Name string |
| 28 | } |
| 29 | |
| 30 | // ParseQuery splits a raw query string into the free-text portion + |
| 31 | // recognised operators. v1 supports `repo:`, `is:`, `state:`, |
| 32 | // `author:`. `is:` and `state:` are aliases. |
| 33 | // |
| 34 | // A quoted run of tokens becomes the Phrase field (one quoted span |
| 35 | // per query in v1). The Text field excludes quoted phrases and |
| 36 | // operator tokens. |
| 37 | // |
| 38 | // Operators and free-text tokens are space-delimited; the parser is |
| 39 | // intentionally tolerant — unknown prefixes (e.g. `language:Go`) |
| 40 | // fall through as free text so future operator additions don't |
| 41 | // break old queries. |
| 42 | func ParseQuery(raw string) ParsedQuery { |
| 43 | out := ParsedQuery{} |
| 44 | if raw == "" { |
| 45 | return out |
| 46 | } |
| 47 | if len(raw) > MaxQueryBytes { |
| 48 | raw = raw[:MaxQueryBytes] |
| 49 | } |
| 50 | |
| 51 | // Pull quoted phrases out first. Single-pass: find the first |
| 52 | // pair of "..." and treat that as the phrase. Anything else |
| 53 | // quoted is treated as free text. |
| 54 | if start := strings.IndexByte(raw, '"'); start >= 0 { |
| 55 | end := strings.IndexByte(raw[start+1:], '"') |
| 56 | if end > 0 { |
| 57 | out.Phrase = strings.TrimSpace(raw[start+1 : start+1+end]) |
| 58 | raw = raw[:start] + " " + raw[start+1+end+1:] |
| 59 | } |
| 60 | } |
| 61 | |
| 62 | var freeText []string |
| 63 | for _, tok := range strings.Fields(raw) { |
| 64 | switch { |
| 65 | case strings.HasPrefix(tok, "repo:"): |
| 66 | val := strings.TrimPrefix(tok, "repo:") |
| 67 | if i := strings.IndexByte(val, '/'); i > 0 && i < len(val)-1 { |
| 68 | out.RepoFilter = &RepoFilter{Owner: val[:i], Name: val[i+1:]} |
| 69 | } else { |
| 70 | freeText = append(freeText, tok) // not owner/name shape — fall through |
| 71 | } |
| 72 | case strings.HasPrefix(tok, "is:"), strings.HasPrefix(tok, "state:"): |
| 73 | val := strings.TrimPrefix(tok, "is:") |
| 74 | val = strings.TrimPrefix(val, "state:") |
| 75 | if val == "open" || val == "closed" { |
| 76 | out.StateFilter = val |
| 77 | } else { |
| 78 | freeText = append(freeText, tok) |
| 79 | } |
| 80 | case strings.HasPrefix(tok, "author:"): |
| 81 | val := strings.TrimPrefix(tok, "author:") |
| 82 | if val != "" { |
| 83 | out.AuthorFilter = val |
| 84 | } else { |
| 85 | freeText = append(freeText, tok) |
| 86 | } |
| 87 | default: |
| 88 | freeText = append(freeText, tok) |
| 89 | } |
| 90 | } |
| 91 | out.Text = strings.TrimSpace(strings.Join(freeText, " ")) |
| 92 | return out |
| 93 | } |
| 94 | |
| 95 | // HasContent reports whether the parsed query contains anything |
| 96 | // searchable (free text, phrase, or any operator). |
| 97 | func (p ParsedQuery) HasContent() bool { |
| 98 | return p.Text != "" || p.Phrase != "" || p.RepoFilter != nil || |
| 99 | p.StateFilter != "" || p.AuthorFilter != "" |
| 100 | } |
| 101 |