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.is_none_or(|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| {
names_match_ignoring_backticks(&s.name_path, np)
|| np
.replace('`', "")
.ends_with(&format!("/{}", s.name_path.replace('`', "")))
}) {
return Some(exact.end_line);
}
}
if matches.len() > 1 {
return None;
}
Some(matches[0].end_line)
}
pub(crate) fn names_match_ignoring_backticks(a: &str, b: &str) -> bool {
a == b || ((a.contains('`') || b.contains('`')) && a.replace('`', "") == b.replace('`', ""))
}
fn collect_ast_candidates<'a>(
symbols: &'a [SymbolInfo],
name: &str,
lsp_start: u32,
out: &mut Vec<&'a SymbolInfo>,
) {
for sym in symbols {
if names_match_ignoring_backticks(&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_for(ctx.workspace_override.as_deref())
.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;
}
}
}
if query.contains('`') || sym.name.contains('`') || sym.name_path.contains('`') {
let q_norm = query.replace('`', "");
for candidate in [sym.name.as_str(), sym.name_path.as_str()] {
if candidate.replace('`', "") == q_norm {
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(", ")
),
"Re-run with one of the fully-qualified name_paths listed above, copied verbatim — a trait-impl method is addressable as \"impl Trait for Type/method\"; the bare \"Type/method\" form stays ambiguous.",
)
.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
}
#[cfg(test)]
mod backtick_match_tests {
use super::*;
use std::path::Path;
#[test]
fn find_ast_end_line_in_resolves_kotlin_lsp_name_without_backticks() {
let source =
"class MyTest {\n fun `no penalty when x`() {\n val a = 1\n }\n}\n";
let ast_syms = crate::ast::parser::extract_symbols_from_source(
source,
Some("kotlin"),
Path::new("Test.kt"),
)
.unwrap();
let end = find_ast_end_line_in(
&ast_syms,
"no penalty when x",
1, Some("MyTest/no penalty when x"),
);
assert_eq!(
end,
Some(3),
"LSP name (no backticks) must resolve to AST symbol (backticks); got {end:?}"
);
}
#[test]
fn find_ast_end_line_in_resolves_nested_kotlin_symbols() {
let source =
"class Outer {\n class Inner {\n fun foo() {\n val a = 1\n }\n }\n}\n";
let ast_syms = crate::ast::parser::extract_symbols_from_source(
source,
Some("kotlin"),
Path::new("Test.kt"),
)
.unwrap();
let foo_end = find_ast_end_line_in(&ast_syms, "foo", 2, Some("Outer/Inner/foo"));
assert_eq!(
foo_end,
Some(4),
"nested method must resolve to its AST end line; got {foo_end:?}"
);
let inner_end = find_ast_end_line_in(&ast_syms, "Inner", 1, Some("Outer/Inner"));
assert_eq!(
inner_end,
Some(5),
"nested class must resolve to its AST end line; got {inner_end:?}"
);
}
#[test]
fn find_ast_end_line_in_resolves_ts_namespace_nested_symbol() {
let source = "namespace Outer {\n export class Inner {\n method(): void {\n const a = 1;\n }\n }\n}\n";
let ast_syms = crate::ast::parser::extract_symbols_from_source(
source,
Some("typescript"),
Path::new("ns.ts"),
)
.unwrap();
let method_end = find_ast_end_line_in(&ast_syms, "method", 2, Some("Outer/Inner/method"));
assert_eq!(
method_end,
Some(4),
"namespace-nested method must resolve to its AST end line; got {method_end:?}"
);
let outer_end = find_ast_end_line_in(&ast_syms, "Outer", 0, Some("Outer"));
assert_eq!(
outer_end,
Some(6),
"namespace must resolve to its AST end line; got {outer_end:?}"
);
}
}