1use std::io::Write;
2use std::path::Path;
3
4use serde::Serialize;
5
6use crate::args::Args;
7use crate::extract;
8use crate::lines::line_range;
9use crate::predicate::{MatchSet, RefMatch};
10use crate::render_uri;
11use code_moniker_core::core::code_graph::DefRecord;
12use code_moniker_core::core::kinds::KIND_COMMENT;
13use code_moniker_core::core::uri::UriConfig;
14use code_moniker_core::lang::Lang;
15
16pub fn write_tsv<W: Write>(
17 w: &mut W,
18 matches: &MatchSet<'_>,
19 source: &str,
20 args: &Args,
21 scheme: &str,
22) -> std::io::Result<()> {
23 let cfg = UriConfig { scheme };
24 for d in &matches.defs {
25 let uri = render_uri(&d.moniker, &cfg);
26 write!(
27 w,
28 "def\t{uri}\t{kind}\t{pos}\t{lines}\t{vis}\t{sig}\t{origin}",
29 kind = utf8_or_dash(&d.kind),
30 pos = pos_or_dash(d.position),
31 lines = lines_or_dash(d.position, source),
32 vis = utf8_or_dash(&d.visibility),
33 sig = utf8_or_dash(&d.signature),
34 origin = utf8_or_dash(&d.origin),
35 )?;
36 if args.with_text && d.kind == KIND_COMMENT {
37 let text = comment_slice(source, d);
38 write!(w, "\t{}", escape_tsv(text))?;
39 }
40 writeln!(w)?;
41 }
42 for r in &matches.refs {
43 let target = render_uri(&r.record.target, &cfg);
44 let src_uri = render_uri(r.source, &cfg);
45 writeln!(
46 w,
47 "ref\t{target}\t{kind}\t{pos}\t{lines}\tsource={src_uri}\t{alias}\t{conf}\t{rcv}",
48 kind = utf8_or_dash(&r.record.kind),
49 pos = pos_or_dash(r.record.position),
50 lines = lines_or_dash(r.record.position, source),
51 alias = utf8_or_dash(&r.record.alias),
52 conf = utf8_or_dash(&r.record.confidence),
53 rcv = utf8_or_dash(&r.record.receiver_hint),
54 )?;
55 }
56 Ok(())
57}
58
59pub fn write_json<W: Write>(
60 w: &mut W,
61 matches: &MatchSet<'_>,
62 source: &str,
63 args: &Args,
64 lang: Lang,
65 path: &Path,
66 scheme: &str,
67) -> anyhow::Result<()> {
68 let out = JsonOutput {
69 uri: extract::file_uri(path),
70 lang: lang.tag(),
71 matches: build_matches(matches, source, args, scheme),
72 };
73 serde_json::to_writer_pretty(&mut *w, &out)?;
74 w.write_all(b"\n")?;
75 Ok(())
76}
77
78pub fn build_matches_value(
79 matches: &MatchSet<'_>,
80 source: &str,
81 args: &Args,
82 scheme: &str,
83) -> serde_json::Value {
84 serde_json::to_value(build_matches(matches, source, args, scheme))
85 .expect("Matches<'_> is always serializable")
86}
87
88fn build_matches<'a>(
89 matches: &'a MatchSet<'_>,
90 source: &'a str,
91 args: &Args,
92 scheme: &'a str,
93) -> Matches<'a> {
94 let cfg = UriConfig { scheme };
95 let defs: Vec<DefView> = matches
96 .defs
97 .iter()
98 .map(|d| DefView::from(d, &cfg, args.with_text, source))
99 .collect();
100 let refs: Vec<RefView> = matches
101 .refs
102 .iter()
103 .map(|r| RefView::from(r, &cfg, source))
104 .collect();
105 Matches { defs, refs }
106}
107
108#[derive(Serialize)]
109struct JsonOutput<'a> {
110 uri: String,
111 lang: &'a str,
112 matches: Matches<'a>,
113}
114
115#[derive(Serialize)]
116struct Matches<'a> {
117 defs: Vec<DefView<'a>>,
118 refs: Vec<RefView<'a>>,
119}
120
121#[derive(Serialize)]
122struct DefView<'a> {
123 moniker: String,
124 kind: &'a str,
125 #[serde(skip_serializing_if = "Option::is_none")]
126 position: Option<[u32; 2]>,
127 #[serde(skip_serializing_if = "Option::is_none")]
128 lines: Option<[u32; 2]>,
129 #[serde(skip_serializing_if = "Option::is_none")]
130 visibility: Option<&'a str>,
131 #[serde(skip_serializing_if = "Option::is_none")]
132 signature: Option<&'a str>,
133 #[serde(skip_serializing_if = "Option::is_none")]
134 binding: Option<&'a str>,
135 #[serde(skip_serializing_if = "Option::is_none")]
136 origin: Option<&'a str>,
137 #[serde(skip_serializing_if = "Option::is_none")]
138 text: Option<String>,
139}
140
141impl<'a> DefView<'a> {
142 fn from(d: &'a DefRecord, cfg: &UriConfig<'_>, with_text: bool, source: &str) -> Self {
143 let text = if with_text && d.kind == KIND_COMMENT {
144 Some(comment_slice(source, d).to_string())
145 } else {
146 None
147 };
148 Self {
149 moniker: render_uri(&d.moniker, cfg),
150 kind: std::str::from_utf8(&d.kind).unwrap_or(""),
151 position: d.position.map(|(l, c)| [l, c]),
152 lines: d.position.map(|(s, e)| {
153 let (a, b) = line_range(source, s, e);
154 [a, b]
155 }),
156 visibility: nullable(&d.visibility),
157 signature: nullable(&d.signature),
158 binding: nullable(&d.binding),
159 origin: nullable(&d.origin),
160 text,
161 }
162 }
163}
164
165#[derive(Serialize)]
166struct RefView<'a> {
167 source: String,
168 target: String,
169 kind: &'a str,
170 #[serde(skip_serializing_if = "Option::is_none")]
171 position: Option<[u32; 2]>,
172 #[serde(skip_serializing_if = "Option::is_none")]
173 lines: Option<[u32; 2]>,
174 #[serde(skip_serializing_if = "Option::is_none")]
175 alias: Option<&'a str>,
176 #[serde(skip_serializing_if = "Option::is_none")]
177 confidence: Option<&'a str>,
178 #[serde(skip_serializing_if = "Option::is_none")]
179 receiver_hint: Option<&'a str>,
180 #[serde(skip_serializing_if = "Option::is_none")]
181 binding: Option<&'a str>,
182}
183
184impl<'a> RefView<'a> {
185 fn from(r: &'a RefMatch<'a>, cfg: &UriConfig<'_>, source: &str) -> Self {
186 Self {
187 source: render_uri(r.source, cfg),
188 target: render_uri(&r.record.target, cfg),
189 kind: std::str::from_utf8(&r.record.kind).unwrap_or(""),
190 position: r.record.position.map(|(l, c)| [l, c]),
191 lines: r.record.position.map(|(s, e)| {
192 let (a, b) = line_range(source, s, e);
193 [a, b]
194 }),
195 alias: nullable(&r.record.alias),
196 confidence: nullable(&r.record.confidence),
197 receiver_hint: nullable(&r.record.receiver_hint),
198 binding: nullable(&r.record.binding),
199 }
200 }
201}
202
203fn nullable(b: &[u8]) -> Option<&str> {
204 if b.is_empty() {
205 None
206 } else {
207 std::str::from_utf8(b).ok()
208 }
209}
210
211fn utf8_or_dash(b: &[u8]) -> &str {
212 if b.is_empty() {
213 "-"
214 } else {
215 std::str::from_utf8(b).unwrap_or("-")
216 }
217}
218
219fn pos_or_dash(p: Option<(u32, u32)>) -> String {
220 match p {
221 Some((start, end)) => format!("{start}..{end}"),
222 None => "-".to_string(),
223 }
224}
225
226fn lines_or_dash(p: Option<(u32, u32)>, source: &str) -> String {
227 match p {
228 Some((start, end)) => {
229 let (a, b) = line_range(source, start, end);
230 format!("L{a}-L{b}")
231 }
232 None => "-".to_string(),
233 }
234}
235
236fn comment_slice<'a>(source: &'a str, d: &DefRecord) -> &'a str {
237 let Some((start, end)) = d.position else {
238 return "";
239 };
240 let bytes = source.as_bytes();
241 let s = (start as usize).min(bytes.len());
242 let e = (end as usize).min(bytes.len()).max(s);
243 std::str::from_utf8(&bytes[s..e]).unwrap_or("")
244}
245
246fn escape_tsv(s: &str) -> String {
247 s.replace('\\', "\\\\")
248 .replace('\t', "\\t")
249 .replace('\n', "\\n")
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255 use crate::args::OutputFormat;
256 use crate::predicate::MatchSet;
257 use code_moniker_core::core::code_graph::CodeGraph;
258 use code_moniker_core::core::moniker::{Moniker, MonikerBuilder};
259
260 fn args() -> Args {
261 Args {
262 file: Some("a.ts".into()),
263 where_: Vec::new(),
264 kind: vec![],
265 format: OutputFormat::Tsv,
266 count: false,
267 quiet: false,
268 with_text: false,
269 scheme: None,
270 cache: None,
271 }
272 }
273
274 fn build_graph_with_class_and_method() -> (CodeGraph, Moniker, Moniker) {
275 let mut b = MonikerBuilder::new();
276 b.project(b"app");
277 let root = b.build();
278 let mut g = CodeGraph::new(root.clone(), b"module");
279 let mut b = MonikerBuilder::new();
280 b.project(b"app");
281 b.segment(b"class", b"Foo");
282 let foo = b.build();
283 let mut b = MonikerBuilder::new();
284 b.project(b"app");
285 b.segment(b"class", b"Foo");
286 b.segment(b"method", b"bar");
287 let bar = b.build();
288 g.add_def(foo.clone(), b"class", &root, Some((1, 0)))
289 .unwrap();
290 g.add_def(bar.clone(), b"method", &foo, Some((2, 2)))
291 .unwrap();
292 (g, foo, bar)
293 }
294
295 #[test]
296 fn tsv_emits_one_line_per_def() {
297 let (g, _, _) = build_graph_with_class_and_method();
298 let matches = MatchSet {
299 defs: g.defs().collect(),
300 refs: vec![],
301 };
302 let mut buf = Vec::new();
303 write_tsv(&mut buf, &matches, "", &args(), "code+moniker://").unwrap();
304 let s = String::from_utf8(buf).unwrap();
305 assert_eq!(s.lines().count(), 3);
306 for line in s.lines() {
307 assert!(line.starts_with("def\t"));
308 assert_eq!(line.matches('\t').count(), 7, "tsv columns: {line}");
309 }
310 }
311
312 #[test]
313 fn tsv_renders_line_range_when_position_is_known() {
314 let mut b = MonikerBuilder::new();
315 b.project(b"app");
316 let root = b.build();
317 let mut g = CodeGraph::new(root.clone(), b"module");
318 let mut b = MonikerBuilder::new();
319 b.project(b"app");
320 b.segment(b"function", b"foo");
321 let foo = b.build();
322 let source = "line1\nfn foo() {\n body\n}\nline5\n";
323 g.add_def(foo.clone(), b"function", &root, Some((6, 25)))
324 .unwrap();
325 let foo_def = g.defs().find(|d| d.moniker == foo).unwrap();
326 let matches = MatchSet {
327 defs: vec![foo_def],
328 refs: vec![],
329 };
330 let mut buf = Vec::new();
331 write_tsv(&mut buf, &matches, source, &args(), "code+moniker://").unwrap();
332 let s = String::from_utf8(buf).unwrap();
333 assert!(s.contains("\tL2-L4\t"), "missing line range column: {s}");
334 }
335
336 #[test]
337 fn tsv_renders_moniker_uri_with_supplied_scheme() {
338 let (g, foo, _) = build_graph_with_class_and_method();
339 let foo_def = g.defs().find(|d| d.moniker == foo).unwrap();
340 let matches = MatchSet {
341 defs: vec![foo_def],
342 refs: vec![],
343 };
344 let mut buf = Vec::new();
345 write_tsv(&mut buf, &matches, "", &args(), "code+moniker://").unwrap();
346 let s = String::from_utf8(buf).unwrap();
347 assert!(
348 s.contains("code+moniker://app/class:Foo"),
349 "missing canonical URI in: {s}"
350 );
351 }
352
353 #[test]
354 fn json_top_level_shape() {
355 let (g, _, _) = build_graph_with_class_and_method();
356 let matches = MatchSet {
357 defs: g.defs().collect(),
358 refs: vec![],
359 };
360 let mut buf = Vec::new();
361 write_json(
362 &mut buf,
363 &matches,
364 "",
365 &args(),
366 Lang::Ts,
367 Path::new("a.ts"),
368 "code+moniker://",
369 )
370 .unwrap();
371 let v: serde_json::Value = serde_json::from_slice(&buf).unwrap();
372 assert_eq!(v["lang"].as_str(), Some("ts"));
373 assert!(v["uri"].as_str().unwrap().starts_with("file://"));
374 assert!(v["matches"]["defs"].is_array());
375 assert!(v["matches"]["refs"].is_array());
376 assert_eq!(v["matches"]["defs"].as_array().unwrap().len(), 3);
377 }
378
379 #[test]
380 fn json_includes_line_range_alongside_byte_position() {
381 let mut b = MonikerBuilder::new();
382 b.project(b"app");
383 let root = b.build();
384 let mut g = CodeGraph::new(root.clone(), b"module");
385 let mut b = MonikerBuilder::new();
386 b.project(b"app");
387 b.segment(b"class", b"Foo");
388 let foo = b.build();
389 let source = "line1\nclass Foo {\n body\n}\nline5\n";
390 g.add_def(foo.clone(), b"class", &root, Some((6, 26)))
391 .unwrap();
392 let foo_def = g.defs().find(|d| d.moniker == foo).unwrap();
393 let matches = MatchSet {
394 defs: vec![foo_def],
395 refs: vec![],
396 };
397 let mut buf = Vec::new();
398 write_json(
399 &mut buf,
400 &matches,
401 source,
402 &args(),
403 Lang::Ts,
404 Path::new("a.ts"),
405 "code+moniker://",
406 )
407 .unwrap();
408 let v: serde_json::Value = serde_json::from_slice(&buf).unwrap();
409 let def = &v["matches"]["defs"][0];
410 assert_eq!(def["position"], serde_json::json!([6, 26]));
411 assert_eq!(def["lines"], serde_json::json!([2, 4]));
412 }
413
414 #[test]
415 fn json_skips_empty_attribute_fields() {
416 let (g, foo, _) = build_graph_with_class_and_method();
417 let foo_def = g.defs().find(|d| d.moniker == foo).unwrap();
418 let matches = MatchSet {
419 defs: vec![foo_def],
420 refs: vec![],
421 };
422 let mut buf = Vec::new();
423 write_json(
424 &mut buf,
425 &matches,
426 "",
427 &args(),
428 Lang::Ts,
429 Path::new("a.ts"),
430 "code+moniker://",
431 )
432 .unwrap();
433 let v: serde_json::Value = serde_json::from_slice(&buf).unwrap();
434 let def = &v["matches"]["defs"][0];
435 assert!(
436 def.get("text").is_none(),
437 "no text field without --with-text"
438 );
439 }
440
441 #[test]
442 fn comment_text_extraction_uses_byte_range() {
443 let src = "line0\n// hello\nline2\n";
444 let mut b = MonikerBuilder::new();
445 b.project(b"app");
446 b.segment(b"comment", b"6");
447 let m = b.build();
448 let d = DefRecord {
449 moniker: m,
450 kind: b"comment".to_vec(),
451 parent: Some(0),
452 position: Some((6, 14)),
453 visibility: vec![],
454 signature: vec![],
455 binding: vec![],
456 origin: vec![],
457 };
458 assert_eq!(comment_slice(src, &d), "// hello");
459 }
460
461 #[test]
462 fn tsv_escape_handles_tabs_and_newlines() {
463 assert_eq!(escape_tsv("a\tb"), "a\\tb");
464 assert_eq!(escape_tsv("a\nb"), "a\\nb");
465 assert_eq!(escape_tsv("a\\b"), "a\\\\b");
466 }
467}