Skip to main content

gitcortex_mcp/mcp/
wiki.rs

1//! Wiki rendering — markdown summary for a single symbol assembled from
2//! the graph store. Pure formatter: no I/O beyond the store reads.
3//!
4//! Output shape (markdown):
5//!
6//! ```text
7//! # <name> (<kind>)
8//!
9//! **Defined in** `<file>:<start>-<end>` · visibility=<vis> · async=<bool> ...
10//!
11//! ## Signature
12//! ```<lang>
13//! <signature>
14//! ```
15//!
16//! ## Doc
17//! <doc_comment>
18//!
19//! ## Callers (N)
20//! - <name> (<kind>) — <file>:<line>
21//!
22//! ## Calls (N)
23//! - …
24//!
25//! ## Used by (N)
26//! - …
27//! ```
28
29use std::fmt::Write;
30
31use gitcortex_core::{
32    error::Result,
33    graph::Node,
34    store::{GraphStore, SymbolContext},
35};
36
37/// Markdown wiki rendering for `name` on `branch`.
38/// Returns an `Err` only when the store itself fails; "symbol not found" is
39/// surfaced by the upstream `symbol_context` error.
40pub fn render_symbol<S: GraphStore + ?Sized>(
41    store: &S,
42    branch: &str,
43    name: &str,
44) -> Result<String> {
45    let ctx = store.symbol_context(branch, name)?;
46    Ok(format(ctx))
47}
48
49fn format(ctx: SymbolContext) -> String {
50    let def = &ctx.definition;
51    let lang = file_lang(&def.file.to_string_lossy());
52    let mut out = String::with_capacity(1024);
53
54    let _ = writeln!(out, "# {} ({})", def.name, def.kind);
55    let _ = writeln!(out);
56    let complexity_part = match def.metadata.lld.complexity {
57        Some(c) => format!("  ·  complexity={c}"),
58        None => String::new(),
59    };
60    let _ = writeln!(
61        out,
62        "**Defined in** `{}:{}-{}`  ·  visibility={}  ·  async={}  ·  loc={}{}",
63        def.file.display(),
64        def.span.start_line,
65        def.span.end_line,
66        def.metadata.visibility,
67        def.metadata.is_async,
68        def.metadata.loc,
69        complexity_part,
70    );
71    if def.qualified_name != def.name {
72        let _ = writeln!(out, "**Qualified** `{}`", def.qualified_name);
73    }
74    let _ = writeln!(out);
75
76    let sig = def.metadata.definition.signature.trim();
77    if !sig.is_empty() {
78        let _ = writeln!(out, "## Signature");
79        let _ = writeln!(out, "```{lang}");
80        let _ = writeln!(out, "{sig}");
81        let _ = writeln!(out, "```");
82        let _ = writeln!(out);
83    }
84
85    if let Some(doc) = def.metadata.definition.doc_comment.as_deref() {
86        let stripped = strip_doc_markers(doc);
87        if !stripped.trim().is_empty() {
88            let _ = writeln!(out, "## Doc");
89            let _ = writeln!(out, "{}", stripped.trim());
90            let _ = writeln!(out);
91        }
92    }
93
94    write_neighbor_list(&mut out, "Callers", &ctx.callers);
95    write_neighbor_list(&mut out, "Calls", &ctx.callees);
96    write_neighbor_list(&mut out, "Used by", &ctx.used_by);
97
98    out
99}
100
101const WIKI_NEIGHBOR_LIMIT: usize = 5;
102
103fn write_neighbor_list(out: &mut String, label: &str, nodes: &[Node]) {
104    if nodes.is_empty() {
105        return;
106    }
107    let shown = nodes.len().min(WIKI_NEIGHBOR_LIMIT);
108    let _ = writeln!(out, "## {label} ({})", nodes.len());
109    for n in &nodes[..shown] {
110        let _ = writeln!(
111            out,
112            "- `{}` ({})  — `{}:{}`",
113            n.name,
114            n.kind,
115            n.file.display(),
116            n.span.start_line
117        );
118    }
119    if nodes.len() > shown {
120        let _ = writeln!(
121            out,
122            "- _+{} more — use `find_callers` for the full list_",
123            nodes.len() - shown
124        );
125    }
126    let _ = writeln!(out);
127}
128
129/// Strip per-line `///`, `//!`, `// `, `# `, `*` doc-comment leaders so the
130/// rendered markdown reads as prose, not as code-fence content.
131fn strip_doc_markers(doc: &str) -> String {
132    let mut out = String::with_capacity(doc.len());
133    for line in doc.lines() {
134        let trimmed = line.trim_start();
135        let cleaned = trimmed
136            .strip_prefix("///")
137            .or_else(|| trimmed.strip_prefix("//!"))
138            .or_else(|| trimmed.strip_prefix("/**"))
139            .or_else(|| trimmed.strip_prefix("*/"))
140            .or_else(|| trimmed.strip_prefix("//"))
141            .or_else(|| trimmed.strip_prefix("# "))
142            .or_else(|| trimmed.strip_prefix("#"))
143            .or_else(|| trimmed.strip_prefix("* "))
144            .or_else(|| trimmed.strip_prefix("*"))
145            .unwrap_or(trimmed);
146        // Also strip a trailing `*/` (single-line javadoc /** … */).
147        let cleaned = cleaned
148            .trim_end()
149            .strip_suffix("*/")
150            .unwrap_or(cleaned)
151            .trim_end();
152        out.push_str(cleaned.trim_start());
153        out.push('\n');
154    }
155    out
156}
157
158/// Best-effort language hint from a file path, for fenced code-block tagging.
159fn file_lang(path: &str) -> &'static str {
160    let ext = path.rsplit('.').next().unwrap_or("");
161    match ext {
162        "rs" => "rust",
163        "py" => "python",
164        "ts" | "tsx" => "typescript",
165        "js" | "jsx" | "mjs" | "cjs" => "javascript",
166        "go" => "go",
167        "java" => "java",
168        _ => "",
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    #[test]
177    fn lang_from_path() {
178        assert_eq!(file_lang("src/main.rs"), "rust");
179        assert_eq!(file_lang("app/foo.tsx"), "typescript");
180        assert_eq!(file_lang("Makefile"), "");
181    }
182
183    #[test]
184    fn strip_rust_doc_markers() {
185        let input = "/// First line\n/// Second line\n";
186        let out = strip_doc_markers(input);
187        assert!(out.contains("First line"));
188        assert!(!out.contains("///"));
189    }
190}