Skip to main content

krait/commands/
list.rs

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/// A symbol in the document outline.
10#[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
19/// List symbols in a file using `textDocument/documentSymbol`.
20///
21/// # Errors
22/// Returns an error if the file can't be opened or the LSP request fails.
23pub 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/// Format symbols as compact output with indentation.
115#[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}