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