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.

g1t.sh
540 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor1//! 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 Issue,
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 "issue" | "issues" => self.kind = Some(ItemKind::Issue),
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`, issues for `is:issue` or anything only
248 /// issues have, code for `path:`, else repositories.
249 pub fn default_type(&self) -> SearchType {
250 if let Some(kind) = self.type_hint {
251 return kind;
252 }
253 match self.kind {
254 Some(ItemKind::Pull) => return SearchType::Pulls,
255 Some(ItemKind::Issue) => return SearchType::Issues,
256 None => {}
257 }
258 if !self.paths.is_empty() {
259 return SearchType::Code;
260 }
261 if self.state.is_some() || !self.authors.is_empty() || !self.labels.is_empty() {
262 return SearchType::Issues;
263 }
264 SearchType::Repositories
265 }
266
267 /// Whether the query can have results of `kind`: qualifiers only one
268 /// kind has rule the others out (`path:` is only code, `label:` only
269 /// issues and pull requests).
270 pub fn applies_to(&self, kind: SearchType) -> bool {
271 let item_only = self.state.is_some() || !self.authors.is_empty() || !self.labels.is_empty();
272 let repo_scoped = !self.repos.is_empty() || !self.owners.is_empty() || self.visibility.is_some();
273 match kind {
274 SearchType::Repositories => self.paths.is_empty() && self.kind.is_none() && !item_only,
275 SearchType::Code => self.kind.is_none() && !item_only,
276 SearchType::Issues => {
277 self.paths.is_empty() && self.languages.is_empty() && self.kind != Some(ItemKind::Pull)
278 && !matches!(self.state, Some(ItemState::Merged | ItemState::Draft))
279 }
280 SearchType::Pulls => self.paths.is_empty() && self.languages.is_empty() && self.kind != Some(ItemKind::Issue),
281 SearchType::People => {
282 self.paths.is_empty() && self.languages.is_empty() && self.kind.is_none() && !item_only && !repo_scoped
283 }
284 }
285 }
286
287 /// The full-text expression for prose (repositories, issues, people):
288 /// every word, each a prefix as people type it, phrases exact, `-`
289 /// words left out. `None` when there are no words to match.
290 pub fn prose_match(&self) -> Option<String> {
291 let words: Vec<&Term> = self
292 .terms
293 .iter()
294 .filter(|term| term.text.chars().any(char::is_alphanumeric))
295 .collect();
296 let positive: Vec<&&Term> = words.iter().filter(|term| !term.negated).collect();
297 if positive.is_empty() {
298 return None;
299 }
300 let mut parts: Vec<String> = positive
301 .iter()
302 .map(|term| {
303 let quoted = fts_string(&term.text);
304 // A bare word that ends in a letter or digit also matches
305 // longer words it starts: `pars` finds `parser`.
306 let prefix = !term.phrase && term.text.chars().last().is_some_and(char::is_alphanumeric);
307 if prefix { format!("{quoted}*") } else { quoted }
308 })
309 .collect();
310 for term in words.iter().filter(|term| term.negated) {
311 parts.push(format!("NOT {}", fts_string(&term.text)));
312 }
313 Some(parts.join(" "))
314 }
315
316 /// The full-text expression for code, which matches any run of three
317 /// characters or more: each word or phrase as written. Shorter words
318 /// cannot use the index and come back separately, to be matched
319 /// against the text directly.
320 pub fn code_match(&self) -> (Option<String>, Vec<String>) {
321 let mut parts = Vec::new();
322 let mut short = Vec::new();
323 for term in self.positive() {
324 if term.text.chars().count() >= 3 {
325 parts.push(fts_string(&term.text));
326 } else {
327 short.push(term.text.to_lowercase());
328 }
329 }
330 if parts.is_empty() {
331 return (None, short);
332 }
333 for term in self.terms.iter().filter(|term| term.negated && term.text.chars().count() >= 3) {
334 parts.push(format!("NOT {}", fts_string(&term.text)));
335 }
336 (Some(parts.join(" ")), short)
337 }
338
339 /// The query written back out, tidied, as results say it was read.
340 pub fn display(&self) -> String {
341 let mut out: Vec<String> = Vec::new();
342 for term in &self.terms {
343 let text = if term.phrase || term.text.contains(' ') { format!("\"{}\"", term.text) } else { term.text.clone() };
344 out.push(if term.negated { format!("-{text}") } else { text });
345 }
346 let mut qualifier = |key: &str, filter: &Filter| {
347 for value in &filter.include {
348 out.push(format!("{key}:{}", quote_if_needed(value)));
349 }
350 for value in &filter.exclude {
351 out.push(format!("-{key}:{}", quote_if_needed(value)));
352 }
353 };
354 qualifier("repo", &self.repos);
355 qualifier("org", &self.owners);
356 qualifier("language", &self.languages);
357 qualifier("path", &self.paths);
358 qualifier("author", &self.authors);
359 qualifier("label", &self.labels);
360 match self.kind {
361 Some(ItemKind::Issue) => out.push("is:issue".into()),
362 Some(ItemKind::Pull) if !matches!(self.state, Some(ItemState::Merged | ItemState::Draft)) => out.push("is:pr".into()),
363 _ => {}
364 }
365 match self.state {
366 Some(ItemState::Open) => out.push("is:open".into()),
367 Some(ItemState::Closed) => out.push("is:closed".into()),
368 Some(ItemState::Merged) => out.push("is:merged".into()),
369 Some(ItemState::Draft) => out.push("is:draft".into()),
370 None => {}
371 }
372 match self.visibility {
373 Some(Visibility::Public) => out.push("is:public".into()),
374 Some(Visibility::Private) => out.push("is:private".into()),
375 None => {}
376 }
377 out.join(" ")
378 }
379}
380
381fn quote_if_needed(value: &str) -> String {
382 if value.contains(' ') { format!("\"{value}\"") } else { value.to_owned() }
383}
384
385/// A string as the full-text index reads it: in double quotes, with any
386/// double quote in it doubled, so nothing in it is read as syntax.
387pub fn fts_string(text: &str) -> String {
388 format!("\"{}\"", text.replace('"', "\"\""))
389}
390
391/// A `path:` value as a SQL `LIKE` pattern: a glob when it has a `*`
392/// (`*.rs`, `src/*/mod.rs`), else anywhere in the path. `%`, `_` and `\`
393/// match themselves.
394pub fn path_pattern(value: &str) -> String {
395 let mut escaped = String::with_capacity(value.len() + 2);
396 for c in value.chars() {
397 match c {
398 '\\' | '%' | '_' => {
399 escaped.push('\\');
400 escaped.push(c);
401 }
402 '*' => escaped.push('%'),
403 '?' => escaped.push('_'),
404 c => escaped.push(c),
405 }
406 }
407 if value.contains('*') || value.contains('?') {
408 escaped
409 } else {
410 format!("%{escaped}%")
411 }
412}
413
414#[cfg(test)]
415mod tests {
416 use super::*;
417
418 fn words(query: &Query) -> Vec<(&str, bool, bool)> {
419 query.terms.iter().map(|t| (t.text.as_str(), t.phrase, t.negated)).collect()
420 }
421
422 #[test]
423 fn words_phrases_and_left_out_words() {
424 let query = Query::parse(r#"parse "exact phrase" -legacy token"#);
425 assert_eq!(
426 words(&query),
427 vec![("parse", false, false), ("exact phrase", true, false), ("legacy", false, true), ("token", false, false)]
428 );
429 }
430
431 #[test]
432 fn qualifiers_are_read() {
433 let query = Query::parse("fn repo:Acme/Web org:acme language:TS path:src/ is:pr is:open author:@ana label:\"good first issue\"");
434 assert_eq!(words(&query), vec![("fn", false, false)]);
435 assert_eq!(query.repos.include, vec!["acme/web"]);
436 assert_eq!(query.owners.include, vec!["acme"]);
437 assert_eq!(query.languages.include, vec!["typescript"]);
438 assert_eq!(query.paths.include, vec!["src/"]);
439 assert_eq!(query.authors.include, vec!["ana"]);
440 assert_eq!(query.labels.include, vec!["good first issue"]);
441 assert_eq!(query.kind, Some(ItemKind::Pull));
442 assert_eq!(query.state, Some(ItemState::Open));
443 assert!(query.notes.is_empty());
444 }
445
446 #[test]
447 fn workspace_and_org_mean_the_same() {
448 assert_eq!(Query::parse("workspace:acme").owners, Query::parse("org:ACME").owners);
449 }
450
451 #[test]
452 fn qualifiers_can_be_left_out() {
453 let query = Query::parse("-label:wontfix -language:markdown -is:closed");
454 assert_eq!(query.labels.exclude, vec!["wontfix"]);
455 assert_eq!(query.languages.exclude, vec!["markdown"]);
456 assert_eq!(query.state, Some(ItemState::Open));
457 }
458
459 #[test]
460 fn unknown_keys_are_words() {
461 let query = Query::parse("http://example.com std::io");
462 assert_eq!(words(&query), vec![("http://example.com", false, false), ("std::io", false, false)]);
463 }
464
465 #[test]
466 fn mistakes_are_said() {
467 let query = Query::parse("repo:acme is:spaceship label:");
468 assert_eq!(query.notes.len(), 3);
469 assert!(query.repos.is_empty());
470 }
471
472 #[test]
473 fn the_tab_follows_the_qualifiers() {
474 assert_eq!(Query::parse("router").default_type(), SearchType::Repositories);
475 assert_eq!(Query::parse("router path:src").default_type(), SearchType::Code);
476 assert_eq!(Query::parse("crash is:pr").default_type(), SearchType::Pulls);
477 assert_eq!(Query::parse("crash is:open").default_type(), SearchType::Issues);
478 assert_eq!(Query::parse("crash label:bug").default_type(), SearchType::Issues);
479 assert_eq!(Query::parse("crash type:code").default_type(), SearchType::Code);
480 }
481
482 #[test]
483 fn qualifiers_rule_kinds_out() {
484 let code = Query::parse("x path:src");
485 assert!(code.applies_to(SearchType::Code));
486 assert!(!code.applies_to(SearchType::Repositories));
487 assert!(!code.applies_to(SearchType::Issues));
488 let issues = Query::parse("x label:bug");
489 assert!(issues.applies_to(SearchType::Issues) && issues.applies_to(SearchType::Pulls));
490 assert!(!issues.applies_to(SearchType::Code) && !issues.applies_to(SearchType::People));
491 let merged = Query::parse("x is:merged");
492 assert!(merged.applies_to(SearchType::Pulls) && !merged.applies_to(SearchType::Issues));
493 let scoped = Query::parse("x repo:a/b");
494 assert!(!scoped.applies_to(SearchType::People));
495 assert!(Query::parse("ana").applies_to(SearchType::People));
496 }
497
498 #[test]
499 fn prose_matching_is_safe_and_prefixed() {
500 let query = Query::parse(r#"pars "say hi" -old"#);
501 assert_eq!(query.prose_match().unwrap(), r#""pars"* "say hi" NOT "old""#);
502 // A quote inside a word is doubled, never read as syntax.
503 assert_eq!(fts_string(r#"a"b"#), r#""a""b""#);
504 // Punctuation alone matches nothing and is not sent.
505 assert_eq!(Query::parse("++ --").prose_match(), None);
506 // Only words left out: nothing to match on.
507 assert_eq!(Query::parse("-old").prose_match(), None);
508 // Words that look like the index's own syntax are only words.
509 assert_eq!(Query::parse("NEAR OR").prose_match().unwrap(), r#""NEAR"* "OR"*"#);
510 }
511
512 #[test]
513 fn code_matching_needs_three_characters() {
514 let (expression, short) = Query::parse("fn parse_query -old").code_match();
515 assert_eq!(expression.unwrap(), r#""parse_query" NOT "old""#);
516 assert_eq!(short, vec!["fn"]);
517 let (expression, short) = Query::parse("fn io").code_match();
518 assert_eq!(expression, None);
519 assert_eq!(short, vec!["fn", "io"]);
520 }
521
522 #[test]
523 fn paths_become_patterns() {
524 assert_eq!(path_pattern("src/"), "%src/%");
525 assert_eq!(path_pattern("*.rs"), "%.rs");
526 assert_eq!(path_pattern("100%_done"), "%100\\%\\_done%");
527 }
528
529 #[test]
530 fn the_query_is_written_back_tidied() {
531 let query = Query::parse(" Parse REPO:acme/web \"two words\" is:issue ");
532 assert_eq!(query.display(), "Parse \"two words\" repo:acme/web is:issue");
533 }
534
535 #[test]
536 fn long_queries_are_cut() {
537 let query = Query::parse(&"a ".repeat(1000));
538 assert!(query.terms.len() <= MAX_QUERY_CHARS / 2);
539 }
540}

This file's history is long; its oldest lines are credited to the oldest commit read.