Skip to main content

md_tui/
parser.rs

1use std::sync::atomic::{AtomicU32, Ordering};
2
3use image::ImageReader;
4use itertools::Itertools;
5use pest::{
6    Parser,
7    iterators::{Pair, Pairs},
8};
9use pest_derive::Parser;
10use ratatui::style::Color;
11
12use crate::nodes::{
13    image::ImageComponent,
14    root::{Component, ComponentRoot},
15    textcomponent::{TextComponent, TextNode},
16    word::{MetaData, Word, WordType},
17};
18
19/// Process-wide monotonic counter for assigning unique IDs to `<details>`
20/// blocks. Each parsed details summary gets a fresh ID so it can be addressed
21/// by the runtime fold-toggle and selector independently of its position in
22/// the document.
23static DETAILS_ID_COUNTER: AtomicU32 = AtomicU32::new(0);
24
25fn next_details_id() -> u32 {
26    DETAILS_ID_COUNTER.fetch_add(1, Ordering::Relaxed)
27}
28
29/// Prepend `id` to the owning-details chain of every text component in
30/// `components`. Used after parsing a `<details>` body so that nested
31/// children (which may already carry inner IDs) correctly record the
32/// outer-to-inner containment order.
33fn tag_owning_details(components: &mut [Component], id: u32) {
34    for c in components.iter_mut() {
35        if let Component::TextComponent(tc) = c {
36            tc.prepend_owning_details_id(id);
37        }
38    }
39}
40
41#[derive(Parser)]
42#[grammar = "md.pest"]
43pub struct MdParser;
44
45pub fn parse_markdown(name: Option<&str>, content: &str, width: u16) -> ComponentRoot {
46    let root: Pairs<'_, Rule> = if let Ok(file) = MdParser::parse(Rule::txt, content) {
47        file
48    } else {
49        return ComponentRoot::new(name.map(str::to_string), Vec::new());
50    };
51
52    let root_pair = root.into_iter().next().unwrap();
53
54    let children = parse_text(root_pair)
55        .children_owned()
56        .into_iter()
57        .dedup_by(|x, y| {
58            x.kind() == MdParseEnum::BlockSeparator && y.kind == MdParseEnum::BlockSeparator
59        })
60        .collect();
61
62    let parse_root = ParseRoot::new(name.map(str::to_string), children);
63
64    let mut root = node_to_component(parse_root).add_missing_components();
65
66    root.transform(width);
67    root.recompute_visibility();
68    root
69}
70
71fn parse_text(pair: Pair<'_, Rule>) -> ParseNode {
72    let content = if pair.as_rule() == Rule::code_line {
73        pair.as_str().replace('\t', "    ").replace('\r', "")
74    } else {
75        pair.as_str().replace('\n', " ")
76    };
77    let mut component = ParseNode::new(pair.as_rule().into(), content);
78    let children = parse_node_children(pair.into_inner());
79    component.add_children(children);
80    component
81}
82
83fn parse_node_children(pair: Pairs<'_, Rule>) -> Vec<ParseNode> {
84    let mut children = Vec::new();
85    for inner_pair in pair {
86        children.push(parse_text(inner_pair));
87    }
88    children
89}
90
91fn node_to_component(root: ParseRoot) -> ComponentRoot {
92    let mut children = Vec::new();
93    let name = root.file_name().clone();
94    for component in root.children_owned() {
95        children.extend(parse_components(component));
96    }
97
98    ComponentRoot::new(name, children)
99}
100
101fn parse_components(parse_node: ParseNode) -> Vec<Component> {
102    if parse_node.kind() == MdParseEnum::Details {
103        return parse_details(parse_node);
104    }
105    vec![parse_component(parse_node)]
106}
107
108fn parse_details(parse_node: ParseNode) -> Vec<Component> {
109    let mut header_text = String::from("Details");
110    let mut body_components: Vec<Component> = Vec::new();
111    let mut open_attr_present = false;
112
113    for child in parse_node.children_owned() {
114        match child.kind() {
115            MdParseEnum::DetailsOpenAttr => {
116                open_attr_present = true;
117            }
118            MdParseEnum::DetailsSummary => {
119                let text: String = get_leaf_nodes(child)
120                    .into_iter()
121                    .map(|n| n.content().to_string())
122                    .collect::<Vec<_>>()
123                    .join("");
124                let trimmed = text.trim().to_string();
125                if !trimmed.is_empty() {
126                    header_text = trimmed;
127                }
128            }
129            MdParseEnum::DetailsBody => {
130                for body_child in child.children_owned() {
131                    body_components.extend(parse_components(body_child));
132                }
133            }
134            _ => {
135                body_components.extend(parse_components(child));
136            }
137        }
138    }
139
140    let id = next_details_id();
141    tag_owning_details(&mut body_components, id);
142
143    let body_len = body_components.len();
144    let folded = !open_attr_present;
145
146    let mut out = Vec::with_capacity(1 + body_len);
147    out.push(Component::TextComponent(TextComponent::new(
148        TextNode::DetailsSummary {
149            id,
150            folded,
151            body_len,
152        },
153        vec![Word::new(header_text, WordType::Normal)],
154    )));
155    out.extend(body_components);
156    out
157}
158
159fn is_url(url: &str) -> bool {
160    url.starts_with("http://") || url.starts_with("https://")
161}
162
163fn parse_component(parse_node: ParseNode) -> Component {
164    match parse_node.kind() {
165        MdParseEnum::Image => {
166            let leaf_nodes = get_leaf_nodes(parse_node);
167            let mut alt_text = String::new();
168            let mut image = None;
169            for node in leaf_nodes {
170                if node.kind() == MdParseEnum::AltText {
171                    node.content().clone_into(&mut alt_text);
172                } else if is_url(node.content()) {
173                    #[cfg(feature = "network")]
174                    {
175                        let mut buf = Vec::new();
176                        image = ureq::get(node.content()).call().ok().and_then(|b| {
177                            let noe = b.into_body().read_to_vec();
178                            noe.ok().and_then(|b| {
179                                buf = b;
180                                image::load_from_memory(&buf).ok()
181                            })
182                        });
183                    }
184                    #[cfg(not(feature = "network"))]
185                    {
186                        image = None;
187                    }
188                } else {
189                    image = ImageReader::open(node.content())
190                        .ok()
191                        .and_then(|r| r.decode().ok());
192                }
193            }
194
195            if let Some(img) = image.as_ref() {
196                let height = img.height();
197
198                let comp = ImageComponent::new(img.to_owned(), height, alt_text.clone());
199
200                if let Some(comp) = comp {
201                    Component::Image(comp)
202                } else {
203                    let word = [Word::new(format!("[{alt_text}]"), WordType::Normal)];
204
205                    let comp = TextComponent::new(TextNode::Paragraph, word.into());
206                    Component::TextComponent(comp)
207                }
208            } else {
209                let word = [
210                    Word::new("Image".to_string(), WordType::Normal),
211                    Word::new(" ".to_owned(), WordType::Normal),
212                    Word::new("not".to_owned(), WordType::Normal),
213                    Word::new(" ".to_owned(), WordType::Normal),
214                    Word::new("found".to_owned(), WordType::Normal),
215                    Word::new("/".to_owned(), WordType::Normal),
216                    Word::new("fetched".to_owned(), WordType::Normal),
217                    Word::new(" ".to_owned(), WordType::Normal),
218                    Word::new(format!("[{alt_text}]"), WordType::Normal),
219                ];
220
221                let comp = TextComponent::new(TextNode::Paragraph, word.into());
222                Component::TextComponent(comp)
223            }
224        }
225
226        MdParseEnum::Task => {
227            let leaf_nodes = get_leaf_nodes(parse_node);
228            let mut words = Vec::new();
229            for node in leaf_nodes {
230                let word_type = WordType::from(node.kind());
231
232                let mut content: String = node
233                    .content()
234                    .chars()
235                    .dedup_by(|x, y| *x == ' ' && *y == ' ')
236                    .collect();
237
238                if matches!(node.kind(), MdParseEnum::WikiLink | MdParseEnum::InlineLink) {
239                    let comp = Word::new(content.clone(), WordType::LinkData);
240                    words.push(comp);
241                }
242
243                if content.starts_with(' ') {
244                    content.remove(0);
245                    let comp = Word::new(" ".to_owned(), word_type);
246                    words.push(comp);
247                }
248                words.push(Word::new(content, word_type));
249            }
250            Component::TextComponent(TextComponent::new(TextNode::Task, words))
251        }
252
253        MdParseEnum::Quote => {
254            let leaf_nodes = get_leaf_nodes(parse_node);
255            let mut words = Vec::new();
256            for node in leaf_nodes {
257                let word_type = WordType::from(node.kind());
258                let mut content = node.content().to_owned();
259
260                if matches!(node.kind(), MdParseEnum::WikiLink | MdParseEnum::InlineLink) {
261                    let comp = Word::new(content.clone(), WordType::LinkData);
262                    words.push(comp);
263                }
264                if content.starts_with(' ') {
265                    content.remove(0);
266                    let comp = Word::new(" ".to_owned(), word_type);
267                    words.push(comp);
268                }
269                words.push(Word::new(content, word_type));
270            }
271            if let Some(w) = words.first_mut() {
272                w.set_content(w.content().trim_start().to_owned());
273            }
274            Component::TextComponent(TextComponent::new(TextNode::Quote, words))
275        }
276
277        MdParseEnum::Heading => {
278            let indent = parse_node
279                .content()
280                .chars()
281                .take_while(|c| *c == '#')
282                .count();
283            let leaf_nodes = get_leaf_nodes(parse_node);
284            let mut words = Vec::new();
285
286            words.push(Word::new(
287                String::new(),
288                WordType::MetaInfo(MetaData::HeadingLevel(indent as u8)),
289            ));
290
291            if indent > 1 {
292                words.push(Word::new(
293                    format!("{} ", "#".repeat(indent)),
294                    WordType::Normal,
295                ));
296            }
297
298            for node in leaf_nodes {
299                let word_type = WordType::from(node.kind());
300                let mut content = node.content().to_owned();
301
302                if matches!(node.kind(), MdParseEnum::WikiLink | MdParseEnum::InlineLink) {
303                    let comp = Word::new(content.clone(), WordType::LinkData);
304                    words.push(comp);
305                }
306
307                if content.starts_with(' ') {
308                    content.remove(0);
309                    let comp = Word::new(" ".to_owned(), word_type);
310                    words.push(comp);
311                }
312                words.push(Word::new(content, word_type));
313            }
314            if let Some(w) = words.first_mut() {
315                w.set_content(w.content().trim_start().to_owned());
316            }
317            Component::TextComponent(TextComponent::new(TextNode::Heading, words))
318        }
319
320        MdParseEnum::Paragraph => {
321            let leaf_nodes = get_leaf_nodes(parse_node);
322            let mut words = Vec::new();
323            for node in leaf_nodes {
324                let word_type = WordType::from(node.kind());
325                let mut content = node.content().to_owned();
326
327                if matches!(node.kind(), MdParseEnum::WikiLink | MdParseEnum::InlineLink) {
328                    let comp = Word::new(content.clone(), WordType::LinkData);
329                    words.push(comp);
330                }
331
332                if content.starts_with(' ') {
333                    content.remove(0);
334                    let comp = Word::new(" ".to_owned(), word_type);
335                    words.push(comp);
336                }
337                words.push(Word::new(content, word_type));
338            }
339            if let Some(w) = words.first_mut() {
340                w.set_content(w.content().trim_start().to_owned());
341            }
342            Component::TextComponent(TextComponent::new(TextNode::Paragraph, words))
343        }
344
345        MdParseEnum::CodeBlock => {
346            let leaf_nodes = get_leaf_nodes(parse_node);
347            let mut words = Vec::new();
348
349            let mut space_indented = false;
350
351            for node in leaf_nodes {
352                if node.kind() == MdParseEnum::CodeBlockStrSpaceIndented {
353                    space_indented = true;
354                }
355                let word_type = WordType::from(node.kind());
356                let content = node.content().to_owned();
357                words.push(vec![Word::new(content, word_type)]);
358            }
359
360            if space_indented {
361                words.push(vec![Word::new(
362                    " ".to_owned(),
363                    WordType::CodeBlock(Color::Reset),
364                )]);
365            }
366
367            Component::TextComponent(TextComponent::new_formatted(TextNode::CodeBlock, words))
368        }
369
370        MdParseEnum::ListContainer => {
371            let mut words = Vec::new();
372            for child in parse_node.children_owned() {
373                let kind = child.kind();
374                let leaf_nodes = get_leaf_nodes(child);
375                let mut inner_words = Vec::new();
376                for node in leaf_nodes {
377                    let word_type = WordType::from(node.kind());
378
379                    let mut content = match node.kind() {
380                        MdParseEnum::Indent => node.content().to_owned(),
381                        _ => node
382                            .content()
383                            .chars()
384                            .dedup_by(|x, y| *x == ' ' && *y == ' ')
385                            .collect(),
386                    };
387
388                    if matches!(node.kind(), MdParseEnum::WikiLink | MdParseEnum::InlineLink) {
389                        let comp = Word::new(content.clone(), WordType::LinkData);
390                        inner_words.push(comp);
391                    }
392                    if content.starts_with(' ') && node.kind() != MdParseEnum::Indent {
393                        content.remove(0);
394                        let comp = Word::new(" ".to_owned(), word_type);
395                        inner_words.push(comp);
396                    }
397
398                    inner_words.push(Word::new(content, word_type));
399                }
400                if kind == MdParseEnum::UnorderedList {
401                    inner_words.push(Word::new(
402                        "X".to_owned(),
403                        WordType::MetaInfo(MetaData::UList),
404                    ));
405                    let list_symbol = Word::new("• ".to_owned(), WordType::ListMarker);
406                    inner_words.insert(1, list_symbol);
407                } else if kind == MdParseEnum::OrderedList {
408                    inner_words.push(Word::new(
409                        "X".to_owned(),
410                        WordType::MetaInfo(MetaData::OList),
411                    ));
412                }
413                words.push(inner_words);
414            }
415            Component::TextComponent(TextComponent::new_formatted(TextNode::List, words))
416        }
417
418        MdParseEnum::Table => {
419            let mut words = Vec::new();
420            let mut meta_info = Vec::new();
421            for cell in parse_node.children_owned() {
422                if cell.kind() == MdParseEnum::TableSeparator {
423                    meta_info.push(Word::new(
424                        cell.content().to_owned(),
425                        WordType::MetaInfo(MetaData::ColumnsCount),
426                    ));
427                    continue;
428                }
429                let mut inner_words = Vec::new();
430
431                if cell.children().is_empty() {
432                    words.push(inner_words);
433                    continue;
434                }
435
436                for word in get_leaf_nodes(cell) {
437                    let word_type = WordType::from(word.kind());
438                    let mut content = word.content().to_owned();
439
440                    if matches!(word.kind(), MdParseEnum::WikiLink | MdParseEnum::InlineLink) {
441                        let comp = Word::new(content.clone(), WordType::LinkData);
442                        inner_words.push(comp);
443                    }
444
445                    if content.starts_with(' ') {
446                        content.remove(0);
447                        let comp = Word::new(" ".to_owned(), word_type);
448                        inner_words.push(comp);
449                    }
450
451                    inner_words.push(Word::new(content, word_type));
452                }
453                words.push(inner_words);
454            }
455            Component::TextComponent(TextComponent::new_formatted_with_meta(
456                TextNode::Table(vec![], vec![]),
457                words,
458                meta_info,
459            ))
460        }
461
462        MdParseEnum::BlockSeparator => {
463            Component::TextComponent(TextComponent::new(TextNode::LineBreak, Vec::new()))
464        }
465        MdParseEnum::HorizontalSeparator => Component::TextComponent(TextComponent::new(
466            TextNode::HorizontalSeparator,
467            Vec::new(),
468        )),
469        MdParseEnum::Footnote => {
470            let mut words = Vec::new();
471            let foot_ref = parse_node.children().first().unwrap().to_owned();
472            words.push(Word::new(foot_ref.content, WordType::FootnoteData));
473            let _rest = parse_node
474                .children_owned()
475                .into_iter()
476                .skip(1)
477                .map(|e| e.content)
478                .collect::<String>();
479            words.push(Word::new(_rest, WordType::Footnote));
480            Component::TextComponent(TextComponent::new(TextNode::Footnote, words))
481        }
482        _ => todo!("Not implemented for {:?}", parse_node.kind()),
483    }
484}
485
486fn get_leaf_nodes(node: ParseNode) -> Vec<ParseNode> {
487    let mut leaf_nodes = Vec::new();
488
489    // Insert separator information between links
490    if node.kind() == MdParseEnum::Link {
491        let comp = if node.content().starts_with(' ') {
492            ParseNode::new(MdParseEnum::Word, " ".to_owned())
493        } else {
494            ParseNode::new(MdParseEnum::Word, String::new())
495        };
496        leaf_nodes.push(comp);
497    }
498
499    if matches!(
500        node.kind(),
501        MdParseEnum::CodeStr
502            | MdParseEnum::ItalicStr
503            | MdParseEnum::BoldStr
504            | MdParseEnum::BoldItalicStr
505            | MdParseEnum::StrikethroughStr
506    ) && node.content().starts_with(' ')
507    {
508        let comp = ParseNode::new(MdParseEnum::Word, " ".to_owned());
509        leaf_nodes.push(comp);
510    }
511
512    if node.children().is_empty() {
513        // Formatting containers (italic/bold/code/strikethrough) with no named
514        // children arise when the grammar matches only silent content — e.g.
515        // bare newlines between asterisks in CRLF documents.  Nothing to
516        // render; skip so WordType::from is never called on a container type.
517        if !matches!(
518            node.kind(),
519            MdParseEnum::ItalicStr
520                | MdParseEnum::BoldStr
521                | MdParseEnum::BoldItalicStr
522                | MdParseEnum::StrikethroughStr
523                | MdParseEnum::CodeStr
524        ) {
525            leaf_nodes.push(node);
526        }
527    } else {
528        for child in node.children_owned() {
529            leaf_nodes.append(&mut get_leaf_nodes(child));
530        }
531    }
532    leaf_nodes
533}
534
535pub fn print_from_root(root: &ComponentRoot) {
536    for child in root.components() {
537        print_component(child, 0);
538    }
539}
540
541fn print_component(component: &TextComponent, _depth: usize) {
542    println!(
543        "Component: {:?}, height: {}, y_offset: {}",
544        component.kind(),
545        component.height(),
546        component.y_offset()
547    );
548    component.meta_info().iter().for_each(|w| {
549        println!("Meta: {}, kind: {:?}", w.content(), w.kind());
550    });
551    component.content().iter().for_each(|w| {
552        w.iter().for_each(|w| {
553            println!("Content:{}, kind: {:?}", w.content(), w.kind());
554        });
555    });
556}
557
558#[derive(Debug, Clone)]
559pub struct ParseRoot {
560    file_name: Option<String>,
561    children: Vec<ParseNode>,
562}
563
564impl ParseRoot {
565    #[must_use]
566    pub fn new(file_name: Option<String>, children: Vec<ParseNode>) -> Self {
567        Self {
568            file_name,
569            children,
570        }
571    }
572
573    #[must_use]
574    pub fn children(&self) -> &Vec<ParseNode> {
575        &self.children
576    }
577
578    #[must_use]
579    pub fn children_owned(self) -> Vec<ParseNode> {
580        self.children
581    }
582
583    #[must_use]
584    pub fn file_name(&self) -> Option<String> {
585        self.file_name.clone()
586    }
587}
588
589#[derive(Debug, Clone, PartialEq, Eq)]
590pub struct ParseNode {
591    kind: MdParseEnum,
592    content: String,
593    children: Vec<ParseNode>,
594}
595
596impl ParseNode {
597    #[must_use]
598    pub fn new(kind: MdParseEnum, content: String) -> Self {
599        Self {
600            kind,
601            content,
602            children: Vec::new(),
603        }
604    }
605
606    #[must_use]
607    pub fn kind(&self) -> MdParseEnum {
608        self.kind
609    }
610
611    #[must_use]
612    pub fn content(&self) -> &str {
613        &self.content
614    }
615
616    pub fn add_children(&mut self, children: Vec<ParseNode>) {
617        self.children.extend(children);
618    }
619
620    #[must_use]
621    pub fn children(&self) -> &Vec<ParseNode> {
622        &self.children
623    }
624
625    #[must_use]
626    pub fn children_owned(self) -> Vec<ParseNode> {
627        self.children
628    }
629}
630
631#[derive(Debug, Clone, Copy, PartialEq, Eq)]
632pub enum MdParseEnum {
633    AltText,
634    BlockSeparator,
635    Bold,
636    BoldItalic,
637    BoldItalicStr,
638    BoldStr,
639    Caution,
640    Code,
641    CodeBlock,
642    CodeBlockStr,
643    CodeBlockStrSpaceIndented,
644    CodeStr,
645    Details,
646    DetailsBody,
647    DetailsOpenAttr,
648    DetailsSummary,
649    Digit,
650    FootnoteRef,
651    Footnote,
652    Heading,
653    HorizontalSeparator,
654    Image,
655    Imortant,
656    Indent,
657    InlineLink,
658    Italic,
659    ItalicStr,
660    Link,
661    LinkData,
662    ListContainer,
663    Note,
664    OrderedList,
665    PLanguage,
666    Paragraph,
667    Quote,
668    Sentence,
669    Strikethrough,
670    StrikethroughStr,
671    Table,
672    TableCell,
673    TableSeparator,
674    Task,
675    TaskClosed,
676    TaskOpen,
677    Tip,
678    UnorderedList,
679    Warning,
680    WikiLink,
681    Word,
682}
683
684impl From<Rule> for MdParseEnum {
685    fn from(value: Rule) -> Self {
686        match value {
687            Rule::word | Rule::h_word | Rule::latex_word | Rule::t_word => Self::Word,
688            Rule::indent => Self::Indent,
689            Rule::italic_word_var_1 | Rule::italic_word_var_2 => Self::Italic,
690            Rule::italic_var_1 | Rule::italic_var_2 => Self::ItalicStr,
691            Rule::bold_word => Self::Bold,
692            Rule::bold => Self::BoldStr,
693            Rule::bold_italic_word => Self::BoldItalic,
694            Rule::bold_italic => Self::BoldItalicStr,
695            Rule::strikethrough_word => Self::Strikethrough,
696            Rule::strikethrough => Self::StrikethroughStr,
697            Rule::code_word => Self::Code,
698            Rule::code => Self::CodeStr,
699            Rule::programming_language => Self::PLanguage,
700            Rule::link_word | Rule::link_line | Rule::link | Rule::wiki_link_word => Self::Link,
701            Rule::wiki_link_alone => Self::WikiLink,
702            Rule::inline_link | Rule::inline_link_wrapper => Self::InlineLink,
703            Rule::o_list_counter | Rule::digit => Self::Digit,
704            Rule::task_open => Self::TaskOpen,
705            Rule::task_complete => Self::TaskClosed,
706            Rule::code_line => Self::CodeBlockStr,
707            Rule::indented_code_line | Rule::indented_code_newline => {
708                Self::CodeBlockStrSpaceIndented
709            }
710            Rule::sentence | Rule::t_sentence | Rule::footnote_sentence => Self::Sentence,
711            Rule::table_cell => Self::TableCell,
712            Rule::table_separator => Self::TableSeparator,
713            Rule::u_list => Self::UnorderedList,
714            Rule::o_list => Self::OrderedList,
715            Rule::h1 | Rule::h2 | Rule::h3 | Rule::h4 | Rule::h5 | Rule::h6 | Rule::heading => {
716                Self::Heading
717            }
718            Rule::list_container => Self::ListContainer,
719            Rule::paragraph => Self::Paragraph,
720            Rule::code_block | Rule::indented_code_block => Self::CodeBlock,
721            Rule::table => Self::Table,
722            Rule::quote => Self::Quote,
723            Rule::task => Self::Task,
724            Rule::block_sep => Self::BlockSeparator,
725            Rule::horizontal_sep => Self::HorizontalSeparator,
726            Rule::link_data | Rule::wiki_link_data => Self::LinkData,
727            Rule::details => Self::Details,
728            Rule::details_body => Self::DetailsBody,
729            Rule::details_open_attr => Self::DetailsOpenAttr,
730            Rule::summary | Rule::summary_text => Self::DetailsSummary,
731            Rule::warning => Self::Warning,
732            Rule::note => Self::Note,
733            Rule::tip => Self::Tip,
734            Rule::important => Self::Imortant,
735            Rule::caution => Self::Caution,
736            Rule::p_char
737            | Rule::t_char
738            | Rule::link_char
739            | Rule::wiki_link_char
740            | Rule::normal
741            | Rule::t_normal
742            | Rule::latex
743            | Rule::comment
744            | Rule::txt
745            | Rule::task_prefix
746            | Rule::quote_prefix
747            | Rule::code_block_prefix
748            | Rule::table_prefix
749            | Rule::list_prefix
750            | Rule::forbidden_sentence_prefix => Self::Paragraph,
751            Rule::image => Self::Image,
752            Rule::alt_word | Rule::alt_text => Self::AltText,
753            Rule::footnote_ref => Self::FootnoteRef,
754            Rule::footnote => Self::Footnote,
755            Rule::heading_prefix
756            | Rule::alt_char
757            | Rule::b_char
758            | Rule::c_char
759            | Rule::c_line_char
760            | Rule::comment_char
761            | Rule::i_char_var_1
762            | Rule::i_char_var_2
763            | Rule::latex_char
764            | Rule::quote_marking
765            | Rule::inline_link_char
766            | Rule::s_char
767            | Rule::WHITESPACE_S
768            | Rule::wiki_link
769            | Rule::footnote_ref_container
770            | Rule::details_open_tag
771            | Rule::details_close_tag
772            | Rule::summary_open_tag
773            | Rule::summary_close_tag => todo!(),
774        }
775    }
776}
777
778#[cfg(test)]
779mod tests {
780    use super::*;
781    use crate::nodes::textcomponent::TextNode;
782
783    fn component_kinds(md: &str) -> Vec<TextNode> {
784        parse_markdown(None, md, 80)
785            .components()
786            .iter()
787            .map(|c| c.kind())
788            .collect()
789    }
790
791    #[test]
792    fn italic_with_trailing_space_followed_by_italic_crlf() {
793        // italic_var_2 can match " *\r\n\r\n*" (space + asterisk + blank line +
794        // asterisk) with only silent NEWLINE iterations in the body, producing an
795        // ItalicStr node with no named children.  That must not panic.
796        let md = "*Section A*\r\n\r\n*Item with trailing space *\r\n\r\n*Section B*\r\n";
797        let kinds = component_kinds(md);
798        assert!(!kinds.is_empty());
799    }
800
801
802    fn has_details_summary(kinds: &[TextNode]) -> bool {
803        kinds
804            .iter()
805            .any(|k| matches!(k, TextNode::DetailsSummary { .. }))
806    }
807
808    #[test]
809    fn parses_details_with_summary() {
810        let md = "<details>\n<summary>Title</summary>\n\nBody paragraph.\n\n</details>\n";
811        let kinds = component_kinds(md);
812        assert!(
813            has_details_summary(&kinds),
814            "expected DetailsSummary header, got {kinds:?}"
815        );
816        assert!(
817            kinds.iter().any(|k| matches!(k, TextNode::Paragraph)),
818            "expected body paragraph, got {kinds:?}"
819        );
820    }
821
822    #[test]
823    fn parses_details_open_attribute_starts_unfolded() {
824        // `<details open>` honors HTML semantics: initial state expanded.
825        let md = "<details open>\n<summary>S</summary>\n\nbody\n\n</details>\n";
826        let kinds = component_kinds(md);
827        let folded = kinds.iter().find_map(|k| match k {
828            TextNode::DetailsSummary { folded, .. } => Some(*folded),
829            _ => None,
830        });
831        assert_eq!(
832            folded,
833            Some(false),
834            "<details open> should start unfolded, got {kinds:?}"
835        );
836    }
837
838    #[test]
839    fn parses_details_without_open_starts_folded() {
840        // Plain `<details>` (no `open` attribute) starts collapsed.
841        let md = "<details>\n<summary>S</summary>\n\nbody\n\n</details>\n";
842        let kinds = component_kinds(md);
843        let folded = kinds.iter().find_map(|k| match k {
844            TextNode::DetailsSummary { folded, .. } => Some(*folded),
845            _ => None,
846        });
847        assert_eq!(
848            folded,
849            Some(true),
850            "<details> without `open` should start folded, got {kinds:?}"
851        );
852    }
853
854    #[test]
855    fn parses_details_without_summary() {
856        let md = "<details>\n\nplain body\n\n</details>\n";
857        let kinds = component_kinds(md);
858        assert!(has_details_summary(&kinds));
859    }
860
861    #[test]
862    fn parses_uppercase_details() {
863        let md = "<DETAILS>\n<SUMMARY>Caps</SUMMARY>\n\nbody\n\n</DETAILS>\n";
864        let kinds = component_kinds(md);
865        assert!(
866            has_details_summary(&kinds),
867            "case-insensitive matching failed, got {kinds:?}"
868        );
869    }
870
871    #[test]
872    fn malformed_details_does_not_panic() {
873        let md = "<details>\n<summary>S</summary>\n\nbody never closes\n";
874        let _ = parse_markdown(None, md, 80);
875    }
876
877    #[test]
878    fn nested_details_produces_two_summary_headers() {
879        let md = "<details>\n<summary>Outer</summary>\n\n<details>\n<summary>Inner</summary>\n\ninner body\n\n</details>\n\n</details>\n";
880        let kinds = component_kinds(md);
881        let summary_count = kinds
882            .iter()
883            .filter(|k| matches!(k, TextNode::DetailsSummary { .. }))
884            .count();
885        assert_eq!(summary_count, 2, "expected 2 DetailsSummary, got {kinds:?}");
886    }
887
888    #[test]
889    fn html_close_tag_not_autolink() {
890        let md = "</details>";
891        let kinds = component_kinds(md);
892        assert!(
893            kinds
894                .iter()
895                .all(|k| !matches!(k, TextNode::DetailsSummary { .. })),
896            "stray close tag shouldn't produce DetailsSummary"
897        );
898    }
899
900    #[test]
901    fn issue_169_example_parses() {
902        // The exact example from issue #169 — two <details> blocks with tables.
903        let md = "# Dependencies\n\n\
904            <details>\n<summary>Explicit dependencies</summary>\n\n\
905            |Dependency|Before|After|\n|-|-|-|\n|bpy|0.10.1|2.10.1|\n\n\
906            </details>\n\n\
907            <details open>\n<summary>Implicit dependencies</summary>\n\n\
908            |Dependency|Before|After|\n|-|-|-|\n|python|0.10.0|0.10.1|\n\n\
909            </details>\n";
910        let kinds = component_kinds(md);
911        let summary_count = kinds
912            .iter()
913            .filter(|k| matches!(k, TextNode::DetailsSummary { .. }))
914            .count();
915        assert_eq!(
916            summary_count, 2,
917            "expected 2 summary headers, got {kinds:?}"
918        );
919        let table_count = kinds
920            .iter()
921            .filter(|k| matches!(k, TextNode::Table(_, _)))
922            .count();
923        assert_eq!(
924            table_count, 2,
925            "expected 2 tables inside details, got {kinds:?}"
926        );
927    }
928
929    #[test]
930    fn plain_paragraph_unaffected() {
931        let md = "Just a paragraph.\n";
932        let kinds = component_kinds(md);
933        assert!(!has_details_summary(&kinds));
934    }
935
936    #[test]
937    fn nested_details_tags_inner_components_with_both_ids() {
938        let md = "<details>\n<summary>Outer</summary>\n\n<details>\n<summary>Inner</summary>\n\ninner body\n\n</details>\n\n</details>\n";
939        let root = parse_markdown(None, md, 80);
940        let comps = root.components();
941        // Find the inner summary's owning chain — it should have exactly
942        // one id (the outer's). The inner body should have two (outer,
943        // inner — outermost-first ordering).
944        let summaries: Vec<&[u32]> = comps
945            .iter()
946            .filter(|c| matches!(c.kind(), TextNode::DetailsSummary { .. }))
947            .map(|c| c.owning_details_ids())
948            .collect();
949        assert_eq!(summaries.len(), 2, "expected 2 summaries");
950        // First (outer) summary has no owning details. Second (inner)
951        // summary has one: the outer.
952        assert_eq!(summaries[0].len(), 0, "outer summary has no owners");
953        assert_eq!(
954            summaries[1].len(),
955            1,
956            "inner summary belongs to one outer details body"
957        );
958
959        // The inner body paragraph belongs to both outer + inner.
960        let inner_para = comps
961            .iter()
962            .find(|c| matches!(c.kind(), TextNode::Paragraph) && c.owning_details_ids().len() == 2)
963            .expect("inner body paragraph with two owning details ids");
964        assert_eq!(
965            inner_para.owning_details_ids().len(),
966            2,
967            "inner body paragraph belongs to outer and inner"
968        );
969    }
970
971    #[test]
972    fn default_collapsed_hides_body_components() {
973        // A plain (collapsed-by-default) <details> hides its body
974        // components, so they contribute zero height to the layout.
975        let md = "<details>\n<summary>S</summary>\n\nhidden body\n\n</details>\n";
976        let root = parse_markdown(None, md, 80);
977        let comps = root.components();
978        let body_para = comps
979            .iter()
980            .find(|c| matches!(c.kind(), TextNode::Paragraph))
981            .expect("expected body paragraph component");
982        assert!(body_para.is_hidden(), "collapsed body should be hidden");
983        assert_eq!(
984            body_para.height(),
985            0,
986            "hidden component height must be 0 so set_scroll positions correctly"
987        );
988    }
989
990    #[test]
991    fn open_attribute_keeps_body_visible() {
992        let md = "<details open>\n<summary>S</summary>\n\nvisible body\n\n</details>\n";
993        let root = parse_markdown(None, md, 80);
994        let comps = root.components();
995        let body_para = comps
996            .iter()
997            .find(|c| matches!(c.kind(), TextNode::Paragraph))
998            .expect("expected body paragraph component");
999        assert!(!body_para.is_hidden(), "open body should be visible");
1000    }
1001
1002    #[test]
1003    fn toggle_fold_hides_and_reveals_body() {
1004        let md = "<details open>\n<summary>S</summary>\n\nbody text\n\n</details>\n";
1005        let mut root = parse_markdown(None, md, 80);
1006        let initial_height = root.height();
1007        // Select the only details summary, then toggle it folded.
1008        root.select_details(0).expect("select_details");
1009        root.toggle_selected_details().expect("toggle");
1010        let folded_height = root.height();
1011        assert!(
1012            folded_height < initial_height,
1013            "folding should reduce total height ({folded_height} < {initial_height})"
1014        );
1015        // Toggle again to re-expand.
1016        root.toggle_selected_details().expect("untoggle");
1017        let unfolded_height = root.height();
1018        assert_eq!(
1019            unfolded_height, initial_height,
1020            "unfolding restores original height"
1021        );
1022    }
1023
1024    #[test]
1025    fn outer_fold_hides_inner_summary() {
1026        let md = "<details open>\n<summary>Outer</summary>\n\n<details open>\n<summary>Inner</summary>\n\ninner body\n\n</details>\n\n</details>\n";
1027        let mut root = parse_markdown(None, md, 80);
1028        // Fold the outer details — the inner summary header AND its body
1029        // should both become hidden.
1030        root.select_details(0).expect("select outer");
1031        root.toggle_selected_details().expect("fold outer");
1032
1033        let mut inner_summary_hidden = false;
1034        let mut inner_body_hidden = false;
1035        for c in root.components() {
1036            if matches!(c.kind(), TextNode::DetailsSummary { .. })
1037                && c.owning_details_ids().len() == 1
1038                && c.is_hidden()
1039            {
1040                inner_summary_hidden = true;
1041            }
1042            if matches!(c.kind(), TextNode::Paragraph)
1043                && c.owning_details_ids().len() == 2
1044                && c.is_hidden()
1045            {
1046                inner_body_hidden = true;
1047            }
1048        }
1049        assert!(
1050            inner_summary_hidden,
1051            "inner summary should be hidden when outer is folded"
1052        );
1053        assert!(
1054            inner_body_hidden,
1055            "inner body should be hidden when outer is folded"
1056        );
1057
1058        // num_details reports only currently-visible summaries — the
1059        // inner one disappears from the selector cycle while outer is
1060        // folded.
1061        assert_eq!(
1062            root.num_details(),
1063            1,
1064            "only the outer summary is visible when outer is folded"
1065        );
1066    }
1067
1068    #[test]
1069    fn linebreak_inherits_shared_owning_ids() {
1070        // The block-separator-inserted LineBreaks should inherit the
1071        // owning-details chain that is shared between their neighbors,
1072        // so a LineBreak between two body components is hidden together
1073        // with them when the surrounding details folds.
1074        let md = "<details>\n<summary>S</summary>\n\nfirst body\n\nsecond body\n\n</details>\n";
1075        let root = parse_markdown(None, md, 80);
1076        let comps = root.components();
1077        let interior_linebreak = comps.iter().find(|c| {
1078            matches!(c.kind(), TextNode::LineBreak) && !c.owning_details_ids().is_empty()
1079        });
1080        assert!(
1081            interior_linebreak.is_some(),
1082            "expected a LineBreak inside the details body to inherit its owners"
1083        );
1084        let lb = interior_linebreak.unwrap();
1085        assert!(
1086            lb.is_hidden(),
1087            "LineBreak inside a folded details body should be hidden"
1088        );
1089    }
1090}