markdown_it_deflist/
lib.rs

1//! Plugin to parse definition lists
2//!
3//! ```rust
4//! let md = &mut markdown_it::MarkdownIt::new();
5//! markdown_it::plugins::cmark::add(md);
6//! markdown_it_deflist::add(md);
7//! assert_eq!(
8//!     md.parse("term\n: definition").render(),
9//!     "<dl>\n<dt>term</dt>\n<dd>definition</dd>\n</dl>\n"
10//! );
11//! ```
12
13use markdown_it::{
14    parser::{
15        block::{BlockRule, BlockState},
16        inline::InlineRoot,
17    },
18    plugins::cmark::block::paragraph::{Paragraph, ParagraphScanner},
19    MarkdownIt, Node, NodeValue, Renderer,
20};
21
22/// Add the definition list plugin to the parser
23pub fn add(md: &mut MarkdownIt) {
24    // insert this rule into block subparser
25    md.block
26        .add_rule::<DefinitionListScanner>()
27        .before::<ParagraphScanner>();
28}
29
30#[derive(Debug)]
31pub struct DefinitionList;
32impl NodeValue for DefinitionList {
33    fn render(&self, node: &Node, fmt: &mut dyn Renderer) {
34        fmt.cr();
35        fmt.open("dl", &node.attrs);
36        fmt.cr();
37        fmt.contents(&node.children);
38        fmt.cr();
39        fmt.close("dl");
40        fmt.cr();
41    }
42}
43
44#[derive(Debug)]
45pub struct DefinitionTerm;
46impl NodeValue for DefinitionTerm {
47    fn render(&self, node: &Node, fmt: &mut dyn Renderer) {
48        fmt.cr();
49        fmt.open("dt", &node.attrs);
50        fmt.contents(&node.children);
51        fmt.close("dt");
52        fmt.cr();
53    }
54}
55
56#[derive(Debug)]
57pub struct DefinitionDescription;
58impl NodeValue for DefinitionDescription {
59    fn render(&self, node: &Node, fmt: &mut dyn Renderer) {
60        fmt.cr();
61        fmt.open("dd", &node.attrs);
62        fmt.contents(&node.children);
63        fmt.close("dd");
64        fmt.cr();
65    }
66}
67
68/// An extension for the block subparser.
69pub struct DefinitionListScanner;
70
71impl BlockRule for DefinitionListScanner {
72    fn check(state: &mut BlockState) -> Option<()> {
73        if state.line_indent(state.line) >= state.md.max_indent {
74            return None;
75        }
76
77        // validation mode validates a dd block only, not a whole deflist
78        if !state.node.is::<DefinitionDescription>() {
79            return None;
80        }
81        check_for_description(state, state.line)?;
82
83        Some(())
84    }
85
86    fn run(state: &mut BlockState) -> Option<(Node, usize)> {
87        if state.line_indent(state.line) >= state.md.max_indent {
88            return None;
89        }
90
91        let start_line = state.line;
92        let mut next_line = state.line + 1;
93        if next_line >= state.line_max {
94            return None;
95        }
96
97        if state.is_empty(next_line) {
98            next_line += 1;
99            if next_line >= state.line_max {
100                return None;
101            }
102        }
103
104        if state.line_offsets[next_line].indent_nonspace < state.blk_indent as i32 {
105            return None;
106        }
107
108        let mut dd_first_nonspace = check_for_description(state, next_line)?;
109
110        // start definition list
111        let mut node_dl = Node::new(DefinitionList);
112        let mut dl_tight = true;
113
114        // iterate over definition list items
115        // One definition list can contain multiple terms (dt),
116        // and one term can be followed by multiple descriptions (dd).
117        // Thus, there is two nested loops here
118
119        let mut dt_line = state.line;
120        let mut dd_line = next_line;
121
122        'terms: loop {
123            let mut prev_empty_end = false;
124
125            // create the term, which is only one line
126            let mut node_dt = Node::new(DefinitionTerm);
127            node_dt.srcmap = state.get_map(dt_line, dt_line);
128            let (content, mapping) = state.get_lines(dt_line, dt_line + 1, state.blk_indent, false);
129            node_dt
130                .children
131                .push(Node::new(InlineRoot::new(content, mapping)));
132            node_dl.children.push(node_dt);
133
134            'descriptions: loop {
135                // compute the offsets of the description start
136                let mut dd_indent_nonspace = dd_first_nonspace as i32
137                    - state.line_offsets[dd_line].first_nonspace as i32
138                    + state.line_offsets[dd_line].indent_nonspace;
139                while dd_first_nonspace < state.line_offsets[dd_line].line_end {
140                    let c = state.src[dd_first_nonspace..].chars().next()?;
141                    if c == ' ' {
142                        dd_indent_nonspace += 1;
143                    } else if c == '\t' {
144                        dd_indent_nonspace += 4 - dd_indent_nonspace % 4;
145                    } else {
146                        break;
147                    }
148                    dd_first_nonspace += 1;
149                }
150
151                // cache, then override, the current state
152                let cached_state = CachedState {
153                    tight: state.tight,
154                    blk_indent: state.blk_indent,
155                    dd_indent_nonspace: state.line_offsets[dd_line].indent_nonspace,
156                    dd_first_nonspace: state.line_offsets[dd_line].first_nonspace,
157                };
158                state.tight = true;
159                state.blk_indent = state.line_offsets[dd_line].indent_nonspace as usize + 2;
160                state.line_offsets[dd_line].indent_nonspace = dd_indent_nonspace;
161                state.line_offsets[dd_line].first_nonspace = dd_first_nonspace;
162                state.line = dd_line;
163
164                // run a nested parse, adding to the description node
165                let cached_node =
166                    std::mem::replace(&mut state.node, Node::new(DefinitionDescription));
167                state.md.block.tokenize(state);
168                let mut node_dd = std::mem::replace(&mut state.node, cached_node);
169                node_dd.srcmap = state.get_map(next_line, state.line - 1);
170                node_dl.children.push(node_dd);
171
172                // If any of the definition list items are tight, mark it as tight
173                if !state.tight || prev_empty_end {
174                    dl_tight = false;
175                }
176                // Items become loose if they finish with empty line (except the last one)
177                prev_empty_end = (state.line - dd_line) > 1 && state.is_empty(state.line - 1);
178
179                // restore the state
180                state.tight = cached_state.tight;
181                state.blk_indent = cached_state.blk_indent;
182                state.line_offsets[dd_line].indent_nonspace = cached_state.dd_indent_nonspace;
183                state.line_offsets[dd_line].first_nonspace = cached_state.dd_first_nonspace;
184
185                next_line = state.line;
186
187                if next_line >= state.line_max
188                    || state.line_offsets[next_line].indent_nonspace < state.blk_indent as i32
189                {
190                    break 'terms;
191                }
192                match check_for_description(state, next_line) {
193                    Some(pos) => dd_first_nonspace = pos,
194                    None => break 'descriptions,
195                }
196
197                dd_line = next_line;
198            }
199
200            dt_line = next_line;
201            dd_line = dt_line + 1;
202
203            if next_line >= state.line_max
204                || state.is_empty(dt_line)
205                || state.line_offsets[dt_line].indent_nonspace < state.blk_indent as i32
206                || dd_line >= state.line_max
207            {
208                break 'terms;
209            }
210            if state.is_empty(dd_line) {
211                dd_line += 1;
212            }
213            if dd_line >= state.line_max
214                || state.line_offsets[dd_line].indent_nonspace < state.blk_indent as i32
215            {
216                break 'terms;
217            }
218            match check_for_description(state, dd_line) {
219                Some(pos) => dd_first_nonspace = pos,
220                None => break 'terms,
221            }
222        }
223
224        // mark paragraphs tight if needed
225        if dl_tight {
226            for child in node_dl.children.iter_mut() {
227                mark_tight_paragraphs(&mut child.children);
228            }
229        }
230
231        state.line = start_line;
232        Some((node_dl, next_line - start_line))
233    }
234}
235
236struct CachedState {
237    tight: bool,
238    blk_indent: usize,
239    dd_indent_nonspace: i32,
240    dd_first_nonspace: usize,
241}
242
243/// Check the line is a description, i.e. `[:~][\t\s]+[^\t\s].*`,
244/// return next pos after marker on success.
245fn check_for_description(state: &mut BlockState, line: usize) -> Option<usize> {
246    let mut chars = state.get_line(line).chars();
247
248    // requires a marker
249    let first_char = chars.next()?;
250    if first_char != ':' && first_char != '~' {
251        return None;
252    }
253
254    // requires at least one space after the marker
255    let second_char = chars.next()?;
256    if second_char != ' ' && second_char != '\t' {
257        return None;
258    }
259
260    // skip remaining spaces after marker
261    // and check if there is any content
262    loop {
263        match chars.next() {
264            Some(' ' | '\t') => {}
265            Some(_) => break,
266            None => return None,
267        }
268    }
269
270    Some(state.line_offsets[line].first_nonspace + 1)
271}
272
273fn mark_tight_paragraphs(nodes: &mut Vec<Node>) {
274    let mut idx = 0;
275    while idx < nodes.len() {
276        if nodes[idx].is::<Paragraph>() {
277            let children = std::mem::take(&mut nodes[idx].children);
278            let len = children.len();
279            nodes.splice(idx..idx + 1, children);
280            idx += len;
281        } else {
282            idx += 1;
283        }
284    }
285}
286
287#[cfg(test)]
288mod tests {
289    use markdown_it::plugins::cmark;
290
291    use super::*;
292
293    #[test]
294    fn test_definition_list() {
295        let mut md = MarkdownIt::new();
296        cmark::add(&mut md);
297        add(&mut md);
298
299        println!("test\n  : foo\n      : bar\n");
300        let ast = md.parse("test\n  : foo\n     : bar\n");
301        // println!("{:?}", ast);
302        println!("{}", ast.render());
303        // panic!("TODO")
304    }
305}