Skip to main content

css_ast/values/
value.rs

1use crate::{
2	AttrFunction, CssAtomSet, CssMetadata, EnvFunction, FirstValidFunction, IdentFunction, IfFunction, MathFunction,
3	TreeCountingFunction, Unresolved, VarFunction,
4};
5use css_lexer::ToSpan;
6use css_parse::{
7	Box, Cursor, DeclarationValue, NodeWithMetadata, Parse, Parser, Peek, Result, SemanticEq, ToCursors,
8	ToNormalisedValue, ToNumberValue,
9};
10use csskit_derives::*;
11use csskit_proc_macro::node;
12
13/// Classifies a function atom as an arbitrary substitution function.
14#[inline]
15pub(crate) fn is_substitution_function(atom: CssAtomSet) -> bool {
16	matches!(atom, CssAtomSet::Var | CssAtomSet::Env | CssAtomSet::Attr | CssAtomSet::If | CssAtomSet::FirstValid)
17}
18
19/// Generates the `Parse` impl for a value-slot enum with the shape
20/// `Literal(..) | Substituted(Box<Sub>) | Unresolved(Box<Unresolved>)`.
21macro_rules! impl_value_slot_parse {
22	($ty:ident, $sub:ident, $lit:ty) => {
23		impl<'a, T: ::css_parse::Parse<'a> + ::css_parse::Peek<'a>> ::css_parse::Parse<'a> for $ty<'a, T> {
24			fn parse<I>(p: &mut ::css_parse::Parser<'a, I>) -> ::css_parse::Result<Self>
25			where
26				I: ::std::iter::Iterator<Item = ::css_parse::Cursor> + ::std::clone::Clone,
27			{
28				if p.peek::<$sub<T>>() {
29					if !p.enter_substitution() {
30						return Ok(Self::Unresolved(::css_parse::Box::new_in(p.alloc(), p.parse::<Unresolved>()?)));
31					}
32					let sub = p.parse::<$sub<T>>();
33					p.exit_substitution();
34					return Ok(Self::Substituted(::css_parse::Box::new_in(p.alloc(), sub?)));
35				}
36				Ok(Self::Literal(p.parse::<$lit>()?))
37			}
38		}
39	};
40}
41pub(crate) use impl_value_slot_parse;
42
43/// Generic wrapper for CSS values whose grammar permits arbitrary substitution functions
44/// (`var()`, `env()`, `attr()`, `if()`, `first-valid()`), but **not** typed math functions.
45///
46/// <https://drafts.csswg.org/css-values-5/#arbitrary-substitution-function>
47#[node]
48#[derive(Peek, ToCursors, ToSpan, SemanticEq, Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
49#[cfg_attr(feature = "serde", derive(serde::Serialize), serde())]
50#[cfg_attr(feature = "visitable", derive(Visitable), visit(children))]
51pub enum Value<'a, T> {
52	Literal(T),
53	Substituted(Box<'a, SubstitutionFunction<'a, T>>),
54	#[peek(skip)]
55	Unresolved(Box<'a, Unresolved<'a>>),
56}
57
58impl_value_slot_parse!(Value, SubstitutionFunction, T);
59
60/// An arbitrary substitution function appearing in a [`Value`] slot.
61///
62/// Fallbacks recurse into the slot's own type (`Value<T>`), preserving maximal type information.
63/// Parse/Peek are derived: each variant is atom-dispatched by the leading function name.
64#[node]
65#[derive(Peek, Parse, ToCursors, ToSpan, SemanticEq, Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
66#[cfg_attr(feature = "serde", derive(serde::Serialize), serde())]
67#[cfg_attr(feature = "visitable", derive(Visitable), visit(children))]
68#[derive(csskit_derives::NodeWithMetadata)]
69pub enum SubstitutionFunction<'a, T> {
70	Var(VarFunction<'a, Value<'a, T>>),
71	Env(EnvFunction<'a, Value<'a, T>>),
72	Attr(Box<'a, AttrFunction<'a>>),
73	If(IfFunction<'a, Value<'a, T>>),
74	FirstValid(FirstValidFunction<'a, Value<'a, T>>),
75}
76
77/// Generic wrapper for numeric CSS values whose grammar permits both arbitrary substitution
78/// functions **and** typed math functions (`calc()`, `min()`, `max()`, etc.).
79///
80/// Used for: `<length>`, `<length-percentage>`, `<number>`, `<percentage>`, `<integer>`,
81/// `<time>`, `<angle>`, `<frequency>`, `<flex>`, `<alpha-value>`, etc.
82///
83/// Structurally identical to [`Value`] except [`CalcableSubstitutionFunction`] adds a `Math` variant.
84#[node]
85#[derive(Peek, ToCursors, ToSpan, SemanticEq, Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
86#[cfg_attr(feature = "serde", derive(serde::Serialize), serde())]
87#[cfg_attr(feature = "visitable", derive(Visitable), visit(children))]
88pub enum CalcableValue<'a, T> {
89	Literal(T),
90	Substituted(Box<'a, CalcableSubstitutionFunction<'a, T>>),
91	#[peek(skip)]
92	Unresolved(Box<'a, Unresolved<'a>>),
93}
94
95impl_value_slot_parse!(CalcableValue, CalcableSubstitutionFunction, T);
96
97/// A substitution or math function appearing in a [`CalcableValue`] slot.
98///
99/// `Math` covers `calc()`, `min()`, `max()`, `clamp()`, `round()`, `mod()`, `rem()`, the
100/// trigonometric/exponential functions, and `abs()`/`sign()` (see [`MathFunction`]). It's
101/// parametrized by the same `T` as the surrounding [`CalcableValue`], since most of these
102/// functions are "type-transparent" (their arguments and result share `T`'s type).
103#[node]
104#[derive(Peek, Parse, ToCursors, ToSpan, SemanticEq, Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
105#[cfg_attr(feature = "serde", derive(serde::Serialize), serde())]
106#[cfg_attr(feature = "visitable", derive(Visitable), visit(children))]
107#[derive(csskit_derives::NodeWithMetadata)]
108pub enum CalcableSubstitutionFunction<'a, T> {
109	Math(MathFunction<'a, T>),
110	Var(VarFunction<'a, CalcableValue<'a, T>>),
111	Env(EnvFunction<'a, CalcableValue<'a, T>>),
112	Attr(Box<'a, AttrFunction<'a>>),
113	If(IfFunction<'a, CalcableValue<'a, T>>),
114	FirstValid(FirstValidFunction<'a, CalcableValue<'a, T>>),
115}
116
117/// Generic wrapper for CSS values whose grammar is an `<integer>` or `<number>`, which permit
118/// everything a [`CalcableValue`] does plus the tree-counting functions (`sibling-count()`,
119/// `sibling-index()`).
120///
121/// <https://drafts.csswg.org/css-values-5/#tree-counting>
122#[node]
123#[derive(Peek, ToCursors, ToSpan, SemanticEq, Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
124#[cfg_attr(feature = "serde", derive(serde::Serialize), serde())]
125#[cfg_attr(feature = "visitable", derive(Visitable), visit(children))]
126pub enum NumericValue<'a, T> {
127	Literal(T),
128	Substituted(Box<'a, NumericSubstitutionFunction<'a, T>>),
129	#[peek(skip)]
130	Unresolved(Box<'a, Unresolved<'a>>),
131}
132
133impl_value_slot_parse!(NumericValue, NumericSubstitutionFunction, T);
134
135/// A tree-counting, substitution, or math function appearing in a [`NumericValue`] slot.
136///
137/// Identical to [`CalcableSubstitutionFunction`] except for the `TreeCounting` variant, which is
138/// not a substitution function but is resolved just as late.
139#[node]
140#[derive(Peek, Parse, ToCursors, ToSpan, SemanticEq, Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
141#[cfg_attr(feature = "serde", derive(serde::Serialize), serde())]
142#[cfg_attr(feature = "visitable", derive(Visitable), visit(children))]
143#[derive(csskit_derives::NodeWithMetadata)]
144pub enum NumericSubstitutionFunction<'a, T> {
145	TreeCounting(TreeCountingFunction),
146	Math(MathFunction<'a, T>),
147	Var(VarFunction<'a, NumericValue<'a, T>>),
148	Env(EnvFunction<'a, NumericValue<'a, T>>),
149	Attr(Box<'a, AttrFunction<'a>>),
150	If(IfFunction<'a, NumericValue<'a, T>>),
151	FirstValid(FirstValidFunction<'a, NumericValue<'a, T>>),
152}
153
154/// Generic wrapper for CSS keyword values whose grammar permits arbitrary substitution functions
155/// **and** the `ident()` function, which constructs a `<custom-ident>` from several parts and is
156/// resolved just as late.
157///
158/// Used for bare keyword slots, so a substitution function or `ident()` can occupy the keyword
159/// position and stay typed to the enclosing style value.
160///
161/// <https://drafts.csswg.org/css-values-5/#ident-fn>
162#[node]
163#[derive(Peek, ToCursors, ToSpan, SemanticEq, Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
164#[cfg_attr(feature = "serde", derive(serde::Serialize), serde())]
165#[cfg_attr(feature = "visitable", derive(Visitable), visit(children))]
166pub enum KeywordValue<'a, T> {
167	Literal(T),
168	Substituted(Box<'a, KeywordSubstitutionFunction<'a, T>>),
169	#[peek(skip)]
170	Unresolved(Box<'a, Unresolved<'a>>),
171}
172
173impl_value_slot_parse!(KeywordValue, KeywordSubstitutionFunction, T);
174
175/// An `ident()` or substitution function appearing in a [`KeywordValue`] slot.
176///
177/// Identical to [`SubstitutionFunction`] except for the `Ident` variant, which is not a
178/// substitution function but is resolved just as late.
179#[node]
180#[derive(Peek, Parse, ToCursors, ToSpan, SemanticEq, Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
181#[cfg_attr(feature = "serde", derive(serde::Serialize), serde())]
182#[cfg_attr(feature = "visitable", derive(Visitable), visit(children))]
183#[derive(csskit_derives::NodeWithMetadata)]
184pub enum KeywordSubstitutionFunction<'a, T> {
185	Ident(IdentFunction<'a>),
186	Var(VarFunction<'a, KeywordValue<'a, T>>),
187	Env(EnvFunction<'a, KeywordValue<'a, T>>),
188	Attr(Box<'a, AttrFunction<'a>>),
189	If(IfFunction<'a, KeywordValue<'a, T>>),
190	FirstValid(FirstValidFunction<'a, KeywordValue<'a, T>>),
191}
192
193/// Generates the value-behaviour trait impls (`ToNumberValue`, `ToNormalisedValue`,
194/// `NodeWithMetadata`, `DeclarationValue`) shared verbatim by every value-slot enum.
195macro_rules! impl_value_slot_traits {
196	($ty:ident) => {
197		impl<T: ToNumberValue> ToNumberValue for $ty<'_, T> {
198			fn to_number_value(&self) -> Option<f32> {
199				match self {
200					Self::Literal(t) => t.to_number_value(),
201					Self::Substituted(_) | Self::Unresolved(_) => None,
202				}
203			}
204		}
205
206		impl<T: ToNormalisedValue> ToNormalisedValue for $ty<'_, T> {
207			fn to_normalised_value(&self) -> Option<f32> {
208				match self {
209					Self::Literal(t) => t.to_normalised_value(),
210					Self::Substituted(_) | Self::Unresolved(_) => None,
211				}
212			}
213		}
214
215		impl<'a, T: NodeWithMetadata<CssMetadata>> NodeWithMetadata<CssMetadata> for $ty<'a, T> {
216			fn self_metadata(&self) -> CssMetadata {
217				match self {
218					Self::Literal(_) => CssMetadata::default(),
219					Self::Substituted(_) | Self::Unresolved(_) => CssMetadata {
220						uses_substitution: true,
221						declaration_kinds: crate::DeclarationKind::Computed,
222						..CssMetadata::default()
223					},
224				}
225			}
226
227			fn metadata(&self) -> CssMetadata {
228				match self {
229					Self::Literal(t) => t.metadata(),
230					Self::Substituted(f) => css_parse::NodeMetadata::merge(f.metadata(), self.self_metadata()),
231					Self::Unresolved(_) => self.self_metadata(),
232				}
233			}
234		}
235
236		impl<'a, T> DeclarationValue<'a, CssMetadata> for $ty<'a, T>
237		where
238			T: Parse<'a>
239				+ Peek<'a>
240				+ ToCursors
241				+ ToSpan
242				+ SemanticEq
243				+ NodeWithMetadata<CssMetadata>
244				+ DeclarationValue<'a, CssMetadata>,
245		{
246			fn is_computed_declaration_value<I>(p: &Parser<'a, I>, c: Cursor) -> bool
247			where
248				I: Iterator<Item = Cursor> + Clone,
249			{
250				<Self as Peek>::peek(p, c)
251			}
252
253			fn is_initial(&self) -> bool {
254				matches!(self, Self::Literal(t) if t.is_initial())
255			}
256			fn is_inherit(&self) -> bool {
257				matches!(self, Self::Literal(t) if t.is_inherit())
258			}
259			fn is_unset(&self) -> bool {
260				matches!(self, Self::Literal(t) if t.is_unset())
261			}
262			fn is_revert(&self) -> bool {
263				matches!(self, Self::Literal(t) if t.is_revert())
264			}
265			fn is_revert_layer(&self) -> bool {
266				matches!(self, Self::Literal(t) if t.is_revert_layer())
267			}
268			fn is_revert_rule(&self) -> bool {
269				matches!(self, Self::Literal(t) if t.is_revert_rule())
270			}
271			fn needs_computing(&self) -> bool {
272				!matches!(self, Self::Literal(_))
273			}
274			fn parse_specified_declaration_value<Iter>(p: &mut Parser<'a, Iter>, name: Cursor) -> Result<Self>
275			where
276				Iter: Iterator<Item = Cursor> + Clone,
277			{
278				let _ = name;
279				p.parse()
280			}
281		}
282	};
283}
284
285impl_value_slot_traits!(Value);
286impl_value_slot_traits!(CalcableValue);
287impl_value_slot_traits!(NumericValue);
288impl_value_slot_traits!(KeywordValue);
289
290#[cfg(test)]
291mod tests {
292	use super::*;
293	use crate::{CSSInt, Color, Length};
294	use css_parse::{assert_parse, assert_peek_false};
295
296	type ValueColor<'a> = Value<'a, Color<'a>>;
297	type CalcLength<'a> = CalcableValue<'a, Length>;
298	type NumericInt<'a> = NumericValue<'a, CSSInt>;
299	type KeywordIdent<'a> = KeywordValue<'a, css_parse::T![Ident]>;
300
301	#[test]
302	fn value_literal() {
303		assert_parse!(CssAtomSet::ATOMS, ValueColor, "red", |v| {
304			assert!(matches!(v, Value::Literal(_)));
305		});
306	}
307
308	#[test]
309	fn value_substituted_var_no_fallback() {
310		assert_parse!(CssAtomSet::ATOMS, ValueColor, "var(--c)", |v| {
311			assert!(matches!(v, Value::Substituted(_)));
312		});
313	}
314
315	#[test]
316	fn value_substituted_var_typed_fallback() {
317		// Fallback recurses into the slot type (Color) and stays typed.
318		assert_parse!(CssAtomSet::ATOMS, ValueColor, "var(--c, red)", |v| {
319			let Value::Substituted(sub) = v else { panic!("expected Substituted") };
320			let SubstitutionFunction::Var(var) = &*sub else { panic!("expected Var") };
321			assert!(matches!(var.fallback.as_deref(), Some(Value::Literal(_))));
322		});
323	}
324
325	#[test]
326	fn value_substituted_env_and_others() {
327		assert_parse!(CssAtomSet::ATOMS, ValueColor, "env(my-color, red)");
328		assert_parse!(CssAtomSet::ATOMS, ValueColor, "attr(data-color)");
329		assert_parse!(CssAtomSet::ATOMS, ValueColor, "first-valid(red, blue)");
330	}
331
332	#[test]
333	fn value_nested_fallback() {
334		// var fallback contains another var, itself with a typed literal fallback.
335		assert_parse!(CssAtomSet::ATOMS, ValueColor, "var(--a, var(--b, red))", |v| {
336			let Value::Substituted(sub) = v else { panic!() };
337			let SubstitutionFunction::Var(outer) = &*sub else { panic!() };
338			let Some(inner) = outer.fallback.as_deref() else { panic!("missing outer fallback") };
339			assert!(matches!(inner, Value::Substituted(_)));
340		});
341	}
342
343	#[test]
344	fn calcable_literal_and_calc() {
345		assert_parse!(CssAtomSet::ATOMS, CalcLength, "10px", |v| {
346			assert!(matches!(v, CalcableValue::Literal(_)));
347		});
348		assert_parse!(CssAtomSet::ATOMS, CalcLength, "calc(1px + 2px)", |v| {
349			let CalcableValue::Substituted(sub) = v else { panic!() };
350			assert!(matches!(&*sub, CalcableSubstitutionFunction::Math(_)));
351		});
352	}
353
354	#[test]
355	fn calcable_var_typed_fallback() {
356		assert_parse!(CssAtomSet::ATOMS, CalcLength, "var(--w, 10px)", |v| {
357			let CalcableValue::Substituted(sub) = v else { panic!() };
358			let CalcableSubstitutionFunction::Var(var) = &*sub else { panic!() };
359			assert!(matches!(var.fallback.as_deref(), Some(CalcableValue::Literal(_))));
360		});
361	}
362
363	#[test]
364	fn numeric_tree_counting_functions() {
365		assert_parse!(CssAtomSet::ATOMS, NumericInt, "sibling-index()", |v| {
366			let NumericValue::Substituted(sub) = v else { panic!() };
367			assert!(matches!(&*sub, NumericSubstitutionFunction::TreeCounting(_)));
368		});
369		assert_parse!(CssAtomSet::ATOMS, NumericInt, "sibling-count()");
370	}
371
372	#[test]
373	fn numeric_covers_alternations_containing_number() {
374		// <number-percentage> and <alpha-value> both admit a bare <number>, so the
375		// tree-counting functions stand in for them too.
376		type NumericNumberPercentage<'a> = NumericValue<'a, crate::NumberPercentage>;
377		type NumericAlpha<'a> = NumericValue<'a, crate::OpacityValue>;
378		assert_parse!(CssAtomSet::ATOMS, NumericNumberPercentage, "sibling-index()");
379		assert_parse!(CssAtomSet::ATOMS, NumericNumberPercentage, "50%");
380		assert_parse!(CssAtomSet::ATOMS, NumericAlpha, "sibling-count()");
381		assert_parse!(CssAtomSet::ATOMS, NumericAlpha, "0.5");
382	}
383
384	#[test]
385	fn numeric_keeps_literal_and_calcable_behaviour() {
386		assert_parse!(CssAtomSet::ATOMS, NumericInt, "3", |v| {
387			assert!(matches!(v, NumericValue::Literal(_)));
388		});
389		assert_parse!(CssAtomSet::ATOMS, NumericInt, "calc(sibling-index() + 1)", |v| {
390			let NumericValue::Substituted(sub) = v else { panic!() };
391			assert!(matches!(&*sub, NumericSubstitutionFunction::Math(_)));
392		});
393	}
394
395	#[test]
396	fn numeric_fallback_recurses_into_numeric_slot() {
397		assert_parse!(CssAtomSet::ATOMS, NumericInt, "var(--i, sibling-index())", |v| {
398			let NumericValue::Substituted(sub) = v else { panic!() };
399			let NumericSubstitutionFunction::Var(var) = &*sub else { panic!() };
400			assert!(matches!(var.fallback.as_deref(), Some(NumericValue::Substituted(_))));
401		});
402	}
403
404	#[test]
405	fn calcable_rejects_tree_counting_functions() {
406		assert_peek_false!(CssAtomSet::ATOMS, CalcLength, "sibling-index()");
407	}
408
409	#[test]
410	fn depth_limit_rejects_to_unresolved() {
411		// Build var(--a, var(--a, ... )) nested past MAX_SUBSTITUTION_DEPTH.
412		use css_lexer::Lexer;
413		use css_parse::{Arena, Parser};
414
415		let depth = (Parser::<std::vec::IntoIter<css_parse::Cursor>>::MAX_SUBSTITUTION_DEPTH as usize) + 5;
416		let mut input = String::new();
417		for _ in 0..depth {
418			input.push_str("var(--a,");
419		}
420		input.push_str("red");
421		for _ in 0..depth {
422			input.push(')');
423		}
424
425		let alloc = Arena::new();
426		let lexer = Lexer::new(&CssAtomSet::ATOMS, &input);
427		let mut p = Parser::new(&alloc, &input, lexer);
428		// Must not stack-overflow; deepest level degrades to Unresolved rather than recursing.
429		let result = p.parse_entirely::<ValueColor>();
430		assert!(result.output.is_some(), "expected parse to succeed via Unresolved degradation");
431	}
432
433	#[test]
434	fn keyword_literal() {
435		assert_parse!(CssAtomSet::ATOMS, KeywordIdent, "flex", |v| {
436			assert!(matches!(v, KeywordValue::Literal(_)));
437		});
438	}
439
440	#[test]
441	fn keyword_ident_function() {
442		assert_parse!(CssAtomSet::ATOMS, KeywordIdent, "ident('vtl-'sibling-index())", |v| {
443			let KeywordValue::Substituted(sub) = v else { panic!("expected Substituted") };
444			assert!(matches!(&*sub, KeywordSubstitutionFunction::Ident(_)));
445		});
446	}
447
448	#[test]
449	fn keyword_substituted_var_typed_fallback() {
450		// Fallback recurses into the keyword slot and stays typed.
451		assert_parse!(CssAtomSet::ATOMS, KeywordIdent, "var(--k, flex)", |v| {
452			let KeywordValue::Substituted(sub) = v else { panic!("expected Substituted") };
453			let KeywordSubstitutionFunction::Var(var) = &*sub else { panic!("expected Var") };
454			assert!(matches!(var.fallback.as_deref(), Some(KeywordValue::Literal(_))));
455		});
456	}
457
458	#[test]
459	fn keyword_rejects_tree_counting_functions() {
460		assert_peek_false!(CssAtomSet::ATOMS, KeywordIdent, "sibling-index()");
461	}
462}