struct Term {
negated: bool,
text: String,
}
fn is_searchable(text: &str) -> bool {
text.chars().any(char::is_alphanumeric)
}
fn as_phrase(text: &str) -> String {
format!("\"{}\"", text.replace('"', "\"\""))
}
fn parse(input: &str) -> Vec<(Term, bool)> {
let chars: Vec<char> = input.chars().collect();
let mut out: Vec<(Term, bool)> = Vec::new();
let mut i = 0;
let mut pending_or = false;
while i < chars.len() {
if chars[i].is_whitespace() {
i += 1;
continue;
}
let mut negated = false;
if chars[i] == '-' && i + 1 < chars.len() && !chars[i + 1].is_whitespace() {
negated = true;
i += 1;
}
let text: String = if chars[i] == '"' {
i += 1;
let start = i;
while i < chars.len() && chars[i] != '"' {
i += 1;
}
let phrase: String = chars[start..i].iter().collect();
if i < chars.len() {
i += 1; }
phrase
} else {
let start = i;
while i < chars.len() && !chars[i].is_whitespace() {
i += 1;
}
let word: String = chars[start..i].iter().collect();
if !negated && word.eq_ignore_ascii_case("or") {
pending_or = true;
continue;
}
word
};
if !is_searchable(&text) {
continue;
}
let after_or = pending_or;
pending_or = false;
out.push((Term { negated, text }, after_or));
}
out
}
#[derive(Default)]
struct Branch {
positives: Vec<String>,
negatives: Vec<String>,
}
impl Branch {
fn render(&self) -> Option<String> {
if self.positives.is_empty() {
return None;
}
let positive = self.positives.join(" AND ");
if self.negatives.is_empty() {
return Some(positive);
}
let positive = if self.positives.len() > 1 {
format!("({positive})")
} else {
positive
};
let excluded = self.negatives.join(" OR ");
let excluded = if self.negatives.len() > 1 {
format!("({excluded})")
} else {
excluded
};
Some(format!("{positive} NOT {excluded}"))
}
fn is_compound(&self) -> bool {
self.positives.len() > 1 || !self.negatives.is_empty()
}
}
pub fn websearch_to_fts5(input: &str) -> Option<String> {
let mut branches: Vec<Branch> = vec![Branch::default()];
for (term, after_or) in parse(input) {
if after_or {
branches.push(Branch::default());
}
if let Some(branch) = branches.last_mut() {
if term.negated {
branch.negatives.push(as_phrase(&term.text));
} else {
branch.positives.push(as_phrase(&term.text));
}
}
}
let mut rendered: Vec<(String, bool)> = branches
.iter()
.filter_map(|branch| Some((branch.render()?, branch.is_compound())))
.collect();
match rendered.len() {
0 => None,
1 => rendered.pop().map(|(expr, _)| expr),
_ => Some(
rendered
.into_iter()
.map(
|(expr, compound)| {
if compound { format!("({expr})") } else { expr }
},
)
.collect::<Vec<_>>()
.join(" OR "),
),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ordinary_english_questions_translate() {
let cases = [
(
"why doesn't the sync work",
r#""why" AND "doesn't" AND "the" AND "sync" AND "work""#,
),
(
"it's returning nothing",
r#""it's" AND "returning" AND "nothing""#,
),
(
"note: check the gateway",
r#""note:" AND "check" AND "the" AND "gateway""#,
),
("full-text search", r#""full-text" AND "search""#),
("read-only mode", r#""read-only" AND "mode""#),
];
for (input, expected) in cases {
assert_eq!(
websearch_to_fts5(input).as_deref(),
Some(expected),
"{input:?}"
);
}
}
#[test]
fn words_are_anded_and_each_is_a_phrase() {
assert_eq!(
websearch_to_fts5("read-only source").as_deref(),
Some(r#""read-only" AND "source""#)
);
}
#[test]
fn a_quoted_phrase_stays_one_term() {
assert_eq!(
websearch_to_fts5(r#""retry backoff" policy"#).as_deref(),
Some(r#""retry backoff" AND "policy""#)
);
}
#[test]
fn an_unterminated_quote_runs_to_the_end() {
assert_eq!(
websearch_to_fts5(r#""retry backoff"#).as_deref(),
Some(r#""retry backoff""#)
);
}
#[test]
fn a_double_quote_inside_a_word_is_doubled_not_left_to_close_the_phrase() {
assert_eq!(
websearch_to_fts5(r#"say"hi"#).as_deref(),
Some(r#""say""hi""#)
);
}
#[test]
fn quotes_around_words_open_phrases_rather_than_being_searched_for() {
assert_eq!(
websearch_to_fts5(r#"say "hi" loudly"#).as_deref(),
Some(r#""say" AND "hi" AND "loudly""#)
);
}
#[test]
fn a_bare_or_separates_alternatives_and_binds_loosest() {
assert_eq!(
websearch_to_fts5("sync fails or stalls").as_deref(),
Some(r#"("sync" AND "fails") OR "stalls""#)
);
assert_eq!(
websearch_to_fts5("fat OR rat").as_deref(),
Some(r#""fat" OR "rat""#)
);
assert_eq!(
websearch_to_fts5("sync fails or replication stalls").as_deref(),
Some(r#"("sync" AND "fails") OR ("replication" AND "stalls")"#)
);
}
#[test]
fn a_quoted_or_is_a_search_term_not_an_operator() {
assert_eq!(
websearch_to_fts5(r#"fat "or" rat"#).as_deref(),
Some(r#""fat" AND "or" AND "rat""#)
);
}
#[test]
fn a_leading_hyphen_excludes_and_the_positive_side_is_parenthesised() {
assert_eq!(
websearch_to_fts5("sync failure -postgres").as_deref(),
Some(r#"("sync" AND "failure") NOT "postgres""#)
);
}
#[test]
fn several_exclusions_are_ored_on_the_subtracted_side() {
assert_eq!(
websearch_to_fts5("sync -postgres -mysql").as_deref(),
Some(r#""sync" NOT ("postgres" OR "mysql")"#)
);
}
#[test]
fn an_exclusion_applies_only_to_the_alternative_it_sits_in() {
assert_eq!(
websearch_to_fts5("gateway or runbook -policy").as_deref(),
Some(r#""gateway" OR ("runbook" NOT "policy")"#)
);
assert_eq!(
websearch_to_fts5("gateway -mode or runbook").as_deref(),
Some(r#"("gateway" NOT "mode") OR "runbook""#)
);
}
#[test]
fn an_alternative_that_is_only_exclusions_is_dropped() {
assert_eq!(
websearch_to_fts5("-postgres or sync").as_deref(),
Some(r#""sync""#)
);
}
#[test]
fn a_hyphen_inside_a_word_is_not_an_exclusion() {
assert_eq!(
websearch_to_fts5("read-only").as_deref(),
Some(r#""read-only""#)
);
}
#[test]
fn a_detached_hyphen_is_dropped_as_punctuation() {
assert_eq!(
websearch_to_fts5("sync - failure").as_deref(),
Some(r#""sync" AND "failure""#)
);
}
#[test]
fn text_with_nothing_to_tokenize_yields_no_expression() {
for input in ["", " ", "---", ":::", r#""""#, r#"" ""#] {
assert_eq!(websearch_to_fts5(input), None, "input {input:?}");
}
}
#[test]
fn exclusions_alone_yield_no_expression() {
assert_eq!(websearch_to_fts5("-postgres"), None);
assert_eq!(websearch_to_fts5("-postgres -mysql"), None);
}
#[test]
fn cjk_terms_survive_unchanged_inside_the_phrase() {
assert_eq!(
websearch_to_fts5("上下文 检索").as_deref(),
Some(r#""上下文" AND "检索""#)
);
}
}