1use super::{
6 ContextLines, DisplaySymbol, Formatter, FormatterMetadata, OutputStreams, PreviewConfig,
7 PreviewExtractor, display_qualified_name,
8};
9use anyhow::Result;
10use serde::Serialize;
11use sqry_core::json_response::{JsonResponse, QueryMeta, Stats};
12use sqry_core::relations::{CallIdentityKind, CallIdentityMetadata};
13use sqry_core::workspace::NodeWithRepo;
14use std::collections::HashMap;
15use std::path::PathBuf;
16
17pub struct JsonFormatter {
19 preview_config: Option<PreviewConfig>,
20 workspace_root: PathBuf,
21}
22
23impl JsonFormatter {
24 #[must_use]
26 pub fn new() -> Self {
27 Self {
28 preview_config: None,
29 workspace_root: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
30 }
31 }
32
33 #[must_use]
35 pub fn with_preview(mut self, config: PreviewConfig, workspace_root: PathBuf) -> Self {
36 self.preview_config = Some(config);
37 self.workspace_root = workspace_root;
38 self
39 }
40
41 pub fn format_workspace(symbols: &[NodeWithRepo], streams: &mut OutputStreams) -> Result<()> {
46 #[derive(Serialize)]
47 struct Repo<'a> {
48 id: &'a str,
49 name: &'a str,
50 path: String,
51 }
52
53 #[derive(Serialize)]
54 struct WorkspaceResult<'a> {
55 repo: Repo<'a>,
56 #[serde(flatten)]
57 symbol: JsonSymbol,
58 }
59
60 let payload: Vec<_> = symbols
61 .iter()
62 .map(|entry| {
63 let info = &entry.match_info;
64 let mut metadata = HashMap::new();
65 if let Some(language) = &info.language {
66 metadata.insert("__raw_language".to_string(), language.clone());
67 }
68 if info.is_static {
69 metadata.insert("static".to_string(), "true".to_string());
70 }
71 let qualified_name = display_qualified_name(
72 info.qualified_name.as_deref().unwrap_or(info.name.as_str()),
73 info.kind.as_str(),
74 info.language.as_deref(),
75 info.is_static,
76 );
77 WorkspaceResult {
78 repo: Repo {
79 id: entry.repo_id.as_str(),
80 name: &entry.repo_name,
81 path: entry.repo_path.display().to_string(),
82 },
83 symbol: JsonSymbol {
84 name: info.name.clone(),
85 qualified_name,
86 kind: info.kind.as_str().to_string(),
87 file_path: info.file_path.display().to_string(),
88 start_line: info.start_line as usize,
89 start_column: info.start_column as usize,
90 end_line: info.end_line as usize,
91 end_column: info.end_column as usize,
92 metadata,
93 caller_identity: None,
94 callee_identity: None,
95 context: None,
96 },
97 }
98 })
99 .collect();
100
101 let json = serde_json::to_string_pretty(&payload)?;
102 streams.write_result(&json)?;
103 Ok(())
104 }
105}
106
107impl Default for JsonFormatter {
108 fn default() -> Self {
109 Self::new()
110 }
111}
112
113impl Formatter for JsonFormatter {
114 fn format(
115 &self,
116 symbols: &[DisplaySymbol],
117 metadata: Option<&FormatterMetadata>,
118 streams: &mut super::OutputStreams,
119 ) -> Result<()> {
120 let mut preview_extractor = self
122 .preview_config
123 .as_ref()
124 .map(|config| PreviewExtractor::new(config.clone(), self.workspace_root.clone()));
125
126 let mut results: Vec<JsonSymbol> = Vec::with_capacity(symbols.len());
127
128 for display in symbols {
129 let mut json_symbol = JsonSymbol::from(display);
130 if let Some(ref mut extractor) = preview_extractor {
131 let ctx = extractor.extract(&display.file_path, display.start_line)?;
132 json_symbol.context = Some(JsonContext::from_context_lines(&ctx));
133 }
134 results.push(json_symbol);
135 }
136
137 let json = if let Some(meta) = metadata {
139 let query_meta = QueryMeta::new(meta.pattern.clone(), meta.execution_time)
141 .with_filters(meta.filters.clone());
142
143 let mut stats = Stats::new(meta.total_matches, results.len());
144 if let Some(age) = meta.index_age_seconds {
145 stats = stats.with_index_age(age);
146 }
147 if let Some(is_ancestor) = meta.used_ancestor_index {
149 stats = stats.with_scope_info(is_ancestor, meta.filtered_to.clone());
150 }
151
152 let response = JsonResponse::new(query_meta, stats, results);
153 serde_json::to_string_pretty(&response)?
154 } else {
155 serde_json::to_string_pretty(&results)?
157 };
158
159 streams.write_result(&json)?;
160 Ok(())
161 }
162}
163
164#[derive(Debug, Serialize)]
166pub struct JsonSymbol {
167 name: String,
168 qualified_name: String,
169 kind: String,
170 file_path: String,
171 start_line: usize,
172 start_column: usize,
173 end_line: usize,
174 end_column: usize,
175 #[serde(skip_serializing_if = "HashMap::is_empty")]
176 metadata: HashMap<String, String>,
177 #[serde(skip_serializing_if = "Option::is_none")]
178 caller_identity: Option<JsonCallerIdentity>,
179 #[serde(skip_serializing_if = "Option::is_none")]
180 callee_identity: Option<JsonCallerIdentity>,
181 #[serde(skip_serializing_if = "Option::is_none")]
182 pub context: Option<JsonContext>,
183}
184
185#[derive(Debug, Serialize)]
186struct JsonCallerIdentity {
187 qualified: String,
188 simple: String,
189 namespace: Vec<String>,
190 method_kind: &'static str,
191 #[serde(skip_serializing_if = "Option::is_none")]
192 receiver: Option<String>,
193}
194
195#[derive(Debug, Serialize)]
196pub struct JsonContext {
197 pub before: Vec<String>,
198 pub line: String,
199 pub after: Vec<String>,
200 pub line_numbers: JsonLineNumbers,
201 #[serde(skip_serializing_if = "Option::is_none")]
202 pub error: Option<String>,
203}
204
205#[derive(Debug, Serialize)]
206pub struct JsonLineNumbers {
207 pub before: Vec<usize>,
208 pub matched: usize,
209 pub after: Vec<usize>,
210}
211
212impl JsonContext {
213 fn from_context_lines(ctx: &ContextLines) -> Self {
214 if ctx.is_error() {
215 return Self {
216 before: Vec::new(),
217 line: String::new(),
218 after: Vec::new(),
219 line_numbers: JsonLineNumbers::empty(),
220 error: ctx.error_message().map(ToOwned::to_owned),
221 };
222 }
223
224 Self {
225 before: ctx.before.iter().map(|l| l.content.clone()).collect(),
226 line: ctx.matched.content.clone(),
227 after: ctx.after.iter().map(|l| l.content.clone()).collect(),
228 line_numbers: JsonLineNumbers::from_context(ctx),
229 error: None,
230 }
231 }
232}
233
234impl JsonLineNumbers {
235 fn from_context(ctx: &ContextLines) -> Self {
236 Self {
237 before: ctx.before.iter().map(|l| l.line_number).collect(),
238 matched: ctx.matched.line_number,
239 after: ctx.after.iter().map(|l| l.line_number).collect(),
240 }
241 }
242
243 fn empty() -> Self {
244 Self {
245 before: Vec::new(),
246 matched: 0,
247 after: Vec::new(),
248 }
249 }
250}
251
252impl From<&DisplaySymbol> for JsonSymbol {
253 fn from(display: &DisplaySymbol) -> Self {
254 let language = display
255 .metadata
256 .get("__raw_language")
257 .map(std::string::String::as_str)
258 .filter(|language| *language != "unknown");
259 let is_static = display
260 .metadata
261 .get("static")
262 .is_some_and(|value| value == "true");
263
264 Self {
265 name: display.name.clone(),
266 qualified_name: display_qualified_name(
267 &display.qualified_name,
268 &display.kind,
269 language,
270 is_static,
271 ),
272 kind: display.kind.clone(),
273 file_path: display.file_path.display().to_string(),
274 start_line: display.start_line,
275 start_column: display.start_column,
276 end_line: display.end_line,
277 end_column: display.end_column,
278 metadata: display.metadata.clone(),
279 caller_identity: display
280 .caller_identity
281 .as_ref()
282 .map(JsonCallerIdentity::from),
283 callee_identity: display
284 .callee_identity
285 .as_ref()
286 .map(JsonCallerIdentity::from),
287 context: None,
288 }
289 }
290}
291
292impl From<&CallIdentityMetadata> for JsonCallerIdentity {
293 fn from(identity: &CallIdentityMetadata) -> Self {
294 Self {
295 qualified: identity.qualified.clone(),
296 simple: identity.simple.clone(),
297 namespace: identity.namespace.clone(),
298 method_kind: match identity.method_kind {
299 CallIdentityKind::Instance => "instance",
300 CallIdentityKind::Singleton => "singleton",
301 CallIdentityKind::SingletonClass => "singleton_class",
302 },
303 receiver: identity.receiver.clone(),
304 }
305 }
306}
307
308#[cfg(test)]
309mod tests {
310 use super::*;
311 use crate::output::TestOutputStreams;
312 use std::fs;
313 use std::path::PathBuf;
314 use tempfile::TempDir;
315
316 fn make_display_symbol(name: &str, kind: &str, path: PathBuf, line: usize) -> DisplaySymbol {
317 DisplaySymbol {
318 name: name.to_string(),
319 qualified_name: name.to_string(),
320 kind: kind.to_string(),
321 file_path: path,
322 start_line: line,
323 start_column: 1,
324 end_line: line,
325 end_column: 5,
326 metadata: HashMap::new(),
327 caller_identity: None,
328 callee_identity: None,
329 }
330 }
331
332 #[test]
333 fn test_json_symbol_from_display_symbol() {
334 let display = make_display_symbol(
335 "test_function",
336 "function",
337 PathBuf::from("src/test.rs"),
338 10,
339 );
340
341 let json_symbol = JsonSymbol::from(&display);
342 assert_eq!(json_symbol.name, "test_function");
343 assert_eq!(json_symbol.kind, "function");
344 assert_eq!(json_symbol.file_path, "src/test.rs");
345 assert_eq!(json_symbol.start_line, 10);
346 }
347
348 #[test]
349 fn test_json_formatter_empty() {
350 use crate::output::OutputStreams;
351
352 let formatter = JsonFormatter::new();
353 let symbols: Vec<DisplaySymbol> = Vec::new();
354 let mut streams = OutputStreams::new();
355
356 let result = formatter.format(&symbols, None, &mut streams);
357 assert!(result.is_ok());
358 }
359
360 #[test]
361 fn test_json_formatter_with_preview() {
362 let tmp = TempDir::new().unwrap();
363 let path = tmp.path().join("sample.rs");
364 fs::write(&path, "fn sample() {}\n").unwrap();
365
366 let sym = make_display_symbol("sample", "function", path, 1);
367
368 let formatter =
369 JsonFormatter::new().with_preview(PreviewConfig::new(1), tmp.path().to_path_buf());
370 let (test, mut streams) = TestOutputStreams::new();
371
372 formatter.format(&[sym], None, &mut streams).unwrap();
373
374 let out = test.stdout_string();
375 assert!(out.contains("\"context\""), "context missing: {out}");
376 assert!(out.contains("fn sample()"), "preview line missing: {out}");
377 }
378}