Skip to main content

sqry_cli/output/
mod.rs

1//! Output formatting for CLI results
2//!
3//! Uses native graph types directly without intermediate Symbol conversion.
4
5mod csv;
6mod json;
7pub mod pager;
8mod preview;
9mod stream;
10mod text;
11mod theme;
12
13pub use csv::{CsvColumn, CsvFormatter, parse_columns};
14pub use json::{JsonFormatter, JsonSymbol};
15pub use pager::PagerConfig;
16pub use preview::PreviewConfig;
17pub use stream::OutputStreams;
18pub use text::TextFormatter;
19pub use theme::{Palette, ThemeName};
20
21// Re-export for testing and downstream consumers
22#[allow(unused_imports)]
23pub use preview::{ContextLines, GroupedContext, MatchLocation, NumberedLine, PreviewExtractor};
24
25#[cfg(test)]
26pub use stream::TestOutputStreams;
27
28use anyhow::Result;
29use sqry_core::graph::Language;
30use sqry_core::graph::unified::node::NodeKind;
31use sqry_core::graph::unified::resolution::display_graph_qualified_name;
32use sqry_core::json_response::Filters;
33use sqry_core::query::results::QueryMatch;
34use sqry_core::relations::{CallIdentityBuilder, CallIdentityKind, CallIdentityMetadata};
35use std::collections::HashMap;
36use std::path::{Path, PathBuf};
37use std::time::Duration;
38
39/// Metadata about the query for structured output
40#[derive(Debug, Clone)]
41pub struct FormatterMetadata {
42    /// The search pattern or query expression
43    pub pattern: Option<String>,
44    /// Total number of matches before limiting
45    pub total_matches: usize,
46    /// Query execution time
47    pub execution_time: Duration,
48    /// Active filters
49    pub filters: Filters,
50    /// Index age in seconds (if applicable)
51    pub index_age_seconds: Option<u64>,
52    /// Whether the index was found in an ancestor directory
53    pub used_ancestor_index: Option<bool>,
54    /// Scope filter applied (e.g., "src/**" or "main.rs")
55    pub filtered_to: Option<String>,
56}
57
58/// Trait for formatting search results
59pub trait Formatter {
60    /// Format and output symbols using provided output streams
61    ///
62    /// Results should go to `streams.write_result()` (stdout)
63    /// Diagnostics should go to `streams.write_diagnostic()` (stderr)
64    ///
65    /// Metadata is optional - used for structured JSON output with query stats
66    ///
67    /// # Errors
68    /// Returns an error if writing to the output streams fails.
69    fn format(
70        &self,
71        symbols: &[DisplaySymbol],
72        metadata: Option<&FormatterMetadata>,
73        streams: &mut OutputStreams,
74    ) -> Result<()>;
75}
76
77fn parse_ruby_instance_identity(qualified: &str) -> (CallIdentityKind, String, Vec<String>) {
78    let parts: Vec<&str> = qualified.rsplitn(2, '#').collect();
79    let simple = parts.first().copied().unwrap_or("").to_string();
80    let ns_str = parts.get(1).unwrap_or(&"");
81    let namespace: Vec<String> = ns_str
82        .split("::")
83        .filter(|segment| !segment.is_empty())
84        .map(str::to_string)
85        .collect();
86    (CallIdentityKind::Instance, simple, namespace)
87}
88
89fn parse_namespace_identity(
90    qualified: &str,
91    kind: &str,
92) -> (CallIdentityKind, String, Vec<String>) {
93    let parts: Vec<&str> = qualified.split("::").collect();
94    if let Some(last) = parts.last() {
95        if last.contains('.') {
96            let method_parts: Vec<&str> = last.rsplitn(2, '.').collect();
97            let simple = method_parts.first().copied().unwrap_or("").to_string();
98            let mut namespace: Vec<String> = parts[..parts.len() - 1]
99                .iter()
100                .map(|segment| (*segment).to_string())
101                .collect();
102            if let Some(class_name) = method_parts.get(1) {
103                namespace.push((*class_name).to_string());
104            }
105            (CallIdentityKind::Singleton, simple, namespace)
106        } else {
107            let simple = (*last).to_string();
108            let namespace: Vec<String> = parts[..parts.len() - 1]
109                .iter()
110                .map(|segment| (*segment).to_string())
111                .collect();
112            let method_kind = if kind == "method" {
113                CallIdentityKind::Instance
114            } else {
115                CallIdentityKind::Singleton
116            };
117            (method_kind, simple, namespace)
118        }
119    } else {
120        (CallIdentityKind::Instance, qualified.to_string(), vec![])
121    }
122}
123
124fn parse_dot_separated_identity(qualified: &str) -> (CallIdentityKind, String, Vec<String>) {
125    let parts: Vec<&str> = qualified.rsplitn(2, '.').collect();
126    let simple = parts.first().copied().unwrap_or("").to_string();
127    let ns_str = parts.get(1).unwrap_or(&"");
128    let namespace: Vec<String> = ns_str
129        .split('.')
130        .filter(|segment| !segment.is_empty())
131        .map(str::to_string)
132        .collect();
133    (CallIdentityKind::Singleton, simple, namespace)
134}
135
136pub(crate) fn call_identity_from_qualified_name(
137    qualified: &str,
138    kind: &str,
139    language: Option<&str>,
140    is_static: bool,
141) -> Option<CallIdentityMetadata> {
142    if qualified.is_empty() {
143        return None;
144    }
145
146    let (method_kind, simple, namespace, display_qualified) = if qualified.contains('#') {
147        let (method_kind, simple, namespace) = parse_ruby_instance_identity(qualified);
148        (method_kind, simple, namespace, qualified.to_string())
149    } else if language == Some("ruby") && kind == "method" && qualified.contains("::") {
150        let (method_kind, simple, namespace) = parse_namespace_identity(qualified, kind);
151        let ruby_method_kind = if is_static {
152            CallIdentityKind::Singleton
153        } else {
154            method_kind
155        };
156        let display_qualified = CallIdentityBuilder::new(simple.clone(), ruby_method_kind)
157            .with_namespace(namespace.clone())
158            .build()
159            .qualified;
160        (ruby_method_kind, simple, namespace, display_qualified)
161    } else if qualified.contains("::") {
162        let (method_kind, simple, namespace) = parse_namespace_identity(qualified, kind);
163        (method_kind, simple, namespace, qualified.to_string())
164    } else if qualified.contains('.') {
165        let (method_kind, simple, namespace) = parse_dot_separated_identity(qualified);
166        (method_kind, simple, namespace, qualified.to_string())
167    } else {
168        (
169            CallIdentityKind::Instance,
170            qualified.to_string(),
171            vec![],
172            qualified.to_string(),
173        )
174    };
175
176    Some(CallIdentityMetadata {
177        qualified: display_qualified,
178        simple,
179        namespace,
180        method_kind,
181        receiver: None,
182    })
183}
184
185pub(crate) fn display_qualified_name(
186    qualified: &str,
187    kind: &str,
188    language: Option<&str>,
189    is_static: bool,
190) -> String {
191    if let (Some(language), Some(kind)) =
192        (language.and_then(Language::from_id), NodeKind::parse(kind))
193    {
194        return display_graph_qualified_name(language, qualified, kind, is_static);
195    }
196
197    call_identity_from_qualified_name(qualified, kind, language, is_static)
198        .map_or_else(|| qualified.to_string(), |identity| identity.qualified)
199}
200
201fn build_preview_config(cli: &crate::args::Cli) -> Option<PreviewConfig> {
202    cli.preview.map(|lines| {
203        if lines == 0 {
204            PreviewConfig::no_context()
205        } else {
206            PreviewConfig::new(lines)
207        }
208    })
209}
210
211fn resolve_workspace_root() -> PathBuf {
212    std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
213}
214
215fn build_csv_formatter(
216    cli: &crate::args::Cli,
217    preview_config: Option<&PreviewConfig>,
218    workspace_root: &Path,
219    tsv: bool,
220) -> Box<dyn Formatter> {
221    let columns =
222        csv::parse_columns(cli.columns.as_ref()).expect("columns validated by Cli::validate");
223    let mut formatter = if tsv {
224        CsvFormatter::tsv(cli.headers, columns)
225    } else {
226        CsvFormatter::csv(cli.headers, columns)
227    };
228    formatter = formatter
229        .raw_mode(cli.raw_csv)
230        .with_workspace_root(workspace_root.to_path_buf());
231    if let Some(config) = preview_config {
232        formatter = formatter.with_preview(config.clone());
233    }
234    Box::new(formatter)
235}
236
237fn build_json_formatter(
238    preview_config: Option<&PreviewConfig>,
239    workspace_root: &Path,
240) -> Box<dyn Formatter> {
241    let mut formatter = JsonFormatter::new();
242    if let Some(config) = preview_config {
243        formatter = formatter.with_preview(config.clone(), workspace_root.to_path_buf());
244    }
245    Box::new(formatter)
246}
247
248fn build_text_formatter(
249    preview_config: Option<&PreviewConfig>,
250    workspace_root: &Path,
251    use_color: bool,
252    mode: NameDisplayMode,
253    theme: ThemeName,
254) -> Box<dyn Formatter> {
255    let mut formatter = TextFormatter::new(use_color, mode, theme);
256    if let Some(config) = preview_config {
257        formatter = formatter.with_preview(config.clone(), workspace_root.to_path_buf());
258    }
259    Box::new(formatter)
260}
261
262/// Create a formatter based on CLI flags
263///
264/// # Panics
265/// Panics if column validation was skipped and `--columns` contains invalid values.
266#[must_use]
267pub fn create_formatter(cli: &crate::args::Cli) -> Box<dyn Formatter> {
268    // Determine preview config if requested
269    let preview_config = build_preview_config(cli);
270
271    // Get workspace root for preview path validation
272    let workspace_root = resolve_workspace_root();
273
274    let theme = resolve_theme(cli);
275    let use_color = !cli.no_color && theme != ThemeName::None && std::env::var("NO_COLOR").is_err();
276
277    let mode = if cli.qualified_names {
278        NameDisplayMode::Qualified
279    } else {
280        NameDisplayMode::Simple
281    };
282
283    match (cli.csv, cli.tsv, cli.json) {
284        (true, _, _) => build_csv_formatter(cli, preview_config.as_ref(), &workspace_root, false),
285        (_, true, _) => build_csv_formatter(cli, preview_config.as_ref(), &workspace_root, true),
286        (_, _, true) => build_json_formatter(preview_config.as_ref(), &workspace_root),
287        _ => build_text_formatter(
288            preview_config.as_ref(),
289            &workspace_root,
290            use_color,
291            mode,
292            theme,
293        ),
294    }
295}
296
297pub(crate) fn resolve_theme(cli: &crate::args::Cli) -> ThemeName {
298    // CLI flag takes precedence unless it is the default and env is set.
299    if cli.theme != ThemeName::Default {
300        return cli.theme;
301    }
302
303    if let Ok(env_theme) = std::env::var("SQRY_THEME") {
304        match env_theme.to_lowercase().as_str() {
305            "default" => ThemeName::Default,
306            "dark" => ThemeName::Dark,
307            "light" => ThemeName::Light,
308            "none" => ThemeName::None,
309            _ => {
310                eprintln!(
311                    "Warning: unrecognized SQRY_THEME value '{env_theme}', using default theme"
312                );
313                ThemeName::Default
314            }
315        }
316    } else {
317        ThemeName::Default
318    }
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324    use crate::args::Cli;
325    use crate::large_stack_test;
326    use clap::Parser;
327    use serial_test::serial;
328
329    large_stack_test! {
330    #[test]
331    #[serial]
332    fn test_resolve_theme_env_fallback() {
333        // CLI default, env sets dark
334        unsafe {
335            std::env::set_var("SQRY_THEME", "dark");
336        }
337        let cli = Cli::parse_from(["sqry"]);
338        assert_eq!(resolve_theme(&cli), ThemeName::Dark);
339        unsafe {
340            std::env::remove_var("SQRY_THEME");
341        }
342    }
343    }
344
345    large_stack_test! {
346    #[test]
347    #[serial]
348    fn test_resolve_theme_cli_overrides_env() {
349        unsafe {
350            std::env::set_var("SQRY_THEME", "dark");
351        }
352        let cli = Cli::parse_from(["sqry", "--theme", "light"]);
353        assert_eq!(resolve_theme(&cli), ThemeName::Light);
354        unsafe {
355            std::env::remove_var("SQRY_THEME");
356        }
357    }
358    }
359}
360
361/// Name display preference for human-readable output.
362#[derive(Clone, Copy, Debug, PartialEq, Eq)]
363pub enum NameDisplayMode {
364    Simple,
365    Qualified,
366}
367
368/// Display data for a symbol, constructed directly from graph queries.
369///
370/// This replaces the legacy `Symbol` wrapper, holding all display data
371/// as owned values without depending on the deprecated Symbol type.
372#[derive(Clone, Debug)]
373pub struct DisplaySymbol {
374    /// Symbol name
375    pub name: String,
376    /// Fully qualified name
377    pub qualified_name: String,
378    /// Symbol kind as string (e.g., "function", "class")
379    pub kind: String,
380    /// Full file path
381    pub file_path: PathBuf,
382    /// Start line (1-based)
383    pub start_line: usize,
384    /// Start column (0-based)
385    pub start_column: usize,
386    /// End line (1-based)
387    pub end_line: usize,
388    /// End column (0-based)
389    pub end_column: usize,
390    /// Additional metadata
391    pub metadata: HashMap<String, String>,
392    /// Caller identity for relation queries
393    pub caller_identity: Option<CallIdentityMetadata>,
394    /// Callee identity for relation queries
395    pub callee_identity: Option<CallIdentityMetadata>,
396}
397
398impl DisplaySymbol {
399    /// Create a `DisplaySymbol` from a `QueryMatch`.
400    #[must_use]
401    pub fn from_query_match(m: &QueryMatch<'_>) -> Self {
402        let name = m.name().map(|s| s.to_string()).unwrap_or_default();
403        let language = m.language().map_or_else(
404            || "unknown".to_string(),
405            |l| l.to_string().to_ascii_lowercase(),
406        );
407        let qualified_name = m
408            .qualified_name()
409            .map_or_else(|| name.clone(), |s| s.to_string());
410        let file_path = m.file_path().map(|p| p.to_path_buf()).unwrap_or_default();
411        let kind = node_kind_to_string(m.kind()).to_string();
412
413        let mut metadata = HashMap::new();
414        metadata.insert(
415            "__raw_file_path".to_string(),
416            file_path.display().to_string(),
417        );
418        metadata.insert("__raw_language".to_string(), language);
419
420        // Add visibility metadata if present
421        if let Some(vis) = m.visibility() {
422            metadata.insert("visibility".to_string(), vis.to_string());
423        }
424
425        // Add async/static flags if true (avoid cluttering output with false values)
426        if m.is_async() {
427            metadata.insert("async".to_string(), "true".to_string());
428        }
429        if m.is_static() {
430            metadata.insert("static".to_string(), "true".to_string());
431        }
432
433        Self {
434            name,
435            qualified_name,
436            kind,
437            file_path,
438            start_line: m.start_line() as usize,
439            start_column: m.start_column() as usize,
440            end_line: m.end_line() as usize,
441            end_column: m.end_column() as usize,
442            metadata,
443            caller_identity: None,
444            callee_identity: None,
445        }
446    }
447
448    /// Create with caller identity for relation queries.
449    #[must_use]
450    pub fn with_caller_identity(mut self, identity: Option<CallIdentityMetadata>) -> Self {
451        self.caller_identity = identity;
452        self
453    }
454
455    /// Create with callee identity for relation queries.
456    #[must_use]
457    pub fn with_callee_identity(mut self, identity: Option<CallIdentityMetadata>) -> Self {
458        self.callee_identity = identity;
459        self
460    }
461
462    /// Get the symbol kind as a string.
463    #[must_use]
464    pub fn kind_string(&self) -> &str {
465        &self.kind
466    }
467}
468
469/// Convert `NodeKind` to lowercase string for display.
470fn node_kind_to_string(kind: NodeKind) -> &'static str {
471    match kind {
472        NodeKind::Function => "function",
473        NodeKind::Method => "method",
474        NodeKind::Class => "class",
475        NodeKind::Interface => "interface",
476        NodeKind::Trait => "trait",
477        NodeKind::Module => "module",
478        NodeKind::Variable => "variable",
479        NodeKind::Constant => "constant",
480        NodeKind::Type => "type",
481        NodeKind::Struct => "struct",
482        NodeKind::Enum => "enum",
483        NodeKind::EnumVariant => "enum_variant",
484        NodeKind::Macro => "macro",
485        NodeKind::Parameter => "parameter",
486        NodeKind::Property => "property",
487        NodeKind::Import => "import",
488        NodeKind::Export => "export",
489        NodeKind::Component => "component",
490        NodeKind::Service => "service",
491        NodeKind::Resource => "resource",
492        NodeKind::Endpoint => "endpoint",
493        NodeKind::Test => "test",
494        NodeKind::CallSite => "call_site",
495        NodeKind::StyleRule => "style_rule",
496        NodeKind::StyleAtRule => "style_at_rule",
497        NodeKind::StyleVariable => "style_variable",
498        NodeKind::Lifetime => "lifetime",
499        NodeKind::Other => "other",
500    }
501}