1use std::ops::Range;
24
25use super::format::{OutputFormat, QuoteMarks};
26use super::visible_scan::{RunBuilder, find_matching, skip_balanced};
27use citum_schema::template::WrapPunctuation;
28
29fn escape_commonmark_text(s: &str) -> String {
37 let mut out = String::with_capacity(s.len() + 4);
38 for ch in s.chars() {
39 match ch {
40 '\\' | '*' | '_' | '[' | ']' | '`' | '<' | '>' | '&' => {
41 out.push('\\');
42 out.push(ch);
43 }
44 _ => out.push(ch),
45 }
46 }
47 out
48}
49
50#[derive(Default, Clone)]
52pub struct Markdown;
53
54impl OutputFormat for Markdown {
55 type Output = String;
56
57 fn text(&self, s: &str) -> Self::Output {
58 escape_commonmark_text(s)
59 }
60
61 fn join(&self, items: Vec<Self::Output>, delimiter: &str) -> Self::Output {
62 items.join(delimiter)
63 }
64
65 fn finish(&self, output: Self::Output) -> String {
66 output
67 }
68
69 fn heading(&self, level: u8, content: Self::Output) -> Self::Output {
71 let marks = "#".repeat(level.max(1) as usize);
72 format!("{marks} {content}\n\n")
73 }
74
75 fn emph(&self, content: Self::Output) -> Self::Output {
77 if content.is_empty() {
78 return content;
79 }
80 format!("*{content}*")
81 }
82
83 fn strong(&self, content: Self::Output) -> Self::Output {
85 if content.is_empty() {
86 return content;
87 }
88 format!("**{content}**")
89 }
90
91 fn small_caps(&self, content: Self::Output) -> Self::Output {
96 if content.is_empty() {
97 return content;
98 }
99 format!("<span style=\"font-variant:small-caps\">{content}</span>")
100 }
101
102 fn superscript(&self, content: Self::Output) -> Self::Output {
107 if content.is_empty() {
108 return content;
109 }
110 format!("<sup>{content}</sup>")
111 }
112
113 fn quote(&self, content: Self::Output, marks: &QuoteMarks) -> Self::Output {
114 if content.is_empty() {
115 return content;
116 }
117 let (open, close) = marks.for_depth(0);
118 format!("{open}{content}{close}")
119 }
120
121 fn affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output {
122 format!("{prefix}{content}{suffix}")
123 }
124
125 fn inner_affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output {
126 format!("{prefix}{content}{suffix}")
127 }
128
129 fn wrap_punctuation(
130 &self,
131 wrap: &WrapPunctuation,
132 content: Self::Output,
133 marks: &QuoteMarks,
134 ) -> Self::Output {
135 match wrap {
136 WrapPunctuation::Parentheses => format!("({content})"),
137 WrapPunctuation::Brackets => format!("[{content}]"),
138 WrapPunctuation::Quotes => self.quote(content, marks),
139 }
140 }
141
142 fn semantic(&self, _class: &str, content: Self::Output) -> Self::Output {
148 content
149 }
150
151 fn annotation(&self, content: Self::Output) -> Self::Output {
152 if content.is_empty() {
153 return content;
154 }
155 format!("\n\n{content}")
156 }
157
158 fn link(&self, url: &str, content: Self::Output) -> Self::Output {
159 if content.is_empty() {
160 return content;
161 }
162 format!("[{content}]({url})")
163 }
164
165 fn entry(
166 &self,
167 _id: &str,
168 content: Self::Output,
169 url: Option<&str>,
170 _metadata: &super::format::ProcEntryMetadata,
171 ) -> Self::Output {
172 if let Some(u) = url {
173 self.link(u, content)
174 } else {
175 content
176 }
177 }
178
179 fn visible_runs(&self, fragment: &str) -> Vec<Range<usize>> {
186 let mut runs = RunBuilder::default();
187 let chars: Vec<(usize, char)> = fragment.char_indices().collect();
188 let mut i = 0;
189 let mut in_tag = false;
190 let mut pending_link_close: Option<usize> = None;
191 while let Some(&(pos, ch)) = chars.get(i) {
192 if in_tag {
193 if ch == '>' {
194 in_tag = false;
195 }
196 i += 1;
197 continue;
198 }
199 match ch {
200 '\\' => {
201 if let Some(&(epos, echar)) = chars.get(i + 1) {
202 runs.push_visible(epos, epos + echar.len_utf8());
203 }
204 i += 2;
205 }
206 '<' => {
207 in_tag = true;
208 i += 1;
209 }
210 '*' => {
211 i += 1;
212 if chars.get(i).map(|&(_, c)| c) == Some('*') {
213 i += 1;
214 }
215 }
216 '[' => {
217 let close_i = find_matching(&chars, i, '[', ']', true);
218 let is_link = close_i
219 .is_some_and(|c| chars.get(c + 1).map(|&(_, next)| next) == Some('('));
220 if is_link {
221 pending_link_close = close_i;
222 i += 1;
223 } else {
224 runs.push_visible(pos, pos + 1);
225 i += 1;
226 }
227 }
228 ']' => {
229 if pending_link_close == Some(i) {
230 pending_link_close = None;
231 let mut j = i + 1;
232 if chars.get(j).map(|&(_, c)| c) == Some('(') {
233 j = skip_balanced(&chars, j, '(', ')', true);
234 }
235 i = j;
236 } else {
237 runs.push_visible(pos, pos + 1);
238 i += 1;
239 }
240 }
241 _ => {
242 runs.push_visible(pos, pos + ch.len_utf8());
243 i += 1;
244 }
245 }
246 }
247 runs.finish()
248 }
249}
250
251#[cfg(test)]
252#[allow(
253 clippy::unwrap_used,
254 clippy::expect_used,
255 clippy::panic,
256 clippy::indexing_slicing,
257 reason = "tests"
258)]
259mod tests {
260 use super::*;
261
262 #[test]
263 fn test_markdown_emph() {
264 let fmt = Markdown;
265 for (input, expected) in [("", ""), ("text", "*text*")] {
266 assert_eq!(fmt.emph(input.to_string()), expected);
267 }
268 }
269
270 #[test]
271 fn test_markdown_strong() {
272 let fmt = Markdown;
273 for (input, expected) in [("", ""), ("text", "**text**")] {
274 assert_eq!(fmt.strong(input.to_string()), expected);
275 }
276 }
277
278 #[test]
279 fn test_markdown_small_caps() {
280 let fmt = Markdown;
281 assert_eq!(fmt.small_caps(String::new()), "");
282 assert_eq!(
283 fmt.small_caps("Smith".to_string()),
284 "<span style=\"font-variant:small-caps\">Smith</span>"
285 );
286 }
287
288 #[test]
289 fn test_markdown_superscript() {
290 let fmt = Markdown;
291 assert_eq!(fmt.superscript(String::new()), "");
292 assert_eq!(fmt.superscript("2".to_string()), "<sup>2</sup>");
293 }
294
295 #[test]
296 fn test_markdown_quote() {
297 let fmt = Markdown;
298 let marks = QuoteMarks::default();
299 for (input, expected) in [("", ""), ("text", "\u{201C}text\u{201D}")] {
300 assert_eq!(fmt.quote(input.to_string(), &marks), expected);
301 }
302 }
303
304 #[test]
305 fn test_markdown_quote_uses_locale_marks() {
306 let fmt = Markdown;
307 let marks = QuoteMarks {
308 open: "\u{ab}".to_string(),
309 close: "\u{bb}".to_string(),
310 open_inner: "\u{2039}".to_string(),
311 close_inner: "\u{203a}".to_string(),
312 };
313
314 assert_eq!(fmt.quote("text".to_string(), &marks), "\u{ab}text\u{bb}");
315 }
316
317 #[test]
318 fn test_markdown_semantic_passthrough() {
319 let fmt = Markdown;
320 assert_eq!(fmt.semantic("author", "Jane Doe".to_string()), "Jane Doe");
321 assert_eq!(fmt.semantic("title", String::new()), "");
322 }
323
324 #[test]
325 fn test_markdown_link() {
326 let fmt = Markdown;
327 assert_eq!(fmt.link("https://example.com", String::new()), "");
328 assert_eq!(
329 fmt.link("https://example.com", "Example".to_string()),
330 "[Example](https://example.com)"
331 );
332 }
333
334 #[test]
335 fn test_markdown_wrap_punctuation() {
336 let fmt = Markdown;
337 let marks = QuoteMarks::default();
338 for (wrap, input, expected) in [
339 (WrapPunctuation::Parentheses, "text", "(text)"),
340 (WrapPunctuation::Brackets, "text", "[text]"),
341 (WrapPunctuation::Quotes, "text", "\u{201C}text\u{201D}"),
342 ] {
343 assert_eq!(
344 fmt.wrap_punctuation(&wrap, input.to_string(), &marks),
345 expected
346 );
347 }
348 }
349
350 #[test]
351 fn test_markdown_text_escapes_active_chars() {
352 let fmt = Markdown;
353 assert_eq!(fmt.text("plain"), "plain");
354 assert_eq!(fmt.text("A * B"), "A \\* B");
355 assert_eq!(fmt.text("use [x]"), "use \\[x\\]");
356 assert_eq!(fmt.text("code `foo`"), "code \\`foo\\`");
357 assert_eq!(fmt.text("back\\slash"), "back\\\\slash");
358 assert_eq!(fmt.text("under_score"), "under\\_score");
359 assert_eq!(fmt.text("<doi:10.1/x>"), "\\<doi:10.1/x\\>");
362 assert_eq!(fmt.text("Smith & Jones"), "Smith \\& Jones");
363 assert_eq!(fmt.text("<em>bold</em>"), "\\<em\\>bold\\</em\\>");
364 }
365
366 #[test]
367 fn visible_text_strips_emph_and_strong_delimiters() {
368 let fmt = Markdown;
369 assert_eq!(fmt.visible_text("*Title.*"), "Title.");
370 assert_eq!(fmt.visible_text("**Title.**"), "Title.");
371 }
372
373 #[test]
374 fn visible_text_hides_link_url_keeps_text() {
375 let fmt = Markdown;
376 assert_eq!(
377 fmt.visible_text("[Example](https://example.com/a.b)"),
378 "Example"
379 );
380 }
381
382 #[test]
383 fn visible_text_keeps_literal_wrap_brackets_visible() {
384 let fmt = Markdown;
385 assert_eq!(fmt.visible_text("[Dataset]"), "[Dataset]");
387 }
388
389 #[test]
390 fn visible_text_strips_raw_html_spans() {
391 let fmt = Markdown;
392 assert_eq!(
393 fmt.visible_text("<span style=\"font-variant:small-caps\">Smith</span>"),
394 "Smith"
395 );
396 assert_eq!(fmt.visible_text("<sup>2</sup>"), "2");
397 }
398
399 #[test]
400 fn visible_text_keeps_escaped_punctuation() {
401 let fmt = Markdown;
402 assert_eq!(fmt.visible_text(r"A \* B"), "A * B");
403 }
404}