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}