use std::path::Path;
use crate::types::QueryType;
pub fn classify(query: &str, scope: &Path) -> QueryType {
if query.len() >= 3 && query.starts_with('/') && query.ends_with('/') {
let pattern = &query[1..query.len() - 1];
if !pattern.is_empty() && has_regex_metachar(pattern) {
return QueryType::Regex(pattern.into());
}
}
if let Some((path, line)) = parse_path_line(query, scope) {
return QueryType::FilePathLine(path, line);
}
if !query.contains(' ')
&& query
.bytes()
.any(|b| matches!(b, b'*' | b'?' | b'{' | b'['))
{
return QueryType::Glob(query.into());
}
if looks_like_path_with_separator(query) {
return match resolve_existing_path(query, scope) {
Some(resolved) => QueryType::FilePath(resolved),
None => QueryType::Fallthrough(query.into()),
};
}
if query.starts_with('.') {
let resolved = scope.join(query);
if resolved.try_exists().unwrap_or(false) {
return QueryType::FilePath(resolved);
}
}
if query.bytes().all(|b| b.is_ascii_digit()) {
return QueryType::Fallthrough(query.into());
}
if looks_like_filename(query) {
let resolved = scope.join(query);
if resolved.try_exists().unwrap_or(false) {
return QueryType::FilePath(resolved);
}
if !is_dotted_symbol(query) {
return QueryType::Glob(format!("**/{query}"));
}
}
if is_identifier(query) {
if looks_like_exact_symbol(query) {
return QueryType::Symbol(query.into());
}
return QueryType::Concept(query.into());
}
if !query.contains(' ') && query.contains('|') {
let parts: Vec<&str> = query.split('|').filter(|s| !s.is_empty()).collect();
if parts.len() >= 2 && parts.iter().all(|p| is_identifier(p)) {
return QueryType::Regex(format!(r"\b({query})\b"));
}
}
if query.contains(' ') && query.split_whitespace().count() <= 4 {
let words: Vec<&str> = query.split_whitespace().collect();
let all_simple = words.iter().all(|w| {
!w.is_empty()
&& w.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
});
if all_simple {
return QueryType::Concept(query.into());
}
}
QueryType::Fallthrough(query.into())
}
fn looks_like_exact_symbol(query: &str) -> bool {
let bytes = query.as_bytes();
if bytes.is_empty() {
return false;
}
if bytes[0].is_ascii_uppercase() {
return true;
}
if query.contains("::") || query.contains('.') {
return true;
}
if query.contains('_') {
return true;
}
if query.contains('-') {
return true;
}
if bytes[0] == b'$' || bytes[0] == b'@' {
return true;
}
if bytes[0].is_ascii_lowercase() && bytes[1..].iter().any(u8::is_ascii_uppercase) {
return true;
}
false
}
fn parse_path_line(query: &str, scope: &Path) -> Option<(std::path::PathBuf, usize)> {
let (path_part, line_part) = query.rsplit_once(':')?;
if path_part.is_empty() || line_part.is_empty() {
return None;
}
let line: usize = line_part.parse().ok()?;
if line == 0 {
return None;
}
let resolved = resolve_existing_path(path_part, scope)?;
Some((resolved, line))
}
fn resolve_existing_path(query: &str, scope: &Path) -> Option<std::path::PathBuf> {
let path = Path::new(query);
let resolved = if path.is_absolute() {
path.to_path_buf()
} else {
scope.join(path)
};
resolved.try_exists().unwrap_or(false).then_some(resolved)
}
fn looks_like_path_with_separator(query: &str) -> bool {
!query.is_empty()
&& (query.starts_with('/')
|| query.starts_with("~/")
|| query.starts_with("./")
|| query.starts_with("../")
|| query.contains('/')
|| query.contains('\\'))
}
pub fn looks_like_path_query(query: &str) -> bool {
if query.contains(' ') || query.is_empty() {
return false;
}
if query
.bytes()
.any(|b| matches!(b, b'*' | b'?' | b'{' | b'['))
{
return false;
}
query.starts_with('/')
|| query.starts_with("~/")
|| query.starts_with("./")
|| query.starts_with("../")
|| query.contains('/')
|| query.contains('\\')
|| looks_like_filename(query)
}
fn looks_like_filename(query: &str) -> bool {
if query.contains(' ') || query.contains('/') {
return false;
}
if let Some(dot_pos) = query.rfind('.') {
if dot_pos > 0 && dot_pos < query.len() - 1 {
return true;
}
}
matches!(
query,
"README"
| "LICENSE"
| "Makefile"
| "GNUmakefile"
| "Dockerfile"
| "Containerfile"
| "Vagrantfile"
| "Rakefile"
| "Gemfile"
| "Procfile"
| "Justfile"
| "Taskfile"
| "CHANGELOG"
| "CONTRIBUTING"
| "AUTHORS"
| "CODEOWNERS"
)
}
fn has_regex_metachar(s: &str) -> bool {
s.bytes().any(|b| {
matches!(
b,
b'(' | b')'
| b'['
| b']'
| b'{'
| b'}'
| b'*'
| b'+'
| b'?'
| b'|'
| b'\\'
| b'^'
| b'$'
)
})
}
const FILE_EXTENSIONS: &[&str] = &[
"rs", "go", "py", "pyi", "ts", "tsx", "js", "jsx", "mjs", "cjs", "java", "kt", "kts", "scala",
"swift", "rb", "php", "cs", "c", "h", "cc", "cpp", "cxx", "hpp", "hh", "hxx", "m", "mm", "lua",
"dart", "ex", "exs", "erl", "hrl", "elm", "hs", "clj", "cljs", "cljc", "ml", "mli", "fs",
"fsi", "fsx", "vb", "pas", "pl", "pm", "r", "jl", "nim", "zig", "v", "sh", "bash", "zsh",
"fish", "ps1", "bat", "cmd", "html", "htm", "css", "scss", "sass", "less", "vue", "svelte",
"astro", "md", "mdx", "rst", "adoc", "txt", "tex", "yaml", "yml", "toml", "json", "jsonc",
"json5", "xml", "ini", "cfg", "conf", "env", "proto", "graphql", "gql", "sql", "prisma",
"wasm", "lock",
];
fn is_dotted_symbol(query: &str) -> bool {
let Some(dot_pos) = query.rfind('.') else {
return false;
};
if dot_pos == 0 || dot_pos >= query.len() - 1 {
return false;
}
let before = &query[..dot_pos];
let after = &query[dot_pos + 1..];
if !is_identifier(before) || !is_identifier(after) {
return false;
}
let after_lower = after.to_ascii_lowercase();
!FILE_EXTENSIONS.contains(&after_lower.as_str())
}
pub fn is_identifier(s: &str) -> bool {
let bytes = s.as_bytes();
if bytes.is_empty() {
return false;
}
let first_valid = matches!(
bytes[0],
b'a'..=b'z' | b'A'..=b'Z' | b'_' | b'$' | b'@'
);
first_valid
&& bytes[1..].iter().all(|&b| {
matches!(
b,
b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_' | b'$' | b'.' | b'-'
)
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn regex_patterns() {
let scope = PathBuf::from(".");
assert!(matches!(
classify("/render(Call|Result)/", &scope),
QueryType::Regex(_)
));
assert!(matches!(
classify("/renderC[a-z]+/", &scope),
QueryType::Regex(_)
));
assert!(matches!(
classify("/renderC[a-z]{3}/", &scope),
QueryType::Regex(_)
));
assert!(matches!(
classify("/renderC.*/", &scope),
QueryType::Regex(_)
));
assert!(!matches!(classify("//", &scope), QueryType::Regex(_)));
assert!(!matches!(
classify("/src/lib.rs/", &scope),
QueryType::Regex(_)
));
assert!(!matches!(classify("/src/", &scope), QueryType::Regex(_)));
}
#[test]
fn glob_patterns() {
let scope = PathBuf::from(".");
assert!(matches!(classify("*.test.ts", &scope), QueryType::Glob(_)));
assert!(matches!(
classify("src/**/*.rs", &scope),
QueryType::Glob(_)
));
assert!(matches!(classify("{a,b}.js", &scope), QueryType::Glob(_)));
}
#[test]
fn identifiers() {
let scope = PathBuf::from(".");
assert!(matches!(
classify("handleAuth", &scope),
QueryType::Symbol(_)
));
assert!(matches!(
classify("handle_auth", &scope),
QueryType::Symbol(_)
));
assert!(matches!(
classify("my-component", &scope),
QueryType::Symbol(_)
));
assert!(matches!(
classify("AuthService.validate", &scope),
QueryType::Symbol(_)
));
assert!(matches!(classify("$ref", &scope), QueryType::Symbol(_)));
assert!(matches!(classify("@types", &scope), QueryType::Symbol(_)));
}
#[test]
fn content_queries() {
let scope = PathBuf::from(".");
assert!(matches!(classify("404", &scope), QueryType::Fallthrough(_)));
assert!(matches!(
classify("TODO: fix this", &scope),
QueryType::Fallthrough(_)
));
assert!(matches!(
classify("import { X }", &scope),
QueryType::Fallthrough(_)
));
}
#[test]
fn concept_queries() {
let scope = PathBuf::from(".");
assert!(matches!(
classify("thinking", &scope),
QueryType::Concept(_)
));
assert!(matches!(classify("alias", &scope), QueryType::Concept(_)));
assert!(matches!(classify("cli", &scope), QueryType::Concept(_)));
assert!(matches!(classify("mode", &scope), QueryType::Concept(_)));
assert!(matches!(classify("config", &scope), QueryType::Concept(_)));
assert!(matches!(classify("server", &scope), QueryType::Concept(_)));
assert!(matches!(
classify("cli mode", &scope),
QueryType::Concept(_)
));
assert!(matches!(
classify("search flow", &scope),
QueryType::Concept(_)
));
assert!(matches!(
classify("model mapping", &scope),
QueryType::Concept(_)
));
}
#[test]
fn symbol_not_concept() {
let scope = PathBuf::from(".");
assert!(matches!(
classify("SearchResult", &scope),
QueryType::Symbol(_)
));
assert!(matches!(classify("MapModel", &scope), QueryType::Symbol(_)));
assert!(matches!(
classify("handleAuth", &scope),
QueryType::Symbol(_)
));
assert!(matches!(
classify("thinkingBudget", &scope),
QueryType::Symbol(_)
));
assert!(matches!(
classify("is_test_file", &scope),
QueryType::Symbol(_)
));
assert!(matches!(
classify("handle_auth", &scope),
QueryType::Symbol(_)
));
assert!(matches!(
classify("Auth.validate", &scope),
QueryType::Symbol(_)
));
}
#[test]
fn is_identifier_checks() {
assert!(is_identifier("handleAuth"));
assert!(is_identifier("_private"));
assert!(is_identifier("$ref"));
assert!(is_identifier("@decorator"));
assert!(is_identifier("my-component"));
assert!(is_identifier("Auth.validate"));
assert!(!is_identifier(""));
assert!(!is_identifier("has space"));
assert!(!is_identifier("123start"));
}
#[test]
fn or_pattern_queries() {
let scope = PathBuf::from(".");
assert!(matches!(
classify("Config|Security|Auth", &scope),
QueryType::Regex(_)
));
assert!(matches!(
classify("handleAuth|handleLogin", &scope),
QueryType::Regex(_)
));
assert!(matches!(
classify("TODO|FIXME|HACK", &scope),
QueryType::Regex(_)
));
assert!(!matches!(classify("Foo|", &scope), QueryType::Regex(_)));
assert!(!matches!(
classify("has space|also space", &scope),
QueryType::Regex(_)
));
assert!(matches!(classify("/Foo|Bar/", &scope), QueryType::Regex(_)));
}
#[test]
fn paths_with_spaces_are_file_paths_when_they_exist() {
let dir = tempfile::tempdir().unwrap();
let file = dir
.path()
.join("source")
.join("DNN Platform")
.join("Modules")
.join("DDRMenu")
.join("Common")
.join("Utilities.cs");
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
std::fs::write(&file, "class Utilities {}\n").unwrap();
let query = "source/DNN Platform/Modules/DDRMenu/Common/Utilities.cs";
match classify(query, dir.path()) {
QueryType::FilePath(path) => assert_eq!(path, file),
other => panic!("expected FilePath, got {other:?}"),
}
}
#[test]
fn path_line_with_spaces_resolves_when_path_exists() {
let dir = tempfile::tempdir().unwrap();
let file = dir
.path()
.join("source")
.join("DNN Platform")
.join("Utilities.cs");
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
std::fs::write(&file, "class Utilities {}\n").unwrap();
let query = "source/DNN Platform/Utilities.cs:7";
match classify(query, dir.path()) {
QueryType::FilePathLine(path, line) => {
assert_eq!(path, file);
assert_eq!(line, 7);
}
other => panic!("expected FilePathLine, got {other:?}"),
}
}
#[test]
fn bare_filename_glob_fallback() {
let scope = PathBuf::from(".");
match classify("ProgramDB.java", &scope) {
QueryType::FilePath(_) => {
}
QueryType::Glob(pattern) => {
assert_eq!(pattern, "**/ProgramDB.java");
}
other => panic!("expected FilePath or Glob, got {other:?}"),
}
match classify("Dockerfile", &scope) {
QueryType::FilePath(_) => {} QueryType::Glob(pattern) => {
assert_eq!(pattern, "**/Dockerfile");
}
other => panic!("expected FilePath or Glob, got {other:?}"),
}
}
}