1use std::path::Path;
2
3use anyhow::Context;
4use serde_json::{json, Value};
5
6use crate::lang::go as lang_go;
7use crate::lsp::client::{self, LspClient};
8use crate::lsp::files::FileTracker;
9
10#[derive(Debug, serde::Serialize)]
12pub struct SymbolMatch {
13 pub path: String,
14 pub line: u32,
15 pub kind: String,
16 pub preview: String,
17 #[serde(skip_serializing_if = "Option::is_none")]
19 pub body: Option<String>,
20}
21
22pub async fn find_symbol(
30 name: &str,
31 client: &mut LspClient,
32 project_root: &Path,
33) -> anyhow::Result<Vec<SymbolMatch>> {
34 let params = json!({ "query": name });
35 let request_id = client
36 .transport_mut()
37 .send_request("workspace/symbol", params)
38 .await?;
39
40 let response = client
41 .wait_for_response_public(request_id)
42 .await
43 .context("workspace/symbol request failed")?;
44
45 Ok(parse_symbol_results(&response, name, project_root))
46}
47
48pub async fn resolve_symbol_location(
56 name: &str,
57 client: &mut LspClient,
58 project_root: &Path,
59) -> anyhow::Result<(std::path::PathBuf, u32, u32)> {
60 let lsp_symbols = find_symbol(name, client, project_root).await?;
61 let symbols = if lsp_symbols.is_empty() {
64 let name_owned = name.to_string();
65 let root = project_root.to_path_buf();
66 tokio::task::spawn_blocking(move || text_search_find_symbol(&name_owned, &root))
67 .await
68 .unwrap_or_default()
69 } else {
70 lsp_symbols
71 };
72 let symbol = symbols
73 .first()
74 .with_context(|| format!("symbol '{name}' not found"))?;
75
76 let abs_path = project_root.join(&symbol.path);
77 let (line_0, char_0) = find_name_position(&abs_path, symbol.line, name);
78 Ok((abs_path, line_0, char_0))
79}
80
81#[must_use]
87pub fn text_search_find_symbol(name: &str, project_root: &Path) -> Vec<SymbolMatch> {
88 use crate::commands::search::{run as search_run, SearchOptions};
89
90 let opts = SearchOptions {
91 pattern: name.to_string(),
92 path: None,
93 ignore_case: false,
94 word: true,
95 literal: true,
96 context: 0,
97 files_only: false,
98 lang_filter: None,
99 max_matches: 50,
100 };
101
102 let Ok(output) = search_run(&opts, project_root) else {
103 return vec![];
104 };
105
106 output
107 .matches
108 .into_iter()
109 .filter_map(|m| {
110 classify_definition(&m.preview, name).map(|kind| SymbolMatch {
111 kind: kind.to_string(),
112 path: m.path,
113 line: m.line,
114 preview: m.preview,
115 body: None,
116 })
117 })
118 .collect()
119}
120
121#[must_use]
126pub fn text_search_find_refs(name: &str, project_root: &Path) -> Vec<ReferenceMatch> {
127 use crate::commands::search::{run as search_run, SearchOptions};
128
129 let opts = SearchOptions {
130 pattern: name.to_string(),
131 path: None,
132 ignore_case: false,
133 word: true,
134 literal: true,
135 context: 0,
136 files_only: false,
137 lang_filter: None,
138 max_matches: 200,
139 };
140
141 let Ok(output) = search_run(&opts, project_root) else {
142 return vec![];
143 };
144
145 let mut results: Vec<ReferenceMatch> = output
146 .matches
147 .into_iter()
148 .map(|m| {
149 let is_definition = classify_definition(&m.preview, name).is_some();
150 ReferenceMatch {
151 path: m.path,
152 line: m.line,
153 preview: m.preview,
154 is_definition,
155 containing_symbol: None,
156 }
157 })
158 .collect();
159
160 results.sort_by(|a, b| {
162 b.is_definition
163 .cmp(&a.is_definition)
164 .then(a.path.cmp(&b.path))
165 .then(a.line.cmp(&b.line))
166 });
167
168 results
169}
170
171fn classify_definition<'a>(line: &str, name: &str) -> Option<&'a str> {
180 let trimmed = line.trim();
181 let name_pos = trimmed.find(name)?;
182 let word_before = trimmed[..name_pos].split_whitespace().last().unwrap_or("");
183 let kind = match word_before {
184 "const" | "let" | "var" => "constant",
185 "function" | "fn" | "def" | "async" => "function",
186 "class" => "class",
187 "interface" => "interface",
188 "type" => "type_alias",
189 "struct" | "enum" => "struct",
190 _ => return None,
191 };
192 Some(kind)
193}
194
195pub async fn find_refs(
203 name: &str,
204 client: &mut LspClient,
205 file_tracker: &mut FileTracker,
206 project_root: &Path,
207) -> anyhow::Result<Vec<ReferenceMatch>> {
208 let symbols = find_symbol(name, client, project_root).await?;
210 let symbol = symbols
211 .first()
212 .with_context(|| format!("symbol '{name}' not found"))?;
213
214 let abs_path = project_root.join(&symbol.path);
216 let was_already_open = file_tracker.is_open(&abs_path);
217 file_tracker
218 .ensure_open(&abs_path, client.transport_mut())
219 .await?;
220 if !was_already_open {
221 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
223 }
224
225 let uri = crate::lsp::client::path_to_uri(&abs_path)?;
227 let (ref_line, ref_char) = find_name_position(&abs_path, symbol.line, name);
228
229 let params = json!({
230 "textDocument": { "uri": uri.as_str() },
231 "position": { "line": ref_line, "character": ref_char },
232 "context": { "includeDeclaration": true }
233 });
234
235 let request_id = client
236 .transport_mut()
237 .send_request("textDocument/references", params)
238 .await?;
239
240 let response = client
241 .wait_for_response_public(request_id)
242 .await
243 .context("textDocument/references request failed")?;
244
245 Ok(parse_reference_results(
246 &response,
247 &symbol.path,
248 symbol.line,
249 project_root,
250 ))
251}
252
253#[derive(Debug, serde::Serialize)]
255pub struct ContainingSymbol {
256 pub name: String,
257 pub kind: String,
258 pub line: u32,
259}
260
261#[derive(Debug, serde::Serialize)]
263pub struct ReferenceMatch {
264 pub path: String,
265 pub line: u32,
266 pub preview: String,
267 pub is_definition: bool,
268 #[serde(skip_serializing_if = "Option::is_none")]
270 pub containing_symbol: Option<ContainingSymbol>,
271}
272
273#[must_use]
276pub fn find_innermost_containing(
277 symbols: &[crate::commands::list::SymbolEntry],
278 line: u32,
279) -> Option<ContainingSymbol> {
280 for sym in symbols {
281 if sym.line <= line && line <= sym.end_line {
282 if !sym.children.is_empty() {
284 if let Some(child) = find_innermost_containing(&sym.children, line) {
285 return Some(child);
286 }
287 }
288 return Some(ContainingSymbol {
289 name: sym.name.clone(),
290 kind: sym.kind.clone(),
291 line: sym.line,
292 });
293 }
294 }
295 None
296}
297
298fn parse_symbol_results(response: &Value, query: &str, project_root: &Path) -> Vec<SymbolMatch> {
299 let Some(items) = response.as_array() else {
300 return Vec::new();
301 };
302
303 let mut results = Vec::new();
304 for item in items {
305 let name = item.get("name").and_then(Value::as_str).unwrap_or_default();
306
307 let match_name = lang_go::base_name(name);
312 if !match_name.eq_ignore_ascii_case(query) && !match_name.starts_with(query) {
313 continue;
314 }
315
316 let kind = symbol_kind_name(item.get("kind").and_then(Value::as_u64).unwrap_or(0));
317
318 let (path, line) = extract_location(item, project_root);
319 let preview = read_line_preview(&project_root.join(&path), line);
320
321 results.push(SymbolMatch {
322 path,
323 line,
324 kind: kind.to_string(),
325 preview,
326 body: None,
327 });
328 }
329
330 results.sort_by(|a, b| a.path.cmp(&b.path).then(a.line.cmp(&b.line)));
331 results
332}
333
334const EXCLUDED_DIRS: &[&str] = &[
336 "target/",
337 ".git/",
338 "node_modules/",
339 ".mypy_cache/",
340 "__pycache__/",
341 ".cache/",
342 "dist/",
343 "build/",
344 ".next/",
345 ".nuxt/",
346];
347
348fn parse_reference_results(
349 response: &Value,
350 def_path: &str,
351 def_line: u32,
352 project_root: &Path,
353) -> Vec<ReferenceMatch> {
354 let Some(locations) = response.as_array() else {
355 return Vec::new();
356 };
357
358 let mut results = Vec::new();
359 for loc in locations {
360 let uri = loc.get("uri").and_then(Value::as_str).unwrap_or_default();
361
362 #[allow(clippy::cast_possible_truncation)]
363 let line = loc
364 .pointer("/range/start/line")
365 .and_then(Value::as_u64)
366 .unwrap_or(0) as u32
367 + 1; let path = uri_to_relative_path(uri, project_root);
370
371 if EXCLUDED_DIRS
373 .iter()
374 .any(|dir| path.starts_with(dir) || path.contains(&format!("/{dir}")))
375 {
376 continue;
377 }
378
379 let abs_path = project_root.join(&path);
380 let preview = read_line_preview(&abs_path, line);
381 let is_definition = path == def_path && line == def_line;
382
383 results.push(ReferenceMatch {
384 path,
385 line,
386 preview,
387 is_definition,
388 containing_symbol: None,
389 });
390 }
391
392 results.sort_by(|a, b| {
394 b.is_definition
395 .cmp(&a.is_definition)
396 .then(a.path.cmp(&b.path))
397 .then(a.line.cmp(&b.line))
398 });
399
400 results
401}
402
403fn extract_location(item: &Value, project_root: &Path) -> (String, u32) {
404 let uri = item
405 .pointer("/location/uri")
406 .and_then(Value::as_str)
407 .unwrap_or_default();
408
409 #[allow(clippy::cast_possible_truncation)]
410 let line = item
411 .pointer("/location/range/start/line")
412 .and_then(Value::as_u64)
413 .unwrap_or(0) as u32
414 + 1; (uri_to_relative_path(uri, project_root), line)
417}
418
419fn uri_to_relative_path(uri: &str, project_root: &Path) -> String {
420 let path = uri.strip_prefix("file://").unwrap_or(uri);
421 let abs = Path::new(path);
422 abs.strip_prefix(project_root)
423 .unwrap_or(abs)
424 .to_string_lossy()
425 .to_string()
426}
427
428#[allow(clippy::cast_possible_truncation)]
434fn find_name_position(path: &Path, line: u32, name: &str) -> (u32, u32) {
435 let Some(content) = std::fs::read_to_string(path).ok() else {
436 return (line.saturating_sub(1), 0);
437 };
438
439 let lines: Vec<&str> = content.lines().collect();
440 let start = line.saturating_sub(1) as usize;
441
442 for offset in 0..4 {
444 let idx = start + offset;
445 if idx >= lines.len() {
446 break;
447 }
448 if let Some(col) = lines[idx].find(name) {
449 return (idx as u32, col as u32);
450 }
451 }
452
453 (line.saturating_sub(1), 0)
454}
455
456fn read_line_preview(path: &Path, line: u32) -> String {
457 std::fs::read_to_string(path)
458 .ok()
459 .and_then(|content| {
460 content
461 .lines()
462 .nth(line.saturating_sub(1) as usize)
463 .map(|l| l.trim().to_string())
464 })
465 .unwrap_or_default()
466}
467
468#[must_use]
474pub fn extract_symbol_body(path: &Path, start_line: u32) -> Option<String> {
475 let content = std::fs::read_to_string(path).ok()?;
476 let lines: Vec<&str> = content.lines().collect();
477 let start = start_line.saturating_sub(1) as usize;
478 if start >= lines.len() {
479 return None;
480 }
481
482 let mut depth: i32 = 0;
483 let mut found_open = false;
484 let mut end = start;
485
486 for (i, line) in lines[start..].iter().enumerate() {
487 let idx = start + i;
488 for ch in line.chars() {
489 match ch {
490 '{' => {
491 depth += 1;
492 found_open = true;
493 }
494 '}' => {
495 depth -= 1;
496 }
497 _ => {}
498 }
499 }
500 end = idx;
501
502 if !found_open && line.trim_end().ends_with(';') {
504 break;
505 }
506 if found_open && depth <= 0 {
507 break;
508 }
509 if i >= 199 {
510 break;
511 }
512 }
513
514 let body: Vec<&str> = lines[start..=end].to_vec();
515 Some(body.join("\n"))
516}
517
518pub async fn find_impl(
526 name: &str,
527 lsp_client: &mut LspClient,
528 file_tracker: &mut FileTracker,
529 project_root: &Path,
530) -> anyhow::Result<Vec<SymbolMatch>> {
531 let symbols = find_symbol(name, lsp_client, project_root).await?;
533 let symbol = symbols
534 .first()
535 .with_context(|| format!("symbol '{name}' not found"))?;
536
537 let abs_path = project_root.join(&symbol.path);
539 let was_open = file_tracker.is_open(&abs_path);
540 file_tracker
541 .ensure_open(&abs_path, lsp_client.transport_mut())
542 .await?;
543 if !was_open {
544 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
545 }
546
547 let (line_0, char_0) = find_name_position(&abs_path, symbol.line, name);
549 let uri = client::path_to_uri(&abs_path)?;
550
551 let params = json!({
553 "textDocument": { "uri": uri.as_str() },
554 "position": { "line": line_0, "character": char_0 }
555 });
556
557 let request_id = lsp_client
558 .transport_mut()
559 .send_request("textDocument/implementation", params)
560 .await?;
561
562 let response = lsp_client
563 .wait_for_response_public(request_id)
564 .await
565 .context("textDocument/implementation request failed")?;
566
567 let results = parse_impl_results(&response, project_root);
568 if !results.is_empty() {
569 return Ok(results);
570 }
571
572 find_impl_via_refs(name, symbol, lsp_client, file_tracker, project_root).await
576}
577
578async fn find_impl_via_refs(
584 name: &str,
585 interface_symbol: &SymbolMatch,
586 client: &mut LspClient,
587 file_tracker: &mut FileTracker,
588 project_root: &Path,
589) -> anyhow::Result<Vec<SymbolMatch>> {
590 let refs = find_refs(name, client, file_tracker, project_root).await?;
591
592 let results: Vec<SymbolMatch> = refs
593 .into_iter()
594 .filter(|r| {
595 if r.is_definition {
597 return false;
598 }
599 let trimmed = r.preview.trim_start();
600 trimmed.starts_with("func ")
603 || trimmed.starts_with("function ")
604 || trimmed.starts_with("async function ")
605 || (trimmed.contains(name) && trimmed.ends_with('{'))
606 })
607 .filter(|r| {
608 !(r.path == interface_symbol.path && r.line == interface_symbol.line)
610 })
611 .map(|r| SymbolMatch {
612 path: r.path,
613 line: r.line,
614 kind: "implementation".to_string(),
615 preview: r.preview,
616 body: None,
617 })
618 .collect();
619
620 Ok(results)
621}
622
623fn parse_impl_results(response: &Value, project_root: &Path) -> Vec<SymbolMatch> {
624 let Some(items) = response.as_array() else {
626 return Vec::new();
627 };
628
629 let mut results = Vec::new();
630 for item in items {
631 let uri = item
634 .get("uri")
635 .or_else(|| item.get("targetUri"))
636 .and_then(Value::as_str)
637 .unwrap_or_default();
638
639 #[allow(clippy::cast_possible_truncation)]
640 let line = item
641 .pointer("/range/start/line")
642 .or_else(|| item.pointer("/targetRange/start/line"))
643 .and_then(Value::as_u64)
644 .unwrap_or(0) as u32
645 + 1; let path = uri_to_relative_path(uri, project_root);
648 let abs_path = project_root.join(&path);
649 let preview = read_line_preview(&abs_path, line);
650
651 results.push(SymbolMatch {
652 path,
653 line,
654 kind: "implementation".to_string(),
655 preview,
656 body: None,
657 });
658 }
659
660 results.sort_by(|a, b| a.path.cmp(&b.path).then(a.line.cmp(&b.line)));
661 results
662}
663
664#[must_use]
666pub fn symbol_kind_name(kind: u64) -> &'static str {
667 match kind {
668 1 => "file",
669 2 => "module",
670 3 => "namespace",
671 4 => "package",
672 5 => "class",
673 6 => "method",
674 7 => "property",
675 8 => "field",
676 9 => "constructor",
677 10 => "enum",
678 11 => "interface",
679 12 => "function",
680 13 => "variable",
681 14 => "constant",
682 15 => "string",
683 16 => "number",
684 17 => "boolean",
685 18 => "array",
686 19 => "object",
687 20 => "key",
688 21 => "null",
689 22 => "enum_member",
690 23 => "struct",
691 24 => "event",
692 25 => "operator",
693 26 => "type_parameter",
694 _ => "unknown",
695 }
696}
697
698#[cfg(test)]
699mod tests {
700 use super::*;
701
702 #[test]
703 fn symbol_kind_function() {
704 assert_eq!(symbol_kind_name(12), "function");
705 }
706
707 #[test]
708 fn symbol_kind_struct() {
709 assert_eq!(symbol_kind_name(23), "struct");
710 }
711
712 #[test]
713 fn uri_to_relative() {
714 let root = Path::new("/home/user/project");
715 let uri = "file:///home/user/project/src/lib.rs";
716 assert_eq!(uri_to_relative_path(uri, root), "src/lib.rs");
717 }
718
719 #[test]
720 fn uri_to_relative_outside_project() {
721 let root = Path::new("/home/user/project");
722 let uri = "file:///other/path/lib.rs";
723 assert_eq!(uri_to_relative_path(uri, root), "/other/path/lib.rs");
724 }
725
726 #[test]
727 fn parse_empty_symbol_results() {
728 let results = parse_symbol_results(&json!(null), "test", Path::new("/tmp"));
729 assert!(results.is_empty());
730 }
731
732 #[test]
733 fn parse_empty_reference_results() {
734 let results = parse_reference_results(&json!(null), "src/lib.rs", 1, Path::new("/tmp"));
735 assert!(results.is_empty());
736 }
737
738 #[test]
739 fn find_name_position_does_not_match_substring() {
740 let dir = tempfile::tempdir().unwrap();
741 let file = dir.path().join("test.ts");
742 std::fs::write(&file, "function renewed() {\n return new Thing();\n}").unwrap();
744
745 let (line, col) = find_name_position(&file, 1, "new");
747 assert!(line < 3, "line should be within search window");
752 let _ = col; }
754
755 #[test]
756 fn classify_definition_recognises_const() {
757 assert_eq!(
758 classify_definition(
759 "export const createPromotionsStep = createStep(",
760 "createPromotionsStep"
761 ),
762 Some("constant")
763 );
764 assert_eq!(
765 classify_definition("const foo = 1;", "foo"),
766 Some("constant")
767 );
768 }
769
770 #[test]
771 fn classify_definition_recognises_function() {
772 assert_eq!(
773 classify_definition("function greet(name: string) {", "greet"),
774 Some("function")
775 );
776 assert_eq!(
777 classify_definition("export function handleRequest(req) {", "handleRequest"),
778 Some("function")
779 );
780 assert_eq!(
781 classify_definition("pub fn run() -> Result<()> {", "run"),
782 Some("function")
783 );
784 }
785
786 #[test]
787 fn classify_definition_rejects_call_sites() {
788 assert_eq!(
789 classify_definition(
790 "const result = createPromotionsStep(data)",
791 "createPromotionsStep"
792 ),
793 None
794 );
795 assert_eq!(
796 classify_definition(
797 "import { createPromotionsStep } from '../steps'",
798 "createPromotionsStep"
799 ),
800 None
801 );
802 assert_eq!(
803 classify_definition("return createPromotionsStep(data)", "createPromotionsStep"),
804 None
805 );
806 }
807
808 #[test]
809 fn text_search_find_symbol_finds_const_export() {
810 use std::fs;
811 use tempfile::tempdir;
812
813 let dir = tempdir().unwrap();
814 fs::write(
815 dir.path().join("step.ts"),
816 "export const createPromotionsStep = createStep(\n stepId,\n async () => {}\n);\n",
817 )
818 .unwrap();
819 fs::write(
820 dir.path().join("workflow.ts"),
821 "import { createPromotionsStep } from './step';\nconst result = createPromotionsStep(data);\n",
822 ).unwrap();
823
824 let results = text_search_find_symbol("createPromotionsStep", dir.path());
825 assert_eq!(results.len(), 1);
827 assert!(results[0].path.ends_with("step.ts"));
828 assert_eq!(results[0].line, 1);
829 assert_eq!(results[0].kind, "constant");
830 }
831
832 #[test]
833 fn text_search_find_refs_returns_all_occurrences() {
834 use std::fs;
835 use tempfile::tempdir;
836
837 let dir = tempdir().unwrap();
838 fs::write(
839 dir.path().join("step.ts"),
840 "export const createPromotionsStep = createStep(stepId, async () => {});\n",
841 )
842 .unwrap();
843 fs::write(
844 dir.path().join("workflow.ts"),
845 "import { createPromotionsStep } from './step';\nconst out = createPromotionsStep(data);\n",
846 ).unwrap();
847
848 let results = text_search_find_refs("createPromotionsStep", dir.path());
849 assert_eq!(results.len(), 3);
850 assert!(results[0].is_definition);
852 }
853
854 #[test]
855 fn classify_definition_detects_kinds() {
856 assert_eq!(
857 classify_definition("export class MyClass {", "MyClass"),
858 Some("class")
859 );
860 assert_eq!(
861 classify_definition("export function doThing() {", "doThing"),
862 Some("function")
863 );
864 assert_eq!(
865 classify_definition("pub fn run() -> Result<()> {", "run"),
866 Some("function")
867 );
868 assert_eq!(
869 classify_definition("export const MY_CONST = 42", "MY_CONST"),
870 Some("constant")
871 );
872 assert_eq!(
873 classify_definition("export interface IService {", "IService"),
874 Some("interface")
875 );
876 assert_eq!(
877 classify_definition("pub struct Config {", "Config"),
878 Some("struct")
879 );
880 assert_eq!(
881 classify_definition("type MyAlias = string;", "MyAlias"),
882 Some("type_alias")
883 );
884 }
885
886 #[test]
887 fn command_to_request_find_symbol() {
888 use crate::cli::{Command, FindCommand};
889 use crate::client::command_to_request;
890 use crate::protocol::Request;
891
892 let cmd = Command::Find(FindCommand::Symbol {
893 name: "MyStruct".into(),
894 path: None,
895 src_only: false,
896 include_body: false,
897 });
898 let req = command_to_request(&cmd);
899 assert!(matches!(req, Request::FindSymbol { name, .. } if name == "MyStruct"));
900 }
901
902 #[test]
903 fn command_to_request_find_refs() {
904 use crate::cli::{Command, FindCommand};
905 use crate::client::command_to_request;
906 use crate::protocol::Request;
907
908 let cmd = Command::Find(FindCommand::Refs {
909 name: "my_func".into(),
910 with_symbol: false,
911 });
912 let req = command_to_request(&cmd);
913 assert!(matches!(req, Request::FindRefs { name, .. } if name == "my_func"));
914 }
915}