Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

553 linesCodeBlameRaw
1//! Reading a search query: words, quoted phrases, `-` to leave a word out,
2//! and qualifiers such as `repo:acme/web` or `is:open`. Pure, so every rule
3//! is tested apart from the index.
4
5use g1t_contracts::search::{MAX_QUERY_CHARS, SearchType};
6
7use crate::rules::normalize_language;
8
9/// A word or phrase to match.
10#[derive(Clone, Debug, PartialEq, Eq)]
11pub struct Term {
12 pub text: String,
13 /// Written in quotes: matched exactly, as one piece.
14 pub phrase: bool,
15 /// Written with a leading `-`: results must not have it.
16 pub negated: bool,
17}
18
19/// Values of one qualifier, kept and left out (`-label:wontfix`).
20#[derive(Clone, Debug, Default, PartialEq, Eq)]
21pub struct Filter {
22 pub include: Vec<String>,
23 pub exclude: Vec<String>,
24}
25
26impl Filter {
27 fn add(&mut self, value: String, negated: bool) {
28 let list = if negated { &mut self.exclude } else { &mut self.include };
29 if !list.contains(&value) {
30 list.push(value);
31 }
32 }
33
34 pub fn is_empty(&self) -> bool {
35 self.include.is_empty() && self.exclude.is_empty()
36 }
37}
38
39#[derive(Clone, Copy, Debug, PartialEq, Eq)]
40pub enum ItemKind {
41 Item,
42 Pull,
43}
44
45/// `is:open`, `is:closed`, `is:merged`, `is:draft`.
46#[derive(Clone, Copy, Debug, PartialEq, Eq)]
47pub enum ItemState {
48 Open,
49 Closed,
50 Merged,
51 Draft,
52}
53
54/// `is:public` or `is:private`.
55#[derive(Clone, Copy, Debug, PartialEq, Eq)]
56pub enum Visibility {
57 Public,
58 Private,
59}
60
61#[derive(Clone, Debug, Default, PartialEq, Eq)]
62pub struct Query {
63 pub terms: Vec<Term>,
64 /// `repo:owner/name`, lowercase `owner/name`.
65 pub repos: Filter,
66 /// `org:`, `workspace:`, `owner:` and `user:`: a workspace's slug.
67 pub owners: Filter,
68 pub languages: Filter,
69 /// `path:`, lowercase, as written: a glob when it has a `*`.
70 pub paths: Filter,
71 pub authors: Filter,
72 pub labels: Filter,
73 pub kind: Option<ItemKind>,
74 pub state: Option<ItemState>,
75 pub visibility: Option<Visibility>,
76 /// `type:code` and the like.
77 pub type_hint: Option<SearchType>,
78 /// What the query asked that could not be done, said plainly.
79 pub notes: Vec<String>,
80}
81
82/// Splits a query into tokens: runs of non-space, with quoted runs kept
83/// whole wherever they start (`"a b"`, `label:"good first issue"`).
84fn tokens(text: &str) -> Vec<String> {
85 let mut out = Vec::new();
86 let mut current = String::new();
87 let mut quoted = false;
88 for c in text.chars() {
89 match c {
90 '"' => {
91 quoted = !quoted;
92 current.push(c);
93 }
94 c if c.is_whitespace() && !quoted => {
95 if !current.is_empty() {
96 out.push(std::mem::take(&mut current));
97 }
98 }
99 c => current.push(c),
100 }
101 }
102 if !current.is_empty() {
103 out.push(current);
104 }
105 out
106}
107
108/// A value with its quotes taken off.
109fn unquote(value: &str) -> (String, bool) {
110 let trimmed = value.trim();
111 if trimmed.len() >= 2 && trimmed.starts_with('"') && trimmed.ends_with('"') {
112 return (trimmed[1..trimmed.len() - 1].to_owned(), true);
113 }
114 // An opening quote never closed: the rest of the query.
115 if let Some(rest) = trimmed.strip_prefix('"') {
116 return (rest.trim_end_matches('"').to_owned(), true);
117 }
118 (trimmed.replace('"', ""), false)
119}
120
121impl Query {
122 pub fn parse(text: &str) -> Query {
123 let text: String = text.chars().take(MAX_QUERY_CHARS).collect();
124 let mut query = Query::default();
125 for token in tokens(&text) {
126 let (negated, body) = match token.strip_prefix('-') {
127 Some(rest) if !rest.is_empty() => (true, rest.to_owned()),
128 _ => (false, token.clone()),
129 };
130 if let Some((key, value)) = body.split_once(':')
131 && !key.starts_with('"')
132 && query.qualifier(&key.to_lowercase(), value, negated)
133 {
134 continue;
135 }
136 let (text, phrase) = unquote(&body);
137 if text.trim().is_empty() {
138 continue;
139 }
140 query.terms.push(Term {
141 text: if phrase { text } else { text.trim().to_owned() },
142 phrase,
143 negated,
144 });
145 }
146 query
147 }
148
149 /// Takes `key:value` as a qualifier, if `key` is one. False when it is
150 /// not, so the token is read as words.
151 fn qualifier(&mut self, key: &str, value: &str, negated: bool) -> bool {
152 let (value, _) = unquote(value);
153 let value = value.trim().to_owned();
154 let known = matches!(
155 key,
156 "repo" | "org" | "workspace" | "owner" | "user" | "language" | "lang" | "path" | "is" | "state" | "author"
157 | "label" | "type" | "in"
158 );
159 if !known {
160 return false;
161 }
162 if value.is_empty() {
163 self.notes.push(format!("{key}: needs a value, so it was left out."));
164 return true;
165 }
166 let lower = value.to_lowercase();
167 match key {
168 "repo" => match lower.split_once('/') {
169 Some((owner, name)) if !owner.is_empty() && !name.is_empty() && !name.contains('/') => {
170 self.repos.add(format!("{owner}/{name}"), negated);
171 }
172 _ => self.notes.push(format!("repo:{value} should be written repo:owner/name.")),
173 },
174 "org" | "workspace" | "owner" | "user" => self.owners.add(lower, negated),
175 "language" | "lang" => self.languages.add(normalize_language(&lower), negated),
176 "path" => self.paths.add(lower.trim_start_matches('/').to_owned(), negated),
177 "author" => self.authors.add(lower.trim_start_matches('@').to_owned(), negated),
178 "label" => self.labels.add(lower, negated),
179 "type" => match SearchType::parse(&lower) {
180 Some(kind) => self.type_hint = Some(kind),
181 None => self.notes.push(format!("type:{value} is not a kind of result.")),
182 },
183 // `in:title` and the like narrow nothing here: every field is searched.
184 "in" => {}
185 "is" | "state" => self.is(&lower, &value, negated),
186 _ => unreachable!(),
187 }
188 true
189 }
190
191 fn is(&mut self, lower: &str, value: &str, negated: bool) {
192 if negated {
193 // `-is:open` reads as its opposite where there is one.
194 match lower {
195 "open" => self.state = Some(ItemState::Closed),
196 "closed" => self.state = Some(ItemState::Open),
197 "private" => self.visibility = Some(Visibility::Public),
198 "public" => self.visibility = Some(Visibility::Private),
199 _ => self.notes.push(format!("-is:{value} cannot be left out, so it was ignored.")),
200 }
201 return;
202 }
203 match lower {
204 "item" | "items" => self.kind = Some(ItemKind::Item),
205 "pr" | "pull" | "pulls" | "pull-request" => self.kind = Some(ItemKind::Pull),
206 "open" => self.state = Some(ItemState::Open),
207 "closed" => self.state = Some(ItemState::Closed),
208 "merged" => {
209 self.kind = Some(ItemKind::Pull);
210 self.state = Some(ItemState::Merged);
211 }
212 "draft" => {
213 self.kind = Some(ItemKind::Pull);
214 self.state = Some(ItemState::Draft);
215 }
216 "public" => self.visibility = Some(Visibility::Public),
217 "private" => self.visibility = Some(Visibility::Private),
218 _ => self.notes.push(format!("is:{value} is not something g1t can search for.")),
219 }
220 }
221
222 /// Words and phrases results must have.
223 pub fn positive(&self) -> impl Iterator<Item = &Term> {
224 self.terms.iter().filter(|term| !term.negated)
225 }
226
227 /// What to highlight in a result: every word and phrase it must have.
228 pub fn highlights(&self) -> Vec<String> {
229 self.positive().map(|term| term.text.clone()).filter(|text| !text.is_empty()).collect()
230 }
231
232 /// Whether anything at all was asked.
233 pub fn is_empty(&self) -> bool {
234 self.terms.is_empty()
235 && self.repos.is_empty()
236 && self.owners.is_empty()
237 && self.languages.is_empty()
238 && self.paths.is_empty()
239 && self.authors.is_empty()
240 && self.labels.is_empty()
241 && self.kind.is_none()
242 && self.state.is_none()
243 && self.visibility.is_none()
244 }
245
246 /// The tab a query is about when none is named: `type:` if given, then
247 /// pull requests for `is:pr`, work items for `is:item` or anything only
248 /// work items and pull requests have, code for `path:`, else
249 /// repositories.
250 pub fn default_type(&self) -> SearchType {
251 if let Some(kind) = self.type_hint {
252 return kind;
253 }
254 match self.kind {
255 Some(ItemKind::Pull) => return SearchType::Pulls,
256 Some(ItemKind::Item) => return SearchType::Items,
257 None => {}
258 }
259 if !self.paths.is_empty() {
260 return SearchType::Code;
261 }
262 if self.state.is_some() || !self.authors.is_empty() || !self.labels.is_empty() {
263 return SearchType::Items;
264 }
265 SearchType::Repositories
266 }
267
268 /// Whether the query can have results of `kind`: qualifiers only one
269 /// kind has rule the others out (`path:` is only code, `label:` only
270 /// work items and pull requests).
271 pub fn applies_to(&self, kind: SearchType) -> bool {
272 let item_only = self.state.is_some() || !self.authors.is_empty() || !self.labels.is_empty();
273 let repo_scoped = !self.repos.is_empty() || !self.owners.is_empty() || self.visibility.is_some();
274 match kind {
275 SearchType::Repositories => self.paths.is_empty() && self.kind.is_none() && !item_only,
276 SearchType::Code => self.kind.is_none() && !item_only,
277 SearchType::Items => {
278 self.paths.is_empty() && self.languages.is_empty() && self.kind != Some(ItemKind::Pull)
279 && !matches!(self.state, Some(ItemState::Merged | ItemState::Draft))
280 }
281 SearchType::Pulls => self.paths.is_empty() && self.languages.is_empty() && self.kind != Some(ItemKind::Item),
282 SearchType::People => {
283 self.paths.is_empty() && self.languages.is_empty() && self.kind.is_none() && !item_only && !repo_scoped
284 }
285 }
286 }
287
288 /// The full-text expression for prose (repositories, work items, people):
289 /// every word, each a prefix as people type it, phrases exact, `-`
290 /// words left out. `None` when there are no words to match.
291 pub fn prose_match(&self) -> Option<String> {
292 let words: Vec<&Term> = self
293 .terms
294 .iter()
295 .filter(|term| term.text.chars().any(char::is_alphanumeric))
296 .collect();
297 let positive: Vec<&&Term> = words.iter().filter(|term| !term.negated).collect();
298 if positive.is_empty() {
299 return None;
300 }
301 let mut parts: Vec<String> = positive
302 .iter()
303 .map(|term| {
304 let quoted = fts_string(&term.text);
305 // A bare word that ends in a letter or digit also matches
306 // longer words it starts: `pars` finds `parser`.
307 let prefix = !term.phrase && term.text.chars().last().is_some_and(char::is_alphanumeric);
308 if prefix { format!("{quoted}*") } else { quoted }
309 })
310 .collect();
311 for term in words.iter().filter(|term| term.negated) {
312 parts.push(format!("NOT {}", fts_string(&term.text)));
313 }
314 Some(parts.join(" "))
315 }
316
317 /// The full-text expression for code, which matches any run of three
318 /// characters or more: each word or phrase as written. Shorter words
319 /// cannot use the index and come back separately, to be matched
320 /// against the text directly.
321 pub fn code_match(&self) -> (Option<String>, Vec<String>) {
322 let mut parts = Vec::new();
323 let mut short = Vec::new();
324 for term in self.positive() {
325 if term.text.chars().count() >= 3 {
326 parts.push(fts_string(&term.text));
327 } else {
328 short.push(term.text.to_lowercase());
329 }
330 }
331 if parts.is_empty() {
332 return (None, short);
333 }
334 for term in self.terms.iter().filter(|term| term.negated && term.text.chars().count() >= 3) {
335 parts.push(format!("NOT {}", fts_string(&term.text)));
336 }
337 (Some(parts.join(" ")), short)
338 }
339
340 /// The query written back out, tidied, as results say it was read.
341 pub fn display(&self) -> String {
342 let mut out: Vec<String> = Vec::new();
343 for term in &self.terms {
344 let text = if term.phrase || term.text.contains(' ') { format!("\"{}\"", term.text) } else { term.text.clone() };
345 out.push(if term.negated { format!("-{text}") } else { text });
346 }
347 let mut qualifier = |key: &str, filter: &Filter| {
348 for value in &filter.include {
349 out.push(format!("{key}:{}", quote_if_needed(value)));
350 }
351 for value in &filter.exclude {
352 out.push(format!("-{key}:{}", quote_if_needed(value)));
353 }
354 };
355 qualifier("repo", &self.repos);
356 qualifier("org", &self.owners);
357 qualifier("language", &self.languages);
358 qualifier("path", &self.paths);
359 qualifier("author", &self.authors);
360 qualifier("label", &self.labels);
361 match self.kind {
362 Some(ItemKind::Item) => out.push("is:item".into()),
363 Some(ItemKind::Pull) if !matches!(self.state, Some(ItemState::Merged | ItemState::Draft)) => out.push("is:pr".into()),
364 _ => {}
365 }
366 match self.state {
367 Some(ItemState::Open) => out.push("is:open".into()),
368 Some(ItemState::Closed) => out.push("is:closed".into()),
369 Some(ItemState::Merged) => out.push("is:merged".into()),
370 Some(ItemState::Draft) => out.push("is:draft".into()),
371 None => {}
372 }
373 match self.visibility {
374 Some(Visibility::Public) => out.push("is:public".into()),
375 Some(Visibility::Private) => out.push("is:private".into()),
376 None => {}
377 }
378 out.join(" ")
379 }
380}
381
382fn quote_if_needed(value: &str) -> String {
383 if value.contains(' ') { format!("\"{value}\"") } else { value.to_owned() }
384}
385
386/// A string as the full-text index reads it: in double quotes, with any
387/// double quote in it doubled, so nothing in it is read as syntax.
388pub fn fts_string(text: &str) -> String {
389 format!("\"{}\"", text.replace('"', "\"\""))
390}
391
392/// A `path:` value as a SQL `LIKE` pattern: a glob when it has a `*`
393/// (`*.rs`, `src/*/mod.rs`), else anywhere in the path. `%`, `_` and `\`
394/// match themselves.
395pub fn path_pattern(value: &str) -> String {
396 let mut escaped = String::with_capacity(value.len() + 2);
397 for c in value.chars() {
398 match c {
399 '\\' | '%' | '_' => {
400 escaped.push('\\');
401 escaped.push(c);
402 }
403 '*' => escaped.push('%'),
404 '?' => escaped.push('_'),
405 c => escaped.push(c),
406 }
407 }
408 if value.contains('*') || value.contains('?') {
409 escaped
410 } else {
411 format!("%{escaped}%")
412 }
413}
414
415#[cfg(test)]
416mod tests {
417 use super::*;
418
419 fn words(query: &Query) -> Vec<(&str, bool, bool)> {
420 query.terms.iter().map(|t| (t.text.as_str(), t.phrase, t.negated)).collect()
421 }
422
423 #[test]
424 fn words_phrases_and_left_out_words() {
425 let query = Query::parse(r#"parse "exact phrase" -legacy token"#);
426 assert_eq!(
427 words(&query),
428 vec![("parse", false, false), ("exact phrase", true, false), ("legacy", false, true), ("token", false, false)]
429 );
430 }
431
432 #[test]
433 fn qualifiers_are_read() {
434 let query = Query::parse("fn repo:Acme/Web org:acme language:TS path:src/ is:pr is:open author:@ana label:\"good first issue\"");
435 assert_eq!(words(&query), vec![("fn", false, false)]);
436 assert_eq!(query.repos.include, vec!["acme/web"]);
437 assert_eq!(query.owners.include, vec!["acme"]);
438 assert_eq!(query.languages.include, vec!["typescript"]);
439 assert_eq!(query.paths.include, vec!["src/"]);
440 assert_eq!(query.authors.include, vec!["ana"]);
441 assert_eq!(query.labels.include, vec!["good first issue"]);
442 assert_eq!(query.kind, Some(ItemKind::Pull));
443 assert_eq!(query.state, Some(ItemState::Open));
444 assert!(query.notes.is_empty());
445 }
446
447 #[test]
448 fn workspace_and_org_mean_the_same() {
449 assert_eq!(Query::parse("workspace:acme").owners, Query::parse("org:ACME").owners);
450 }
451
452 #[test]
453 fn qualifiers_can_be_left_out() {
454 let query = Query::parse("-label:wontfix -language:markdown -is:closed");
455 assert_eq!(query.labels.exclude, vec!["wontfix"]);
456 assert_eq!(query.languages.exclude, vec!["markdown"]);
457 assert_eq!(query.state, Some(ItemState::Open));
458 }
459
460 #[test]
461 fn unknown_keys_are_words() {
462 let query = Query::parse("http://example.com std::io");
463 assert_eq!(words(&query), vec![("http://example.com", false, false), ("std::io", false, false)]);
464 }
465
466 #[test]
467 fn work_items_are_items() {
468 assert_eq!(Query::parse("is:item").kind, Some(ItemKind::Item));
469 // The old names are not kept.
470 let old = Query::parse("is:issue type:issues");
471 assert_eq!(old.kind, None);
472 assert_eq!(old.type_hint, None);
473 assert_eq!(old.notes.len(), 2);
474 }
475
476 #[test]
477 fn mistakes_are_said() {
478 let query = Query::parse("repo:acme is:spaceship label:");
479 assert_eq!(query.notes.len(), 3);
480 assert!(query.repos.is_empty());
481 }
482
483 #[test]
484 fn the_tab_follows_the_qualifiers() {
485 assert_eq!(Query::parse("router").default_type(), SearchType::Repositories);
486 assert_eq!(Query::parse("router path:src").default_type(), SearchType::Code);
487 assert_eq!(Query::parse("crash is:pr").default_type(), SearchType::Pulls);
488 assert_eq!(Query::parse("crash is:open").default_type(), SearchType::Items);
489 assert_eq!(Query::parse("crash is:item").default_type(), SearchType::Items);
490 assert_eq!(Query::parse("crash type:items").default_type(), SearchType::Items);
491 assert_eq!(Query::parse("crash label:bug").default_type(), SearchType::Items);
492 assert_eq!(Query::parse("crash type:code").default_type(), SearchType::Code);
493 }
494
495 #[test]
496 fn qualifiers_rule_kinds_out() {
497 let code = Query::parse("x path:src");
498 assert!(code.applies_to(SearchType::Code));
499 assert!(!code.applies_to(SearchType::Repositories));
500 assert!(!code.applies_to(SearchType::Items));
501 let items = Query::parse("x label:bug");
502 assert!(items.applies_to(SearchType::Items) && items.applies_to(SearchType::Pulls));
503 assert!(!items.applies_to(SearchType::Code) && !items.applies_to(SearchType::People));
504 let merged = Query::parse("x is:merged");
505 assert!(merged.applies_to(SearchType::Pulls) && !merged.applies_to(SearchType::Items));
506 let scoped = Query::parse("x repo:a/b");
507 assert!(!scoped.applies_to(SearchType::People));
508 assert!(Query::parse("ana").applies_to(SearchType::People));
509 }
510
511 #[test]
512 fn prose_matching_is_safe_and_prefixed() {
513 let query = Query::parse(r#"pars "say hi" -old"#);
514 assert_eq!(query.prose_match().unwrap(), r#""pars"* "say hi" NOT "old""#);
515 // A quote inside a word is doubled, never read as syntax.
516 assert_eq!(fts_string(r#"a"b"#), r#""a""b""#);
517 // Punctuation alone matches nothing and is not sent.
518 assert_eq!(Query::parse("++ --").prose_match(), None);
519 // Only words left out: nothing to match on.
520 assert_eq!(Query::parse("-old").prose_match(), None);
521 // Words that look like the index's own syntax are only words.
522 assert_eq!(Query::parse("NEAR OR").prose_match().unwrap(), r#""NEAR"* "OR"*"#);
523 }
524
525 #[test]
526 fn code_matching_needs_three_characters() {
527 let (expression, short) = Query::parse("fn parse_query -old").code_match();
528 assert_eq!(expression.unwrap(), r#""parse_query" NOT "old""#);
529 assert_eq!(short, vec!["fn"]);
530 let (expression, short) = Query::parse("fn io").code_match();
531 assert_eq!(expression, None);
532 assert_eq!(short, vec!["fn", "io"]);
533 }
534
535 #[test]
536 fn paths_become_patterns() {
537 assert_eq!(path_pattern("src/"), "%src/%");
538 assert_eq!(path_pattern("*.rs"), "%.rs");
539 assert_eq!(path_pattern("100%_done"), "%100\\%\\_done%");
540 }
541
542 #[test]
543 fn the_query_is_written_back_tidied() {
544 let query = Query::parse(" Parse REPO:acme/web \"two words\" is:item ");
545 assert_eq!(query.display(), "Parse \"two words\" repo:acme/web is:item");
546 }
547
548 #[test]
549 fn long_queries_are_cut() {
550 let query = Query::parse(&"a ".repeat(1000));
551 assert!(query.terms.len() <= MAX_QUERY_CHARS / 2);
552 }
553}