Skip to main content

logicaffeine_web/ui/components/
hover_hint.rs

1//! VS Code-style hover hints for the Studio's LOGOS editors.
2//!
3//! The words come from the language server's own analysis (`logicaffeine_lsp::hover`), linked
4//! into the page: the same hover VS Code shows over a `.logos` file, with no server to reach.
5
6use logicaffeine_lsp::document::DocumentState;
7
8/// The language server's hover for the source at byte `offset`, as markdown.
9pub fn hover_markdown(doc: &DocumentState, offset: usize) -> Option<String> {
10    use logicaffeine_lsp::lsp_types::{HoverContents, MarkedString};
11    let hover = logicaffeine_lsp::hover::hover(doc, doc.line_index.position(offset))?;
12    let text = |m: MarkedString| match m {
13        MarkedString::String(s) => s,
14        MarkedString::LanguageString(code) => format!("```{}\n{}\n```", code.language, code.value),
15    };
16    let markdown = match hover.contents {
17        HoverContents::Markup(m) => m.value,
18        HoverContents::Scalar(m) => text(m),
19        HoverContents::Array(ms) => ms.into_iter().map(text).collect::<Vec<_>>().join("\n\n"),
20    };
21    (!markdown.trim().is_empty()).then_some(markdown)
22}
23
24/// The hover's markdown as HTML for the tooltip: paragraphs, `---` rules, bullet lists, fenced
25/// and inline code, bold and emphasis. Everything else is text, escaped.
26pub fn markdown_to_html(markdown: &str) -> String {
27    let mut html = String::new();
28    let mut paragraph: Vec<&str> = Vec::new();
29    let mut bullets: Vec<&str> = Vec::new();
30    let flush = |html: &mut String, paragraph: &mut Vec<&str>, bullets: &mut Vec<&str>| {
31        if !paragraph.is_empty() {
32            let lines: Vec<String> = paragraph.iter().map(|l| inline(l)).collect();
33            html.push_str(&format!("<p>{}</p>", lines.join("<br>")));
34            paragraph.clear();
35        }
36        if !bullets.is_empty() {
37            html.push_str("<ul>");
38            for item in bullets.iter() {
39                html.push_str(&format!("<li>{}</li>", inline(item)));
40            }
41            html.push_str("</ul>");
42            bullets.clear();
43        }
44    };
45    let mut lines = markdown.lines();
46    while let Some(line) = lines.next() {
47        let trimmed = line.trim();
48        if trimmed.starts_with("```") {
49            flush(&mut html, &mut paragraph, &mut bullets);
50            let code: Vec<&str> = lines.by_ref().take_while(|l| !l.trim_start().starts_with("```")).collect();
51            html.push_str(&format!("<pre><code>{}</code></pre>", escape(&code.join("\n"))));
52        } else if trimmed == "---" || trimmed == "***" || trimmed == "___" {
53            flush(&mut html, &mut paragraph, &mut bullets);
54            html.push_str("<hr>");
55        } else if let Some(item) = trimmed.strip_prefix("- ").or_else(|| trimmed.strip_prefix("* ")) {
56            if !paragraph.is_empty() {
57                let pending = std::mem::take(&mut bullets);
58                flush(&mut html, &mut paragraph, &mut Vec::new());
59                bullets = pending;
60            }
61            bullets.push(item);
62        } else if trimmed.is_empty() {
63            flush(&mut html, &mut paragraph, &mut bullets);
64        } else {
65            if !bullets.is_empty() {
66                flush(&mut html, &mut Vec::new(), &mut bullets);
67            }
68            paragraph.push(trimmed);
69        }
70    }
71    flush(&mut html, &mut paragraph, &mut bullets);
72    html
73}
74
75pub(crate) fn escape(text: &str) -> String {
76    text.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
77}
78
79/// One line of prose: `code` spans verbatim, then links, bold and emphasis in what is left.
80fn inline(text: &str) -> String {
81    let mut out = String::new();
82    let mut rest = text;
83    while let Some(open) = rest.find('`') {
84        let Some(len) = rest[open + 1..].find('`') else { break };
85        out.push_str(&prose(&rest[..open]));
86        out.push_str(&format!("<code>{}</code>", escape(&rest[open + 1..open + 1 + len])));
87        rest = &rest[open + 1 + len + 1..];
88    }
89    out.push_str(&prose(rest));
90    out
91}
92
93/// Text outside code spans: `[label](url)` links — only to web addresses, opened in a new
94/// tab; any other target leaves just the label — then bold and emphasis.
95fn prose(text: &str) -> String {
96    let mut out = String::new();
97    let mut rest = text;
98    while let Some(open) = rest.find('[') {
99        let Some(mid) = rest[open..].find("](").map(|i| open + i) else { break };
100        let Some(close) = rest[mid + 2..].find(')').map(|i| mid + 2 + i) else { break };
101        let (label, url) = (&rest[open + 1..mid], &rest[mid + 2..close]);
102        out.push_str(&emphasis(&escape(&rest[..open])));
103        if url.starts_with("https://") || url.starts_with("http://") {
104            out.push_str(&format!(
105                "<a href=\"{}\" target=\"_blank\" rel=\"noopener noreferrer\">{}</a>",
106                escape(url),
107                emphasis(&escape(label))
108            ));
109        } else {
110            out.push_str(&emphasis(&escape(label)));
111        }
112        rest = &rest[close + 1..];
113    }
114    out.push_str(&emphasis(&escape(rest)));
115    out
116}
117
118/// `**bold**`, then `_emphasis_` / `*emphasis*` — each only when it opens and closes around
119/// text, so `2 ** 3` and a `snake_case` name stay as they are.
120fn emphasis(text: &str) -> String {
121    let bold = wrap_pairs(text, "**", "strong");
122    let em = wrap_pairs(&bold, "_", "em");
123    wrap_pairs(&em, "*", "em")
124}
125
126fn wrap_pairs(text: &str, marker: &str, tag: &str) -> String {
127    let mut out = String::new();
128    let mut rest = text;
129    loop {
130        let Some(open) = find_marker(rest, marker, true) else { break };
131        let after = &rest[open + marker.len()..];
132        let Some(close) = find_marker(after, marker, false) else { break };
133        let inner = &after[..close];
134        out.push_str(&rest[..open]);
135        out.push_str(&format!("<{tag}>{inner}</{tag}>"));
136        rest = &after[close + marker.len()..];
137    }
138    out.push_str(rest);
139    out
140}
141
142/// Where `marker` can open (followed by text, not after a letter) or close (after text, not
143/// before a letter) a span.
144fn find_marker(text: &str, marker: &str, opening: bool) -> Option<usize> {
145    let mut from = 0;
146    while let Some(i) = text[from..].find(marker).map(|i| i + from) {
147        let before = text[..i].chars().next_back();
148        let after = text[i + marker.len()..].chars().next();
149        let ok = if opening {
150            after.is_some_and(|c| !c.is_whitespace()) && before.is_none_or(|c| !c.is_alphanumeric())
151        } else {
152            before.is_some_and(|c| !c.is_whitespace()) && after.is_none_or(|c| !c.is_alphanumeric())
153        };
154        if ok && !(marker != "**" && text[i..].starts_with("**")) {
155            return Some(i);
156        }
157        from = i + marker.len();
158    }
159    None
160}
161
162#[cfg(test)]
163#[path = "../../../../../crates/logicaffeine_tests/unit/logicaffeine_web/ui/components/hover_hint/tests.rs"]
164mod tests;