1fn special(c: char) -> String {
12 let name = match c {
13 '\u{2192}' => "->",
14 '\u{2190}' => "<-",
15 '\u{2191}' => "ua",
16 '\u{2193}' => "da",
17 '\u{2014}' => "em",
18 '\u{2013}' => "en",
19 '\u{00d7}' => "mu",
20 '\u{2264}' => "<=",
21 '\u{2265}' => ">=",
22 '\u{2260}' => "!=",
23 '\u{2212}' => "mi",
24 '\u{00b7}' => "pc",
25 '\u{2018}' => "oq",
26 '\u{2019}' => "cq",
27 '\u{201c}' => "lq",
28 '\u{201d}' => "rq",
29 '\u{03bc}' | '\u{00b5}' => "*m",
30 '\u{03c1}' => "*r",
31 '\u{00b0}' => "de",
32 _ => return format!("\\[u{:04X}]", c as u32),
33 };
34 if name.len() == 2 {
35 format!("\\({name}")
36 } else {
37 format!("\\[{name}]")
38 }
39}
40
41const BREAK: char = '\u{E000}';
43
44fn with_breaks(s: &str) -> String {
47 let mut out = String::with_capacity(s.len());
48 for (n, word) in s.split(' ').enumerate() {
49 if n > 0 {
50 out.push(' ');
51 }
52 if word.chars().count() <= 24 {
53 out.push_str(word);
54 continue;
55 }
56 let chars: Vec<char> = word.chars().collect();
57 for (i, &c) in chars.iter().enumerate() {
58 out.push(c);
59 if matches!(c, '.' | '/' | ',') && i + 1 < chars.len() {
60 out.push(BREAK);
61 }
62 }
63 }
64 out
65}
66
67fn push_char(out: &mut String, c: char, literal: bool, option_like: bool) {
70 match c {
71 '\\' => out.push_str("\\e"),
72 BREAK => out.push_str("\\:"),
73 '-' if literal || option_like => out.push_str("\\-"),
74 c if c.is_ascii() => out.push(c),
75 c => out.push_str(&special(c)),
76 }
77}
78
79fn guard(line: String) -> String {
81 if line.starts_with('.') || line.starts_with('\'') {
82 format!("\\&{line}")
83 } else {
84 line
85 }
86}
87
88pub fn text(s: &str) -> String {
91 let mut out = String::new();
92 let mut prev: Option<char> = None;
93 for c in with_breaks(s).chars() {
94 let option_like = c == '-'
95 && prev.is_none_or(|p| {
96 p.is_whitespace() || matches!(p, '(' | '[' | '"' | '\'' | '-' | '=' | '/' | ',')
97 });
98 push_char(&mut out, c, false, option_like);
99 prev = Some(c);
100 }
101 out
102}
103
104pub fn literal(s: &str) -> String {
106 let mut out = String::new();
107 for c in s.chars() {
108 push_char(&mut out, c, true, false);
109 }
110 out
111}
112
113pub fn line(escaped: String) -> String {
115 guard(escaped)
116}
117
118pub fn arg(escaped: &str) -> String {
120 format!("\"{}\"", escaped.replace('"', "\\(dq"))
121}
122
123fn breakable(code: &str) -> String {
127 literal(&with_breaks(code))
128}
129
130pub fn bold(s: &str) -> String {
132 format!("\\fB{}\\fR", literal(s))
133}
134
135pub fn italic(s: &str) -> String {
137 format!("\\fI{}\\fR", literal(s))
138}
139
140pub fn example_block(out: &mut String, code: &str) {
142 out.push_str(".PP\n.RS 4\n.EX\n");
143 for l in code.trim_end_matches('\n').lines() {
144 out.push_str(&guard(literal(l)));
145 out.push('\n');
146 }
147 out.push_str(".EE\n.RE\n");
148}
149
150pub fn tidy(roff: &str) -> String {
153 let mut out = String::with_capacity(roff.len());
154 let mut after_heading = false;
155 for l in roff.lines() {
156 if l == ".PP" && after_heading {
157 continue;
158 }
159 after_heading = l.starts_with(".SH") || l.starts_with(".SS");
160 out.push_str(l);
161 out.push('\n');
162 }
163 out
164}
165
166pub fn inline(md: &str) -> String {
170 let md = md
171 .replace("<kbd>", "\u{1}")
172 .replace("</kbd>", "\u{1}")
173 .replace("<", "<")
174 .replace(">", ">")
175 .replace("&", "&")
176 .replace("<br>", " ");
177 let chars: Vec<char> = md.chars().collect();
178 let mut out = String::new();
179 let mut plain = String::new();
180 let flush = |plain: &mut String, out: &mut String| {
181 if !plain.is_empty() {
182 out.push_str(&text(plain));
185 plain.clear();
186 }
187 };
188 let mut i = 0;
189 while i < chars.len() {
190 let c = chars[i];
191 match c {
192 '\\' if i + 1 < chars.len() && chars[i + 1].is_ascii_punctuation() => {
193 plain.push(chars[i + 1]);
194 i += 2;
195 }
196 '`' => {
197 let ticks = chars[i..].iter().take_while(|&&c| c == '`').count();
199 let start = i + ticks;
200 let mut j = start;
201 let mut end = None;
202 while j < chars.len() {
203 if chars[j] == '`' {
204 let run = chars[j..].iter().take_while(|&&c| c == '`').count();
205 if run == ticks {
206 end = Some(j);
207 break;
208 }
209 j += run;
210 } else {
211 j += 1;
212 }
213 }
214 match end {
215 Some(end) => {
216 flush(&mut plain, &mut out);
217 let code: String = chars[start..end].iter().collect();
218 let code = code.trim().replace("\\|", "|");
219 out.push_str(&format!("\\fB{}\\fR", breakable(&code)));
220 i = end + ticks;
221 }
222 None => {
223 plain.push(c);
224 i += 1;
225 }
226 }
227 }
228 '\u{1}' => {
229 let end = chars[i + 1..].iter().position(|&c| c == '\u{1}');
230 match end {
231 Some(n) => {
232 flush(&mut plain, &mut out);
233 let key: String = chars[i + 1..i + 1 + n].iter().collect();
234 out.push_str(&bold(&key));
235 i += n + 2;
236 }
237 None => i += 1,
238 }
239 }
240 '*' | '_' if c == '*' || (i == 0 || !chars[i - 1].is_alphanumeric()) => {
241 let strong = chars.get(i + 1) == Some(&c);
242 let width = if strong { 2 } else { 1 };
243 let start = i + width;
244 let close: Vec<char> = std::iter::repeat_n(c, width).collect();
245 let end = (start..chars.len().saturating_sub(width - 1)).find(|&j| {
246 chars[j..j + width] == close[..]
247 && j > start
248 && !chars[j - 1].is_whitespace()
249 && (c == '*' || chars.get(j + width).is_none_or(|n| !n.is_alphanumeric()))
250 });
251 match end {
252 Some(end) if !chars[start].is_whitespace() => {
253 flush(&mut plain, &mut out);
254 let inner: String = chars[start..end].iter().collect();
255 let font = if strong { "B" } else { "I" };
256 out.push_str(&format!("\\f{font}{}\\fR", inline(&inner)));
257 i = end + width;
258 }
259 _ => {
260 plain.push(c);
261 i += 1;
262 }
263 }
264 }
265 '[' => {
266 let close = chars[i..].iter().position(|&c| c == ']').map(|n| i + n);
268 let link = close
269 .filter(|&j| chars.get(j + 1) == Some(&'('))
270 .and_then(|j| {
271 chars[j + 2..]
272 .iter()
273 .position(|&c| c == ')')
274 .map(|n| (j, j + 2 + n))
275 });
276 match link {
277 Some((j, k)) => {
278 flush(&mut plain, &mut out);
279 let label: String = chars[i + 1..j].iter().collect();
280 let target: String = chars[j + 2..k].iter().collect();
281 out.push_str(&inline(&label));
282 if target.starts_with("http://") || target.starts_with("https://") {
283 out.push_str(&format!(" <\\fI{}\\fR>", literal(&target)));
284 }
285 i = k + 1;
286 }
287 None => {
288 plain.push(c);
289 i += 1;
290 }
291 }
292 }
293 _ => {
294 plain.push(c);
295 i += 1;
296 }
297 }
298 }
299 flush(&mut plain, &mut out);
300 out
301}
302
303pub fn plain(md: &str) -> String {
305 let md = md.replace(['`', '*'], "").replace("\\|", "|");
306 let mut out = String::new();
307 let mut rest = md.as_str();
308 while let Some(open) = rest.find('[') {
310 out.push_str(&rest[..open]);
311 let after = &rest[open + 1..];
312 match after
313 .find("](")
314 .and_then(|c| after[c..].find(')').map(|e| (c, c + e)))
315 {
316 Some((c, e)) => {
317 out.push_str(&after[..c]);
318 rest = &after[e + 1..];
319 }
320 None => {
321 out.push('[');
322 rest = after;
323 }
324 }
325 }
326 out.push_str(rest);
327 out
328}
329
330#[derive(Debug, Clone, Copy, PartialEq, Eq)]
332pub enum Headings {
333 Sections,
335 Subsections,
337}
338
339pub struct Markdown<'a> {
341 pub headings: Headings,
342 pub dataset_label: Option<&'a dyn Fn(&str) -> String>,
344}
345
346fn cells(row: &str) -> Vec<String> {
348 let row = row.trim().trim_start_matches('|');
349 let row = row.strip_suffix('|').unwrap_or(row);
350 let mut out = vec![String::new()];
351 let mut chars = row.chars().peekable();
352 while let Some(c) = chars.next() {
353 match c {
354 '\\' if chars.peek() == Some(&'|') => {
355 out.last_mut().unwrap().push_str("\\|");
356 chars.next();
357 }
358 '|' => out.push(String::new()),
359 c => out.last_mut().unwrap().push(c),
360 }
361 }
362 out.into_iter().map(|c| c.trim().to_string()).collect()
363}
364
365fn describes(header: &str) -> bool {
367 matches!(
368 plain(header).as_str(),
369 "Description"
370 | "Means"
371 | "What it means"
372 | "What it does"
373 | "What it says"
374 | "Role"
375 | "Does"
376 | "Read as"
377 | "Action"
378 )
379}
380
381impl Markdown<'_> {
382 pub fn render(&self, md: &str) -> String {
384 let md = md.replace("\r\n", "\n");
385 let lines: Vec<&str> = md.lines().collect();
386 let mut out = String::new();
387 let mut i = 0;
388 while i < lines.len() {
389 let l = lines[i];
390 let t = l.trim();
391 if t.is_empty() || t.starts_with("<!--") || (t.starts_with("# ") && !l.starts_with(' '))
392 {
393 i += 1;
394 continue;
395 }
396 if let Some(info) = t.strip_prefix("```") {
397 let fence: String = t.chars().take_while(|&c| c == '`').collect();
398 let mut body = Vec::new();
399 i += 1;
400 while i < lines.len() && !lines[i].trim().starts_with(fence.as_str()) {
401 body.push(lines[i]);
402 i += 1;
403 }
404 i += 1;
405 let dataset = info
406 .split(',')
407 .find_map(|a| a.trim().strip_prefix("dataset="));
408 if let (Some(name), Some(label)) = (dataset, self.dataset_label) {
409 out.push_str(&format!(".PP\n{}\n", line(label(name))));
410 }
411 let indent = body
412 .iter()
413 .filter(|b| !b.trim().is_empty())
414 .map(|b| b.len() - b.trim_start().len())
415 .min()
416 .unwrap_or(0);
417 let code: Vec<&str> = body.iter().map(|b| b.get(indent..).unwrap_or("")).collect();
418 example_block(&mut out, &code.join("\n"));
419 continue;
420 }
421 if let Some(heading) = t.strip_prefix("## ") {
422 match self.headings {
423 Headings::Sections => out.push_str(&format!(
424 ".SH {}\n",
425 arg(&text(&plain(heading).to_uppercase()))
426 )),
427 Headings::Subsections => {
428 out.push_str(&format!(".SS {}\n", arg(&text(&plain(heading)))))
429 }
430 }
431 i += 1;
432 continue;
433 }
434 if let Some(heading) = t.strip_prefix("### ").or_else(|| t.strip_prefix("#### ")) {
435 match self.headings {
436 Headings::Sections => {
437 out.push_str(&format!(".SS {}\n", arg(&text(&plain(heading)))))
438 }
439 Headings::Subsections => out.push_str(&format!(
440 ".PP\n{}\n",
441 line(format!("\\fB{}\\fR", text(&plain(heading))))
442 )),
443 }
444 i += 1;
445 continue;
446 }
447 if t.starts_with('|') {
448 let mut rows = Vec::new();
449 while i < lines.len() && lines[i].trim().starts_with('|') {
450 rows.push(cells(lines[i]));
451 i += 1;
452 }
453 self.table(&mut out, &rows);
454 continue;
455 }
456 let item = t
457 .strip_prefix("- ")
458 .or_else(|| t.strip_prefix("* "))
459 .map(|rest| (None, rest))
460 .or_else(|| {
461 let (n, rest) = t.split_once(". ")?;
462 n.parse::<u32>().ok().map(|n| (Some(n), rest))
463 });
464 if let Some((number, first)) = item {
465 let mut body = vec![first.to_string()];
466 i += 1;
467 while i < lines.len()
468 && !lines[i].trim().is_empty()
469 && lines[i].starts_with(' ')
470 && !lines[i].trim().starts_with("- ")
471 {
472 body.push(lines[i].trim().to_string());
473 i += 1;
474 }
475 let tag = number.map_or("\\(bu".to_string(), |n| format!("{n}."));
476 out.push_str(&format!(".IP {tag} 4\n{}\n", line(inline(&body.join(" ")))));
477 continue;
478 }
479 let mut body = Vec::new();
481 while i < lines.len() {
482 let t = lines[i].trim();
483 if t.is_empty()
484 || t.starts_with("```")
485 || t.starts_with('#')
486 || t.starts_with('|')
487 || t.starts_with("- ")
488 || t.starts_with("<!--")
489 {
490 break;
491 }
492 body.push(t);
493 i += 1;
494 }
495 out.push_str(&format!(".PP\n{}\n", line(inline(&body.join(" ")))));
496 }
497 out
498 }
499
500 fn table(&self, out: &mut String, rows: &[Vec<String>]) {
503 let Some(header) = rows.first() else {
504 return;
505 };
506 let body = rows
507 .iter()
508 .skip(1)
509 .filter(|r| !r.iter().all(|c| c.chars().all(|c| matches!(c, '-' | ':'))));
510 let description = if header.len() == 2 {
511 Some(1)
512 } else {
513 header.iter().position(|h| describes(h))
514 };
515 for row in body {
516 let tag = row.first().map(String::as_str).unwrap_or("");
517 out.push_str(&format!(".TP\n{}\n", line(inline(tag))));
518 let mut first = true;
519 if let Some(d) = description
520 && let Some(cell) = row.get(d)
521 && !cell.is_empty()
522 {
523 out.push_str(&line(inline(cell)));
524 out.push('\n');
525 first = false;
526 }
527 for (n, cell) in row.iter().enumerate().skip(1) {
528 if Some(n) == description || cell.is_empty() || cell == "\u{2014}" {
529 continue;
530 }
531 if !first {
532 out.push_str(".br\n");
533 }
534 let label = header.get(n).map(|h| plain(h)).unwrap_or_default();
535 out.push_str(&line(format!("{}: {}", text(&label), inline(cell))));
536 out.push('\n');
537 first = false;
538 }
539 }
540 }
541}
542
543#[cfg(test)]
544mod tests {
545 use super::*;
546
547 #[test]
548 fn options_are_minus_signs_and_hyphens_stay() {
549 assert_eq!(
550 text("--hive and -c, comma-separated"),
551 "\\-\\-hive and \\-c, comma-separated"
552 );
553 assert_eq!(literal("a-b"), "a\\-b");
554 }
555
556 #[test]
557 fn unicode_is_escaped() {
558 assert_eq!(text("↑ → é"), "\\(ua \\(-> \\[u00E9]");
559 assert!(inline("a — b").is_ascii());
560 }
561
562 #[test]
563 fn inline_markdown() {
564 assert_eq!(
565 inline("`--format` and **bold**"),
566 "\\fB\\-\\-format\\fR and \\fBbold\\fR"
567 );
568 assert_eq!(inline("see [Query](query.md)"), "see Query");
569 assert_eq!(
570 inline("<kbd>Enter</kbd> or *X*"),
571 "\\fBEnter\\fR or \\fIX\\fR"
572 );
573 assert_eq!(inline("a \\| b, `x \\| y`"), "a | b, \\fBx | y\\fR");
574 assert_eq!(inline("snake_case_name"), "snake_case_name");
575 }
576
577 #[test]
578 fn a_line_never_starts_a_request() {
579 assert_eq!(line(".hidden".into()), "\\&.hidden");
580 }
581
582 #[test]
583 fn blocks() {
584 let md = Markdown {
585 headings: Headings::Sections,
586 dataset_label: None,
587 };
588 let roff = md.render(
589 "# Title\n\nPara one\nline two.\n\n## A `b` c\n\n```bash\ndatui -x\n```\n\n| K | V |\n|---|---|\n| `a` | b |\n\n- item\n more\n",
590 );
591 assert_eq!(
592 roff,
593 ".PP\nPara one line two.\n.SH \"A B C\"\n.PP\n.RS 4\n.EX\ndatui \\-x\n.EE\n.RE\n.TP\n\\fBa\\fR\nb\n.IP \\(bu 4\nitem more\n"
594 );
595 }
596}