1use 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
406pub 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 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 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 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 assert_eq!(
725 parse_relative_color_value("rgb(from red r g b)").as_deref(),
726 Some("rgb(255 0 0)")
727 );
728 assert_eq!(
730 parse_relative_color_value("rgb(from #112233 b g r)").as_deref(),
731 Some("rgb(51 34 17)")
732 );
733 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 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}