use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use anyhow::Context as _;
use regex::{Regex, RegexBuilder};
use serde_json::Value;
use sqry_core::graph::CodeGraph;
use sqry_core::graph::unified::node::{NodeId, NodeKind};
use sqry_core::search::fuzzy::{CandidateGenerator, FuzzyConfig};
use sqry_core::search::matcher::{FuzzyMatcher, MatchConfig};
use sqry_core::search::trigram::TrigramIndex;
use sqry_daemon_protocol::{SearchItem, SearchMode, SearchRequest, SearchResult};
use super::super::protocol::{ResponseEnvelope, ResponseMeta};
use super::super::tool_core;
use super::{HandlerContext, MethodError};
const DEFAULT_LIMIT_REGEX_EXACT: usize = 100;
const DEFAULT_LIMIT_FUZZY: usize = 50;
const FUZZY_MIN_SCORE: f64 = 0.6;
const FUZZY_MAX_CANDIDATES: usize = 1000;
const FUZZY_MIN_SIMILARITY: f64 = 0.1;
pub(crate) async fn handle(ctx: &HandlerContext, params: Value) -> Result<Value, MethodError> {
let req: SearchRequest = match params {
Value::Null => {
return Err(MethodError::InvalidParams(serde::de::Error::custom(
"daemon/search requires params",
)));
}
other => serde_json::from_value(other).map_err(MethodError::InvalidParams)?,
};
let regex = validate_request(&req)?;
let path = req.search_path.clone();
let tool_timeout = Duration::from_secs(ctx.config.tool_timeout_secs);
let verdict = tool_core::acquire_and_execute(
Arc::clone(&ctx.manager),
Arc::clone(&ctx.workspace_builder),
Arc::clone(&ctx.tool_executor),
tool_timeout,
&path,
Some("daemon/search"),
move |wctx, _cancel| -> anyhow::Result<Value> {
let result = run_search_on_graph(&wctx.graph, &req, regex.as_ref());
serde_json::to_value(&result).context("serialise SearchResult")
},
)
.await
.map_err(MethodError::Daemon)?;
match verdict {
tool_core::ExecuteVerdict::Fresh { inner, state } => {
let envelope = ResponseEnvelope {
result: inner,
meta: ResponseMeta::fresh_from(state, ctx.daemon_version),
};
serde_json::to_value(&envelope)
.map_err(|e| MethodError::Internal(anyhow::anyhow!("envelope serialise: {e}")))
}
tool_core::ExecuteVerdict::Stale {
inner,
stale_warning: _,
last_good_at,
last_error,
} => {
let envelope = ResponseEnvelope {
result: inner,
meta: ResponseMeta::stale_from(last_good_at, last_error, ctx.daemon_version),
};
serde_json::to_value(&envelope)
.map_err(|e| MethodError::Internal(anyhow::anyhow!("envelope serialise: {e}")))
}
}
}
fn validate_request(req: &SearchRequest) -> Result<Option<Regex>, MethodError> {
if req.pattern.is_empty() {
return Err(MethodError::InvalidParams(serde::de::Error::custom(
"daemon/search: pattern must not be empty",
)));
}
let regex = match req.mode {
SearchMode::Regex => {
let compiled = RegexBuilder::new(&req.pattern).build().map_err(|e| {
MethodError::InvalidParams(serde::de::Error::custom(format!(
"daemon/search: invalid regex pattern: {e}"
)))
})?;
Some(compiled)
}
SearchMode::Exact | SearchMode::Fuzzy => None,
};
Ok(regex)
}
#[derive(Debug, Clone, Copy)]
struct ScoredHit {
node_id: NodeId,
score: Option<f32>,
}
fn run_search_on_graph(
graph: &CodeGraph,
req: &SearchRequest,
precompiled_regex: Option<&Regex>,
) -> SearchResult {
let hits: Vec<ScoredHit> = match req.mode {
SearchMode::Exact => exact_hits(graph, &req.pattern),
SearchMode::Regex => regex_hits(
graph,
precompiled_regex.expect("validate_request must precompile regex"),
),
SearchMode::Fuzzy => fuzzy_hits(graph, &req.pattern),
};
let hits = match req.mode {
SearchMode::Fuzzy => dedup_preserve_order(hits),
SearchMode::Exact | SearchMode::Regex => {
let mut h = hits;
h.sort_by_key(|hit| hit.node_id);
h.dedup_by_key(|hit| hit.node_id);
h
}
};
let hits = if req.include_generated {
hits
} else {
filter_macro_generated_hits(graph, hits)
};
let mut items: Vec<SearchItem> = hits
.into_iter()
.filter_map(|hit| node_to_search_item(graph, hit.node_id, hit.score))
.collect();
apply_filters(&mut items, req.kind.as_deref(), req.lang.as_deref());
let limit = req.limit.map(|l| l as usize).unwrap_or(match req.mode {
SearchMode::Fuzzy => DEFAULT_LIMIT_FUZZY,
_ => DEFAULT_LIMIT_REGEX_EXACT,
});
let pre_truncate_count = items.len();
let truncated = pre_truncate_count > limit;
if truncated {
items.truncate(limit);
}
let total = pre_truncate_count as u64;
SearchResult {
items,
total,
truncated,
cursor: None,
}
}
fn filter_macro_generated_hits(graph: &CodeGraph, hits: Vec<ScoredHit>) -> Vec<ScoredHit> {
let store = graph.macro_metadata();
hits.into_iter()
.filter(|hit| {
store
.get(hit.node_id)
.is_none_or(|m| m.macro_generated != Some(true))
})
.collect()
}
fn dedup_preserve_order(hits: Vec<ScoredHit>) -> Vec<ScoredHit> {
let mut seen: std::collections::HashSet<NodeId> = std::collections::HashSet::new();
let mut out: Vec<ScoredHit> = Vec::with_capacity(hits.len());
for hit in hits {
if seen.insert(hit.node_id) {
out.push(hit);
}
}
out
}
fn exact_hits(graph: &CodeGraph, pattern: &str) -> Vec<ScoredHit> {
graph
.snapshot()
.find_by_exact_name(pattern)
.into_iter()
.map(|node_id| ScoredHit {
node_id,
score: None,
})
.collect()
}
fn regex_hits(graph: &CodeGraph, regex: &Regex) -> Vec<ScoredHit> {
let strings = graph.strings();
let indices = graph.indices();
let mut matches: Vec<ScoredHit> = Vec::new();
for (str_id, s) in strings.iter() {
if regex.is_match(s) {
matches.extend(
indices
.by_qualified_name(str_id)
.iter()
.map(|&n| ScoredHit {
node_id: n,
score: None,
}),
);
matches.extend(indices.by_name(str_id).iter().map(|&n| ScoredHit {
node_id: n,
score: None,
}));
}
}
matches
}
fn fuzzy_hits(graph: &CodeGraph, pattern: &str) -> Vec<ScoredHit> {
let mut trigram = TrigramIndex::new();
for (str_id, s) in graph.strings().iter() {
trigram.add_symbol(str_id.index() as usize, s);
}
let trigram_arc = Arc::new(trigram);
let fuzzy_config = FuzzyConfig {
max_candidates: FUZZY_MAX_CANDIDATES,
min_similarity: FUZZY_MIN_SIMILARITY,
};
let generator = CandidateGenerator::with_config(trigram_arc, fuzzy_config);
let candidate_ids = generator.generate(pattern);
if candidate_ids.is_empty() {
return Vec::new();
}
let match_config = MatchConfig {
algorithm: sqry_core::search::matcher::MatchAlgorithm::JaroWinkler,
min_score: FUZZY_MIN_SCORE,
case_sensitive: false,
};
let matcher = FuzzyMatcher::with_config(match_config);
let strings = graph.strings();
let resolved: Vec<(usize, Arc<str>)> = candidate_ids
.iter()
.filter_map(|&id| {
let raw = u32::try_from(id).ok()?;
let sid = sqry_core::graph::unified::string::StringId::new(raw);
strings.resolve(sid).map(|s| (id, s))
})
.collect();
let targets = resolved.iter().map(|(id, s)| (*id, s.as_ref()));
let mut scored = matcher.match_many(pattern, targets);
scored.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
let indices = graph.indices();
let mut hits: Vec<ScoredHit> = Vec::new();
for result in scored {
let Ok(raw) = u32::try_from(result.entry_id) else {
continue;
};
let sid = sqry_core::graph::unified::string::StringId::new(raw);
let score = Some(result.score as f32);
hits.extend(
indices
.by_qualified_name(sid)
.iter()
.map(|&n| ScoredHit { node_id: n, score }),
);
hits.extend(
indices
.by_name(sid)
.iter()
.map(|&n| ScoredHit { node_id: n, score }),
);
}
hits
}
fn node_to_search_item(
graph: &CodeGraph,
node_id: NodeId,
score: Option<f32>,
) -> Option<SearchItem> {
let entry = graph.nodes().get(node_id)?;
let strings = graph.strings();
let files = graph.files();
let name = strings
.resolve(entry.name)
.map(|s| s.to_string())
.unwrap_or_default();
let qualified_name = entry
.qualified_name
.and_then(|id| strings.resolve(id))
.map_or_else(|| name.clone(), |s| s.to_string());
let file_path = files
.resolve(entry.file)
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default();
let language = language_from_extension(Path::new(&file_path));
Some(SearchItem {
name,
qualified_name,
kind: node_kind_to_string(entry.kind).to_owned(),
language,
file_path,
start_line: entry.start_line,
start_column: entry.start_column,
end_line: entry.end_line,
end_column: entry.end_column,
score,
})
}
fn apply_filters(items: &mut Vec<SearchItem>, kind: Option<&str>, lang: Option<&str>) {
if let Some(k) = kind {
let target = k.to_lowercase();
items.retain(|s| s.kind.to_lowercase() == target);
}
if let Some(l) = lang {
items.retain(|s| {
Path::new(&s.file_path)
.extension()
.and_then(|e| e.to_str())
.is_some_and(|ext| matches_language(ext, l))
});
}
}
fn node_kind_to_string(kind: NodeKind) -> &'static str {
match kind {
NodeKind::Function => "function",
NodeKind::Method => "method",
NodeKind::Class => "class",
NodeKind::Interface => "interface",
NodeKind::Trait => "trait",
NodeKind::Module => "module",
NodeKind::Variable => "variable",
NodeKind::Constant => "constant",
NodeKind::Type => "type",
NodeKind::Struct => "struct",
NodeKind::Enum => "enum",
NodeKind::EnumVariant => "enum_variant",
NodeKind::Macro => "macro",
NodeKind::Parameter => "parameter",
NodeKind::Property => "property",
NodeKind::Import => "import",
NodeKind::Export => "export",
NodeKind::Component => "component",
NodeKind::Service => "service",
NodeKind::Resource => "resource",
NodeKind::Endpoint => "endpoint",
NodeKind::Test => "test",
NodeKind::CallSite => "call_site",
NodeKind::StyleRule => "style_rule",
NodeKind::StyleAtRule => "style_at_rule",
NodeKind::StyleVariable => "style_variable",
NodeKind::Lifetime => "lifetime",
NodeKind::TypeParameter => "type_parameter",
NodeKind::Annotation => "annotation",
NodeKind::AnnotationValue => "annotation_value",
NodeKind::LambdaTarget => "lambda_target",
NodeKind::JavaModule => "java_module",
NodeKind::EnumConstant => "enum_constant",
NodeKind::Other => "other",
}
}
fn language_from_extension(path: &Path) -> String {
path.extension().and_then(|ext| ext.to_str()).map_or_else(
|| "unknown".to_string(),
|ext| match ext.to_lowercase().as_str() {
"rs" => "rust".to_string(),
"js" | "mjs" | "cjs" => "javascript".to_string(),
"ts" | "mts" | "cts" => "typescript".to_string(),
"jsx" => "javascriptreact".to_string(),
"tsx" => "typescriptreact".to_string(),
"py" | "pyw" => "python".to_string(),
"rb" => "ruby".to_string(),
"go" => "go".to_string(),
"java" => "java".to_string(),
"kt" | "kts" => "kotlin".to_string(),
"scala" | "sc" => "scala".to_string(),
"c" | "h" => "c".to_string(),
"cpp" | "cc" | "cxx" | "hpp" | "hxx" => "cpp".to_string(),
"cs" => "csharp".to_string(),
"php" => "php".to_string(),
"swift" => "swift".to_string(),
"sql" => "sql".to_string(),
"dart" => "dart".to_string(),
"lua" => "lua".to_string(),
"sh" | "bash" | "zsh" => "shell".to_string(),
"pl" | "pm" => "perl".to_string(),
"groovy" | "gvy" => "groovy".to_string(),
"ex" | "exs" => "elixir".to_string(),
"r" => "r".to_string(),
"hs" | "lhs" => "haskell".to_string(),
"svelte" => "svelte".to_string(),
"vue" => "vue".to_string(),
"zig" => "zig".to_string(),
"css" | "scss" | "sass" | "less" => "css".to_string(),
"html" | "htm" => "html".to_string(),
"tf" | "tfvars" => "terraform".to_string(),
"pp" => "puppet".to_string(),
"pls" | "plb" | "pck" => "plsql".to_string(),
"cls" | "trigger" => "apex".to_string(),
"abap" => "abap".to_string(),
_ => "unknown".to_string(),
},
)
}
fn matches_language(ext: &str, lang: &str) -> bool {
let ext = ext.to_lowercase();
let lang = lang.to_lowercase();
match lang.as_str() {
"rust" | "rs" => ext == "rs",
"javascript" | "js" => matches!(ext.as_str(), "js" | "jsx" | "mjs" | "cjs"),
"typescript" | "ts" => matches!(ext.as_str(), "ts" | "tsx"),
"python" | "py" => matches!(ext.as_str(), "py" | "pyi" | "pyw"),
"go" => ext == "go",
"java" => ext == "java",
"swift" => ext == "swift",
"c" => matches!(ext.as_str(), "c" | "h"),
"cpp" | "c++" | "cxx" => {
matches!(
ext.as_str(),
"cpp" | "cc" | "cxx" | "hpp" | "hh" | "hxx" | "h"
)
}
"csharp" | "c#" | "cs" => matches!(ext.as_str(), "cs" | "csx"),
"dart" => ext == "dart",
"kotlin" | "kt" => matches!(ext.as_str(), "kt" | "kts"),
"ruby" | "rb" => matches!(ext.as_str(), "rb" | "rake" | "gemspec"),
"scala" => matches!(ext.as_str(), "scala" | "sc"),
"php" => ext == "php",
"lua" => ext == "lua",
"elixir" | "ex" => matches!(ext.as_str(), "ex" | "exs"),
"haskell" | "hs" => matches!(ext.as_str(), "hs" | "lhs"),
"perl" | "pl" => matches!(ext.as_str(), "pl" | "pm"),
"r" => ext == "r",
"shell" | "sh" | "bash" => matches!(ext.as_str(), "sh" | "bash" | "zsh"),
"zig" => ext == "zig",
"groovy" => matches!(ext.as_str(), "groovy" | "gvy" | "gy" | "gsh"),
"vue" => ext == "vue",
"svelte" => ext == "svelte",
"html" => matches!(ext.as_str(), "html" | "htm"),
"css" => matches!(ext.as_str(), "css" | "scss" | "sass" | "less"),
"terraform" | "tf" | "hcl" => matches!(ext.as_str(), "tf" | "tfvars" | "hcl"),
"puppet" | "pp" => ext == "pp",
"sql" => ext == "sql",
"servicenow" | "servicenow-xanadu" | "servicenow-xanadu-js" | "snjs" => ext == "snjs",
"apex" | "salesforce" => matches!(ext.as_str(), "cls" | "trigger"),
"abap" => ext == "abap",
"plsql" | "oracle-plsql" => matches!(ext.as_str(), "pks" | "pkb" | "pls"),
other => ext == other,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn node_kind_to_string_covers_known_variants() {
assert_eq!(node_kind_to_string(NodeKind::Function), "function");
assert_eq!(node_kind_to_string(NodeKind::Method), "method");
assert_eq!(node_kind_to_string(NodeKind::Class), "class");
assert_eq!(node_kind_to_string(NodeKind::Struct), "struct");
assert_eq!(node_kind_to_string(NodeKind::Other), "other");
}
#[test]
fn language_from_extension_maps_common_extensions() {
assert_eq!(language_from_extension(Path::new("foo.rs")), "rust");
assert_eq!(language_from_extension(Path::new("foo.py")), "python");
assert_eq!(
language_from_extension(Path::new("foo.tsx")),
"typescriptreact"
);
assert_eq!(
language_from_extension(Path::new("foo.unknownext")),
"unknown"
);
assert_eq!(language_from_extension(Path::new("noext")), "unknown");
}
#[test]
fn matches_language_handles_aliases_and_default_passthrough() {
assert!(matches_language("rs", "rust"));
assert!(matches_language("py", "python"));
assert!(matches_language("tsx", "typescript"));
assert!(matches_language("hpp", "cpp"));
assert!(!matches_language("rs", "python"));
assert!(matches_language("custom", "custom"));
}
#[test]
fn apply_filters_drops_non_matching_kind_and_lang() {
let mut items = vec![
SearchItem {
name: "a".into(),
qualified_name: "a".into(),
kind: "function".into(),
language: "rust".into(),
file_path: "a.rs".into(),
start_line: 1,
start_column: 0,
end_line: 1,
end_column: 1,
score: None,
},
SearchItem {
name: "b".into(),
qualified_name: "b".into(),
kind: "class".into(),
language: "python".into(),
file_path: "b.py".into(),
start_line: 1,
start_column: 0,
end_line: 1,
end_column: 1,
score: None,
},
];
apply_filters(&mut items, Some("function"), None);
assert_eq!(items.len(), 1);
assert_eq!(items[0].kind, "function");
apply_filters(&mut items, None, Some("rust"));
assert_eq!(items.len(), 1);
apply_filters(&mut items, None, Some("python"));
assert!(items.is_empty());
}
#[test]
fn run_search_on_graph_empty_graph_returns_empty_result() {
let graph = CodeGraph::new();
let req = req_for(SearchMode::Exact, "anything", None);
let result = run_search_on_graph(&graph, &req, None);
assert!(result.items.is_empty());
assert_eq!(result.total, 0);
assert!(!result.truncated);
assert!(result.cursor.is_none());
}
#[test]
fn validate_request_rejects_empty_pattern_as_invalid_params() {
let req = req_for(SearchMode::Exact, "", None);
let err = validate_request(&req).expect_err("empty pattern must reject");
assert!(
matches!(err, MethodError::InvalidParams(_)),
"empty pattern must map to InvalidParams (-32602), got: {err:?}"
);
}
#[test]
fn validate_request_rejects_malformed_regex_as_invalid_params() {
let req = req_for(SearchMode::Regex, "[", None);
let err = validate_request(&req).expect_err("invalid regex must reject");
let MethodError::InvalidParams(inner) = &err else {
panic!("invalid regex must map to InvalidParams (-32602), got: {err:?}");
};
let msg = format!("{inner}");
assert!(
msg.contains("invalid regex pattern"),
"expected invalid-regex context, got: {msg}"
);
let resp = err.into_jsonrpc_response(None);
let body = serde_json::to_value(&resp).expect("response to_value");
let code = body
.get("error")
.and_then(|e| e.get("code"))
.and_then(|c| c.as_i64())
.expect("error.code present");
assert_eq!(
code, -32602,
"wire code for malformed regex must be -32602, got: {code}"
);
}
#[test]
fn validate_request_compiles_regex_for_regex_mode() {
let req = req_for(SearchMode::Regex, "fo+", None);
let regex = validate_request(&req).expect("valid regex compiles");
let regex = regex.expect("regex mode must yield Some(Regex)");
assert!(regex.is_match("foo"));
assert!(!regex.is_match("bar"));
}
#[test]
fn validate_request_returns_none_for_exact_and_fuzzy_modes() {
assert!(
validate_request(&req_for(SearchMode::Exact, "x", None))
.expect("exact ok")
.is_none()
);
assert!(
validate_request(&req_for(SearchMode::Fuzzy, "x", None))
.expect("fuzzy ok")
.is_none()
);
}
fn build_populated_graph() -> CodeGraph {
use sqry_core::graph::unified::storage::arena::NodeEntry;
let mut graph = CodeGraph::new();
let rs_file = graph
.files_mut()
.register(Path::new("foo.rs"))
.expect("register foo.rs");
let py_file = graph
.files_mut()
.register(Path::new("bar.py"))
.expect("register bar.py");
for (name, file) in [
("alpha", rs_file),
("beta", rs_file),
("alphabet", rs_file),
("delta", py_file),
] {
let name_id = graph.strings_mut().intern(name).expect("intern name");
let entry = NodeEntry::new(NodeKind::Function, name_id, file).with_location(1, 0, 1, 4);
graph.nodes_mut().alloc(entry).expect("alloc node");
}
graph.rebuild_indices();
graph
}
fn req_for(mode: SearchMode, pattern: &str, limit: Option<u32>) -> SearchRequest {
SearchRequest {
envelope_version: 1,
pattern: pattern.into(),
search_path: "/tmp".into(),
mode,
kind: None,
lang: None,
limit,
include_generated: true,
}
}
fn build_graph_with_macro_generated() -> (CodeGraph, NodeKind) {
use sqry_core::graph::unified::storage::arena::NodeEntry;
use sqry_core::graph::unified::storage::metadata::MacroNodeMetadata;
let mut graph = CodeGraph::new();
let rs_file = graph
.files_mut()
.register(Path::new("foo.rs"))
.expect("register foo.rs");
let user_name = graph.strings_mut().intern("alpha").expect("intern alpha");
let user_entry =
NodeEntry::new(NodeKind::Function, user_name, rs_file).with_location(1, 0, 1, 5);
graph
.nodes_mut()
.alloc(user_entry)
.expect("alloc user alpha");
let macro_name = graph
.strings_mut()
.intern("alpha")
.expect("intern macro alpha");
let macro_entry =
NodeEntry::new(NodeKind::Function, macro_name, rs_file).with_location(2, 0, 2, 5);
let macro_id = graph
.nodes_mut()
.alloc(macro_entry)
.expect("alloc macro alpha");
graph.macro_metadata_mut().insert(
macro_id,
MacroNodeMetadata {
macro_generated: Some(true),
macro_source: Some("derive_Debug".to_string()),
cfg_condition: None,
cfg_active: None,
proc_macro_kind: None,
expansion_cached: None,
unresolved_attributes: Vec::new(),
},
);
graph.rebuild_indices();
(graph, NodeKind::Function)
}
#[test]
fn run_search_on_graph_drops_macro_generated_when_include_generated_false() {
let (graph, _) = build_graph_with_macro_generated();
let mut req = req_for(SearchMode::Exact, "alpha", None);
req.include_generated = false;
let result = run_search_on_graph(&graph, &req, None);
assert_eq!(
result.total, 1,
"include_generated=false must drop the macro_generated alpha sibling, got: {result:?}",
);
assert_eq!(result.items.len(), 1);
assert_eq!(result.items[0].start_line, 1);
assert!(!result.truncated);
}
#[test]
fn run_search_on_graph_keeps_macro_generated_when_include_generated_true() {
let (graph, _) = build_graph_with_macro_generated();
let req = req_for(SearchMode::Exact, "alpha", None); let result = run_search_on_graph(&graph, &req, None);
assert_eq!(
result.total, 2,
"include_generated=true must surface both alphas, got: {result:?}",
);
assert_eq!(result.items.len(), 2);
}
#[test]
fn run_search_on_graph_default_request_keeps_macro_generated() {
let (graph, _) = build_graph_with_macro_generated();
let wire = serde_json::json!({
"envelope_version": sqry_daemon_protocol::ENVELOPE_VERSION,
"pattern": "alpha",
"search_path": "/tmp",
"mode": "exact"
});
let req: SearchRequest = serde_json::from_value(wire).expect("default-deserialise");
assert!(
req.include_generated,
"wire default must keep include_generated=true"
);
let result = run_search_on_graph(&graph, &req, None);
assert_eq!(result.total, 2);
}
#[test]
fn run_search_on_graph_exact_finds_by_exact_name() {
let graph = build_populated_graph();
let req = req_for(SearchMode::Exact, "alpha", None);
let result = run_search_on_graph(&graph, &req, None);
assert_eq!(
result.total, 1,
"expected exactly one exact hit for 'alpha'"
);
assert_eq!(result.items.len(), 1);
assert_eq!(result.items[0].name, "alpha");
assert_eq!(result.items[0].kind, "function");
assert_eq!(result.items[0].language, "rust");
assert!(result.items[0].score.is_none(), "exact hits carry no score");
assert!(!result.truncated);
}
#[test]
fn run_search_on_graph_regex_uses_precompiled_pattern() {
let graph = build_populated_graph();
let req = req_for(SearchMode::Regex, "^alpha", None);
let regex = validate_request(&req)
.expect("regex compile")
.expect("regex mode yields regex");
let result = run_search_on_graph(&graph, &req, Some(®ex));
let names: Vec<&str> = result.items.iter().map(|i| i.name.as_str()).collect();
assert!(
names.contains(&"alpha"),
"regex 'alpha' must match: {names:?}"
);
assert!(
names.contains(&"alphabet"),
"regex '^alpha' must match alphabet too: {names:?}"
);
assert!(
!names.contains(&"beta"),
"regex '^alpha' must NOT match beta: {names:?}"
);
for item in &result.items {
assert!(item.score.is_none(), "regex hits carry no score");
}
}
#[test]
fn run_search_on_graph_fuzzy_preserves_score_descending_order() {
let graph = build_populated_graph();
let req = req_for(SearchMode::Fuzzy, "alph", None);
let result = run_search_on_graph(&graph, &req, None);
assert!(
!result.items.is_empty(),
"fuzzy search must produce at least one hit for 'alph'"
);
for item in &result.items {
assert!(
item.score.is_some(),
"every fuzzy hit must carry a score; got: {item:?}"
);
}
for pair in result.items.windows(2) {
let a = pair[0].score.unwrap();
let b = pair[1].score.unwrap();
assert!(
a >= b,
"fuzzy items must be sorted score-descending; {a} >= {b} failed at {:?}",
pair
);
}
if result.items.iter().any(|i| i.name == "alpha") {
assert_eq!(
result.items[0].name, "alpha",
"fuzzy top hit for 'alph' must be 'alpha' when present: {:?}",
result.items
);
}
}
#[test]
fn run_search_on_graph_limit_truncates_and_reports_pre_truncate_total() {
let graph = build_populated_graph();
let req = req_for(SearchMode::Regex, ".*", Some(2));
let regex = validate_request(&req)
.expect("regex compile")
.expect("regex mode yields regex");
let result = run_search_on_graph(&graph, &req, Some(®ex));
assert_eq!(result.items.len(), 2, "limit must cap items");
assert_eq!(
result.total, 4,
"total must report the pre-truncate count, got: {result:?}"
);
assert!(
result.truncated,
"truncated must be true when items were capped"
);
}
fn cli_equivalent_node_kind_to_string(kind: NodeKind) -> &'static str {
match kind {
NodeKind::Function => "function",
NodeKind::Method => "method",
NodeKind::Class => "class",
NodeKind::Interface => "interface",
NodeKind::Trait => "trait",
NodeKind::Module => "module",
NodeKind::Variable => "variable",
NodeKind::Constant => "constant",
NodeKind::Type => "type",
NodeKind::Struct => "struct",
NodeKind::Enum => "enum",
NodeKind::EnumVariant => "enum_variant",
NodeKind::Macro => "macro",
NodeKind::Parameter => "parameter",
NodeKind::Property => "property",
NodeKind::Import => "import",
NodeKind::Export => "export",
NodeKind::Component => "component",
NodeKind::Service => "service",
NodeKind::Resource => "resource",
NodeKind::Endpoint => "endpoint",
NodeKind::Test => "test",
NodeKind::CallSite => "call_site",
NodeKind::StyleRule => "style_rule",
NodeKind::StyleAtRule => "style_at_rule",
NodeKind::StyleVariable => "style_variable",
NodeKind::Lifetime => "lifetime",
NodeKind::TypeParameter => "type_parameter",
NodeKind::Annotation => "annotation",
NodeKind::AnnotationValue => "annotation_value",
NodeKind::LambdaTarget => "lambda_target",
NodeKind::JavaModule => "java_module",
NodeKind::EnumConstant => "enum_constant",
NodeKind::Other => "other",
}
}
fn cli_equivalent_language_from_path(path: &Path) -> String {
path.extension().and_then(|ext| ext.to_str()).map_or_else(
|| "unknown".to_string(),
|ext| match ext.to_lowercase().as_str() {
"rs" => "rust".to_string(),
"js" | "mjs" | "cjs" => "javascript".to_string(),
"ts" | "mts" | "cts" => "typescript".to_string(),
"jsx" => "javascriptreact".to_string(),
"tsx" => "typescriptreact".to_string(),
"py" | "pyw" => "python".to_string(),
"rb" => "ruby".to_string(),
"go" => "go".to_string(),
"java" => "java".to_string(),
"kt" | "kts" => "kotlin".to_string(),
"scala" | "sc" => "scala".to_string(),
"c" | "h" => "c".to_string(),
"cpp" | "cc" | "cxx" | "hpp" | "hxx" => "cpp".to_string(),
"cs" => "csharp".to_string(),
"php" => "php".to_string(),
"swift" => "swift".to_string(),
"sql" => "sql".to_string(),
"dart" => "dart".to_string(),
"lua" => "lua".to_string(),
"sh" | "bash" | "zsh" => "shell".to_string(),
"pl" | "pm" => "perl".to_string(),
"groovy" | "gvy" => "groovy".to_string(),
"ex" | "exs" => "elixir".to_string(),
"r" => "r".to_string(),
"hs" | "lhs" => "haskell".to_string(),
"svelte" => "svelte".to_string(),
"vue" => "vue".to_string(),
"zig" => "zig".to_string(),
"css" | "scss" | "sass" | "less" => "css".to_string(),
"html" | "htm" => "html".to_string(),
"tf" | "tfvars" => "terraform".to_string(),
"pp" => "puppet".to_string(),
"pls" | "plb" | "pck" => "plsql".to_string(),
"cls" | "trigger" => "apex".to_string(),
"abap" => "abap".to_string(),
_ => "unknown".to_string(),
},
)
}
fn in_process_search_projection_exact(graph: &CodeGraph, pattern: &str) -> Vec<SearchItem> {
let snapshot = graph.snapshot();
let mut node_ids = snapshot.find_by_exact_name(pattern);
node_ids.sort_unstable();
node_ids.dedup();
node_ids
.into_iter()
.filter_map(|nid| {
let entry = graph.nodes().get(nid)?;
let strings = graph.strings();
let files = graph.files();
let name = strings.resolve(entry.name).map(|s| s.to_string())?;
let qualified_name = entry
.qualified_name
.and_then(|id| strings.resolve(id))
.map_or_else(|| name.clone(), |s| s.to_string());
let file_path = files
.resolve(entry.file)
.map(|p| p.to_string_lossy().into_owned())?;
let language = cli_equivalent_language_from_path(Path::new(&file_path));
let kind = cli_equivalent_node_kind_to_string(entry.kind).to_owned();
Some(SearchItem {
name,
qualified_name,
kind,
language,
file_path,
start_line: entry.start_line,
start_column: entry.start_column,
end_line: entry.end_line,
end_column: entry.end_column,
score: None,
})
})
.collect()
}
#[test]
fn daemon_and_in_process_exact_search_produce_identical_search_item_sets() {
let graph = build_populated_graph();
let patterns = ["alpha", "beta", "alphabet", "delta", "nonexistent"];
for pat in patterns {
let req = req_for(SearchMode::Exact, pat, None);
let daemon_items = run_search_on_graph(&graph, &req, None).items;
let in_process_items = in_process_search_projection_exact(&graph, pat);
assert_eq!(
daemon_items, in_process_items,
"in-process vs daemon parity FAILED for pattern '{pat}':\n \
daemon = {daemon_items:#?}\n in_process = {in_process_items:#?}"
);
}
}
#[test]
fn daemon_search_item_wire_shape_matches_node_entry_canonical_fields() {
let graph = build_populated_graph();
let req = req_for(SearchMode::Exact, "alpha", None);
let result = run_search_on_graph(&graph, &req, None);
assert_eq!(result.items.len(), 1, "fixture has one 'alpha' node");
let item = &result.items[0];
let snapshot = graph.snapshot();
let candidates = snapshot.find_by_exact_name("alpha");
assert_eq!(candidates.len(), 1, "expected one NodeId for 'alpha'");
let nid = candidates[0];
let entry = graph.nodes().get(nid).expect("alpha NodeEntry");
let expected_name = graph
.strings()
.resolve(entry.name)
.expect("alpha name interned")
.to_string();
let expected_qn = entry
.qualified_name
.and_then(|id| graph.strings().resolve(id))
.map_or_else(|| expected_name.clone(), |s| s.to_string());
let expected_file = graph
.files()
.resolve(entry.file)
.expect("alpha file registered")
.to_string_lossy()
.into_owned();
let expected_lang = language_from_extension(Path::new(&expected_file));
let expected_kind = node_kind_to_string(entry.kind).to_owned();
assert_eq!(item.name, expected_name, "name wire field");
assert_eq!(
item.qualified_name, expected_qn,
"qualified_name wire field (with fallback)"
);
assert_eq!(item.file_path, expected_file, "file_path wire field");
assert_eq!(item.language, expected_lang, "language wire field");
assert_eq!(item.kind, expected_kind, "kind wire field");
assert_eq!(item.start_line, entry.start_line, "start_line wire field");
assert_eq!(
item.start_column, entry.start_column,
"start_column wire field"
);
assert_eq!(item.end_line, entry.end_line, "end_line wire field");
assert_eq!(item.end_column, entry.end_column, "end_column wire field");
assert!(
item.score.is_none(),
"exact-mode hits must carry None score"
);
}
#[test]
fn run_search_on_graph_kind_and_lang_filters_preserve_order() {
let graph = build_populated_graph();
let mut req = req_for(SearchMode::Regex, ".*", None);
req.lang = Some("rust".into());
let regex = validate_request(&req)
.expect("regex compile")
.expect("regex mode yields regex");
let result = run_search_on_graph(&graph, &req, Some(®ex));
assert!(!result.items.is_empty(), "rust filter must keep some hits");
for item in &result.items {
assert_eq!(item.language, "rust", "lang filter must drop non-rust");
}
}
}