use std::vec;
use log::debug;
const ORDER_CHAR: &str = "^";
const ORDER_LETTER: &str = "or";
enum ElementType {
Invalid,
Term,
In,
At,
Path,
OrderBy { asc: bool },
ExcludedTerm,
ExcludedIn,
ExcludedAt,
ExcludedPath,
Label,
ExcludedLabel,
Links,
ExcludedLinks,
ForwardLinks,
ExcludedForwardLinks,
}
struct QueryTermExtractor {
el_type: ElementType,
term: String,
remainder: String,
}
type PrefixEntry = (&'static str, &'static str, fn() -> ElementType);
fn prefix_table() -> [PrefixEntry; 12] {
[
("-name:", "-=", || ElementType::ExcludedAt),
("-lk:", "-<", || ElementType::ExcludedLinks),
("-fwd:", "->", || ElementType::ExcludedForwardLinks),
("-in:", "-@", || ElementType::ExcludedIn),
("-pt:", "-/", || ElementType::ExcludedPath),
("-lb:", "-#", || ElementType::ExcludedLabel),
("name:", "=", || ElementType::At),
("lk:", "<", || ElementType::Links),
("fwd:", ">", || ElementType::ForwardLinks),
("in:", "@", || ElementType::In),
("pt:", "/", || ElementType::Path),
("lb:", "#", || ElementType::Label),
]
}
fn detect_prefix(query: &str) -> Option<(ElementType, &str)> {
for (long, short, make_type) in prefix_table() {
if let Some(remaining) = query
.strip_prefix(long)
.or_else(|| query.strip_prefix(short))
{
return Some((make_type(), remaining));
}
}
None
}
impl QueryTermExtractor {
fn extract_and_consume<S: AsRef<str>>(query: S) -> QueryTermExtractor {
let query = query.as_ref().trim();
let (element_type, remaining) = if let Some((el_type, remaining)) = detect_prefix(query) {
(el_type, remaining.to_string())
} else {
let order_prefix = format!("{}:", ORDER_LETTER);
let desc_order_prefix = format!("-{}:", ORDER_LETTER);
let desc_order_char = format!("-{}", ORDER_CHAR);
if let Some(rest) = query.strip_prefix(&desc_order_prefix) {
(ElementType::OrderBy { asc: false }, rest.to_string())
} else if let Some(rest) = query.strip_prefix(&order_prefix) {
(ElementType::OrderBy { asc: true }, rest.to_string())
} else if let Some(rest) = query.strip_prefix(&desc_order_char) {
(ElementType::OrderBy { asc: false }, rest.to_string())
} else if let Some(rest) = query.strip_prefix(ORDER_CHAR) {
(ElementType::OrderBy { asc: true }, rest.to_string())
} else if let Some(rest) = query.strip_prefix('-') {
(ElementType::ExcludedTerm, rest.to_string())
} else {
(ElementType::Term, query.to_string())
}
};
let (sep_char, mut term) = if remaining.starts_with('"') {
('"', remaining.chars().skip(1).collect())
} else if remaining.starts_with("'") {
('\'', remaining.chars().skip(1).collect())
} else {
(' ', remaining)
};
match term.find(sep_char) {
Some(pos) => {
let mut remaining = term.split_off(pos);
remaining = remaining
.strip_prefix(sep_char)
.map_or_else(|| remaining.trim().to_owned(), |s| s.trim().to_string());
debug!("TERM: {}", term);
debug!("REMAINING: {}", remaining);
QueryTermExtractor {
el_type: element_type,
term,
remainder: remaining,
}
}
None => {
if sep_char == ' ' {
let term = term
.strip_suffix(sep_char)
.map_or_else(|| term.clone(), |s| s.to_string());
QueryTermExtractor {
el_type: element_type,
term,
remainder: String::new(),
}
} else {
QueryTermExtractor {
el_type: ElementType::Invalid,
term: String::new(),
remainder: String::new(),
}
}
}
}
}
}
#[derive(Debug)]
pub enum OrderBy {
Title {
asc: bool,
},
FileName {
asc: bool,
},
}
impl OrderBy {
fn from_term(term: &str, asc: bool) -> Option<Self> {
match term {
"f" => Some(OrderBy::FileName { asc }),
"file" => Some(OrderBy::FileName { asc }),
"filename" => Some(OrderBy::FileName { asc }),
"t" => Some(OrderBy::Title { asc }),
"title" => Some(OrderBy::Title { asc }),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OrderField {
Title,
FileName,
}
fn is_order_token(token: &str) -> bool {
let rest = token.strip_prefix('-').unwrap_or(token);
rest.starts_with(ORDER_CHAR)
|| rest
.strip_prefix(ORDER_LETTER)
.is_some_and(|after| after.starts_with(':'))
}
pub fn quote_query_term(term: &str) -> String {
if term.chars().any(char::is_whitespace) {
format!("\"{term}\"")
} else {
term.to_string()
}
}
fn is_note_element(el: &ElementType) -> bool {
matches!(
el,
ElementType::Links
| ElementType::ExcludedLinks
| ElementType::ForwardLinks
| ElementType::ExcludedForwardLinks
| ElementType::At
| ElementType::ExcludedAt
)
}
pub fn expand_bare_note_prefixes(query: &str, target: &str) -> String {
let mut out = String::with_capacity(query.len());
let mut rest = query;
while !rest.is_empty() {
let token_start = match rest.find(|c: char| !c.is_whitespace()) {
Some(pos) => pos,
None => {
out.push_str(rest);
break;
}
};
out.push_str(&rest[..token_start]);
rest = &rest[token_start..];
let detected = detect_prefix(rest);
let prefix_len = detected
.as_ref()
.map_or(0, |(_, remaining)| rest.len() - remaining.len());
let value = &rest[prefix_len..];
let token_len = match value.chars().next() {
Some(quote @ ('"' | '\'')) => {
match value[quote.len_utf8()..].find(quote) {
Some(pos) => prefix_len + quote.len_utf8() * 2 + pos,
None => rest.len(),
}
}
_ => rest.find(' ').unwrap_or(rest.len()),
};
let token = &rest[..token_len];
out.push_str(token);
if prefix_len == token_len {
if let Some((el, _)) = detected {
if is_note_element(&el) {
out.push_str(target);
}
}
}
rest = &rest[token_len..];
}
out
}
pub fn strip_order_directive(query: &str) -> String {
query
.split_whitespace()
.filter(|t| !is_order_token(t))
.collect::<Vec<_>>()
.join(" ")
}
pub fn with_order_directive(query: &str, field: OrderField, asc: bool) -> String {
let base = strip_order_directive(query);
let field_term = match field {
OrderField::Title => "title",
OrderField::FileName => "file",
};
let directive = if asc {
format!("{}:{}", ORDER_LETTER, field_term)
} else {
format!("-{}:{}", ORDER_LETTER, field_term)
};
if base.is_empty() {
directive
} else {
format!("{} {}", base, directive)
}
}
#[derive(Default, Debug)]
pub struct SearchTerms {
pub terms: Vec<String>,
pub breadcrumb: Vec<String>,
pub order_by: Vec<OrderBy>,
pub filename: Vec<String>,
pub path: Vec<String>,
pub labels: Vec<String>,
pub links: Vec<String>,
pub forward_links: Vec<String>,
pub excluded_terms: Vec<String>,
pub excluded_breadcrumb: Vec<String>,
pub excluded_filename: Vec<String>,
pub excluded_path: Vec<String>,
pub excluded_labels: Vec<String>,
pub excluded_links: Vec<String>,
pub excluded_forward_links: Vec<String>,
}
const MAX_QUERY_LEN: usize = 8 * 1024;
impl SearchTerms {
pub fn from_query_string<S: AsRef<str>>(query: S) -> Self {
let query_ref = query.as_ref();
let query_ref = if query_ref.len() > MAX_QUERY_LEN {
let mut idx = MAX_QUERY_LEN;
while !query_ref.is_char_boundary(idx) {
idx -= 1;
}
&query_ref[..idx]
} else {
query_ref
};
let mut query = query_ref.to_string();
let mut breadcrumb = vec![];
let mut terms = vec![];
let mut filename = vec![];
let mut order_by = vec![];
let mut path = vec![];
let mut labels = vec![];
let mut links = vec![];
let mut forward_links = vec![];
let mut excluded_terms = vec![];
let mut excluded_breadcrumb = vec![];
let mut excluded_filename = vec![];
let mut excluded_path = vec![];
let mut excluded_labels = vec![];
let mut excluded_links = vec![];
let mut excluded_forward_links = vec![];
while !query.is_empty() {
let qp = QueryTermExtractor::extract_and_consume(query);
query = qp.remainder;
match qp.el_type {
ElementType::Term => {
if !qp.term.is_empty() {
terms.push(qp.term);
}
}
ElementType::In => {
if !qp.term.is_empty() {
breadcrumb.push(qp.term);
}
}
ElementType::At => {
if !qp.term.is_empty() {
filename.push(qp.term);
}
}
ElementType::OrderBy { asc } => {
if let Some(o) = OrderBy::from_term(&qp.term, asc) {
order_by.push(o);
}
}
ElementType::Invalid => {}
ElementType::Path => {
if !qp.term.is_empty() {
path.push(qp.term);
}
}
ElementType::Label => {
let n = qp.term.to_lowercase();
if !n.is_empty() {
labels.push(n);
}
}
ElementType::ExcludedTerm => {
if !qp.term.is_empty() {
excluded_terms.push(qp.term);
}
}
ElementType::ExcludedIn => {
if !qp.term.is_empty() {
excluded_breadcrumb.push(qp.term);
}
}
ElementType::ExcludedAt => {
if !qp.term.is_empty() {
excluded_filename.push(qp.term);
}
}
ElementType::ExcludedPath => {
if !qp.term.is_empty() {
excluded_path.push(qp.term);
}
}
ElementType::ExcludedLabel => {
let n = qp.term.to_lowercase();
if !n.is_empty() {
excluded_labels.push(n);
}
}
ElementType::Links => {
if !qp.term.is_empty() {
links.push(qp.term);
}
}
ElementType::ExcludedLinks => {
if !qp.term.is_empty() {
excluded_links.push(qp.term);
}
}
ElementType::ForwardLinks => {
if !qp.term.is_empty() {
forward_links.push(qp.term);
}
}
ElementType::ExcludedForwardLinks => {
if !qp.term.is_empty() {
excluded_forward_links.push(qp.term);
}
}
}
}
dedup_preserving_order(&mut labels);
dedup_preserving_order(&mut excluded_labels);
dedup_preserving_order(&mut links);
dedup_preserving_order(&mut excluded_links);
dedup_preserving_order(&mut forward_links);
dedup_preserving_order(&mut excluded_forward_links);
Self {
breadcrumb,
filename,
order_by,
terms,
path,
labels,
links,
forward_links,
excluded_terms,
excluded_breadcrumb,
excluded_filename,
excluded_path,
excluded_labels,
excluded_links,
excluded_forward_links,
}
}
}
fn dedup_preserving_order(v: &mut Vec<String>) {
let mut seen = std::collections::HashSet::new();
v.retain(|x| seen.insert(x.clone()));
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum QueryTokenClass {
Negation,
FieldKey,
LinkValue,
TagValue,
Quoted,
Date,
Number,
Term,
Unterminated,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct QueryTokenSpan {
pub range: std::ops::Range<usize>,
pub class: QueryTokenClass,
}
fn is_date_like(term: &str) -> bool {
let b = term.as_bytes();
b.len() == 10
&& b[4] == b'-'
&& b[7] == b'-'
&& b.iter()
.enumerate()
.all(|(i, c)| matches!(i, 4 | 7) || c.is_ascii_digit())
}
fn value_class(el: &ElementType, term: &str) -> QueryTokenClass {
match el {
ElementType::Label | ElementType::ExcludedLabel => QueryTokenClass::TagValue,
ElementType::Links
| ElementType::ExcludedLinks
| ElementType::ForwardLinks
| ElementType::ExcludedForwardLinks
| ElementType::At
| ElementType::ExcludedAt => QueryTokenClass::LinkValue,
ElementType::Term | ElementType::ExcludedTerm => {
if is_date_like(term) {
QueryTokenClass::Date
} else if !term.is_empty() && term.chars().all(|c| c.is_ascii_digit()) {
QueryTokenClass::Number
} else {
QueryTokenClass::Term
}
}
_ => QueryTokenClass::Term,
}
}
pub fn query_token_spans(query: &str) -> Vec<QueryTokenSpan> {
let mut spans = Vec::new();
let mut pos = 0usize;
let len = query.len();
while pos < len {
let c = query[pos..].chars().next().expect("pos < len");
if c.is_whitespace() {
pos += c.len_utf8();
continue;
}
let token_start = pos;
let rest = &query[pos..];
let (neg, after_neg) = match rest.strip_prefix('-') {
Some(r) => (true, r),
None => (false, rest),
};
let prefixed = prefix_table()
.into_iter()
.find_map(|(long, short, make_type)| {
let long = long.strip_prefix('-').unwrap_or(long);
let short = short.strip_prefix('-').unwrap_or(short);
after_neg
.strip_prefix(long)
.map(|r| (make_type(), long.len(), r))
.or_else(|| {
after_neg
.strip_prefix(short)
.map(|r| (make_type(), short.len(), r))
})
})
.or_else(|| {
let order_letter = format!("{ORDER_LETTER}:");
after_neg
.strip_prefix(&order_letter)
.map(|r| (ElementType::OrderBy { asc: !neg }, order_letter.len(), r))
.or_else(|| {
after_neg
.strip_prefix(ORDER_CHAR)
.map(|r| (ElementType::OrderBy { asc: !neg }, ORDER_CHAR.len(), r))
})
});
let (el, prefix_len, value) = match prefixed {
Some((el, plen, value)) => (el, plen, value),
None => (
if neg {
ElementType::ExcludedTerm
} else {
ElementType::Term
},
0,
after_neg,
),
};
let mut cursor = token_start;
if neg {
spans.push(QueryTokenSpan {
range: cursor..cursor + 1,
class: QueryTokenClass::Negation,
});
cursor += 1;
}
if prefix_len > 0 {
spans.push(QueryTokenSpan {
range: cursor..cursor + prefix_len,
class: QueryTokenClass::FieldKey,
});
cursor += prefix_len;
}
if let Some(q) = value.chars().next().filter(|c| *c == '"' || *c == '\'') {
match value[1..].find(q) {
Some(close_rel) => {
let end = cursor + 1 + close_rel + 1;
spans.push(QueryTokenSpan {
range: cursor..end,
class: QueryTokenClass::Quoted,
});
pos = end;
}
None => {
spans.push(QueryTokenSpan {
range: cursor..len,
class: QueryTokenClass::Unterminated,
});
pos = len;
}
}
} else {
let value_end = value.find(' ').map_or(len, |i| cursor + i);
if value_end > cursor {
spans.push(QueryTokenSpan {
range: cursor..value_end,
class: value_class(&el, &query[cursor..value_end]),
});
}
pos = value_end.max(cursor + usize::from(value_end == cursor));
}
}
spans
}
pub fn query_has_unterminated_quote(query: &str) -> bool {
query_token_spans(query)
.last()
.is_some_and(|s| s.class == QueryTokenClass::Unterminated)
}
#[cfg(test)]
mod lexer_tests {
use super::*;
fn classes(q: &str) -> Vec<(QueryTokenClass, String)> {
query_token_spans(q)
.into_iter()
.map(|s| (s.class, q[s.range].to_string()))
.collect()
}
#[test]
fn lexes_field_prefixes_and_values() {
use QueryTokenClass as C;
assert_eq!(
classes("#meeting <maria fwd:plan"),
vec![
(C::FieldKey, "#".into()),
(C::TagValue, "meeting".into()),
(C::FieldKey, "<".into()),
(C::LinkValue, "maria".into()),
(C::FieldKey, "fwd:".into()),
(C::LinkValue, "plan".into()),
]
);
}
#[test]
fn lexes_negation_quotes_dates_numbers() {
use QueryTokenClass as C;
assert_eq!(
classes(r#"-#wip "refresh token" 2026-04-01 42"#),
vec![
(C::Negation, "-".into()),
(C::FieldKey, "#".into()),
(C::TagValue, "wip".into()),
(C::Quoted, "\"refresh token\"".into()),
(C::Date, "2026-04-01".into()),
(C::Number, "42".into()),
]
);
}
#[test]
fn lexes_order_directive_and_bare_negated_term() {
use QueryTokenClass as C;
assert_eq!(
classes("or:title -^file -draft"),
vec![
(C::FieldKey, "or:".into()),
(C::Term, "title".into()),
(C::Negation, "-".into()),
(C::FieldKey, "^".into()),
(C::Term, "file".into()),
(C::Negation, "-".into()),
(C::Term, "draft".into()),
]
);
}
#[test]
fn whitespace_before_a_token_does_not_hide_its_prefix() {
use QueryTokenClass as C;
assert_eq!(
classes("a \t#x"),
vec![
(C::Term, "a".into()),
(C::FieldKey, "#".into()),
(C::TagValue, "x".into()),
]
);
assert_eq!(classes("a\tb"), vec![(C::Term, "a\tb".into())]);
}
#[test]
fn unterminated_quote_marks_the_tail() {
use QueryTokenClass as C;
let got = classes(r#"plan #"half open"#);
assert_eq!(got.last().unwrap().0, C::Unterminated);
assert!(query_has_unterminated_quote(r#"plan #"half open"#));
assert!(!query_has_unterminated_quote(r#"plan #"closed""#));
}
#[test]
fn lexer_agrees_with_parser_on_values() {
let q = r#"alpha -#wip <maria "two words" pt:proj/sub or:title"#;
let terms = SearchTerms::from_query_string(q);
let spans = query_token_spans(q);
let values: Vec<&str> = spans
.iter()
.filter(|s| {
!matches!(
s.class,
QueryTokenClass::FieldKey | QueryTokenClass::Negation
)
})
.map(|s| q[s.range.clone()].trim_matches('"'))
.collect();
for term in terms
.terms
.iter()
.chain(terms.labels.iter())
.chain(terms.excluded_labels.iter())
.chain(terms.links.iter())
.chain(terms.path.iter())
{
assert!(
values.iter().any(|v| v.eq_ignore_ascii_case(term)),
"parser term {term:?} missing from lexer values {values:?}"
);
}
}
}
#[cfg(test)]
mod tests {
use super::expand_bare_note_prefixes;
use super::SearchTerms;
#[test]
fn expand_bare_short_note_prefixes() {
assert_eq!(expand_bare_note_prefixes("<", "{note}"), "<{note}");
assert_eq!(expand_bare_note_prefixes(">", "{note}"), ">{note}");
assert_eq!(expand_bare_note_prefixes("=", "{note}"), "={note}");
assert_eq!(
expand_bare_note_prefixes("#todo <", "{note}"),
"#todo <{note}"
);
assert_eq!(
expand_bare_note_prefixes("< #todo", "{note}"),
"<{note} #todo"
);
}
#[test]
fn expand_bare_long_note_prefixes() {
assert_eq!(expand_bare_note_prefixes("lk:", "{note}"), "lk:{note}");
assert_eq!(expand_bare_note_prefixes("fwd:", "{note}"), "fwd:{note}");
assert_eq!(expand_bare_note_prefixes("name:", "{note}"), "name:{note}");
}
#[test]
fn expand_bare_excluded_note_prefixes() {
assert_eq!(expand_bare_note_prefixes("-<", "{note}"), "-<{note}");
assert_eq!(expand_bare_note_prefixes("->", "{note}"), "->{note}");
assert_eq!(expand_bare_note_prefixes("-=", "{note}"), "-={note}");
assert_eq!(expand_bare_note_prefixes("-lk:", "{note}"), "-lk:{note}");
}
#[test]
fn expand_leaves_prefixes_with_targets_untouched() {
assert_eq!(
expand_bare_note_prefixes("<projects", "{note}"),
"<projects"
);
assert_eq!(
expand_bare_note_prefixes(">projects", "{note}"),
">projects"
);
assert_eq!(
expand_bare_note_prefixes("=projects", "{note}"),
"=projects"
);
assert_eq!(
expand_bare_note_prefixes("lk:projects", "{note}"),
"lk:projects"
);
assert_eq!(
expand_bare_note_prefixes("<\"my note\"", "{note}"),
"<\"my note\""
);
}
#[test]
fn expand_leaves_non_note_prefixes_untouched() {
assert_eq!(expand_bare_note_prefixes("@", "{note}"), "@");
assert_eq!(expand_bare_note_prefixes("#", "{note}"), "#");
assert_eq!(expand_bare_note_prefixes("/", "{note}"), "/");
assert_eq!(expand_bare_note_prefixes("in:", "{note}"), "in:");
assert_eq!(expand_bare_note_prefixes("term", "{note}"), "term");
}
#[test]
fn expand_ignores_operators_inside_quoted_terms() {
assert_eq!(
expand_bare_note_prefixes("\"a < b\"", "{note}"),
"\"a < b\""
);
assert_eq!(expand_bare_note_prefixes("'a = b'", "{note}"), "'a = b'");
}
#[test]
fn expand_treats_mid_token_quotes_as_literal() {
assert_eq!(
expand_bare_note_prefixes("= don't <", "{note}"),
"={note} don't <{note}"
);
}
#[test]
fn expand_preserves_whitespace_verbatim() {
assert_eq!(
expand_bare_note_prefixes(" #todo < ", "{note}"),
" #todo <{note} "
);
}
#[test]
fn expand_matches_parser_ascii_space_tokenization() {
assert_eq!(
expand_bare_note_prefixes("<\u{a0}foo", "{note}"),
"<\u{a0}foo"
);
assert_eq!(expand_bare_note_prefixes("a\t<", "{note}"), "a\t<");
}
#[test]
fn expand_with_unterminated_quote() {
assert_eq!(
expand_bare_note_prefixes("< \"my no", "{note}"),
"<{note} \"my no"
);
}
#[test]
fn search_terms() {
let query = "some text more terms";
let search_terms = SearchTerms::from_query_string(query);
println!("{:?}", &search_terms);
let breadcrumb = search_terms.breadcrumb;
let filename = search_terms.filename;
let path = search_terms.path;
let terms = search_terms.terms;
assert!(breadcrumb.is_empty());
assert!(filename.is_empty());
assert!(path.is_empty());
assert!(!terms.is_empty());
assert_eq!(4, terms.len());
assert!(terms.contains(&"some".to_string()));
assert!(terms.contains(&"text".to_string()));
assert!(terms.contains(&"more".to_string()));
assert!(terms.contains(&"terms".to_string()));
}
#[test]
fn search_in() {
let query = "@title in:othertitle";
let search_terms = SearchTerms::from_query_string(query);
println!("{:?}", &search_terms);
let breadcrumb = search_terms.breadcrumb;
let filename = search_terms.filename;
let terms = search_terms.terms;
assert!(!breadcrumb.is_empty());
assert!(filename.is_empty());
assert!(terms.is_empty());
assert_eq!(2, breadcrumb.len());
assert!(breadcrumb.contains(&"title".to_string()));
assert!(breadcrumb.contains(&"othertitle".to_string()));
}
#[test]
fn search_at() {
let query = "=file name:directory";
let search_terms = SearchTerms::from_query_string(query);
println!("{:?}", &search_terms);
let breadcrumb = search_terms.breadcrumb;
let filename = search_terms.filename;
let terms = search_terms.terms;
assert!(breadcrumb.is_empty());
assert!(!filename.is_empty());
assert!(terms.is_empty());
assert_eq!(2, filename.len());
assert!(filename.contains(&"file".to_string()));
assert!(filename.contains(&"directory".to_string()));
}
#[test]
fn search_at_quoted() {
let query = "='file name' name:\"directory path\"";
let search_terms = SearchTerms::from_query_string(query);
println!("{:?}", &search_terms);
let breadcrumb = search_terms.breadcrumb;
let filename = search_terms.filename;
let terms = search_terms.terms;
assert!(breadcrumb.is_empty());
assert!(!filename.is_empty());
assert!(terms.is_empty());
assert_eq!(2, filename.len());
assert!(filename.contains(&"file name".to_string()));
assert!(filename.contains(&"directory path".to_string()));
}
#[test]
fn search_at_quoted_not_closed() {
let query = "='file name' name:\"directory path";
let search_terms = SearchTerms::from_query_string(query);
println!("{:?}", &search_terms);
let breadcrumb = search_terms.breadcrumb;
let filename = search_terms.filename;
let path = search_terms.path;
let terms = search_terms.terms;
assert!(breadcrumb.is_empty());
assert!(!filename.is_empty());
assert!(terms.is_empty());
assert!(path.is_empty());
assert_eq!(1, filename.len());
assert!(filename.contains(&"file name".to_string()));
}
#[test]
fn search_combined() {
let query = "searchterm =file otherterm name:directory in:title @text \"some text\" /basedirectory";
let search_terms = SearchTerms::from_query_string(query);
println!("{:?}", &search_terms);
let breadcrumb = search_terms.breadcrumb;
let filename = search_terms.filename;
let terms = search_terms.terms;
let path = search_terms.path;
assert!(!breadcrumb.is_empty());
assert!(!filename.is_empty());
assert!(!terms.is_empty());
assert!(!path.is_empty());
assert_eq!(3, terms.len());
assert!(terms.contains(&"searchterm".to_string()));
assert!(terms.contains(&"otherterm".to_string()));
assert!(terms.contains(&"some text".to_string()));
assert_eq!(2, breadcrumb.len());
assert!(breadcrumb.contains(&"title".to_string()));
assert!(breadcrumb.contains(&"text".to_string()));
assert_eq!(2, filename.len());
assert!(filename.contains(&"file".to_string()));
assert!(filename.contains(&"directory".to_string()));
assert_eq!(1, path.len());
assert!(path.contains(&"basedirectory".to_string()));
}
#[test]
fn test_basic_exclusion_parsing() {
let search_terms = SearchTerms::from_query_string("meeting -cancelled");
assert_eq!(search_terms.terms, vec!["meeting"]);
assert_eq!(search_terms.excluded_terms, vec!["cancelled"]);
assert!(search_terms.breadcrumb.is_empty());
}
#[test]
fn test_compound_exclusion_prefixes() {
let search_terms = SearchTerms::from_query_string("-@draft -in:private -=temp -/secret");
assert!(search_terms.terms.is_empty());
assert!(search_terms.breadcrumb.is_empty());
assert_eq!(search_terms.excluded_breadcrumb, vec!["draft", "private"]);
assert_eq!(search_terms.excluded_filename, vec!["temp"]);
assert_eq!(search_terms.excluded_path, vec!["secret"]);
}
#[test]
fn search_links_short() {
let s = SearchTerms::from_query_string("<projects");
assert_eq!(s.links, vec!["projects".to_string()]);
assert!(s.terms.is_empty());
}
#[test]
fn search_links_long() {
let s = SearchTerms::from_query_string("lk:projects");
assert_eq!(s.links, vec!["projects".to_string()]);
}
#[test]
fn search_links_with_extension_and_path() {
let s = SearchTerms::from_query_string("<work/projects.md");
assert_eq!(s.links, vec!["work/projects.md".to_string()]);
}
#[test]
fn search_links_excluded_short() {
let s = SearchTerms::from_query_string("-<draft");
assert_eq!(s.excluded_links, vec!["draft".to_string()]);
}
#[test]
fn search_links_excluded_long() {
let s = SearchTerms::from_query_string("-lk:draft");
assert_eq!(s.excluded_links, vec!["draft".to_string()]);
}
#[test]
fn search_links_mixed_with_term() {
let s = SearchTerms::from_query_string("report <spec");
assert_eq!(s.terms, vec!["report".to_string()]);
assert_eq!(s.links, vec!["spec".to_string()]);
}
#[test]
fn search_links_quoted() {
let s = SearchTerms::from_query_string("<\"my note\"");
assert_eq!(s.links, vec!["my note".to_string()]);
}
#[test]
fn search_forward_links_short() {
let s = SearchTerms::from_query_string(">spec");
assert_eq!(s.forward_links, vec!["spec".to_string()]);
assert!(s.terms.is_empty());
assert!(s.links.is_empty());
}
#[test]
fn search_forward_links_long() {
let s = SearchTerms::from_query_string("fwd:spec");
assert_eq!(s.forward_links, vec!["spec".to_string()]);
}
#[test]
fn search_forward_links_excluded_short() {
let s = SearchTerms::from_query_string("->draft");
assert_eq!(s.excluded_forward_links, vec!["draft".to_string()]);
assert!(s.excluded_links.is_empty());
}
#[test]
fn search_forward_links_excluded_long() {
let s = SearchTerms::from_query_string("-fwd:draft");
assert_eq!(s.excluded_forward_links, vec!["draft".to_string()]);
}
#[test]
fn search_backlinks_filename_section_chars() {
assert_eq!(
SearchTerms::from_query_string("<spec").links,
vec!["spec".to_string()]
);
assert_eq!(
SearchTerms::from_query_string("=file").filename,
vec!["file".to_string()]
);
assert_eq!(
SearchTerms::from_query_string("@title").breadcrumb,
vec!["title".to_string()]
);
}
#[test]
fn search_label_short() {
let s = SearchTerms::from_query_string("#important");
assert_eq!(s.labels, vec!["important".to_string()]);
assert!(s.terms.is_empty());
}
#[test]
fn search_label_long() {
let s = SearchTerms::from_query_string("lb:important");
assert_eq!(s.labels, vec!["important".to_string()]);
}
#[test]
fn search_label_case_normalized() {
let s = SearchTerms::from_query_string("#Important");
assert_eq!(s.labels, vec!["important".to_string()]);
}
#[test]
fn search_label_excluded_short() {
let s2 = SearchTerms::from_query_string("-#draft");
assert_eq!(s2.excluded_labels, vec!["draft".to_string()]);
let s3 = SearchTerms::from_query_string("-lb:draft");
assert_eq!(s3.excluded_labels, vec!["draft".to_string()]);
}
#[test]
fn search_multiple_labels() {
let s = SearchTerms::from_query_string("#a #b lb:c");
let mut labels = s.labels.clone();
labels.sort();
assert_eq!(labels, vec!["a", "b", "c"]);
}
#[test]
fn search_label_mixed_with_term() {
let s = SearchTerms::from_query_string("meeting #important");
assert_eq!(s.labels, vec!["important".to_string()]);
assert_eq!(s.terms, vec!["meeting".to_string()]);
}
#[test]
fn search_bare_hash_is_dropped() {
let s = SearchTerms::from_query_string("#");
assert!(s.labels.is_empty());
assert!(s.terms.is_empty());
}
#[test]
fn search_labels_are_deduped() {
let s = SearchTerms::from_query_string("#foo #foo lb:foo #bar");
assert_eq!(s.labels, vec!["foo".to_string(), "bar".to_string()]);
}
#[test]
fn excluded_labels_are_deduped() {
let s = SearchTerms::from_query_string("-#draft -lb:draft -#old");
assert_eq!(
s.excluded_labels,
vec!["draft".to_string(), "old".to_string()]
);
}
#[test]
fn exclusion_short_forms_parse_to_excluded_fields() {
assert_eq!(
SearchTerms::from_query_string("-=foo").excluded_filename,
vec!["foo"]
);
assert_eq!(
SearchTerms::from_query_string("-<foo").excluded_links,
vec!["foo"]
);
assert_eq!(
SearchTerms::from_query_string("->foo").excluded_forward_links,
vec!["foo"]
);
assert_eq!(
SearchTerms::from_query_string("-@foo").excluded_breadcrumb,
vec!["foo"]
);
assert_eq!(
SearchTerms::from_query_string("-/foo").excluded_path,
vec!["foo"]
);
assert_eq!(
SearchTerms::from_query_string("-#foo").excluded_labels,
vec!["foo"]
);
}
#[test]
fn positive_short_forms_parse_to_fields() {
assert_eq!(SearchTerms::from_query_string("=foo").filename, vec!["foo"]);
assert_eq!(SearchTerms::from_query_string("<foo").links, vec!["foo"]);
assert_eq!(
SearchTerms::from_query_string(">foo").forward_links,
vec!["foo"]
);
assert_eq!(
SearchTerms::from_query_string("@foo").breadcrumb,
vec!["foo"]
);
assert_eq!(SearchTerms::from_query_string("/foo").path, vec!["foo"]);
assert_eq!(SearchTerms::from_query_string("#foo").labels, vec!["foo"]);
}
#[test]
fn from_query_string_caps_input_length() {
let huge = "#a ".repeat(20_000); let s = SearchTerms::from_query_string(huge);
assert!(s.labels.len() <= 1);
}
#[test]
fn with_order_inserts_into_plain_query() {
use super::{with_order_directive, OrderField};
assert_eq!(
with_order_directive("hello world", OrderField::Title, true),
"hello world or:title"
);
assert_eq!(
with_order_directive("hello", OrderField::FileName, false),
"hello -or:file"
);
}
#[test]
fn with_order_replaces_existing_directive() {
use super::{with_order_directive, OrderField};
assert_eq!(
with_order_directive("foo or:title bar", OrderField::FileName, true),
"foo bar or:file"
);
assert_eq!(
with_order_directive("-or:file foo", OrderField::Title, true),
"foo or:title"
);
assert_eq!(
with_order_directive("foo ^title", OrderField::Title, false),
"foo -or:title"
);
assert_eq!(
with_order_directive("-^file foo", OrderField::FileName, true),
"foo or:file"
);
}
#[test]
fn quote_query_term_wraps_only_when_whitespace() {
use super::quote_query_term;
assert_eq!(quote_query_term("spec"), "spec");
assert_eq!(quote_query_term("my note"), "\"my note\"");
let s = SearchTerms::from_query_string(format!("<{}", quote_query_term("my note")));
assert_eq!(s.links, vec!["my note".to_string()]);
}
#[test]
fn strip_order_removes_directive_keeps_rest() {
use super::strip_order_directive;
assert_eq!(strip_order_directive("foo or:title bar"), "foo bar");
assert_eq!(strip_order_directive("-^file <{note}"), "<{note}");
assert_eq!(strip_order_directive("<{note}"), "<{note}");
assert_eq!(strip_order_directive("or:title"), "");
}
#[test]
fn with_order_empty_query_yields_bare_directive() {
use super::{with_order_directive, OrderField};
assert_eq!(
with_order_directive("", OrderField::Title, true),
"or:title"
);
}
#[test]
fn with_order_roundtrips_through_parser() {
use super::{with_order_directive, OrderBy, OrderField, SearchTerms};
let q = with_order_directive("note text", OrderField::Title, false);
let st = SearchTerms::from_query_string(&q);
assert!(matches!(
st.order_by.first(),
Some(OrderBy::Title { asc: false })
));
assert!(st.terms.iter().any(|t| t == "note"));
}
#[test]
fn with_order_strips_all_existing_directives() {
use super::{with_order_directive, OrderField};
assert_eq!(
with_order_directive("or:title foo -or:file", OrderField::FileName, true),
"foo or:file"
);
}
#[test]
fn bare_prefix_terms_are_dropped() {
for q in &[
"=", "<", ">", "@", "/", "#", "-", "-=", "-<", "->", "-@", "-/", "-#", "name:", "lk:",
"fwd:", "in:", "pt:", "lb:", "-name:", "-lk:", "-fwd:", "-in:", "-pt:", "-lb:",
] {
let s = SearchTerms::from_query_string(*q);
assert!(s.terms.is_empty(), "{:?} produced terms: {:?}", q, s.terms);
assert!(
s.breadcrumb.is_empty(),
"{:?} produced breadcrumb: {:?}",
q,
s.breadcrumb
);
assert!(
s.filename.is_empty(),
"{:?} produced filename: {:?}",
q,
s.filename
);
assert!(s.path.is_empty(), "{:?} produced path: {:?}", q, s.path);
assert!(
s.labels.is_empty(),
"{:?} produced labels: {:?}",
q,
s.labels
);
assert!(
s.excluded_terms.is_empty(),
"{:?} produced excluded_terms: {:?}",
q,
s.excluded_terms
);
assert!(
s.excluded_breadcrumb.is_empty(),
"{:?} produced excluded_breadcrumb: {:?}",
q,
s.excluded_breadcrumb
);
assert!(
s.excluded_filename.is_empty(),
"{:?} produced excluded_filename: {:?}",
q,
s.excluded_filename
);
assert!(
s.excluded_path.is_empty(),
"{:?} produced excluded_path: {:?}",
q,
s.excluded_path
);
assert!(
s.excluded_labels.is_empty(),
"{:?} produced excluded_labels: {:?}",
q,
s.excluded_labels
);
assert!(s.links.is_empty(), "{:?} produced links: {:?}", q, s.links);
assert!(
s.excluded_links.is_empty(),
"{:?} produced excluded_links: {:?}",
q,
s.excluded_links
);
assert!(
s.forward_links.is_empty(),
"{:?} produced forward_links: {:?}",
q,
s.forward_links
);
assert!(
s.excluded_forward_links.is_empty(),
"{:?} produced excluded_forward_links: {:?}",
q,
s.excluded_forward_links
);
}
}
}