Skip to main content

markdown_that/plugins/cmark/block/
reference.rs

1//! Link reference definition
2//!
3//! `[label]: /url "title"`
4//!
5//! <https://spec.commonmark.org/0.30/#link-reference-definition>
6//!
7//! This plugin parses Markdown link references. Check the documentation on [ReferenceMap]
8//! to see how you can use and/or extend it if you have an external source for references.
9//!
10use crate::common::utils::normalize_reference;
11use crate::generics::inline::full_link;
12use crate::parser::block::{BlockRule, BlockState};
13use crate::parser::extset::RootExt;
14use crate::{MarkdownThat, Node, NodeValue};
15use downcast_rs::{Downcast, impl_downcast};
16use educe::Educe;
17use std::collections::HashMap;
18use std::fmt::Debug;
19use std::ops::{Deref, DerefMut};
20
21/// Storage for parsed references
22///
23/// If you have some external source for your link references, you can add them like this:
24///
25/// ```rust
26/// use markdown_that::parser::block::builtin::BlockParserRule;
27/// use markdown_that::parser::core::{CoreRule, Root};
28/// use markdown_that::plugins::cmark::block::reference::{ReferenceMap, DefaultReferenceMap, CustomReferenceMap};
29/// use markdown_that::{MarkdownThat, Node};
30///
31/// let md = &mut MarkdownThat::new();
32/// markdown_that::plugins::cmark::add(md);
33///
34/// #[derive(Debug, Default)]
35/// struct RefMapOverride(DefaultReferenceMap);
36/// impl CustomReferenceMap for RefMapOverride {
37///     fn insert(&mut self, label: String, destination: String, title: Option<String>) -> bool {
38///         self.0.insert(label, destination, title)
39///     }
40///
41///     fn get(&self, label: &str) -> Option<(&str, Option<&str>)> {
42///         // override a specific link
43///         if label == "rust" {
44///             return Some((
45///                 "https://www.rust-lang.org/",
46///                 Some("The Rust Language"),
47///             ));
48///         }
49///         self.0.get(label)
50///     }
51/// }
52///
53/// struct AddCustomReferences;
54/// impl CoreRule for AddCustomReferences {
55///     fn run(root: &mut Node, _: &MarkdownThat) {
56///         let data = root.cast_mut::<Root>().unwrap();
57///         data.ext.insert(ReferenceMap::new(RefMapOverride::default()));
58///     }
59/// }
60///
61/// md.add_rule::<AddCustomReferences>()
62///     .before::<BlockParserRule>();
63///
64/// let html = md.parse("[rust]").render();
65/// assert_eq!(
66///     html.trim(),
67///     r#"<p><a href="https://www.rust-lang.org/" title="The Rust Language">rust</a></p>"#
68/// );
69/// ```
70///
71/// You can also view all references that the user created by adding the following rule:
72///
73/// ```rust
74/// use markdown_that::parser::core::{CoreRule, Root};
75/// use markdown_that::plugins::cmark::block::reference::{ReferenceMap, DefaultReferenceMap};
76/// use markdown_that::{MarkdownThat, Node};
77///
78/// let md = &mut MarkdownThat::new();
79/// markdown_that::plugins::cmark::add(md);
80///
81/// let ast = md.parse("[hello]: world");
82/// let root = ast.node_value.downcast_ref::<Root>().unwrap();
83/// let refmap = root.ext.get::<ReferenceMap>()
84///     .map(|m| m.downcast_ref::<DefaultReferenceMap>().expect("expect references to be handled by default map"));
85///
86/// let mut labels = vec![];
87/// if let Some(refmap) = refmap {
88///     for (label, _dest, _title) in refmap.iter() {
89///         labels.push(label);
90///     }
91/// }
92///
93/// assert_eq!(labels, ["hello"]);
94/// ```
95///
96#[derive(Debug)]
97pub struct ReferenceMap(Box<dyn CustomReferenceMap>);
98
99impl Deref for ReferenceMap {
100    type Target = Box<dyn CustomReferenceMap>;
101
102    fn deref(&self) -> &Self::Target {
103        &self.0
104    }
105}
106
107impl DerefMut for ReferenceMap {
108    fn deref_mut(&mut self) -> &mut Self::Target {
109        &mut self.0
110    }
111}
112
113impl ReferenceMap {
114    pub fn new(custom_map: impl CustomReferenceMap + 'static) -> Self {
115        Self(Box::new(custom_map))
116    }
117}
118
119impl Default for ReferenceMap {
120    fn default() -> Self {
121        Self::new(DefaultReferenceMap::new())
122    }
123}
124
125impl RootExt for ReferenceMap {}
126
127pub trait CustomReferenceMap: Debug + Downcast + Send + Sync {
128    /// Insert a new element to the reference map. You may return false if it's not a valid label to stop parsing.
129    fn insert(&mut self, label: String, destination: String, title: Option<String>) -> bool;
130
131    /// Get an element referenced by `label` from the map, returns destination and optional title.
132    fn get(&self, label: &str) -> Option<(&str, Option<&str>)>;
133}
134
135impl_downcast!(CustomReferenceMap);
136
137#[derive(Default, Debug)]
138pub struct DefaultReferenceMap(HashMap<ReferenceMapKey, ReferenceMapEntry>);
139
140impl DefaultReferenceMap {
141    pub fn new() -> Self {
142        Self::default()
143    }
144
145    pub fn iter(&self) -> impl Iterator<Item = (&str, &str, Option<&str>)> {
146        Box::new(
147            self.0
148                .iter()
149                .map(|(a, b)| (a.label.as_str(), b.destination.as_str(), b.title.as_deref())),
150        )
151    }
152}
153
154impl CustomReferenceMap for DefaultReferenceMap {
155    fn insert(&mut self, label: String, destination: String, title: Option<String>) -> bool {
156        let Some(key) = ReferenceMapKey::new(label) else {
157            return false;
158        };
159        self.0
160            .entry(key)
161            .or_insert(ReferenceMapEntry::new(destination, title));
162        true
163    }
164
165    fn get(&self, label: &str) -> Option<(&str, Option<&str>)> {
166        let key = ReferenceMapKey::new(label.to_owned())?;
167        self.0
168            .get(&key)
169            .map(|r| (r.destination.as_str(), r.title.as_deref()))
170    }
171}
172
173#[derive(Debug, Default, Educe, Eq)]
174#[educe(Hash, PartialEq)]
175/// Reference label
176struct ReferenceMapKey {
177    #[educe(PartialEq(ignore), Hash(ignore))]
178    pub label: String,
179    normalized: String,
180}
181
182impl ReferenceMapKey {
183    pub fn new(label: String) -> Option<Self> {
184        let normalized = normalize_reference(&label);
185
186        if normalized.is_empty() {
187            // CommonMark 0.20 disallows empty labels
188            return None;
189        }
190
191        Some(Self { label, normalized })
192    }
193}
194
195#[derive(Debug, Default)]
196/// Reference value
197struct ReferenceMapEntry {
198    pub destination: String,
199    pub title: Option<String>,
200}
201
202impl ReferenceMapEntry {
203    pub fn new(destination: String, title: Option<String>) -> Self {
204        Self { destination, title }
205    }
206}
207
208/// Add a plugin that parses Markdown link references
209pub fn add(md: &mut MarkdownThat) {
210    md.block.add_rule::<ReferenceScanner>();
211}
212
213#[derive(Debug)]
214pub struct Definition {
215    pub label: String,
216    pub destination: String,
217    pub title: Option<String>,
218}
219impl NodeValue for Definition {
220    fn render(&self, _: &Node, _: &mut dyn crate::Renderer) {}
221}
222
223#[doc(hidden)]
224pub struct ReferenceScanner;
225impl BlockRule for ReferenceScanner {
226    fn check(_: &mut BlockState) -> Option<()> {
227        None // can't interrupt anything
228    }
229
230    fn run(state: &mut BlockState) -> Option<(Node, usize)> {
231        if state.line_indent(state.line) >= state.md.max_indent {
232            return None;
233        }
234
235        let mut chars = state.get_line(state.line).chars();
236
237        let Some('[') = chars.next() else {
238            return None;
239        };
240
241        // Simple check to quickly interrupt the scan on [link](url) at the start of the line.
242        // Can be useful in practice: https://github.com/markdown-it/markdown-it/issues/54
243        loop {
244            match chars.next() {
245                Some('\\') => {
246                    chars.next();
247                }
248                Some(']') => {
249                    if let Some(':') = chars.next() {
250                        break;
251                    } else {
252                        return None;
253                    }
254                }
255                Some(_) => {}
256                None => break,
257            }
258        }
259
260        let start_line = state.line;
261        let mut next_line = start_line;
262
263        // jump line-by-line until empty one or EOF
264        'outer: loop {
265            next_line += 1;
266
267            if next_line >= state.line_max || state.is_empty(next_line) {
268                break;
269            }
270
271            // this may be a code block normally, but after a paragraph
272            // it's considered a lazy continuation regardless of what's there
273            if state.line_indent(next_line) >= state.md.max_indent {
274                continue;
275            }
276
277            // quirk for blockquotes, that rule should already check this line
278            if state.line_offsets[next_line].indent_nonspace < 0 {
279                continue;
280            }
281
282            // Some tags can terminate a paragraph without an empty line.
283            let old_state_line = state.line;
284            state.line = next_line;
285            if state.test_rules_at_line() {
286                state.line = old_state_line;
287                break 'outer;
288            }
289            state.line = old_state_line;
290        }
291
292        let (str_before_trim, _) = state.get_lines(start_line, next_line, state.blk_indent, false);
293        let str = str_before_trim.trim();
294        let mut chars = str.char_indices();
295        chars.next(); // skip '['
296        let label_end;
297        let mut lines = 0;
298
299        loop {
300            match chars.next() {
301                Some((_, '[')) => return None,
302                Some((p, ']')) => {
303                    label_end = p;
304                    break;
305                }
306                Some((_, '\n')) => lines += 1,
307                Some((_, '\\')) => {
308                    if let Some((_, '\n')) = chars.next() {
309                        lines += 1;
310                    }
311                }
312                Some(_) => {}
313                None => return None,
314            }
315        }
316
317        let Some((_, ':')) = chars.next() else {
318            return None;
319        };
320
321        // [label]:   destination   'title'
322        //         ^^^ skip optional whitespace here
323        let mut pos = label_end + 2;
324        while let Some((_, ch @ (' ' | '\t' | '\n'))) = chars.next() {
325            if ch == '\n' {
326                lines += 1;
327            }
328            pos += 1;
329        }
330
331        // [label]:   destination   'title'
332        //            ^^^^^^^^^^^ parse this
333        let href;
334        if let Some(res) = full_link::parse_link_destination(str, pos, str.len()) {
335            if pos == res.pos {
336                return None;
337            }
338            href = state.md.link_formatter.normalize_link(&res.str);
339            state.md.link_formatter.validate_link(&href)?;
340            pos = res.pos;
341            lines += res.lines;
342        } else {
343            return None;
344        }
345
346        // to save the cursor state, we could require rolling back later
347        let dest_end_pos = pos;
348        let dest_end_lines = lines;
349
350        // [label]:   destination   'title'
351        //                       ^^^ skipping those spaces
352        let start = pos;
353        let mut chars = str[pos..].chars();
354        while let Some(ch @ (' ' | '\t' | '\n')) = chars.next() {
355            if ch == '\n' {
356                lines += 1;
357            }
358            pos += 1;
359        }
360
361        // [label]:   destination   'title'
362        //                          ^^^^^^^ parse this
363        let mut title = None;
364        if pos != start {
365            if let Some(res) = full_link::parse_link_title(str, pos, str.len()) {
366                title = Some(res.str);
367                pos = res.pos;
368                lines += res.lines;
369            } else {
370                pos = dest_end_pos;
371                lines = dest_end_lines;
372            }
373        }
374
375        // skip trailing spaces until the rest of the line
376        let mut chars = str[pos..].chars();
377        loop {
378            match chars.next() {
379                Some(' ' | '\t') => pos += 1,
380                Some('\n') | None => break,
381                Some(_) if title.is_some() => {
382                    // garbage at the end of the line after the title,
383                    // but it could still be a valid reference if we roll back
384                    title = None;
385                    pos = dest_end_pos;
386                    lines = dest_end_lines;
387                    chars = str[pos..].chars();
388                }
389                Some(_) => {
390                    // garbage at the end of the line
391                    return None;
392                }
393            }
394        }
395
396        let references = state.root_ext.get_or_insert_default::<ReferenceMap>();
397        if !references.insert(str[1..label_end].to_owned(), href.clone(), title.clone()) {
398            return None;
399        }
400
401        Some((
402            Node::new(Definition {
403                label: str[1..label_end].to_owned(),
404                destination: href,
405                title,
406            }),
407            lines + 1,
408        ))
409    }
410}