Go · 3299 bytes Raw Blame History
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