logicaffeine_web/ui/components/
hover_hint.rs1use logicaffeine_lsp::document::DocumentState;
7
8pub 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
24pub 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('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
77}
78
79fn 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
93fn 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
118fn 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
142fn 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;