Skip to main content

code_moniker_cli/
format.rs

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}