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