Skip to main content

sqry_cli/output/
text.rs

1//! Text output formatter with optional colors
2//!
3//! Uses `DisplaySymbol` directly without deprecated Symbol type.
4
5use super::{
6    DisplaySymbol, Formatter, GroupedContext, MatchLocation, NameDisplayMode, OutputStreams,
7    Palette, PreviewConfig, PreviewExtractor, ThemeName, display_qualified_name,
8};
9use anyhow::Result;
10use sqry_core::workspace::NodeWithRepo;
11use std::collections::HashMap;
12use std::path::{Path, PathBuf};
13
14const MAX_ALIGN_WIDTH: usize = 80;
15
16type MatchMap<'a> = HashMap<(PathBuf, usize), Vec<&'a DisplaySymbol>>;
17
18/// Text formatter for human-readable output
19pub struct TextFormatter {
20    use_color: bool,
21    display_mode: NameDisplayMode,
22    palette: Palette,
23    preview_config: Option<PreviewConfig>,
24    workspace_root: PathBuf,
25}
26
27impl TextFormatter {
28    /// Create new text formatter
29    #[must_use]
30    pub fn new(use_color: bool, display_mode: NameDisplayMode, theme: ThemeName) -> Self {
31        // Respect NO_COLOR environment variable (handled by caller) and theme=none
32        let use_color = use_color && theme != ThemeName::None && std::env::var("NO_COLOR").is_err();
33
34        if !use_color {
35            colored::control::set_override(false);
36        }
37
38        Self {
39            use_color,
40            display_mode,
41            palette: Palette::built_in(theme),
42            preview_config: None,
43            workspace_root: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
44        }
45    }
46
47    /// Enable preview rendering with the given configuration and workspace root
48    #[must_use]
49    pub fn with_preview(mut self, config: PreviewConfig, workspace_root: PathBuf) -> Self {
50        self.preview_config = Some(config);
51        self.workspace_root = workspace_root;
52        self
53    }
54
55    /// Format file path with color
56    #[allow(dead_code)]
57    fn format_path(&self, path: &std::path::Path) -> String {
58        let path_str = path.display().to_string();
59        self.palette.path.apply(&path_str, self.use_color)
60    }
61
62    /// Format line:column with color
63    fn format_location(&self, line: usize, column: usize) -> String {
64        let loc = format!("{line}:{column}");
65        self.palette.location.apply(&loc, self.use_color)
66    }
67
68    /// Format symbol kind with color
69    fn format_kind(&self, display: &DisplaySymbol) -> String {
70        self.palette
71            .kind
72            .apply(display.kind_string(), self.use_color)
73    }
74
75    /// Format symbol name (bold if color enabled)
76    fn format_name(&self, name: &str) -> String {
77        self.palette.name.apply(name, self.use_color)
78    }
79
80    fn format_path_str(&self, path: &str) -> String {
81        self.palette.path.apply(path, self.use_color)
82    }
83
84    /// Shorten a long string by keeping prefix/suffix and inserting ellipsis in the middle.
85    fn shorten_middle(s: &str, max_len: usize) -> String {
86        if s.chars().count() <= max_len || max_len < 5 {
87            return s.to_string();
88        }
89        let ellipsis = "...";
90        let keep = (max_len.saturating_sub(ellipsis.len())) / 2;
91        let prefix: String = s.chars().take(keep).collect();
92        let suffix: String = s
93            .chars()
94            .rev()
95            .take(keep)
96            .collect::<String>()
97            .chars()
98            .rev()
99            .collect();
100        format!("{prefix}{ellipsis}{suffix}")
101    }
102
103    /// Format workspace results that include repository metadata.
104    /// P2-3 Step 2e: Text formatting is display/logging code - deprecated accessors allowed
105    ///
106    /// # Errors
107    /// Returns an error if writing to the output streams fails.
108    #[allow(deprecated)]
109    pub fn format_workspace(
110        &self,
111        symbols: &[NodeWithRepo],
112        streams: &mut OutputStreams,
113    ) -> Result<()> {
114        if symbols.is_empty() {
115            let msg = self
116                .palette
117                .dimmed
118                .apply("No workspace matches", self.use_color);
119            streams.write_diagnostic(&msg)?;
120            return Ok(());
121        }
122
123        let mut align_width = 0;
124        let mut formatted: Vec<(String, usize, String)> = Vec::with_capacity(symbols.len());
125
126        for entry in symbols {
127            let info = &entry.match_info;
128            let repo_segment = format!(
129                "{} {}",
130                self.palette.repo_label.apply("repo", self.use_color),
131                self.palette
132                    .repo_name
133                    .apply(entry.repo_name.as_str(), self.use_color)
134            );
135
136            let display_name_text = if self.display_mode == NameDisplayMode::Qualified {
137                display_qualified_name(
138                    info.qualified_name.as_deref().unwrap_or(info.name.as_str()),
139                    info.kind.as_str(),
140                    info.language.as_deref(),
141                    info.is_static,
142                )
143            } else {
144                info.name.clone()
145            };
146            let display_name = self.format_name(&display_name_text);
147
148            let loc_raw =
149                self.format_location(info.start_line as usize, info.start_column as usize);
150            let path_budget = MAX_ALIGN_WIDTH.saturating_sub(loc_raw.chars().count() + 1);
151            let path_raw = Self::shorten_middle(&info.file_path.display().to_string(), path_budget);
152            let path_colored = self.format_path_str(&path_raw);
153            let loc_colored = self.palette.location.apply(&loc_raw, self.use_color);
154            let path_loc_raw = format!("{path_raw}:{loc_raw}");
155            let path_loc_colored = format!("{path_colored}:{loc_colored}");
156            let width = path_loc_raw.chars().count();
157            align_width = align_width.max(width);
158
159            let kind_str = info.kind.as_str();
160            let kind_colored = self.palette.kind.apply(kind_str, self.use_color);
161            let tail = format!("{path_loc_colored} {kind_colored} {display_name}");
162
163            formatted.push((repo_segment, width, tail));
164        }
165
166        align_width = align_width.min(MAX_ALIGN_WIDTH);
167
168        for (repo_segment, raw_width, tail) in formatted {
169            let pad = align_width.saturating_sub(raw_width);
170            let line = format!(
171                "{repo_segment} {tail:>width$}",
172                tail = tail,
173                width = tail.len() + pad
174            );
175            streams.write_result(&line)?;
176        }
177
178        let summary = format!(
179            "\n{} workspace matches",
180            self.palette
181                .name
182                .apply(&symbols.len().to_string(), self.use_color)
183        );
184        streams.write_diagnostic(&summary)?;
185        Ok(())
186    }
187}
188
189impl Formatter for TextFormatter {
190    fn format(
191        &self,
192        symbols: &[DisplaySymbol],
193        _metadata: Option<&super::FormatterMetadata>,
194        streams: &mut super::OutputStreams,
195    ) -> Result<()> {
196        if symbols.is_empty() {
197            let msg = self
198                .palette
199                .dimmed
200                .apply("No matches found", self.use_color);
201            streams.write_diagnostic(&msg)?;
202            return Ok(());
203        }
204
205        let mut preview_extractor = self
206            .preview_config
207            .as_ref()
208            .map(|config| PreviewExtractor::new(config.clone(), self.workspace_root.clone()));
209
210        let mut align_width = 0;
211        let mut formatted: Vec<(String, usize, String, String)> = Vec::with_capacity(symbols.len());
212
213        for display in symbols {
214            let loc_raw = self.format_location(display.start_line, display.start_column);
215            let path_budget = MAX_ALIGN_WIDTH.saturating_sub(loc_raw.chars().count() + 1);
216            let path_raw =
217                Self::shorten_middle(&display.file_path.display().to_string(), path_budget);
218            let path_colored = self.format_path_str(&path_raw);
219            let loc_colored = self.palette.location.apply(&loc_raw, self.use_color);
220            let path_loc_raw = format!("{path_raw}:{loc_raw}");
221            let path_loc_colored = format!("{path_colored}:{loc_colored}");
222            let width = path_loc_raw.chars().count();
223            align_width = align_width.max(width);
224            formatted.push((
225                path_loc_colored,
226                width,
227                self.format_kind(display),
228                self.format_display_name(display),
229            ));
230        }
231
232        align_width = align_width.min(MAX_ALIGN_WIDTH);
233
234        for (path_loc, raw_width, kind, name) in formatted {
235            let pad = align_width.saturating_sub(raw_width);
236            let line = format!("{path_loc}{:pad$} {kind} {name}", "", pad = pad);
237            streams.write_result(&line)?;
238        }
239
240        if let Some(ref mut extractor) = preview_extractor {
241            self.write_grouped_previews(symbols, extractor, streams)?;
242        }
243
244        // Summary to stderr
245        let summary = format!(
246            "\n{} matches found",
247            self.palette
248                .name
249                .apply(&symbols.len().to_string(), self.use_color)
250        );
251        streams.write_diagnostic(&summary)?;
252
253        Ok(())
254    }
255}
256
257impl TextFormatter {
258    fn format_display_name(&self, display: &DisplaySymbol) -> String {
259        let simple = &display.name;
260        let language = display.metadata.get("__raw_language").map(String::as_str);
261        let is_static = display
262            .metadata
263            .get("static")
264            .is_some_and(|value| value == "true");
265
266        match self.display_mode {
267            NameDisplayMode::Simple => self.format_name(simple),
268            NameDisplayMode::Qualified => {
269                let qualified_opt = display
270                    .caller_identity
271                    .as_ref()
272                    .or(display.callee_identity.as_ref())
273                    .map(|identity| identity.qualified.clone())
274                    .filter(|q| !q.is_empty())
275                    .or({
276                        if display.qualified_name.is_empty() {
277                            None
278                        } else {
279                            Some(display_qualified_name(
280                                &display.qualified_name,
281                                &display.kind,
282                                language,
283                                is_static,
284                            ))
285                        }
286                    });
287
288                if let Some(qualified) = qualified_opt {
289                    let simple_looks_qualified = simple.contains("::")
290                        || simple.contains('.')
291                        || simple.contains('#')
292                        || simple.contains('\\');
293
294                    if qualified == *simple || simple_looks_qualified {
295                        self.format_name(&qualified)
296                    } else {
297                        format!(
298                            "{} ({})",
299                            self.format_name(&qualified),
300                            self.format_name(simple)
301                        )
302                    }
303                } else {
304                    self.format_name(simple)
305                }
306            }
307        }
308    }
309
310    fn write_grouped_previews(
311        &self,
312        symbols: &[DisplaySymbol],
313        extractor: &mut PreviewExtractor,
314        streams: &mut OutputStreams,
315    ) -> Result<()> {
316        if symbols.is_empty() {
317            return Ok(());
318        }
319
320        let (matches, match_map) = Self::build_match_context(symbols);
321        let mut grouped = extractor.extract_grouped(&matches);
322        Self::sort_grouped_contexts(&mut grouped);
323        let gutter_width = Self::compute_gutter_width(&grouped);
324
325        if !grouped.is_empty() {
326            streams.write_result("")?;
327        }
328
329        for group in &grouped {
330            self.write_grouped_preview_group(group, &match_map, gutter_width, streams)?;
331        }
332
333        Ok(())
334    }
335}
336
337impl TextFormatter {
338    fn build_match_context<'a>(symbols: &'a [DisplaySymbol]) -> (Vec<MatchLocation>, MatchMap<'a>) {
339        let mut matches = Vec::with_capacity(symbols.len());
340        let mut match_map: MatchMap<'a> = HashMap::new();
341
342        for display in symbols {
343            let file = display.file_path.clone();
344            matches.push(MatchLocation {
345                file: file.clone(),
346                line: display.start_line,
347            });
348            match_map
349                .entry((file, display.start_line))
350                .or_default()
351                .push(display);
352        }
353
354        (matches, match_map)
355    }
356
357    fn sort_grouped_contexts(grouped: &mut [GroupedContext]) {
358        grouped.sort_by(|a, b| {
359            a.file
360                .cmp(&b.file)
361                .then(a.start_line.cmp(&b.start_line))
362                .then(a.end_line.cmp(&b.end_line))
363        });
364    }
365
366    fn compute_gutter_width(grouped: &[GroupedContext]) -> usize {
367        grouped
368            .iter()
369            .flat_map(|g| g.lines.iter().map(|l| l.line_number.to_string().len()))
370            .max()
371            .unwrap_or(1)
372    }
373
374    fn write_grouped_preview_group(
375        &self,
376        group: &GroupedContext,
377        match_map: &MatchMap<'_>,
378        gutter_width: usize,
379        streams: &mut OutputStreams,
380    ) -> Result<()> {
381        let file_fmt = self.format_group_file(group);
382
383        if let Some(err) = &group.error {
384            streams.write_result(&format!("{file_fmt}: {err}"))?;
385            streams.write_result("")?;
386            return Ok(());
387        }
388
389        streams.write_result(&format!(
390            "{file_fmt}: lines {}-{}",
391            group.start_line, group.end_line
392        ))?;
393
394        for line in &group.lines {
395            let marker = self.group_line_marker(line.is_match);
396            let gutter = format!("{:>width$}", line.line_number, width = gutter_width);
397            let content =
398                self.decorate_grouped_line(&group.file, line.line_number, &line.content, match_map);
399            streams.write_result(&format!("{marker} {gutter} | {content}"))?;
400        }
401
402        streams.write_result("")?;
403        Ok(())
404    }
405
406    fn format_group_file(&self, group: &GroupedContext) -> String {
407        let file_str = group.file.display().to_string();
408        self.palette.path.apply(&file_str, self.use_color)
409    }
410
411    fn group_line_marker(&self, is_match: bool) -> String {
412        if is_match {
413            self.palette.name.apply(">", self.use_color)
414        } else {
415            " ".to_string()
416        }
417    }
418
419    fn decorate_grouped_line(
420        &self,
421        file: &Path,
422        line_number: usize,
423        content: &str,
424        match_map: &MatchMap<'_>,
425    ) -> String {
426        let mut content = content.to_string();
427        if let Some(symbols_at_line) = match_map.get(&(file.to_path_buf(), line_number))
428            && let Some(annotations) = self.build_line_annotation(symbols_at_line)
429        {
430            content = format!("{content}  // {annotations}");
431        }
432        content
433    }
434
435    fn build_line_annotation(&self, symbols_at_line: &[&DisplaySymbol]) -> Option<String> {
436        let annotations: Vec<String> = symbols_at_line
437            .iter()
438            .map(|d| format!("{} {}", d.kind_string(), self.format_display_name(d)))
439            .collect();
440        (!annotations.is_empty()).then(|| annotations.join("; "))
441    }
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447
448    use crate::output::TestOutputStreams;
449    use std::fs;
450    use std::path::PathBuf;
451    use tempfile::TempDir;
452
453    fn make_display_symbol(name: &str, kind: &str, path: PathBuf, line: usize) -> DisplaySymbol {
454        DisplaySymbol {
455            name: name.to_string(),
456            qualified_name: name.to_string(),
457            kind: kind.to_string(),
458            file_path: path,
459            start_line: line,
460            start_column: 1,
461            end_line: line,
462            end_column: 5,
463            metadata: HashMap::new(),
464            caller_identity: None,
465            callee_identity: None,
466        }
467    }
468
469    #[test]
470    fn test_text_formatter_no_color() {
471        let formatter = TextFormatter::new(false, NameDisplayMode::Simple, ThemeName::Default);
472        assert!(!formatter.use_color);
473
474        let path = formatter.format_path(&PathBuf::from("test.rs"));
475        assert_eq!(path, "test.rs");
476
477        let loc = formatter.format_location(10, 5);
478        assert_eq!(loc, "10:5");
479
480        let name = formatter.format_name("main");
481        assert_eq!(name, "main");
482    }
483
484    #[serial_test::serial]
485    #[test]
486    fn test_text_formatter_respects_no_color_env() {
487        unsafe {
488            std::env::set_var("NO_COLOR", "1");
489        }
490        let formatter = TextFormatter::new(true, NameDisplayMode::Simple, ThemeName::Default);
491        assert!(!formatter.use_color);
492        unsafe {
493            std::env::remove_var("NO_COLOR");
494        }
495    }
496
497    #[test]
498    fn test_text_formatter_none_theme_disables_color() {
499        let formatter = TextFormatter::new(true, NameDisplayMode::Simple, ThemeName::None);
500        assert!(!formatter.use_color);
501        let path = formatter.format_path(&PathBuf::from("file.rs"));
502        assert_eq!(path, "file.rs");
503    }
504
505    #[test]
506    fn test_shorten_middle() {
507        let s = "this/is/a/very/long/path.rs";
508        let shortened = TextFormatter::shorten_middle(s, 10);
509        // The contract is chars().count() <= max_len; byte length may exceed it
510        // for multi-byte characters, but for ASCII paths both are equivalent.
511        assert!(
512            shortened.chars().count() <= 10,
513            "shortened string has {} chars, expected <= 10: {shortened:?}",
514            shortened.chars().count()
515        );
516        assert!(shortened.contains("..."));
517    }
518
519    #[test]
520    fn test_text_formatter_with_preview_grouped() {
521        let tmp = TempDir::new().unwrap();
522        let path = tmp.path().join("sample.rs");
523        fs::write(&path, "fn a() {}\nfn b() {}\nfn c() {}\n").unwrap();
524
525        let sym1 = make_display_symbol("a", "function", path.clone(), 1);
526        let sym2 = make_display_symbol("b", "function", path.clone(), 2);
527
528        let formatter = TextFormatter::new(false, NameDisplayMode::Simple, ThemeName::Default)
529            .with_preview(PreviewConfig::new(1), tmp.path().to_path_buf());
530        let (test, mut streams) = TestOutputStreams::new();
531
532        formatter.format(&[sym1, sym2], None, &mut streams).unwrap();
533
534        let out = test.stdout_string();
535        assert!(out.contains("lines 1-3"), "preview header missing: {out}");
536        assert!(
537            out.contains("> 1 | fn a() {}") && out.contains("> 2 | fn b() {}"),
538            "match markers missing: {out}"
539        );
540    }
541
542    #[test]
543    fn test_shorten_middle_exact_fit() {
544        // String that exactly equals max_len should not be shortened
545        let s = "hello";
546        let result = TextFormatter::shorten_middle(s, 5);
547        assert_eq!(result, "hello");
548    }
549
550    #[test]
551    fn test_shorten_middle_short_max_len() {
552        // max_len < 5 → return original unchanged
553        let s = "hello world";
554        let result = TextFormatter::shorten_middle(s, 4);
555        assert_eq!(result, "hello world");
556    }
557
558    #[test]
559    fn test_shorten_middle_zero_max_len() {
560        let s = "hello world";
561        let result = TextFormatter::shorten_middle(s, 0);
562        assert_eq!(result, "hello world");
563    }
564
565    #[test]
566    fn test_shorten_middle_short_string() {
567        // String shorter than max_len: untouched
568        let s = "ab";
569        let result = TextFormatter::shorten_middle(s, 10);
570        assert_eq!(result, "ab");
571    }
572
573    #[test]
574    fn test_text_formatter_format_empty_symbols() {
575        let formatter = TextFormatter::new(false, NameDisplayMode::Simple, ThemeName::Default);
576        let (test, mut streams) = TestOutputStreams::new();
577        formatter.format(&[], None, &mut streams).unwrap();
578        let err = test.stderr_string();
579        assert!(
580            err.contains("No matches"),
581            "Expected 'No matches' diagnostic: {err}"
582        );
583    }
584
585    #[test]
586    fn test_text_formatter_format_with_symbol_simple_mode() {
587        let sym = make_display_symbol("my_function", "function", PathBuf::from("src/lib.rs"), 42);
588        let formatter = TextFormatter::new(false, NameDisplayMode::Simple, ThemeName::Default);
589        let (test, mut streams) = TestOutputStreams::new();
590        formatter.format(&[sym], None, &mut streams).unwrap();
591        let out = test.stdout_string();
592        assert!(out.contains("my_function"), "Expected symbol name: {out}");
593        assert!(out.contains("lib.rs"), "Expected file path: {out}");
594        assert!(out.contains("42"), "Expected line number: {out}");
595    }
596
597    #[test]
598    fn test_text_formatter_format_with_symbol_qualified_mode() {
599        let mut sym =
600            make_display_symbol("my_function", "function", PathBuf::from("src/lib.rs"), 10);
601        sym.qualified_name = "crate::module::my_function".to_string();
602        let formatter = TextFormatter::new(false, NameDisplayMode::Qualified, ThemeName::Default);
603        let (test, mut streams) = TestOutputStreams::new();
604        formatter.format(&[sym], None, &mut streams).unwrap();
605        let out = test.stdout_string();
606        // In qualified mode the qualified name is shown
607        assert!(
608            out.contains("my_function"),
609            "Expected function name in output: {out}"
610        );
611    }
612
613    #[test]
614    fn test_text_formatter_qualified_mode_with_caller_identity() {
615        use crate::output::CallIdentityMetadata;
616        use sqry_core::relations::CallIdentityKind;
617
618        let mut sym = make_display_symbol("show", "method", PathBuf::from("controllers.rb"), 5);
619        sym.caller_identity = Some(CallIdentityMetadata {
620            qualified: "UsersController#show".to_string(),
621            simple: "show".to_string(),
622            method_kind: CallIdentityKind::Instance,
623            namespace: vec!["UsersController".to_string()],
624            receiver: None,
625        });
626        let formatter = TextFormatter::new(false, NameDisplayMode::Qualified, ThemeName::Default);
627        let (test, mut streams) = TestOutputStreams::new();
628        formatter.format(&[sym], None, &mut streams).unwrap();
629        let out = test.stdout_string();
630        // In qualified mode the caller identity's qualified name is used, so the
631        // output must contain the full "UsersController#show" form, not just the
632        // simple method name.
633        assert!(
634            out.contains("UsersController#show"),
635            "Expected qualified caller identity 'UsersController#show' in output: {out}"
636        );
637    }
638
639    #[test]
640    fn test_text_formatter_format_multiple_symbols_alignment() {
641        let sym1 = make_display_symbol("alpha", "function", PathBuf::from("src/a.rs"), 1);
642        let sym2 = make_display_symbol(
643            "beta_long_name",
644            "method",
645            PathBuf::from("src/b/c/d.rs"),
646            200,
647        );
648        let formatter = TextFormatter::new(false, NameDisplayMode::Simple, ThemeName::Default);
649        let (test, mut streams) = TestOutputStreams::new();
650        formatter.format(&[sym1, sym2], None, &mut streams).unwrap();
651        let out = test.stdout_string();
652        assert!(out.contains("alpha"), "Expected alpha: {out}");
653        assert!(
654            out.contains("beta_long_name"),
655            "Expected beta_long_name: {out}"
656        );
657        // Summary goes to stderr
658        let err = test.stderr_string();
659        assert!(
660            err.contains("2 matches"),
661            "Expected match count in stderr: {err}"
662        );
663    }
664
665    #[test]
666    fn test_text_formatter_preview_missing_file() {
667        let tmp = TempDir::new().unwrap();
668        let path = tmp.path().join("missing.rs");
669
670        let sym = make_display_symbol("missing", "function", path, 1);
671
672        let formatter = TextFormatter::new(false, NameDisplayMode::Simple, ThemeName::Default)
673            .with_preview(PreviewConfig::new(1), tmp.path().to_path_buf());
674        let (test, mut streams) = TestOutputStreams::new();
675
676        formatter.format(&[sym], None, &mut streams).unwrap();
677
678        let out = test.stdout_string();
679        assert!(
680            out.contains("[file not found"),
681            "expected error preview: {out}"
682        );
683    }
684}