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
19static DETAILS_ID_COUNTER: AtomicU32 = AtomicU32::new(0);
24
25fn next_details_id() -> u32 {
26 DETAILS_ID_COUNTER.fetch_add(1, Ordering::Relaxed)
27}
28
29fn 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}