Skip to main content

datui_cli/man/
roff.rs

1//! Roff for the manpages: text escaped as man(7) wants it, and the Markdown of the
2//! docs turned into man macros, so a page and its web page share one source.
3//!
4//! Prose is one line per paragraph and left to the formatter to fill. Commands and
5//! code are unfilled (`.EX`), so they are never broken or hyphenated and paste as
6//! written. Every character outside ASCII is a roff escape, so a page reads the same
7//! on a terminal that has no UTF-8.
8
9/// The escape for a character outside ASCII: its glyph name where roff has one that
10/// groff and mandoc both know, else `\[uXXXX]`.
11fn 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
41/// Where a long word may break: written as `\:`, which adds nothing to the text.
42const BREAK: char = '\u{E000}';
43
44/// `s` with a break allowed after each `.`, `/` and `,` of a word longer than fits a
45/// narrow terminal's line beside an indent: a long path, URL or timestamp.
46fn 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
67/// Escape one character; `literal` writes every `-` as the minus `\-`, which pastes
68/// as the hyphen-minus a shell needs.
69fn 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
79/// Keep a line from being read as a request: a leading `.` or `'`.
80fn guard(line: String) -> String {
81    if line.starts_with('.') || line.starts_with('\'') {
82        format!("\\&{line}")
83    } else {
84        line
85    }
86}
87
88/// Prose, escaped. A `-` that starts a word (`-c`, `--hive`, a lone `-`) is an option
89/// and becomes `\-`; a hyphen inside a word stays one.
90pub 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
104/// Something typed: a command, an option, a value. Every `-` is `\-`.
105pub 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
113/// A text line: escaped already, and guarded against reading as a request.
114pub fn line(escaped: String) -> String {
115    guard(escaped)
116}
117
118/// A macro's argument, quoted: `"` inside is `\(dq`.
119pub fn arg(escaped: &str) -> String {
120    format!("\"{}\"", escaped.replace('"', "\\(dq"))
121}
122
123/// Literal text that may break after a `.`, `/` or `,` when it is long, so a long
124/// path or timestamp in prose still fits a narrow terminal. `\:` adds nothing to the
125/// text, so it copies as written.
126fn breakable(code: &str) -> String {
127    literal(&with_breaks(code))
128}
129
130/// Bold, for literal input.
131pub fn bold(s: &str) -> String {
132    format!("\\fB{}\\fR", literal(s))
133}
134
135/// Italic, for something to replace.
136pub fn italic(s: &str) -> String {
137    format!("\\fI{}\\fR", literal(s))
138}
139
140/// A command shown unfilled and indented: one per line, never broken.
141pub 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
150/// Drop the paragraph breaks a heading already makes: `.PP` right after `.SH` or
151/// `.SS`, which mandoc warns about.
152pub 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
166/// Markdown inline text as roff: `code` and `<kbd>` bold, `**bold**`, `*italic*`,
167/// links as their text (and an absolute link's URL after it), entities and `\|`
168/// unescaped.
169pub fn inline(md: &str) -> String {
170    let md = md
171        .replace("<kbd>", "\u{1}")
172        .replace("</kbd>", "\u{1}")
173        .replace("&lt;", "<")
174        .replace("&gt;", ">")
175        .replace("&amp;", "&")
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            // A word's start is judged within the run of plain text; a run that
183            // follows a font change starts a word only if it starts with a space.
184            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                // A code span: as many backticks close it as open it.
198                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                // [text](target)
267                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
303/// Markdown inline text with its formatting dropped: for a heading.
304pub 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    // Links keep their text.
309    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/// How a page's Markdown headings map to man's.
331#[derive(Debug, Clone, Copy, PartialEq, Eq)]
332pub enum Headings {
333    /// `##` is a section (`.SH`, upper case), `###` a subsection.
334    Sections,
335    /// `##` is a subsection, `###` a bold line: the Markdown sits inside a section.
336    Subsections,
337}
338
339/// Turns a Markdown page into roff.
340pub struct Markdown<'a> {
341    pub headings: Headings,
342    /// What a `dataset=NAME` code block runs on: a line saying so goes before it.
343    pub dataset_label: Option<&'a dyn Fn(&str) -> String>,
344}
345
346/// A table row's cells, `\|` kept as part of a cell.
347fn 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
365/// A header that says the column is the row's description.
366fn 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    /// The whole page; its `#` title and generated-region comments left out.
383    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            // A paragraph: every line up to a blank one or a block.
480            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    /// A table as a list: the first cell is the tag, the description column the
501    /// text, and any other column a labeled line under it.
502    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}