Skip to main content

standout_bbparser/
lib.rs

1//! BBCode-style tag parser for terminal styling.
2//!
3//! This crate provides a parser for `[tag]content[/tag]` style markup,
4//! designed for terminal output styling. It handles nested tags correctly
5//! and supports multiple output modes.
6//!
7//! # Example
8//!
9//! ```rust
10//! use standout_bbparser::{BBParser, TagTransform};
11//! use console::Style;
12//! use std::collections::HashMap;
13//!
14//! let mut styles = HashMap::new();
15//! styles.insert("bold".to_string(), Style::new().bold());
16//! styles.insert("red".to_string(), Style::new().red());
17//!
18//! // Apply ANSI codes
19//! let parser = BBParser::new(styles.clone(), TagTransform::Apply);
20//! let output = parser.parse("[bold]hello[/bold]");
21//! // output contains ANSI escape codes for bold
22//!
23//! // Strip tags (plain text)
24//! let parser = BBParser::new(styles.clone(), TagTransform::Remove);
25//! let output = parser.parse("[bold]hello[/bold]");
26//! assert_eq!(output, "hello");
27//!
28//! // Keep tags visible (debug mode)
29//! let parser = BBParser::new(styles, TagTransform::Keep);
30//! let output = parser.parse("[bold]hello[/bold]");
31//! assert_eq!(output, "[bold]hello[/bold]");
32//! ```
33//!
34//! # Unknown Tag Handling
35//!
36//! Tags not found in the styles map can be handled in two ways:
37//!
38//! - [`UnknownTagBehavior::Passthrough`]: Keep tags with a `?` marker: `[foo]` → `[foo?]`
39//! - [`UnknownTagBehavior::Strip`]: Remove tags entirely, keep content: `[foo]text[/foo]` → `text`
40//!
41//! For validation, use [`BBParser::validate`] to check for unknown tags before parsing.
42//!
43//! # Tag Name Syntax
44//!
45//! Tag names follow CSS identifier rules:
46//! - Start with a letter (`a-z`) or underscore (`_`)
47//! - Followed by letters, digits (`0-9`), underscores, or hyphens (`-`)
48//! - Cannot start with a digit or hyphen followed by digit
49//! - Case-sensitive (lowercase recommended)
50//!
51//! Pattern: `[a-z_][a-z0-9_-]*`
52
53use console::Style;
54use std::collections::HashMap;
55
56/// How to transform matched tags in the output.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum TagTransform {
59    /// Apply ANSI escape codes from the associated Style.
60    /// Used for terminal output with color support.
61    Apply,
62
63    /// Remove all tags, outputting only the content.
64    /// Used for plain text output without styling.
65    Remove,
66
67    /// Keep tags as-is in the output.
68    /// Used for debug mode to visualize tag structure.
69    Keep,
70}
71
72/// How to handle tags not found in the styles map.
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
74pub enum UnknownTagBehavior {
75    /// Keep unknown tags as literal text with a `?` marker.
76    /// `[foo]text[/foo]` → `[foo?]text[/foo?]`
77    ///
78    /// This makes unknown tags visible without breaking output.
79    #[default]
80    Passthrough,
81
82    /// Strip unknown tags entirely, keeping only inner content.
83    /// `[foo]text[/foo]` → `text`
84    ///
85    /// Use this for graceful degradation in production.
86    Strip,
87}
88
89/// The kind of unknown tag encountered.
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub enum UnknownTagKind {
92    /// An opening tag: `[foo]`
93    Open,
94    /// A closing tag: `[/foo]`
95    Close,
96    /// An unbalanced opening tag: `[foo]...` (no matching close)
97    Unbalanced,
98    /// An unexpected closing tag: `...[/foo]` (no matching open)
99    UnexpectedClose,
100}
101
102/// An error representing an unknown tag in the input.
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct UnknownTagError {
105    /// The tag name that was not found in styles.
106    pub tag: String,
107    /// The kind of tag (open or close).
108    pub kind: UnknownTagKind,
109    /// Byte offset of the opening `[` in the input.
110    pub start: usize,
111    /// Byte offset after the closing `]` in the input.
112    pub end: usize,
113}
114
115impl std::fmt::Display for UnknownTagError {
116    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117        let kind = match self.kind {
118            UnknownTagKind::Open => "unknown opening",
119            UnknownTagKind::Close => "unknown closing",
120            UnknownTagKind::Unbalanced => "unbalanced",
121            UnknownTagKind::UnexpectedClose => "unexpected closing",
122        };
123        write!(
124            f,
125            "{} tag '{}' at position {}..{}",
126            kind, self.tag, self.start, self.end
127        )
128    }
129}
130
131impl std::error::Error for UnknownTagError {}
132
133/// A collection of unknown tag errors found during parsing.
134#[derive(Debug, Clone, Default, PartialEq, Eq)]
135pub struct UnknownTagErrors {
136    /// The list of unknown tag errors.
137    pub errors: Vec<UnknownTagError>,
138}
139
140impl UnknownTagErrors {
141    /// Creates an empty error collection.
142    pub fn new() -> Self {
143        Self::default()
144    }
145
146    /// Returns true if no errors were found.
147    pub fn is_empty(&self) -> bool {
148        self.errors.is_empty()
149    }
150
151    /// Returns the number of errors.
152    pub fn len(&self) -> usize {
153        self.errors.len()
154    }
155
156    /// Adds an error to the collection.
157    pub fn push(&mut self, error: UnknownTagError) {
158        self.errors.push(error);
159    }
160}
161
162impl std::fmt::Display for UnknownTagErrors {
163    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
164        writeln!(f, "found {} unknown tag(s):", self.errors.len())?;
165        for error in &self.errors {
166            writeln!(f, "  - {}", error)?;
167        }
168        Ok(())
169    }
170}
171
172impl std::error::Error for UnknownTagErrors {}
173
174impl IntoIterator for UnknownTagErrors {
175    type Item = UnknownTagError;
176    type IntoIter = std::vec::IntoIter<UnknownTagError>;
177
178    fn into_iter(self) -> Self::IntoIter {
179        self.errors.into_iter()
180    }
181}
182
183impl<'a> IntoIterator for &'a UnknownTagErrors {
184    type Item = &'a UnknownTagError;
185    type IntoIter = std::slice::Iter<'a, UnknownTagError>;
186
187    fn into_iter(self) -> Self::IntoIter {
188        self.errors.iter()
189    }
190}
191
192/// Strips all BBCode-style tags from input, returning only visible content.
193///
194/// This is a convenience function that creates a parser in [`TagTransform::Remove`] mode
195/// with [`UnknownTagBehavior::Strip`] to remove all tags (both known and unknown).
196///
197/// Useful for measuring the visible width of text that may contain inline markup.
198///
199/// # Example
200///
201/// ```rust
202/// use standout_bbparser::strip_tags;
203///
204/// assert_eq!(strip_tags("[bold]hello[/bold]"), "hello");
205/// assert_eq!(strip_tags("[additions]+32[/additions]/[deletions]-0[/deletions]/32"), "+32/-0/32");
206/// assert_eq!(strip_tags("no tags here"), "no tags here");
207/// ```
208pub fn strip_tags(input: &str) -> String {
209    let parser = BBParser::new(HashMap::new(), TagTransform::Remove)
210        .unknown_behavior(UnknownTagBehavior::Strip);
211    parser.parse(input)
212}
213
214/// A BBCode-style tag parser for terminal styling.
215///
216/// The parser processes `[tag]content[/tag]` patterns and transforms them
217/// according to the configured [`TagTransform`] mode.
218#[derive(Debug, Clone)]
219pub struct BBParser {
220    styles: HashMap<String, Style>,
221    transform: TagTransform,
222    unknown_behavior: UnknownTagBehavior,
223}
224
225impl BBParser {
226    /// Creates a new parser with the given styles and transform mode.
227    ///
228    /// # Arguments
229    ///
230    /// * `styles` - Map of tag names to console styles.
231    ///   Note: These styles are used directly; no alias resolution is performed.
232    /// * `transform` - How to handle matched tags
233    ///
234    /// Unknown tags default to [`UnknownTagBehavior::Passthrough`].
235    pub fn new(styles: HashMap<String, Style>, transform: TagTransform) -> Self {
236        Self {
237            styles,
238            transform,
239            unknown_behavior: UnknownTagBehavior::default(),
240        }
241    }
242
243    /// Sets the behavior for unknown tags.
244    ///
245    /// # Example
246    ///
247    /// ```rust
248    /// use standout_bbparser::{BBParser, TagTransform, UnknownTagBehavior};
249    /// use std::collections::HashMap;
250    ///
251    /// let parser = BBParser::new(HashMap::new(), TagTransform::Remove)
252    ///     .unknown_behavior(UnknownTagBehavior::Strip);
253    ///
254    /// // Unknown tags are stripped
255    /// assert_eq!(parser.parse("[foo]text[/foo]"), "text");
256    /// ```
257    pub fn unknown_behavior(mut self, behavior: UnknownTagBehavior) -> Self {
258        self.unknown_behavior = behavior;
259        self
260    }
261
262    /// Parses and transforms input.
263    ///
264    /// Unknown tags are handled according to the configured [`UnknownTagBehavior`].
265    pub fn parse(&self, input: &str) -> String {
266        let (output, _) = self.parse_internal(input);
267        output
268    }
269
270    /// Parses input and collects any unknown tag errors.
271    ///
272    /// Returns the transformed output AND any errors found.
273    /// The output uses the configured [`UnknownTagBehavior`] for transformation.
274    ///
275    /// # Example
276    ///
277    /// ```rust
278    /// use standout_bbparser::{BBParser, TagTransform};
279    /// use std::collections::HashMap;
280    ///
281    /// let parser = BBParser::new(HashMap::new(), TagTransform::Remove);
282    /// let (output, errors) = parser.parse_with_diagnostics("[unknown]text[/unknown]");
283    ///
284    /// assert!(!errors.is_empty());
285    /// assert_eq!(errors.len(), 2); // open and close tags
286    /// ```
287    pub fn parse_with_diagnostics(&self, input: &str) -> (String, UnknownTagErrors) {
288        self.parse_internal(input)
289    }
290
291    /// Validates input for unknown tags without producing transformed output.
292    ///
293    /// Returns `Ok(())` if all tags are known, `Err` with details otherwise.
294    ///
295    /// # Example
296    ///
297    /// ```rust
298    /// use standout_bbparser::{BBParser, TagTransform};
299    /// use std::collections::HashMap;
300    /// use console::Style;
301    ///
302    /// let mut styles = HashMap::new();
303    /// styles.insert("bold".to_string(), Style::new().bold());
304    ///
305    /// let parser = BBParser::new(styles, TagTransform::Apply);
306    ///
307    /// // Known tag passes validation
308    /// assert!(parser.validate("[bold]text[/bold]").is_ok());
309    ///
310    /// // Unknown tag fails validation
311    /// let result = parser.validate("[unknown]text[/unknown]");
312    /// assert!(result.is_err());
313    /// ```
314    pub fn validate(&self, input: &str) -> Result<(), UnknownTagErrors> {
315        let (_, errors) = self.parse_internal(input);
316        if errors.is_empty() {
317            Ok(())
318        } else {
319            Err(errors)
320        }
321    }
322
323    /// Internal parsing that returns both output and errors.
324    fn parse_internal(&self, input: &str) -> (String, UnknownTagErrors) {
325        let tokens = Tokenizer::new(input).collect::<Vec<_>>();
326        let valid_opens = self.compute_valid_tags(&tokens);
327        let mut events = Vec::new();
328        let mut errors = UnknownTagErrors::new();
329        let mut stack: Vec<&str> = Vec::new();
330
331        // ...
332        // ...
333        let mut i = 0;
334        while i < tokens.len() {
335            match &tokens[i] {
336                Token::Text { content, .. } => {
337                    events.push(ParseEvent::Literal(std::borrow::Cow::Borrowed(content)));
338                }
339                Token::OpenTag { name, start, end } => {
340                    if valid_opens.contains(&i) {
341                        stack.push(name);
342                        self.emit_open_tag_event(&mut events, &mut errors, name, *start, *end);
343                    } else {
344                        // Check if this looks like a valid tag name but was just unclosed/unbalanced
345                        let is_valid_name = Tokenizer::is_valid_tag_name(name);
346                        if is_valid_name {
347                            // Strictly error on unbalanced tags
348                            errors.push(UnknownTagError {
349                                tag: name.to_string(),
350                                kind: UnknownTagKind::Unbalanced, // NEW VARIANT
351                                start: *start,
352                                end: *end,
353                            });
354                            // Also treat as literal to not break output entirely?
355                            // Or just error? Issue says "Unbalanced tags must error".
356                            // We record error. Output depends on transform.
357                            // We'll output literal text for visual feedback?
358                            events.push(ParseEvent::Literal(std::borrow::Cow::Owned(format!(
359                                "[{}]",
360                                name
361                            ))));
362                        } else {
363                            events.push(ParseEvent::Literal(std::borrow::Cow::Owned(format!(
364                                "[{}]",
365                                name
366                            ))));
367                        }
368                    }
369                }
370                Token::CloseTag { name, start, end } => {
371                    if stack.last().copied() == Some(*name) {
372                        stack.pop();
373                        self.emit_close_tag_event(&mut events, &mut errors, name, *start, *end);
374                    } else if stack.contains(name) {
375                        while let Some(open) = stack.pop() {
376                            self.emit_close_tag_event(&mut events, &mut errors, open, 0, 0);
377                            if open == *name {
378                                break;
379                            }
380                        }
381                    } else {
382                        // Unexpected close tag
383                        let is_valid_name = Tokenizer::is_valid_tag_name(name);
384                        if is_valid_name {
385                            errors.push(UnknownTagError {
386                                tag: name.to_string(),
387                                kind: UnknownTagKind::UnexpectedClose, // NEW VARIANT
388                                start: *start,
389                                end: *end,
390                            });
391                        }
392                        events.push(ParseEvent::Literal(std::borrow::Cow::Owned(format!(
393                            "[/{}]",
394                            name
395                        ))));
396                    }
397                }
398                Token::InvalidTag { content, .. } => {
399                    events.push(ParseEvent::Literal(std::borrow::Cow::Borrowed(content)));
400                }
401            }
402            i += 1;
403        }
404
405        while let Some(tag) = stack.pop() {
406            self.emit_close_tag_event(&mut events, &mut errors, tag, 0, 0);
407        }
408
409        let output = self.render(events);
410        (output, errors)
411    }
412
413    fn emit_open_tag_event<'a>(
414        &self,
415        events: &mut Vec<ParseEvent<'a>>,
416        errors: &mut UnknownTagErrors,
417        tag: &'a str,
418        start: usize,
419        end: usize,
420    ) {
421        let is_known = self.styles.contains_key(tag);
422
423        if !is_known {
424            errors.push(UnknownTagError {
425                tag: tag.to_string(),
426                kind: UnknownTagKind::Open,
427                start,
428                end,
429            });
430        }
431
432        match self.transform {
433            TagTransform::Keep => {
434                events.push(ParseEvent::Literal(std::borrow::Cow::Owned(format!(
435                    "[{}]",
436                    tag
437                ))));
438            }
439            TagTransform::Remove => {
440                // Nothing to emit for known or stripped unknown tags
441            }
442            TagTransform::Apply => {
443                if is_known {
444                    events.push(ParseEvent::StyleStart(tag));
445                } else {
446                    match self.unknown_behavior {
447                        UnknownTagBehavior::Passthrough => {
448                            events.push(ParseEvent::Literal(std::borrow::Cow::Owned(format!(
449                                "[{}?]",
450                                tag
451                            ))));
452                        }
453                        UnknownTagBehavior::Strip => {
454                            // Nothing to emit
455                        }
456                    }
457                }
458            }
459        }
460    }
461
462    fn emit_close_tag_event<'a>(
463        &self,
464        events: &mut Vec<ParseEvent<'a>>,
465        errors: &mut UnknownTagErrors,
466        tag: &'a str,
467        start: usize,
468        end: usize,
469    ) {
470        let is_known = self.styles.contains_key(tag);
471
472        // Only record error if we have valid position info (not auto-closed)
473        if !is_known && end > 0 {
474            errors.push(UnknownTagError {
475                tag: tag.to_string(),
476                kind: UnknownTagKind::Close,
477                start,
478                end,
479            });
480        }
481
482        match self.transform {
483            TagTransform::Keep => {
484                events.push(ParseEvent::Literal(std::borrow::Cow::Owned(format!(
485                    "[/{}]",
486                    tag
487                ))));
488            }
489            TagTransform::Remove => {
490                // Nothing to emit
491            }
492            TagTransform::Apply => {
493                if is_known {
494                    events.push(ParseEvent::StyleEnd(tag));
495                } else {
496                    match self.unknown_behavior {
497                        UnknownTagBehavior::Passthrough => {
498                            events.push(ParseEvent::Literal(std::borrow::Cow::Owned(format!(
499                                "[/{}?]",
500                                tag
501                            ))));
502                        }
503                        UnknownTagBehavior::Strip => {
504                            // Nothing to emit
505                        }
506                    }
507                }
508            }
509        }
510    }
511
512    /// Renders events to a string.
513    fn render(&self, events: Vec<ParseEvent>) -> String {
514        let mut result = String::new();
515        let mut style_stack: Vec<&Style> = Vec::new();
516
517        for event in events {
518            match event {
519                ParseEvent::Literal(text) => {
520                    self.append_styled(&mut result, &text, &style_stack);
521                }
522                ParseEvent::StyleStart(tag) => {
523                    if let Some(style) = self.styles.get(tag) {
524                        style_stack.push(style);
525                    }
526                }
527                ParseEvent::StyleEnd(tag) => {
528                    if self.styles.contains_key(tag) {
529                        style_stack.pop();
530                    }
531                }
532            }
533        }
534        result
535    }
536
537    /// Pre-computes which OpenTag tokens have a valid matching CloseTag.
538    /// This is O(N) instead of O(N^2).
539    fn compute_valid_tags(&self, tokens: &[Token]) -> std::collections::HashSet<usize> {
540        use std::collections::{HashMap, HashSet};
541        let mut valid_indices = HashSet::new();
542        let mut open_indices_by_tag: HashMap<&str, Vec<usize>> = HashMap::new();
543
544        for (i, token) in tokens.iter().enumerate() {
545            match token {
546                Token::OpenTag { name, .. } => {
547                    open_indices_by_tag.entry(name).or_default().push(i);
548                }
549                Token::CloseTag { name, .. } => {
550                    if let Some(indices) = open_indices_by_tag.get_mut(name) {
551                        if let Some(open_idx) = indices.pop() {
552                            valid_indices.insert(open_idx);
553                        }
554                    }
555                }
556                _ => {}
557            }
558        }
559
560        valid_indices
561    }
562
563    /// Helper to append styled text.
564    fn append_styled(&self, output: &mut String, text: &str, style_stack: &[&Style]) {
565        if text.is_empty() {
566            return;
567        }
568
569        if style_stack.is_empty() {
570            output.push_str(text);
571        } else {
572            let mut current = text.to_string();
573            // Apply styles from innermost (top of stack) to outermost (bottom).
574            // This ensures that inner styles override outer styles (ANSI rules: last code wins).
575            // Also optimizes by stripping nested resets.
576            for style in style_stack.iter().rev() {
577                if current.ends_with("\x1b[0m") {
578                    current.truncate(current.len() - 4);
579                }
580                current = style.apply_to(current).to_string();
581            }
582            output.push_str(&current);
583        }
584    }
585}
586
587enum ParseEvent<'a> {
588    Literal(std::borrow::Cow<'a, str>),
589    StyleStart(&'a str),
590    StyleEnd(&'a str),
591}
592
593/// Token types produced by the tokenizer.
594#[derive(Debug, Clone, PartialEq, Eq)]
595enum Token<'a> {
596    /// Plain text content.
597    Text {
598        content: &'a str,
599        start: usize,
600        end: usize,
601    },
602    /// Opening tag: `[tagname]`
603    OpenTag {
604        name: &'a str,
605        start: usize,
606        end: usize,
607    },
608    /// Closing tag: `[/tagname]`
609    CloseTag {
610        name: &'a str,
611        start: usize,
612        end: usize,
613    },
614    /// Invalid tag syntax (passed through as text).
615    InvalidTag {
616        content: &'a str,
617        start: usize,
618        end: usize,
619    },
620}
621
622/// Tokenizer for BBCode-style tags.
623struct Tokenizer<'a> {
624    input: &'a str,
625    pos: usize,
626}
627
628impl<'a> Tokenizer<'a> {
629    fn new(input: &'a str) -> Self {
630        Self { input, pos: 0 }
631    }
632
633    /// Checks if a string is a valid tag name (CSS identifier rules).
634    fn is_valid_tag_name(s: &str) -> bool {
635        if s.is_empty() {
636            return false;
637        }
638
639        let mut chars = s.chars();
640        let first = chars.next().unwrap();
641
642        // First char must be letter or underscore
643        if !first.is_ascii_lowercase() && first != '_' {
644            return false;
645        }
646
647        // Rest can be letter, digit, underscore, or hyphen
648        for c in chars {
649            if !c.is_ascii_lowercase() && !c.is_ascii_digit() && c != '_' && c != '-' {
650                return false;
651            }
652        }
653
654        true
655    }
656}
657
658impl<'a> Iterator for Tokenizer<'a> {
659    type Item = Token<'a>;
660
661    fn next(&mut self) -> Option<Self::Item> {
662        if self.pos >= self.input.len() {
663            return None;
664        }
665
666        let remaining = &self.input[self.pos..];
667        let start_pos = self.pos;
668
669        // Look for the next '['
670        if let Some(bracket_pos) = remaining.find('[') {
671            if bracket_pos > 0 {
672                // There's text before the bracket
673                let text = &remaining[..bracket_pos];
674                self.pos += bracket_pos;
675                return Some(Token::Text {
676                    content: text,
677                    start: start_pos,
678                    end: self.pos,
679                });
680            }
681
682            // We're at a '['
683            // Try to parse a tag
684            if let Some(close_bracket) = remaining.find(']') {
685                let tag_content = &remaining[1..close_bracket];
686                let full_tag = &remaining[..=close_bracket];
687                let end_pos = start_pos + close_bracket + 1;
688
689                // Check for closing tag
690                if let Some(tag_name) = tag_content.strip_prefix('/') {
691                    if Self::is_valid_tag_name(tag_name) {
692                        self.pos = end_pos;
693                        Some(Token::CloseTag {
694                            name: tag_name,
695                            start: start_pos,
696                            end: end_pos,
697                        })
698                    } else {
699                        self.pos = end_pos;
700                        Some(Token::InvalidTag {
701                            content: full_tag,
702                            start: start_pos,
703                            end: end_pos,
704                        })
705                    }
706                } else if Self::is_valid_tag_name(tag_content) {
707                    self.pos = end_pos;
708                    Some(Token::OpenTag {
709                        name: tag_content,
710                        start: start_pos,
711                        end: end_pos,
712                    })
713                } else {
714                    self.pos = end_pos;
715                    Some(Token::InvalidTag {
716                        content: full_tag,
717                        start: start_pos,
718                        end: end_pos,
719                    })
720                }
721            } else {
722                // No closing bracket - rest is text
723                let end_pos = self.input.len();
724                self.pos = end_pos;
725                Some(Token::Text {
726                    content: remaining,
727                    start: start_pos,
728                    end: end_pos,
729                })
730            }
731        } else {
732            // No more brackets - rest is text
733            let end_pos = self.input.len();
734            self.pos = end_pos;
735            Some(Token::Text {
736                content: remaining,
737                start: start_pos,
738                end: end_pos,
739            })
740        }
741    }
742}
743
744#[cfg(test)]
745mod tests {
746    use super::*;
747
748    fn test_styles() -> HashMap<String, Style> {
749        let mut styles = HashMap::new();
750        styles.insert("bold".to_string(), Style::new().bold());
751        styles.insert("red".to_string(), Style::new().red());
752        styles.insert("dim".to_string(), Style::new().dim());
753        styles.insert("title".to_string(), Style::new().cyan().bold());
754        styles.insert("error".to_string(), Style::new().red().bold());
755        styles.insert("my_style".to_string(), Style::new().green());
756        styles.insert("style-with-dash".to_string(), Style::new().yellow());
757        styles
758    }
759
760    // ==================== strip_tags Tests ====================
761
762    mod strip_tags_tests {
763        use super::super::strip_tags;
764
765        #[test]
766        fn strips_known_style_tags() {
767            assert_eq!(strip_tags("[bold]hello[/bold]"), "hello");
768        }
769
770        #[test]
771        fn strips_unknown_tags() {
772            assert_eq!(strip_tags("[additions]+32[/additions]"), "+32");
773        }
774
775        #[test]
776        fn strips_multiple_tags() {
777            assert_eq!(
778                strip_tags("[additions]+32[/additions]/[deletions]-0[/deletions]/32"),
779                "+32/-0/32"
780            );
781        }
782
783        #[test]
784        fn plain_text_unchanged() {
785            assert_eq!(strip_tags("no tags here"), "no tags here");
786        }
787
788        #[test]
789        fn empty_string() {
790            assert_eq!(strip_tags(""), "");
791        }
792
793        #[test]
794        fn nested_tags() {
795            assert_eq!(strip_tags("[a][b]text[/b][/a]"), "text");
796        }
797    }
798
799    // ==================== TagTransform::Keep Tests ====================
800
801    mod keep_mode {
802        use super::*;
803
804        #[test]
805        fn plain_text_unchanged() {
806            let parser = BBParser::new(test_styles(), TagTransform::Keep);
807            assert_eq!(parser.parse("hello world"), "hello world");
808        }
809
810        #[test]
811        fn single_tag_preserved() {
812            let parser = BBParser::new(test_styles(), TagTransform::Keep);
813            assert_eq!(parser.parse("[bold]hello[/bold]"), "[bold]hello[/bold]");
814        }
815
816        #[test]
817        fn nested_tags_preserved() {
818            let parser = BBParser::new(test_styles(), TagTransform::Keep);
819            assert_eq!(
820                parser.parse("[bold][red]hello[/red][/bold]"),
821                "[bold][red]hello[/red][/bold]"
822            );
823        }
824
825        #[test]
826        fn adjacent_tags_preserved() {
827            let parser = BBParser::new(test_styles(), TagTransform::Keep);
828            assert_eq!(
829                parser.parse("[bold]a[/bold][red]b[/red]"),
830                "[bold]a[/bold][red]b[/red]"
831            );
832        }
833
834        #[test]
835        fn text_around_tags() {
836            let parser = BBParser::new(test_styles(), TagTransform::Keep);
837            assert_eq!(
838                parser.parse("before [bold]middle[/bold] after"),
839                "before [bold]middle[/bold] after"
840            );
841        }
842
843        #[test]
844        fn unknown_tags_preserved() {
845            let parser = BBParser::new(test_styles(), TagTransform::Keep);
846            assert_eq!(
847                parser.parse("[unknown]text[/unknown]"),
848                "[unknown]text[/unknown]"
849            );
850        }
851    }
852
853    // ==================== TagTransform::Remove Tests ====================
854
855    mod remove_mode {
856        use super::*;
857
858        #[test]
859        fn plain_text_unchanged() {
860            let parser = BBParser::new(test_styles(), TagTransform::Remove);
861            assert_eq!(parser.parse("hello world"), "hello world");
862        }
863
864        #[test]
865        fn single_tag_stripped() {
866            let parser = BBParser::new(test_styles(), TagTransform::Remove);
867            assert_eq!(parser.parse("[bold]hello[/bold]"), "hello");
868        }
869
870        #[test]
871        fn nested_tags_stripped() {
872            let parser = BBParser::new(test_styles(), TagTransform::Remove);
873            assert_eq!(parser.parse("[bold][red]hello[/red][/bold]"), "hello");
874        }
875
876        #[test]
877        fn adjacent_tags_stripped() {
878            let parser = BBParser::new(test_styles(), TagTransform::Remove);
879            assert_eq!(parser.parse("[bold]a[/bold][red]b[/red]"), "ab");
880        }
881
882        #[test]
883        fn text_around_tags() {
884            let parser = BBParser::new(test_styles(), TagTransform::Remove);
885            assert_eq!(
886                parser.parse("before [bold]middle[/bold] after"),
887                "before middle after"
888            );
889        }
890
891        #[test]
892        fn unknown_tags_stripped() {
893            let parser = BBParser::new(test_styles(), TagTransform::Remove);
894            // Default is Passthrough, but Remove mode ignores unknown_behavior for output
895            assert_eq!(parser.parse("[unknown]text[/unknown]"), "text");
896        }
897    }
898
899    // ==================== Unknown Tag Behavior Tests ====================
900
901    mod unknown_tag_behavior {
902        use super::*;
903
904        #[test]
905        fn passthrough_adds_question_mark_in_apply_mode() {
906            let parser = BBParser::new(test_styles(), TagTransform::Apply)
907                .unknown_behavior(UnknownTagBehavior::Passthrough);
908            assert_eq!(
909                parser.parse("[unknown]text[/unknown]"),
910                "[unknown?]text[/unknown?]"
911            );
912        }
913
914        #[test]
915        fn passthrough_is_default() {
916            let parser = BBParser::new(test_styles(), TagTransform::Apply);
917            assert_eq!(
918                parser.parse("[unknown]text[/unknown]"),
919                "[unknown?]text[/unknown?]"
920            );
921        }
922
923        #[test]
924        fn strip_removes_unknown_tags_in_apply_mode() {
925            let parser = BBParser::new(test_styles(), TagTransform::Apply)
926                .unknown_behavior(UnknownTagBehavior::Strip);
927            assert_eq!(parser.parse("[unknown]text[/unknown]"), "text");
928        }
929
930        #[test]
931        fn passthrough_nested_with_known() {
932            let parser = BBParser::new(test_styles(), TagTransform::Apply)
933                .unknown_behavior(UnknownTagBehavior::Passthrough);
934            let result = parser.parse("[bold][unknown]text[/unknown][/bold]");
935            assert!(result.contains("[unknown?]"));
936            assert!(result.contains("[/unknown?]"));
937            assert!(result.contains("text"));
938        }
939
940        #[test]
941        fn strip_nested_with_known() {
942            let mut styles = HashMap::new();
943            styles.insert("bold".to_string(), Style::new().bold().force_styling(true));
944            let parser = BBParser::new(styles, TagTransform::Apply)
945                .unknown_behavior(UnknownTagBehavior::Strip);
946            let result = parser.parse("[bold][unknown]text[/unknown][/bold]");
947            // Should have bold styling but no unknown tag markers
948            assert!(!result.contains("[unknown"));
949            assert!(result.contains("text"));
950        }
951
952        #[test]
953        fn keep_mode_ignores_unknown_behavior() {
954            // In Keep mode, all tags are preserved as-is regardless of unknown_behavior
955            let parser = BBParser::new(test_styles(), TagTransform::Keep)
956                .unknown_behavior(UnknownTagBehavior::Strip);
957            assert_eq!(
958                parser.parse("[unknown]text[/unknown]"),
959                "[unknown]text[/unknown]"
960            );
961        }
962
963        #[test]
964        fn remove_mode_always_strips_tags() {
965            // In Remove mode, all tags are stripped regardless of unknown_behavior
966            let parser = BBParser::new(test_styles(), TagTransform::Remove)
967                .unknown_behavior(UnknownTagBehavior::Passthrough);
968            assert_eq!(parser.parse("[unknown]text[/unknown]"), "text");
969        }
970    }
971
972    // ==================== Validation Tests ====================
973
974    mod validation {
975        use super::*;
976
977        #[test]
978        fn validate_all_known_tags_passes() {
979            let parser = BBParser::new(test_styles(), TagTransform::Apply);
980            assert!(parser.validate("[bold]text[/bold]").is_ok());
981        }
982
983        #[test]
984        fn validate_nested_known_tags_passes() {
985            let parser = BBParser::new(test_styles(), TagTransform::Apply);
986            assert!(parser.validate("[bold][red]text[/red][/bold]").is_ok());
987        }
988
989        #[test]
990        fn validate_unknown_tag_fails() {
991            let parser = BBParser::new(test_styles(), TagTransform::Apply);
992            let result = parser.validate("[unknown]text[/unknown]");
993            assert!(result.is_err());
994        }
995
996        #[test]
997        fn validate_returns_correct_error_count() {
998            let parser = BBParser::new(test_styles(), TagTransform::Apply);
999            let result = parser.validate("[unknown]text[/unknown]");
1000            let errors = result.unwrap_err();
1001            assert_eq!(errors.len(), 2); // open and close
1002        }
1003
1004        #[test]
1005        fn validate_error_contains_tag_name() {
1006            let parser = BBParser::new(test_styles(), TagTransform::Apply);
1007            let result = parser.validate("[foobar]text[/foobar]");
1008            let errors = result.unwrap_err();
1009            assert!(errors.errors.iter().all(|e| e.tag == "foobar"));
1010        }
1011
1012        #[test]
1013        fn validate_error_distinguishes_open_and_close() {
1014            let parser = BBParser::new(test_styles(), TagTransform::Apply);
1015            let result = parser.validate("[unknown]text[/unknown]");
1016            let errors = result.unwrap_err();
1017
1018            let open_count = errors
1019                .errors
1020                .iter()
1021                .filter(|e| e.kind == UnknownTagKind::Open)
1022                .count();
1023            let close_count = errors
1024                .errors
1025                .iter()
1026                .filter(|e| e.kind == UnknownTagKind::Close)
1027                .count();
1028
1029            assert_eq!(open_count, 1);
1030            assert_eq!(close_count, 1);
1031        }
1032
1033        #[test]
1034        fn validate_error_has_correct_positions() {
1035            let parser = BBParser::new(test_styles(), TagTransform::Apply);
1036            let input = "[unknown]text[/unknown]";
1037            let result = parser.validate(input);
1038            let errors = result.unwrap_err();
1039
1040            let open_error = errors
1041                .errors
1042                .iter()
1043                .find(|e| e.kind == UnknownTagKind::Open)
1044                .unwrap();
1045            assert_eq!(open_error.start, 0);
1046            assert_eq!(open_error.end, 9); // "[unknown]"
1047
1048            let close_error = errors
1049                .errors
1050                .iter()
1051                .find(|e| e.kind == UnknownTagKind::Close)
1052                .unwrap();
1053            assert_eq!(close_error.start, 13);
1054            assert_eq!(close_error.end, 23); // "[/unknown]"
1055        }
1056
1057        #[test]
1058        fn validate_multiple_unknown_tags() {
1059            let parser = BBParser::new(test_styles(), TagTransform::Apply);
1060            let result = parser.validate("[foo]a[/foo][bar]b[/bar]");
1061            let errors = result.unwrap_err();
1062            assert_eq!(errors.len(), 4); // 2 opens + 2 closes
1063
1064            let tags: std::collections::HashSet<_> =
1065                errors.errors.iter().map(|e| e.tag.as_str()).collect();
1066            assert!(tags.contains("foo"));
1067            assert!(tags.contains("bar"));
1068        }
1069
1070        #[test]
1071        fn validate_mixed_known_and_unknown() {
1072            let parser = BBParser::new(test_styles(), TagTransform::Apply);
1073            let result = parser.validate("[bold][unknown]text[/unknown][/bold]");
1074            let errors = result.unwrap_err();
1075            assert_eq!(errors.len(), 2); // only unknown tag errors
1076
1077            for error in &errors.errors {
1078                assert_eq!(error.tag, "unknown");
1079            }
1080        }
1081
1082        #[test]
1083        fn validate_plain_text_passes() {
1084            let parser = BBParser::new(test_styles(), TagTransform::Apply);
1085            assert!(parser.validate("plain text without tags").is_ok());
1086        }
1087
1088        #[test]
1089        fn validate_empty_string_passes() {
1090            let parser = BBParser::new(test_styles(), TagTransform::Apply);
1091            assert!(parser.validate("").is_ok());
1092        }
1093    }
1094
1095    // ==================== Parse With Diagnostics Tests ====================
1096
1097    mod parse_with_diagnostics {
1098        use super::*;
1099
1100        #[test]
1101        fn returns_output_and_errors() {
1102            let parser = BBParser::new(test_styles(), TagTransform::Apply)
1103                .unknown_behavior(UnknownTagBehavior::Passthrough);
1104            let (output, errors) = parser.parse_with_diagnostics("[unknown]text[/unknown]");
1105
1106            assert_eq!(output, "[unknown?]text[/unknown?]");
1107            assert_eq!(errors.len(), 2);
1108        }
1109
1110        #[test]
1111        fn output_uses_strip_behavior() {
1112            let parser = BBParser::new(test_styles(), TagTransform::Apply)
1113                .unknown_behavior(UnknownTagBehavior::Strip);
1114            let (output, errors) = parser.parse_with_diagnostics("[unknown]text[/unknown]");
1115
1116            assert_eq!(output, "text");
1117            assert_eq!(errors.len(), 2);
1118        }
1119
1120        #[test]
1121        fn no_errors_for_known_tags() {
1122            let parser = BBParser::new(test_styles(), TagTransform::Apply);
1123            let (_, errors) = parser.parse_with_diagnostics("[bold]text[/bold]");
1124            assert!(errors.is_empty());
1125        }
1126
1127        #[test]
1128        fn errors_iterable() {
1129            let parser = BBParser::new(test_styles(), TagTransform::Apply);
1130            let (_, errors) = parser.parse_with_diagnostics("[a]x[/a][b]y[/b]");
1131
1132            let mut count = 0;
1133            for error in &errors {
1134                assert!(error.tag == "a" || error.tag == "b");
1135                count += 1;
1136            }
1137            assert_eq!(count, 4);
1138        }
1139    }
1140
1141    // ==================== Tag Name Validation Tests ====================
1142
1143    mod tag_names {
1144        use super::*;
1145
1146        #[test]
1147        fn valid_simple_names() {
1148            assert!(Tokenizer::is_valid_tag_name("bold"));
1149            assert!(Tokenizer::is_valid_tag_name("red"));
1150            assert!(Tokenizer::is_valid_tag_name("a"));
1151        }
1152
1153        #[test]
1154        fn valid_with_underscore() {
1155            assert!(Tokenizer::is_valid_tag_name("my_style"));
1156            assert!(Tokenizer::is_valid_tag_name("_private"));
1157            assert!(Tokenizer::is_valid_tag_name("a_b_c"));
1158        }
1159
1160        #[test]
1161        fn valid_with_hyphen() {
1162            assert!(Tokenizer::is_valid_tag_name("my-style"));
1163            assert!(Tokenizer::is_valid_tag_name("font-bold"));
1164            assert!(Tokenizer::is_valid_tag_name("a-b-c"));
1165        }
1166
1167        #[test]
1168        fn valid_with_numbers() {
1169            assert!(Tokenizer::is_valid_tag_name("h1"));
1170            assert!(Tokenizer::is_valid_tag_name("col2"));
1171            assert!(Tokenizer::is_valid_tag_name("style123"));
1172        }
1173
1174        #[test]
1175        fn invalid_starts_with_digit() {
1176            assert!(!Tokenizer::is_valid_tag_name("1style"));
1177            assert!(!Tokenizer::is_valid_tag_name("123"));
1178        }
1179
1180        #[test]
1181        fn invalid_starts_with_hyphen() {
1182            assert!(!Tokenizer::is_valid_tag_name("-style"));
1183            assert!(!Tokenizer::is_valid_tag_name("-1"));
1184        }
1185
1186        #[test]
1187        fn invalid_uppercase() {
1188            assert!(!Tokenizer::is_valid_tag_name("Bold"));
1189            assert!(!Tokenizer::is_valid_tag_name("BOLD"));
1190            assert!(!Tokenizer::is_valid_tag_name("myStyle"));
1191        }
1192
1193        #[test]
1194        fn invalid_special_chars() {
1195            assert!(!Tokenizer::is_valid_tag_name("my.style"));
1196            assert!(!Tokenizer::is_valid_tag_name("my@style"));
1197            assert!(!Tokenizer::is_valid_tag_name("my style"));
1198        }
1199
1200        #[test]
1201        fn invalid_empty() {
1202            assert!(!Tokenizer::is_valid_tag_name(""));
1203        }
1204    }
1205
1206    // ==================== Edge Cases ====================
1207
1208    mod edge_cases {
1209        use super::*;
1210
1211        #[test]
1212        fn empty_input() {
1213            let parser = BBParser::new(test_styles(), TagTransform::Keep);
1214            assert_eq!(parser.parse(""), "");
1215        }
1216
1217        #[test]
1218        fn unclosed_tag_passthrough() {
1219            let parser = BBParser::new(test_styles(), TagTransform::Keep);
1220            assert_eq!(parser.parse("[bold]hello"), "[bold]hello");
1221        }
1222
1223        #[test]
1224        fn orphan_close_tag_passthrough() {
1225            let parser = BBParser::new(test_styles(), TagTransform::Keep);
1226            assert_eq!(parser.parse("hello[/bold]"), "hello[/bold]");
1227        }
1228
1229        #[test]
1230        fn mismatched_tags() {
1231            let parser = BBParser::new(test_styles(), TagTransform::Keep);
1232            assert_eq!(
1233                parser.parse("[bold]hello[/red][/bold]"),
1234                "[bold]hello[/red][/bold]"
1235            );
1236        }
1237
1238        #[test]
1239        fn overlapping_tags_auto_close() {
1240            let parser = BBParser::new(test_styles(), TagTransform::Keep);
1241            let result = parser.parse("[bold][red]hello[/bold][/red]");
1242            assert!(result.contains("hello"));
1243        }
1244
1245        #[test]
1246        fn empty_tag_content() {
1247            let parser = BBParser::new(test_styles(), TagTransform::Remove);
1248            assert_eq!(parser.parse("[bold][/bold]"), "");
1249        }
1250
1251        #[test]
1252        fn brackets_in_content() {
1253            let parser = BBParser::new(test_styles(), TagTransform::Remove);
1254            assert_eq!(parser.parse("[bold]array[0][/bold]"), "array[0]");
1255        }
1256
1257        #[test]
1258        fn invalid_tag_syntax_passthrough() {
1259            let parser = BBParser::new(test_styles(), TagTransform::Keep);
1260            assert_eq!(parser.parse("[123]text[/123]"), "[123]text[/123]");
1261            assert_eq!(parser.parse("[-bad]text[/-bad]"), "[-bad]text[/-bad]");
1262            assert_eq!(parser.parse("[Bad]text[/Bad]"), "[Bad]text[/Bad]");
1263        }
1264
1265        #[test]
1266        fn deeply_nested() {
1267            let parser = BBParser::new(test_styles(), TagTransform::Remove);
1268            assert_eq!(
1269                parser.parse("[bold][red][dim]deep[/dim][/red][/bold]"),
1270                "deep"
1271            );
1272        }
1273
1274        #[test]
1275        fn many_adjacent_tags() {
1276            let parser = BBParser::new(test_styles(), TagTransform::Remove);
1277            assert_eq!(
1278                parser.parse("[bold]a[/bold][red]b[/red][dim]c[/dim]"),
1279                "abc"
1280            );
1281        }
1282
1283        #[test]
1284        fn unclosed_bracket() {
1285            let parser = BBParser::new(test_styles(), TagTransform::Keep);
1286            assert_eq!(parser.parse("hello [bold world"), "hello [bold world");
1287        }
1288
1289        #[test]
1290        fn multiline_content() {
1291            let parser = BBParser::new(test_styles(), TagTransform::Remove);
1292            assert_eq!(
1293                parser.parse("[bold]line1\nline2\nline3[/bold]"),
1294                "line1\nline2\nline3"
1295            );
1296        }
1297
1298        #[test]
1299        fn style_with_underscore() {
1300            let parser = BBParser::new(test_styles(), TagTransform::Remove);
1301            assert_eq!(parser.parse("[my_style]text[/my_style]"), "text");
1302        }
1303
1304        #[test]
1305        fn style_with_dash() {
1306            let parser = BBParser::new(test_styles(), TagTransform::Remove);
1307            assert_eq!(
1308                parser.parse("[style-with-dash]text[/style-with-dash]"),
1309                "text"
1310            );
1311        }
1312    }
1313
1314    // ==================== Tokenizer Tests ====================
1315
1316    mod tokenizer {
1317        use super::*;
1318
1319        #[test]
1320        fn tokenize_plain_text() {
1321            let tokens: Vec<_> = Tokenizer::new("hello world").collect();
1322            assert_eq!(
1323                tokens,
1324                vec![Token::Text {
1325                    content: "hello world",
1326                    start: 0,
1327                    end: 11
1328                }]
1329            );
1330        }
1331
1332        #[test]
1333        fn tokenize_single_tag() {
1334            let tokens: Vec<_> = Tokenizer::new("[bold]hello[/bold]").collect();
1335            assert_eq!(
1336                tokens,
1337                vec![
1338                    Token::OpenTag {
1339                        name: "bold",
1340                        start: 0,
1341                        end: 6
1342                    },
1343                    Token::Text {
1344                        content: "hello",
1345                        start: 6,
1346                        end: 11
1347                    },
1348                    Token::CloseTag {
1349                        name: "bold",
1350                        start: 11,
1351                        end: 18
1352                    },
1353                ]
1354            );
1355        }
1356
1357        #[test]
1358        fn tokenize_nested_tags() {
1359            let tokens: Vec<_> = Tokenizer::new("[a][b]x[/b][/a]").collect();
1360            assert_eq!(
1361                tokens,
1362                vec![
1363                    Token::OpenTag {
1364                        name: "a",
1365                        start: 0,
1366                        end: 3
1367                    },
1368                    Token::OpenTag {
1369                        name: "b",
1370                        start: 3,
1371                        end: 6
1372                    },
1373                    Token::Text {
1374                        content: "x",
1375                        start: 6,
1376                        end: 7
1377                    },
1378                    Token::CloseTag {
1379                        name: "b",
1380                        start: 7,
1381                        end: 11
1382                    },
1383                    Token::CloseTag {
1384                        name: "a",
1385                        start: 11,
1386                        end: 15
1387                    },
1388                ]
1389            );
1390        }
1391
1392        #[test]
1393        fn tokenize_invalid_tag() {
1394            let tokens: Vec<_> = Tokenizer::new("[123]text[/123]").collect();
1395            assert_eq!(
1396                tokens,
1397                vec![
1398                    Token::InvalidTag {
1399                        content: "[123]",
1400                        start: 0,
1401                        end: 5
1402                    },
1403                    Token::Text {
1404                        content: "text",
1405                        start: 5,
1406                        end: 9
1407                    },
1408                    Token::InvalidTag {
1409                        content: "[/123]",
1410                        start: 9,
1411                        end: 15
1412                    },
1413                ]
1414            );
1415        }
1416
1417        #[test]
1418        fn tokenize_mixed() {
1419            let tokens: Vec<_> = Tokenizer::new("a[b]c[/b]d").collect();
1420            assert_eq!(
1421                tokens,
1422                vec![
1423                    Token::Text {
1424                        content: "a",
1425                        start: 0,
1426                        end: 1
1427                    },
1428                    Token::OpenTag {
1429                        name: "b",
1430                        start: 1,
1431                        end: 4
1432                    },
1433                    Token::Text {
1434                        content: "c",
1435                        start: 4,
1436                        end: 5
1437                    },
1438                    Token::CloseTag {
1439                        name: "b",
1440                        start: 5,
1441                        end: 9
1442                    },
1443                    Token::Text {
1444                        content: "d",
1445                        start: 9,
1446                        end: 10
1447                    },
1448                ]
1449            );
1450        }
1451    }
1452
1453    // ==================== Apply Mode Tests ====================
1454
1455    mod apply_mode {
1456        use super::*;
1457
1458        #[test]
1459        fn plain_text_unchanged() {
1460            let parser = BBParser::new(test_styles(), TagTransform::Apply);
1461            assert_eq!(parser.parse("hello world"), "hello world");
1462        }
1463
1464        #[test]
1465        fn unknown_tag_passthrough_with_marker() {
1466            let parser = BBParser::new(test_styles(), TagTransform::Apply);
1467            let result = parser.parse("[unknown]text[/unknown]");
1468            assert!(result.contains("[unknown?]"));
1469            assert!(result.contains("[/unknown?]"));
1470            assert!(result.contains("text"));
1471        }
1472
1473        #[test]
1474        fn known_tag_applies_style() {
1475            let mut styles = HashMap::new();
1476            styles.insert("bold".to_string(), Style::new().bold().force_styling(true));
1477
1478            let parser = BBParser::new(styles, TagTransform::Apply);
1479            let result = parser.parse("[bold]hello[/bold]");
1480
1481            assert!(result.contains("\x1b[1m") || result.contains("hello"));
1482        }
1483    }
1484
1485    // ==================== Error Display Tests ====================
1486
1487    mod error_display {
1488        use super::*;
1489
1490        #[test]
1491        fn unknown_tag_error_display() {
1492            let error = UnknownTagError {
1493                tag: "foo".to_string(),
1494                kind: UnknownTagKind::Open,
1495                start: 0,
1496                end: 5,
1497            };
1498            let display = format!("{}", error);
1499            assert!(display.contains("foo"));
1500            assert!(display.contains("opening"));
1501            assert!(display.contains("0..5"));
1502        }
1503
1504        #[test]
1505        fn unknown_tag_errors_display() {
1506            let mut errors = UnknownTagErrors::new();
1507            errors.push(UnknownTagError {
1508                tag: "foo".to_string(),
1509                kind: UnknownTagKind::Open,
1510                start: 0,
1511                end: 5,
1512            });
1513            errors.push(UnknownTagError {
1514                tag: "foo".to_string(),
1515                kind: UnknownTagKind::Close,
1516                start: 9,
1517                end: 15,
1518            });
1519
1520            let display = format!("{}", errors);
1521            assert!(display.contains("2 unknown tag"));
1522        }
1523    }
1524}
1525
1526#[cfg(test)]
1527mod proptests {
1528    use super::*;
1529    use proptest::prelude::*;
1530
1531    fn valid_tag_name() -> impl Strategy<Value = String> {
1532        "[a-z_][a-z0-9_-]{0,10}"
1533    }
1534
1535    fn plain_text() -> impl Strategy<Value = String> {
1536        "[a-zA-Z0-9 .,!?:;'\"]{0,50}"
1537            .prop_filter("no brackets", |s| !s.contains('[') && !s.contains(']'))
1538    }
1539
1540    proptest! {
1541        #![proptest_config(ProptestConfig::with_cases(500))]
1542
1543        #[test]
1544        fn keep_mode_roundtrip(content in plain_text()) {
1545            let parser = BBParser::new(HashMap::new(), TagTransform::Keep);
1546            prop_assert_eq!(parser.parse(&content), content);
1547        }
1548
1549        #[test]
1550        fn remove_mode_plain_text_unchanged(content in plain_text()) {
1551            let parser = BBParser::new(HashMap::new(), TagTransform::Remove);
1552            prop_assert_eq!(parser.parse(&content), content);
1553        }
1554
1555        #[test]
1556        fn valid_tag_names_accepted(tag in valid_tag_name()) {
1557            prop_assert!(Tokenizer::is_valid_tag_name(&tag));
1558        }
1559
1560        #[test]
1561        fn remove_strips_known_tags(tag in valid_tag_name(), content in plain_text()) {
1562            let mut styles = HashMap::new();
1563            styles.insert(tag.clone(), Style::new());
1564
1565            let parser = BBParser::new(styles, TagTransform::Remove);
1566            let input = format!("[{}]{}[/{}]", tag, content, tag);
1567            let result = parser.parse(&input);
1568
1569            prop_assert_eq!(result, content);
1570        }
1571
1572        #[test]
1573        fn keep_preserves_structure(tag in valid_tag_name(), content in plain_text()) {
1574            let parser = BBParser::new(HashMap::new(), TagTransform::Keep);
1575            let input = format!("[{}]{}[/{}]", tag, content, tag);
1576            let result = parser.parse(&input);
1577
1578            prop_assert_eq!(result, input);
1579        }
1580
1581        #[test]
1582        fn nested_tags_balanced(
1583            outer in valid_tag_name(),
1584            inner in valid_tag_name(),
1585            content in plain_text()
1586        ) {
1587            let mut styles = HashMap::new();
1588            styles.insert(outer.clone(), Style::new());
1589            styles.insert(inner.clone(), Style::new());
1590
1591            let parser = BBParser::new(styles, TagTransform::Remove);
1592            let input = format!("[{}][{}]{}[/{}][/{}]", outer, inner, content, inner, outer);
1593            let result = parser.parse(&input);
1594
1595            prop_assert_eq!(result, content);
1596        }
1597
1598        #[test]
1599        fn validate_finds_unknown_tags(tag in valid_tag_name(), content in plain_text()) {
1600            let parser = BBParser::new(HashMap::new(), TagTransform::Apply);
1601            let input = format!("[{}]{}[/{}]", tag, content, tag);
1602            let result = parser.validate(&input);
1603
1604            prop_assert!(result.is_err());
1605            let errors = result.unwrap_err();
1606            prop_assert_eq!(errors.len(), 2); // open + close
1607        }
1608
1609        #[test]
1610        fn invalid_start_digit_rejected(n in 0..10u8, rest in "[a-z0-9_-]{0,5}") {
1611            let tag = format!("{}{}", n, rest);
1612            prop_assert!(!Tokenizer::is_valid_tag_name(&tag));
1613        }
1614
1615        #[test]
1616        fn invalid_start_hyphen_rejected(rest in "[a-z0-9_-]{0,5}") {
1617            let tag = format!("-{}", rest);
1618            prop_assert!(!Tokenizer::is_valid_tag_name(&tag));
1619        }
1620
1621        #[test]
1622        fn uppercase_rejected(tag in "[A-Z][a-zA-Z0-9_-]{0,5}") {
1623            prop_assert!(!Tokenizer::is_valid_tag_name(&tag));
1624        }
1625    }
1626}