Skip to main content

omena_value_lattice/
lib.rs

1//! Region-local CSS value lens and canonical value equality substrate.
2//!
3//! This crate intentionally accepts declaration value slices plus a base byte
4//! offset. It does not accept rules, declarations, or stylesheets, so whole-doc
5//! property analysis stays unrepresentable at this layer.
6
7use omena_parser::StyleDialect;
8
9mod token_stream;
10
11pub use token_stream::{
12    CssValueComponentKindV0, CssValueComponentV0, CssValueTokenStreamErrorV0,
13    css_value_component_stream,
14};
15
16pub mod color;
17mod color_names;
18mod functions;
19pub mod number;
20
21pub use color::{
22    StaticSrgbColorWithAlpha, can_shorten_hex_pairs, compress_hex_color_token_text,
23    is_relative_color_form, parse_basic_named_static_color_with_alpha, parse_color_function_value,
24    parse_color_mix_value, parse_oklab_oklch_value, parse_relative_color_value,
25    parse_static_hsl_function_color_with_alpha, parse_static_hwb_function_color_with_alpha,
26    parse_static_rgb_function_color_with_alpha, parse_static_srgb_color,
27    parse_static_srgb_color_with_alpha, relative_color_channel_names, shorten_hex_pairs,
28    shortest_static_srgb_color_with_alpha_text,
29};
30pub use functions::{
31    StaticCssFunctionParser, StaticCssFunctionSpec, matching_function_call_end,
32    parse_whole_function_value_arguments, parse_whole_function_value_inner,
33    split_top_level_value_arguments as split_top_level_value_arguments_owned,
34    split_top_level_whitespace_value_components as split_top_level_whitespace_value_components_owned,
35    substitute_static_css_function_references_in_value,
36    substitute_static_css_function_references_in_value_until_stable,
37};
38pub use number::{
39    compress_number_prefix, compress_numeric_token_text, format_css_number, numeric_prefix_end,
40    parse_numeric_value_with_unit, parse_reducible_abs_value, parse_reducible_calc_value,
41    parse_reducible_ceil_value, parse_reducible_clamp_value, parse_reducible_exp_value,
42    parse_reducible_floor_value, parse_reducible_hypot_value, parse_reducible_log_value,
43    parse_reducible_max_value, parse_reducible_min_value, parse_reducible_mod_value,
44    parse_reducible_pow_value, parse_reducible_rem_value, parse_reducible_round_to_integer_value,
45    parse_reducible_round_value, parse_reducible_sign_value, parse_reducible_sqrt_value,
46    reduce_static_numeric_expression,
47};
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub struct ValueByteSpanV0 {
51    pub start: usize,
52    pub end: usize,
53}
54
55impl ValueByteSpanV0 {
56    pub const fn new(start: usize, end: usize) -> Self {
57        Self { start, end }
58    }
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub struct ValueSegmentV0<'a> {
63    pub text: &'a str,
64    pub span: ValueByteSpanV0,
65}
66
67#[derive(Debug, Clone, Copy, PartialEq)]
68pub struct NumericValueV0<'a> {
69    pub value: f64,
70    pub unit: &'a str,
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub struct SrgbColor {
75    pub red: u8,
76    pub green: u8,
77    pub blue: u8,
78}
79
80pub fn parse_basic_named_srgb_color(text: &str) -> Option<SrgbColor> {
81    color_names::parse_basic_named_srgb_color(text)
82}
83
84pub fn shortest_named_srgb_color(color: SrgbColor) -> Option<&'static str> {
85    color_names::shortest_named_srgb_color(color)
86}
87
88#[derive(Debug, Clone, PartialEq)]
89pub enum ValueNodeV0<'a> {
90    Raw {
91        span: ValueByteSpanV0,
92        text: &'a str,
93    },
94    Number {
95        span: ValueByteSpanV0,
96        value: NumericValueV0<'a>,
97    },
98    Color {
99        span: ValueByteSpanV0,
100        text: &'a str,
101    },
102    Keyword {
103        span: ValueByteSpanV0,
104        text: &'a str,
105    },
106    List {
107        span: ValueByteSpanV0,
108        items: Vec<usize>,
109    },
110    Function {
111        span: ValueByteSpanV0,
112        name: &'a str,
113        arguments: Vec<usize>,
114    },
115    SassMap {
116        span: ValueByteSpanV0,
117        text: &'a str,
118    },
119    SassList {
120        span: ValueByteSpanV0,
121        items: Vec<usize>,
122    },
123}
124
125#[derive(Debug, Clone, PartialEq)]
126pub struct DeclarationValueLensV0<'a> {
127    source: &'a str,
128    base_offset: usize,
129    root_node: usize,
130    nodes: Vec<ValueNodeV0<'a>>,
131}
132
133impl<'a> DeclarationValueLensV0<'a> {
134    pub fn source(&self) -> &'a str {
135        self.source
136    }
137
138    pub const fn base_offset(&self) -> usize {
139        self.base_offset
140    }
141
142    pub fn root(&self) -> &ValueNodeV0<'a> {
143        &self.nodes[self.root_node]
144    }
145
146    pub fn nodes(&self) -> &[ValueNodeV0<'a>] {
147        self.nodes.as_slice()
148    }
149}
150
151#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
152pub struct CanonicalCssValueV0 {
153    pub serialized: String,
154}
155
156pub fn declaration_value_lens(value: &str, base_offset: usize) -> DeclarationValueLensV0<'_> {
157    let span = ValueByteSpanV0::new(base_offset, base_offset + value.len());
158    let mut nodes = Vec::new();
159    let root_node = css_value_component_stream(value, base_offset)
160        .ok()
161        .filter(|components| !components.is_empty())
162        .map(|components| push_component_list(value, base_offset, &components, &mut nodes))
163        .unwrap_or_else(|| push_value_node(value, span, &mut nodes));
164    DeclarationValueLensV0 {
165        source: value,
166        base_offset,
167        root_node,
168        nodes,
169    }
170}
171
172fn push_component_list<'a>(
173    source: &'a str,
174    base_offset: usize,
175    components: &[CssValueComponentV0],
176    nodes: &mut Vec<ValueNodeV0<'a>>,
177) -> usize {
178    if let [component] = components {
179        return push_component_node(source, base_offset, component, nodes);
180    }
181    let items = components
182        .iter()
183        .map(|component| push_component_node(source, base_offset, component, nodes))
184        .collect();
185    let span = component_list_span(components, base_offset, source.len());
186    nodes.push(ValueNodeV0::List { span, items });
187    nodes.len() - 1
188}
189
190fn push_component_node<'a>(
191    source: &'a str,
192    base_offset: usize,
193    component: &CssValueComponentV0,
194    nodes: &mut Vec<ValueNodeV0<'a>>,
195) -> usize {
196    let text = component_source_slice(source, base_offset, component.span);
197    match &component.kind {
198        CssValueComponentKindV0::Function { arguments, .. } => {
199            let argument_nodes = arguments
200                .iter()
201                .map(|argument| push_component_node(source, base_offset, argument, nodes))
202                .collect();
203            let name = text.split_once('(').map_or(text, |(name, _)| name);
204            nodes.push(ValueNodeV0::Function {
205                span: component.span,
206                name,
207                arguments: argument_nodes,
208            });
209            nodes.len() - 1
210        }
211        CssValueComponentKindV0::Parenthesized { values }
212        | CssValueComponentKindV0::Bracketed { values }
213        | CssValueComponentKindV0::Braced { values } => {
214            let items = values
215                .iter()
216                .map(|value| push_component_node(source, base_offset, value, nodes))
217                .collect();
218            nodes.push(ValueNodeV0::List {
219                span: component.span,
220                items,
221            });
222            nodes.len() - 1
223        }
224        _ => push_value_node(text, component.span, nodes),
225    }
226}
227
228fn component_source_slice(source: &str, base_offset: usize, span: ValueByteSpanV0) -> &str {
229    let start = span.start.saturating_sub(base_offset).min(source.len());
230    let end = span.end.saturating_sub(base_offset).min(source.len());
231    source.get(start..end).unwrap_or(source)
232}
233
234fn component_list_span(
235    components: &[CssValueComponentV0],
236    base_offset: usize,
237    source_len: usize,
238) -> ValueByteSpanV0 {
239    match (components.first(), components.last()) {
240        (Some(first), Some(last)) => ValueByteSpanV0::new(first.span.start, last.span.end),
241        _ => ValueByteSpanV0::new(base_offset, base_offset + source_len),
242    }
243}
244
245pub fn css_values_canonically_equal(left: &str, right: &str) -> bool {
246    if left.trim() == right.trim() {
247        return true;
248    }
249    let Some(left) = canonicalize_css_value(left) else {
250        return false;
251    };
252    let Some(right) = canonicalize_css_value(right) else {
253        return false;
254    };
255    left == right
256}
257
258pub fn css_number_is_zero(number: &str) -> bool {
259    number::css_number_is_zero(number)
260}
261
262pub fn canonicalize_css_value(value: &str) -> Option<CanonicalCssValueV0> {
263    let value = value.trim();
264    if value.is_empty() {
265        return None;
266    }
267    if is_static_quoted_string_value(value) {
268        return Some(CanonicalCssValueV0 {
269            serialized: value.to_string(),
270        });
271    }
272    if value.contains("var(")
273        || value.contains("VAR(")
274        || value.contains("env(")
275        || value.contains("ENV(")
276    {
277        return None;
278    }
279    if let Some(serialized) = canonicalize_static_color_value(value) {
280        return Some(CanonicalCssValueV0 { serialized });
281    }
282    if value.contains('(') || value.contains(')') {
283        return None;
284    }
285    if let Some(serialized) = canonicalize_static_whitespace_list_value(value) {
286        return Some(CanonicalCssValueV0 { serialized });
287    }
288    if is_css_keyword_like(value) {
289        return Some(CanonicalCssValueV0 {
290            serialized: value.to_string(),
291        });
292    }
293
294    let numeric = parse_numeric_value(value)?;
295    canonicalize_numeric_value(numeric)
296}
297
298fn canonicalize_static_color_value(value: &str) -> Option<String> {
299    parse_static_srgb_color_with_alpha(value)
300        .or_else(|| parse_static_rgb_function_color_with_alpha(value))
301        .or_else(|| parse_static_hsl_function_color_with_alpha(value))
302        .or_else(|| parse_static_hwb_function_color_with_alpha(value))
303        .map(shortest_static_srgb_color_with_alpha_text)
304        .or_else(|| {
305            parse_color_function_value(value)
306                .or_else(|| parse_color_mix_value(value))
307                .or_else(|| parse_oklab_oklch_value(value))
308                .and_then(|value| canonicalize_static_color_value(value.as_str()))
309        })
310}
311
312pub fn split_top_level_value_arguments<'a>(
313    inner: &'a str,
314    base_offset: usize,
315) -> Option<Vec<ValueSegmentV0<'a>>> {
316    split_top_level_segments(inner, base_offset, SegmentDelimiterV0::Comma)
317}
318
319pub fn split_top_level_whitespace_value_components<'a>(
320    value: &'a str,
321    base_offset: usize,
322) -> Option<Vec<ValueSegmentV0<'a>>> {
323    split_top_level_segments(value, base_offset, SegmentDelimiterV0::Whitespace)
324}
325
326fn push_value_node<'a>(
327    value: &'a str,
328    span: ValueByteSpanV0,
329    nodes: &mut Vec<ValueNodeV0<'a>>,
330) -> usize {
331    let node = if let Some(number) = parse_numeric_value(value) {
332        ValueNodeV0::Number {
333            span,
334            value: number,
335        }
336    } else if looks_like_unknown_function(value) {
337        ValueNodeV0::Raw { span, text: value }
338    } else if looks_like_sass_collection(value) {
339        ValueNodeV0::SassList {
340            span,
341            items: Vec::new(),
342        }
343    } else if value.starts_with('#') {
344        ValueNodeV0::Color { span, text: value }
345    } else if is_css_keyword_like(value) {
346        ValueNodeV0::Keyword { span, text: value }
347    } else {
348        ValueNodeV0::Raw { span, text: value }
349    };
350    nodes.push(node);
351    nodes.len() - 1
352}
353
354fn canonicalize_numeric_value(value: NumericValueV0<'_>) -> Option<CanonicalCssValueV0> {
355    let unit = value.unit.trim();
356    let normalized_unit = unit.to_ascii_lowercase();
357    if !value.value.is_finite() {
358        return None;
359    }
360    if value.value == 0.0 {
361        if unit.is_empty() || is_absolute_zero_collapsible_unit(&normalized_unit) {
362            return Some(CanonicalCssValueV0 {
363                serialized: "0".to_string(),
364            });
365        }
366        if normalized_unit == "%" {
367            return Some(CanonicalCssValueV0 {
368                serialized: "0%".to_string(),
369            });
370        }
371        if is_serializable_numeric_unit(&normalized_unit) {
372            return Some(CanonicalCssValueV0 {
373                serialized: format!("0{normalized_unit}"),
374            });
375        }
376        return None;
377    }
378    if normalized_unit == "%" {
379        return Some(CanonicalCssValueV0 {
380            serialized: format!("{}%", format_css_number(value.value)),
381        });
382    }
383    if unit.is_empty() || is_serializable_numeric_unit(&normalized_unit) {
384        return Some(CanonicalCssValueV0 {
385            serialized: format!("{}{}", format_css_number(value.value), normalized_unit),
386        });
387    }
388    None
389}
390
391fn parse_numeric_value(text: &str) -> Option<NumericValueV0<'_>> {
392    number::parse_numeric_value_with_unit(text)
393}
394
395fn is_absolute_zero_collapsible_unit(unit: &str) -> bool {
396    matches!(
397        unit,
398        "px" | "cm" | "mm" | "q" | "in" | "pc" | "pt" | "deg" | "grad" | "rad" | "turn"
399    )
400}
401
402fn is_serializable_numeric_unit(unit: &str) -> bool {
403    is_absolute_zero_collapsible_unit(unit) || matches!(unit, "s" | "ms")
404}
405
406/// Container-query length units (`cqw cqh cqi cqb cqmin cqmax`).
407///
408/// A closed, context-relative family: like `em`/`rem` they are never collapsed
409/// to `0` or to each other and never folded into a shorthand. Recognition
410/// vocabulary for container-query lowering — not a new canonicalization rule.
411pub fn is_container_query_length_unit(unit: &str) -> bool {
412    matches!(
413        unit.to_ascii_lowercase().as_str(),
414        "cqw" | "cqh" | "cqi" | "cqb" | "cqmin" | "cqmax"
415    )
416}
417
418fn canonicalize_static_whitespace_list_value(value: &str) -> Option<String> {
419    let segments = split_top_level_whitespace_value_components(value, 0)?;
420    if segments.len() < 2 {
421        return None;
422    }
423    segments
424        .into_iter()
425        .map(|segment| canonicalize_css_value(segment.text).map(|value| value.serialized))
426        .collect::<Option<Vec<_>>>()
427        .map(|values| values.join(" "))
428}
429
430fn is_static_quoted_string_value(value: &str) -> bool {
431    let Some(quote) = value.chars().next() else {
432        return false;
433    };
434    if quote != '"' && quote != '\'' {
435        return false;
436    }
437    let mut escaped = false;
438    let mut chars = value.char_indices().skip(1).peekable();
439    while let Some((index, ch)) = chars.next() {
440        if escaped {
441            escaped = false;
442            continue;
443        }
444        if ch == '\\' {
445            escaped = true;
446            continue;
447        }
448        if ch == quote {
449            return index + ch.len_utf8() == value.len() && chars.peek().is_none();
450        }
451    }
452    false
453}
454
455fn looks_like_unknown_function(value: &str) -> bool {
456    value.contains('(') || value.contains(')')
457}
458
459fn looks_like_sass_collection(value: &str) -> bool {
460    value.contains('{') || value.contains('}')
461}
462
463fn is_css_keyword_like(value: &str) -> bool {
464    value
465        .chars()
466        .all(|ch| ch == '-' || ch == '_' || ch.is_ascii_alphabetic())
467}
468
469#[derive(Debug, Clone, Copy, PartialEq, Eq)]
470enum SegmentDelimiterV0 {
471    Comma,
472    Whitespace,
473}
474
475fn split_top_level_segments<'a>(
476    value: &'a str,
477    base_offset: usize,
478    delimiter: SegmentDelimiterV0,
479) -> Option<Vec<ValueSegmentV0<'a>>> {
480    let mut segments = Vec::new();
481    let mut start = 0usize;
482    let mut index = 0usize;
483    let mut paren_depth = 0usize;
484    let mut bracket_depth = 0usize;
485    let mut brace_depth = 0usize;
486    let mut quote: Option<char> = None;
487    let mut escaped = false;
488
489    while index < value.len() {
490        let ch = value[index..].chars().next()?;
491        if let Some(active_quote) = quote {
492            index += ch.len_utf8();
493            if escaped {
494                escaped = false;
495            } else if ch == '\\' {
496                escaped = true;
497            } else if ch == active_quote {
498                quote = None;
499            }
500            continue;
501        }
502
503        match ch {
504            '"' | '\'' => {
505                quote = Some(ch);
506                index += ch.len_utf8();
507            }
508            '(' => {
509                paren_depth += 1;
510                index += ch.len_utf8();
511            }
512            ')' => {
513                paren_depth = paren_depth.checked_sub(1)?;
514                index += ch.len_utf8();
515            }
516            '[' => {
517                bracket_depth += 1;
518                index += ch.len_utf8();
519            }
520            ']' => {
521                bracket_depth = bracket_depth.checked_sub(1)?;
522                index += ch.len_utf8();
523            }
524            '{' => {
525                brace_depth += 1;
526                index += ch.len_utf8();
527            }
528            '}' => {
529                brace_depth = brace_depth.checked_sub(1)?;
530                index += ch.len_utf8();
531            }
532            ',' if delimiter == SegmentDelimiterV0::Comma
533                && paren_depth == 0
534                && bracket_depth == 0
535                && brace_depth == 0 =>
536            {
537                if !push_trimmed_segment(value, base_offset, start, index, &mut segments)? {
538                    return None;
539                }
540                index += ch.len_utf8();
541                start = index;
542            }
543            _ if delimiter == SegmentDelimiterV0::Whitespace
544                && ch.is_whitespace()
545                && paren_depth == 0
546                && bracket_depth == 0
547                && brace_depth == 0 =>
548            {
549                push_trimmed_segment(value, base_offset, start, index, &mut segments)?;
550                index += ch.len_utf8();
551                start = index;
552            }
553            _ => {
554                index += ch.len_utf8();
555            }
556        }
557    }
558
559    if quote.is_some() || paren_depth != 0 || bracket_depth != 0 || brace_depth != 0 {
560        return None;
561    }
562    let pushed = push_trimmed_segment(value, base_offset, start, value.len(), &mut segments)?;
563    if delimiter == SegmentDelimiterV0::Comma && !pushed {
564        return None;
565    }
566    (!segments.is_empty()).then_some(segments)
567}
568
569fn push_trimmed_segment<'a>(
570    source: &'a str,
571    base_offset: usize,
572    start: usize,
573    end: usize,
574    segments: &mut Vec<ValueSegmentV0<'a>>,
575) -> Option<bool> {
576    if start > end || end > source.len() {
577        return None;
578    }
579    let (start, end) = trim_byte_span(source, start, end);
580    if start >= end {
581        return Some(false);
582    }
583    segments.push(ValueSegmentV0 {
584        text: &source[start..end],
585        span: ValueByteSpanV0::new(base_offset + start, base_offset + end),
586    });
587    Some(true)
588}
589
590fn trim_byte_span(source: &str, mut start: usize, mut end: usize) -> (usize, usize) {
591    while start < end {
592        let Some(ch) = source[start..end].chars().next() else {
593            break;
594        };
595        if !ch.is_whitespace() {
596            break;
597        }
598        start += ch.len_utf8();
599    }
600    while start < end {
601        let Some(ch) = source[start..end].chars().next_back() else {
602            break;
603        };
604        if !ch.is_whitespace() {
605            break;
606        }
607        end -= ch.len_utf8();
608    }
609    (start, end)
610}
611
612pub fn dialect_is_supported_for_raw_value_lens(dialect: StyleDialect) -> bool {
613    matches!(
614        dialect,
615        StyleDialect::Css | StyleDialect::Scss | StyleDialect::Sass | StyleDialect::Less
616    )
617}
618
619#[cfg(test)]
620mod tests {
621    use super::*;
622
623    #[test]
624    fn value_lens_is_region_local_and_keeps_base_offsets() {
625        let lens = declaration_value_lens("  0px  ", 20);
626        assert_eq!(lens.source(), "  0px  ");
627        assert_eq!(lens.base_offset(), 20);
628        assert_eq!(lens.nodes().len(), 1);
629        assert!(matches!(lens.root(), ValueNodeV0::Number { .. }));
630
631        let segments = split_top_level_whitespace_value_components("  0px  var(--x)  ", 10);
632        assert!(segments.is_some());
633        let segments = segments.unwrap_or_default();
634        assert_eq!(segments[0].text, "0px");
635        assert_eq!(segments[0].span, ValueByteSpanV0::new(12, 15));
636        assert_eq!(segments[1].text, "var(--x)");
637        assert_eq!(segments[1].span, ValueByteSpanV0::new(17, 25));
638    }
639
640    #[test]
641    fn value_lens_materializes_css_lists_and_nested_functions() -> Result<(), String> {
642        let source = "1px solid color-mix(in srgb, red 20%, black)";
643        let lens = declaration_value_lens(source, 7);
644        let ValueNodeV0::List { span, items } = lens.root() else {
645            return Err("compound value should materialize a list root".to_string());
646        };
647        assert_eq!(*span, ValueByteSpanV0::new(7, 7 + source.len()));
648        assert_eq!(items.len(), 3);
649        assert!(lens.nodes().iter().any(|node| {
650            matches!(
651                node,
652                ValueNodeV0::Function { name, arguments, .. }
653                    if *name == "color-mix" && !arguments.is_empty()
654            )
655        }));
656        Ok(())
657    }
658
659    #[test]
660    fn canonical_equality_collapses_absolute_zero_but_not_percent_or_relative_units() {
661        assert!(css_values_canonically_equal("0px", "0"));
662        assert!(css_values_canonically_equal("0deg", "0"));
663        assert!(css_values_canonically_equal("+0.000PX", "-0"));
664        assert!(css_values_canonically_equal("050%", "50%"));
665        assert!(!css_values_canonically_equal("0%", "0"));
666        assert!(!css_values_canonically_equal("0em", "0"));
667        assert!(!css_values_canonically_equal("0cqw", "0"));
668    }
669
670    #[test]
671    fn container_query_length_units_are_recognized_but_never_collapsed() {
672        for unit in ["cqw", "cqh", "cqi", "cqb", "cqmin", "cqmax"] {
673            assert!(is_container_query_length_unit(unit));
674            assert!(is_container_query_length_unit(&unit.to_ascii_uppercase()));
675            assert!(!is_absolute_zero_collapsible_unit(unit));
676            assert!(!is_serializable_numeric_unit(unit));
677            assert!(!css_values_canonically_equal(&format!("0{unit}"), "0"));
678            assert!(!css_values_canonically_equal(&format!("1{unit}"), "1"));
679        }
680        assert!(!css_values_canonically_equal("1cqw", "1cqh"));
681        assert!(!css_values_canonically_equal("0cqi", "0cqb"));
682        assert!(!is_container_query_length_unit("px"));
683        assert!(!is_container_query_length_unit("em"));
684    }
685
686    #[test]
687    fn subgrid_round_trips_byte_identically() {
688        assert_eq!(
689            canonicalize_css_value("subgrid").map(|value| value.serialized),
690            Some("subgrid".to_string())
691        );
692        // A subgrid track list carrying line-name args the lattice cannot model
693        // stays opaque (never rewritten or reordered).
694        assert!(canonicalize_css_value("subgrid [full-start]").is_none());
695    }
696
697    #[test]
698    fn relative_color_channel_vocabulary_recognizes_without_folding() {
699        assert_eq!(
700            relative_color_channel_names("rgb"),
701            Some(["r", "g", "b", "alpha"].as_slice())
702        );
703        assert_eq!(
704            relative_color_channel_names("OKLCH"),
705            Some(["l", "c", "h", "alpha"].as_slice())
706        );
707        assert_eq!(relative_color_channel_names("rotate"), None);
708        assert!(is_relative_color_form("rgb(from red r g b)"));
709        // `color(from …)` is a recognized relative form whose channels are
710        // colorspace-dependent, so it has no fixed channel-name list.
711        assert!(is_relative_color_form("color(from red srgb r g b)"));
712        assert_eq!(relative_color_channel_names("color"), None);
713        assert!(!is_relative_color_form("rgb(255 0 0)"));
714        assert!(!is_relative_color_form("rotate(45deg)"));
715        // Recognition is a no-op on bytes: a relative-color value is not folded,
716        // it stays opaque (`Raw`/identity) through canonicalization.
717        assert!(canonicalize_css_value("rgb(from red r g b)").is_none());
718        assert!(canonicalize_css_value("oklch(from blue l c h)").is_none());
719    }
720
721    #[test]
722    fn relative_color_fold_resolves_channels_by_name_via_recognition_vocab() {
723        // Identity: each slot's name matches its position.
724        assert_eq!(
725            parse_relative_color_value("rgb(from red r g b)").as_deref(),
726            Some("rgb(255 0 0)")
727        );
728        // Swap: a `b g r` slot order must honour the channel NAME (origin #112233).
729        assert_eq!(
730            parse_relative_color_value("rgb(from #112233 b g r)").as_deref(),
731            Some("rgb(51 34 17)")
732        );
733        // Literal channels.
734        assert_eq!(
735            parse_relative_color_value("rgb(from red 0 255 0)").as_deref(),
736            Some("rgb(0 255 0)")
737        );
738        assert!(parse_relative_color_value("rgb(from #0000ff r g b / 0.5)").is_some());
739        // Gated by the Wave-1 recognition vocabulary: non-recognized / non-rgb /
740        // non-static forms stay unfolded (preserved verbatim).
741        assert!(parse_relative_color_value("hsl(from red h s l)").is_none());
742        assert!(parse_relative_color_value("color(from red srgb r g b)").is_none());
743        assert!(parse_relative_color_value("rgb(from var(--x) r g b)").is_none());
744        assert!(parse_relative_color_value("rgb(255 0 0)").is_none());
745    }
746
747    #[test]
748    fn canonical_equality_uses_raw_identity_without_typed_transfer_for_unknowns() {
749        assert!(css_values_canonically_equal("var(--x)", "var(--x)"));
750        assert!(!css_values_canonically_equal("var(--x)", "0"));
751        assert!(!css_values_canonically_equal("calc(0px)", "0"));
752        assert_eq!(canonicalize_css_value("env(safe-area-inset-top)"), None);
753    }
754
755    #[test]
756    fn canonical_equality_tracks_static_color_values() {
757        assert_eq!(
758            canonicalize_css_value("#ff0000").map(|value| value.serialized),
759            Some("red".to_string())
760        );
761        assert!(css_values_canonically_equal("#f00", "rgb(255 0 0)"));
762        assert_eq!(
763            canonicalize_css_value("color-mix(in srgb, red 50%, blue 50%)")
764                .map(|value| value.serialized),
765            Some("purple".to_string())
766        );
767        assert_eq!(
768            canonicalize_css_value("color(srgb 1 0 0 / .5)").map(|value| value.serialized),
769            Some("#ff000080".to_string())
770        );
771    }
772
773    #[test]
774    fn canonical_equality_tracks_single_keyword_values() {
775        assert_eq!(
776            canonicalize_css_value("block").map(|value| value.serialized),
777            Some("block".to_string())
778        );
779        assert_eq!(
780            canonicalize_css_value("true").map(|value| value.serialized),
781            Some("true".to_string())
782        );
783        assert_eq!(
784            canonicalize_css_value("one two").map(|value| value.serialized),
785            Some("one two".to_string())
786        );
787        assert_eq!(canonicalize_css_value("var(--keyword)"), None);
788    }
789
790    #[test]
791    fn canonical_equality_tracks_static_strings_lists_and_time_units() {
792        assert_eq!(
793            canonicalize_css_value("\"hello less\"").map(|value| value.serialized),
794            Some("\"hello less\"".to_string())
795        );
796        assert_eq!(
797            canonicalize_css_value("1px solid red").map(|value| value.serialized),
798            Some("1px solid red".to_string())
799        );
800        assert_eq!(
801            canonicalize_css_value("1 2 3 4").map(|value| value.serialized),
802            Some("1 2 3 4".to_string())
803        );
804        assert_eq!(
805            canonicalize_css_value("1000ms").map(|value| value.serialized),
806            Some("1000ms".to_string())
807        );
808        assert_eq!(
809            canonicalize_css_value("0ms").map(|value| value.serialized),
810            Some("0ms".to_string())
811        );
812        assert_eq!(canonicalize_css_value("1px var(--gap)"), None);
813    }
814
815    #[test]
816    fn css_number_zero_predicate_accepts_css_numeric_spelling_only() {
817        assert!(css_number_is_zero("0"));
818        assert!(css_number_is_zero("-.0"));
819        assert!(css_number_is_zero("+0e10"));
820        assert!(!css_number_is_zero("-"));
821        assert!(!css_number_is_zero("."));
822        assert!(!css_number_is_zero("0px"));
823        assert!(!css_number_is_zero("1"));
824    }
825
826    #[test]
827    fn value_splitter_respects_depth_quotes_brackets_and_braces() {
828        let args = split_top_level_value_arguments("rgb(0, 0, 0), \"a,b\", [x,y], {a:b}", 3);
829        assert!(args.is_some());
830        let args = args.unwrap_or_default();
831        assert_eq!(
832            args.iter().map(|segment| segment.text).collect::<Vec<_>>(),
833            vec!["rgb(0, 0, 0)", "\"a,b\"", "[x,y]", "{a:b}"]
834        );
835        assert_eq!(args[0].span, ValueByteSpanV0::new(3, 15));
836        assert_eq!(args[3].span, ValueByteSpanV0::new(31, 36));
837
838        let parts = split_top_level_whitespace_value_components(
839            "1px minmax(0, 1fr) \"a b\" [line name]",
840            0,
841        );
842        assert!(parts.is_some());
843        let parts = parts.unwrap_or_default();
844        assert_eq!(
845            parts.iter().map(|segment| segment.text).collect::<Vec<_>>(),
846            vec!["1px", "minmax(0, 1fr)", "\"a b\"", "[line name]"]
847        );
848    }
849
850    #[test]
851    fn numeric_kernel_reduces_static_css_expressions() {
852        assert_eq!(
853            reduce_static_numeric_expression("1px + 2px").as_deref(),
854            Some("3px")
855        );
856        assert_eq!(
857            parse_reducible_calc_value("calc((2px + 4px) / 2)").as_deref(),
858            Some("3px")
859        );
860        assert_eq!(
861            parse_reducible_sign_value("sign(-2px)").as_deref(),
862            Some("-1")
863        );
864        assert_eq!(
865            parse_reducible_ceil_value("ceil(1.2px)").as_deref(),
866            Some("2px")
867        );
868        assert_eq!(
869            parse_reducible_floor_value("floor(1.8px)").as_deref(),
870            Some("1px")
871        );
872        assert_eq!(
873            parse_reducible_round_to_integer_value("round(1.5px)").as_deref(),
874            Some("2px")
875        );
876        assert_eq!(
877            parse_reducible_clamp_value("clamp(1px, 3px, 2px)").as_deref(),
878            Some("2px")
879        );
880        assert_eq!(
881            compress_numeric_token_text("+001.5000px").as_deref(),
882            Some("1.5px")
883        );
884    }
885
886    #[test]
887    fn static_function_substitution_respects_function_name_boundaries() {
888        fn parse_static_max(value: &str) -> Option<String> {
889            parse_reducible_max_value(value)
890        }
891
892        assert_eq!(
893            substitute_static_css_function_references_in_value(
894                "max(1px, 2px)",
895                &[("max", parse_static_max)]
896            )
897            .as_deref(),
898            Some("2px")
899        );
900        assert_eq!(
901            substitute_static_css_function_references_in_value(
902                "calc(max(1px, 2px) + 1px)",
903                &[("max", parse_static_max)]
904            )
905            .as_deref(),
906            Some("calc(2px + 1px)")
907        );
908        assert_eq!(
909            substitute_static_css_function_references_in_value(
910                "math.max(1px, 2px)",
911                &[("max", parse_static_max)]
912            ),
913            None
914        );
915        assert_eq!(
916            substitute_static_css_function_references_in_value(
917                "mymax(1px, 2px)",
918                &[("max", parse_static_max)]
919            ),
920            None
921        );
922    }
923
924    #[test]
925    fn static_function_substitution_reduces_nested_calls_before_outer_calls() {
926        fn parse_static_outer(value: &str) -> Option<String> {
927            let inner = parse_whole_function_value_inner(value, "outer")?.trim();
928            Some(if inner == "ok" { "good" } else { "bad" }.to_string())
929        }
930
931        fn parse_static_inner(value: &str) -> Option<String> {
932            (value.trim() == "inner()").then(|| "ok".to_string())
933        }
934
935        assert_eq!(
936            substitute_static_css_function_references_in_value_until_stable(
937                "outer(inner())",
938                &[("outer", parse_static_outer), ("inner", parse_static_inner),],
939            )
940            .as_deref(),
941            Some("good")
942        );
943    }
944
945    #[test]
946    fn color_kernel_parses_static_color_values() {
947        assert!(parse_static_srgb_color("rebeccapurple").is_some());
948        assert_eq!(
949            compress_hex_color_token_text("#ff0000").as_deref(),
950            Some("red")
951        );
952        assert_eq!(
953            parse_color_function_value("color(srgb 1 0 0 / .5)").as_deref(),
954            Some("rgb(255 0 0 / .5)")
955        );
956        assert_eq!(
957            parse_color_mix_value("color-mix(in srgb, red 50%, blue 50%)").as_deref(),
958            Some("rgb(128 0 128)")
959        );
960        assert_eq!(
961            parse_oklab_oklch_value("oklab(1 0 0)").as_deref(),
962            Some("rgb(255 255 255)")
963        );
964    }
965}