Skip to main content

farben_core/
lexer.rs

1//! Tokenizer for farben markup strings.
2//!
3//! Parses bracket-delimited tag syntax (`[bold red]text[/]`) into a flat sequence of
4//! [`Token`] values. Each token is either a [`Token::Tag`] carrying styling information
5//! or a [`Token::Text`] carrying a run of literal characters.
6//!
7//! The main entry point is [`tokenize`]. The lower-level `parse_tag` and `parse_part`
8//! functions handle individual tag strings and are not part of the public API.
9
10use std::borrow::Cow;
11
12use crate::{
13    ansi::{Color, Ground, NamedColor, Style},
14    errors::LexError,
15    registry::search_registry,
16};
17
18/// A text emphasis modifier supported by farben markup.
19#[derive(Debug, PartialEq, Clone, Copy)]
20pub enum EmphasisType {
21    /// Reduced intensity (SGR 2). Lower intensity.
22    Dim,
23    /// Italic text (SGR 3). Slanted text.
24    Italic,
25    /// Underlined text (SGR 4). Single underline.
26    Underline,
27    /// Double-underlined text (SGR 21). Two lines.
28    DoubleUnderline,
29    /// Bold text (SGR 1). Increased intensity.
30    Bold,
31    /// Crossed-out text (SGR 9). Strikethrough.
32    Strikethrough,
33    /// Blinking text (SGR 5). Slow blink.
34    Blink,
35    /// Overlined text (SGR 53). Line above text.
36    Overline,
37    /// Invisible text (SGR 8). Hidden but selectable.
38    Invisible,
39    /// Reverse video (SGR 7). Swaps foreground and background.
40    Reverse,
41    /// Rapid blinking (SGR 6). Faster than Blink.
42    RapidBlink,
43}
44
45/// The target of a reset-one operation.
46///
47/// Unlike `TagType`, this only allows emphasis or color variants (not nested resets
48/// or prefixes), so it can be stored inline without a `Box`.
49#[derive(Debug, PartialEq, Clone)]
50pub enum ResetKind {
51    /// Resets a text emphasis attribute.
52    Emphasis(EmphasisType),
53    /// Resets a foreground or background color.
54    Color {
55        /// The color to remove.
56        color: Color,
57        /// Whether foreground or background.
58        ground: Ground,
59    },
60}
61
62impl ResetKind {
63    /// Returns `true` if this reset target matches the given `TagType`.
64    pub(crate) fn matches_tag(&self, tag: &TagType) -> bool {
65        match (self, tag) {
66            (Self::Emphasis(a), TagType::Emphasis(b)) => a == b,
67            (
68                Self::Color {
69                    color: ca,
70                    ground: ga,
71                },
72                TagType::Color {
73                    color: cb,
74                    ground: gb,
75                },
76            ) => ca == cb && ga == gb,
77            _ => false,
78        }
79    }
80
81    /// Converts a `TagType` into a `ResetKind`.
82    ///
83    /// Returns `None` if the tag is not a color or emphasis type (i.e., it is a reset,
84    /// prefix, or reset-all).
85    fn from_tag(tag: &TagType) -> Option<Self> {
86        match tag {
87            TagType::Emphasis(e) => Some(Self::Emphasis(*e)),
88            TagType::Color { color, ground } => Some(Self::Color {
89                color: color.clone(),
90                ground: *ground,
91            }),
92            _ => None,
93        }
94    }
95}
96
97/// The kind of styling operation a tag represents.
98#[derive(Debug, PartialEq, Clone)]
99pub enum TagType {
100    /// Resets all active styles (`[/]`).
101    ResetAll,
102    /// Resets one specific active style, then re-applies the rest.
103    /// Example: `[/bold]` resets bold but keeps other active styles.
104    ResetOne(ResetKind),
105    /// Applies a text emphasis attribute.
106    Emphasis(EmphasisType),
107    /// Sets a foreground or background color.
108    Color {
109        /// The color to apply.
110        color: Color,
111        /// Whether foreground or background.
112        ground: Ground,
113    },
114    /// A literal prefix string injected before the style sequence by the registry.
115    Prefix(String),
116}
117
118/// A single unit produced by the tokenizer: either a styling tag or a run of plain text.
119#[derive(Debug, PartialEq)]
120pub enum Token {
121    /// A parsed styling tag (color, emphasis, reset).
122    Tag(TagType),
123    /// A run of plain text with no markup.
124    Text(Cow<'static, str>),
125}
126
127impl EmphasisType {
128    /// Parses an emphasis keyword into an `EmphasisType`.
129    ///
130    /// Returns `None` if the string is not a recognized emphasis name.
131    /// Matching is case-sensitive.
132    fn from_str(input: &str) -> Option<Self> {
133        match input {
134            "dim" => Some(Self::Dim),
135            "italic" => Some(Self::Italic),
136            "underline" => Some(Self::Underline),
137            "double-underline" => Some(Self::DoubleUnderline),
138            "bold" => Some(Self::Bold),
139            "strikethrough" => Some(Self::Strikethrough),
140            "blink" => Some(Self::Blink),
141            "overline" => Some(Self::Overline),
142            "invisible" => Some(Self::Invisible),
143            "reverse" => Some(Self::Reverse),
144            "rapid-blink" => Some(Self::RapidBlink),
145            _ => None,
146        }
147    }
148}
149
150/// Expands a [`Style`] from the registry into its equivalent sequence of [`TagType`] values.
151///
152/// A `Prefix` tag is always prepended first, if one is set. A `reset` style short-circuits
153/// after the prefix: no emphasis or color tags are emitted.
154fn style_to_tags(style: &Style) -> Vec<TagType> {
155    let mut res: Vec<TagType> = Vec::new();
156    let prefix = style.prefix.clone();
157
158    if style.reset {
159        if let Some(p) = prefix {
160            res.push(TagType::Prefix(p));
161        }
162        res.push(TagType::ResetAll);
163        return res;
164    }
165
166    for (enabled, tag) in [
167        (style.bold, TagType::Emphasis(EmphasisType::Bold)),
168        (style.blink, TagType::Emphasis(EmphasisType::Blink)),
169        (style.dim, TagType::Emphasis(EmphasisType::Dim)),
170        (style.italic, TagType::Emphasis(EmphasisType::Italic)),
171        (
172            style.strikethrough,
173            TagType::Emphasis(EmphasisType::Strikethrough),
174        ),
175        (style.underline, TagType::Emphasis(EmphasisType::Underline)),
176        (
177            style.double_underline,
178            TagType::Emphasis(EmphasisType::DoubleUnderline),
179        ),
180        (style.overline, TagType::Emphasis(EmphasisType::Overline)),
181        (style.invisible, TagType::Emphasis(EmphasisType::Invisible)),
182        (style.reverse, TagType::Emphasis(EmphasisType::Reverse)),
183        (
184            style.rapid_blink,
185            TagType::Emphasis(EmphasisType::RapidBlink),
186        ),
187    ] {
188        if enabled {
189            res.push(tag);
190        }
191    }
192
193    if let Some(fg) = style.fg.clone() {
194        res.push(TagType::Color {
195            color: fg,
196            ground: Ground::Foreground,
197        });
198    }
199    if let Some(bg) = style.bg.clone() {
200        res.push(TagType::Color {
201            color: bg,
202            ground: Ground::Background,
203        });
204    }
205
206    if let Some(p) = prefix {
207        res.push(TagType::Prefix(p));
208    }
209
210    res
211}
212
213/// Parses a single whitespace-delimited tag part into a `TagType`.
214///
215/// Recognizes:
216/// - `/` as a reset
217/// - Named colors (`red`, `blue`, etc.)
218/// - Emphasis keywords (`bold`, `italic`, etc.)
219/// - `ansi(N)` for ANSI 256-palette colors
220/// - `rgb(R,G,B)` for true-color values
221/// - `hsl(H,S,L)` for HSL colors (H=0–360, S=0–100, L=0–100)
222/// - `hsv(H,S,V)` / `hsb(H,S,B)` for HSV colors (H=0–360, S=0–100, V=0–100)
223/// - `hwb(H,W,B)` for HWB colors (H=0–360, W=0–100, B=0–100; W+B≤100)
224/// - `lab(L,a,b)` for CIE Lab (D65) colors (L=0–100, a=-128–127, b=-128–127)
225/// - `lch(L,C,H)` for `CIELCh` colors (L=0–100, C=0–150, H=0–360)
226/// - `oklch(L,C,H)` for OKLCH colors (L=0–1, C=0–0.4, H=0–360)
227/// - `#fff` / `#ffffff` for hex colors
228/// - A named style from the registry as a fallback
229///
230/// All color functions accept optional spaces inside the parentheses.
231/// Parts may be prefixed with `bg:` to target the background ground, or `fg:` to
232/// explicitly target the foreground. Unprefixed color parts default to foreground.
233///
234/// # Errors
235///
236/// Returns `LexError::InvalidTag` if the part matches none of the above forms.
237/// Returns `LexError::InvalidValue` if a numeric argument cannot be parsed or is out of range.
238/// Returns `LexError::InvalidArgumentCount` if a color function does not receive exactly three values.
239#[allow(clippy::too_many_lines)]
240pub(crate) fn parse_part(
241    part: &str,
242    position: usize,
243    out: &mut Vec<TagType>,
244) -> Result<(), LexError> {
245    let (ground, part) = if let Some(rest) = part.strip_prefix("bg:") {
246        (Ground::Background, rest)
247    } else if let Some(rest) = part.strip_prefix("fg:") {
248        (Ground::Foreground, rest)
249    } else {
250        (Ground::Foreground, part)
251    };
252    if let Some(remainder) = part.strip_prefix('/') {
253        if remainder.is_empty() {
254            out.push(TagType::ResetAll);
255            Ok(())
256        } else {
257            let mut inner = Vec::new();
258            parse_part(remainder, position + 1, &mut inner)?;
259            if let [tag] = inner.as_slice() {
260                match tag {
261                    TagType::ResetAll | TagType::ResetOne(_) | TagType::Prefix(_) => {
262                        Err(LexError::InvalidResetTarget(position))
263                    }
264                    _ => {
265                        out.push(TagType::ResetOne(ResetKind::from_tag(tag).unwrap()));
266                        Ok(())
267                    }
268                }
269            } else {
270                let count_before = out.len();
271                for t in &inner {
272                    if !matches!(
273                        t,
274                        TagType::Prefix(_) | TagType::ResetAll | TagType::ResetOne(_)
275                    ) && let Some(kind) = ResetKind::from_tag(t)
276                    {
277                        out.push(TagType::ResetOne(kind));
278                    }
279                }
280                if out.len() == count_before {
281                    Err(LexError::InvalidResetTarget(position))
282                } else {
283                    Ok(())
284                }
285            }
286        }
287    } else if let Some(color) = NamedColor::from_str(part) {
288        out.push(TagType::Color {
289            color: Color::Named(color),
290            ground,
291        });
292        Ok(())
293    } else if let Some(emphasis) = EmphasisType::from_str(part) {
294        out.push(TagType::Emphasis(emphasis));
295        Ok(())
296    } else if let Some(rest) = part.strip_prefix("ansi(") {
297        if !rest.ends_with(')') {
298            return Err(LexError::UnclosedValue(position));
299        }
300        let ansi_val = &rest[..rest.len() - 1];
301        match ansi_val.trim().parse::<u8>() {
302            Ok(code) => {
303                out.push(TagType::Color {
304                    color: Color::Ansi256(code),
305                    ground,
306                });
307                Ok(())
308            }
309            Err(_) => Err(LexError::InvalidValue {
310                value: ansi_val.to_string(),
311                position,
312            }),
313        }
314    } else if let Some(rest) = part.strip_prefix("rgb(") {
315        if !rest.ends_with(')') {
316            return Err(LexError::UnclosedValue(position));
317        }
318        let rgb_val = &rest[..rest.len() - 1];
319        let parts: Result<Vec<u8>, _> =
320            rgb_val.split(',').map(|v| v.trim().parse::<u8>()).collect();
321        match parts {
322            Ok(v) if v.len() == 3 => {
323                out.push(TagType::Color {
324                    color: Color::Rgb(v[0], v[1], v[2]),
325                    ground,
326                });
327                Ok(())
328            }
329            Ok(v) => Err(LexError::InvalidArgumentCount {
330                expected: 3,
331                got: v.len(),
332                position,
333            }),
334            Err(_) => Err(LexError::InvalidValue {
335                value: rgb_val.to_string(),
336                position,
337            }),
338        }
339    } else if let Some(rest) = part.strip_prefix("hsl(") {
340        if !rest.ends_with(')') {
341            return Err(LexError::UnclosedValue(position));
342        }
343        let inner = &rest[..rest.len() - 1];
344        let raw: Vec<&str> = inner.split(',').collect();
345        if raw.len() != 3 {
346            return Err(LexError::InvalidArgumentCount {
347                expected: 3,
348                got: raw.len(),
349                position,
350            });
351        }
352        let vals: Vec<f64> = raw
353            .iter()
354            .map(|v| v.trim().parse::<f64>())
355            .collect::<Result<_, _>>()
356            .map_err(|_| LexError::InvalidValue {
357                value: inner.to_string(),
358                position,
359            })?;
360        if !(0.0..=360.0).contains(&vals[0]) {
361            return Err(LexError::InvalidValue {
362                value: format!("hue {} out of range (0-360)", vals[0]),
363                position,
364            });
365        }
366        if !(0.0..=100.0).contains(&vals[1]) {
367            return Err(LexError::InvalidValue {
368                value: format!("saturation {} out of range (0-100)", vals[1]),
369                position,
370            });
371        }
372        if !(0.0..=100.0).contains(&vals[2]) {
373            return Err(LexError::InvalidValue {
374                value: format!("lightness {} out of range (0-100)", vals[2]),
375                position,
376            });
377        }
378        let (r, g, b) = hsl_to_rgb(vals[0], vals[1], vals[2]);
379        out.push(TagType::Color {
380            color: Color::Rgb(r, g, b),
381            ground,
382        });
383        Ok(())
384    } else if let Some(rest) = part.strip_prefix("hsv(") {
385        let [h, s, v] =
386            parse_color_triple(rest, "hsv", position, 0.0, 360.0, 0.0, 100.0, 0.0, 100.0)?;
387        let (r, g, b) = hsv_to_rgb(h, s, v);
388        out.push(TagType::Color {
389            color: Color::Rgb(r, g, b),
390            ground,
391        });
392        Ok(())
393    } else if let Some(rest) = part.strip_prefix("hsb(") {
394        let [h, s, v] =
395            parse_color_triple(rest, "hsb", position, 0.0, 360.0, 0.0, 100.0, 0.0, 100.0)?;
396        let (r, g, b) = hsv_to_rgb(h, s, v);
397        out.push(TagType::Color {
398            color: Color::Rgb(r, g, b),
399            ground,
400        });
401        Ok(())
402    } else if let Some(rest) = part.strip_prefix("hwb(") {
403        let [h, w, blk] =
404            parse_color_triple(rest, "hwb", position, 0.0, 360.0, 0.0, 100.0, 0.0, 100.0)?;
405        if w + blk > 100.0 {
406            return Err(LexError::InvalidValue {
407                value: format!("whiteness+blackness {} exceeds 100", w + blk),
408                position,
409            });
410        }
411        let (r, g, b) = hwb_to_rgb(h, w, blk);
412        out.push(TagType::Color {
413            color: Color::Rgb(r, g, b),
414            ground,
415        });
416        Ok(())
417    } else if let Some(rest) = part.strip_prefix("lab(") {
418        let [l, a, b_val] = parse_color_triple(
419            rest, "lab", position, 0.0, 100.0, -128.0, 127.0, -128.0, 127.0,
420        )?;
421        let (r, g, b) = lab_to_rgb(l, a, b_val);
422        out.push(TagType::Color {
423            color: Color::Rgb(r, g, b),
424            ground,
425        });
426        Ok(())
427    } else if let Some(rest) = part.strip_prefix("lch(") {
428        let [l, c, h] =
429            parse_color_triple(rest, "lch", position, 0.0, 100.0, 0.0, 150.0, 0.0, 360.0)?;
430        let (r, g, b) = lch_to_rgb(l, c, h);
431        out.push(TagType::Color {
432            color: Color::Rgb(r, g, b),
433            ground,
434        });
435        Ok(())
436    } else if let Some(rest) = part.strip_prefix("oklch(") {
437        let [l, c, h] =
438            parse_color_triple(rest, "oklch", position, 0.0, 1.0, 0.0, 0.4, 0.0, 360.0)?;
439        let (r, g, b) = oklch_to_rgb(l, c, h);
440        out.push(TagType::Color {
441            color: Color::Rgb(r, g, b),
442            ground,
443        });
444        Ok(())
445    } else if let Some(hex) = part.strip_prefix('#') {
446        if hex.is_empty() {
447            return Err(LexError::InvalidValue {
448                value: String::new(),
449                position,
450            });
451        }
452        let (r, g, b) = match hex.len() {
453            3 => {
454                let r = u8::from_str_radix(&hex[0..1].repeat(2), 16).map_err(|_| {
455                    LexError::InvalidValue {
456                        value: hex.to_string(),
457                        position,
458                    }
459                })?;
460                let g = u8::from_str_radix(&hex[1..2].repeat(2), 16).map_err(|_| {
461                    LexError::InvalidValue {
462                        value: hex.to_string(),
463                        position,
464                    }
465                })?;
466                let b = u8::from_str_radix(&hex[2..3].repeat(2), 16).map_err(|_| {
467                    LexError::InvalidValue {
468                        value: hex.to_string(),
469                        position,
470                    }
471                })?;
472                (r, g, b)
473            }
474            6 => {
475                let r = u8::from_str_radix(&hex[0..2], 16).map_err(|_| LexError::InvalidValue {
476                    value: hex.to_string(),
477                    position,
478                })?;
479                let g = u8::from_str_radix(&hex[2..4], 16).map_err(|_| LexError::InvalidValue {
480                    value: hex.to_string(),
481                    position,
482                })?;
483                let b = u8::from_str_radix(&hex[4..6], 16).map_err(|_| LexError::InvalidValue {
484                    value: hex.to_string(),
485                    position,
486                })?;
487                (r, g, b)
488            }
489            _ => {
490                return Err(LexError::InvalidValue {
491                    value: hex.to_string(),
492                    position,
493                });
494            }
495        };
496        out.push(TagType::Color {
497            color: Color::Rgb(r, g, b),
498            ground,
499        });
500        Ok(())
501    } else {
502        match search_registry(part) {
503            Ok(style) => {
504                for tag in style_to_tags(&style) {
505                    out.push(tag);
506                }
507                Ok(())
508            }
509            Err(_) => Err(LexError::InvalidTag {
510                tag_content: part.to_string(),
511                position,
512            }),
513        }
514    }
515}
516
517/// Splits a raw tag string on whitespace, but not within `(…)` groups.
518///
519/// This allows constructs like `rgb(1, 2, 3)` or `ansi( 93 )` to survive as a
520/// single part instead of being split on the inner whitespace.
521pub(crate) fn split_tag_parts(raw_tag: &str) -> Vec<(usize, &str)> {
522    let mut parts = Vec::new();
523    let mut part_start = 0;
524    let mut depth = 0usize;
525
526    for (i, c) in raw_tag.char_indices() {
527        match c {
528            '(' => depth += 1,
529            ')' => depth = depth.saturating_sub(1),
530            c if c.is_whitespace() && depth == 0 => {
531                if i > part_start {
532                    parts.push((part_start, &raw_tag[part_start..i]));
533                }
534                part_start = i + c.len_utf8();
535            }
536            _ => {}
537        }
538    }
539    if part_start < raw_tag.len() {
540        parts.push((part_start, &raw_tag[part_start..]));
541    }
542    parts
543}
544
545/// Splits a raw tag string on whitespace and parses each part into a `TagType`.
546///
547/// A tag like `"bold red"` produces two `TagType` values. Whitespace between parts
548/// is consumed and does not appear in the output.
549///
550/// # Errors
551///
552/// Propagates any error from `parse_part`.
553fn parse_tag(raw_tag: &str, tag_start: usize, tokens: &mut Vec<Token>) -> Result<(), LexError> {
554    let mut parts = Vec::new();
555
556    for (offset, part) in split_tag_parts(raw_tag) {
557        let abs_position = tag_start + offset;
558        parse_part(part, abs_position, &mut parts)?;
559    }
560
561    for tag in parts {
562        tokens.push(Token::Tag(tag));
563    }
564
565    Ok(())
566}
567
568/// Tokenizes a farben markup string into a sequence of `Token`s.
569///
570/// Tags are delimited by `[` and `]`. Use `[[` to emit a literal `[` and `]]` to emit
571/// a literal `]`. Text between tags is emitted as [`Token::Text`]; tags are parsed and
572/// emitted as [`Token::Tag`].
573///
574/// # Errors
575///
576/// Returns `LexError::UnclosedTag` if a `[` has no matching `]`.
577/// Returns any error produced by `parse_tag` for malformed tag contents.
578///
579/// # Example
580///
581/// ```ignore
582/// let tokens = tokenize("[red]hello")?;
583/// // => [Token::Tag(TagType::Color { color: Color::Named(NamedColor::Red), ground: Ground::Foreground }),
584/// //     Token::Text("hello".into())]
585/// ```
586pub fn tokenize(input: impl Into<String>) -> Result<Vec<Token>, LexError> {
587    let input = input.into();
588    let mut tokens: Vec<Token> = Vec::with_capacity(input.len() / 4);
589    let mut pos = 0;
590    loop {
591        let next = [
592            input[pos..].find('[').map(|i| (i, b'[')),
593            input[pos..].find(']').map(|i| (i, b']')),
594        ]
595        .into_iter()
596        .flatten()
597        .min_by_key(|(i, _)| *i);
598
599        let Some((starting, kind)) = next else {
600            if pos < input.len() {
601                tokens.push(Token::Text(Cow::Owned(input[pos..].to_string())));
602            }
603            break;
604        };
605        let abs_starting = starting + pos;
606
607        if kind == b']' {
608            if pos != abs_starting {
609                tokens.push(Token::Text(Cow::Owned(
610                    input[pos..abs_starting].to_string(),
611                )));
612            }
613            if input.as_bytes().get(abs_starting + 1) == Some(&b']') {
614                tokens.push(Token::Text(Cow::Borrowed("]")));
615                pos = abs_starting + 2;
616            } else {
617                tokens.push(Token::Text(Cow::Borrowed("]")));
618                pos = abs_starting + 1;
619            }
620            continue;
621        }
622
623        // kind == b'['
624        if abs_starting > 0 && input.as_bytes().get(abs_starting.wrapping_sub(1)) == Some(&b'\x1b')
625        {
626            tokens.push(Token::Text(Cow::Owned(
627                input[pos..=abs_starting].to_string(),
628            )));
629            pos = abs_starting + 1;
630            continue;
631        }
632
633        if input.as_bytes().get(abs_starting + 1) == Some(&b'[') {
634            let before = &input[pos..abs_starting];
635            if !before.is_empty() {
636                tokens.push(Token::Text(Cow::Owned(before.to_string())));
637            }
638            tokens.push(Token::Text(Cow::Borrowed("[")));
639            pos = abs_starting + 2;
640            continue;
641        }
642
643        if pos != abs_starting {
644            tokens.push(Token::Text(Cow::Owned(
645                input[pos..abs_starting].to_string(),
646            )));
647        }
648
649        let Some(closing) = input[abs_starting..].find(']') else {
650            return Err(LexError::UnclosedTag(abs_starting));
651        };
652        let abs_closing = closing + abs_starting;
653        let raw_tag = &input[abs_starting + 1..abs_closing];
654        parse_tag(raw_tag, abs_starting, &mut tokens)?;
655        pos = abs_closing + 1;
656    }
657    Ok(tokens)
658}
659
660/// Parses a 3-argument float color function like `hsv(H,S,V)`.
661///
662/// Expects `rest` to be the part after `func_name(` and checks for the closing `)`.
663/// Validates that exactly 3 comma-separated `f64` values are present, each within
664/// its respective `[min, max]` range.
665///
666/// Returns `[f64; 3]` on success, or a `LexError` on failure.
667#[allow(clippy::too_many_arguments)]
668fn parse_color_triple(
669    rest: &str,
670    func_name: &str,
671    position: usize,
672    min1: f64,
673    max1: f64,
674    min2: f64,
675    max2: f64,
676    min3: f64,
677    max3: f64,
678) -> Result<[f64; 3], LexError> {
679    if !rest.ends_with(')') {
680        return Err(LexError::UnclosedValue(position));
681    }
682    let inner = &rest[..rest.len() - 1];
683    let raw: Vec<&str> = inner.split(',').collect();
684    if raw.len() != 3 {
685        return Err(LexError::InvalidArgumentCount {
686            expected: 3,
687            got: raw.len(),
688            position,
689        });
690    }
691    let vals: Vec<f64> = raw
692        .iter()
693        .map(|v| v.trim().parse::<f64>())
694        .collect::<Result<_, _>>()
695        .map_err(|_| LexError::InvalidValue {
696            value: inner.to_string(),
697            position,
698        })?;
699
700    if !(min1..=max1).contains(&vals[0]) {
701        return Err(LexError::InvalidValue {
702            value: format!("{func_name} arg1 {} out of range ({min1}-{max1})", vals[0]),
703            position,
704        });
705    }
706    if !(min2..=max2).contains(&vals[1]) {
707        return Err(LexError::InvalidValue {
708            value: format!("{func_name} arg2 {} out of range ({min2}-{max2})", vals[1]),
709            position,
710        });
711    }
712    if !(min3..=max3).contains(&vals[2]) {
713        return Err(LexError::InvalidValue {
714            value: format!("{func_name} arg3 {} out of range ({min3}-{max3})", vals[2]),
715            position,
716        });
717    }
718
719    Ok([vals[0], vals[1], vals[2]])
720}
721
722/// Converts HSL (hue, saturation, lightness) to an RGB triple (0–255 each).
723///
724/// `hue` in [0, 360), `saturation` in [0, 100], `lightness` in [0, 100].
725///
726/// The cast from `f64` to `u16` / `u8` is intentional: the input values are validated
727/// to be in range before this function is called, so no truncation or sign loss occurs.
728#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
729fn hsl_to_rgb(hue: f64, saturation: f64, lightness: f64) -> (u8, u8, u8) {
730    let saturation = saturation / 100.0;
731    let lightness = lightness / 100.0;
732
733    let chroma = (1.0 - (2.0 * lightness - 1.0).abs()) * saturation;
734    let x = chroma * (1.0 - ((hue / 60.0) % 2.0 - 1.0).abs());
735    let match_lightness = lightness - chroma / 2.0;
736
737    let (red, green, blue) = match hue as u16 % 360 {
738        0..=59 => (chroma, x, 0.0),
739        60..=119 => (x, chroma, 0.0),
740        120..=179 => (0.0, chroma, x),
741        180..=239 => (0.0, x, chroma),
742        240..=299 => (x, 0.0, chroma),
743        _ => (chroma, 0.0, x),
744    };
745
746    (
747        ((red + match_lightness) * 255.0).round() as u8,
748        ((green + match_lightness) * 255.0).round() as u8,
749        ((blue + match_lightness) * 255.0).round() as u8,
750    )
751}
752
753/// Converts HSV (hue, saturation, value) to an RGB triple (0–255 each).
754///
755/// `hue` in [0, 360), `saturation` in [0, 100], `value` in [0, 100].
756#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
757fn hsv_to_rgb(hue: f64, saturation: f64, value: f64) -> (u8, u8, u8) {
758    let s = saturation / 100.0;
759    let v = value / 100.0;
760
761    let chroma = v * s;
762    let x = chroma * (1.0 - ((hue / 60.0) % 2.0 - 1.0).abs());
763    let m = v - chroma;
764
765    let (red, green, blue) = match hue as u16 % 360 {
766        0..=59 => (chroma, x, 0.0),
767        60..=119 => (x, chroma, 0.0),
768        120..=179 => (0.0, chroma, x),
769        180..=239 => (0.0, x, chroma),
770        240..=299 => (x, 0.0, chroma),
771        _ => (chroma, 0.0, x),
772    };
773
774    (
775        ((red + m) * 255.0).round() as u8,
776        ((green + m) * 255.0).round() as u8,
777        ((blue + m) * 255.0).round() as u8,
778    )
779}
780
781/// Converts HWB (hue, whiteness, blackness) to an RGB triple (0–255 each).
782///
783/// `hue` in [0, 360), `whiteness` in [0, 100], `blackness` in [0, 100].
784/// When `whiteness + blackness ≥ 100`, the result is a shade of gray.
785#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
786fn hwb_to_rgb(hue: f64, whiteness: f64, blackness: f64) -> (u8, u8, u8) {
787    let w = whiteness / 100.0;
788    let b = blackness / 100.0;
789
790    // If w + b >= 1, result is a shade of gray
791    if w + b >= 1.0 {
792        let gray = (w / (w + b) * 255.0).round() as u8;
793        return (gray, gray, gray);
794    }
795
796    // Pure hue at full saturation/value (chroma = 1.0)
797    let x = 1.0 - ((hue / 60.0) % 2.0 - 1.0).abs();
798
799    let (red, green, blue) = match hue as u16 % 360 {
800        0..=59 => (1.0, x, 0.0),
801        60..=119 => (x, 1.0, 0.0),
802        120..=179 => (0.0, 1.0, x),
803        180..=239 => (0.0, x, 1.0),
804        240..=299 => (x, 0.0, 1.0),
805        _ => (1.0, 0.0, x),
806    };
807
808    let factor = 1.0 - w - b;
809    (
810        ((red * factor + w) * 255.0).round() as u8,
811        ((green * factor + w) * 255.0).round() as u8,
812        ((blue * factor + w) * 255.0).round() as u8,
813    )
814}
815
816/// Applies sRGB gamma encoding to a linear-channel value clamped to [0, 1].
817fn srgb_gamma(c: f64) -> f64 {
818    let c = c.clamp(0.0, 1.0);
819    if c <= 0.003_130_8 {
820        12.92 * c
821    } else {
822        1.055 * c.powf(1.0 / 2.4) - 0.055
823    }
824}
825
826/// Converts CIE Lab (D65) to an sRGB triple (0–255 each).
827///
828/// `l` in [0, 100], `a` and `b` typically in [-128, 127].
829/// Out-of-sRGB-gamut colors are clamped to the displayable range.
830#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
831fn lab_to_rgb(l: f64, a: f64, b: f64) -> (u8, u8, u8) {
832    // D65 reference white
833    const XN: f64 = 0.950_47;
834    const YN: f64 = 1.0;
835    const ZN: f64 = 1.088_83;
836
837    const DELTA: f64 = 6.0 / 29.0;
838    const DELTA_SQ: f64 = DELTA * DELTA; // (6/29)²
839
840    // Lab → XYZ (D65)
841    let fy = (l + 16.0) / 116.0;
842    let fx = a / 500.0 + fy;
843    let fz = fy - b / 200.0;
844
845    let x = if fx > DELTA {
846        fx * fx * fx
847    } else {
848        3.0 * DELTA_SQ * (fx - 4.0 / 29.0)
849    };
850    let y = if fy > DELTA {
851        fy * fy * fy
852    } else {
853        3.0 * DELTA_SQ * (fy - 4.0 / 29.0)
854    };
855    let z = if fz > DELTA {
856        fz * fz * fz
857    } else {
858        3.0 * DELTA_SQ * (fz - 4.0 / 29.0)
859    };
860
861    let x = x * XN;
862    let y = y * YN;
863    let z = z * ZN;
864
865    // XYZ → linear sRGB (D65 matrix from IEC 61966-2-1)
866    let r_lin = 3.240_454_2 * x - 1.537_138_5 * y - 0.498_531_4 * z;
867    let g_lin = -0.969_266_0 * x + 1.876_010_8 * y + 0.041_556_0 * z;
868    let b_lin = 0.055_643_4 * x - 0.204_025_9 * y + 1.057_225_2 * z;
869
870    (
871        (srgb_gamma(r_lin) * 255.0).round() as u8,
872        (srgb_gamma(g_lin) * 255.0).round() as u8,
873        (srgb_gamma(b_lin) * 255.0).round() as u8,
874    )
875}
876
877/// Converts `CIELCh` (`LCh` ab, D65) to an sRGB triple (0–255 each).
878///
879/// `l` in [0, 100], `c` (chroma) typically in [0, 150], `h` (hue) in [0, 360).
880/// Delegates to [`lab_to_rgb`] after converting LCH → Lab.
881#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
882fn lch_to_rgb(l: f64, c: f64, h: f64) -> (u8, u8, u8) {
883    let h_rad = h.to_radians();
884    let a = c * h_rad.cos();
885    let b = c * h_rad.sin();
886    lab_to_rgb(l, a, b)
887}
888
889/// Converts OKLCH to an sRGB triple (0–255 each).
890///
891/// `l` in [0, 1], `c` (chroma) typically in [0, 0.4], `h` (hue) in [0, 360).
892#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
893fn oklch_to_rgb(l: f64, c: f64, h: f64) -> (u8, u8, u8) {
894    let h_rad = h.to_radians();
895    let a = c * h_rad.cos();
896    let b = c * h_rad.sin();
897
898    // OKLab → LMS'
899    let lms_l = l + 0.396_337_777_4 * a + 0.215_803_757_3 * b;
900    let lms_m = l - 0.105_561_345_8 * a - 0.063_854_172_8 * b;
901    let lms_s = l - 0.089_484_177_5 * a - 1.291_485_548_0 * b;
902
903    // Cube to linear LMS
904    let l_lms = lms_l * lms_l * lms_l;
905    let m_lms = lms_m * lms_m * lms_m;
906    let s_lms = lms_s * lms_s * lms_s;
907
908    // LMS → linear sRGB
909    let r_lin = 4.076_741_662_1 * l_lms - 3.307_711_591_3 * m_lms + 0.230_969_929_2 * s_lms;
910    let g_lin = -1.268_438_004_6 * l_lms + 2.609_757_401_1 * m_lms - 0.341_319_396_5 * s_lms;
911    let b_lin = -0.004_196_086_3 * l_lms - 0.703_418_614_7 * m_lms + 1.707_614_701_0 * s_lms;
912
913    (
914        (srgb_gamma(r_lin) * 255.0).round() as u8,
915        (srgb_gamma(g_lin) * 255.0).round() as u8,
916        (srgb_gamma(b_lin) * 255.0).round() as u8,
917    )
918}
919
920#[cfg(test)]
921mod tests {
922    use super::*;
923    use crate::ansi::{Color, Ground, NamedColor};
924
925    // Shadow the outer 3-arg parse_part so existing 2-arg test calls keep working.
926    fn parse_part(part: &str, position: usize) -> Result<Vec<TagType>, LexError> {
927        let mut out = Vec::new();
928        super::parse_part(part, position, &mut out).map(|_| out)
929    }
930
931    // --- EmphasisType::from_str ---
932
933    #[test]
934    fn test_emphasis_from_str_all_known() {
935        assert_eq!(EmphasisType::from_str("dim"), Some(EmphasisType::Dim));
936        assert_eq!(EmphasisType::from_str("italic"), Some(EmphasisType::Italic));
937        assert_eq!(
938            EmphasisType::from_str("underline"),
939            Some(EmphasisType::Underline)
940        );
941        assert_eq!(EmphasisType::from_str("bold"), Some(EmphasisType::Bold));
942        assert_eq!(
943            EmphasisType::from_str("strikethrough"),
944            Some(EmphasisType::Strikethrough)
945        );
946        assert_eq!(EmphasisType::from_str("blink"), Some(EmphasisType::Blink));
947    }
948
949    #[test]
950    fn test_emphasis_from_str_unknown_returns_none() {
951        assert_eq!(EmphasisType::from_str("flash"), None);
952    }
953
954    #[test]
955    fn test_emphasis_from_str_case_sensitive() {
956        assert_eq!(EmphasisType::from_str("Bold"), None);
957    }
958
959    // --- parse_part ---
960
961    #[test]
962    fn test_parse_part_reset() {
963        assert_eq!(parse_part("/", 0).unwrap(), vec![TagType::ResetAll]);
964    }
965
966    #[test]
967    fn test_parse_part_named_color_foreground_default() {
968        assert_eq!(
969            parse_part("red", 0).unwrap(),
970            vec![TagType::Color {
971                color: Color::Named(NamedColor::Red),
972                ground: Ground::Foreground,
973            }]
974        );
975    }
976
977    #[test]
978    fn test_parse_part_named_color_explicit_fg() {
979        assert_eq!(
980            parse_part("fg:red", 0).unwrap(),
981            vec![TagType::Color {
982                color: Color::Named(NamedColor::Red),
983                ground: Ground::Foreground,
984            }]
985        );
986    }
987
988    #[test]
989    fn test_parse_part_named_color_bg() {
990        assert_eq!(
991            parse_part("bg:red", 0).unwrap(),
992            vec![TagType::Color {
993                color: Color::Named(NamedColor::Red),
994                ground: Ground::Background,
995            }]
996        );
997    }
998
999    #[test]
1000    fn test_parse_part_emphasis_bold() {
1001        assert_eq!(
1002            parse_part("bold", 0).unwrap(),
1003            vec![TagType::Emphasis(EmphasisType::Bold)]
1004        );
1005    }
1006
1007    #[test]
1008    fn test_parse_part_ansi256_valid() {
1009        assert_eq!(
1010            parse_part("ansi(200)", 0).unwrap(),
1011            vec![TagType::Color {
1012                color: Color::Ansi256(200),
1013                ground: Ground::Foreground,
1014            }]
1015        );
1016    }
1017
1018    #[test]
1019    fn test_parse_part_ansi256_bg() {
1020        assert_eq!(
1021            parse_part("bg:ansi(200)", 0).unwrap(),
1022            vec![TagType::Color {
1023                color: Color::Ansi256(200),
1024                ground: Ground::Background,
1025            }]
1026        );
1027    }
1028
1029    #[test]
1030    fn test_parse_part_ansi256_with_whitespace() {
1031        assert_eq!(
1032            parse_part("ansi( 42 )", 0).unwrap(),
1033            vec![TagType::Color {
1034                color: Color::Ansi256(42),
1035                ground: Ground::Foreground,
1036            }]
1037        );
1038    }
1039
1040    #[test]
1041    fn test_parse_part_ansi256_invalid_value() {
1042        assert!(parse_part("ansi(abc)", 0).is_err());
1043    }
1044
1045    #[test]
1046    fn test_parse_part_rgb_valid() {
1047        assert_eq!(
1048            parse_part("rgb(255,128,0)", 0).unwrap(),
1049            vec![TagType::Color {
1050                color: Color::Rgb(255, 128, 0),
1051                ground: Ground::Foreground,
1052            }]
1053        );
1054    }
1055
1056    #[test]
1057    fn test_parse_part_rgb_bg() {
1058        assert_eq!(
1059            parse_part("bg:rgb(255,128,0)", 0).unwrap(),
1060            vec![TagType::Color {
1061                color: Color::Rgb(255, 128, 0),
1062                ground: Ground::Background,
1063            }]
1064        );
1065    }
1066
1067    #[test]
1068    fn test_parse_part_rgb_with_spaces() {
1069        assert_eq!(
1070            parse_part("rgb( 10 , 20 , 30 )", 0).unwrap(),
1071            vec![TagType::Color {
1072                color: Color::Rgb(10, 20, 30),
1073                ground: Ground::Foreground,
1074            }]
1075        );
1076    }
1077
1078    #[test]
1079    fn test_parse_part_rgb_wrong_arg_count() {
1080        let result = parse_part("rgb(1,2)", 0);
1081        assert!(result.is_err());
1082        if let Err(crate::errors::LexError::InvalidArgumentCount { expected, got, .. }) = result {
1083            assert_eq!(expected, 3);
1084            assert_eq!(got, 2);
1085        }
1086    }
1087
1088    #[test]
1089    fn test_parse_part_rgb_invalid_value() {
1090        assert!(parse_part("rgb(r,g,b)", 0).is_err());
1091    }
1092
1093    // --- hex (#fff / #ffffff) ---
1094
1095    #[test]
1096    fn test_parse_part_hex_6digit() {
1097        assert_eq!(
1098            parse_part("#ff0000", 0).unwrap(),
1099            vec![TagType::Color {
1100                color: Color::Rgb(255, 0, 0),
1101                ground: Ground::Foreground,
1102            }]
1103        );
1104    }
1105
1106    #[test]
1107    fn test_parse_part_hex_3digit() {
1108        assert_eq!(
1109            parse_part("#f00", 0).unwrap(),
1110            vec![TagType::Color {
1111                color: Color::Rgb(255, 0, 0),
1112                ground: Ground::Foreground,
1113            }]
1114        );
1115    }
1116
1117    #[test]
1118    fn test_parse_part_hex_bg() {
1119        assert_eq!(
1120            parse_part("bg:#ffffff", 0).unwrap(),
1121            vec![TagType::Color {
1122                color: Color::Rgb(255, 255, 255),
1123                ground: Ground::Background,
1124            }]
1125        );
1126    }
1127
1128    #[test]
1129    fn test_parse_part_hex_invalid_length() {
1130        assert!(parse_part("#ff", 0).is_err());
1131        assert!(parse_part("#ffff", 0).is_err());
1132        assert!(parse_part("#fffffff", 0).is_err());
1133    }
1134
1135    #[test]
1136    fn test_parse_part_hex_invalid_chars() {
1137        assert!(parse_part("#xyz", 0).is_err());
1138        assert!(parse_part("#xyzzzz", 0).is_err());
1139    }
1140
1141    #[test]
1142    fn test_parse_part_hex_empty() {
1143        assert!(parse_part("#", 0).is_err());
1144    }
1145
1146    // --- hsl(H,S,L) ---
1147
1148    #[test]
1149    fn test_parse_part_hsl_red() {
1150        assert_eq!(
1151            parse_part("hsl(0,100,50)", 0).unwrap(),
1152            vec![TagType::Color {
1153                color: Color::Rgb(255, 0, 0),
1154                ground: Ground::Foreground,
1155            }]
1156        );
1157    }
1158
1159    #[test]
1160    fn test_parse_part_hsl_green() {
1161        assert_eq!(
1162            parse_part("hsl(120,100,50)", 0).unwrap(),
1163            vec![TagType::Color {
1164                color: Color::Rgb(0, 255, 0),
1165                ground: Ground::Foreground,
1166            }]
1167        );
1168    }
1169
1170    #[test]
1171    fn test_parse_part_hsl_blue() {
1172        assert_eq!(
1173            parse_part("hsl(240,100,50)", 0).unwrap(),
1174            vec![TagType::Color {
1175                color: Color::Rgb(0, 0, 255),
1176                ground: Ground::Foreground,
1177            }]
1178        );
1179    }
1180
1181    #[test]
1182    fn test_parse_part_hsl_bg() {
1183        assert_eq!(
1184            parse_part("bg:hsl(0,100,50)", 0).unwrap(),
1185            vec![TagType::Color {
1186                color: Color::Rgb(255, 0, 0),
1187                ground: Ground::Background,
1188            }]
1189        );
1190    }
1191
1192    #[test]
1193    fn test_parse_part_hsl_wrong_arg_count() {
1194        let result = parse_part("hsl(0,50)", 0);
1195        assert!(result.is_err());
1196        if let Err(crate::errors::LexError::InvalidArgumentCount { expected, got, .. }) = result {
1197            assert_eq!(expected, 3);
1198            assert_eq!(got, 2);
1199        }
1200    }
1201
1202    #[test]
1203    fn test_parse_part_hsl_hue_out_of_range() {
1204        let result = parse_part("hsl(400,50,50)", 0);
1205        assert!(result.is_err());
1206    }
1207
1208    #[test]
1209    fn test_parse_part_hsl_sat_out_of_range() {
1210        let result = parse_part("hsl(0,150,50)", 0);
1211        assert!(result.is_err());
1212    }
1213
1214    #[test]
1215    fn test_parse_part_hsl_light_out_of_range() {
1216        let result = parse_part("hsl(0,50,110)", 0);
1217        assert!(result.is_err());
1218    }
1219
1220    #[test]
1221    fn test_parse_part_hsl_invalid_value() {
1222        assert!(parse_part("hsl(a,b,c)", 0).is_err());
1223    }
1224
1225    #[test]
1226    fn test_parse_part_hsl_unclosed() {
1227        assert!(parse_part("hsl(0,50,50", 0).is_err());
1228    }
1229
1230    #[test]
1231    fn test_parse_part_hsl_with_spaces() {
1232        assert_eq!(
1233            parse_part("hsl( 120 , 100 , 50 )", 0).unwrap(),
1234            vec![TagType::Color {
1235                color: Color::Rgb(0, 255, 0),
1236                ground: Ground::Foreground,
1237            }]
1238        );
1239    }
1240
1241    // --- hsv(H,S,V) ---
1242
1243    #[test]
1244    fn test_parse_part_hsv_red() {
1245        assert_eq!(
1246            parse_part("hsv(0,100,100)", 0).unwrap(),
1247            vec![TagType::Color {
1248                color: Color::Rgb(255, 0, 0),
1249                ground: Ground::Foreground,
1250            }]
1251        );
1252    }
1253
1254    #[test]
1255    fn test_parse_part_hsv_green() {
1256        assert_eq!(
1257            parse_part("hsv(120,100,100)", 0).unwrap(),
1258            vec![TagType::Color {
1259                color: Color::Rgb(0, 255, 0),
1260                ground: Ground::Foreground,
1261            }]
1262        );
1263    }
1264
1265    #[test]
1266    fn test_parse_part_hsv_blue() {
1267        assert_eq!(
1268            parse_part("hsv(240,100,100)", 0).unwrap(),
1269            vec![TagType::Color {
1270                color: Color::Rgb(0, 0, 255),
1271                ground: Ground::Foreground,
1272            }]
1273        );
1274    }
1275
1276    #[test]
1277    fn test_parse_part_hsv_gray() {
1278        assert_eq!(
1279            parse_part("hsv(0,0,50)", 0).unwrap(),
1280            vec![TagType::Color {
1281                color: Color::Rgb(128, 128, 128),
1282                ground: Ground::Foreground,
1283            }]
1284        );
1285    }
1286
1287    #[test]
1288    fn test_parse_part_hsv_bg() {
1289        assert_eq!(
1290            parse_part("bg:hsv(0,100,100)", 0).unwrap(),
1291            vec![TagType::Color {
1292                color: Color::Rgb(255, 0, 0),
1293                ground: Ground::Background,
1294            }]
1295        );
1296    }
1297
1298    #[test]
1299    fn test_parse_part_hsv_wrong_arg_count() {
1300        let result = parse_part("hsv(0,50)", 0);
1301        assert!(result.is_err());
1302        if let Err(crate::errors::LexError::InvalidArgumentCount { expected, got, .. }) = result {
1303            assert_eq!(expected, 3);
1304            assert_eq!(got, 2);
1305        }
1306    }
1307
1308    #[test]
1309    fn test_parse_part_hsv_hue_out_of_range() {
1310        assert!(parse_part("hsv(400,50,50)", 0).is_err());
1311    }
1312
1313    #[test]
1314    fn test_parse_part_hsv_sat_out_of_range() {
1315        assert!(parse_part("hsv(0,150,50)", 0).is_err());
1316    }
1317
1318    #[test]
1319    fn test_parse_part_hsv_val_out_of_range() {
1320        assert!(parse_part("hsv(0,50,110)", 0).is_err());
1321    }
1322
1323    #[test]
1324    fn test_parse_part_hsv_invalid_value() {
1325        assert!(parse_part("hsv(a,b,c)", 0).is_err());
1326    }
1327
1328    #[test]
1329    fn test_parse_part_hsv_unclosed() {
1330        assert!(parse_part("hsv(0,50,50", 0).is_err());
1331    }
1332
1333    #[test]
1334    fn test_parse_part_hsv_with_spaces() {
1335        assert_eq!(
1336            parse_part("hsv( 120 , 100 , 100 )", 0).unwrap(),
1337            vec![TagType::Color {
1338                color: Color::Rgb(0, 255, 0),
1339                ground: Ground::Foreground,
1340            }]
1341        );
1342    }
1343
1344    // --- hsb(H,S,B) alias ---
1345
1346    #[test]
1347    fn test_parse_part_hsb_alias() {
1348        assert_eq!(
1349            parse_part("hsb(0,100,100)", 0).unwrap(),
1350            parse_part("hsv(0,100,100)", 0).unwrap(),
1351        );
1352    }
1353
1354    // --- hwb(H,W,B) ---
1355
1356    #[test]
1357    fn test_parse_part_hwb_red() {
1358        assert_eq!(
1359            parse_part("hwb(0,0,0)", 0).unwrap(),
1360            vec![TagType::Color {
1361                color: Color::Rgb(255, 0, 0),
1362                ground: Ground::Foreground,
1363            }]
1364        );
1365    }
1366
1367    #[test]
1368    fn test_parse_part_hwb_white() {
1369        assert_eq!(
1370            parse_part("hwb(0,100,0)", 0).unwrap(),
1371            vec![TagType::Color {
1372                color: Color::Rgb(255, 255, 255),
1373                ground: Ground::Foreground,
1374            }]
1375        );
1376    }
1377
1378    #[test]
1379    fn test_parse_part_hwb_black() {
1380        assert_eq!(
1381            parse_part("hwb(0,0,100)", 0).unwrap(),
1382            vec![TagType::Color {
1383                color: Color::Rgb(0, 0, 0),
1384                ground: Ground::Foreground,
1385            }]
1386        );
1387    }
1388
1389    #[test]
1390    fn test_parse_part_hwb_pink() {
1391        assert_eq!(
1392            parse_part("hwb(0,50,0)", 0).unwrap(),
1393            vec![TagType::Color {
1394                color: Color::Rgb(255, 128, 128),
1395                ground: Ground::Foreground,
1396            }]
1397        );
1398    }
1399
1400    #[test]
1401    fn test_parse_part_hwb_wb_too_high() {
1402        assert!(parse_part("hwb(0,60,60)", 0).is_err());
1403    }
1404
1405    #[test]
1406    fn test_parse_part_hwb_bg() {
1407        assert_eq!(
1408            parse_part("bg:hwb(0,0,0)", 0).unwrap(),
1409            vec![TagType::Color {
1410                color: Color::Rgb(255, 0, 0),
1411                ground: Ground::Background,
1412            }]
1413        );
1414    }
1415
1416    // --- lab(L,a,b) ---
1417
1418    #[test]
1419    fn test_parse_part_lab_black() {
1420        assert_eq!(
1421            parse_part("lab(0,0,0)", 0).unwrap(),
1422            vec![TagType::Color {
1423                color: Color::Rgb(0, 0, 0),
1424                ground: Ground::Foreground,
1425            }]
1426        );
1427    }
1428
1429    #[test]
1430    fn test_parse_part_lab_white() {
1431        assert_eq!(
1432            parse_part("lab(100,0,0)", 0).unwrap(),
1433            vec![TagType::Color {
1434                color: Color::Rgb(255, 255, 255),
1435                ground: Ground::Foreground,
1436            }]
1437        );
1438    }
1439
1440    #[test]
1441    fn test_parse_part_lab_bg() {
1442        assert_eq!(
1443            parse_part("bg:lab(53,80,67)", 0).unwrap(),
1444            vec![TagType::Color {
1445                color: Color::Rgb(254, 0, 0),
1446                ground: Ground::Background,
1447            }]
1448        );
1449    }
1450
1451    #[test]
1452    fn test_parse_part_lab_wrong_arg_count() {
1453        assert!(parse_part("lab(50,20)", 0).is_err());
1454    }
1455
1456    #[test]
1457    fn test_parse_part_lab_l_out_of_range() {
1458        assert!(parse_part("lab(110,0,0)", 0).is_err());
1459    }
1460
1461    #[test]
1462    fn test_parse_part_lab_unclosed() {
1463        assert!(parse_part("lab(50,20,30", 0).is_err());
1464    }
1465
1466    // --- lch(L,C,H) ---
1467
1468    #[test]
1469    fn test_parse_part_lch_black() {
1470        assert_eq!(
1471            parse_part("lch(0,0,0)", 0).unwrap(),
1472            vec![TagType::Color {
1473                color: Color::Rgb(0, 0, 0),
1474                ground: Ground::Foreground,
1475            }]
1476        );
1477    }
1478
1479    #[test]
1480    fn test_parse_part_lch_white() {
1481        assert_eq!(
1482            parse_part("lch(100,0,0)", 0).unwrap(),
1483            vec![TagType::Color {
1484                color: Color::Rgb(255, 255, 255),
1485                ground: Ground::Foreground,
1486            }]
1487        );
1488    }
1489
1490    #[test]
1491    fn test_parse_part_lch_bg() {
1492        assert_eq!(
1493            parse_part("bg:lch(50,0,0)", 0).unwrap(),
1494            vec![TagType::Color {
1495                color: Color::Rgb(119, 119, 119),
1496                ground: Ground::Background,
1497            }]
1498        );
1499    }
1500
1501    #[test]
1502    fn test_parse_part_lch_chroma_out_of_range() {
1503        assert!(parse_part("lch(50,200,0)", 0).is_err());
1504    }
1505
1506    // --- oklch(L,C,H) ---
1507
1508    #[test]
1509    fn test_parse_part_oklch_black() {
1510        assert_eq!(
1511            parse_part("oklch(0,0,0)", 0).unwrap(),
1512            vec![TagType::Color {
1513                color: Color::Rgb(0, 0, 0),
1514                ground: Ground::Foreground,
1515            }]
1516        );
1517    }
1518
1519    #[test]
1520    fn test_parse_part_oklch_white() {
1521        assert_eq!(
1522            parse_part("oklch(1,0,0)", 0).unwrap(),
1523            vec![TagType::Color {
1524                color: Color::Rgb(255, 255, 255),
1525                ground: Ground::Foreground,
1526            }]
1527        );
1528    }
1529
1530    #[test]
1531    fn test_parse_part_oklch_l_out_of_range() {
1532        assert!(parse_part("oklch(1.5,0,0)", 0).is_err());
1533    }
1534
1535    #[test]
1536    fn test_parse_part_oklch_chroma_out_of_range() {
1537        assert!(parse_part("oklch(0.5,0.5,0)", 0).is_err());
1538    }
1539
1540    // --- tokenize ---
1541
1542    #[test]
1543    fn test_parse_part_unknown_tag_returns_error() {
1544        assert!(parse_part("fuchsia", 0).is_err());
1545    }
1546
1547    // --- tokenize ---
1548
1549    #[test]
1550    fn test_tokenize_plain_text() {
1551        let tokens = tokenize("hello world").unwrap();
1552        assert_eq!(tokens, vec![Token::Text("hello world".into())]);
1553    }
1554
1555    #[test]
1556    fn test_tokenize_empty_string() {
1557        assert!(tokenize("").unwrap().is_empty());
1558    }
1559
1560    #[test]
1561    fn test_tokenize_single_color_tag() {
1562        let tokens = tokenize("[red]text").unwrap();
1563        assert_eq!(
1564            tokens,
1565            vec![
1566                Token::Tag(TagType::Color {
1567                    color: Color::Named(NamedColor::Red),
1568                    ground: Ground::Foreground
1569                }),
1570                Token::Text("text".into()),
1571            ]
1572        );
1573    }
1574
1575    #[test]
1576    fn test_tokenize_bg_color_tag() {
1577        let tokens = tokenize("[bg:red]text").unwrap();
1578        assert_eq!(
1579            tokens,
1580            vec![
1581                Token::Tag(TagType::Color {
1582                    color: Color::Named(NamedColor::Red),
1583                    ground: Ground::Background
1584                }),
1585                Token::Text("text".into()),
1586            ]
1587        );
1588    }
1589
1590    #[test]
1591    fn test_tokenize_fg_and_bg_in_same_bracket() {
1592        let tokens = tokenize("[fg:white bg:blue]text").unwrap();
1593        assert_eq!(
1594            tokens,
1595            vec![
1596                Token::Tag(TagType::Color {
1597                    color: Color::Named(NamedColor::White),
1598                    ground: Ground::Foreground
1599                }),
1600                Token::Tag(TagType::Color {
1601                    color: Color::Named(NamedColor::Blue),
1602                    ground: Ground::Background
1603                }),
1604                Token::Text("text".into()),
1605            ]
1606        );
1607    }
1608
1609    #[test]
1610    fn test_tokenize_reset_tag() {
1611        assert_eq!(
1612            tokenize("[/]").unwrap(),
1613            vec![Token::Tag(TagType::ResetAll)]
1614        );
1615    }
1616
1617    #[test]
1618    fn test_tokenize_compound_tag() {
1619        let tokens = tokenize("[bold red]hi").unwrap();
1620        assert_eq!(
1621            tokens,
1622            vec![
1623                Token::Tag(TagType::Emphasis(EmphasisType::Bold)),
1624                Token::Tag(TagType::Color {
1625                    color: Color::Named(NamedColor::Red),
1626                    ground: Ground::Foreground
1627                }),
1628                Token::Text("hi".into()),
1629            ]
1630        );
1631    }
1632
1633    #[test]
1634    fn test_tokenize_double_bracket_escape() {
1635        let tokens = tokenize("[[not a tag]").unwrap();
1636        assert_eq!(
1637            tokens,
1638            vec![
1639                Token::Text("[".into()),
1640                Token::Text("not a tag".into()),
1641                Token::Text("]".into()),
1642            ]
1643        );
1644    }
1645
1646    #[test]
1647    fn test_tokenize_double_bracket_escape_with_prefix() {
1648        let tokens = tokenize("before[[not a tag]").unwrap();
1649        assert_eq!(
1650            tokens,
1651            vec![
1652                Token::Text("before".into()),
1653                Token::Text("[".into()),
1654                Token::Text("not a tag".into()),
1655                Token::Text("]".into()),
1656            ]
1657        );
1658    }
1659
1660    #[test]
1661    fn test_tokenize_double_bracket_symmetric() {
1662        let tokens = tokenize("[[thing]]").unwrap();
1663        assert_eq!(
1664            tokens,
1665            vec![
1666                Token::Text("[".into()),
1667                Token::Text("thing".into()),
1668                Token::Text("]".into()),
1669            ]
1670        );
1671    }
1672
1673    #[test]
1674    fn test_tokenize_bare_close_bracket_is_text() {
1675        let tokens = tokenize("hello]world").unwrap();
1676        assert_eq!(
1677            tokens,
1678            vec![
1679                Token::Text("hello".into()),
1680                Token::Text("]".into()),
1681                Token::Text("world".into()),
1682            ]
1683        );
1684    }
1685
1686    #[test]
1687    fn test_tokenize_double_close_bracket_emits_one() {
1688        let tokens = tokenize("]]").unwrap();
1689        assert_eq!(tokens, vec![Token::Text("]".into())]);
1690    }
1691
1692    #[test]
1693    fn test_tokenize_triple_close_bracket_emits_two() {
1694        let tokens = tokenize("]]]").unwrap();
1695        assert_eq!(
1696            tokens,
1697            vec![Token::Text("]".into()), Token::Text("]".into())]
1698        );
1699    }
1700
1701    #[test]
1702    fn test_tokenize_unclosed_tag_returns_error() {
1703        assert!(tokenize("[red").is_err());
1704    }
1705
1706    #[test]
1707    fn test_tokenize_invalid_tag_name_returns_error() {
1708        assert!(tokenize("[fuchsia]").is_err());
1709    }
1710
1711    #[test]
1712    fn test_tokenize_text_before_and_after_tag() {
1713        let tokens = tokenize("before[red]after").unwrap();
1714        assert_eq!(
1715            tokens,
1716            vec![
1717                Token::Text("before".into()),
1718                Token::Tag(TagType::Color {
1719                    color: Color::Named(NamedColor::Red),
1720                    ground: Ground::Foreground
1721                }),
1722                Token::Text("after".into()),
1723            ]
1724        );
1725    }
1726
1727    #[test]
1728    fn test_tokenize_ansi256_tag() {
1729        let tokens = tokenize("[ansi(1)]text").unwrap();
1730        assert_eq!(
1731            tokens[0],
1732            Token::Tag(TagType::Color {
1733                color: Color::Ansi256(1),
1734                ground: Ground::Foreground,
1735            })
1736        );
1737    }
1738
1739    #[test]
1740    fn test_split_tag_parts_simple() {
1741        assert_eq!(
1742            split_tag_parts("bold red"),
1743            vec![(0usize, "bold"), (5, "red")]
1744        );
1745    }
1746
1747    #[test]
1748    fn test_split_tag_parts_respects_parens() {
1749        assert_eq!(
1750            split_tag_parts("rgb(1, 2, 3)"),
1751            vec![(0usize, "rgb(1, 2, 3)")]
1752        );
1753    }
1754
1755    #[test]
1756    fn test_split_tag_parts_mixed() {
1757        assert_eq!(
1758            split_tag_parts("bold rgb(255, 128, 0)"),
1759            vec![(0usize, "bold"), (5, "rgb(255, 128, 0)")]
1760        );
1761    }
1762
1763    #[test]
1764    fn test_split_tag_parts_ansi_with_spaces() {
1765        assert_eq!(
1766            split_tag_parts("fg:ansi( 93 )"),
1767            vec![(0usize, "fg:ansi( 93 )")]
1768        );
1769    }
1770
1771    #[test]
1772    fn test_tokenize_rgb_with_spaces_inside_parens() {
1773        let tokens = tokenize("[rgb(1, 2, 3)]text").unwrap();
1774        assert_eq!(
1775            tokens[0],
1776            Token::Tag(TagType::Color {
1777                color: Color::Rgb(1, 2, 3),
1778                ground: Ground::Foreground,
1779            })
1780        );
1781    }
1782
1783    #[test]
1784    fn test_tokenize_mixed_bold_rgb_with_spaces() {
1785        let tokens = tokenize("[bold rgb(255, 128, 0)]text").unwrap();
1786        assert_eq!(
1787            tokens,
1788            vec![
1789                Token::Tag(TagType::Emphasis(EmphasisType::Bold)),
1790                Token::Tag(TagType::Color {
1791                    color: Color::Rgb(255, 128, 0),
1792                    ground: Ground::Foreground,
1793                }),
1794                Token::Text("text".into()),
1795            ]
1796        );
1797    }
1798
1799    #[test]
1800    fn test_tokenize_rgb_tag() {
1801        let tokens = tokenize("[rgb(255,0,128)]text").unwrap();
1802        assert_eq!(
1803            tokens[0],
1804            Token::Tag(TagType::Color {
1805                color: Color::Rgb(255, 0, 128),
1806                ground: Ground::Foreground,
1807            })
1808        );
1809    }
1810
1811    #[test]
1812    fn test_tokenize_bg_rgb_tag() {
1813        let tokens = tokenize("[bg:rgb(0,255,0)]text").unwrap();
1814        assert_eq!(
1815            tokens[0],
1816            Token::Tag(TagType::Color {
1817                color: Color::Rgb(0, 255, 0),
1818                ground: Ground::Background,
1819            })
1820        );
1821    }
1822
1823    // --- tokenization: hex ---
1824
1825    #[test]
1826    fn test_tokenize_hex_tag() {
1827        let tokens = tokenize("[#ff0000]text").unwrap();
1828        assert_eq!(
1829            tokens[0],
1830            Token::Tag(TagType::Color {
1831                color: Color::Rgb(255, 0, 0),
1832                ground: Ground::Foreground,
1833            })
1834        );
1835    }
1836
1837    #[test]
1838    fn test_tokenize_hex_3digit_tag() {
1839        let tokens = tokenize("[#f00]text").unwrap();
1840        assert_eq!(
1841            tokens[0],
1842            Token::Tag(TagType::Color {
1843                color: Color::Rgb(255, 0, 0),
1844                ground: Ground::Foreground,
1845            })
1846        );
1847    }
1848
1849    #[test]
1850    fn test_tokenize_bg_hex_tag() {
1851        let tokens = tokenize("[bg:#fff]text").unwrap();
1852        assert_eq!(
1853            tokens[0],
1854            Token::Tag(TagType::Color {
1855                color: Color::Rgb(255, 255, 255),
1856                ground: Ground::Background,
1857            })
1858        );
1859    }
1860
1861    // --- tokenization: hsl ---
1862
1863    #[test]
1864    fn test_tokenize_hsl_tag() {
1865        let tokens = tokenize("[hsl(0,100,50)]text").unwrap();
1866        assert_eq!(
1867            tokens[0],
1868            Token::Tag(TagType::Color {
1869                color: Color::Rgb(255, 0, 0),
1870                ground: Ground::Foreground,
1871            })
1872        );
1873    }
1874
1875    #[test]
1876    fn test_tokenize_bg_hsl_tag() {
1877        let tokens = tokenize("[bg:hsl(0,100,50)]text").unwrap();
1878        assert_eq!(
1879            tokens[0],
1880            Token::Tag(TagType::Color {
1881                color: Color::Rgb(255, 0, 0),
1882                ground: Ground::Background,
1883            })
1884        );
1885    }
1886
1887    #[test]
1888    fn test_tokenize_hsl_with_spaces() {
1889        let tokens = tokenize("[hsl( 120 , 100 , 50 )]text").unwrap();
1890        assert_eq!(
1891            tokens[0],
1892            Token::Tag(TagType::Color {
1893                color: Color::Rgb(0, 255, 0),
1894                ground: Ground::Foreground,
1895            })
1896        );
1897    }
1898
1899    #[test]
1900    fn test_tokenize_mixed_bold_hsl() {
1901        let tokens = tokenize("[bold hsl(0,100,50)]text").unwrap();
1902        assert_eq!(
1903            tokens,
1904            vec![
1905                Token::Tag(TagType::Emphasis(EmphasisType::Bold)),
1906                Token::Tag(TagType::Color {
1907                    color: Color::Rgb(255, 0, 0),
1908                    ground: Ground::Foreground,
1909                }),
1910                Token::Text("text".into()),
1911            ]
1912        );
1913    }
1914
1915    // --- tokenization: hsv ---
1916
1917    #[test]
1918    fn test_tokenize_hsv_tag() {
1919        let tokens = tokenize("[hsv(0,100,100)]text").unwrap();
1920        assert_eq!(
1921            tokens[0],
1922            Token::Tag(TagType::Color {
1923                color: Color::Rgb(255, 0, 0),
1924                ground: Ground::Foreground,
1925            })
1926        );
1927    }
1928
1929    #[test]
1930    fn test_tokenize_bg_hsv_tag() {
1931        let tokens = tokenize("[bg:hsv(120,100,100)]text").unwrap();
1932        assert_eq!(
1933            tokens[0],
1934            Token::Tag(TagType::Color {
1935                color: Color::Rgb(0, 255, 0),
1936                ground: Ground::Background,
1937            })
1938        );
1939    }
1940
1941    // --- tokenization: hsb (alias) ---
1942
1943    #[test]
1944    fn test_tokenize_hsb_tag() {
1945        let tokens = tokenize("[hsb(0,100,100)]text").unwrap();
1946        assert_eq!(
1947            tokens[0],
1948            Token::Tag(TagType::Color {
1949                color: Color::Rgb(255, 0, 0),
1950                ground: Ground::Foreground,
1951            })
1952        );
1953    }
1954
1955    // --- tokenization: hwb ---
1956
1957    #[test]
1958    fn test_tokenize_hwb_tag() {
1959        let tokens = tokenize("[hwb(0,0,0)]text").unwrap();
1960        assert_eq!(
1961            tokens[0],
1962            Token::Tag(TagType::Color {
1963                color: Color::Rgb(255, 0, 0),
1964                ground: Ground::Foreground,
1965            })
1966        );
1967    }
1968
1969    #[test]
1970    fn test_tokenize_bg_hwb_tag() {
1971        let tokens = tokenize("[bg:hwb(0,0,0)]text").unwrap();
1972        assert_eq!(
1973            tokens[0],
1974            Token::Tag(TagType::Color {
1975                color: Color::Rgb(255, 0, 0),
1976                ground: Ground::Background,
1977            })
1978        );
1979    }
1980
1981    // --- tokenization: lab ---
1982
1983    #[test]
1984    fn test_tokenize_lab_tag() {
1985        let tokens = tokenize("[lab(0,0,0)]text").unwrap();
1986        assert_eq!(
1987            tokens[0],
1988            Token::Tag(TagType::Color {
1989                color: Color::Rgb(0, 0, 0),
1990                ground: Ground::Foreground,
1991            })
1992        );
1993    }
1994
1995    #[test]
1996    fn test_tokenize_bg_lab_tag() {
1997        let tokens = tokenize("[bg:lab(100,0,0)]text").unwrap();
1998        assert_eq!(
1999            tokens[0],
2000            Token::Tag(TagType::Color {
2001                color: Color::Rgb(255, 255, 255),
2002                ground: Ground::Background,
2003            })
2004        );
2005    }
2006
2007    // --- tokenization: lch ---
2008
2009    #[test]
2010    fn test_tokenize_lch_tag() {
2011        let tokens = tokenize("[lch(0,0,0)]text").unwrap();
2012        assert_eq!(
2013            tokens[0],
2014            Token::Tag(TagType::Color {
2015                color: Color::Rgb(0, 0, 0),
2016                ground: Ground::Foreground,
2017            })
2018        );
2019    }
2020
2021    // --- tokenization: oklch ---
2022
2023    #[test]
2024    fn test_tokenize_oklch_tag() {
2025        let tokens = tokenize("[oklch(0,0,0)]text").unwrap();
2026        assert_eq!(
2027            tokens[0],
2028            Token::Tag(TagType::Color {
2029                color: Color::Rgb(0, 0, 0),
2030                ground: Ground::Foreground,
2031            })
2032        );
2033    }
2034
2035    #[test]
2036    fn test_tokenize_bg_oklch_tag() {
2037        let tokens = tokenize("[bg:oklch(1,0,0)]text").unwrap();
2038        assert_eq!(
2039            tokens[0],
2040            Token::Tag(TagType::Color {
2041                color: Color::Rgb(255, 255, 255),
2042                ground: Ground::Background,
2043            })
2044        );
2045    }
2046
2047    #[test]
2048    fn test_parse_part_custom_style_from_registry() {
2049        crate::registry::insert_style("danger", crate::ansi::Style::parse("[bold red]").unwrap())
2050            .unwrap();
2051        let result = parse_part("danger", 0).unwrap();
2052        assert_eq!(
2053            result,
2054            vec![
2055                TagType::Emphasis(EmphasisType::Bold),
2056                TagType::Color {
2057                    color: Color::Named(NamedColor::Red),
2058                    ground: Ground::Foreground
2059                },
2060            ]
2061        );
2062    }
2063}