Skip to main content

grove_core/
render.rs

1//! Human text rendering for the read-only structural verbs, shared by the CLI
2//! (`cli/src/main.rs`) and the explore inner toolset (`core::explore::toolset`)
3//! so both faces emit identical output. See ADR 0003.
4//!
5//! Each function reproduces the CLI's stdout `println!` block byte-for-byte
6//! (one line + `\n` per row). The CLI keeps its own `--json` branch and its
7//! `eprintln!` summaries; those stay CLI-only and are not part of this surface.
8
9use crate::engine::Symbol;
10use crate::ops::{CallSite, CallSource, FileMap, SourceResult};
11
12/// `grove outline` — one line per definition (kind, name, owner, line:col, sig).
13pub fn outline(syms: &[Symbol]) -> String {
14    let mut out = String::new();
15    for s in syms {
16        let owner = s.parent.clone().unwrap_or_default();
17        out.push_str(&format!(
18            "{:<10} {:<26} {:<18} {}:{:<4} {}\n",
19            s.kind, s.name, owner, s.line, s.col, s.signature
20        ));
21    }
22    out
23}
24
25/// `grove symbols` — a def/ref mark, kind, name, and the stable symbol id.
26pub fn symbols(syms: &[Symbol]) -> String {
27    let mut out = String::new();
28    for s in syms {
29        let mark = if s.is_definition { "def" } else { "ref" };
30        out.push_str(&format!("{:<3} {:<10} {:<28} {}\n", mark, s.kind, s.name, s.id));
31    }
32    out
33}
34
35/// `grove source` — the symbol's source body. The CLI's "also matched" hint goes
36/// to stderr, so it is not part of captured stdout and not rendered here.
37pub fn source(res: &SourceResult) -> String {
38    format!("{}\n", res.source)
39}
40
41/// `grove callers` — `file:line:col`, enclosing function, provenance tag
42/// (`S`=structural / `T`=textual), and the call-site text.
43pub fn callers(sites: &[CallSite]) -> String {
44    let mut out = String::new();
45    for s in sites {
46        let inf = s.in_function.as_deref().unwrap_or("<top-level>");
47        let tag = if s.source == CallSource::Structural { "S" } else { "T" };
48        out.push_str(&format!(
49            "{}:{}:{}   {:<28} [{}] {}\n",
50            s.file, s.line, s.col, inf, tag, s.text
51        ));
52    }
53    out
54}
55
56/// `grove definition` — leads with `file:line:col` so directory-wide hits that
57/// span files are locatable without a follow-up `symbols`.
58pub fn definition(defs: &[Symbol]) -> String {
59    let mut out = String::new();
60    for s in defs {
61        let owner = s.parent.clone().unwrap_or_default();
62        let loc = format!("{}:{}:{}", s.file, s.line, s.col);
63        out.push_str(&format!(
64            "{:<10} {:<26} {:<18} {:<28} {}\n",
65            s.kind, s.name, owner, loc, s.signature
66        ));
67    }
68    out
69}
70
71/// `grove map` — each file's definitions with their outgoing references.
72pub fn map(maps: &[FileMap]) -> String {
73    let mut out = String::new();
74    for fm in maps {
75        out.push_str(&format!("{}\n", fm.file));
76        for e in &fm.entries {
77            let parent = e.parent.as_deref().unwrap_or("");
78            if e.references.is_empty() {
79                out.push_str(&format!(
80                    "  {:<10} {:<26} {:<18} {:<4} {}\n",
81                    e.kind, e.name, parent, e.row, e.signature
82                ));
83            } else {
84                out.push_str(&format!(
85                    "  {:<10} {:<26} {:<18} {:<4} {}  → {}\n",
86                    e.kind, e.name, parent, e.row, e.signature, e.references.join(", ")
87                ));
88            }
89        }
90    }
91    out
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97    use crate::ops::MapEntry;
98
99    fn sym(kind: &str, name: &str, parent: Option<&str>, is_def: bool) -> Symbol {
100        Symbol {
101            id: format!("rust:x.rs#{name}@1"),
102            name: name.into(),
103            kind: kind.into(),
104            is_definition: is_def,
105            file: "x.rs".into(),
106            line: 1,
107            col: 0,
108            start_byte: 0,
109            end_byte: 1,
110            signature: format!("fn {name}()"),
111            parent: parent.map(String::from),
112        }
113    }
114
115    #[test]
116    fn outline_and_symbols_have_one_line_per_symbol() {
117        let syms = vec![sym("function", "foo", None, true), sym("method", "bar", Some("Baz"), true)];
118        let o = outline(&syms);
119        assert_eq!(o.lines().count(), 2);
120        assert!(o.contains("function") && o.contains("foo") && o.contains("1:0"));
121        assert!(o.contains("Baz"), "owner rendered");
122
123        let s = symbols(&syms);
124        assert!(s.starts_with("def "), "definition mark: {s}");
125        assert!(s.contains("rust:x.rs#foo@1"), "stable id: {s}");
126    }
127
128    #[test]
129    fn map_appends_references_arrow_only_when_present() {
130        let maps = vec![FileMap {
131            file: "x.rs".into(),
132            entries: vec![
133                MapEntry { id: "i1".into(), kind: "function".into(), name: "a".into(),
134                    parent: None, row: 1, signature: "fn a()".into(), references: vec![] },
135                MapEntry { id: "i2".into(), kind: "function".into(), name: "b".into(),
136                    parent: None, row: 2, signature: "fn b()".into(), references: vec!["a".into()] },
137            ],
138        }];
139        let m = map(&maps);
140        assert!(m.starts_with("x.rs\n"), "file header first: {m}");
141        assert!(!m.lines().nth(1).unwrap().contains('→'), "no arrow without refs");
142        assert!(m.lines().nth(2).unwrap().contains("→ a"), "arrow with refs");
143    }
144}