1use std::path::Path;
2
3use anyhow::Context;
4use serde_json::{json, Value};
5
6use crate::lsp::client::LspClient;
7use crate::lsp::files::FileTracker;
8
9#[derive(Debug, serde::Serialize)]
11pub struct SymbolEntry {
12 pub name: String,
13 pub kind: String,
14 pub line: u32,
15 pub end_line: u32,
16 pub children: Vec<SymbolEntry>,
17}
18
19pub async fn list_symbols(
24 file_path: &Path,
25 depth: u8,
26 client: &mut LspClient,
27 file_tracker: &mut FileTracker,
28 project_root: &Path,
29) -> anyhow::Result<Vec<SymbolEntry>> {
30 let abs_path = if file_path.is_absolute() {
31 file_path.to_path_buf()
32 } else {
33 project_root.join(file_path)
34 };
35
36 file_tracker
37 .ensure_open(&abs_path, client.transport_mut())
38 .await
39 .with_context(|| format!("failed to open: {}", file_path.display()))?;
40
41 let uri = crate::lsp::client::path_to_uri(&abs_path)?;
42 let params = json!({
43 "textDocument": { "uri": uri.as_str() }
44 });
45
46 let request_id = client
47 .transport_mut()
48 .send_request("textDocument/documentSymbol", params)
49 .await?;
50
51 let response = client
52 .wait_for_response_public(request_id)
53 .await
54 .context("textDocument/documentSymbol request failed")?;
55
56 let symbols = parse_document_symbols(&response, depth, 1);
57 Ok(symbols)
58}
59
60fn parse_document_symbols(value: &Value, max_depth: u8, current_depth: u8) -> Vec<SymbolEntry> {
61 let Some(items) = value.as_array() else {
62 return Vec::new();
63 };
64
65 let mut results = Vec::new();
66 for item in items {
67 let name = item
68 .get("name")
69 .and_then(Value::as_str)
70 .unwrap_or_default()
71 .to_string();
72
73 let kind = crate::commands::find::symbol_kind_name(
74 item.get("kind").and_then(Value::as_u64).unwrap_or(0),
75 )
76 .to_string();
77
78 #[allow(clippy::cast_possible_truncation)]
79 let line = item
80 .pointer("/range/start/line")
81 .or_else(|| item.pointer("/location/range/start/line"))
82 .and_then(Value::as_u64)
83 .unwrap_or(0) as u32
84 + 1;
85
86 #[allow(clippy::cast_possible_truncation)]
87 let end_line = item
88 .pointer("/range/end/line")
89 .or_else(|| item.pointer("/location/range/end/line"))
90 .and_then(Value::as_u64)
91 .unwrap_or(0) as u32
92 + 1;
93
94 let children = if current_depth < max_depth {
95 item.get("children")
96 .map(|c| parse_document_symbols(c, max_depth, current_depth + 1))
97 .unwrap_or_default()
98 } else {
99 Vec::new()
100 };
101
102 results.push(SymbolEntry {
103 name,
104 kind,
105 line,
106 end_line,
107 children,
108 });
109 }
110
111 results
112}
113
114#[must_use]
116pub fn format_compact(symbols: &[SymbolEntry], indent: usize) -> String {
117 use std::fmt::Write;
118 let mut out = String::new();
119 for sym in symbols {
120 let prefix = " ".repeat(indent);
121 let _ = writeln!(out, "{prefix}{} {}", sym.kind, sym.name);
122 if !sym.children.is_empty() {
123 out.push_str(&format_compact(&sym.children, indent + 1));
124 }
125 }
126 out
127}
128
129#[cfg(test)]
130mod tests {
131 use super::*;
132
133 #[test]
134 fn parse_empty_response() {
135 let response = json!(null);
136 let symbols = parse_document_symbols(&response, 2, 1);
137 assert!(symbols.is_empty());
138 }
139
140 #[test]
141 fn parse_nested_symbols() {
142 let response = json!([
143 {
144 "name": "Config",
145 "kind": 23,
146 "range": { "start": { "line": 4, "character": 0 }, "end": { "line": 7, "character": 1 } },
147 "selectionRange": { "start": { "line": 4, "character": 11 }, "end": { "line": 4, "character": 17 } },
148 "children": [
149 {
150 "name": "name",
151 "kind": 8,
152 "range": { "start": { "line": 5, "character": 4 }, "end": { "line": 5, "character": 20 } },
153 "selectionRange": { "start": { "line": 5, "character": 8 }, "end": { "line": 5, "character": 12 } }
154 }
155 ]
156 }
157 ]);
158
159 let symbols = parse_document_symbols(&response, 2, 1);
160 assert_eq!(symbols.len(), 1);
161 assert_eq!(symbols[0].name, "Config");
162 assert_eq!(symbols[0].kind, "struct");
163 assert_eq!(symbols[0].children.len(), 1);
164 assert_eq!(symbols[0].children[0].name, "name");
165 assert_eq!(symbols[0].children[0].kind, "field");
166 }
167
168 #[test]
169 fn depth_1_excludes_children() {
170 let response = json!([
171 {
172 "name": "Config",
173 "kind": 23,
174 "range": { "start": { "line": 0, "character": 0 }, "end": { "line": 5, "character": 1 } },
175 "selectionRange": { "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 6 } },
176 "children": [
177 {
178 "name": "field",
179 "kind": 8,
180 "range": { "start": { "line": 1, "character": 0 }, "end": { "line": 1, "character": 10 } },
181 "selectionRange": { "start": { "line": 1, "character": 0 }, "end": { "line": 1, "character": 5 } }
182 }
183 ]
184 }
185 ]);
186
187 let symbols = parse_document_symbols(&response, 1, 1);
188 assert_eq!(symbols.len(), 1);
189 assert!(symbols[0].children.is_empty());
190 }
191
192 #[test]
193 fn format_compact_output() {
194 let symbols = vec![
195 SymbolEntry {
196 name: "greet".into(),
197 kind: "function".into(),
198 line: 1,
199 end_line: 3,
200 children: vec![],
201 },
202 SymbolEntry {
203 name: "Config".into(),
204 kind: "struct".into(),
205 line: 5,
206 end_line: 8,
207 children: vec![SymbolEntry {
208 name: "name".into(),
209 kind: "field".into(),
210 line: 6,
211 end_line: 6,
212 children: vec![],
213 }],
214 },
215 ];
216
217 let out = format_compact(&symbols, 0);
218 assert!(out.contains("function greet"));
219 assert!(out.contains("struct Config"));
220 assert!(out.contains(" field name"));
221 }
222}