Something went wrong. Try again.
Monorepo for Tangled forked from tangled.org/core
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478use crate::ast::{Clause, Query, Sort, Span, Term, Text, TicketKind, TicketState};use crate::error::{QueryError, QueryErrorKind, QueryErrors};use crate::lex::{self, RawClause, RawKind, RawValue};
pub const MAX_QUERY_LEN: usize = 1024;pub const MAX_CLAUSES: usize = 64;
const IS_EXPECTED: &str = "open, closed or merged";const TYPE_EXPECTED: &str = "issue or pull";const AUTHOR_EXPECTED: &str = "a did";const SORT_EXPECTED: &str = "relevance, created-desc or created-asc";
/// the known qualifier keyspub const QUALIFIERS: &[&str] = &["is", "type", "label", "author", "language", "sort"];
/// parses the query and fails if a clause is not validpub fn parse(input: &str) -> Result<Query, QueryErrors> { let (query, errors) = parse_lenient(input); if errors.is_empty() { Ok(query) } else { Err(QueryErrors::new(errors)) }}
/// parses the query and returns the valid clauses with the errorspub fn parse_lenient(input: &str) -> (Query, Vec<QueryError>) { if input.len() > MAX_QUERY_LEN { let err = QueryError::new( Span::new(0, input.len()), QueryErrorKind::TooLong { max: MAX_QUERY_LEN }, ); return (Query::default(), vec![err]); } let mut lowering = Lowering::default(); for raw in lex::tokenize(input) { lowering.clause(raw); } if lowering.clauses.len() > MAX_CLAUSES { let span = lowering.clauses[MAX_CLAUSES].span; lowering.clauses.truncate(MAX_CLAUSES); lowering.errors.push(QueryError::new( Span::new(span.start, input.len()), QueryErrorKind::TooManyClauses { max: MAX_CLAUSES }, )); } let Lowering { clauses, errors, .. } = lowering; (Query { clauses }, errors)}
#[derive(Default)]struct Lowering { clauses: Vec<Clause>, errors: Vec<QueryError>, sort_seen: bool,}
impl Lowering { fn clause(&mut self, raw: RawClause<'_>) { let RawClause { negated, span, body, kind, } = raw; let term = match kind { RawKind::Word(w) => Some(Term::Text(Text::Word(w.to_owned()))), RawKind::Phrase(p) if p.trim().is_empty() => None, RawKind::Phrase(p) => Some(Term::Text(Text::Phrase(p))), RawKind::Qualifier { key, values } => { let lower = key.to_ascii_lowercase(); if QUALIFIERS.contains(&lower.as_str()) { self.qualifier(&lower, negated, span, values) } else { Some(Term::Text(Text::Word(body.to_owned()))) } } }; if let Some(term) = term { self.clauses.push(Clause { negated, span, term, }); } }
fn qualifier( &mut self, key: &str, negated: bool, span: Span, values: Vec<RawValue>, ) -> Option<Term> { let mut ok = true; for v in values.iter().filter(|v| v.text.trim().is_empty()) { ok = false; self.error( if v.span.is_empty() { span } else { v.span }, QueryErrorKind::MissingValue { key: key.to_owned(), }, ); } if !ok { return None; } match key { "is" => self .each(key, &values, IS_EXPECTED, TicketState::parse) .map(Term::State), "type" => self .each(key, &values, TYPE_EXPECTED, TicketKind::parse) .map(Term::Kind), "label" => Some(Term::Label(names(values))), "language" => Some(Term::Language(names(values))), "author" => self .each(key, &values, AUTHOR_EXPECTED, |v| { v.starts_with("did:").then(|| v.to_owned()) }) .map(Term::Author), "sort" => { self.not_negated(key, negated, span)?; let v = self.single(key, span, &values)?; let Some(sort) = Sort::parse(v.text.trim()) else { self.invalid(key, v, SORT_EXPECTED); return None; }; if std::mem::replace(&mut self.sort_seen, true) { self.error(span, QueryErrorKind::ConflictingSort); return None; } Some(Term::Sort(sort)) } _ => None, } }
fn each<T>( &mut self, key: &str, values: &[RawValue], expected: &'static str, parse: impl Fn(&str) -> Option<T>, ) -> Option<Vec<T>> { let mut out = Vec::with_capacity(values.len()); let mut bad = false; for v in values { match parse(v.text.trim()) { Some(t) => out.push(t), None => { bad = true; self.invalid(key, v, expected); } } } (!bad).then_some(out) }
fn single<'v>( &mut self, key: &str, span: Span, values: &'v [RawValue], ) -> Option<&'v RawValue> { match values { [v] => Some(v), _ => { self.error( span, QueryErrorKind::TooManyValues { key: key.to_owned(), }, ); None } } }
fn not_negated(&mut self, key: &str, negated: bool, span: Span) -> Option<()> { if negated { self.error( span, QueryErrorKind::NotNegatable { key: key.to_owned(), }, ); None } else { Some(()) } }
fn invalid(&mut self, key: &str, v: &RawValue, expected: &'static str) { self.error( v.span, QueryErrorKind::InvalidValue { key: key.to_owned(), value: v.text.clone(), expected, }, ); }
fn error(&mut self, span: Span, kind: QueryErrorKind) { self.errors.push(QueryError::new(span, kind)); }}
fn names(values: Vec<RawValue>) -> Vec<String> { values .into_iter() .map(|v| v.text.trim().to_owned()) .collect()}
#[cfg(test)]mod tests { use super::*;
fn terms(input: &str) -> Vec<(bool, Term)> { parse(input) .unwrap_or_else(|e| panic!("{input:?}: {e}")) .clauses .into_iter() .map(|c| (c.negated, c.term)) .collect() }
fn errors(input: &str) -> Vec<QueryErrorKind> { parse(input) .expect_err(input) .into_iter() .map(|e| e.kind) .collect() }
fn word(s: &str) -> Term { Term::Text(Text::Word(s.to_owned())) }
#[test] fn empty_query_is_empty() { assert!(parse("").unwrap().is_empty()); assert!(parse(" ").unwrap().is_empty()); assert!(parse("\"\"").unwrap().is_empty()); }
#[test] fn free_text_and_phrases() { assert_eq!( terms("kelp \"sea urchin\" -squid"), vec![ (false, word("kelp")), (false, Term::Text(Text::Phrase("sea urchin".into()))), (true, word("squid")), ] ); }
#[test] fn is_takes_states_and_type_takes_kinds() { assert_eq!( terms("is:open,merged type:pr -is:closed"), vec![ ( false, Term::State(vec![TicketState::Open, TicketState::Completed]) ), (false, Term::Kind(vec![TicketKind::Pull])), (true, Term::State(vec![TicketState::Closed])), ] ); assert_eq!( errors("is:issue"), vec![QueryErrorKind::InvalidValue { key: "is".into(), value: "issue".into(), expected: IS_EXPECTED }] ); }
#[test] fn keys_and_enum_values_are_case_insensitive() { assert_eq!( terms("IS:Open Is:CLOSED Type:Issue"), vec![ (false, Term::State(vec![TicketState::Open])), (false, Term::State(vec![TicketState::Closed])), (false, Term::Kind(vec![TicketKind::Issue])), ] ); }
#[test] fn labels_keep_names_and_quotes() { assert_eq!( terms(r#"label:bug,"good first issue" -label:wontfix"#), vec![ ( false, Term::Label(vec!["bug".into(), "good first issue".into()]) ), (true, Term::Label(vec!["wontfix".into()])), ] ); }
#[test] fn languages_take_names() { let parsed = parse(r#"kelp language:Rust,"Jupyter Notebook" -language:Go lang:C"#).unwrap(); assert!(parsed.targets_repos()); assert!(!parsed.targets_tickets()); assert_eq!( parsed .clauses .into_iter() .map(|c| (c.negated, c.term)) .collect::<Vec<_>>(), vec![ (false, word("kelp")), ( false, Term::Language(vec!["Rust".into(), "Jupyter Notebook".into()]) ), (true, Term::Language(vec!["Go".into()])), (false, word("lang:C")), ] ); }
#[test] fn author_takes_dids_only() { assert_eq!( terms("author:did:plc:alice,did:plc:bob"), vec![( false, Term::Author(vec!["did:plc:alice".into(), "did:plc:bob".into()]) )] ); assert_eq!( errors("author:alice.tngl.sh"), vec![QueryErrorKind::InvalidValue { key: "author".into(), value: "alice.tngl.sh".into(), expected: AUTHOR_EXPECTED }] ); }
#[test] fn sort_is_read_back() { assert_eq!( parse("kelp sort:oldest").unwrap().sort(), Some(Sort::CreatedAsc) ); assert_eq!(parse("kelp").unwrap().sort(), None); }
#[test] fn dropped_keys_read_as_text() { assert_eq!( terms("created:>=2026-01-01 in:title"), vec![ (false, word("created:>=2026-01-01")), (false, word("in:title")) ] ); }
#[test] fn unknown_keys_are_text() { assert_eq!( terms("https://tangled.org -foo:bar state:open"), vec![ (false, word("https://tangled.org")), (true, word("foo:bar")), (false, word("state:open")) ] ); }
#[test] fn reports_every_problem_with_spans() { let src = "is:nope label: -sort:oldest sort:x"; let errs = parse(src).unwrap_err().into_vec(); let kinds: Vec<_> = errs.iter().map(|e| e.kind.clone()).collect(); assert_eq!( kinds, vec![ QueryErrorKind::InvalidValue { key: "is".into(), value: "nope".into(), expected: IS_EXPECTED }, QueryErrorKind::MissingValue { key: "label".into() }, QueryErrorKind::NotNegatable { key: "sort".into() }, QueryErrorKind::InvalidValue { key: "sort".into(), value: "x".into(), expected: SORT_EXPECTED }, ] ); let spans: Vec<&str> = errs .iter() .map(|e| &src[e.span.start..e.span.end]) .collect(); assert_eq!(spans, vec!["nope", "label:", "-sort:oldest", "x"]); }
#[test] fn second_sort_conflicts() { assert_eq!( errors("sort:oldest sort:newest"), vec![QueryErrorKind::ConflictingSort] ); }
#[test] fn empty_list_slots_are_errors() { assert_eq!( errors("label:a,,b"), vec![QueryErrorKind::MissingValue { key: "label".into() }] ); assert_eq!( errors("label:\"\""), vec![QueryErrorKind::MissingValue { key: "label".into() }] ); }
#[test] fn lenient_keeps_the_good_parts() { let (q, errs) = parse_lenient("kelp is:nope label:bug"); assert_eq!(errs.len(), 1); assert_eq!( q.clauses.into_iter().map(|c| c.term).collect::<Vec<_>>(), vec![word("kelp"), Term::Label(vec!["bug".into()])] ); }
#[test] fn limits() { let long = "a".repeat(MAX_QUERY_LEN + 1); assert_eq!( errors(&long), vec![QueryErrorKind::TooLong { max: MAX_QUERY_LEN }] ); let many = vec!["w"; MAX_CLAUSES + 3].join(" "); let (q, errs) = parse_lenient(&many); assert_eq!(q.clauses.len(), MAX_CLAUSES); assert_eq!( errs.into_iter().map(|e| e.kind).collect::<Vec<_>>(), vec![QueryErrorKind::TooManyClauses { max: MAX_CLAUSES }] ); }
#[test] fn ticket_scope_detection() { assert!(parse("kelp is:open").unwrap().targets_tickets()); assert!(parse("-label:bug").unwrap().targets_tickets()); assert!( !parse("kelp author:did:plc:alice sort:newest") .unwrap() .targets_tickets() ); }}