Skip to main content

code_moniker_cli/format/
tree.rs

1use std::collections::BTreeMap;
2use std::io::Write;
3
4use anstyle::{AnsiColor, Style};
5use rustc_hash::FxHashMap;
6
7use crate::args::{Charset, ColorChoice, ExtractArgs};
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, KIND_LOCAL, KIND_PARAM};
13use code_moniker_core::core::uri::UriConfig;
14
15const NOISE_KINDS: &[&[u8]] = &[KIND_LOCAL, KIND_PARAM, KIND_COMMENT];
16
17pub fn write_tree<W: Write>(
18	w: &mut W,
19	matches: &MatchSet<'_>,
20	source: &str,
21	args: &ExtractArgs,
22	scheme: &str,
23) -> std::io::Result<()> {
24	write_tree_with_prefix(w, matches, source, args, scheme, "")
25}
26
27pub fn write_tree_with_prefix<W: Write>(
28	w: &mut W,
29	matches: &MatchSet<'_>,
30	source: &str,
31	args: &ExtractArgs,
32	scheme: &str,
33	prefix: &str,
34) -> std::io::Result<()> {
35	let cfg = UriConfig { scheme };
36	let opts = TreeOpts::from_args(args);
37	let user_filtered = !args.kind.is_empty();
38
39	let kept_defs: Vec<&DefRecord> = matches
40		.defs
41		.iter()
42		.copied()
43		.filter(|d| user_filtered || !is_noise(&d.kind))
44		.collect();
45
46	let kept_refs: Vec<&RefMatch<'_>> = if user_filtered {
47		matches.refs.iter().collect()
48	} else {
49		Vec::new()
50	};
51
52	if kept_defs.is_empty() && kept_refs.is_empty() {
53		return Ok(());
54	}
55
56	let def_uris: Vec<String> = kept_defs
57		.iter()
58		.map(|d| render_uri(&d.moniker, &cfg))
59		.collect();
60	let mut refs_by_src: FxHashMap<String, Vec<&RefMatch<'_>>> = FxHashMap::default();
61	for r in &kept_refs {
62		refs_by_src
63			.entry(render_uri(r.source, &cfg))
64			.or_default()
65			.push(r);
66	}
67
68	let split: Vec<Vec<&str>> = def_uris
69		.iter()
70		.map(|u| strip_fs_prefix(u.split('/').collect()))
71		.collect();
72
73	let mut root: Node = Node::default();
74	for (i, d) in kept_defs.iter().enumerate() {
75		let segs = &split[i];
76		root.insert(segs, NodePayload::Def(d));
77		if let Some(rs) = refs_by_src.get(&def_uris[i]) {
78			for r in rs {
79				root.insert(segs, NodePayload::Ref(r));
80			}
81		}
82	}
83
84	render(w, &root, prefix, true, &opts, &cfg, source)
85}
86
87#[derive(Default)]
88struct Node<'a> {
89	def: Option<&'a DefRecord>,
90	refs: Vec<&'a RefMatch<'a>>,
91	children: BTreeMap<String, Node<'a>>,
92}
93
94enum NodePayload<'a> {
95	Def(&'a DefRecord),
96	Ref(&'a RefMatch<'a>),
97}
98
99impl<'a> Node<'a> {
100	fn insert(&mut self, segs: &[&str], payload: NodePayload<'a>) {
101		let Some((head, rest)) = segs.split_first() else {
102			match payload {
103				NodePayload::Def(d) => self.def = Some(d),
104				NodePayload::Ref(r) => self.refs.push(r),
105			}
106			return;
107		};
108		let entry = self.children.entry((*head).to_string()).or_default();
109		entry.insert(rest, payload);
110	}
111}
112
113fn render<W: Write>(
114	w: &mut W,
115	node: &Node<'_>,
116	prefix: &str,
117	is_top: bool,
118	opts: &TreeOpts,
119	cfg: &UriConfig<'_>,
120	source: &str,
121) -> std::io::Result<()> {
122	let mut entries: Vec<(&String, &Node<'_>)> = node.children.iter().collect();
123	entries.sort_by(|a, b| def_line(a.1).cmp(&def_line(b.1)).then_with(|| a.0.cmp(b.0)));
124
125	let total = entries.len() + node.refs.len();
126	let mut i = 0usize;
127
128	for (seg, child) in &entries {
129		let last = i + 1 == total;
130		let (branch, cont) = branch_glyphs(is_top, last, opts);
131		let label = format_seg_label(seg, child.def, source, opts);
132		writeln!(w, "{prefix}{branch}{label}")?;
133		let next_prefix = format!("{prefix}{cont}");
134		render(w, child, &next_prefix, false, opts, cfg, source)?;
135		i += 1;
136	}
137
138	for r in &node.refs {
139		let last = i + 1 == total;
140		let (branch, _) = branch_glyphs(is_top, last, opts);
141		let label = format_ref_label(r, cfg, opts);
142		writeln!(w, "{prefix}{branch}{label}")?;
143		i += 1;
144	}
145	Ok(())
146}
147
148fn branch_glyphs(is_top: bool, last: bool, opts: &TreeOpts) -> (String, String) {
149	if is_top {
150		("".to_string(), "".to_string())
151	} else if last {
152		(
153			format!("{} ", opts.glyph.last),
154			opts.glyph.skip_last.to_string(),
155		)
156	} else {
157		(
158			format!("{} ", opts.glyph.tee),
159			opts.glyph.skip_mid.to_string(),
160		)
161	}
162}
163
164fn def_line(node: &Node<'_>) -> u32 {
165	node.def
166		.and_then(|d| d.position)
167		.map(|(s, _)| s)
168		.unwrap_or(u32::MAX)
169}
170
171fn format_seg_label(seg: &str, def: Option<&DefRecord>, source: &str, opts: &TreeOpts) -> String {
172	let (kind_part, name_part) = seg.split_once(':').unwrap_or(("", seg));
173	let (name_only, args_part) = match name_part.find('(') {
174		Some(i) => (&name_part[..i], &name_part[i..]),
175		None => (name_part, ""),
176	};
177	let kind_disp = def
178		.map(|d| std::str::from_utf8(&d.kind).unwrap_or(kind_part))
179		.unwrap_or(kind_part);
180	let lines = def
181		.and_then(|d| d.position)
182		.map(|(s, e)| {
183			let (a, b) = line_range(source, s, e);
184			if a == b {
185				format!("  L{a}")
186			} else {
187				format!("  L{a}-L{b}")
188			}
189		})
190		.unwrap_or_default();
191
192	let p = &opts.palette;
193	let args_colored = colorize_args(args_part, p);
194	format!(
195		"{kpre}{kind_disp:<7}{kpost} {npre}{name_only}{npost}{args_colored}{rpre}{lines}{rpost}",
196		kpre = p.kind.render(),
197		kpost = p.kind.render_reset(),
198		npre = p.name.render(),
199		npost = p.name.render_reset(),
200		rpre = p.range.render(),
201		rpost = p.range.render_reset(),
202	)
203}
204
205#[derive(Copy, Clone, PartialEq, Eq)]
206enum ArgTok {
207	Punct,
208	Name,
209	Type,
210	Plain,
211}
212
213fn classify(c: char, paren_depth: usize, in_name: bool) -> ArgTok {
214	match c {
215		'(' | ')' => ArgTok::Punct,
216		',' | ':' if paren_depth > 0 => ArgTok::Punct,
217		_ if paren_depth > 0 && in_name => ArgTok::Name,
218		_ if paren_depth > 0 => ArgTok::Type,
219		_ => ArgTok::Plain,
220	}
221}
222
223fn colorize_args(args: &str, p: &Palette) -> String {
224	if args.is_empty() {
225		return String::new();
226	}
227	let mut out = String::with_capacity(args.len() + 32);
228	let mut cur_tok: Option<ArgTok> = None;
229	let mut in_name = true;
230	let mut paren_depth = 0usize;
231	for c in args.chars() {
232		let tok = classify(c, paren_depth, in_name);
233		if cur_tok != Some(tok) {
234			if let Some(prev) = cur_tok {
235				write_close(&mut out, p, prev);
236			}
237			write_open(&mut out, p, tok);
238			cur_tok = Some(tok);
239		}
240		out.push(c);
241		match c {
242			'(' => {
243				paren_depth += 1;
244				in_name = true;
245			}
246			')' => paren_depth = paren_depth.saturating_sub(1),
247			',' if paren_depth > 0 => in_name = true,
248			':' if paren_depth > 0 && in_name => in_name = false,
249			_ => {}
250		}
251	}
252	if let Some(prev) = cur_tok {
253		write_close(&mut out, p, prev);
254	}
255	out
256}
257
258fn style_for(p: &Palette, tok: ArgTok) -> Style {
259	match tok {
260		ArgTok::Punct => p.punct,
261		ArgTok::Name => p.arg_name,
262		ArgTok::Type => p.arg_type,
263		ArgTok::Plain => Style::new(),
264	}
265}
266
267fn write_open(out: &mut String, p: &Palette, tok: ArgTok) {
268	let s = style_for(p, tok);
269	let ansi = s.render().to_string();
270	out.push_str(&ansi);
271}
272
273fn write_close(out: &mut String, p: &Palette, tok: ArgTok) {
274	let s = style_for(p, tok);
275	let ansi = s.render_reset().to_string();
276	out.push_str(&ansi);
277}
278
279fn format_ref_label(r: &RefMatch<'_>, cfg: &UriConfig<'_>, opts: &TreeOpts) -> String {
280	let kind = std::str::from_utf8(&r.record.kind).unwrap_or("?");
281	let target = render_uri(&r.record.target, cfg);
282	let last_seg = target.rsplit('/').next().unwrap_or(&target);
283	let target_name = last_seg.split_once(':').map_or(last_seg, |s| s.1);
284	let p = &opts.palette;
285	format!(
286		"{apre}{arrow} {apost}{rkpre}{kind:<10}{rkpost} {dpre}{target_name}{dpost}",
287		apre = p.arrow.render(),
288		arrow = opts.glyph.arrow,
289		apost = p.arrow.render_reset(),
290		rkpre = p.ref_kind.render(),
291		rkpost = p.ref_kind.render_reset(),
292		dpre = p.dim.render(),
293		dpost = p.dim.render_reset(),
294	)
295}
296
297fn strip_fs_prefix(segs: Vec<&str>) -> Vec<&str> {
298	let i = segs
299		.iter()
300		.position(|s| {
301			if s.is_empty() || *s == "." || s.starts_with("code+moniker:") {
302				return false;
303			}
304			let kind = s.split_once(':').map(|(k, _)| k).unwrap_or("");
305			!matches!(kind, "lang" | "dir")
306		})
307		.unwrap_or(segs.len());
308	segs.into_iter().skip(i).collect()
309}
310
311fn is_noise(kind: &[u8]) -> bool {
312	NOISE_KINDS.contains(&kind)
313}
314
315struct TreeOpts {
316	glyph: Glyphs,
317	palette: Palette,
318}
319
320impl TreeOpts {
321	fn from_args(args: &ExtractArgs) -> Self {
322		let glyph = match args.charset {
323			Charset::Utf8 => Glyphs::utf8(),
324			Charset::Ascii => Glyphs::ascii(),
325		};
326		let palette = if resolve_color(args.color) {
327			Palette::ansi()
328		} else {
329			Palette::none()
330		};
331		Self { glyph, palette }
332	}
333}
334
335struct Glyphs {
336	tee: &'static str,
337	last: &'static str,
338	skip_mid: &'static str,
339	skip_last: &'static str,
340	arrow: &'static str,
341}
342
343impl Glyphs {
344	fn utf8() -> Self {
345		Self {
346			tee: "├──",
347			last: "└──",
348			skip_mid: "│   ",
349			skip_last: "    ",
350			arrow: "→",
351		}
352	}
353	fn ascii() -> Self {
354		Self {
355			tee: "+--",
356			last: "+--",
357			skip_mid: "|   ",
358			skip_last: "    ",
359			arrow: "->",
360		}
361	}
362}
363
364struct Palette {
365	kind: Style,
366	name: Style,
367	range: Style,
368	arrow: Style,
369	ref_kind: Style,
370	dim: Style,
371	punct: Style,
372	arg_name: Style,
373	arg_type: Style,
374}
375
376impl Palette {
377	fn none() -> Self {
378		Self {
379			kind: Style::new(),
380			name: Style::new(),
381			range: Style::new(),
382			arrow: Style::new(),
383			ref_kind: Style::new(),
384			dim: Style::new(),
385			punct: Style::new(),
386			arg_name: Style::new(),
387			arg_type: Style::new(),
388		}
389	}
390	fn ansi() -> Self {
391		Self {
392			kind: Style::new().fg_color(Some(AnsiColor::Cyan.into())),
393			name: Style::new().bold(),
394			range: Style::new().fg_color(Some(AnsiColor::Green.into())),
395			arrow: Style::new()
396				.fg_color(Some(AnsiColor::BrightBlack.into()))
397				.dimmed(),
398			ref_kind: Style::new().fg_color(Some(AnsiColor::Magenta.into())),
399			dim: Style::new()
400				.fg_color(Some(AnsiColor::BrightBlack.into()))
401				.dimmed(),
402			punct: Style::new().fg_color(Some(AnsiColor::BrightBlack.into())),
403			arg_name: Style::new().fg_color(Some(AnsiColor::Yellow.into())),
404			arg_type: Style::new().fg_color(Some(AnsiColor::Blue.into())),
405		}
406	}
407}
408
409fn resolve_color(arg: ColorChoice) -> bool {
410	use std::io::IsTerminal;
411	if std::env::var_os("NO_COLOR").is_some() {
412		return false;
413	}
414	if std::env::var_os("CLICOLOR_FORCE").is_some_and(|v| v != "0") {
415		return true;
416	}
417	match arg {
418		ColorChoice::Always => true,
419		ColorChoice::Never => false,
420		ColorChoice::Auto => {
421			if std::env::var("TERM").is_ok_and(|t| t == "dumb") {
422				return false;
423			}
424			if std::env::var("CLICOLOR").is_ok_and(|v| v == "0") {
425				return false;
426			}
427			std::io::stdout().is_terminal()
428		}
429	}
430}
431
432pub fn write_file_header<W: Write>(
433	w: &mut W,
434	path: &std::path::Path,
435	args: &ExtractArgs,
436) -> std::io::Result<()> {
437	let opts = TreeOpts::from_args(args);
438	let style = opts.palette.name;
439	writeln!(
440		w,
441		"\n{}── {} ──{}",
442		style.render(),
443		path.display(),
444		style.render_reset()
445	)
446}
447
448pub struct FileEntry<'a> {
449	pub rel_path: String,
450	pub matches: MatchSet<'a>,
451	pub source: &'a str,
452}
453
454pub fn write_files_tree<W: Write>(
455	w: &mut W,
456	files: &[FileEntry<'_>],
457	args: &ExtractArgs,
458	scheme: &str,
459) -> std::io::Result<()> {
460	let opts = TreeOpts::from_args(args);
461	let mut trie: FileTrie = FileTrie::default();
462	for (i, f) in files.iter().enumerate() {
463		let segs: Vec<&str> = f.rel_path.split('/').filter(|s| !s.is_empty()).collect();
464		trie.insert(&segs, i);
465	}
466	render_file_trie(w, &trie, "", files, args, scheme, &opts)
467}
468
469type FileTrie = LeafTrie<usize>;
470
471fn render_file_trie<W: Write>(
472	w: &mut W,
473	node: &FileTrie,
474	prefix: &str,
475	files: &[FileEntry<'_>],
476	args: &ExtractArgs,
477	scheme: &str,
478	opts: &TreeOpts,
479) -> std::io::Result<()> {
480	let total = node.children.len();
481	for (i, (name, child)) in node.children.iter().enumerate() {
482		let last = i + 1 == total;
483		let branch = if last {
484			opts.glyph.last
485		} else {
486			opts.glyph.tee
487		};
488		let cont = if last {
489			opts.glyph.skip_last
490		} else {
491			opts.glyph.skip_mid
492		};
493		let is_dir = child.leaf.is_none();
494		let suffix = if is_dir { "/" } else { "" };
495		writeln!(
496			w,
497			"{prefix}{branch} {hpre}{name}{suffix}{hpost}",
498			hpre = opts.palette.name.render(),
499			hpost = opts.palette.name.render_reset(),
500		)?;
501		let sub_prefix = format!("{prefix}{cont}");
502		if let Some(idx) = child.leaf {
503			let f = &files[idx];
504			write_tree_with_prefix(w, &f.matches, f.source, args, scheme, &sub_prefix)?;
505		} else {
506			render_file_trie(w, child, &sub_prefix, files, args, scheme, opts)?;
507		}
508	}
509	Ok(())
510}
511
512pub fn render_dir_tree<W: Write>(
513	w: &mut W,
514	entries: &[(String, String)],
515	args: &ExtractArgs,
516) -> std::io::Result<()> {
517	let opts = TreeOpts::from_args(args);
518	let mut root: PathNode = PathNode::default();
519	for (path, label) in entries {
520		let segs: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
521		root.insert(&segs, label.clone());
522	}
523	render_path_node(w, &root, "", &opts)
524}
525
526type PathNode = LeafTrie<String>;
527
528struct LeafTrie<T> {
529	leaf: Option<T>,
530	children: BTreeMap<String, LeafTrie<T>>,
531}
532
533impl<T> Default for LeafTrie<T> {
534	fn default() -> Self {
535		Self {
536			leaf: None,
537			children: BTreeMap::new(),
538		}
539	}
540}
541
542impl<T> LeafTrie<T> {
543	fn insert(&mut self, segs: &[&str], val: T) {
544		let Some((head, rest)) = segs.split_first() else {
545			self.leaf = Some(val);
546			return;
547		};
548		self.children
549			.entry((*head).to_string())
550			.or_default()
551			.insert(rest, val);
552	}
553}
554
555fn render_path_node<W: Write>(
556	w: &mut W,
557	node: &PathNode,
558	prefix: &str,
559	opts: &TreeOpts,
560) -> std::io::Result<()> {
561	let total = node.children.len();
562	for (i, (seg, child)) in node.children.iter().enumerate() {
563		let last = i + 1 == total;
564		let branch = if last {
565			opts.glyph.last
566		} else {
567			opts.glyph.tee
568		};
569		let cont = if last {
570			opts.glyph.skip_last
571		} else {
572			opts.glyph.skip_mid
573		};
574		let label = match &child.leaf {
575			Some(l) => format!(
576				"{npre}{seg}{npost} {dpre}{l}{dpost}",
577				npre = opts.palette.name.render(),
578				npost = opts.palette.name.render_reset(),
579				dpre = opts.palette.dim.render(),
580				dpost = opts.palette.dim.render_reset(),
581			),
582			None => format!(
583				"{kpre}{seg}/{kpost}",
584				kpre = opts.palette.kind.render(),
585				kpost = opts.palette.kind.render_reset(),
586			),
587		};
588		writeln!(w, "{prefix}{branch} {label}")?;
589		let next_prefix = format!("{prefix}{cont}");
590		render_path_node(w, child, &next_prefix, opts)?;
591	}
592	Ok(())
593}
594
595#[cfg(test)]
596mod tests {
597	use super::*;
598	use crate::args::OutputFormat;
599	use code_moniker_core::core::code_graph::CodeGraph;
600	use code_moniker_core::core::moniker::MonikerBuilder;
601
602	fn base_args() -> ExtractArgs {
603		let mut a = ExtractArgs::for_tests();
604		a.format = OutputFormat::Tree;
605		a
606	}
607
608	fn graph_class_method_and_local() -> CodeGraph {
609		let mut b = MonikerBuilder::new();
610		b.project(b"app");
611		let root = b.build();
612		let mut g = CodeGraph::new(root.clone(), b"module");
613
614		let mut b = MonikerBuilder::new();
615		b.project(b"app");
616		b.segment(b"class", b"Foo");
617		let foo = b.build();
618		g.add_def(foo.clone(), b"class", &root, Some((1, 0)))
619			.unwrap();
620
621		let mut b = MonikerBuilder::new();
622		b.project(b"app");
623		b.segment(b"class", b"Foo");
624		b.segment(b"method", b"bar");
625		let bar = b.build();
626		g.add_def(bar.clone(), b"method", &foo, Some((2, 2)))
627			.unwrap();
628
629		let mut b = MonikerBuilder::new();
630		b.project(b"app");
631		b.segment(b"class", b"Foo");
632		b.segment(b"method", b"bar");
633		b.segment(b"local", b"x");
634		let local_x = b.build();
635		g.add_def(local_x, b"local", &bar, Some((3, 3))).unwrap();
636
637		g
638	}
639
640	#[test]
641	fn structural_only_by_default_hides_locals() {
642		let g = graph_class_method_and_local();
643		let matches = MatchSet {
644			defs: g.defs().collect(),
645			refs: vec![],
646		};
647		let mut buf = Vec::new();
648		write_tree(&mut buf, &matches, "", &base_args(), "code+moniker://").unwrap();
649		let s = String::from_utf8(buf).unwrap();
650		assert!(s.contains("Foo"), "class missing: {s}");
651		assert!(s.contains("bar"), "method missing: {s}");
652		assert!(
653			!s.contains("local"),
654			"local should be hidden by default: {s}"
655		);
656		assert!(
657			!s.contains("code+moniker"),
658			"URI header should not appear: {s}"
659		);
660	}
661
662	#[test]
663	fn explicit_kind_local_reveals_them() {
664		let g = graph_class_method_and_local();
665		let matches = MatchSet {
666			defs: g.defs().collect(),
667			refs: vec![],
668		};
669		let mut args = base_args();
670		args.kind = vec!["local".into()];
671		let mut buf = Vec::new();
672		write_tree(&mut buf, &matches, "", &args, "code+moniker://").unwrap();
673		let s = String::from_utf8(buf).unwrap();
674		assert!(
675			s.contains("local"),
676			"user-requested local kind should appear: {s}"
677		);
678	}
679
680	#[test]
681	fn ascii_charset_avoids_unicode_glyphs() {
682		let g = graph_class_method_and_local();
683		let matches = MatchSet {
684			defs: g.defs().collect(),
685			refs: vec![],
686		};
687		let mut args = base_args();
688		args.charset = Charset::Ascii;
689		let mut buf = Vec::new();
690		write_tree(&mut buf, &matches, "", &args, "code+moniker://").unwrap();
691		let s = String::from_utf8(buf).unwrap();
692		assert!(s.is_ascii(), "ascii mode produced non-ASCII: {s:?}");
693	}
694
695	#[test]
696	fn always_color_emits_ansi_escapes() {
697		let g = graph_class_method_and_local();
698		let matches = MatchSet {
699			defs: g.defs().collect(),
700			refs: vec![],
701		};
702		let mut args = base_args();
703		args.color = ColorChoice::Always;
704		unsafe { std::env::remove_var("NO_COLOR") };
705		let mut buf = Vec::new();
706		write_tree(&mut buf, &matches, "", &args, "code+moniker://").unwrap();
707		let s = String::from_utf8(buf).unwrap();
708		assert!(
709			s.contains("\x1b["),
710			"no ANSI escape in always-color output: {s:?}"
711		);
712	}
713
714	#[test]
715	fn no_color_env_disables_color_even_with_always() {
716		unsafe { std::env::set_var("NO_COLOR", "1") };
717		assert!(!resolve_color(ColorChoice::Always));
718		unsafe { std::env::remove_var("NO_COLOR") };
719	}
720}