use super::toolbox::ResolvedGlob;
use anyhow::{Result, anyhow};
use grep_matcher::Matcher;
use grep_regex::RegexMatcherBuilder;
use grep_searcher::{Searcher, Sink, SinkMatch};
use ignore::WalkBuilder;
use regex::Regex;
use serde::Deserialize;
use serde_json::Value;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::sync::Mutex;
use tracing::{debug, warn};
use super::filesystem_manager::FilesystemManager;
use super::handler::{check_server_health, display_path};
use super::symbols::{
self, SymbolInfo, extract_locations, extract_symbol_infos, format_symbol_kind,
};
use super::tool_server::ToolServer;
use crate::lsp::{LspClient, LspClientManager};
const GREP_HOVER_THRESHOLD: usize = 10;
#[derive(Debug, Deserialize)]
pub struct GrepInput {
pub pattern: String,
#[serde(default)]
pub glob: Option<String>,
#[serde(default)]
pub exclude: Option<String>,
#[serde(default)]
pub include_gitignored: bool,
#[serde(default)]
pub include_hidden: bool,
}
pub struct GrepServer {
pub(super) client_manager: Arc<LspClientManager>,
pub(super) fs_manager: Arc<FilesystemManager>,
pub(super) notified_offline: Arc<std::sync::Mutex<HashSet<String>>>,
}
impl ToolServer for GrepServer {
async fn execute(
&self,
params: &serde_json::Value,
parent_id: Option<i64>,
) -> Result<serde_json::Value> {
let input: GrepInput = serde_json::from_value(params.clone())
.map_err(|e| anyhow!("Invalid arguments: {e}"))?;
if input.pattern.is_empty() {
return Err(anyhow!("pattern must be non-empty"));
}
let clients = self.client_manager.clients().await;
for (lang, client_mutex) in &clients {
if !client_mutex.lock().await.wait_ready().await {
warn!("[{lang}] server died \u{2014} tool will run in degraded mode");
}
}
let touched: Vec<String> = clients.keys().cloned().collect();
let health =
check_server_health(&self.client_manager, &touched, &self.notified_offline).await;
let output = self.run(input, parent_id).await?;
let text = if let Some(note) = health.notification {
if output.is_empty() {
note
} else {
format!("{note}\n\n{output}")
}
} else {
output
};
Ok(Value::String(text))
}
}
impl GrepServer {
#[allow(clippy::too_many_lines, reason = "Core grep orchestration")]
async fn run(&self, input: GrepInput, parent_id: Option<i64>) -> Result<String> {
use std::fmt::Write;
let re = Regex::new(&format!("(?i){}", &input.pattern))
.map_err(|e| anyhow!("Invalid regex pattern: {e}"))?;
debug!("Grep request: pattern={}", input.pattern);
let resolved_glob = input
.glob
.as_deref()
.map(ResolvedGlob::new)
.transpose()?
.map(Arc::new);
let resolved_exclude = input
.exclude
.as_deref()
.map(ResolvedGlob::new)
.transpose()?
.map(Arc::new);
let workspace_roots = self.client_manager.roots().await;
let effective_roots = if let Some(ref rg) = resolved_glob
&& let Some(override_root) = rg.override_root()
{
vec![override_root.to_path_buf()]
} else {
workspace_roots
};
let rg = Self::ripgrep_matches(
&input.pattern,
&effective_roots,
resolved_glob.as_ref(),
resolved_exclude.as_ref(),
input.include_gitignored,
input.include_hidden,
&self.fs_manager,
)?;
let rg_paths: Vec<PathBuf> = rg.file_lines.keys().map(PathBuf::from).collect();
self.client_manager
.ensure_clients_for_paths(&rg_paths)
.await;
let mut symbols = {
let clients = self.client_manager.clients().await;
let mut all_symbols: Vec<SymbolInfo> =
self.fetch_symbol_universe(&clients, parent_id).await;
if all_symbols.is_empty() && !rg.matched_strings.is_empty() {
all_symbols = self
.fetch_symbols_by_queries(&rg.matched_strings, &clients, parent_id)
.await;
}
all_symbols.retain(|s| re.is_match(&s.name));
let mut seen: HashSet<(String, String, u32)> = HashSet::new();
all_symbols.retain(|s| seen.insert((s.name.clone(), s.file_path.clone(), s.line)));
all_symbols
};
if let Some(ref rg) = resolved_glob {
symbols.retain(|s| {
effective_roots
.iter()
.any(|root| rg.is_match(Path::new(&s.file_path), root))
});
}
if let Some(ref rg) = resolved_exclude {
symbols.retain(|s| {
!effective_roots
.iter()
.any(|root| rg.is_match(Path::new(&s.file_path), root))
});
}
let show_hover = symbols.len() <= GREP_HOVER_THRESHOLD;
let mut name_order: Vec<String> = Vec::new();
let mut by_name: BTreeMap<String, Vec<&SymbolInfo>> = BTreeMap::new();
for sym in &symbols {
if !by_name.contains_key(&sym.name) {
name_order.push(sym.name.clone());
}
by_name.entry(sym.name.clone()).or_default().push(sym);
}
let mut enrichments: HashMap<(String, u32), SymbolEnrichment> = HashMap::new();
let mut all_ref_lines: HashMap<String, HashSet<u32>> = HashMap::new();
for sym in &symbols {
let enrichment = self.enrich_symbol(sym, parent_id).await;
for (file, lines) in &enrichment.ref_lines {
all_ref_lines.entry(file.clone()).or_default().extend(lines);
}
let key = (sym.file_path.clone(), sym.line);
enrichments.insert(key, enrichment);
}
let br = self
.bootstrap_from_rg(&rg, &by_name, &all_ref_lines, parent_id)
.await;
enrichments.extend(br.enrichments);
for (file, lines) in br.ref_lines {
all_ref_lines.entry(file).or_default().extend(lines);
}
for n in &br.name_order {
if !name_order.contains(n) {
name_order.push(n.clone());
}
}
let bootstrapped = br.symbols;
for sym in &bootstrapped {
by_name.entry(sym.name.clone()).or_default().push(sym);
}
let rg_by_name = assign_rg_lines_to_symbols(&by_name, &all_ref_lines, &rg);
for heading in rg_by_name.keys() {
if !heading.is_empty() && !name_order.contains(heading) {
name_order.push(heading.clone());
}
}
if name_order.is_empty() && rg.file_lines.is_empty() {
return Ok("No results found".to_string());
}
let mut output = String::new();
for name in &name_order {
if !output.is_empty() {
output.push('\n');
}
let _ = writeln!(output, "# {name}");
if let Some(lines) = rg_by_name.get(name.as_str())
&& !lines.is_empty()
{
let _ = writeln!(output);
for (file, file_lines) in lines {
let path = display_path(file, &self.fs_manager);
let _ = writeln!(output, "{path} {}", format_line_ranges(file_lines));
}
}
if let Some(defs) = by_name.get(name) {
for sym in defs {
let kind = format_symbol_kind(sym.kind);
let path = display_path(&sym.file_path, &self.fs_manager);
let line = sym.line + 1;
let _ = writeln!(output, "\n## [{kind}] {path}:{line}");
let key = (sym.file_path.clone(), sym.line);
if let Some(enrichment) = enrichments.get(&key) {
if show_hover && let Some(hover) = &enrichment.hover {
output.push('\n');
for line in hover.lines() {
let _ = writeln!(output, "> {line}");
}
}
let mut labeled_lines: HashSet<(String, u32)> = HashSet::new();
if !enrichment.incoming_calls.is_empty() {
let _ = writeln!(output, "\n### Callers\n");
for (name, file, line) in &enrichment.incoming_calls {
let path = display_path(file, &self.fs_manager);
let _ = writeln!(output, "{name} {path}:{line}");
labeled_lines.insert((file.clone(), *line));
}
}
if !enrichment.implementations.is_empty() {
let _ = writeln!(output, "\n### Implementations\n");
for (file, line) in &enrichment.implementations {
let path = display_path(file, &self.fs_manager);
let _ = writeln!(output, "{path}:{line}");
labeled_lines.insert((file.clone(), *line));
}
}
if !enrichment.subtypes.is_empty() {
let _ = writeln!(output, "\n### Subtypes\n");
for (name, file, line) in &enrichment.subtypes {
let path = display_path(file, &self.fs_manager);
let _ = writeln!(output, "{name} {path}:{line}");
labeled_lines.insert((file.clone(), *line));
}
}
let ref_output = format_symbol_references(
enrichment,
&sym.file_path,
sym.line,
&self.fs_manager,
&labeled_lines,
);
if !ref_output.is_empty() {
let _ = writeln!(output, "\n### References\n\n{ref_output}");
}
}
}
}
}
let trimmed_len = output.trim_end().len();
output.truncate(trimmed_len);
if output.is_empty() {
return Ok("No results found".to_string());
}
Ok(output)
}
async fn fetch_symbol_universe(
&self,
clients: &HashMap<String, Arc<Mutex<LspClient>>>,
parent_id: Option<i64>,
) -> Vec<SymbolInfo> {
let mut all_symbols: Vec<SymbolInfo> = Vec::new();
for client_mutex in clients.values() {
let mut client = client_mutex.lock().await;
client.set_parent_id(parent_id);
let supports_resolve = client.supports_workspace_symbol_resolve();
let Ok(response) = client.workspace_symbols("").await else {
continue;
};
all_symbols.extend(extract_symbol_infos(&response));
if supports_resolve && let Some(arr) = response.as_array() {
for item in arr {
let has_uri = item.get("location").and_then(|l| l.get("uri")).is_some();
let has_range = item.get("location").and_then(|l| l.get("range")).is_some();
if has_uri
&& !has_range
&& let Ok(resolved) = client.workspace_symbol_resolve(item).await
{
all_symbols.extend(extract_symbol_infos(&Value::Array(vec![resolved])));
}
}
}
}
all_symbols
}
async fn fetch_symbols_by_queries(
&self,
queries: &[String],
clients: &HashMap<String, Arc<Mutex<LspClient>>>,
parent_id: Option<i64>,
) -> Vec<SymbolInfo> {
let mut all_symbols: Vec<SymbolInfo> = Vec::new();
for query in queries {
for client_mutex in clients.values() {
let mut client = client_mutex.lock().await;
client.set_parent_id(parent_id);
let supports_resolve = client.supports_workspace_symbol_resolve();
let Ok(response) = client.workspace_symbols(query).await else {
continue;
};
all_symbols.extend(extract_symbol_infos(&response));
if supports_resolve && let Some(arr) = response.as_array() {
for item in arr {
let has_uri = item.get("location").and_then(|l| l.get("uri")).is_some();
let has_range = item.get("location").and_then(|l| l.get("range")).is_some();
if has_uri
&& !has_range
&& let Ok(resolved) = client.workspace_symbol_resolve(item).await
{
all_symbols.extend(extract_symbol_infos(&Value::Array(vec![resolved])));
}
}
}
}
}
all_symbols
}
async fn enrich_symbol(&self, sym: &SymbolInfo, parent_id: Option<i64>) -> SymbolEnrichment {
let path = PathBuf::from(&sym.file_path);
self.enrich_at_position(&path, sym.line, sym.character, sym.kind, parent_id)
.await
}
#[allow(clippy::too_many_lines, reason = "Sequential LSP calls by kind")]
async fn enrich_at_position(
&self,
path: &Path,
line_0: u32,
col: u32,
kind: u32,
parent_id: Option<i64>,
) -> SymbolEnrichment {
let mut enrichment = SymbolEnrichment::default();
let Ok((uri_str, client_mutex)) = self
.client_manager
.ensure_document_open(path, parent_id)
.await
else {
return enrichment;
};
let mut client = client_mutex.lock().await;
client.set_parent_id(parent_id);
if let Ok(hover) = client.hover(&uri_str, line_0, col).await {
enrichment.hover = extract_hover_text_from_value(&hover);
}
if let Ok(refs) = client.references(&uri_str, line_0, col, true).await {
for (file, line, _char) in extract_locations(&refs) {
enrichment
.ref_lines
.entry(file)
.or_default()
.insert(line + 1);
}
}
match kind {
symbols::SK_FUNCTION | symbols::SK_METHOD | symbols::SK_CONSTRUCTOR => {
if let Ok(response) = client.prepare_call_hierarchy(&uri_str, line_0, col).await
&& let Some(items) = response.as_array()
{
for item in items {
if let Ok(calls) = client.incoming_calls(item).await {
extract_incoming_calls(&calls, &mut enrichment);
}
}
}
}
symbols::SK_STRUCT | symbols::SK_CLASS | symbols::SK_ENUM => {
if let Ok(response) = client.implementation(&uri_str, line_0, col).await {
for (file, line, _char) in extract_locations(&response) {
enrichment.implementations.push((file, line + 1));
}
}
}
symbols::SK_INTERFACE => {
if let Ok(response) = client.prepare_type_hierarchy(&uri_str, line_0, col).await
&& let Some(items) = response.as_array()
{
for item in items {
if let Ok(subs) = client.subtypes(item).await {
extract_subtypes(&subs, &mut enrichment);
}
}
}
}
_ => {}
}
enrichment
}
#[allow(
clippy::too_many_lines,
reason = "Sequential LSP calls for kind inference"
)]
async fn enrich_at_position_infer_kind(
&self,
path: &Path,
line_0: u32,
col: u32,
parent_id: Option<i64>,
) -> (u32, Option<String>, SymbolEnrichment) {
let mut enrichment = SymbolEnrichment::default();
let mut inferred_kind = symbols::SK_VARIABLE;
let mut resolved_name: Option<String> = None;
let Ok((uri_str, client_mutex)) = self
.client_manager
.ensure_document_open(path, parent_id)
.await
else {
return (inferred_kind, resolved_name, enrichment);
};
let mut client = client_mutex.lock().await;
client.set_parent_id(parent_id);
if let Ok(hover) = client.hover(&uri_str, line_0, col).await {
enrichment.hover = extract_hover_text_from_value(&hover);
}
if let Ok(refs) = client.references(&uri_str, line_0, col, true).await {
for (file, line, _char) in extract_locations(&refs) {
enrichment
.ref_lines
.entry(file)
.or_default()
.insert(line + 1);
}
}
if let Ok(response) = client.prepare_call_hierarchy(&uri_str, line_0, col).await
&& let Some(items) = response.as_array()
&& !items.is_empty()
{
inferred_kind = symbols::SK_FUNCTION;
resolved_name = items
.first()
.and_then(|i| i.get("name"))
.and_then(Value::as_str)
.map(str::to_string);
for item in items {
if let Ok(calls) = client.incoming_calls(item).await {
extract_incoming_calls(&calls, &mut enrichment);
}
}
}
if inferred_kind == symbols::SK_VARIABLE
&& let Ok(response) = client.implementation(&uri_str, line_0, col).await
{
let locs = extract_locations(&response);
if !locs.is_empty() {
inferred_kind = symbols::SK_STRUCT;
for (file, line, _char) in locs {
enrichment.implementations.push((file, line + 1));
}
}
}
if inferred_kind == symbols::SK_VARIABLE
&& let Ok(response) = client.prepare_type_hierarchy(&uri_str, line_0, col).await
&& let Some(items) = response.as_array()
&& !items.is_empty()
{
inferred_kind = symbols::SK_INTERFACE;
resolved_name = items
.first()
.and_then(|i| i.get("name"))
.and_then(Value::as_str)
.map(str::to_string);
for item in items {
if let Ok(subs) = client.subtypes(item).await {
extract_subtypes(&subs, &mut enrichment);
}
}
}
(inferred_kind, resolved_name, enrichment)
}
#[allow(clippy::too_many_lines, reason = "Iterative elimination loop")]
async fn bootstrap_from_rg(
&self,
rg: &RipgrepMatches,
by_name: &BTreeMap<String, Vec<&SymbolInfo>>,
all_ref_lines: &HashMap<String, HashSet<u32>>,
parent_id: Option<i64>,
) -> BootstrapResult {
let mut result = BootstrapResult {
symbols: Vec::new(),
enrichments: HashMap::new(),
ref_lines: HashMap::new(),
name_order: Vec::new(),
};
let mut accounted: HashMap<String, HashSet<u32>> = HashMap::new();
for defs in by_name.values() {
for sym in defs {
accounted
.entry(sym.file_path.clone())
.or_default()
.insert(sym.line + 1);
}
}
for (file, ref_set) in all_ref_lines {
accounted.entry(file.clone()).or_default().extend(ref_set);
}
let mut unaccounted: Vec<(String, u32, String, u32)> = Vec::new();
for (file, line_map) in &rg.file_line_texts {
for (&line, texts) in line_map {
if accounted.get(file).is_some_and(|s| s.contains(&line)) {
continue;
}
for (text, col) in texts {
unaccounted.push((file.clone(), line, text.clone(), *col));
}
}
}
if unaccounted.is_empty() {
return result;
}
let Ok(ident_re) = Regex::new(r"[a-zA-Z_]\w*") else {
return result;
};
let mut bootstrapped_names: HashSet<String> = HashSet::new();
let total_symbols = |result: &BootstrapResult| by_name.len() + result.symbols.len();
for (file, line_1, matched_text, match_col) in &unaccounted {
if total_symbols(&result) >= GREP_HOVER_THRESHOLD {
break;
}
if accounted.get(file).is_some_and(|s| s.contains(line_1)) {
continue;
}
let line_0 = line_1 - 1;
let path = PathBuf::from(file.as_str());
for m in ident_re.find_iter(matched_text) {
if total_symbols(&result) >= GREP_HOVER_THRESHOLD {
break;
}
let token = m.as_str();
if by_name.contains_key(token) || bootstrapped_names.contains(token) {
continue;
}
let col = match_col + u32::try_from(m.start()).unwrap_or(0);
let is_symbol = {
let open_result = self
.client_manager
.ensure_document_open(&path, parent_id)
.await;
if let Ok((uri_str, client_mutex)) = open_result {
let mut client = client_mutex.lock().await;
client.set_parent_id(parent_id);
if client.supports_rename() {
let response = client.prepare_rename(&uri_str, line_0, col).await;
drop(client);
matches!(response, Ok(ref v) if !v.is_null())
} else {
true
}
} else {
false
}
};
if !is_symbol {
continue;
}
let (kind, resolved, enrichment) = self
.enrich_at_position_infer_kind(&path, line_0, col, parent_id)
.await;
let Some(name) = resolved else { continue };
if !name.contains(token) {
continue;
}
if by_name.contains_key(&name) || bootstrapped_names.contains(&name) {
for (ref_file, ref_lines) in &enrichment.ref_lines {
accounted
.entry(ref_file.clone())
.or_default()
.extend(ref_lines);
result
.ref_lines
.entry(ref_file.clone())
.or_default()
.extend(ref_lines);
}
accounted.entry(file.clone()).or_default().insert(*line_1);
continue;
}
for (ref_file, ref_lines) in &enrichment.ref_lines {
accounted
.entry(ref_file.clone())
.or_default()
.extend(ref_lines);
result
.ref_lines
.entry(ref_file.clone())
.or_default()
.extend(ref_lines);
}
accounted.entry(file.clone()).or_default().insert(*line_1);
result
.enrichments
.insert((file.clone(), line_0), enrichment);
let sym = SymbolInfo {
name: name.clone(),
kind,
file_path: file.clone(),
line: line_0,
character: col,
};
if !by_name.contains_key(&name) {
result.name_order.push(name.clone());
}
result.symbols.push(sym);
bootstrapped_names.insert(name);
}
}
result
}
fn ripgrep_matches(
pattern: &str,
roots: &[PathBuf],
glob: Option<&Arc<ResolvedGlob>>,
exclude: Option<&Arc<ResolvedGlob>>,
include_gitignored: bool,
include_hidden: bool,
fs_manager: &Arc<FilesystemManager>,
) -> Result<RipgrepMatches> {
use ignore::WalkState;
use std::sync::Mutex as StdMutex;
let matcher = RegexMatcherBuilder::new()
.case_insensitive(true)
.build(pattern)
.map_err(|e| anyhow!("Invalid regex pattern: {e}"))?;
let collected = Arc::new(StdMutex::new(Vec::<ThreadMatches>::new()));
let skip_gitignored = !include_gitignored;
let skip_hidden = !include_hidden;
for root in roots {
let walker = WalkBuilder::new(root)
.git_ignore(skip_gitignored)
.hidden(skip_hidden)
.build_parallel();
walker.run(|| {
let matcher = matcher.clone();
let glob = glob.cloned();
let exclude = exclude.cloned();
let root = root.clone();
let fs_manager = Arc::clone(fs_manager);
let mut state = CollectOnDrop {
local: ThreadMatches::default(),
collected: Arc::clone(&collected),
};
Box::new(move |entry| {
let Ok(entry) = entry else {
return WalkState::Continue;
};
let path = entry.path();
if !path.is_file() {
return WalkState::Continue;
}
if let Some(rg) = &glob
&& !rg.is_match(path, &root)
{
return WalkState::Continue;
}
if let Some(rg) = &exclude
&& rg.is_match(path, &root)
{
return WalkState::Continue;
}
if let Ok(metadata) = path.metadata()
&& fs_manager.is_binary(path, &metadata)
{
return WalkState::Continue;
}
let path_str = path.to_string_lossy().to_string();
let mut sink = MatchSink {
matcher: &matcher,
path: &path_str,
local: &mut state.local,
};
if let Err(e) = Searcher::new().search_path(&matcher, path, &mut sink) {
warn!("grep: skipping {path_str}: {e}");
}
WalkState::Continue
})
});
}
let parts = Arc::into_inner(collected)
.ok_or_else(|| anyhow!("walker threads still hold references"))?
.into_inner()
.map_err(|e| anyhow!("lock poisoned: {e}"))?;
Ok(RipgrepMatches::merge(parts))
}
}
struct CollectOnDrop {
local: ThreadMatches,
collected: Arc<std::sync::Mutex<Vec<ThreadMatches>>>,
}
impl Drop for CollectOnDrop {
fn drop(&mut self) {
let local = std::mem::take(&mut self.local);
if local.file_lines.is_empty() && local.matched_set.is_empty() {
return;
}
if let Ok(mut vec) = self.collected.lock() {
vec.push(local);
}
}
}
struct MatchSink<'a> {
matcher: &'a grep_regex::RegexMatcher,
path: &'a str,
local: &'a mut ThreadMatches,
}
impl Sink for MatchSink<'_> {
type Error = std::io::Error;
fn matched(&mut self, _searcher: &Searcher, mat: &SinkMatch<'_>) -> Result<bool, Self::Error> {
let Some(line_num) = mat
.line_number()
.and_then(|n| u32::try_from(n).ok())
.filter(|&n| n > 0)
else {
return Ok(true);
};
let line_bytes = mat.bytes();
let mut at = 0;
while at < line_bytes.len() {
let Ok(Some(m)) = self.matcher.find_at(line_bytes, at) else {
break;
};
if m.start() == m.end() {
at = m.end() + 1;
continue;
}
if let Ok(text) = std::str::from_utf8(&line_bytes[m]) {
let text = text.to_string();
let col = u32::try_from(m.start()).unwrap_or(0);
self.local.matched_set.insert(text.clone());
self.local
.file_line_texts
.entry(self.path.to_string())
.or_default()
.entry(line_num)
.or_default()
.push((text, col));
}
at = m.end();
}
self.local
.file_lines
.entry(self.path.to_string())
.or_default()
.push(line_num);
Ok(true)
}
}
#[derive(Default)]
struct SymbolEnrichment {
hover: Option<String>,
ref_lines: HashMap<String, HashSet<u32>>,
incoming_calls: Vec<(String, String, u32)>,
implementations: Vec<(String, u32)>,
subtypes: Vec<(String, String, u32)>,
}
struct BootstrapResult {
symbols: Vec<SymbolInfo>,
enrichments: HashMap<(String, u32), SymbolEnrichment>,
ref_lines: HashMap<String, HashSet<u32>>,
name_order: Vec<String>,
}
#[derive(Default)]
struct RipgrepMatches {
matched_strings: Vec<String>,
file_lines: BTreeMap<String, Vec<u32>>,
file_line_texts: HashMap<String, HashMap<u32, Vec<(String, u32)>>>,
}
impl RipgrepMatches {
fn merge(parts: Vec<ThreadMatches>) -> Self {
let mut file_lines: BTreeMap<String, Vec<u32>> = BTreeMap::new();
let mut matched_set: HashSet<String> = HashSet::new();
let mut file_line_texts: HashMap<String, HashMap<u32, Vec<(String, u32)>>> = HashMap::new();
for part in parts {
for (file, lines) in part.file_lines {
file_lines.entry(file).or_default().extend(lines);
}
matched_set.extend(part.matched_set);
for (file, line_map) in part.file_line_texts {
let entry = file_line_texts.entry(file).or_default();
for (line, texts) in line_map {
entry.entry(line).or_default().extend(texts);
}
}
}
Self {
matched_strings: matched_set.into_iter().collect(),
file_lines,
file_line_texts,
}
}
}
#[derive(Default)]
struct ThreadMatches {
file_lines: BTreeMap<String, Vec<u32>>,
matched_set: HashSet<String>,
file_line_texts: HashMap<String, HashMap<u32, Vec<(String, u32)>>>,
}
fn extract_hover_text_from_value(hover: &Value) -> Option<String> {
let contents = hover.get("contents")?;
if let Some(s) = contents.as_str() {
let s = s.trim();
return if s.is_empty() {
None
} else {
Some(s.to_string())
};
}
if let Some(value) = contents.get("value").and_then(Value::as_str) {
let text = value.trim();
return if text.is_empty() {
None
} else {
Some(text.to_string())
};
}
if let Some(arr) = contents.as_array() {
let texts: Vec<String> = arr
.iter()
.filter_map(|item| {
item.as_str().map_or_else(
|| {
item.get("value")
.and_then(Value::as_str)
.map(str::to_string)
},
|s| Some(s.to_string()),
)
})
.collect();
return if texts.is_empty() {
None
} else {
Some(texts.join("\n"))
};
}
None
}
fn value_file_path(item: &Value) -> String {
item.get("uri")
.and_then(Value::as_str)
.and_then(symbols::uri_to_path)
.unwrap_or_default()
}
fn value_start_line(item: &Value) -> u32 {
item.get("range")
.and_then(|r| r.get("start"))
.and_then(|s| s.get("line"))
.and_then(Value::as_u64)
.and_then(|n| u32::try_from(n).ok())
.unwrap_or(0)
}
fn extract_incoming_calls(response: &Value, enrichment: &mut SymbolEnrichment) {
if let Some(calls) = response.as_array() {
for call in calls {
if let Some(from) = call.get("from") {
enrichment.incoming_calls.push((
from.get("name")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
value_file_path(from),
value_start_line(from) + 1,
));
}
}
}
}
fn extract_subtypes(response: &Value, enrichment: &mut SymbolEnrichment) {
if let Some(subs) = response.as_array() {
for sub in subs {
enrichment.subtypes.push((
sub.get("name")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
value_file_path(sub),
value_start_line(sub) + 1,
));
}
}
}
fn format_line_ranges(lines: &[u32]) -> String {
if lines.is_empty() {
return String::new();
}
let mut sorted = lines.to_vec();
sorted.sort_unstable();
sorted.dedup();
let max_line = sorted[sorted.len() - 1];
let isqrt = u32::isqrt(max_line);
let merge_distance = if isqrt * isqrt == max_line {
isqrt
} else {
isqrt + 1
}
.max(1);
let mut ranges: Vec<String> = Vec::new();
let mut start = sorted[0];
let mut end = sorted[0];
for &line in &sorted[1..] {
if line - end > merge_distance {
ranges.push(format_single_range(start, end));
start = line;
}
end = line;
}
ranges.push(format_single_range(start, end));
ranges.join(" ")
}
fn format_single_range(start: u32, end: u32) -> String {
if start == end {
format!("L{start}")
} else {
format!("L{start}-L{end}")
}
}
fn format_symbol_references(
enrichment: &SymbolEnrichment,
def_file: &str,
def_line_0: u32,
fs: &FilesystemManager,
labeled_lines: &HashSet<(String, u32)>,
) -> String {
use std::fmt::Write;
let def_line_1 = def_line_0 + 1;
let mut output = String::new();
let mut files: Vec<&String> = enrichment.ref_lines.keys().collect();
files.sort();
for file in files {
let lines = &enrichment.ref_lines[file];
let is_def_file = file.as_str() == def_file;
let mut filtered: Vec<u32> = lines
.iter()
.copied()
.filter(|&l| {
if is_def_file && l == def_line_1 {
return false;
}
!labeled_lines.contains(&(file.clone(), l))
})
.collect();
if filtered.is_empty() {
continue;
}
filtered.sort_unstable();
let path = display_path(file, fs);
let _ = writeln!(output, "{path} {}", format_line_ranges(&filtered));
}
let trimmed_len = output.trim_end().len();
output.truncate(trimmed_len);
output
}
fn assign_rg_lines_to_symbols(
by_name: &BTreeMap<String, Vec<&SymbolInfo>>,
all_ref_lines: &HashMap<String, HashSet<u32>>,
rg: &RipgrepMatches,
) -> BTreeMap<String, Vec<(String, Vec<u32>)>> {
let mut result: BTreeMap<String, Vec<(String, Vec<u32>)>> = BTreeMap::new();
let mut claimed: HashMap<String, HashSet<u32>> = HashMap::new();
for (file, ref_set) in all_ref_lines {
claimed.entry(file.clone()).or_default().extend(ref_set);
}
for defs in by_name.values() {
for sym in defs {
claimed
.entry(sym.file_path.clone())
.or_default()
.insert(sym.line + 1);
}
}
let name_lower: Vec<(String, String)> = by_name
.keys()
.map(|n: &String| (n.clone(), n.to_lowercase()))
.collect();
for (file, lines) in &rg.file_lines {
let unclaimed: Vec<u32> = lines
.iter()
.copied()
.filter(|l| !claimed.get(file.as_str()).is_some_and(|s| s.contains(l)))
.collect();
if unclaimed.is_empty() {
continue;
}
let file_texts = rg.file_line_texts.get(file.as_str());
let mut heading_lines: BTreeMap<String, Vec<u32>> = BTreeMap::new();
for &line_num in &unclaimed {
let texts = file_texts.and_then(|ft| ft.get(&line_num));
let mut routed = false;
if let Some(texts) = texts {
for (matched_text, _col) in texts {
let mt_lower = matched_text.to_lowercase();
for (name, nl) in &name_lower {
if mt_lower == *nl || mt_lower.contains(nl.as_str()) {
heading_lines
.entry(name.clone())
.or_default()
.push(line_num);
routed = true;
break;
}
}
if !routed {
heading_lines
.entry(matched_text.clone())
.or_default()
.push(line_num);
routed = true;
}
if routed {
break;
}
}
}
if !routed {
heading_lines
.entry(String::new())
.or_default()
.push(line_num);
}
}
for (heading, lines) in heading_lines {
result
.entry(heading)
.or_default()
.push((file.clone(), lines));
}
}
result
}
#[cfg(test)]
#[allow(
clippy::expect_used,
reason = "tests use expect for readable assertions"
)]
mod tests {
use super::*;
#[test]
fn test_display_path_strips_root() {
let fs = FilesystemManager::new();
fs.set_roots(vec![PathBuf::from("/home/user/project")]);
assert_eq!(
display_path("/home/user/project/src/main.rs", &fs),
"src/main.rs"
);
}
#[test]
fn test_display_path_no_matching_root() {
let fs = FilesystemManager::new();
fs.set_roots(vec![PathBuf::from("/home/user/project")]);
assert_eq!(
display_path("/other/path/file.rs", &fs),
"/other/path/file.rs"
);
}
#[test]
fn test_format_line_ranges_empty() {
assert_eq!(format_line_ranges(&[]), "");
}
#[test]
fn test_format_line_ranges_single() {
assert_eq!(format_line_ranges(&[42]), "L42");
}
#[test]
fn test_format_line_ranges_consecutive() {
assert_eq!(format_line_ranges(&[10, 11, 12]), "L10-L12");
}
#[test]
fn test_format_line_ranges_disjoint() {
assert_eq!(format_line_ranges(&[5, 10, 20]), "L5-L10 L20");
}
#[test]
fn test_format_line_ranges_mixed() {
assert_eq!(format_line_ranges(&[1, 2, 3, 10, 11, 50]), "L1-L11 L50");
}
#[test]
fn test_format_line_ranges_unsorted() {
assert_eq!(format_line_ranges(&[20, 10, 11]), "L10-L11 L20");
}
#[test]
fn test_format_line_ranges_dbscan_nearby() {
assert_eq!(format_line_ranges(&[25, 30]), "L25-L30");
}
#[test]
fn test_format_line_ranges_dbscan_far_apart() {
assert_eq!(format_line_ranges(&[1, 1000]), "L1 L1000");
}
#[test]
fn test_format_line_ranges_dbscan_mixed() {
assert_eq!(format_line_ranges(&[5, 10, 14]), "L5 L10-L14");
}
#[test]
fn test_format_symbol_references_excludes_def() {
let mut ref_lines = HashMap::new();
ref_lines.insert("/src/lib.rs".to_string(), HashSet::from([1, 10, 20]));
let enrichment = SymbolEnrichment {
hover: None,
ref_lines,
..SymbolEnrichment::default()
};
let fs = FilesystemManager::new();
fs.set_roots(vec![PathBuf::from("/")]);
let labeled = HashSet::new();
let result = format_symbol_references(&enrichment, "/src/lib.rs", 0, &fs, &labeled);
assert!(result.contains("L10"));
assert!(result.contains("L20"));
assert!(
!result.contains("L1 "),
"Definition line should be excluded"
);
}
}