Skip to main content

oxc_markdown_parser/syntax/
decode.rs

1//! Backslash-escape and character-reference decoding.
2//!
3//! The AST carries raw source spans only;
4//! this is the shared decoder that turns a raw slice into its cooked text.
5//! It is public because a printer needs the very same recognition to judge escape removal safely
6//! (an escape whose removal forms an entity changes meaning).
7
8use std::borrow::Cow;
9
10use super::entities;
11use crate::syntax::escapes_next;
12use crate::{ast::Destination, pos::Segment};
13
14/// Decodes backslash escapes and entities.
15/// `escapes: false` decodes only entities
16/// (autolink contents; backslash escapes don't work there).
17pub fn decode(raw: &str, escapes: bool) -> Cow<'_, str> {
18    if !raw.bytes().any(|b| b == b'\\' || b == b'&') {
19        return Cow::Borrowed(raw);
20    }
21    let mut out = String::with_capacity(raw.len());
22    let bytes = raw.as_bytes();
23    let mut i = 0;
24    while i < bytes.len() {
25        match bytes[i] {
26            b'\\' if escapes && escapes_next(bytes, i) => {
27                out.push(bytes[i + 1] as char);
28                i += 2;
29            }
30            b'&' => {
31                if let Some((text, end)) = entity(raw, i) {
32                    out.push_str(&text);
33                    i = end;
34                } else {
35                    out.push('&');
36                    i += 1;
37                }
38            }
39            _ => {
40                let Some(c) = raw[i..].chars().next() else { break };
41                out.push(c);
42                i += c.len_utf8();
43            }
44        }
45    }
46    Cow::Owned(out)
47}
48
49/// Recognizes a character reference at `at` (which holds `&`).
50/// Returns the replacement text and the position just past the `;`.
51pub fn entity(raw: &str, at: usize) -> Option<(Cow<'static, str>, usize)> {
52    let rest = &raw.as_bytes()[at + 1..];
53    if rest.first() == Some(&b'#') {
54        let (digits, radix, skip) = if matches!(rest.get(1), Some(b'x' | b'X')) {
55            (&rest[2..], 16u32, 2)
56        } else {
57            (&rest[1..], 10u32, 1)
58        };
59        let max_len = if radix == 16 { 6 } else { 7 };
60        let len = digits
61            .iter()
62            .take_while(|b| b.is_ascii_digit() || (radix == 16 && b.is_ascii_hexdigit()))
63            .count();
64        if len == 0 || len > max_len || digits.get(len) != Some(&b';') {
65            return None;
66        }
67        let text = std::str::from_utf8(&digits[..len]).ok()?;
68        let value = u32::from_str_radix(text, radix).ok()?;
69        let c = match char::from_u32(value) {
70            Some(c) if value != 0 => c,
71            _ => '\u{FFFD}',
72        };
73        return Some((Cow::Owned(c.to_string()), at + 1 + skip + len + 1));
74    }
75    // Named: letters/digits then `;`. micromark caps the scan at 31 chars;
76    // any larger bound behaves the same because the lookup rejects unknown
77    // names and the longest real entity is 31 chars. 48 leaves slack.
78    let len = rest.iter().take_while(|b| b.is_ascii_alphanumeric()).count();
79    if len == 0 || len > 48 || rest.get(len) != Some(&b';') {
80        return None;
81    }
82    let name = std::str::from_utf8(&rest[..len]).ok()?;
83    let value = entities::lookup(name)?;
84    Some((Cow::Borrowed(value), at + 1 + len + 1))
85}
86
87/// The cooked value of a link or definition title held as line pieces:
88/// quotes stripped, continuation lines' leading whitespace removed (micromark strips all of it),
89/// escapes and entities decoded.
90pub fn title(source: &str, pieces: &[Segment]) -> String {
91    let joined = join_stripped(source, pieces, usize::MAX);
92    decode(&joined[1..joined.len() - 1], true).into_owned()
93}
94
95/// Inline HTML as micromark's HTML compiler emits it.
96///
97/// The pieces joined with `\n`, continuation lines minus up to 3 columns of leading whitespace.
98/// For the mdast `value` (what a formatter prints) use [`Segment::join`]: it keeps that whitespace.
99pub fn html_inline(source: &str, pieces: &[Segment]) -> String {
100    join_stripped(source, pieces, 3)
101}
102
103/// Pieces joined with `\n`, each continuation piece minus up to `max` leading whitespace columns
104/// (padding counts as spaces).
105/// Tabs are counted from the piece start, not from the source column micromark uses,
106/// and a partially consumed tab is not re-emitted as spaces:
107/// `<b\n\t\tc=1>` gives `\tc=1` here and ` \tc=1` in micromark.
108/// Exact tab columns would need the segment's start column, which `Segment` does not carry;
109/// the shape (a tab-indented continuation line of an inline tag) is rare.
110fn join_stripped(source: &str, pieces: &[Segment], max: usize) -> String {
111    let mut out = String::new();
112    for (i, piece) in pieces.iter().enumerate() {
113        let mut text = piece.span.slice(source);
114        let mut padding = usize::from(piece.padding);
115        if i > 0 {
116            out.push('\n');
117            let mut cols = padding.min(max);
118            padding -= cols;
119            while cols < max {
120                match text.as_bytes().first() {
121                    Some(b' ') => cols += 1,
122                    Some(b'\t') => cols += 4 - cols % 4,
123                    _ => break,
124                }
125                text = &text[1..];
126            }
127        }
128        out.extend(std::iter::repeat_n(' ', padding));
129        out.push_str(text);
130    }
131    out
132}
133
134/// The cooked value of a link, image or definition destination (mdast's `url`):
135/// angle brackets stripped, escapes and entities decoded.
136pub fn destination<'s>(source: &'s str, destination: &Destination) -> Cow<'s, str> {
137    let raw = destination.span.slice(source);
138    let raw = if destination.angle_bracketed { &raw[1..raw.len() - 1] } else { raw };
139    decode(raw, true)
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use crate::Span;
146
147    #[test]
148    fn decodes_escapes_and_entities() {
149        assert!(matches!(decode("plain", true), Cow::Borrowed(_)));
150        assert_eq!(decode(r"\*a\\b\c", true), r"*a\b\c");
151        assert_eq!(decode("&amp; &#35; &#x41; &unknown; &", true), "& # A &unknown; &");
152        // `escapes: false` keeps backslashes but still decodes entities
153        assert_eq!(decode(r"\&amp;", false), r"\&");
154        assert_eq!(decode(r"\&amp;", true), "&amp;");
155    }
156
157    #[test]
158    fn entity_recognition() {
159        assert_eq!(entity("&amp;x", 0), Some((Cow::Borrowed("&"), 5)));
160        assert_eq!(entity("&#0;", 0), Some((Cow::Borrowed("\u{FFFD}"), 4)));
161        assert_eq!(entity("&#xD800;", 0), Some((Cow::Borrowed("\u{FFFD}"), 8)));
162        assert_eq!(entity("&amp", 0), None);
163        assert_eq!(entity("&#;", 0), None);
164        assert_eq!(entity("&#12345678;", 0), None, "more than 7 decimal digits");
165    }
166
167    #[test]
168    fn title_and_html_inline() {
169        let source = "\"a\n    b\"";
170        let pieces = [Segment::new(Span::new(0, 2), 0), Segment::new(Span::new(3, 9), 0)];
171        assert_eq!(title(source, &pieces), "a\nb");
172
173        // Continuation lines lose up to 3 columns; padding counts as columns
174        let source = "<b\n     c>";
175        let pieces = [Segment::new(Span::new(0, 2), 0), Segment::new(Span::new(3, 10), 0)];
176        assert_eq!(html_inline(source, &pieces), "<b\n  c>");
177        let pieces = [Segment::new(Span::new(0, 2), 0), Segment::new(Span::new(8, 10), 2)];
178        assert_eq!(html_inline(source, &pieces), "<b\nc>");
179    }
180}