use serde_json::{json, Value};
use crate::lsp::SymbolInfo;
use crate::tools::RecoverableError;
use crate::tools::ToolContext;
pub fn matches_kind_filter(kind: &crate::lsp::SymbolKind, filter: &str) -> bool {
use crate::lsp::SymbolKind as K;
match filter {
"function" => matches!(kind, K::Function | K::Method | K::Constructor),
"class" => matches!(kind, K::Class),
"struct" => matches!(kind, K::Struct),
"interface" => matches!(kind, K::Interface),
"type" => matches!(kind, K::TypeParameter),
"enum" => matches!(kind, K::Enum | K::EnumMember),
"module" => matches!(kind, K::Module | K::Namespace | K::Package),
"constant" => matches!(kind, K::Constant),
_ => true,
}
}
pub fn filter_variable_symbols(symbols: Vec<Value>) -> Vec<Value> {
symbols
.into_iter()
.filter(|s| s["kind"].as_str() != Some("Variable"))
.map(|mut s| {
if let Some(children) = s["children"].as_array().cloned() {
let filtered = filter_variable_symbols(children);
if filtered.is_empty() {
s.as_object_mut().unwrap().remove("children");
} else {
s["children"] = json!(filtered);
}
}
s
})
.collect()
}
#[allow(clippy::too_many_arguments)]
pub fn collect_matching(
symbols: &[SymbolInfo],
name_ok: &dyn Fn(&SymbolInfo) -> bool,
include_body: bool,
source_code: Option<&str>,
depth: usize,
show_file: bool,
out: &mut Vec<Value>,
kind_filter: Option<&str>,
) {
for sym in symbols {
let kind_ok = kind_filter.map_or(true, |f| matches_kind_filter(&sym.kind, f));
let pushed = name_ok(sym) && kind_ok;
if pushed {
out.push(symbol_to_json(
sym,
include_body,
source_code,
depth,
show_file,
));
}
let suppress = pushed
&& matches!(
sym.kind,
crate::lsp::SymbolKind::Function
| crate::lsp::SymbolKind::Method
| crate::lsp::SymbolKind::Constructor
);
if !suppress {
collect_matching(
&sym.children,
name_ok,
include_body,
source_code,
depth,
show_file,
out,
kind_filter,
);
}
}
}
pub fn symbol_to_json(
sym: &SymbolInfo,
include_body: bool,
source_code: Option<&str>,
depth: usize,
show_file: bool,
) -> Value {
let mut map = serde_json::Map::new();
map.insert("name".into(), json!(sym.name));
map.insert("symbol".into(), json!(sym.name_path));
map.insert("kind".into(), json!(format!("{:?}", sym.kind)));
if show_file {
map.insert("file".into(), json!(sym.file.display().to_string()));
}
if let Some(sig) = &sym.detail {
map.insert("signature".into(), json!(sig));
}
if include_body {
if let Some(src) = source_code {
let lines: Vec<&str> = src.lines().collect();
let body_start = crate::symbol::edit::editing_start_line(sym, &lines);
let end = (sym.end_line as usize + 1).min(lines.len());
if body_start < lines.len() {
map.insert("body".into(), json!(lines[body_start..end].join("\n")));
map.insert("body_start_line".into(), json!(body_start + 1));
}
}
}
if depth > 0 && !sym.children.is_empty() {
map.insert(
"children".into(),
json!(sym
.children
.iter()
.map(|c| symbol_to_json(c, include_body, source_code, depth - 1, show_file))
.collect::<Vec<_>>()),
);
}
map.insert("start_line".into(), json!(sym.start_line + 1));
map.insert("end_line".into(), json!(sym.end_line + 1));
Value::Object(map)
}
pub fn validate_symbol_range(sym: &SymbolInfo) -> anyhow::Result<()> {
let Ok(source) = std::fs::read_to_string(&sym.file) else {
return Ok(());
};
let lang = crate::ast::detect_language(&sym.file);
if let Some(lang) = lang {
if crate::ast::has_syntax_errors(&source, lang) {
return Ok(());
}
}
let Ok(ast_syms) = crate::ast::parser::extract_symbols_from_source(&source, lang, &sym.file)
else {
return Ok(());
};
if let Some(ast_end) =
find_ast_end_line_in(&ast_syms, &sym.name, sym.start_line, Some(&sym.name_path))
{
if ast_end > sym.end_line {
anyhow::bail!(RecoverableError::with_hint(
format!(
"LSP returned suspicious range for '{}' (lines {}-{}, but AST shows it spans to line {})",
sym.name,
sym.start_line + 1,
sym.end_line + 1,
ast_end + 1,
),
"The LSP server may have returned a selection range instead of the full symbol range. \
Try edit_file for this symbol, or check symbols(path) to verify the range.",
));
}
}
Ok(())
}
pub fn validate_symbol_position(sym: &SymbolInfo, lines: &[&str]) -> anyhow::Result<()> {
let sl = sym.start_line as usize;
if sl >= lines.len() {
return Err(RecoverableError::with_hint(
format!(
"symbol '{}' at line {} is beyond file end ({} lines)",
sym.name,
sl + 1,
lines.len(),
),
"The LSP may have stale data after a prior edit. \
Call symbols(path) to refresh, then retry.",
)
.into());
}
let start_text = lines[sl];
if start_text.contains(&*sym.name) {
return Ok(());
}
if !is_lead_in_line(start_text) {
return Err(RecoverableError::with_hint(
format!(
"symbol '{}' expected near line {} but that line is unrelated code — \
LSP positions are likely stale after a prior edit",
sym.name,
sl + 1,
),
"The file was recently modified and the LSP hasn't re-indexed yet. \
Call symbols(path) to refresh, then retry the operation.",
)
.into());
}
let trimmed_start = start_text.trim_start();
let is_bare_star_continuation = trimmed_start.starts_with('*')
&& !trimmed_start.starts_with("/**")
&& !trimmed_start.starts_with("/*")
&& trimmed_start != "*/";
if is_bare_star_continuation {
let lookback_end = sl;
let lookback_start = sl.saturating_sub(64);
let opener_found = lines[lookback_start..lookback_end]
.iter()
.rev()
.take_while(|l| {
let t = l.trim_start();
t.is_empty() || t.starts_with('*') || t.starts_with("/**") || t.starts_with("/*")
})
.any(|l| {
let t = l.trim_start();
t.starts_with("/**") || t.starts_with("/*")
});
if !opener_found {
return Err(RecoverableError::with_hint(
format!(
"symbol '{}' at line {} is on a block-comment continuation ('*') \
with no '/**' or '/*' opener visible above — writing here would \
orphan the comment",
sym.name,
sl + 1,
),
"The LSP returned a position inside a comment. Refresh via \
symbols(path) and retry; if this persists, use edit_file \
for this symbol.",
)
.into());
}
}
let check_end = (sl + 6).min(lines.len());
let found = lines[sl..check_end].iter().any(|l| l.contains(&*sym.name));
if !found {
return Err(RecoverableError::with_hint(
format!(
"symbol '{}' expected within 6 lines of line {} but not found — \
LSP positions are likely stale after a prior edit",
sym.name,
sl + 1,
),
"The file was recently modified and the LSP hasn't re-indexed yet. \
Call symbols(path) to refresh, then retry the operation.",
)
.into());
}
Ok(())
}
pub fn is_lead_in_line(line: &str) -> bool {
let trimmed = line.trim();
if trimmed.is_empty() {
return true;
}
if trimmed.starts_with("//")
|| trimmed.starts_with("/*")
|| trimmed.starts_with('*')
|| trimmed == "*/"
{
return true;
}
if trimmed.starts_with('@') || trimmed.starts_with("#[") || trimmed.starts_with("#!") {
return true;
}
trimmed
.chars()
.all(|c| matches!(c, '}' | ')' | ']' | ';' | ',' | '?' | '>' | ' ' | '\t'))
}
pub fn find_ast_end_line_in(
symbols: &[SymbolInfo],
name: &str,
lsp_start: u32,
name_path: Option<&str>,
) -> Option<u32> {
let mut matches: Vec<&SymbolInfo> = Vec::new();
collect_ast_candidates(symbols, name, lsp_start, &mut matches);
if matches.is_empty() {
return None;
}
if let Some(np) = name_path {
if let Some(exact) = matches
.iter()
.find(|s| s.name_path == np || np.ends_with(&format!("/{}", s.name_path)))
{
return Some(exact.end_line);
}
}
if matches.len() > 1 {
return None;
}
Some(matches[0].end_line)
}
fn collect_ast_candidates<'a>(
symbols: &'a [SymbolInfo],
name: &str,
lsp_start: u32,
out: &mut Vec<&'a SymbolInfo>,
) {
for sym in symbols {
if sym.name == name && sym.start_line.abs_diff(lsp_start) <= 1 {
out.push(sym);
}
collect_ast_candidates(&sym.children, name, lsp_start, out);
}
}
pub async fn fetch_validated_symbol(
client: &std::sync::Arc<dyn crate::lsp::LspClientOps>,
path: &std::path::Path,
lang: &str,
name_path: &str,
) -> anyhow::Result<(SymbolInfo, Vec<SymbolInfo>)> {
const MAX_RETRIES: u32 = 3;
let mut last_err: Option<anyhow::Error> = None;
for attempt in 0..MAX_RETRIES {
let attempt_result = async {
let symbols = client.document_symbols(path, lang).await?;
let sym = find_unique_symbol_by_name_path(&symbols, name_path)?.clone();
validate_symbol_range(&sym)?;
let content = std::fs::read_to_string(path)?;
let lines: Vec<&str> = content.lines().collect();
validate_symbol_position(&sym, &lines)?;
anyhow::Ok((sym, symbols))
}
.await;
match attempt_result {
Ok(pair) => return Ok(pair),
Err(e) => {
last_err = Some(e);
if attempt < MAX_RETRIES - 1 {
let _ = client.did_change(path).await;
let backoff_ms = 50u64 * (attempt as u64 + 1);
tokio::time::sleep(std::time::Duration::from_millis(backoff_ms)).await;
}
}
}
}
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("fetch_validated_symbol: no error recorded")))
}
pub fn count_symbols_by_name_path(symbols: &[SymbolInfo], name_path: &str) -> usize {
symbols
.iter()
.map(|s| {
let self_hit = if s.name_path == name_path { 1 } else { 0 };
self_hit + count_symbols_by_name_path(&s.children, name_path)
})
.sum()
}
pub async fn resolve_range_via_document_symbols(
sym: &SymbolInfo,
ctx: &ToolContext,
) -> Option<SymbolInfo> {
let lang = crate::ast::detect_language(&sym.file)?;
let language_id = crate::lsp::servers::lsp_language_id(lang);
let root = ctx.agent.require_project_root().await.ok()?;
let mux_override = ctx.agent.lsp_mux_override(lang).await;
let client = ctx.lsp.get_or_start(lang, &root, mux_override).await.ok()?;
let doc_symbols = client.document_symbols(&sym.file, language_id).await.ok()?;
find_matching_symbol(&doc_symbols, &sym.name, sym.start_line)
}
pub fn find_matching_symbol(
symbols: &[SymbolInfo],
name: &str,
lsp_start: u32,
) -> Option<SymbolInfo> {
for sym in symbols {
if sym.name == name && sym.start_line.abs_diff(lsp_start) <= 1 {
return Some(sym.clone());
}
if let Some(found) = find_matching_symbol(&sym.children, name, lsp_start) {
return Some(found);
}
}
None
}
pub fn symbol_name_matches(sym: &SymbolInfo, query: &str) -> bool {
if sym.name_path == query || sym.name == query {
return true;
}
for candidate in [sym.name.as_str(), sym.name_path.as_str()] {
if candidate.starts_with(query) {
if let Some(&next) = candidate.as_bytes().get(query.len()) {
if matches!(next, b'<' | b'(' | b' ') {
return true;
}
}
}
}
for candidate in [sym.name.as_str(), sym.name_path.as_str()] {
if candidate.len() > query.len() && candidate.ends_with(query) {
let boundary = candidate.as_bytes()[candidate.len() - query.len() - 1];
if matches!(boundary, b' ' | b'/' | b':') {
return true;
}
}
}
false
}
#[cfg(test)]
pub fn find_symbol_by_name_path<'a>(
symbols: &'a [SymbolInfo],
name_path: &str,
) -> Option<&'a SymbolInfo> {
for sym in symbols {
if symbol_name_matches(sym, name_path) {
return Some(sym);
}
if let Some(found) = find_symbol_by_name_path(&sym.children, name_path) {
return Some(found);
}
}
None
}
pub fn find_unique_symbol_by_name_path<'a>(
symbols: &'a [SymbolInfo],
name_path: &str,
) -> anyhow::Result<&'a SymbolInfo> {
let matches = collect_matching_symbols(symbols, name_path);
match matches.len() {
0 => {
let leaf = name_path.rsplit('/').next().unwrap_or(name_path);
let message = if leaf != name_path {
let suggestions: Vec<String> = collect_matching_symbols(symbols, leaf)
.into_iter()
.take(3)
.map(|s| format!("'{}'", s.name_path))
.collect();
if suggestions.is_empty() {
format!("symbol not found: {name_path}")
} else {
format!(
"symbol not found: {name_path} — did you mean {}?",
suggestions.join(", ")
)
}
} else {
format!("symbol not found: {name_path}")
};
Err(RecoverableError::with_hint(
message,
"Use symbols(path) to list symbols. Trait impl methods use format 'impl Trait for Struct/method'.",
)
.into())
}
1 => Ok(matches.into_iter().next().unwrap()),
_ => {
let exact: Vec<_> = matches
.iter()
.copied()
.filter(|s| s.name_path == name_path)
.collect();
if exact.len() == 1 {
return Ok(exact.into_iter().next().unwrap());
}
let paths: Vec<String> = matches.iter().map(|s| s.name_path.clone()).collect();
Err(RecoverableError::with_hint(
format!(
"ambiguous name_path \"{name_path}\" matches {} symbols: {}",
paths.len(),
paths.join(", ")
),
"Provide the full name_path (e.g. \"StructName/method_name\") to disambiguate.",
)
.into())
}
}
}
pub fn collect_matching_symbols<'a>(
symbols: &'a [SymbolInfo],
name_path: &str,
) -> Vec<&'a SymbolInfo> {
let mut results = Vec::new();
for sym in symbols {
if symbol_name_matches(sym, name_path) {
results.push(sym);
}
results.extend(collect_matching_symbols(&sym.children, name_path));
}
results
}