bbcode_tagger/
lib.rs

1//! A tree parser and tagger for BBCode formatted text.
2//!
3//! # Usage
4//! ```rust
5//! use bbcode_tagger::BBCode;
6//!
7//! let parser = BBCode::default();
8//! let tree = parser.parse(r"This is some [B]BBCODE![\B]");
9//!
10//! println!("{}", tree);
11//! ```
12#![warn(
13    missing_docs,
14    rust_2018_idioms,
15    missing_debug_implementations,
16    rustdoc::broken_intra_doc_links
17)]
18use core::fmt;
19use regex::Regex;
20use std::collections::HashMap;
21use std::fmt::Display;
22
23static RE_OPEN_TAG: &str = r#"^\[(?P<tag>[^/\]]+?\S*?)((?:[ \t]+\S+?)?="?(?P<val>[^\]\n]*?))?"?\]"#;
24static RE_CLOSE_TAG: &str = r#"^\[/(?P<tag>[^/\]]+?\S*?)\]"#;
25static RE_NEWLINE: &str = r#"^\r?\n"#;
26
27/// TODO: Handle some extra codes
28/// - indent
29
30/// BBCode tag type enum
31#[derive(Debug, PartialEq, Eq, Clone)]
32pub enum BBTag {
33    /// No tag
34    None,
35    Bold,
36    Italic,
37    Underline,
38    Strikethrough,
39    FontSize,
40    FontColor,
41    Center,
42    Left,
43    Right,
44    Superscript,
45    Subscript,
46    Blur,
47    Quote,
48    Spoiler,
49    Link,
50    Email,
51    Image,
52    ListOrdered,
53    ListUnordered,
54    ListItem,
55    Code,
56    Preformatted,
57    Table,
58    TableHeading,
59    TableRow,
60    TableCell,
61    YouTube,
62    /// Some other unhandled tag
63    Unknown,
64}
65impl From<&str> for BBTag {
66    fn from(value: &str) -> BBTag {
67        let binding = value.trim().to_lowercase();
68        let trim_tag = binding.as_str();
69        match trim_tag {
70            "b" => BBTag::Bold,
71            "i" => BBTag::Italic,
72            "u" => BBTag::Underline,
73            "s" => BBTag::Strikethrough,
74            "size" => BBTag::FontSize,
75            "color" => BBTag::FontColor,
76            "center" => BBTag::Center,
77            "left" => BBTag::Left,
78            "right" => BBTag::Right,
79            "sup" => BBTag::Superscript,
80            "sub" => BBTag::Subscript,
81            "blur" => BBTag::Blur,
82            "email" => BBTag::Email,
83            "quote" => BBTag::Quote,
84            "spoiler" => BBTag::Spoiler,
85            "url" => BBTag::Link,
86            "img" => BBTag::Image,
87            "ul" | "list" => BBTag::ListUnordered,
88            "ol" => BBTag::ListOrdered,
89            "li" | "*" => BBTag::ListItem,
90            "code" | "highlight" => BBTag::Code,
91            "pre" => BBTag::Preformatted,
92            "table" => BBTag::Table,
93            "tr" => BBTag::TableRow,
94            "th" => BBTag::TableHeading,
95            "td" => BBTag::TableCell,
96            "youtube" => BBTag::YouTube,
97            "" => BBTag::None,
98            &_ => BBTag::Unknown,
99        }
100    }
101}
102
103/// Node in the BBTag Tree with associated data
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct BBNode {
106    /// Unformatted string text
107    pub text: String,
108    /// Associated tag
109    pub tag: BBTag,
110    /// Possible value related to tag (i.e. "4" in [SIZE=4])
111    pub value: Option<String>,
112    /// Parent node. Only root (id = 0) node should not have parent
113    pub parent: Option<i32>,
114    /// Child nodes
115    pub children: Vec<i32>,
116}
117impl Default for BBNode {
118    fn default() -> Self {
119        Self {
120            text: "".to_string(),
121            tag: BBTag::None,
122            value: None,
123            parent: None,
124            children: vec![],
125        }
126    }
127}
128
129impl BBNode {
130    /// Create a new BBNode with Text and Tag
131    pub fn new(text: &str, tag: BBTag) -> BBNode {
132        BBNode {
133            text: String::from(text),
134            tag,
135            value: None,
136            parent: None,
137            children: vec![],
138        }
139    }
140}
141
142/// Main data scructure for parsed BBCode, usually a root node and child nodes
143#[derive(Clone, PartialEq, Eq)]
144pub struct BBTree {
145    /// Nodes stored in the tree
146    pub nodes: HashMap<i32, BBNode>,
147    id: i32,
148}
149impl Default for BBTree {
150    fn default() -> Self {
151        Self {
152            nodes: HashMap::new(),
153            id: -1,
154        }
155    }
156}
157impl Display for BBTree {
158    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
159        writeln!(f, "Nodes: {}", self.id)?;
160        self.fmt_node(f, 0)
161    }
162}
163impl fmt::Debug for BBTree {
164    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
165        writeln!(f, "Nodes: {}", self.id)?;
166        self.fmt_node(f, 0)
167    }
168}
169
170impl BBTree {
171    /// Get a node by ID
172    pub fn get_node(&self, i: i32) -> &BBNode {
173        self.nodes.get(&i).unwrap()
174    }
175    /// Get a node as mutable by ID
176    pub fn get_node_mut(&mut self, i: i32) -> &mut BBNode {
177        self.nodes.get_mut(&i).unwrap()
178    }
179    /// Add a new node and return the new node ID
180    pub fn add_node(&mut self, node: BBNode) -> i32 {
181        self.id += 1;
182        self.nodes.insert(self.id, node);
183        self.id
184    }
185    /// Recursive (I know...) function to get the depth of a given node ID in the tree
186    pub fn get_depth(&self, i: i32) -> usize {
187        if self.get_node(i).parent.is_none() {
188            return 0;
189        }
190        return 1 + self.get_depth(self.get_node(i).parent.unwrap());
191    }
192    fn fmt_node(&self, f: &mut std::fmt::Formatter<'_>, i: i32) -> std::fmt::Result {
193        let indent = self.get_depth(i) * 2;
194        let node = self.get_node(i);
195        writeln!(f, "{:indent$}ID    : {}", "", i, indent = indent)?;
196        writeln!(f, "{:indent$}Text  : {}", "", node.text, indent = indent)?;
197        writeln!(f, "{:indent$}Tag   : {:?}", "", node.tag, indent = indent)?;
198        writeln!(f, "{:indent$}Value : {:?}", "", node.value, indent = indent)?;
199        writeln!(
200            f,
201            "{:indent$}Parent: {:?}",
202            "",
203            node.parent,
204            indent = indent
205        )?;
206        writeln!(f)?;
207        for child in node.children.iter() {
208            self.fmt_node(f, *child)?;
209        }
210        Ok(())
211    }
212}
213
214/// BBCode parser
215#[derive(Debug)]
216pub struct BBCode {
217    open_matcher: Regex,
218    close_matcher: Regex,
219    newline_matcher: Regex,
220}
221impl Default for BBCode {
222    fn default() -> Self {
223        Self {
224            open_matcher: Regex::new(RE_OPEN_TAG).unwrap(),
225            close_matcher: Regex::new(RE_CLOSE_TAG).unwrap(),
226            newline_matcher: Regex::new(RE_NEWLINE).unwrap(),
227        }
228    }
229}
230
231impl BBCode {
232    /// Parse the given input into tagged BBCode tree
233    pub fn parse(&self, input: &str) -> BBTree {
234        // Slice through string until open or close tag match
235        let mut slice = &input[0..];
236
237        // set up initial tree with empty node
238        // let curr_node = BBNode::new("", BBTag::None);
239        let mut tree = BBTree::default();
240        let mut curr_node = tree.add_node(BBNode::default());
241        let mut closed_tag = false;
242
243        while !slice.is_empty() {
244            // special handling for [*] short code
245            // check for newline while ListItem is open
246            if let Some(captures) = self.newline_matcher.captures(slice) {
247                if tree.get_node(curr_node).tag == BBTag::ListItem {
248                    // we are in a ListItem, close list item
249                    curr_node = tree.get_node(curr_node).parent.unwrap();
250
251                    // move past newline
252                    slice = &slice[captures.get(0).unwrap().as_str().len()..];
253                    closed_tag = true;
254                    continue;
255                }
256                if tree.get_node(curr_node).parent.is_some()
257                    && tree.get_node(tree.get_node(curr_node).parent.unwrap()).tag
258                        == BBTag::ListItem
259                {
260                    // parent is a list item
261                    // close current and parent
262                    curr_node = tree
263                        .get_node(tree.get_node(curr_node).parent.unwrap())
264                        .parent
265                        .unwrap();
266                    // move past newline
267                    slice = &slice[captures.get(0).unwrap().as_str().len()..];
268                    closed_tag = true;
269                    continue;
270                }
271            }
272            // check open
273            if let Some(captures) = self.open_matcher.captures(slice) {
274                // we have open tag, create child and go deeper
275                // if current node has no tag, use it's parent as the parent,
276                // instead of creating child of just text
277                let tag = captures.name("tag").unwrap().as_str();
278                let curr_node_obj = tree.get_node(curr_node);
279                // do not attempt to get parent of root node
280                if curr_node_obj.tag == BBTag::None && curr_node != 0 {
281                    curr_node = curr_node_obj.parent.unwrap();
282                }
283                let mut node = BBNode {
284                    tag: BBTag::from(tag),
285                    parent: Some(curr_node),
286                    ..Default::default()
287                };
288                if let Some(val) = captures.name("val") {
289                    node.value = Some(val.as_str().to_string());
290                }
291                let new_id = tree.add_node(node);
292                tree.get_node_mut(curr_node).children.push(new_id);
293                curr_node = new_id;
294
295                // increment slice past open tag
296                slice = &slice[captures.get(0).unwrap().as_str().len()..];
297                closed_tag = false;
298                continue;
299            } else if let Some(captures) = self.close_matcher.captures(slice) {
300                // if close tag, check current. If same, end child node and go back up. Otherwise toss the tag and keep going.
301                let tag = captures.name("tag").unwrap().as_str();
302                let bbtag = BBTag::from(tag);
303                let curr_node_obj = tree.get_node(curr_node);
304                if curr_node_obj.tag == BBTag::None && !curr_node_obj.text.is_empty() {
305                    // current tag is only text, check the parent and close current if has text and matching,
306                    // then close parent
307                    let parent = tree.get_node(curr_node_obj.parent.unwrap());
308                    if parent.tag == bbtag {
309                        curr_node = parent.parent.unwrap();
310                        slice = &slice[captures.get(0).unwrap().as_str().len()..];
311                        closed_tag = true;
312                        continue;
313                    }
314                }
315                if bbtag == tree.get_node(curr_node).tag {
316                    // matching open and close tags
317                    // we're done with this node
318                    curr_node = tree.get_node(curr_node).parent.unwrap();
319                    // increment slice past close tag
320                    slice = &slice[captures.get(0).unwrap().as_str().len()..];
321                    closed_tag = true;
322                    continue;
323                } else {
324                    // not a matching close tag, toss the tag and keep going
325                    slice = &slice[captures.get(0).unwrap().as_str().len()..];
326                    closed_tag = false;
327                    continue;
328                }
329            }
330
331            // no tags, grab text and continue
332            if let Some(ch) = slice.chars().next() {
333                if closed_tag {
334                    // we just closed a tag but have more text to get, create a new node
335                    let node = BBNode {
336                        parent: Some(curr_node),
337                        ..Default::default()
338                    };
339                    let new_id = tree.add_node(node);
340                    tree.get_node_mut(curr_node).children.push(new_id);
341                    curr_node = new_id;
342                }
343
344                tree.get_node_mut(curr_node).text.push(ch);
345                slice = &slice[ch.len_utf8()..];
346                closed_tag = false;
347            } else {
348                // end of the line
349                break;
350            }
351        }
352
353        tree
354    }
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360
361    macro_rules! bbtest_regex {
362        ($($name:ident: $value:expr;)*) => {
363            $(
364                #[test]
365                fn $name() {
366                    let open_re = Regex::new(RE_OPEN_TAG).unwrap();
367                    let (input, expected_tag, expected_val) = $value;
368
369                    // check expected match
370                    let captures = open_re.captures(input);
371                    if expected_tag.is_empty() && expected_val.is_empty() {
372                        assert!(captures.is_none());
373                    } else {
374                        let captures = captures.unwrap();
375                        let tag = captures.name("tag").unwrap().as_str();
376                        assert_eq!(expected_tag, tag);
377
378                        if expected_val.is_empty() {
379                            let val = captures.name("val");
380                            assert!(val.is_none());
381                        } else {
382                            let val = captures.name("val").unwrap().as_str();
383                            assert_eq!(expected_val, val);
384                        }
385                    }
386                    // if value not None, check expected value
387                    // let val = captures.name("val").unwrap().as_str();
388                    // assert_eq!(expected_val, val);
389
390
391                    // assert_eq!(bbcode.parse(input), expected);
392                }
393            )*
394        }
395    }
396
397    macro_rules! test_lines {
398        ($($name:ident: $value:expr,)*) => {
399            $(
400                #[test]
401                fn $name() {
402                    let (input, expected_tree) = $value;
403                    let parser = BBCode::default();
404                    let tree = parser.parse(input);
405                    assert_eq!(expected_tree, tree);
406                }
407            )*
408        }
409    }
410
411    test_lines! {
412        test_one_tag: (
413            "[b]bold text[/b]",
414            BBTree {
415                nodes: HashMap::from([
416                    (0, BBNode {
417                        text: "".to_string(),
418                        tag: BBTag::None,
419                        children: vec![1],
420                        ..Default::default()
421                    }),
422                    (1, BBNode {
423                        text: "bold text".to_string(),
424                        tag: BBTag::Bold,
425                        parent: Some(0),
426                        ..Default::default()
427                    })
428                ]),
429                id: 1,
430            }),
431        test_one_tag_value: (
432            r#"[size="3"]big text[/size]"#,
433            BBTree {
434                nodes: HashMap::from([
435                    (0, BBNode {
436                        text: "".to_string(),
437                        tag: BBTag::None,
438                        children: vec![1],
439                        ..Default::default()
440                    }),
441                    (1, BBNode {
442                        text: "big text".to_string(),
443                        tag: BBTag::FontSize,
444                        value: Some("3".to_string()),
445                        parent: Some(0),
446                        ..Default::default()
447                    })
448                ]),
449                id: 1,
450            }),
451        test_braces_not_tags: (
452            "text [] is [i]a[/i] thing",
453            BBTree {
454                nodes: HashMap::from([
455                    (0, BBNode {
456                        text: "text [] is ".to_string(),
457                        children: vec![1, 2],
458                        ..Default::default()
459                    }),
460                    (1, BBNode {
461                        text: "a".to_string(),
462                        tag: BBTag::Italic,
463                        parent: Some(0),
464                        ..Default::default()
465                    }),
466                    (2, BBNode {
467                        text: " thing".to_string(),
468                        parent: Some(0),
469                        ..Default::default()
470                    })
471                ]),
472                id: 2
473            }),
474        test_post_text: (
475            "[i]thing[/i] after",
476            BBTree {
477                nodes: HashMap::from([
478                    (0, BBNode {
479                        children: vec![1, 2],
480                        ..Default::default()
481                    }),
482                    (1, BBNode {
483                        text: "thing".to_string(),
484                        tag: BBTag::Italic,
485                        parent: Some(0),
486                        ..Default::default()
487                    }),
488                    (2, BBNode {
489                        text: " after".to_string(),
490                        parent: Some(0),
491                        ..Default::default()
492                    })
493                ]),
494                id: 2
495            }
496        ),
497        test_ul_list_items: (
498            r#"[ul]
499[*]item one
500[*]item two
501[/ul]"#,
502            BBTree {
503                nodes: HashMap::from([
504                    (0, BBNode {
505                        children: vec![1],
506                        ..Default::default()
507                    }),
508                    (1, BBNode {
509                        text: "\n".to_string(),
510                        tag: BBTag::ListUnordered,
511                        parent: Some(0),
512                        children: vec![2, 3],
513                        ..Default::default()
514                    }),
515                    (2, BBNode {
516                        text: "item one".to_string(),
517                        tag: BBTag::ListItem,
518                        parent: Some(1),
519                        ..Default::default()
520                    }),
521                    (3, BBNode {
522                        text: "item two".to_string(),
523                        tag: BBTag::ListItem,
524                        parent: Some(1),
525                        ..Default::default()
526                    })
527                ]),
528                id: 3
529            }
530        ),
531        test_ul_list_item_subtag: (
532            r#"[ul]
533[*][SIZE=4]wow[/SIZE]
534[/ul]"#,
535            BBTree {
536                nodes: HashMap::from([
537                    (0, BBNode {
538                        children: vec![1],
539                        ..Default::default()
540                    }),
541                    (1, BBNode {
542                        text: "\n".to_string(),
543                        tag: BBTag::ListUnordered,
544                        parent: Some(0),
545                        children: vec![2],
546                        ..Default::default()
547                    }),
548                    (2, BBNode {
549                        tag: BBTag::ListItem,
550                        parent: Some(1),
551                        children: vec![3],
552                        ..Default::default()
553                    }),
554                    (3, BBNode {
555                        tag: BBTag::FontSize,
556                        value: Some("4".to_string()),
557                        text: "wow".to_string(),
558                        parent: Some(2),
559                        ..Default::default()
560                    })
561                ]),
562                id: 3
563            }
564        ),
565        test_serveral: (
566            r#"[COLOR=#E5E5E5][CENTER][SIZE=5][COLOR=#00ffff]This Is A Title[/COLOR][/SIZE][/CENTER]
567
568[/COLOR][CENTER][SIZE=4][COLOR=#ff8c00]Now with new stuff![/COLOR][/SIZE][/CENTER]"#,
569            BBTree {
570                nodes: HashMap::from([
571                    (0, BBNode {
572                        children: vec![1, 6],
573                        ..Default::default()
574                    }),
575                    (1, BBNode {
576                        tag: BBTag::FontColor,
577                        value: Some("#E5E5E5".to_string()),
578                        parent: Some(0),
579                        children: vec![2, 5],
580                        ..Default::default()
581                    }),
582                    (2, BBNode {
583                        tag: BBTag::Center,
584                        parent: Some(1),
585                        children: vec![3],
586                        ..Default::default()
587                    }),
588                    (3, BBNode {
589                        tag: BBTag::FontSize,
590                        value: Some("5".to_string()),
591                        parent: Some(2),
592                        children: vec![4],
593                        ..Default::default()
594                    }),
595                    (4, BBNode {
596                        text: "This Is A Title".to_string(),
597                        tag: BBTag::FontColor,
598                        value: Some("#00ffff".to_string()),
599                        parent: Some(3),
600                        ..Default::default()
601                    }),
602                    (5, BBNode {
603                        text: "\n\n".to_string(),
604                        parent: Some(1),
605                        ..Default::default()
606                    }),
607                    (6, BBNode {
608                        tag: BBTag::Center,
609                        parent: Some(0),
610                        children: vec![7],
611                        ..Default::default()
612                    }),
613                    (7, BBNode {
614                        tag: BBTag::FontSize,
615                        value: Some("4".to_string()),
616                        parent: Some(6),
617                        children: vec![8],
618                        ..Default::default()
619                    }),
620                    (8, BBNode {
621                        text: "Now with new stuff!".to_string(),
622                        tag: BBTag::FontColor,
623                        value: Some("#ff8c00".to_string()),
624                        parent: Some(7),
625                        ..Default::default()
626                    })
627                ]),
628                id: 8
629            }
630        ),
631        test_complex_large: (
632            r#"[I][B][COLOR="Orange"]AddOn Name[/COLOR][/B][/I] is an addon
633
634[COLOR="DeepSkyBlue"]Color text:[/COLOR]
635[LIST]
636[*][COLOR="Orange"]Colored list item1[/COLOR]
637[LIST]
638[*]sublist1 item
639[*]item2:
640[LIST]
641[*]Sublist2 item [I]wow[/I] and [I]wooh[/I], is a thing
642[/LIST]
643
644Non listitem text in list.
645[/LIST]
646
647
648[*][COLOR="Orange"]List item2[/COLOR]
649[/LIST]
650
651[LIST]
652[*][COLOR="Orange"]color list item:[/COLOR][LIST][*] inline sub list item
653[/LIST][/LIST]
654"#,
655            BBTree {
656                nodes: HashMap::from([
657                    (0, BBNode {
658                        children: vec![1, 4, 5, 6, 7, 23, 24],
659                        ..Default::default()
660                    }),
661                    (1, BBNode {
662                        tag: BBTag::Italic,
663                        parent: Some(0),
664                        children: vec![2],
665                        ..Default::default()
666                    }),
667                    (2, BBNode {
668                        tag: BBTag::Bold,
669                        parent: Some(1),
670                        children: vec![3],
671                        ..Default::default()
672                    }),
673                    (3, BBNode {
674                        text: "AddOn Name".to_string(),
675                        tag: BBTag::FontColor,
676                        value: Some("Orange".to_string()),
677                        parent: Some(2),
678                        ..Default::default()
679                    }),
680                    (4, BBNode {
681                        text: " is an addon\n\n".to_string(),
682                        parent: Some(0),
683                        ..Default::default()
684                    }),
685                    (5, BBNode {
686                        text: "Color text:".to_string(),
687                        tag: BBTag::FontColor,
688                        value: Some("DeepSkyBlue".to_string()),
689                        parent: Some(0),
690                        ..Default::default()
691                    }),
692                    (6, BBNode {
693                        text: "\n".to_string(),
694                        parent: Some(0),
695                        ..Default::default()
696                    }),
697                    (7, BBNode {
698                        text: "\n".to_string(),
699                        tag: BBTag::ListUnordered,
700                        parent: Some(0),
701                        children: vec![8, 10, 20, 21],
702                        ..Default::default()
703                    }),
704                    (8, BBNode {
705                        tag: BBTag::ListItem,
706                        parent: Some(7),
707                        children: vec![9],
708                        ..Default::default()
709                    }),
710                    (9, BBNode {
711                        text: "Colored list item1".to_string(),
712                        tag: BBTag::FontColor,
713                        value: Some("Orange".to_string()),
714                        parent: Some(8),
715                        ..Default::default()
716                    }),
717                    (10, BBNode {
718                        text: "\n".to_string(),
719                        tag: BBTag::ListUnordered,
720                        parent: Some(7),
721                        children: vec![11, 12, 13, 19],
722                        ..Default::default()
723                    }),
724                    (11, BBNode {
725                        text: "sublist1 item".to_string(),
726                        tag: BBTag::ListItem,
727                        parent: Some(10),
728                        ..Default::default()
729                    }),
730                    (12, BBNode {
731                        text: "item2:".to_string(),
732                        tag: BBTag::ListItem,
733                        parent: Some(10),
734                        ..Default::default()
735                    }),
736                    (13, BBNode {
737                        text: "\n".to_string(),
738                        tag: BBTag::ListUnordered,
739                        parent: Some(10),
740                        children: vec![14],
741                        ..Default::default()
742                    }),
743                    (14, BBNode {
744                        text: "Sublist2 item ".to_string(),
745                        tag: BBTag::ListItem,
746                        parent: Some(13),
747                        children: vec![15, 16, 17, 18],
748                        ..Default::default()
749                    }),
750                    (15, BBNode {
751                        text: "wow".to_string(),
752                        tag: BBTag::Italic,
753                        parent: Some(14),
754                        ..Default::default()
755                    }),
756                    (16, BBNode {
757                        text: " and ".to_string(),
758                        parent: Some(14),
759                        ..Default::default()
760                    }),
761                    (17, BBNode {
762                        text: "wooh".to_string(),
763                        tag: BBTag::Italic,
764                        parent: Some(14),
765                        ..Default::default()
766                    }),
767                    (18, BBNode {
768                        text: ", is a thing".to_string(),
769                        parent: Some(14),
770                        ..Default::default()
771                    }),
772                    (19, BBNode {
773                        text: "\n\nNon listitem text in list.\n".to_string(),
774                        parent: Some(10),
775                        ..Default::default()
776                    }),
777                    (20, BBNode {
778                        text: "\n\n\n".to_string(),
779                        parent: Some(7),
780                        ..Default::default()
781                    }),
782                    (21, BBNode {
783                        tag: BBTag::ListItem,
784                        parent: Some(7),
785                        children: vec![22],
786                        ..Default::default()
787                    }),
788                    (22, BBNode {
789                        text: "List item2".to_string(),
790                        tag: BBTag::FontColor,
791                        value: Some("Orange".to_string()),
792                        parent: Some(21),
793                        ..Default::default()
794                    }),
795                    (23, BBNode {
796                        text: "\n\n".to_string(),
797                        parent: Some(0),
798                        ..Default::default()
799                    }),
800                    (24, BBNode {
801                        text: "\n".to_string(),
802                        tag: BBTag::ListUnordered,
803                        parent: Some(0),
804                        children: vec![25],
805                        ..Default::default()
806                    }),
807                    (25, BBNode {
808                        tag: BBTag::ListItem,
809                        parent: Some(24),
810                        children: vec![26, 27],
811                        ..Default::default()
812                    }),
813                    (26, BBNode {
814                        text: "color list item:".to_string(),
815                        tag: BBTag::FontColor,
816                        value: Some("Orange".to_string()),
817                        parent: Some(25),
818                        ..Default::default()
819                    }),
820                    (27, BBNode {
821                        tag: BBTag::ListUnordered,
822                        parent: Some(25),
823                        children: vec![28],
824                        ..Default::default()
825                    }),
826                    (28, BBNode {
827                        text: " inline sub list item".to_string(),
828                        tag: BBTag::ListItem,
829                        parent: Some(27),
830                        ..Default::default()
831                    })
832                ]),
833                id: 28
834            }
835        ),
836    }
837
838    #[test]
839    fn build_re() {
840        // should not fail
841        let _open_re = Regex::new(RE_OPEN_TAG).unwrap();
842        let _close_re = Regex::new(RE_CLOSE_TAG).unwrap();
843    }
844
845    #[test]
846    fn bbcode_default() {
847        // init should not fail with default regex
848        let _bbcode = BBCode::default();
849    }
850
851    #[test]
852    fn parse() {
853        let parser = BBCode::default();
854        // let result = parser.parse("[b]hello[/b]");
855        let tree = parser.parse(r#"[SIZE="3"]Features:[/SIZE]
856
857[LIST]
858[*][B][URL=https://github.com/sirinsidiator/ESO-LibAddonMenu/wiki/Controls]Controls[/URL][/B] - LAM offers different control types to build elaborate settings menus
859[*][B]Reset to Default[/B] - LAM can restore the settings to their default state with one key press
860[*][B]Additional AddOn Info[/B] - Add a version label and URLs for website, donations, translations or feedback
861[*][B]AddOn Search[/B] - Can't find the settings for your AddOn between the other hundred entries? No problem! Simply use the text search to quickly find what you are looking for
862[*][B]Slash Commands[/B] - Provides a shortcut to open your settings menu from chat
863[*][B]Tooltips[/B] - In case you need more space to explain what a control does, simply use a tooltip
864[*][B]Warnings[/B] - If your setting causes some unexpected behaviour, you can simply slap a warning on them
865[*][B]Dangerous Buttons[/B] - when flagged as such, a button will have red text and ask for confirmation before it runs any action
866[*][B]Required UI Reload[/B] - For cases where settings have to reload the UI or should be stored to disk right away, LAM offers a user friendly way to ask for a UI reload.
867[*]Support for all 5 official languages and 6 custom localisation projects
868[/LIST]"#);
869        println!("{}", tree);
870
871        // assert_eq!("".to_string(), result.borrow().text);
872        // assert_eq!(BBTag::None, result.borrow().tag);
873        // assert_eq!(1, result.borrow().children.len());
874
875        // let child = result.borrow_mut().children.pop().unwrap();
876        // assert_eq!("hello".to_string(), child.borrow().text);
877        // assert_eq!(BBTag::Bold, child.borrow().tag);
878    }
879
880    // test [*] short code
881    // [list]
882    // [*]item
883    // [*]item2 yeah[
884    //
885    // [*]three
886    // [*] second [i]things[/i]
887    // [*] trick [/*]
888
889    bbtest_regex! {
890        empty: ("hello", "", "");
891        bold: ("[b]hello[/b]", "b", "");
892        no_tag: ("[]hello[/]", "", "");
893        tag_and_val: ("[size=3]large[/size]", "size", "3");
894        tag_and_val_quote: (r#"[size="3"]large[/size]"#, "size", "3");
895        url: ("[url=https://www.com]some url[/url] ", "url", "https://www.com");
896        multi_tag: (r#"[SIZE="2"][COLOR=#e5e5e5][B]Team:[/COLOR][/B] "#, "SIZE", "2");
897    }
898
899    #[test]
900    fn test_node_eq() {
901        let node1 = BBNode {
902            text: "text1".to_string(),
903            ..Default::default()
904        };
905        let node2 = BBNode {
906            text: "text1".to_string(),
907            ..Default::default()
908        };
909        assert_eq!(node1, node2);
910    }
911
912    #[test]
913    fn test_node_ne_text() {
914        let node1 = BBNode {
915            text: "text1".to_string(),
916            ..Default::default()
917        };
918        let node2 = BBNode {
919            text: "text2".to_string(),
920            ..Default::default()
921        };
922        assert_ne!(node1, node2);
923    }
924
925    #[test]
926    fn test_node_ne_child() {
927        let node1 = BBNode {
928            text: "text1".to_string(),
929            children: vec![1],
930            ..Default::default()
931        };
932        let node2 = BBNode {
933            text: "text1".to_string(),
934            children: vec![2],
935            ..Default::default()
936        };
937        assert_ne!(node1, node2);
938    }
939}