Skip to main content

glum_lib/
typography.rs

1//! Typographic substitutions: straight quotes → curly, -- → em dash, ... → ellipsis.
2//!
3//! Performed on plain text *before* wrapping so that display widths reflect the
4//! final glyphs. We deliberately avoid touching text inside code spans or code
5//! blocks — those are passed through verbatim by the renderer.
6
7/// Apply smart-quote, em-dash, and ellipsis substitutions to a paragraph.
8pub fn smarten(input: &str) -> String {
9    let mut out = String::with_capacity(input.len());
10    let chars: Vec<char> = input.chars().collect();
11    let mut i = 0;
12    while i < chars.len() {
13        let c = chars[i];
14        let prev = if i == 0 { None } else { Some(chars[i - 1]) };
15
16        // --- → em dash; -- → en dash (keep simple: treat both double+triple as em).
17        if c == '-' && i + 1 < chars.len() && chars[i + 1] == '-' {
18            let triple = i + 2 < chars.len() && chars[i + 2] == '-';
19            out.push('\u{2014}'); // em dash
20            i += if triple { 3 } else { 2 };
21            continue;
22        }
23
24        // ... → ellipsis (only an exact triple, to avoid swallowing "....").
25        if c == '.' && i + 2 < chars.len() && chars[i + 1] == '.' && chars[i + 2] == '.' {
26            let next_is_dot = i + 3 < chars.len() && chars[i + 3] == '.';
27            let prev_is_dot = matches!(prev, Some('.'));
28            if !next_is_dot && !prev_is_dot {
29                out.push('\u{2026}');
30                i += 3;
31                continue;
32            }
33        }
34
35        // Smart quotes: open if preceded by whitespace/start/opening punctuation.
36        if c == '"' {
37            let opens = match prev {
38                None => true,
39                Some(p) => {
40                    p.is_whitespace() || matches!(p, '(' | '[' | '{' | '\u{2014}' | '\u{2013}')
41                }
42            };
43            out.push(if opens { '\u{201C}' } else { '\u{201D}' });
44            i += 1;
45            continue;
46        }
47        if c == '\'' {
48            // Apostrophe if between letters (don't, it's) or after letter (boys').
49            let between_letters = matches!(
50                (prev, chars.get(i + 1)),
51                (Some(a), Some(b)) if a.is_alphanumeric() && b.is_alphanumeric()
52            );
53            let after_letter = matches!(prev, Some(a) if a.is_alphanumeric());
54            if between_letters || after_letter {
55                out.push('\u{2019}');
56            } else {
57                let opens = match prev {
58                    None => true,
59                    Some(p) => p.is_whitespace() || matches!(p, '(' | '[' | '{' | '"' | '\u{201C}'),
60                };
61                out.push(if opens { '\u{2018}' } else { '\u{2019}' });
62            }
63            i += 1;
64            continue;
65        }
66
67        out.push(c);
68        i += 1;
69    }
70    out
71}
72
73#[cfg(test)]
74mod tests {
75    use super::smarten;
76
77    #[test]
78    fn em_dash() {
79        assert_eq!(smarten("a--b"), "a\u{2014}b");
80        assert_eq!(smarten("a---b"), "a\u{2014}b");
81    }
82
83    #[test]
84    fn ellipsis() {
85        assert_eq!(smarten("wait..."), "wait\u{2026}");
86        assert_eq!(smarten("x....y"), "x....y"); // four dots unchanged
87    }
88
89    #[test]
90    fn quotes_open_close() {
91        assert_eq!(smarten("he said \"hi\""), "he said \u{201C}hi\u{201D}");
92    }
93
94    #[test]
95    fn apostrophe() {
96        assert_eq!(smarten("don't"), "don\u{2019}t");
97        assert_eq!(smarten("boys'"), "boys\u{2019}");
98    }
99
100    #[test]
101    fn single_open_quote() {
102        assert_eq!(smarten("he said 'hi'"), "he said \u{2018}hi\u{2019}");
103    }
104
105    #[test]
106    fn preserves_plain_text() {
107        assert_eq!(smarten("hello world"), "hello world");
108    }
109}