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