use 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 keys pub const QUALIFIERS: &[&str] = &["is", "type", "label", "author", "language", "sort"]; /// parses the query and fails if a clause is not valid pub fn parse(input: &str) -> Result { 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 errors pub fn parse_lenient(input: &str) -> (Query, Vec) { 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, errors: Vec, 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, ) -> Option { 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( &mut self, key: &str, values: &[RawValue], expected: &'static str, parse: impl Fn(&str) -> Option, ) -> Option> { 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) -> Vec { 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 { 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![ (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![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![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() ); } }