Skip to main content

stryke/
doc_render.rs

1//! Render raw hover-doc markdown (from `lsp::doc_text_for`) into a terminal
2//! display string. Same visual surface as the `stryke docs` interactive
3//! browser — bold topic heading, dim rule, cyan inline `backticks`, green
4//! ```code fences``` — but without the pager chrome (header/footer banner,
5//! page navigation hints), so a single doc page can be returned by the
6//! `docs(TOPIC)` builtin and printed inline at the REPL.
7//!
8//! Colors are conditional: callers pass `colored=true` only when stdout is
9//! a TTY so piped / file-redirected output stays clean.
10
11const ANSI_CYAN: &str = "\x1b[36m";
12const ANSI_GREEN: &str = "\x1b[32m";
13const ANSI_DIM: &str = "\x1b[2m";
14const ANSI_RESET: &str = "\x1b[0m";
15
16/// Render a doc page (heading + rule + body) for terminal display.
17/// `text` is the raw markdown returned by `lsp::doc_text_for`. When
18/// `colored` is false, ANSI sequences are stripped to empty strings so
19/// the output is plain UTF-8 suitable for files / pipes / non-tty sinks.
20pub fn render_doc(topic: &str, text: &str, colored: bool) -> String {
21    let (c, g, d, n) = if colored {
22        (ANSI_CYAN, ANSI_GREEN, ANSI_DIM, ANSI_RESET)
23    } else {
24        ("", "", "", "")
25    };
26    let rule_len = topic.chars().count().clamp(20, 76);
27    let mut out = String::with_capacity(text.len() + 256);
28    out.push_str(&format!("{c}{topic}{n}\n"));
29    out.push_str(&format!("{d}{}{n}\n", "─".repeat(rule_len)));
30    let mut in_code = false;
31    for line in text.split('\n') {
32        if line.starts_with("```") {
33            in_code = !in_code;
34            continue;
35        }
36        if in_code {
37            out.push_str(&format!("{g}  {line}{n}\n"));
38        } else if line.trim().is_empty() {
39            out.push('\n');
40        } else {
41            out.push_str(&render_inline_code(line, c, n));
42            out.push('\n');
43        }
44    }
45    out
46}
47
48/// Replace `\`backtick\`` spans with `color`-prefixed / `reset`-suffixed
49/// runs. Unmatched trailing backticks pass through unchanged so doc text
50/// containing literal `` ` `` (e.g. shell snippets) doesn't corrupt the
51/// terminal state.
52fn render_inline_code(line: &str, color: &str, reset: &str) -> String {
53    let mut out = String::with_capacity(line.len() + 64);
54    let mut in_tick = false;
55    for ch in line.chars() {
56        if ch == '`' {
57            if in_tick {
58                out.push_str(reset);
59            } else {
60                out.push_str(color);
61            }
62            in_tick = !in_tick;
63        } else {
64            out.push(ch);
65        }
66    }
67    if in_tick {
68        out.push_str(reset);
69    }
70    out
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76
77    #[test]
78    fn plain_mode_strips_ansi() {
79        let r = render_doc("pmap", "Hello `world`", false);
80        assert!(
81            !r.contains('\x1b'),
82            "plain mode must not emit ANSI: {:?}",
83            r
84        );
85        assert!(r.contains("pmap"));
86        // Backticks are consumed by the inline-code renderer; in plain
87        // mode the color/reset wrappers collapse to empty strings, so the
88        // literal `word` segment stays in the output without its delimiters.
89        assert!(r.contains("Hello world"), "body text preserved: {:?}", r);
90    }
91
92    #[test]
93    fn colored_mode_wraps_inline_ticks() {
94        let r = render_doc("pmap", "Hello `world`", true);
95        assert!(r.contains("\x1b[36m"), "colored must emit cyan: {:?}", r);
96        assert!(r.contains("\x1b[0m"), "colored must reset: {:?}", r);
97    }
98
99    #[test]
100    fn code_fence_lines_render_green() {
101        let r = render_doc("pmap", "intro\n```perl\nmy $x = 1\n```\nafter", true);
102        assert!(
103            r.contains("\x1b[32m"),
104            "fenced block must emit green: {:?}",
105            r
106        );
107        assert!(!r.contains("```"), "fence markers must be hidden: {:?}", r);
108    }
109}