1mod 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#[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#[derive(Debug, Clone)]
41pub struct FormatterMetadata {
42 pub pattern: Option<String>,
44 pub total_matches: usize,
46 pub execution_time: Duration,
48 pub filters: Filters,
50 pub index_age_seconds: Option<u64>,
52 pub used_ancestor_index: Option<bool>,
54 pub filtered_to: Option<String>,
56}
57
58pub trait Formatter {
60 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#[must_use]
267pub fn create_formatter(cli: &crate::args::Cli) -> Box<dyn Formatter> {
268 let preview_config = build_preview_config(cli);
270
271 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 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 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
363pub enum NameDisplayMode {
364 Simple,
365 Qualified,
366}
367
368#[derive(Clone, Debug)]
373pub struct DisplaySymbol {
374 pub name: String,
376 pub qualified_name: String,
378 pub kind: String,
380 pub file_path: PathBuf,
382 pub start_line: usize,
384 pub start_column: usize,
386 pub end_line: usize,
388 pub end_column: usize,
390 pub metadata: HashMap<String, String>,
392 pub caller_identity: Option<CallIdentityMetadata>,
394 pub callee_identity: Option<CallIdentityMetadata>,
396}
397
398impl DisplaySymbol {
399 #[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 if let Some(vis) = m.visibility() {
422 metadata.insert("visibility".to_string(), vis.to_string());
423 }
424
425 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 #[must_use]
450 pub fn with_caller_identity(mut self, identity: Option<CallIdentityMetadata>) -> Self {
451 self.caller_identity = identity;
452 self
453 }
454
455 #[must_use]
457 pub fn with_callee_identity(mut self, identity: Option<CallIdentityMetadata>) -> Self {
458 self.callee_identity = identity;
459 self
460 }
461
462 #[must_use]
464 pub fn kind_string(&self) -> &str {
465 &self.kind
466 }
467}
468
469fn 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}