use std::fmt;
#[derive(Debug, Clone, PartialEq)]
pub struct ParsedQuery {
pub terms: Vec<String>,
pub phrases: Vec<String>,
pub filters: Vec<Filter>,
pub negated_terms: Vec<String>,
pub required_terms: Vec<String>,
pub text_query: String,
pub has_or: bool,
pub or_branches: Vec<String>,
pub regex_patterns: Vec<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Filter {
pub field: FilterField,
pub values: Vec<String>,
pub negated: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum FilterField {
Repo,
Lang,
Type,
File,
Path,
Group,
Tag,
}
impl FilterField {
fn from_str(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"repo" | "repository" => Some(Self::Repo),
"lang" | "language" => Some(Self::Lang),
"type" | "kind" => Some(Self::Type),
"file" | "filename" => Some(Self::File),
"path" | "filepath" => Some(Self::Path),
"group" => Some(Self::Group),
"tag" | "label" => Some(Self::Tag),
_ => None,
}
}
pub fn canonical_name(&self) -> &'static str {
match self {
Self::Repo => "repo",
Self::Lang => "lang",
Self::Type => "type",
Self::File => "file",
Self::Path => "path",
Self::Group => "group",
Self::Tag => "tag",
}
}
pub fn column_name(&self) -> &'static str {
match self {
Self::Repo => "repo",
Self::Lang => "language",
Self::Type => "chunk_type",
Self::File | Self::Path => "file_path",
Self::Group => "repo", Self::Tag => "tags",
}
}
}
impl fmt::Display for FilterField {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.canonical_name())
}
}
pub fn parse(input: &str) -> ParsedQuery {
let mut terms: Vec<String> = Vec::new();
let mut phrases: Vec<String> = Vec::new();
let mut filters: Vec<Filter> = Vec::new();
let mut negated_terms: Vec<String> = Vec::new();
let mut required_terms: Vec<String> = Vec::new();
let mut regex_patterns: Vec<String> = Vec::new();
let mut has_or = false;
let mut next_negated = false;
let input = input.trim();
if input.is_empty() {
return ParsedQuery {
terms,
phrases,
filters,
negated_terms,
required_terms,
text_query: String::new(),
has_or: false,
or_branches: vec![],
regex_patterns: vec![],
};
}
let chars: Vec<char> = input.chars().collect();
let len = chars.len();
let mut i = 0;
while i < len {
if chars[i].is_whitespace() {
i += 1;
continue;
}
if chars[i] == '/' {
if let Some((pattern, end)) = parse_regex(&chars, i) {
if !pattern.is_empty() {
regex_patterns.push(pattern);
}
i = end;
continue;
}
}
if chars[i] == '"' {
if let Some((phrase, end)) = parse_quoted(&chars, i) {
if !phrase.is_empty() {
if next_negated {
negated_terms.push(phrase);
next_negated = false;
} else {
phrases.push(phrase);
}
}
i = end;
continue;
}
}
if chars[i] == '-' && i + 1 < len && !chars[i + 1].is_whitespace() {
if let Some((filter, end)) = parse_filter(&chars, i + 1, true) {
filters.push(filter);
i = end;
continue;
}
let (word, end) = parse_word(&chars, i + 1);
if !word.is_empty() {
negated_terms.push(word);
}
i = end;
continue;
}
if chars[i] == '+' && i + 1 < len && !chars[i + 1].is_whitespace() {
if let Some((filter, end)) = parse_filter(&chars, i + 1, false) {
filters.push(filter);
i = end;
continue;
}
if chars[i + 1] == '"' {
if let Some((phrase, end)) = parse_quoted(&chars, i + 1) {
if !phrase.is_empty() {
required_terms.push(phrase.clone());
phrases.push(phrase);
}
i = end;
continue;
}
}
let (word, end) = parse_word(&chars, i + 1);
if !word.is_empty() {
required_terms.push(word.clone());
terms.push(word);
i = end;
continue;
}
}
if let Some((filter, end)) = parse_filter(&chars, i, false) {
filters.push(filter);
i = end;
continue;
}
let (word, end) = parse_word(&chars, i);
if !word.is_empty() {
let upper = word.to_uppercase();
if upper == "AND" {
} else if upper == "OR" {
has_or = true;
} else if upper == "NOT" {
next_negated = true;
} else if next_negated {
negated_terms.push(word);
next_negated = false;
} else {
terms.push(word);
}
}
i = end;
}
let mut text_parts: Vec<String> = Vec::new();
for t in &terms {
text_parts.push(t.clone());
}
for p in &phrases {
text_parts.push(format!("\"{}\"", p));
}
let text_query = text_parts.join(" ");
let or_branches = if has_or {
build_or_branches(input, &filters)
} else {
vec![]
};
ParsedQuery {
terms,
phrases,
filters,
negated_terms,
required_terms,
text_query,
has_or,
or_branches,
regex_patterns,
}
}
pub fn retain_matching<T>(
results: &mut Vec<T>,
parsed: &ParsedQuery,
content_of: impl Fn(&T) -> &str,
) {
if !parsed.required_terms.is_empty() {
let required: Vec<String> = parsed
.required_terms
.iter()
.map(|t| t.to_lowercase())
.collect();
results.retain(|r| {
let haystack = content_of(r).to_lowercase();
required.iter().all(|t| haystack.contains(t))
});
}
if !parsed.negated_terms.is_empty() {
let negated: Vec<String> = parsed
.negated_terms
.iter()
.map(|t| t.to_lowercase())
.collect();
results.retain(|r| {
let haystack = content_of(r).to_lowercase();
!negated.iter().any(|t| haystack.contains(t))
});
}
if !parsed.regex_patterns.is_empty() {
let compiled: Vec<regex::Regex> = parsed
.regex_patterns
.iter()
.filter_map(|p| regex::Regex::new(p).ok())
.collect();
if !compiled.is_empty() {
results.retain(|r| {
let haystack = content_of(r);
compiled.iter().all(|re| re.is_match(haystack))
});
}
}
}
fn build_or_branches(input: &str, _filters: &[Filter]) -> Vec<String> {
let mut branches = Vec::new();
let parts: Vec<&str> = split_on_or(input);
for part in parts {
let sub = parse(part);
if !sub.text_query.is_empty() {
branches.push(sub.text_query);
}
}
branches
}
fn split_on_or(input: &str) -> Vec<&str> {
let mut parts = Vec::new();
let mut last = 0;
let bytes = input.as_bytes();
let len = bytes.len();
let mut i = 0;
while i + 4 <= len {
if bytes[i] == b' '
&& (bytes[i + 1] == b'O' || bytes[i + 1] == b'o')
&& (bytes[i + 2] == b'R' || bytes[i + 2] == b'r')
&& bytes[i + 3] == b' '
{
let part = &input[last..i];
if !part.trim().is_empty() {
parts.push(part.trim());
}
last = i + 4;
i = last;
} else {
i += 1;
}
}
let remaining = &input[last..];
if !remaining.trim().is_empty() {
parts.push(remaining.trim());
}
parts
}
pub fn filters_to_sql(filters: &[Filter]) -> Vec<String> {
let mut clauses = Vec::new();
for filter in filters {
if filter.field == FilterField::Group {
continue;
}
let col = filter.field.column_name();
let sql = if filter.values.len() == 1 {
let val = &filter.values[0];
let escaped = val.replace('\'', "''");
if val.contains('*') || val.contains('?') {
let like_pattern = escaped.replace('*', "%").replace('?', "_");
if filter.negated {
format!("{} NOT LIKE '{}'", col, like_pattern)
} else {
format!("{} LIKE '{}'", col, like_pattern)
}
} else if filter.field == FilterField::File || filter.field == FilterField::Path {
if filter.negated {
format!("{} NOT LIKE '%{}%'", col, escaped)
} else {
format!("{} LIKE '%{}%'", col, escaped)
}
} else if filter.negated {
format!("{} != '{}'", col, escaped)
} else {
format!("{} = '{}'", col, escaped)
}
} else {
let vals: Vec<String> = filter
.values
.iter()
.map(|v| format!("'{}'", v.replace('\'', "''")))
.collect();
if filter.negated {
format!("{} NOT IN ({})", col, vals.join(", "))
} else {
format!("{} IN ({})", col, vals.join(", "))
}
};
clauses.push(sql);
}
clauses
}
pub fn extract_group_filters(filters: &[Filter]) -> Vec<String> {
filters
.iter()
.filter(|f| f.field == FilterField::Group && !f.negated)
.flat_map(|f| f.values.clone())
.collect()
}
fn parse_quoted(chars: &[char], start: usize) -> Option<(String, usize)> {
if start >= chars.len() || chars[start] != '"' {
return None;
}
let mut i = start + 1;
let mut content = String::new();
while i < chars.len() {
if chars[i] == '"' {
return Some((content, i + 1));
}
if chars[i] == '\\' && i + 1 < chars.len() {
content.push(chars[i + 1]);
i += 2;
} else {
content.push(chars[i]);
i += 1;
}
}
Some((content, i))
}
fn parse_regex(chars: &[char], start: usize) -> Option<(String, usize)> {
if start >= chars.len() || chars[start] != '/' {
return None;
}
let mut i = start + 1;
let mut content = String::new();
while i < chars.len() {
if chars[i] == '/' {
let next = i + 1;
if next >= chars.len() || chars[next].is_whitespace() {
if content.is_empty() {
return None; }
return Some((content, next));
}
return None;
}
if chars[i] == '\\' && i + 1 < chars.len() && chars[i + 1] == '/' {
content.push('/');
i += 2;
} else {
content.push(chars[i]);
i += 1;
}
}
None
}
fn parse_filter(chars: &[char], start: usize, negated: bool) -> Option<(Filter, usize)> {
let mut i = start;
let mut field_name = String::new();
while i < chars.len() && (chars[i].is_alphanumeric() || chars[i] == '_') {
field_name.push(chars[i]);
i += 1;
}
if i >= chars.len() || chars[i] != ':' || field_name.is_empty() {
return None;
}
let field = FilterField::from_str(&field_name)?;
i += 1;
if i >= chars.len() || chars[i].is_whitespace() {
return None;
}
let (value, end) = if chars[i] == '"' {
match parse_quoted(chars, i) {
Some((v, end)) => (v, end),
None => return None,
}
} else {
parse_word(chars, i)
};
if value.is_empty() {
return None;
}
let values: Vec<String> = value
.split(',')
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
.collect();
if values.is_empty() {
return None;
}
let actual_start = if negated { start - 1 } else { start };
let _ = actual_start;
Some((
Filter {
field,
values,
negated,
},
end,
))
}
fn parse_word(chars: &[char], start: usize) -> (String, usize) {
let mut i = start;
let mut word = String::new();
while i < chars.len() && !chars[i].is_whitespace() {
word.push(chars[i]);
i += 1;
}
(word, i)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_plain_text() {
let q = parse("context assembler");
assert_eq!(q.terms, vec!["context", "assembler"]);
assert!(q.filters.is_empty());
assert!(q.phrases.is_empty());
assert_eq!(q.text_query, "context assembler");
}
#[test]
fn test_quoted_phrase() {
let q = parse("\"error handling\" in rust");
assert_eq!(q.terms, vec!["in", "rust"]);
assert_eq!(q.phrases, vec!["error handling"]);
assert_eq!(q.text_query, "in rust \"error handling\"");
}
#[test]
fn test_single_filter() {
let q = parse("repo:aegis PostToolUse");
assert_eq!(q.terms, vec!["PostToolUse"]);
assert_eq!(q.filters.len(), 1);
assert_eq!(q.filters[0].field, FilterField::Repo);
assert_eq!(q.filters[0].values, vec!["aegis"]);
assert!(!q.filters[0].negated);
assert_eq!(q.text_query, "PostToolUse");
}
#[test]
fn test_multiple_filters() {
let q = parse("repo:aegis lang:rust type:function search");
assert_eq!(q.terms, vec!["search"]);
assert_eq!(q.filters.len(), 3);
assert_eq!(q.filters[0].field, FilterField::Repo);
assert_eq!(q.filters[1].field, FilterField::Lang);
assert_eq!(q.filters[2].field, FilterField::Type);
}
#[test]
fn test_negated_filter() {
let q = parse("-repo:aegis search query");
assert_eq!(q.terms, vec!["search", "query"]);
assert_eq!(q.filters.len(), 1);
assert_eq!(q.filters[0].field, FilterField::Repo);
assert_eq!(q.filters[0].values, vec!["aegis"]);
assert!(q.filters[0].negated);
}
#[test]
fn test_multi_value_filter() {
let q = parse("lang:rust,go search");
assert_eq!(q.terms, vec!["search"]);
assert_eq!(q.filters.len(), 1);
assert_eq!(q.filters[0].values, vec!["rust", "go"]);
}
#[test]
fn test_quoted_filter_value() {
let q = parse("file:\"src/cli/hook.rs\" search");
assert_eq!(q.terms, vec!["search"]);
assert_eq!(q.filters.len(), 1);
assert_eq!(q.filters[0].field, FilterField::File);
assert_eq!(q.filters[0].values, vec!["src/cli/hook.rs"]);
}
#[test]
fn test_unknown_field_is_literal() {
let q = parse("http:server search");
assert_eq!(q.terms, vec!["http:server", "search"]);
assert!(q.filters.is_empty());
}
#[test]
fn test_and_is_implicit() {
let q = parse("context AND assembler");
assert_eq!(q.terms, vec!["context", "assembler"]);
assert!(q.filters.is_empty());
assert!(!q.has_or);
assert_eq!(q.text_query, "context assembler");
}
#[test]
fn test_or_sets_flag_and_branches() {
let q = parse("redis OR memcached");
assert_eq!(q.terms, vec!["redis", "memcached"]);
assert!(q.has_or);
assert_eq!(q.or_branches, vec!["redis", "memcached"]);
}
#[test]
fn test_or_with_multiple_branches() {
let q = parse("redis OR memcached OR etcd");
assert!(q.has_or);
assert_eq!(q.or_branches, vec!["redis", "memcached", "etcd"]);
}
#[test]
fn test_or_preserves_filters() {
let q = parse("redis OR memcached repo:aegis");
assert!(q.has_or);
assert_eq!(q.filters.len(), 1);
assert_eq!(q.filters[0].field, FilterField::Repo);
assert_eq!(q.or_branches, vec!["redis", "memcached"]);
}
#[test]
fn test_negated_term_dash_prefix() {
let q = parse("context -assembler");
assert_eq!(q.terms, vec!["context"]);
assert_eq!(q.negated_terms, vec!["assembler"]);
assert_eq!(q.text_query, "context");
}
#[test]
fn test_negated_term_not_keyword() {
let q = parse("context NOT assembler");
assert_eq!(q.terms, vec!["context"]);
assert_eq!(q.negated_terms, vec!["assembler"]);
}
#[test]
fn test_and_not_combination() {
let q = parse("context AND NOT assembler");
assert_eq!(q.terms, vec!["context"]);
assert_eq!(q.negated_terms, vec!["assembler"]);
}
#[test]
fn test_negated_phrase() {
let q = parse("search NOT \"error handling\"");
assert_eq!(q.terms, vec!["search"]);
assert_eq!(q.negated_terms, vec!["error handling"]);
assert!(q.phrases.is_empty()); }
#[test]
fn test_plus_prefix_is_a_required_term() {
let q = parse("+context -assembler");
assert_eq!(q.terms, vec!["context"]);
assert_eq!(q.required_terms, vec!["context"]);
assert_eq!(q.negated_terms, vec!["assembler"]);
assert_eq!(q.text_query, "context");
}
#[test]
fn test_plus_prefixed_phrase_is_required() {
let q = parse("+\"error handling\" retries");
assert_eq!(q.phrases, vec!["error handling"]);
assert_eq!(q.required_terms, vec!["error handling"]);
assert_eq!(q.terms, vec!["retries"]);
}
#[test]
fn test_plus_prefixed_filter_is_just_the_filter() {
let q = parse("+repo:aegis context");
assert_eq!(q.filters.len(), 1);
assert_eq!(q.filters[0].field, FilterField::Repo);
assert_eq!(q.filters[0].values, vec!["aegis"]);
assert!(!q.filters[0].negated);
assert!(q.required_terms.is_empty());
}
#[test]
fn test_plain_query_has_no_required_terms() {
let q = parse("context assembler");
assert!(q.required_terms.is_empty());
}
#[test]
fn test_retain_matching_enforces_required_negated_and_regex() {
let docs = || {
vec![
"fn context_assembler() {}".to_string(),
"fn context_only() {}".to_string(),
"fn unrelated() {}".to_string(),
]
};
let mut v = docs();
retain_matching(&mut v, &parse("+context"), |s| s.as_str());
assert_eq!(v.len(), 2);
let mut v = docs();
retain_matching(&mut v, &parse("+context -assembler"), |s| s.as_str());
assert_eq!(v, vec!["fn context_only() {}".to_string()]);
let mut v = docs();
retain_matching(&mut v, &parse("+context /_only/"), |s| s.as_str());
assert_eq!(v, vec!["fn context_only() {}".to_string()]);
let mut v = docs();
retain_matching(&mut v, &parse("context"), |s| s.as_str());
assert_eq!(v.len(), 3);
}
#[test]
fn test_empty_query() {
let q = parse("");
assert!(q.terms.is_empty());
assert!(q.filters.is_empty());
assert!(q.phrases.is_empty());
assert_eq!(q.text_query, "");
}
#[test]
fn test_whitespace_only() {
let q = parse(" ");
assert!(q.terms.is_empty());
}
#[test]
fn test_filter_aliases() {
let q1 = parse("language:rust");
assert_eq!(q1.filters[0].field, FilterField::Lang);
let q2 = parse("repository:aegis");
assert_eq!(q2.filters[0].field, FilterField::Repo);
let q3 = parse("filepath:src/main.rs");
assert_eq!(q3.filters[0].field, FilterField::Path);
let q4 = parse("label:reviewed");
assert_eq!(q4.filters[0].field, FilterField::Tag);
}
#[test]
fn test_filter_no_value_is_literal() {
let q = parse("repo: search");
assert!(q.filters.is_empty());
}
#[test]
fn test_complex_query() {
let q = parse("repo:aegis lang:rust -type:section \"error handling\" PostToolUse");
assert_eq!(q.terms, vec!["PostToolUse"]);
assert_eq!(q.phrases, vec!["error handling"]);
assert_eq!(q.filters.len(), 3);
assert_eq!(q.filters[0].field, FilterField::Repo);
assert!(!q.filters[0].negated);
assert_eq!(q.filters[1].field, FilterField::Lang);
assert_eq!(q.filters[2].field, FilterField::Type);
assert!(q.filters[2].negated);
}
#[test]
fn test_unmatched_quote_graceful() {
let q = parse("search \"unfinished");
assert_eq!(q.terms, vec!["search"]);
assert_eq!(q.phrases, vec!["unfinished"]);
}
#[test]
fn test_path_filter_with_glob() {
let q = parse("file:*.rs search");
assert_eq!(q.filters.len(), 1);
assert_eq!(q.filters[0].field, FilterField::File);
assert_eq!(q.filters[0].values, vec!["*.rs"]);
}
#[test]
fn test_column_names() {
assert_eq!(FilterField::Repo.column_name(), "repo");
assert_eq!(FilterField::Lang.column_name(), "language");
assert_eq!(FilterField::Type.column_name(), "chunk_type");
assert_eq!(FilterField::File.column_name(), "file_path");
assert_eq!(FilterField::Path.column_name(), "file_path");
}
#[test]
fn test_case_insensitive_field_names() {
let q1 = parse("Repo:aegis");
assert_eq!(q1.filters.len(), 1);
assert_eq!(q1.filters[0].field, FilterField::Repo);
let q2 = parse("LANG:rust");
assert_eq!(q2.filters.len(), 1);
assert_eq!(q2.filters[0].field, FilterField::Lang);
}
#[test]
fn test_sql_simple_eq() {
let q = parse("repo:aegis");
let sql = filters_to_sql(&q.filters);
assert_eq!(sql, vec!["repo = 'aegis'"]);
}
#[test]
fn test_sql_negated() {
let q = parse("-repo:aegis");
let sql = filters_to_sql(&q.filters);
assert_eq!(sql, vec!["repo != 'aegis'"]);
}
#[test]
fn test_sql_multi_value_in() {
let q = parse("lang:rust,go");
let sql = filters_to_sql(&q.filters);
assert_eq!(sql, vec!["language IN ('rust', 'go')"]);
}
#[test]
fn test_sql_negated_multi_value() {
let q = parse("-lang:rust,go");
let sql = filters_to_sql(&q.filters);
assert_eq!(sql, vec!["language NOT IN ('rust', 'go')"]);
}
#[test]
fn test_sql_glob_wildcard() {
let q = parse("file:*.rs");
let sql = filters_to_sql(&q.filters);
assert_eq!(sql, vec!["file_path LIKE '%.rs'"]);
}
#[test]
fn test_sql_path_substring() {
let q = parse("path:src/cli");
let sql = filters_to_sql(&q.filters);
assert_eq!(sql, vec!["file_path LIKE '%src/cli%'"]);
}
#[test]
fn test_sql_group_excluded() {
let q = parse("group:infra search");
let sql = filters_to_sql(&q.filters);
assert!(sql.is_empty());
}
#[test]
fn test_sql_multiple_filters() {
let q = parse("repo:aegis lang:rust -type:section");
let sql = filters_to_sql(&q.filters);
assert_eq!(sql.len(), 3);
assert_eq!(sql[0], "repo = 'aegis'");
assert_eq!(sql[1], "language = 'rust'");
assert_eq!(sql[2], "chunk_type != 'section'");
}
#[test]
fn test_sql_escapes_quotes() {
let q = parse("repo:o'brien");
let sql = filters_to_sql(&q.filters);
assert_eq!(sql, vec!["repo = 'o''brien'"]);
}
#[test]
fn test_extract_group_filters() {
let q = parse("group:infra group:apps search");
let groups = extract_group_filters(&q.filters);
assert_eq!(groups, vec!["infra", "apps"]);
}
#[test]
fn test_regex_pattern_basic() {
let q = parse("/fn\\s+\\w+/");
assert!(q.terms.is_empty());
assert_eq!(q.regex_patterns, vec!["fn\\s+\\w+"]);
}
#[test]
fn test_regex_pattern_with_terms() {
let q = parse("search /TODO/ repo:aegis");
assert_eq!(q.terms, vec!["search"]);
assert_eq!(q.regex_patterns, vec!["TODO"]);
assert_eq!(q.filters.len(), 1);
}
#[test]
fn test_regex_multiple_patterns() {
let q = parse("/error/ /warning/");
assert_eq!(q.regex_patterns, vec!["error", "warning"]);
}
#[test]
fn test_regex_not_matched_for_paths() {
let q = parse("file:src/cli/hook.rs search");
assert!(q.regex_patterns.is_empty());
assert_eq!(q.filters.len(), 1);
}
#[test]
fn test_regex_unclosed_is_literal() {
let q = parse("/unclosed search");
assert!(q.regex_patterns.is_empty());
assert_eq!(q.terms, vec!["/unclosed", "search"]);
}
#[test]
fn test_regex_empty_pattern_rejected() {
let q = parse("// search");
assert!(q.regex_patterns.is_empty());
}
#[test]
fn test_extract_group_filters_negated_excluded() {
let q = parse("-group:infra search");
let groups = extract_group_filters(&q.filters);
assert!(groups.is_empty());
}
}