Skip to main content

sqry_cli/commands/
explain.rs

1//! Explain command implementation
2//!
3//! Provides CLI interface for explaining a symbol with context and relations.
4
5use crate::args::Cli;
6use crate::commands::graph::loader::{GraphLoadConfig, load_unified_graph_for_cli};
7use crate::index_discovery::find_nearest_index;
8use crate::output::OutputStreams;
9use anyhow::{Context, Result, anyhow};
10use serde::Serialize;
11use sqry_core::graph::unified::concurrent::GraphSnapshot;
12use sqry_core::graph::unified::storage::{FileRegistry, NodeEntry};
13use sqry_core::graph::unified::{FileScope, ResolutionMode, SymbolQuery, SymbolResolutionOutcome};
14
15/// Symbol explanation output
16#[derive(Debug, Serialize)]
17struct ExplainOutput {
18    /// Symbol name
19    name: String,
20    /// Qualified name
21    qualified_name: String,
22    /// Symbol kind
23    kind: String,
24    /// File path
25    file: String,
26    /// Line number
27    line: u32,
28    /// Language
29    language: String,
30    /// Visibility
31    visibility: Option<String>,
32    /// Documentation/comments
33    documentation: Option<String>,
34    /// Context (surrounding code)
35    #[serde(skip_serializing_if = "Option::is_none")]
36    context: Option<SymbolContext>,
37}
38
39#[derive(Debug, Serialize)]
40struct SymbolContext {
41    /// Code snippet
42    code: String,
43    /// Start line of snippet
44    start_line: u32,
45    /// End line of snippet
46    end_line: u32,
47}
48
49/// Find a symbol in the graph by file path and symbol name.
50fn resolve_symbol_by_file_and_name(
51    snapshot: &GraphSnapshot,
52    file_path: &std::path::Path,
53    symbol_name: &str,
54) -> Result<sqry_core::graph::unified::NodeId> {
55    let query = SymbolQuery {
56        symbol: symbol_name,
57        file_scope: FileScope::Path(file_path),
58        mode: ResolutionMode::Strict,
59    };
60
61    let witness = snapshot.resolve_symbol_with_witness(&query);
62    match witness.outcome {
63        SymbolResolutionOutcome::Resolved(node_id) => Ok(node_id),
64        SymbolResolutionOutcome::NotFound => Err(anyhow!(
65            "Symbol '{symbol_name}' not found in '{}'",
66            file_path.display()
67        )),
68        SymbolResolutionOutcome::FileNotIndexed => {
69            Err(anyhow!("File '{}' is not indexed", file_path.display()))
70        }
71        SymbolResolutionOutcome::Ambiguous(_) => Err(anyhow!(
72            "Symbol '{symbol_name}' is ambiguous in '{}'",
73            file_path.display()
74        )),
75    }
76}
77
78/// Build a `SymbolContext` by reading the source file and extracting the relevant lines.
79fn build_symbol_context(
80    index_root: &std::path::Path,
81    files_registry: &FileRegistry,
82    entry: &NodeEntry,
83) -> Option<SymbolContext> {
84    let file_to_read = files_registry
85        .resolve(entry.file)
86        .map(|p| index_root.join(p.as_ref()));
87
88    let file_to_read = file_to_read?;
89    let content = std::fs::read_to_string(&file_to_read).ok()?;
90    let lines: Vec<&str> = content.lines().collect();
91    let start = entry.start_line.saturating_sub(1) as usize;
92    let end = (entry.end_line as usize).min(lines.len());
93
94    if start < lines.len() {
95        let code_lines: Vec<&str> = lines[start..end].to_vec();
96        Some(SymbolContext {
97            code: code_lines.join("\n"),
98            start_line: entry.start_line,
99            end_line: entry.end_line,
100        })
101    } else {
102        None
103    }
104}
105
106/// Run the explain command.
107///
108/// # Errors
109/// Returns an error if the graph cannot be loaded or symbol cannot be found.
110pub fn run_explain(
111    cli: &Cli,
112    file_path: &str,
113    symbol_name: &str,
114    path: Option<&str>,
115    include_context: bool,
116    _include_relations: bool,
117) -> Result<()> {
118    let mut streams = OutputStreams::new();
119
120    // Find index
121    let search_path = path.map_or_else(
122        || std::env::current_dir().unwrap_or_default(),
123        std::path::PathBuf::from,
124    );
125
126    let index_location = find_nearest_index(&search_path);
127    let Some(ref loc) = index_location else {
128        streams
129            .write_diagnostic("No .sqry-index found. Run 'sqry index' first to build the index.")?;
130        return Ok(());
131    };
132
133    // Load unified graph
134    let config = GraphLoadConfig::default();
135    let graph = load_unified_graph_for_cli(&loc.index_root, &config, cli)
136        .context("Failed to load graph. Run 'sqry index' to build the graph.")?;
137
138    let snapshot = graph.snapshot();
139    let strings = snapshot.strings();
140    let files_registry = snapshot.files();
141    let requested_file_path = if std::path::Path::new(file_path).is_absolute() {
142        std::path::PathBuf::from(file_path)
143    } else {
144        loc.index_root.join(file_path)
145    };
146
147    let node_id = resolve_symbol_by_file_and_name(&snapshot, &requested_file_path, symbol_name)?;
148    let symbol_entry = snapshot
149        .get_node(node_id)
150        .ok_or_else(|| anyhow!("Symbol node not found in graph"))?;
151
152    let file_path_resolved = files_registry
153        .resolve(symbol_entry.file)
154        .map(|p| p.display().to_string())
155        .unwrap_or_default();
156
157    let language = files_registry
158        .language_for_file(symbol_entry.file)
159        .map_or_else(|| "Unknown".to_string(), |l| l.to_string());
160
161    let name = strings
162        .resolve(symbol_entry.name)
163        .map(|s| s.to_string())
164        .unwrap_or_default();
165
166    let qualified_name = symbol_entry
167        .qualified_name
168        .and_then(|id| strings.resolve(id))
169        .map_or_else(|| name.clone(), |s| s.to_string());
170
171    let visibility = symbol_entry
172        .visibility
173        .and_then(|id| strings.resolve(id))
174        .map(|s| s.to_string());
175
176    let documentation = symbol_entry
177        .doc
178        .and_then(|id| strings.resolve(id))
179        .map(|s| s.to_string());
180
181    // Build context if requested
182    let context = if include_context {
183        build_symbol_context(&loc.index_root, files_registry, symbol_entry)
184    } else {
185        None
186    };
187
188    let output = ExplainOutput {
189        name,
190        qualified_name,
191        kind: format!("{:?}", symbol_entry.kind),
192        file: file_path_resolved,
193        line: symbol_entry.start_line,
194        language,
195        visibility,
196        documentation,
197        context,
198    };
199
200    // Output
201    if cli.json {
202        let json = serde_json::to_string_pretty(&output).context("Failed to serialize to JSON")?;
203        streams.write_result(&json)?;
204    } else {
205        let text = format_explain_text(&output);
206        streams.write_result(&text)?;
207    }
208
209    Ok(())
210}
211
212fn format_explain_text(output: &ExplainOutput) -> String {
213    let mut lines = Vec::new();
214
215    lines.push(format!("Symbol: {}", output.qualified_name));
216    lines.push(format!("  Kind: {}", output.kind));
217    lines.push(format!("  File: {}:{}", output.file, output.line));
218    lines.push(format!("  Language: {}", output.language));
219
220    if let Some(ref vis) = output.visibility {
221        lines.push(format!("  Visibility: {vis}"));
222    }
223
224    if let Some(ref doc) = output.documentation {
225        lines.push(format!("  Documentation: {doc}"));
226    }
227
228    if let Some(ref ctx) = output.context {
229        lines.push(String::new());
230        lines.push(format!("Code (lines {}-{}):", ctx.start_line, ctx.end_line));
231        for (i, line) in ctx.code.lines().enumerate() {
232            lines.push(format!("{:4} | {}", ctx.start_line as usize + i, line));
233        }
234    }
235
236    lines.join("\n")
237}