1use 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
18pub 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 #[must_use]
30 pub fn new(use_color: bool, display_mode: NameDisplayMode, theme: ThemeName) -> Self {
31 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 #[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 #[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 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 fn format_kind(&self, display: &DisplaySymbol) -> String {
70 self.palette
71 .kind
72 .apply(display.kind_string(), self.use_color)
73 }
74
75 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 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 #[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 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 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 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 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 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 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 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 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}