Skip to main content

azul_css/props/style/
content.rs

1//! CSS properties for generated content (`content`, `counter-reset`,
2//! `counter-increment`, `string-set`).
3//!
4//! Defines [`Content`], [`CounterReset`], [`CounterIncrement`], and
5//! [`StringSet`], which are registered as [`CssProperty`] variants.
6
7use alloc::string::{String, ToString};
8
9use crate::{corety::AzString, props::formatter::PrintAsCssValue};
10
11/// CSS `content` property value, stored as a raw string.
12///
13/// Intentionally simplified: stores the unparsed CSS value rather than
14/// a structured `ContentPart` enum. Complex values like `counter(section) ". "`
15/// are preserved verbatim but not individually evaluated.
16///
17/// **Note:** Currently parsed and stored but not yet consumed by the layout
18/// engine (e.g., for `::before`/`::after` pseudo-element generated content).
19#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
20#[repr(C)]
21pub struct Content {
22    pub inner: AzString,
23}
24
25impl Default for Content {
26    fn default() -> Self {
27        Self {
28            inner: "normal".into(),
29        }
30    }
31}
32
33impl PrintAsCssValue for Content {
34    fn print_as_css_value(&self) -> String {
35        self.inner.as_str().to_string()
36    }
37}
38
39/// CSS `counter-reset` property: resets a named counter to a given value.
40#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
41#[repr(C)]
42pub struct CounterReset {
43    pub counter_name: AzString,
44    pub value: i32,
45}
46
47impl CounterReset {
48    #[must_use] pub const fn new(counter_name: AzString, value: i32) -> Self {
49        Self {
50            counter_name,
51            value,
52        }
53    }
54
55    #[must_use] pub const fn none() -> Self {
56        Self {
57            counter_name: AzString::from_const_str("none"),
58            value: 0,
59        }
60    }
61
62    #[must_use] pub const fn list_item() -> Self {
63        Self {
64            counter_name: AzString::from_const_str("list-item"),
65            value: 0,
66        }
67    }
68}
69
70impl Default for CounterReset {
71    fn default() -> Self {
72        Self::none()
73    }
74}
75
76impl PrintAsCssValue for CounterReset {
77    fn print_as_css_value(&self) -> String {
78        if self.counter_name.as_str() == "none" {
79            "none".to_string()
80        } else {
81            alloc::format!("{} {}", self.counter_name.as_str(), self.value)
82        }
83    }
84}
85
86/// CSS `counter-increment` property: increments a named counter by a given value.
87#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
88#[repr(C)]
89pub struct CounterIncrement {
90    pub counter_name: AzString,
91    pub value: i32,
92}
93
94impl CounterIncrement {
95    #[must_use] pub const fn new(counter_name: AzString, value: i32) -> Self {
96        Self {
97            counter_name,
98            value,
99        }
100    }
101
102    #[must_use] pub const fn none() -> Self {
103        Self {
104            counter_name: AzString::from_const_str("none"),
105            value: 0,
106        }
107    }
108
109    #[must_use] pub const fn list_item() -> Self {
110        Self {
111            counter_name: AzString::from_const_str("list-item"),
112            value: 1,
113        }
114    }
115}
116
117impl Default for CounterIncrement {
118    fn default() -> Self {
119        Self::none()
120    }
121}
122
123impl PrintAsCssValue for CounterIncrement {
124    fn print_as_css_value(&self) -> String {
125        if self.counter_name.as_str() == "none" {
126            "none".to_string()
127        } else {
128            alloc::format!("{} {}", self.counter_name.as_str(), self.value)
129        }
130    }
131}
132
133/// CSS `string-set` property value, stored as a raw string.
134///
135/// **Note:** Currently parsed and stored but not yet consumed by the layout engine.
136#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
137#[repr(C)]
138pub struct StringSet {
139    pub inner: AzString,
140}
141
142impl Default for StringSet {
143    fn default() -> Self {
144        Self {
145            inner: "none".into(),
146        }
147    }
148}
149
150impl PrintAsCssValue for StringSet {
151    fn print_as_css_value(&self) -> String {
152        self.inner.as_str().to_string()
153    }
154}
155
156// Formatting to Rust code
157impl crate::codegen::format::FormatAsRustCode for Content {
158    fn format_as_rust_code(&self, _tabs: usize) -> String {
159        format!("Content {{ inner: String::from({:?}) }}", self.inner)
160    }
161}
162
163impl crate::codegen::format::FormatAsRustCode for CounterReset {
164    fn format_as_rust_code(&self, _tabs: usize) -> String {
165        alloc::format!(
166            "CounterReset {{ counter_name: AzString::from_const_str({:?}), value: {} }}",
167            self.counter_name.as_str(),
168            self.value
169        )
170    }
171}
172
173impl crate::codegen::format::FormatAsRustCode for CounterIncrement {
174    fn format_as_rust_code(&self, _tabs: usize) -> String {
175        alloc::format!(
176            "CounterIncrement {{ counter_name: AzString::from_const_str({:?}), value: {} }}",
177            self.counter_name.as_str(),
178            self.value
179        )
180    }
181}
182
183impl crate::codegen::format::FormatAsRustCode for StringSet {
184    fn format_as_rust_code(&self, _tabs: usize) -> String {
185        format!("StringSet {{ inner: String::from({:?}) }}", self.inner)
186    }
187}
188
189// --- PARSERS ---
190
191#[cfg(feature = "parser")]
192pub mod parser {
193    #[allow(clippy::wildcard_imports)] // parser submodule reuses the parent module's value types
194    use super::*;
195
196    // Simplified parsers that just take the raw string value.
197    /// # Errors
198    ///
199    /// Returns an error if `input` is not a valid CSS `content` value.
200    pub fn parse_content(input: &str) -> Result<Content, ()> {
201        Ok(Content {
202            inner: input.trim().into(),
203        })
204    }
205
206    fn parse_counter_name_value(input: &str, default_value: i32) -> Result<(AzString, i32), ()> {
207        let trimmed = input.trim();
208
209        if trimmed == "none" {
210            return Ok((AzString::from_const_str("none"), 0));
211        }
212
213        let parts: Vec<&str> = trimmed.split_whitespace().collect();
214
215        if parts.is_empty() {
216            return Err(());
217        }
218
219        let counter_name = parts[0].into();
220        let value = if parts.len() > 1 {
221            parts[1].parse::<i32>().map_err(|_| ())?
222        } else {
223            default_value
224        };
225
226        Ok((counter_name, value))
227    }
228
229    /// # Errors
230    ///
231    /// Returns an error if `input` is not a valid CSS `counter-reset` value.
232    pub fn parse_counter_reset(input: &str) -> Result<CounterReset, ()> {
233        let (counter_name, value) = parse_counter_name_value(input, 0)?;
234        Ok(CounterReset::new(counter_name, value))
235    }
236
237    /// # Errors
238    ///
239    /// Returns an error if `input` is not a valid CSS `counter-increment` value.
240    pub fn parse_counter_increment(input: &str) -> Result<CounterIncrement, ()> {
241        let (counter_name, value) = parse_counter_name_value(input, 1)?;
242        Ok(CounterIncrement::new(counter_name, value))
243    }
244
245    /// # Errors
246    ///
247    /// Returns an error if `input` is not a valid CSS `string-set` value.
248    pub fn parse_string_set(input: &str) -> Result<StringSet, ()> {
249        Ok(StringSet {
250            inner: input.trim().into(),
251        })
252    }
253}
254
255#[cfg(feature = "parser")]
256pub use parser::*;
257
258#[cfg(all(test, feature = "parser"))]
259mod tests {
260    use super::*;
261
262    #[test]
263    fn test_simple_content_parser() {
264        assert_eq!(parse_content("'Hello'").unwrap().inner.as_str(), "'Hello'");
265
266        // Test counter-reset parsing
267        let reset = parse_counter_reset("page 1").unwrap();
268        assert_eq!(reset.counter_name.as_str(), "page");
269        assert_eq!(reset.value, 1);
270
271        let reset = parse_counter_reset("list-item 0").unwrap();
272        assert_eq!(reset.counter_name.as_str(), "list-item");
273        assert_eq!(reset.value, 0);
274
275        let reset = parse_counter_reset("none").unwrap();
276        assert_eq!(reset.counter_name.as_str(), "none");
277
278        // Test counter-increment parsing
279        let inc = parse_counter_increment("section").unwrap();
280        assert_eq!(inc.counter_name.as_str(), "section");
281        assert_eq!(inc.value, 1); // Default value
282
283        let inc = parse_counter_increment("list-item 2").unwrap();
284        assert_eq!(inc.counter_name.as_str(), "list-item");
285        assert_eq!(inc.value, 2);
286
287        assert_eq!(
288            parse_string_set("chapter-title content()")
289                .unwrap()
290                .inner
291                .as_str(),
292            "chapter-title content()"
293        );
294    }
295}
296
297#[cfg(all(test, feature = "parser"))]
298mod autotest_generated {
299    use std::{
300        collections::hash_map::DefaultHasher,
301        hash::{Hash, Hasher},
302    };
303
304    use super::*;
305    use crate::codegen::format::FormatAsRustCode;
306
307    fn hash_of<T: Hash>(t: &T) -> u64 {
308        let mut h = DefaultHasher::new();
309        t.hash(&mut h);
310        h.finish()
311    }
312
313    /// Nasty inputs that must never panic in any parser in this module.
314    fn hostile_corpus() -> Vec<String> {
315        alloc::vec![
316            String::new(),
317            " ".to_string(),
318            "\t\n\r\u{b}\u{c}".to_string(),
319            "\u{a0}".to_string(),      // NBSP: Unicode White_Space
320            "\u{200b}".to_string(),    // ZWSP: NOT White_Space
321            "\0".to_string(),          // embedded NUL
322            "\0page\0 1\0".to_string(),
323            ";".to_string(),
324            "}{".to_string(),
325            "counter(section) \". \"".to_string(),
326            "-".to_string(),
327            "--".to_string(),
328            "+".to_string(),
329            "e".to_string(),
330            "NaN".to_string(),
331            "inf".to_string(),
332            "-inf".to_string(),
333            "\u{1F600}".to_string(),
334            "a\u{0301}\u{0301}\u{0301}".to_string(), // stacked combining marks
335            "\u{202e}reversed".to_string(),          // RTL override
336            "page 1 page 2 page 3".to_string(),
337            "page \u{fffd}".to_string(),
338            "\"unterminated".to_string(),
339            "url(".to_string(),
340        ]
341    }
342
343    // ---------------------------------------------------------------
344    // Constructors: CounterReset::{new,none,list_item}
345    //               CounterIncrement::{new,none,list_item}
346    // ---------------------------------------------------------------
347
348    #[test]
349    fn counter_constructors_preserve_fields_at_i32_extremes() {
350        for value in [i32::MIN, i32::MIN + 1, -1, 0, 1, i32::MAX - 1, i32::MAX] {
351            let r = CounterReset::new(AzString::from_const_str("c"), value);
352            assert_eq!(r.counter_name.as_str(), "c");
353            assert_eq!(r.value, value);
354
355            let i = CounterIncrement::new(AzString::from_const_str("c"), value);
356            assert_eq!(i.counter_name.as_str(), "c");
357            assert_eq!(i.value, value);
358        }
359    }
360
361    #[test]
362    fn counter_constructors_accept_degenerate_names_verbatim() {
363        // Empty, whitespace-laden, unicode and very long names are stored as-is:
364        // the constructors perform no validation whatsoever.
365        let huge: String = "x".repeat(100_000);
366        let names = ["", " ", "a b", "\u{1F600}\u{1F600}", "none", huge.as_str()];
367
368        for name in names {
369            let r = CounterReset::new(name.into(), i32::MIN);
370            assert_eq!(r.counter_name.as_str(), name);
371            assert_eq!(r.counter_name.as_str().len(), name.len());
372            assert_eq!(r.value, i32::MIN);
373
374            let i = CounterIncrement::new(name.into(), i32::MAX);
375            assert_eq!(i.counter_name.as_str(), name);
376            assert_eq!(i.counter_name.as_str().len(), name.len());
377            assert_eq!(i.value, i32::MAX);
378        }
379    }
380
381    #[test]
382    fn counter_none_and_list_item_constants() {
383        assert_eq!(CounterReset::none().counter_name.as_str(), "none");
384        assert_eq!(CounterReset::none().value, 0);
385        assert_eq!(CounterReset::default(), CounterReset::none());
386
387        assert_eq!(CounterIncrement::none().counter_name.as_str(), "none");
388        assert_eq!(CounterIncrement::none().value, 0);
389        assert_eq!(CounterIncrement::default(), CounterIncrement::none());
390
391        // Per CSS, `counter-reset: list-item` starts at 0 while
392        // `counter-increment: list-item` steps by 1 -- the asymmetry is intended.
393        assert_eq!(CounterReset::list_item().counter_name.as_str(), "list-item");
394        assert_eq!(CounterReset::list_item().value, 0);
395        assert_eq!(
396            CounterIncrement::list_item().counter_name.as_str(),
397            "list-item"
398        );
399        assert_eq!(CounterIncrement::list_item().value, 1);
400    }
401
402    #[test]
403    fn content_and_string_set_defaults() {
404        assert_eq!(Content::default().inner.as_str(), "normal");
405        assert_eq!(StringSet::default().inner.as_str(), "none");
406    }
407
408    // ---------------------------------------------------------------
409    // parse_content / parse_string_set  (raw passthrough parsers)
410    // ---------------------------------------------------------------
411
412    #[test]
413    fn content_and_string_set_are_pure_trim_and_never_error() {
414        // NOTE: both parsers are infallible despite their `# Errors` docs --
415        // they accept empty input and arbitrary garbage. `str::trim` is the
416        // exact oracle, which also means Unicode whitespace (NBSP) is stripped
417        // while ZWSP is not.
418        for input in hostile_corpus() {
419            let c = parse_content(&input).expect("parse_content never errors");
420            assert_eq!(c.inner.as_str(), input.trim());
421
422            let s = parse_string_set(&input).expect("parse_string_set never errors");
423            assert_eq!(s.inner.as_str(), input.trim());
424        }
425
426        assert_eq!(parse_content("").unwrap().inner.as_str(), "");
427        assert_eq!(parse_content("   \t\n  ").unwrap().inner.as_str(), "");
428        assert_eq!(parse_string_set("").unwrap().inner.as_str(), "");
429        // NBSP is Unicode White_Space, so it is trimmed away entirely:
430        assert_eq!(parse_content("\u{a0}x\u{a0}").unwrap().inner.as_str(), "x");
431        // ZWSP is not, so it survives as content:
432        assert_eq!(
433            parse_content(" \u{200b} ").unwrap().inner.as_str(),
434            "\u{200b}"
435        );
436    }
437
438    #[test]
439    fn content_positive_control_and_inner_junk_is_kept() {
440        assert_eq!(parse_content("'Hi'").unwrap().inner.as_str(), "'Hi'");
441        assert_eq!(parse_content("  'Hi'  ").unwrap().inner.as_str(), "'Hi'");
442        // Trailing junk is *not* rejected, only outer whitespace is stripped:
443        assert_eq!(
444            parse_content("'Hi';garbage").unwrap().inner.as_str(),
445            "'Hi';garbage"
446        );
447        assert_eq!(
448            parse_string_set("chapter content()").unwrap().inner.as_str(),
449            "chapter content()"
450        );
451    }
452
453    #[test]
454    fn content_extremely_long_input_does_not_hang() {
455        let huge = "a".repeat(1_000_000);
456        let padded = alloc::format!("   {huge}\n");
457
458        let c = parse_content(&padded).unwrap();
459        assert_eq!(c.inner.as_str().len(), 1_000_000);
460        assert!(c.inner.as_str().starts_with("aa"));
461
462        let s = parse_string_set(&padded).unwrap();
463        assert_eq!(s.inner.as_str().len(), 1_000_000);
464    }
465
466    #[test]
467    fn content_deeply_nested_input_does_not_stack_overflow() {
468        // The parser is non-recursive, so 10k nested brackets are just bytes.
469        let nested = alloc::format!("{}{}", "(".repeat(10_000), ")".repeat(10_000));
470        let c = parse_content(&nested).unwrap();
471        assert_eq!(c.inner.as_str().len(), 20_000);
472
473        let deep = alloc::format!("{}x{}", "counter(".repeat(5_000), ")".repeat(5_000));
474        assert!(parse_content(&deep).is_ok());
475        assert!(parse_string_set(&deep).is_ok());
476    }
477
478    #[test]
479    fn content_preserves_multibyte_unicode_byte_for_byte() {
480        let input = "  \u{1F600}a\u{0301}\u{4e2d}\u{6587}  ";
481        let c = parse_content(input).unwrap();
482        assert_eq!(c.inner.as_str(), input.trim());
483        assert_eq!(c.inner.as_str().len(), input.trim().len());
484        // 5 scalar values, not 4 glyphs: U+0301 COMBINING ACUTE ACCENT is its own
485        // `char` (it renders as one grapheme with the preceding 'a', but `chars()`
486        // counts scalars).
487        assert_eq!(c.inner.as_str().chars().count(), 5);
488    }
489
490    #[test]
491    fn content_boundary_number_strings_are_accepted_as_plain_text() {
492        // `content` has no numeric grammar here: numbers survive verbatim.
493        for n in [
494            "0",
495            "-0",
496            "9223372036854775807",
497            "-9223372036854775808",
498            "1e309",
499            "NaN",
500            "inf",
501        ] {
502            assert_eq!(parse_content(n).unwrap().inner.as_str(), n);
503            assert_eq!(parse_string_set(n).unwrap().inner.as_str(), n);
504        }
505    }
506
507    #[test]
508    fn content_print_parse_roundtrips_for_parsed_values() {
509        for input in hostile_corpus() {
510            let c = parse_content(&input).unwrap();
511            let reparsed = parse_content(&c.print_as_css_value()).unwrap();
512            assert_eq!(reparsed, c, "content round-trip failed for {input:?}");
513
514            let s = parse_string_set(&input).unwrap();
515            let re_s = parse_string_set(&s.print_as_css_value()).unwrap();
516            assert_eq!(re_s, s, "string-set round-trip failed for {input:?}");
517        }
518
519        let d = Content::default();
520        assert_eq!(parse_content(&d.print_as_css_value()).unwrap(), d);
521        let d = StringSet::default();
522        assert_eq!(parse_string_set(&d.print_as_css_value()).unwrap(), d);
523    }
524
525    #[test]
526    fn content_roundtrip_is_lossy_for_untrimmed_handbuilt_values() {
527        // print -> parse is only idempotent for already-trimmed values; a value
528        // built directly (not via the parser) loses its padding on re-parse.
529        let padded = Content {
530            inner: "  x  ".into(),
531        };
532        assert_eq!(padded.print_as_css_value(), "  x  ");
533        assert_ne!(parse_content(&padded.print_as_css_value()).unwrap(), padded);
534        assert_eq!(
535            parse_content(&padded.print_as_css_value())
536                .unwrap()
537                .inner
538                .as_str(),
539            "x"
540        );
541    }
542
543    // ---------------------------------------------------------------
544    // parse_counter_reset / parse_counter_increment
545    // (these fully exercise the private `parse_counter_name_value`:
546    //  default_value = 0 for reset, 1 for increment)
547    // ---------------------------------------------------------------
548
549    #[test]
550    fn counter_empty_and_whitespace_only_input_is_rejected() {
551        for input in ["", " ", "   ", "\t\n", "\r\n\t ", "\u{a0}", "\u{2003}"] {
552            assert!(
553                parse_counter_reset(input).is_err(),
554                "counter-reset accepted whitespace-only {input:?}"
555            );
556            assert!(
557                parse_counter_increment(input).is_err(),
558                "counter-increment accepted whitespace-only {input:?}"
559            );
560        }
561    }
562
563    #[test]
564    fn counter_missing_value_uses_per_property_default() {
565        let r = parse_counter_reset("section").unwrap();
566        assert_eq!(r.counter_name.as_str(), "section");
567        assert_eq!(r.value, 0);
568
569        let i = parse_counter_increment("section").unwrap();
570        assert_eq!(i.counter_name.as_str(), "section");
571        assert_eq!(i.value, 1);
572    }
573
574    #[test]
575    fn counter_none_keyword_is_case_sensitive() {
576        // CSS keywords are ASCII case-insensitive, but only lowercase `none`
577        // hits the keyword branch. `NONE` is treated as a *counter name*.
578        let r = parse_counter_reset("NONE").unwrap();
579        assert_eq!(r.counter_name.as_str(), "NONE");
580        assert_eq!(r.value, 0);
581
582        let i = parse_counter_increment("None").unwrap();
583        assert_eq!(i.counter_name.as_str(), "None");
584        assert_eq!(i.value, 1, "uppercase `None` took the counter-name branch");
585
586        // The lowercase keyword branch ignores the property default entirely.
587        assert_eq!(parse_counter_increment("none").unwrap().value, 0);
588        assert_eq!(parse_counter_increment("  none  ").unwrap().value, 0);
589    }
590
591    #[test]
592    fn counter_none_with_value_keeps_value_but_prints_as_bare_none() {
593        // "none 5" misses the keyword fast-path (it is not *exactly* "none"),
594        // so it parses as a counter literally named "none" with value 5 --
595        // but PrintAsCssValue then drops the 5, so print->parse is lossy.
596        let r = parse_counter_reset("none 5").unwrap();
597        assert_eq!(r.counter_name.as_str(), "none");
598        assert_eq!(r.value, 5);
599        assert_eq!(r.print_as_css_value(), "none");
600
601        let reparsed = parse_counter_reset(&r.print_as_css_value()).unwrap();
602        assert_eq!(reparsed.value, 0);
603        assert_ne!(reparsed, r, "value 5 silently vanished across a round-trip");
604    }
605
606    #[test]
607    fn counter_value_at_i32_boundaries_parses_exactly() {
608        assert_eq!(parse_counter_reset("c 2147483647").unwrap().value, i32::MAX);
609        assert_eq!(parse_counter_reset("c -2147483648").unwrap().value, i32::MIN);
610        assert_eq!(parse_counter_reset("c 0").unwrap().value, 0);
611        assert_eq!(parse_counter_reset("c -0").unwrap().value, 0);
612        assert_eq!(parse_counter_reset("c +7").unwrap().value, 7);
613        assert_eq!(parse_counter_reset("c 007").unwrap().value, 7);
614        assert_eq!(
615            parse_counter_increment("c -2147483648").unwrap().value,
616            i32::MIN
617        );
618    }
619
620    #[test]
621    fn counter_value_overflowing_i32_is_rejected_not_wrapped() {
622        for over in [
623            "c 2147483648",           // i32::MAX + 1
624            "c -2147483649",          // i32::MIN - 1
625            "c 9223372036854775807",  // i64::MAX
626            "c -9223372036854775808", // i64::MIN
627            "c 340282366920938463463374607431768211456",
628        ] {
629            assert!(
630                parse_counter_reset(over).is_err(),
631                "overflowing value silently accepted: {over:?}"
632            );
633            assert!(parse_counter_increment(over).is_err());
634        }
635    }
636
637    #[test]
638    fn counter_non_integer_values_are_rejected() {
639        for bad in [
640            "c 1.0", "c 1.5", "c 1e3", "c NaN", "c nan", "c inf", "c -inf", "c 0x10", "c 1_000",
641            "c 1,", "c one", "c -", "c +", "c ٣", // Arabic-Indic digit three
642            "c 1",  // fullwidth digit one
643            "c 1\u{200b}",
644        ] {
645            assert!(
646                parse_counter_reset(bad).is_err(),
647                "counter-reset accepted non-integer {bad:?}"
648            );
649            assert!(
650                parse_counter_increment(bad).is_err(),
651                "counter-increment accepted non-integer {bad:?}"
652            );
653        }
654    }
655
656    #[test]
657    fn counter_extra_tokens_after_the_first_pair_are_silently_dropped() {
658        // CSS allows a *list* of counters; this parser keeps only the first
659        // name/value pair and discards the rest without erroring.
660        let r = parse_counter_reset("a 1 b 2 c 3").unwrap();
661        assert_eq!(r.counter_name.as_str(), "a");
662        assert_eq!(r.value, 1);
663
664        // ...the parse errors instead of falling back to a default when the
665        // second token is not an integer.
666        parse_counter_increment("a b").unwrap_err();
667    }
668
669    #[test]
670    fn counter_arbitrary_whitespace_forms_are_normalized() {
671        for input in [
672            "\t page \n 42 \r",
673            "page\u{a0}42",   // NBSP separates under split_whitespace
674            "page    42",     // runs of spaces
675            "\u{2003}page 42", // em-space
676        ] {
677            let r = parse_counter_reset(input).unwrap();
678            assert_eq!(r.counter_name.as_str(), "page", "for {input:?}");
679            assert_eq!(r.value, 42, "for {input:?}");
680        }
681    }
682
683    #[test]
684    fn counter_unicode_names_are_preserved() {
685        let r = parse_counter_reset("\u{7ae0}\u{8282} 3").unwrap();
686        assert_eq!(r.counter_name.as_str(), "\u{7ae0}\u{8282}");
687        assert_eq!(r.value, 3);
688
689        let i = parse_counter_increment("\u{1F600}").unwrap();
690        assert_eq!(i.counter_name.as_str(), "\u{1F600}");
691        assert_eq!(i.value, 1);
692
693        // A zero-width space is not whitespace: it becomes a counter name.
694        let z = parse_counter_reset("\u{200b}").unwrap();
695        assert_eq!(z.counter_name.as_str(), "\u{200b}");
696        assert_eq!(z.value, 0);
697    }
698
699    #[test]
700    fn counter_hostile_corpus_never_panics() {
701        for input in hostile_corpus() {
702            let _ = parse_counter_reset(&input);
703            let _ = parse_counter_increment(&input);
704        }
705    }
706
707    #[test]
708    fn counter_extremely_long_input_does_not_hang() {
709        let long_name = "n".repeat(1_000_000);
710        let r = parse_counter_reset(&long_name).unwrap();
711        assert_eq!(r.counter_name.as_str().len(), 1_000_000);
712        assert_eq!(r.value, 0);
713
714        let with_value = alloc::format!("{long_name} 5");
715        assert_eq!(parse_counter_increment(&with_value).unwrap().value, 5);
716
717        // A million digits must be rejected, not truncated or wrapped.
718        let long_number = alloc::format!("c {}", "9".repeat(1_000_000));
719        assert!(parse_counter_reset(&long_number).is_err());
720
721        // Whitespace-only input of the same size is still just an error.
722        assert!(parse_counter_reset(&" ".repeat(1_000_000)).is_err());
723    }
724
725    #[test]
726    fn counter_deeply_nested_input_does_not_stack_overflow() {
727        let nested = alloc::format!("{}{}", "(".repeat(10_000), ")".repeat(10_000));
728        let r = parse_counter_reset(&nested).unwrap();
729        assert_eq!(r.counter_name.as_str().len(), 20_000);
730        assert_eq!(r.value, 0);
731
732        let nested_with_value = alloc::format!("{nested} 1");
733        assert_eq!(parse_counter_increment(&nested_with_value).unwrap().value, 1);
734    }
735
736    // ---------------------------------------------------------------
737    // PrintAsCssValue <-> parser round-trips
738    // ---------------------------------------------------------------
739
740    #[test]
741    fn counter_print_parse_roundtrips_for_well_formed_values() {
742        for (name, value) in [
743            ("page", 0),
744            ("page", 1),
745            ("section", -1),
746            ("list-item", i32::MAX),
747            ("list-item", i32::MIN),
748            ("\u{7ae0}", 7),
749        ] {
750            let r = CounterReset::new(name.into(), value);
751            assert_eq!(parse_counter_reset(&r.print_as_css_value()).unwrap(), r);
752
753            let i = CounterIncrement::new(name.into(), value);
754            assert_eq!(parse_counter_increment(&i.print_as_css_value()).unwrap(), i);
755        }
756
757        // `none`/`list_item` constants also survive a full round-trip.
758        let n = CounterReset::none();
759        assert_eq!(n.print_as_css_value(), "none");
760        assert_eq!(parse_counter_reset(&n.print_as_css_value()).unwrap(), n);
761
762        let li = CounterIncrement::list_item();
763        assert_eq!(li.print_as_css_value(), "list-item 1");
764        assert_eq!(parse_counter_increment(&li.print_as_css_value()).unwrap(), li);
765
766        assert_eq!(CounterReset::list_item().print_as_css_value(), "list-item 0");
767        assert_eq!(CounterIncrement::none().print_as_css_value(), "none");
768    }
769
770    #[test]
771    fn counter_print_of_empty_name_reparses_into_a_different_counter() {
772        // An empty name prints as " 5"; re-parsing reads "5" as the *name*
773        // and falls back to the default value -- a silent identity change.
774        let r = CounterReset::new(AzString::from_const_str(""), 5);
775        assert_eq!(r.print_as_css_value(), " 5");
776
777        let reparsed = parse_counter_reset(&r.print_as_css_value()).unwrap();
778        assert_eq!(reparsed.counter_name.as_str(), "5");
779        assert_eq!(reparsed.value, 0);
780        assert_ne!(reparsed, r);
781    }
782
783    #[test]
784    fn counter_print_of_name_containing_space_fails_to_reparse() {
785        // "a b" + " 5" prints as "a b 5"; the second token "b" is not an i32,
786        // so the printed form is no longer parseable at all.
787        let r = CounterReset::new("a b".into(), 5);
788        assert_eq!(r.print_as_css_value(), "a b 5");
789        assert!(parse_counter_reset(&r.print_as_css_value()).is_err());
790
791        let i = CounterIncrement::new("a b".into(), 5);
792        assert!(parse_counter_increment(&i.print_as_css_value()).is_err());
793    }
794
795    // ---------------------------------------------------------------
796    // Derived-trait invariants (Eq / Ord / Hash) and codegen formatting
797    // ---------------------------------------------------------------
798
799    #[test]
800    fn counter_ord_is_name_then_value_and_hash_agrees_with_eq() {
801        let a1 = CounterReset::new("a".into(), 1);
802        let a2 = CounterReset::new("a".into(), 2);
803        let b_min = CounterReset::new("b".into(), i32::MIN);
804
805        assert!(a1 < a2, "equal names must order by value");
806        assert!(a2 < b_min, "name must dominate value in the ordering");
807        assert!(CounterReset::new("a".into(), i32::MAX) < b_min);
808
809        // Eq/Hash consistency, including across differently-allocated names.
810        let owned = CounterReset::new(String::from("a").into(), 1);
811        assert_eq!(owned, a1);
812        assert_eq!(hash_of(&owned), hash_of(&a1));
813        assert_ne!(a1, a2);
814
815        assert_eq!(
816            hash_of(&Content {
817                inner: "x".into()
818            }),
819            hash_of(&parse_content(" x ").unwrap())
820        );
821    }
822
823    #[test]
824    fn format_as_rust_code_escapes_quotes_and_control_chars() {
825        let c = Content {
826            inner: "a\"b\\c\nd".into(),
827        };
828        assert_eq!(
829            c.format_as_rust_code(0),
830            r#"Content { inner: String::from("a\"b\\c\nd") }"#
831        );
832
833        let s = StringSet {
834            inner: "\"q\"".into(),
835        };
836        assert_eq!(
837            s.format_as_rust_code(0),
838            r#"StringSet { inner: String::from("\"q\"") }"#
839        );
840
841        let r = CounterReset::new("a\"b".into(), i32::MIN);
842        assert_eq!(
843            r.format_as_rust_code(0),
844            r#"CounterReset { counter_name: AzString::from_const_str("a\"b"), value: -2147483648 }"#
845        );
846
847        let i = CounterIncrement::new("".into(), i32::MAX);
848        assert_eq!(
849            i.format_as_rust_code(0),
850            r#"CounterIncrement { counter_name: AzString::from_const_str(""), value: 2147483647 }"#
851        );
852    }
853
854    #[test]
855    fn format_as_rust_code_ignores_the_tab_argument() {
856        let c = Content::default();
857        assert_eq!(c.format_as_rust_code(0), c.format_as_rust_code(usize::MAX));
858
859        let r = CounterReset::list_item();
860        assert_eq!(r.format_as_rust_code(0), r.format_as_rust_code(usize::MAX));
861    }
862}