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