Skip to main content

code_moniker_cli/
format.rs

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}