use std::path::Path;
use anyhow::Context;
use serde_json::{json, Value};
use tracing::debug;
use crate::lang::go as lang_go;
use crate::lsp::client::{self, LspClient};
use crate::lsp::files::FileTracker;
#[derive(Debug, serde::Serialize)]
pub struct SymbolMatch {
pub path: String,
pub line: u32,
pub kind: String,
pub preview: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
}
pub async fn find_symbol(
name: &str,
client: &mut LspClient,
project_root: &Path,
) -> anyhow::Result<Vec<SymbolMatch>> {
let params = json!({ "query": name });
let request_id = client
.transport_mut()
.send_request("workspace/symbol", params)
.await?;
let response = client
.wait_for_response_public(request_id)
.await
.context("workspace/symbol request failed")?;
Ok(parse_symbol_results(&response, name, project_root))
}
const HEADER_EXTS: &[&str] = &["h", "hpp", "hxx", "hh"];
pub async fn resolve_symbol_location(
name: &str,
client: &mut LspClient,
project_root: &Path,
) -> anyhow::Result<(std::path::PathBuf, u32, u32)> {
let lsp_symbols = find_symbol(name, client, project_root).await?;
let symbols = if lsp_symbols.is_empty() {
let name_owned = name.to_string();
let root = project_root.to_path_buf();
tokio::task::spawn_blocking(move || text_search_find_symbol(&name_owned, &root))
.await
.unwrap_or_default()
} else {
lsp_symbols
};
let symbol = symbols
.iter()
.find(|s| {
let ext = std::path::Path::new(&s.path)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
!HEADER_EXTS.contains(&ext)
})
.or_else(|| symbols.first())
.with_context(|| format!("symbol '{name}' not found"))?;
let abs_path = project_root.join(&symbol.path);
let (line_0, char_0) = find_name_position(&abs_path, symbol.line, name);
Ok((abs_path, line_0, char_0))
}
#[must_use]
pub fn text_search_find_symbol(name: &str, project_root: &Path) -> Vec<SymbolMatch> {
use crate::commands::search::{run as search_run, SearchOptions};
let opts = SearchOptions {
pattern: name.to_string(),
path: None,
ignore_case: false,
word: true,
literal: true,
context: 0,
files_only: false,
lang_filter: None,
max_matches: 50,
};
let Ok(output) = search_run(&opts, project_root) else {
return vec![];
};
output
.matches
.into_iter()
.filter_map(|m| {
classify_definition(&m.preview, name).map(|kind| SymbolMatch {
kind: kind.to_string(),
path: m.path,
line: m.line,
preview: m.preview,
body: None,
})
})
.collect()
}
#[must_use]
pub fn text_search_find_refs(name: &str, project_root: &Path) -> Vec<ReferenceMatch> {
use crate::commands::search::{run as search_run, SearchOptions};
let opts = SearchOptions {
pattern: name.to_string(),
path: None,
ignore_case: false,
word: true,
literal: true,
context: 0,
files_only: false,
lang_filter: None,
max_matches: 200,
};
let Ok(output) = search_run(&opts, project_root) else {
return vec![];
};
let mut results: Vec<ReferenceMatch> = output
.matches
.into_iter()
.map(|m| {
let is_definition = classify_definition(&m.preview, name).is_some();
ReferenceMatch {
path: m.path,
line: m.line,
preview: m.preview,
is_definition,
containing_symbol: None,
}
})
.collect();
results.sort_by(|a, b| {
b.is_definition
.cmp(&a.is_definition)
.then(a.path.cmp(&b.path))
.then(a.line.cmp(&b.line))
});
results
}
fn classify_definition<'a>(line: &str, name: &str) -> Option<&'a str> {
let trimmed = line.trim();
let name_pos = trimmed.find(name)?;
let word_before = trimmed[..name_pos].split_whitespace().last().unwrap_or("");
let kind = match word_before {
"const" | "let" | "var" => "constant",
"function" | "fn" | "def" | "async" => "function",
"class" => "class",
"interface" => "interface",
"type" => "type_alias",
"struct" | "enum" => "struct",
_ => return None,
};
Some(kind)
}
async fn find_go_receiver_method(
name: &str,
client: &mut LspClient,
project_root: &Path,
) -> anyhow::Result<Option<(SymbolMatch, String)>> {
let Some(dot) = name.find('.') else {
return Ok(None);
};
let receiver = &name[..dot];
let method = &name[dot + 1..];
let params = json!({ "query": method });
let request_id = client
.transport_mut()
.send_request("workspace/symbol", params)
.await?;
let response = client
.wait_for_response_public(request_id)
.await
.context("workspace/symbol request failed")?;
let Some(items) = response.as_array() else {
return Ok(None);
};
debug!(
"find_go_receiver_method: got {} items for query '{method}'",
items.len()
);
for item in items {
let sym_name = item.get("name").and_then(Value::as_str).unwrap_or_default();
let matches = crate::lang::go::receiver_method_matches(sym_name, receiver, method);
debug!(" sym_name={sym_name:?} receiver_method_matches({receiver},{method})={matches}");
if !matches {
continue;
}
let (path, line) = extract_location(item, project_root);
let preview = read_line_preview(&project_root.join(&path), line);
let kind =
symbol_kind_name(item.get("kind").and_then(Value::as_u64).unwrap_or(0)).to_string();
return Ok(Some((
SymbolMatch {
path,
line,
kind,
preview,
body: None,
},
method.to_string(),
)));
}
debug!("find_go_receiver_method: no match found for '{name}'");
Ok(None)
}
pub async fn find_refs(
name: &str,
client: &mut LspClient,
file_tracker: &mut FileTracker,
project_root: &Path,
) -> anyhow::Result<Vec<ReferenceMatch>> {
let symbols = find_symbol(name, client, project_root).await?;
debug!(
"find_refs: find_symbol('{name}') returned {} results",
symbols.len()
);
let (symbol, token) = if let Some(sym) = symbols.into_iter().next() {
debug!("find_refs: direct match at {}:{}", sym.path, sym.line);
let token = if Path::new(&sym.path).extension().and_then(|e| e.to_str()) == Some("go")
&& name.contains('.')
{
name.rsplit('.').next().unwrap_or(name).to_string()
} else {
name.to_string()
};
(sym, token)
} else if name.contains('.') {
debug!("find_refs: no direct match, trying go receiver method path");
let result = find_go_receiver_method(name, client, project_root).await?;
debug!(
"find_refs: find_go_receiver_method returned: {}",
result.is_some()
);
result.with_context(|| format!("symbol '{name}' not found"))?
} else {
anyhow::bail!("symbol '{name}' not found");
};
let abs_path = project_root.join(&symbol.path);
let was_already_open = file_tracker.is_open(&abs_path);
file_tracker
.ensure_open(&abs_path, client.transport_mut())
.await?;
if !was_already_open {
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
}
let uri = crate::lsp::client::path_to_uri(&abs_path)?;
let (ref_line, ref_char) = find_name_position(&abs_path, symbol.line, &token);
let params = json!({
"textDocument": { "uri": uri.as_str() },
"position": { "line": ref_line, "character": ref_char },
"context": { "includeDeclaration": true }
});
let request_id = client
.transport_mut()
.send_request("textDocument/references", params)
.await?;
let response = client
.wait_for_response_public(request_id)
.await
.context("textDocument/references request failed")?;
Ok(parse_reference_results(
&response,
&symbol.path,
symbol.line,
project_root,
))
}
#[derive(Debug, serde::Serialize)]
pub struct ContainingSymbol {
pub name: String,
pub kind: String,
pub line: u32,
}
#[derive(Debug, serde::Serialize)]
pub struct ReferenceMatch {
pub path: String,
pub line: u32,
pub preview: String,
pub is_definition: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub containing_symbol: Option<ContainingSymbol>,
}
#[must_use]
pub fn find_innermost_containing(
symbols: &[crate::commands::list::SymbolEntry],
line: u32,
) -> Option<ContainingSymbol> {
for sym in symbols {
if sym.line <= line && line <= sym.end_line {
if !sym.children.is_empty() {
if let Some(child) = find_innermost_containing(&sym.children, line) {
return Some(child);
}
}
return Some(ContainingSymbol {
name: sym.name.clone(),
kind: sym.kind.clone(),
line: sym.line,
});
}
}
None
}
fn parse_symbol_results(response: &Value, query: &str, project_root: &Path) -> Vec<SymbolMatch> {
let Some(items) = response.as_array() else {
return Vec::new();
};
let mut results = Vec::new();
for item in items {
let name = item.get("name").and_then(Value::as_str).unwrap_or_default();
let match_name = lang_go::base_name(name);
if !match_name.eq_ignore_ascii_case(query) && !match_name.starts_with(query) {
continue;
}
let kind = symbol_kind_name(item.get("kind").and_then(Value::as_u64).unwrap_or(0));
let (path, line) = extract_location(item, project_root);
let preview = read_line_preview(&project_root.join(&path), line);
results.push(SymbolMatch {
path,
line,
kind: kind.to_string(),
preview,
body: None,
});
}
results.sort_by(|a, b| a.path.cmp(&b.path).then(a.line.cmp(&b.line)));
results
}
const EXCLUDED_DIRS: &[&str] = &[
"target/",
".git/",
"node_modules/",
".mypy_cache/",
"__pycache__/",
".cache/",
"dist/",
"build/",
".next/",
".nuxt/",
];
fn parse_reference_results(
response: &Value,
def_path: &str,
def_line: u32,
project_root: &Path,
) -> Vec<ReferenceMatch> {
let Some(locations) = response.as_array() else {
return Vec::new();
};
let mut results = Vec::new();
for loc in locations {
let uri = loc.get("uri").and_then(Value::as_str).unwrap_or_default();
#[allow(clippy::cast_possible_truncation)]
let line = loc
.pointer("/range/start/line")
.and_then(Value::as_u64)
.unwrap_or(0) as u32
+ 1;
let path = uri_to_relative_path(uri, project_root);
if EXCLUDED_DIRS
.iter()
.any(|dir| path.starts_with(dir) || path.contains(&format!("/{dir}")))
{
continue;
}
let abs_path = project_root.join(&path);
let preview = read_line_preview(&abs_path, line);
let is_definition = path == def_path && line == def_line;
results.push(ReferenceMatch {
path,
line,
preview,
is_definition,
containing_symbol: None,
});
}
results.sort_by(|a, b| {
b.is_definition
.cmp(&a.is_definition)
.then(a.path.cmp(&b.path))
.then(a.line.cmp(&b.line))
});
results
}
fn extract_location(item: &Value, project_root: &Path) -> (String, u32) {
let uri = item
.pointer("/location/uri")
.and_then(Value::as_str)
.unwrap_or_default();
#[allow(clippy::cast_possible_truncation)]
let line = item
.pointer("/location/range/start/line")
.and_then(Value::as_u64)
.unwrap_or(0) as u32
+ 1;
(uri_to_relative_path(uri, project_root), line)
}
fn uri_to_relative_path(uri: &str, project_root: &Path) -> String {
let path = uri.strip_prefix("file://").unwrap_or(uri);
let abs = Path::new(path);
abs.strip_prefix(project_root)
.unwrap_or(abs)
.to_string_lossy()
.to_string()
}
#[allow(clippy::cast_possible_truncation)]
fn find_name_position(path: &Path, line: u32, name: &str) -> (u32, u32) {
let Some(content) = std::fs::read_to_string(path).ok() else {
return (line.saturating_sub(1), 0);
};
let lines: Vec<&str> = content.lines().collect();
let start = line.saturating_sub(1) as usize;
for offset in 0..4 {
let idx = start + offset;
if idx >= lines.len() {
break;
}
if let Some(col) = lines[idx].find(name) {
return (idx as u32, col as u32);
}
}
(line.saturating_sub(1), 0)
}
fn read_line_preview(path: &Path, line: u32) -> String {
std::fs::read_to_string(path)
.ok()
.and_then(|content| {
content
.lines()
.nth(line.saturating_sub(1) as usize)
.map(|l| l.trim().to_string())
})
.unwrap_or_default()
}
#[must_use]
pub fn extract_symbol_body(path: &Path, start_line: u32) -> Option<String> {
let content = std::fs::read_to_string(path).ok()?;
let lines: Vec<&str> = content.lines().collect();
let start = start_line.saturating_sub(1) as usize;
if start >= lines.len() {
return None;
}
let mut depth: i32 = 0;
let mut found_open = false;
let mut end = start;
for (i, line) in lines[start..].iter().enumerate() {
let idx = start + i;
for ch in line.chars() {
match ch {
'{' => {
depth += 1;
found_open = true;
}
'}' => {
depth -= 1;
}
_ => {}
}
}
end = idx;
if !found_open && line.trim_end().ends_with(';') {
break;
}
if found_open && depth <= 0 {
break;
}
if i >= 199 {
break;
}
}
let body: Vec<&str> = lines[start..=end].to_vec();
Some(body.join("\n"))
}
pub async fn find_impl(
name: &str,
lsp_client: &mut LspClient,
file_tracker: &mut FileTracker,
project_root: &Path,
) -> anyhow::Result<Vec<SymbolMatch>> {
let symbols = find_symbol(name, lsp_client, project_root).await?;
let symbol = symbols
.first()
.with_context(|| format!("symbol '{name}' not found"))?;
let abs_path = project_root.join(&symbol.path);
let was_open = file_tracker.is_open(&abs_path);
file_tracker
.ensure_open(&abs_path, lsp_client.transport_mut())
.await?;
if !was_open {
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
}
let (line_0, char_0) = find_name_position(&abs_path, symbol.line, name);
let uri = client::path_to_uri(&abs_path)?;
let params = json!({
"textDocument": { "uri": uri.as_str() },
"position": { "line": line_0, "character": char_0 }
});
let request_id = lsp_client
.transport_mut()
.send_request("textDocument/implementation", params)
.await?;
let response = lsp_client
.wait_for_response_public(request_id)
.await
.context("textDocument/implementation request failed")?;
let results = parse_impl_results(&response, project_root);
if !results.is_empty() {
return Ok(results);
}
find_impl_via_refs(name, symbol, lsp_client, file_tracker, project_root).await
}
async fn find_impl_via_refs(
name: &str,
interface_symbol: &SymbolMatch,
client: &mut LspClient,
file_tracker: &mut FileTracker,
project_root: &Path,
) -> anyhow::Result<Vec<SymbolMatch>> {
let refs = find_refs(name, client, file_tracker, project_root).await?;
let results: Vec<SymbolMatch> = refs
.into_iter()
.filter(|r| {
if r.is_definition {
return false;
}
let trimmed = r.preview.trim_start();
trimmed.starts_with("func ")
|| trimmed.starts_with("function ")
|| trimmed.starts_with("async function ")
|| (trimmed.contains(name) && trimmed.ends_with('{'))
})
.filter(|r| {
!(r.path == interface_symbol.path && r.line == interface_symbol.line)
})
.map(|r| SymbolMatch {
path: r.path,
line: r.line,
kind: "implementation".to_string(),
preview: r.preview,
body: None,
})
.collect();
Ok(results)
}
fn parse_impl_results(response: &Value, project_root: &Path) -> Vec<SymbolMatch> {
let Some(items) = response.as_array() else {
return Vec::new();
};
let mut results = Vec::new();
for item in items {
let uri = item
.get("uri")
.or_else(|| item.get("targetUri"))
.and_then(Value::as_str)
.unwrap_or_default();
#[allow(clippy::cast_possible_truncation)]
let line = item
.pointer("/range/start/line")
.or_else(|| item.pointer("/targetRange/start/line"))
.and_then(Value::as_u64)
.unwrap_or(0) as u32
+ 1;
let path = uri_to_relative_path(uri, project_root);
let abs_path = project_root.join(&path);
let preview = read_line_preview(&abs_path, line);
results.push(SymbolMatch {
path,
line,
kind: "implementation".to_string(),
preview,
body: None,
});
}
results.sort_by(|a, b| a.path.cmp(&b.path).then(a.line.cmp(&b.line)));
results
}
#[must_use]
pub fn symbol_kind_name(kind: u64) -> &'static str {
match kind {
1 => "file",
2 => "module",
3 => "namespace",
4 => "package",
5 => "class",
6 => "method",
7 => "property",
8 => "field",
9 => "constructor",
10 => "enum",
11 => "interface",
12 => "function",
13 => "variable",
14 => "constant",
15 => "string",
16 => "number",
17 => "boolean",
18 => "array",
19 => "object",
20 => "key",
21 => "null",
22 => "enum_member",
23 => "struct",
24 => "event",
25 => "operator",
26 => "type_parameter",
_ => "unknown",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn symbol_kind_function() {
assert_eq!(symbol_kind_name(12), "function");
}
#[test]
fn symbol_kind_struct() {
assert_eq!(symbol_kind_name(23), "struct");
}
#[test]
fn uri_to_relative() {
let root = Path::new("/home/user/project");
let uri = "file:///home/user/project/src/lib.rs";
assert_eq!(uri_to_relative_path(uri, root), "src/lib.rs");
}
#[test]
fn uri_to_relative_outside_project() {
let root = Path::new("/home/user/project");
let uri = "file:///other/path/lib.rs";
assert_eq!(uri_to_relative_path(uri, root), "/other/path/lib.rs");
}
#[test]
fn parse_empty_symbol_results() {
let results = parse_symbol_results(&json!(null), "test", Path::new("/tmp"));
assert!(results.is_empty());
}
#[test]
fn parse_empty_reference_results() {
let results = parse_reference_results(&json!(null), "src/lib.rs", 1, Path::new("/tmp"));
assert!(results.is_empty());
}
#[test]
fn find_name_position_does_not_match_substring() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test.ts");
std::fs::write(&file, "function renewed() {\n return new Thing();\n}").unwrap();
let (line, col) = find_name_position(&file, 1, "new");
assert!(line < 3, "line should be within search window");
let _ = col; }
#[test]
fn classify_definition_recognises_const() {
assert_eq!(
classify_definition(
"export const createPromotionsStep = createStep(",
"createPromotionsStep"
),
Some("constant")
);
assert_eq!(
classify_definition("const foo = 1;", "foo"),
Some("constant")
);
}
#[test]
fn classify_definition_recognises_function() {
assert_eq!(
classify_definition("function greet(name: string) {", "greet"),
Some("function")
);
assert_eq!(
classify_definition("export function handleRequest(req) {", "handleRequest"),
Some("function")
);
assert_eq!(
classify_definition("pub fn run() -> Result<()> {", "run"),
Some("function")
);
}
#[test]
fn classify_definition_rejects_call_sites() {
assert_eq!(
classify_definition(
"const result = createPromotionsStep(data)",
"createPromotionsStep"
),
None
);
assert_eq!(
classify_definition(
"import { createPromotionsStep } from '../steps'",
"createPromotionsStep"
),
None
);
assert_eq!(
classify_definition("return createPromotionsStep(data)", "createPromotionsStep"),
None
);
}
#[test]
fn text_search_find_symbol_finds_const_export() {
use std::fs;
use tempfile::tempdir;
let dir = tempdir().unwrap();
fs::write(
dir.path().join("step.ts"),
"export const createPromotionsStep = createStep(\n stepId,\n async () => {}\n);\n",
)
.unwrap();
fs::write(
dir.path().join("workflow.ts"),
"import { createPromotionsStep } from './step';\nconst result = createPromotionsStep(data);\n",
).unwrap();
let results = text_search_find_symbol("createPromotionsStep", dir.path());
assert_eq!(results.len(), 1);
assert!(results[0].path.ends_with("step.ts"));
assert_eq!(results[0].line, 1);
assert_eq!(results[0].kind, "constant");
}
#[test]
fn text_search_find_refs_returns_all_occurrences() {
use std::fs;
use tempfile::tempdir;
let dir = tempdir().unwrap();
fs::write(
dir.path().join("step.ts"),
"export const createPromotionsStep = createStep(stepId, async () => {});\n",
)
.unwrap();
fs::write(
dir.path().join("workflow.ts"),
"import { createPromotionsStep } from './step';\nconst out = createPromotionsStep(data);\n",
).unwrap();
let results = text_search_find_refs("createPromotionsStep", dir.path());
assert_eq!(results.len(), 3);
assert!(results[0].is_definition);
}
#[test]
fn classify_definition_detects_kinds() {
assert_eq!(
classify_definition("export class MyClass {", "MyClass"),
Some("class")
);
assert_eq!(
classify_definition("export function doThing() {", "doThing"),
Some("function")
);
assert_eq!(
classify_definition("pub fn run() -> Result<()> {", "run"),
Some("function")
);
assert_eq!(
classify_definition("export const MY_CONST = 42", "MY_CONST"),
Some("constant")
);
assert_eq!(
classify_definition("export interface IService {", "IService"),
Some("interface")
);
assert_eq!(
classify_definition("pub struct Config {", "Config"),
Some("struct")
);
assert_eq!(
classify_definition("type MyAlias = string;", "MyAlias"),
Some("type_alias")
);
}
#[test]
fn command_to_request_find_symbol() {
use crate::cli::{Command, FindCommand};
use crate::client::command_to_request;
use crate::protocol::Request;
let cmd = Command::Find(FindCommand::Symbol {
name: "MyStruct".into(),
path: None,
src_only: false,
include_body: false,
});
let req = command_to_request(&cmd);
assert!(matches!(req, Request::FindSymbol { name, .. } if name == "MyStruct"));
}
#[test]
fn command_to_request_find_refs() {
use crate::cli::{Command, FindCommand};
use crate::client::command_to_request;
use crate::protocol::Request;
let cmd = Command::Find(FindCommand::Refs {
name: "my_func".into(),
with_symbol: false,
});
let req = command_to_request(&cmd);
assert!(matches!(req, Request::FindRefs { name, .. } if name == "my_func"));
}
}