Skip to main content

markdown_that/plugins/extra/
smartquotes.rs

1//! Replaces `"` and `'` quotes with "nicer" ones like `‘`, `’`, `“`, `”`, or
2//! with `’` for words like "isn't".
3//!
4//! This currently only supports single-character quotes, which is a limitation
5//! of the Rust implementation due to the use of `const` generics.
6//!
7//! ## Implementation notes
8//!
9//! The main obstacle to implementing this was the fact that the document is
10//! necessarily represented as a tree of nodes.
11//! Each node is thus necessarily referenced by its parents, which means that at
12//! any given moment we cannot hold a mutable reference to a node if any other
13//! part of the code holds a reference to the document. At least that's my
14//! understanding of the problem.
15//! The smartquotes algorithm from the JS library makes heavy use of iteration
16//! backwards and forwards through a flat list of tokens. This isn't really
17//! possible in the Rust implementation. Building a flat representation of all
18//! `Node` objects is straightforward, but holding that list precludes us from executing a
19//! `root.walk_mut` call at the same time.
20//! On top of that, while the smartquotes algorithm iterates linearly over all
21//! nodes/tokens, looking at a specific token with index `j` can trigger
22//! replacements in any of the tokens with `0` to `j - 1`.
23//!
24//! The solution proposed here is to first compute all the replacement
25//! operations on a read-only flat view of the document, and _then_ to perform
26//! all replacements in a single call to `root.walk_mut`.
27use std::collections::HashMap;
28
29use crate::common::utils::is_punct_char;
30use crate::parser::core::CoreRule;
31use crate::parser::inline::Text;
32use crate::plugins::cmark::block::paragraph::Paragraph;
33use crate::plugins::cmark::inline::newline::{Hardbreak, Softbreak};
34use crate::plugins::html::html_inline::HtmlInline;
35use crate::{MarkdownThat, Node};
36
37const APOSTROPHE: char = '\u{2019}';
38const SINGLE_QUOTE: char = '\'';
39const DOUBLE_QUOTE: char = '"';
40const SPACE: char = ' ';
41
42/// Add smartquotes with the "classic" quote set of `‘`, `’`, `“`, and `”`.
43pub fn add(md: &mut MarkdownThat) {
44    add_with::<'‘', '’', '“', '”'>(md);
45}
46
47pub fn add_with<
48    const OPEN_SINGLE_QUOTE: char,
49    const CLOSE_SINGLE_QUOTE: char,
50    const OPEN_DOUBLE_QUOTE: char,
51    const CLOSE_DOUBLE_QUOTE: char,
52>(
53    md: &mut MarkdownThat,
54) {
55    md.add_rule::<SmartQuotesRule<
56        OPEN_SINGLE_QUOTE,
57        CLOSE_SINGLE_QUOTE,
58        OPEN_DOUBLE_QUOTE,
59        CLOSE_DOUBLE_QUOTE>>();
60}
61
62/// Simplified Node type that only holds the info we need
63///
64/// To replace quotes, we'll be iterating forward and backward over the nodes in
65/// our document tree. The `Node` class doesn't provide a mechanism to do this
66/// efficiently, and in any case we only care about certain parts of the
67/// information. This struct will be used to build a flat view of the document;
68/// the `Irrelevant` variant serves as a "filler" so that the indexes of the
69/// entries line up correctly with the order we see during tree traversal.
70enum FlatToken {
71    LineBreak,
72    Text { content: String, nesting_level: u32 },
73    HtmlInline { content: String },
74    Irrelevant,
75}
76
77/// A simple enum to distinguish single and double quotes
78#[derive(PartialEq, Eq, Debug, Clone, Copy)]
79enum QuoteType {
80    Single,
81    Double,
82}
83
84/// Holds information about quotes we have encountered thus far.
85///
86/// These quotes may or may not be used to close a pair further down the line.
87/// The different fields thus hold all the information we need to a. decide
88/// whether to match them up with another quote we encounter, and b. to
89/// perform the correct replacement, should be indeed using this quote to close a
90/// pair.
91struct QuoteMarker {
92    /// The iteration index of the node in which this quote was found.
93    ///
94    /// This is the index at which this quote's `Node` appears in a pre-order
95    /// depth-first walk of the document tree. Since we can only _modify_ nodes
96    /// during a walk, we rely on this index to tell us which nodes to modify.
97    walk_index: usize,
98    /// The position of the quote within the node's `content`
99    quote_position: usize,
100    /// Whether this is a single or a double quote
101    quote_type: QuoteType,
102    /// Nesting level of the containing token
103    ///
104    /// This is the nesting of the containing `Node` within the document tree.
105    /// It is used to decide which quotes can be matched up.
106    level: u32,
107}
108
109/// Description of a single quote replacement to be executed
110///
111/// As described above, we have to compute the replacements in a first step that
112/// treats the entire document tree read-only. Only then can we perform the
113/// actual replacements. This `struct` holds the information we need to perform
114/// the replacement of a single quote character during a `walk_mut`.
115struct ReplacementOp {
116    walk_index: usize,
117    quote_position: usize,
118    quote: char,
119}
120
121pub struct SmartQuotesRule<
122    const OPEN_SINGLE_QUOTE: char,
123    const CLOSE_SINGLE_QUOTE: char,
124    const OPEN_DOUBLE_QUOTE: char,
125    const CLOSE_DOUBLE_QUOTE: char,
126>;
127
128impl<
129    const OPEN_SINGLE_QUOTE: char,
130    const CLOSE_SINGLE_QUOTE: char,
131    const OPEN_DOUBLE_QUOTE: char,
132    const CLOSE_DOUBLE_QUOTE: char,
133> CoreRule
134    for SmartQuotesRule<
135        OPEN_SINGLE_QUOTE,
136        CLOSE_SINGLE_QUOTE,
137        OPEN_DOUBLE_QUOTE,
138        CLOSE_DOUBLE_QUOTE,
139    >
140{
141    fn run(root: &mut Node, _: &MarkdownThat) {
142        let text_tokens = all_text_tokens(root);
143
144        let replacement_ops = Self::compute_replacements(text_tokens);
145
146        // now that we know what we want to replace where, we go over the nodes a _third_ time to do all the actual replacements.
147        let mut current_index: usize = 0;
148
149        root.walk_mut(|node, _| {
150            if let Some(current_replacements) = replacement_ops.get(&current_index) {
151                let text_node = node.cast_mut::<Text>()
152                    .expect("Expected to find a text node at this index because we constructed our replacements HashMap accordingly.");
153                text_node.content = execute_replacements(current_replacements, &text_node.content);
154            };
155            current_index += 1;
156        });
157    }
158}
159
160impl<
161    const OPEN_SINGLE_QUOTE: char,
162    const CLOSE_SINGLE_QUOTE: char,
163    const OPEN_DOUBLE_QUOTE: char,
164    const CLOSE_DOUBLE_QUOTE: char,
165> SmartQuotesRule<OPEN_SINGLE_QUOTE, CLOSE_SINGLE_QUOTE, OPEN_DOUBLE_QUOTE, CLOSE_DOUBLE_QUOTE>
166{
167    /// Walk the list of tokens to figure out what needs replacing where. To do
168    /// this, we need to search back and forth over the nodes to find matching
169    /// quotes across nodes. The borrow checker won't let us handle the entire
170    /// set of nodes as mutable at the same time, however, so all we do here is
171    /// figure out what we _want_ to replace in which node.
172    fn compute_replacements(text_tokens: Vec<FlatToken>) -> HashMap<usize, HashMap<usize, char>> {
173        let mut quote_stack: Vec<QuoteMarker> = Vec::new();
174        let mut replacement_ops: HashMap<usize, HashMap<usize, char>> = HashMap::new();
175        for (walk_index, token) in text_tokens.iter().enumerate() {
176            if let FlatToken::Text {
177                content,
178                nesting_level,
179            } = token
180            {
181                for op in Self::replace_smartquotes(
182                    content,
183                    walk_index,
184                    *nesting_level,
185                    &text_tokens,
186                    &mut quote_stack,
187                ) {
188                    replacement_ops
189                        .entry(op.walk_index)
190                        .or_default()
191                        .insert(op.quote_position, op.quote);
192                }
193            }
194        }
195        replacement_ops
196    }
197
198    /// Compute quote replacements found by looking at a single text block
199    fn replace_smartquotes(
200        content: &str,
201        walk_index: usize,
202        level: u32,
203        text_tokens: &[FlatToken],
204        quote_stack: &mut Vec<QuoteMarker>,
205    ) -> Vec<ReplacementOp> {
206        truncate_stack(quote_stack, level);
207
208        let mut result: Vec<_> = Vec::new();
209        for (quote_position, quote_type) in find_quotes(content) {
210            let last_char = find_last_char_before(text_tokens, walk_index, quote_position);
211            let next_char = find_first_char_after(text_tokens, walk_index, quote_position);
212
213            let (can_open, can_close): (bool, bool) =
214                can_open_or_close(&quote_type, last_char, next_char);
215
216            if !can_open && !can_close {
217                // if this is a single quote, then we're in the middle of a word and
218                // assume it to be an apostrophe
219                if quote_type == QuoteType::Single {
220                    result.push(ReplacementOp {
221                        walk_index,
222                        quote_position,
223                        quote: APOSTROPHE,
224                    });
225                }
226                // in any case, we're done with this quote and continue searching
227                // for more quotes in this text block
228                continue;
229            }
230
231            if can_close {
232                if let Some((opening_op, closing_op, new_stack_len)) =
233                    Self::try_close(quote_stack, walk_index, level, quote_type, quote_position)
234                {
235                    quote_stack.truncate(new_stack_len);
236                    result.push(opening_op);
237                    result.push(closing_op);
238                    continue;
239                }
240            }
241
242            if can_open {
243                quote_stack.push(QuoteMarker {
244                    walk_index,
245                    quote_position,
246                    quote_type,
247                    level,
248                });
249            } else if can_close && quote_type == QuoteType::Single {
250                result.push(ReplacementOp {
251                    walk_index,
252                    quote_position,
253                    quote: APOSTROPHE,
254                });
255            }
256        }
257        result
258    }
259
260    /// Try to find a matching opening quote to the given one.
261    ///
262    /// If a match is found, returns `Some` with two `ReplacementOp`s to be
263    /// added to the result, and with the resulting length of the `quote_stack`.
264    fn try_close(
265        quote_stack: &[QuoteMarker],
266        walk_index: usize,
267        level: u32,
268        quote_type: QuoteType,
269        quote_position: usize,
270    ) -> Option<(ReplacementOp, ReplacementOp, usize)> {
271        for (j, other_item) in quote_stack.iter().enumerate().rev() {
272            if other_item.level < level {
273                return None;
274            }
275            if other_item.quote_type == quote_type && other_item.level == level {
276                return Some((
277                    ReplacementOp {
278                        walk_index: other_item.walk_index,
279                        quote_position: other_item.quote_position,
280                        quote: if quote_type == QuoteType::Single {
281                            OPEN_SINGLE_QUOTE
282                        } else {
283                            OPEN_DOUBLE_QUOTE
284                        },
285                    },
286                    ReplacementOp {
287                        walk_index,
288                        quote_position,
289                        quote: if quote_type == QuoteType::Single {
290                            CLOSE_SINGLE_QUOTE
291                        } else {
292                            CLOSE_DOUBLE_QUOTE
293                        },
294                    },
295                    j,
296                ));
297            }
298        }
299        None
300    }
301}
302
303/// Produces a simplified flat list of all tokens, with the necessary
304/// information to do smart quote replacement.
305///
306/// This handles inline HTML and inline code like the JS version seems to do.
307/// This list is a work-around for the fact that we can't build a flat list of
308/// all nodes for iteration back and forth, and at the same time do a mutable
309/// walk on the document tree.
310///
311/// Returns a `Vec<FlatToken<'a>>` where `<'a>` is the same lifetime as `root`.
312/// This simply reflects the fact that the `content: &str` entries of the
313/// `FlatToken` structs reference the same memory as `root`'s children.
314/// Every entry in the `Vec` will produce an entry in the result, meaning that
315/// the index of a token in the resulting `Vec` will be the same as the index it
316/// would get during a `root.walk` call.
317fn all_text_tokens(root: &Node) -> Vec<FlatToken> {
318    let mut result = Vec::new();
319    let mut walk_index = 0;
320    root.walk(|node, nesting_level| {
321        if let Some(text_node) = node.cast::<Text>() {
322            result.push(FlatToken::Text {
323                content: text_node.content.clone(),
324                nesting_level,
325            });
326        } else if let Some(html_node) = node.cast::<HtmlInline>() {
327            result.push(FlatToken::HtmlInline {
328                content: html_node.content.clone(),
329            });
330        } else if node.is::<Paragraph>() || node.is::<Hardbreak>() || node.is::<Softbreak>() {
331            result.push(FlatToken::LineBreak);
332        } else {
333            result.push(FlatToken::Irrelevant);
334        }
335        walk_index += 1;
336    });
337    result
338}
339
340/// Checks whether we can open or close a pair of quotes, given the quote type
341/// and the type of characters before and after the quote
342fn can_open_or_close(quote_type: &QuoteType, last_char: char, next_char: char) -> (bool, bool) {
343    // special case: 1"" -> count the first quote as an inch
344    // We handle this before doing anything else to simplify the conditions
345    // below.
346    let is_double = *quote_type == QuoteType::Double;
347    let next_is_double = next_char == DOUBLE_QUOTE;
348    let last_is_digit = last_char.is_ascii_digit();
349    if next_is_double && is_double && last_is_digit {
350        return (false, false);
351    }
352
353    // using `is_ascii_punctuation` here matches the JS version exactly, but
354    // that also means we might inherit that implementation's shortcomings
355    // by ignoring Unicode punctuation. `is_punct_char `, however, should
356    // compensate for this.
357    let is_last_punctuation = last_char.is_ascii_punctuation() || is_punct_char(last_char);
358    let is_next_punctuation = next_char.is_ascii_punctuation() || is_punct_char(next_char);
359
360    // Yet again we rely on rust's built-in character handling. The definition
361    // of `is_whitespace` according to the Unicode proplist.txt shows that the
362    // difference to the JS version.
363    // https://www.unicode.org/Public/UCD/latest/ucd/PropList.txt
364    //
365    // Recognized as whitespace by Rust, but not by JS:
366    // 0x85, 0x28, 0x29
367    let is_last_whitespace = last_char.is_whitespace();
368    let is_next_whitespace = next_char.is_whitespace();
369
370    let can_open =
371        !is_next_whitespace && (!is_next_punctuation || is_last_whitespace || is_last_punctuation);
372    let can_close =
373        !is_last_whitespace && (!is_last_punctuation || is_next_whitespace || is_next_punctuation);
374
375    if can_open && can_close {
376        // Replace quotes in the middle of a punctuation sequence, but not
377        // in the middle of the words, i.e.:
378        //
379        // 1. foo " bar " baz - not replaced
380        // 2. foo-"-bar-"-baz - replaced
381        // 3. foo"bar"baz     - not replaced
382        return (is_last_punctuation, is_next_punctuation);
383    }
384
385    (can_open, can_close)
386}
387
388/// Executes a set of character replacements on a string
389fn execute_replacements(replacement_ops: &HashMap<usize, char>, content: &str) -> String {
390    content
391        .chars()
392        .enumerate()
393        .map(|(i, c)| *replacement_ops.get(&i).unwrap_or(&c))
394        .collect()
395}
396
397/// Truncates the stack of quotes following the JS implementation.
398///
399/// This _might_ be simplified by removing the `rev` call and using
400/// `Vec::take_while` instead, but I'm not 100% sure yet that the levels on the
401/// stack are really monotonously increasing, so I'm leaving it as it is for now.
402fn truncate_stack(quote_stack: &mut Vec<QuoteMarker>, level: u32) {
403    let stack_len = quote_stack
404        .iter()
405        .rev()
406        .skip_while(|qm| qm.level > level)
407        .count();
408    quote_stack.truncate(stack_len);
409}
410
411/// Finds all single or double quotes in a string, together with their positions
412///
413/// This might be replaced with a regex search, but not sure that's really worth
414/// it, given that we only check for two fixed characters.
415fn find_quotes(content: &str) -> impl Iterator<Item = (usize, QuoteType)> + '_ {
416    content.chars().enumerate().filter_map(|(p, c)| {
417        if c == SINGLE_QUOTE || c == DOUBLE_QUOTE {
418            Some((
419                p,
420                if c == SINGLE_QUOTE {
421                    QuoteType::Single
422                } else {
423                    QuoteType::Double
424                },
425            ))
426        } else {
427            None
428        }
429    })
430}
431
432/// Finds the next relevant character after a given position
433///
434/// This is the mirror image of `find_last_char_before`.
435///
436/// The position given is that of a quote we found. It is identified by its
437/// token/node index and the position of the quote inside that token. The full
438/// sequence of the text tokens is searched forwards from that point and the
439/// first character is returned.
440///
441/// If a line breaks or the end of the document is encountered during search,
442/// space (0x20) is returned.
443///
444/// This function is a bit simpler than `find_last_char_before` because Vec
445/// conveniently returns None for out-of-range indexes at the top end, while not
446/// allowing to index with a negative index.
447fn find_first_char_after(
448    text_tokens: &[FlatToken],
449    token_index: usize,
450    quote_position: usize,
451) -> char {
452    for (idx_t, text_token) in text_tokens.iter().enumerate().skip(token_index) {
453        let token = match text_token {
454            FlatToken::LineBreak => return SPACE,
455            FlatToken::Text {
456                content,
457                nesting_level: _,
458            } => content,
459            FlatToken::HtmlInline { content } => content,
460            FlatToken::Irrelevant => continue,
461        };
462        let start_index = if idx_t == token_index {
463            quote_position + 1
464        } else {
465            0
466        };
467        if let Some(c) = token.chars().nth(start_index) {
468            return c;
469        }
470    }
471    // this will be hit if we start searching at the last position of the last
472    // text token
473    SPACE
474}
475
476/// Finds the last relevant character before a given position
477///
478/// The position given is that of a quote we found. It is identified by its
479/// token/node index and the position of the quote inside that token. The full
480/// sequence of the text tokens is searched backwards from that point, and the
481/// first character is returned.
482///
483/// If a line break or the beginning of the document is encountered during
484/// search, space (0x20) is returned.
485fn find_last_char_before(
486    text_tokens: &[FlatToken],
487    token_index: usize,
488    quote_position: usize,
489) -> char {
490    for idx_t in (0..=token_index).rev() {
491        let token = match &text_tokens[idx_t] {
492            FlatToken::LineBreak => return SPACE,
493            FlatToken::Text {
494                content,
495                nesting_level: _,
496            } => content,
497            FlatToken::HtmlInline { content } => content,
498            FlatToken::Irrelevant => continue,
499        };
500
501        // This is _not_ the first index we want to look at, but rather the
502        // index just _after_ that.  The reason is simply that this is `usize`
503        // and we want to first check if it's possible to still subtract 1 from
504        // it without panicking.
505        let start_index: usize = if idx_t == token_index {
506            quote_position
507        } else {
508            token.chars().count()
509        };
510        // means we can't go any further left -> try the next token (i.e., the
511        // one preceding this one)
512        if start_index == 0 {
513            continue;
514        }
515        // unwrapping is safe here, we built our index to match the length of
516        // the string, or (in the case of the token containing the quote itself)
517        // it should be indexing a _prefix_ of the string.
518        return token.chars().nth(start_index - 1).unwrap();
519    }
520    // this will be hit if we find a quote in the first position of the first token
521    SPACE
522}
523
524#[cfg(test)]
525mod tests {
526    #[test]
527    fn smartquotes_basics() {
528        let md = &mut crate::MarkdownThat::new();
529        crate::plugins::cmark::add(md);
530        crate::plugins::extra::smartquotes::add(md);
531        let html = md.parse(r#"'hello' "world""#).render();
532        assert_eq!(html.trim(), r#"<p>‘hello’ “world”</p>"#);
533    }
534
535    #[test]
536    fn smartquotes_shouldnt_affect_html() {
537        let md = &mut crate::MarkdownThat::new();
538        crate::plugins::cmark::add(md);
539        crate::plugins::html::html_inline::add(md);
540        crate::plugins::extra::smartquotes::add(md);
541        let html = md.parse(r#"<a href="hello"></a>"#).render();
542        assert_eq!(html.trim(), r#"<p><a href="hello"></a></p>"#);
543    }
544
545    #[test]
546    fn smartquotes_should_work_with_typographer() {
547        // regression test for https://github.com/rlidwka/markdown-it.rs/issues/26
548        let md = &mut crate::MarkdownThat::new();
549        crate::plugins::cmark::add(md);
550        crate::plugins::html::html_inline::add(md);
551        crate::plugins::extra::typographer::add(md);
552        crate::plugins::extra::smartquotes::add(md);
553        let html = md.parse("\"**...**\"").render();
554        assert_eq!(html.trim(), "<p>“<strong>…</strong>”</p>");
555    }
556}