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

            
7
use alloc::string::{String, ToString};
8

            
9
use 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)]
21
pub struct Content {
22
    pub inner: AzString,
23
}
24

            
25
impl Default for Content {
26
3
    fn default() -> Self {
27
3
        Self {
28
3
            inner: "normal".into(),
29
3
        }
30
3
    }
31
}
32

            
33
impl PrintAsCssValue for Content {
34
28
    fn print_as_css_value(&self) -> String {
35
28
        self.inner.as_str().to_string()
36
28
    }
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)]
42
pub struct CounterReset {
43
    pub counter_name: AzString,
44
    pub value: i32,
45
}
46

            
47
impl CounterReset {
48
74
    #[must_use] pub const fn new(counter_name: AzString, value: i32) -> Self {
49
74
        Self {
50
74
            counter_name,
51
74
            value,
52
74
        }
53
74
    }
54

            
55
5
    #[must_use] pub const fn none() -> Self {
56
5
        Self {
57
5
            counter_name: AzString::from_const_str("none"),
58
5
            value: 0,
59
5
        }
60
5
    }
61

            
62
4
    #[must_use] pub const fn list_item() -> Self {
63
4
        Self {
64
4
            counter_name: AzString::from_const_str("list-item"),
65
4
            value: 0,
66
4
        }
67
4
    }
68
}
69

            
70
impl Default for CounterReset {
71
1
    fn default() -> Self {
72
1
        Self::none()
73
1
    }
74
}
75

            
76
impl PrintAsCssValue for CounterReset {
77
15
    fn print_as_css_value(&self) -> String {
78
15
        if self.counter_name.as_str() == "none" {
79
4
            "none".to_string()
80
        } else {
81
11
            alloc::format!("{} {}", self.counter_name.as_str(), self.value)
82
        }
83
15
    }
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)]
89
pub struct CounterIncrement {
90
    pub counter_name: AzString,
91
    pub value: i32,
92
}
93

            
94
impl CounterIncrement {
95
55
    #[must_use] pub const fn new(counter_name: AzString, value: i32) -> Self {
96
55
        Self {
97
55
            counter_name,
98
55
            value,
99
55
        }
100
55
    }
101

            
102
5
    #[must_use] pub const fn none() -> Self {
103
5
        Self {
104
5
            counter_name: AzString::from_const_str("none"),
105
5
            value: 0,
106
5
        }
107
5
    }
108

            
109
3
    #[must_use] pub const fn list_item() -> Self {
110
3
        Self {
111
3
            counter_name: AzString::from_const_str("list-item"),
112
3
            value: 1,
113
3
        }
114
3
    }
115
}
116

            
117
impl Default for CounterIncrement {
118
1
    fn default() -> Self {
119
1
        Self::none()
120
1
    }
121
}
122

            
123
impl PrintAsCssValue for CounterIncrement {
124
10
    fn print_as_css_value(&self) -> String {
125
10
        if self.counter_name.as_str() == "none" {
126
1
            "none".to_string()
127
        } else {
128
9
            alloc::format!("{} {}", self.counter_name.as_str(), self.value)
129
        }
130
10
    }
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)]
138
pub struct StringSet {
139
    pub inner: AzString,
140
}
141

            
142
impl Default for StringSet {
143
2
    fn default() -> Self {
144
2
        Self {
145
2
            inner: "none".into(),
146
2
        }
147
2
    }
148
}
149

            
150
impl PrintAsCssValue for StringSet {
151
25
    fn print_as_css_value(&self) -> String {
152
25
        self.inner.as_str().to_string()
153
25
    }
154
}
155

            
156
// Formatting to Rust code
157
impl crate::codegen::format::FormatAsRustCode for Content {
158
3
    fn format_as_rust_code(&self, _tabs: usize) -> String {
159
3
        format!("Content {{ inner: String::from({:?}) }}", self.inner)
160
3
    }
161
}
162

            
163
impl crate::codegen::format::FormatAsRustCode for CounterReset {
164
3
    fn format_as_rust_code(&self, _tabs: usize) -> String {
165
3
        alloc::format!(
166
3
            "CounterReset {{ counter_name: AzString::from_const_str({:?}), value: {} }}",
167
3
            self.counter_name.as_str(),
168
            self.value
169
        )
170
3
    }
171
}
172

            
173
impl crate::codegen::format::FormatAsRustCode for CounterIncrement {
174
1
    fn format_as_rust_code(&self, _tabs: usize) -> String {
175
1
        alloc::format!(
176
1
            "CounterIncrement {{ counter_name: AzString::from_const_str({:?}), value: {} }}",
177
1
            self.counter_name.as_str(),
178
            self.value
179
        )
180
1
    }
181
}
182

            
183
impl crate::codegen::format::FormatAsRustCode for StringSet {
184
1
    fn format_as_rust_code(&self, _tabs: usize) -> String {
185
1
        format!("StringSet {{ inner: String::from({:?}) }}", self.inner)
186
1
    }
187
}
188

            
189
// --- PARSERS ---
190

            
191
#[cfg(feature = "parser")]
192
pub 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
126
    pub fn parse_content(input: &str) -> Result<Content, ()> {
201
126
        Ok(Content {
202
126
            inner: input.trim().into(),
203
126
        })
204
126
    }
205

            
206
156
    fn parse_counter_name_value(input: &str, default_value: i32) -> Result<(AzString, i32), ()> {
207
156
        let trimmed = input.trim();
208

            
209
156
        if trimmed == "none" {
210
5
            return Ok((AzString::from_const_str("none"), 0));
211
151
        }
212

            
213
151
        let parts: Vec<&str> = trimmed.split_whitespace().collect();
214

            
215
151
        if parts.is_empty() {
216
23
            return Err(());
217
128
        }
218

            
219
128
        let counter_name = parts[0].into();
220
128
        let value = if parts.len() > 1 {
221
86
            parts[1].parse::<i32>().map_err(|_| ())?
222
        } else {
223
42
            default_value
224
        };
225

            
226
76
        Ok((counter_name, value))
227
156
    }
228

            
229
    /// # Errors
230
    ///
231
    /// Returns an error if `input` is not a valid CSS `counter-reset` value.
232
85
    pub fn parse_counter_reset(input: &str) -> Result<CounterReset, ()> {
233
85
        let (counter_name, value) = parse_counter_name_value(input, 0)?;
234
47
        Ok(CounterReset::new(counter_name, value))
235
85
    }
236

            
237
    /// # Errors
238
    ///
239
    /// Returns an error if `input` is not a valid CSS `counter-increment` value.
240
71
    pub fn parse_counter_increment(input: &str) -> Result<CounterIncrement, ()> {
241
71
        let (counter_name, value) = parse_counter_name_value(input, 1)?;
242
34
        Ok(CounterIncrement::new(counter_name, value))
243
71
    }
244

            
245
    /// # Errors
246
    ///
247
    /// Returns an error if `input` is not a valid CSS `string-set` value.
248
85
    pub fn parse_string_set(input: &str) -> Result<StringSet, ()> {
249
85
        Ok(StringSet {
250
85
            inner: input.trim().into(),
251
85
        })
252
85
    }
253
}
254

            
255
#[cfg(feature = "parser")]
256
pub use parser::*;
257

            
258
#[cfg(all(test, feature = "parser"))]
259
mod tests {
260
    use super::*;
261

            
262
    #[test]
263
1
    fn test_simple_content_parser() {
264
1
        assert_eq!(parse_content("'Hello'").unwrap().inner.as_str(), "'Hello'");
265

            
266
        // Test counter-reset parsing
267
1
        let reset = parse_counter_reset("page 1").unwrap();
268
1
        assert_eq!(reset.counter_name.as_str(), "page");
269
1
        assert_eq!(reset.value, 1);
270

            
271
1
        let reset = parse_counter_reset("list-item 0").unwrap();
272
1
        assert_eq!(reset.counter_name.as_str(), "list-item");
273
1
        assert_eq!(reset.value, 0);
274

            
275
1
        let reset = parse_counter_reset("none").unwrap();
276
1
        assert_eq!(reset.counter_name.as_str(), "none");
277

            
278
        // Test counter-increment parsing
279
1
        let inc = parse_counter_increment("section").unwrap();
280
1
        assert_eq!(inc.counter_name.as_str(), "section");
281
1
        assert_eq!(inc.value, 1); // Default value
282

            
283
1
        let inc = parse_counter_increment("list-item 2").unwrap();
284
1
        assert_eq!(inc.counter_name.as_str(), "list-item");
285
1
        assert_eq!(inc.value, 2);
286

            
287
1
        assert_eq!(
288
1
            parse_string_set("chapter-title content()")
289
1
                .unwrap()
290
1
                .inner
291
1
                .as_str(),
292
            "chapter-title content()"
293
        );
294
1
    }
295
}
296

            
297
#[cfg(all(test, feature = "parser"))]
298
mod 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
}