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    match snapshot.resolve_symbol(&query) {
62        SymbolResolutionOutcome::Resolved(node_id) => Ok(node_id),
63        SymbolResolutionOutcome::NotFound => Err(anyhow!(
64            "Symbol '{symbol_name}' not found in '{}'",
65            file_path.display()
66        )),
67        SymbolResolutionOutcome::FileNotIndexed => {
68            Err(anyhow!("File '{}' is not indexed", file_path.display()))
69        }
70        SymbolResolutionOutcome::Ambiguous(_) => Err(anyhow!(
71            "Symbol '{symbol_name}' is ambiguous in '{}'",
72            file_path.display()
73        )),
74    }
75}
76
77/// Build a `SymbolContext` by reading the source file and extracting the relevant lines.
78fn build_symbol_context(
79    index_root: &std::path::Path,
80    files_registry: &FileRegistry,
81    entry: &NodeEntry,
82) -> Option<SymbolContext> {
83    let file_to_read = files_registry
84        .resolve(entry.file)
85        .map(|p| index_root.join(p.as_ref()));
86
87    let file_to_read = file_to_read?;
88    let content = std::fs::read_to_string(&file_to_read).ok()?;
89    let lines: Vec<&str> = content.lines().collect();
90    let start = entry.start_line.saturating_sub(1) as usize;
91    let end = (entry.end_line as usize).min(lines.len());
92
93    if start < lines.len() {
94        let code_lines: Vec<&str> = lines[start..end].to_vec();
95        Some(SymbolContext {
96            code: code_lines.join("\n"),
97            start_line: entry.start_line,
98            end_line: entry.end_line,
99        })
100    } else {
101        None
102    }
103}
104
105/// Run the explain command.
106///
107/// # Errors
108/// Returns an error if the graph cannot be loaded or symbol cannot be found.
109pub fn run_explain(
110    cli: &Cli,
111    file_path: &str,
112    symbol_name: &str,
113    path: Option<&str>,
114    include_context: bool,
115    _include_relations: bool,
116) -> Result<()> {
117    let mut streams = OutputStreams::new();
118
119    // Find index
120    let search_path = path.map_or_else(
121        || std::env::current_dir().unwrap_or_default(),
122        std::path::PathBuf::from,
123    );
124
125    let index_location = find_nearest_index(&search_path);
126    let Some(ref loc) = index_location else {
127        streams
128            .write_diagnostic("No .sqry-index found. Run 'sqry index' first to build the index.")?;
129        return Ok(());
130    };
131
132    // Load unified graph
133    let config = GraphLoadConfig::default();
134    let graph = load_unified_graph_for_cli(&loc.index_root, &config, cli)
135        .context("Failed to load graph. Run 'sqry index' to build the graph.")?;
136
137    let snapshot = graph.snapshot();
138    let strings = snapshot.strings();
139    let files_registry = snapshot.files();
140    let requested_file_path = if std::path::Path::new(file_path).is_absolute() {
141        std::path::PathBuf::from(file_path)
142    } else {
143        loc.index_root.join(file_path)
144    };
145
146    let node_id = resolve_symbol_by_file_and_name(&snapshot, &requested_file_path, symbol_name)?;
147    let symbol_entry = snapshot
148        .get_node(node_id)
149        .ok_or_else(|| anyhow!("Symbol node not found in graph"))?;
150
151    let file_path_resolved = files_registry
152        .resolve(symbol_entry.file)
153        .map(|p| p.display().to_string())
154        .unwrap_or_default();
155
156    let language = files_registry
157        .language_for_file(symbol_entry.file)
158        .map_or_else(|| "Unknown".to_string(), |l| l.to_string());
159
160    let name = strings
161        .resolve(symbol_entry.name)
162        .map(|s| s.to_string())
163        .unwrap_or_default();
164
165    let qualified_name = symbol_entry
166        .qualified_name
167        .and_then(|id| strings.resolve(id))
168        .map_or_else(|| name.clone(), |s| s.to_string());
169
170    let visibility = symbol_entry
171        .visibility
172        .and_then(|id| strings.resolve(id))
173        .map(|s| s.to_string());
174
175    let documentation = symbol_entry
176        .doc
177        .and_then(|id| strings.resolve(id))
178        .map(|s| s.to_string());
179
180    // Build context if requested
181    let context = if include_context {
182        build_symbol_context(&loc.index_root, files_registry, symbol_entry)
183    } else {
184        None
185    };
186
187    let output = ExplainOutput {
188        name,
189        qualified_name,
190        kind: format!("{:?}", symbol_entry.kind),
191        file: file_path_resolved,
192        line: symbol_entry.start_line,
193        language,
194        visibility,
195        documentation,
196        context,
197    };
198
199    // Output
200    if cli.json {
201        let json = serde_json::to_string_pretty(&output).context("Failed to serialize to JSON")?;
202        streams.write_result(&json)?;
203    } else {
204        let text = format_explain_text(&output);
205        streams.write_result(&text)?;
206    }
207
208    Ok(())
209}
210
211fn format_explain_text(output: &ExplainOutput) -> String {
212    let mut lines = Vec::new();
213
214    lines.push(format!("Symbol: {}", output.qualified_name));
215    lines.push(format!("  Kind: {}", output.kind));
216    lines.push(format!("  File: {}:{}", output.file, output.line));
217    lines.push(format!("  Language: {}", output.language));
218
219    if let Some(ref vis) = output.visibility {
220        lines.push(format!("  Visibility: {vis}"));
221    }
222
223    if let Some(ref doc) = output.documentation {
224        lines.push(format!("  Documentation: {doc}"));
225    }
226
227    if let Some(ref ctx) = output.context {
228        lines.push(String::new());
229        lines.push(format!("Code (lines {}-{}):", ctx.start_line, ctx.end_line));
230        for (i, line) in ctx.code.lines().enumerate() {
231            lines.push(format!("{:4} | {}", ctx.start_line as usize + i, line));
232        }
233    }
234
235    lines.join("\n")
236}