1
//! Azul-specific CSS properties for advanced layout features
2
//!
3
//! Defines `StyleExclusionMargin` (spacing between text and shape exclusions)
4
//! and `StyleHyphenationLanguage` (BCP 47 language code for automatic hyphenation).
5

            
6
use std::num::ParseFloatError;
7

            
8
#[cfg(feature = "parser")]
9
use crate::macros::*;
10
use crate::{
11
    corety::AzString,
12
    codegen::format::FormatAsRustCode,
13
    props::{
14
        basic::{length::parse_float_value, FloatValue},
15
        formatter::{FormatAsCssValue, PrintAsCssValue},
16
    },
17
};
18

            
19
/// `-azul-exclusion-margin` property: defines margin around shape exclusions
20
///
21
/// This property controls the spacing between text and shapes that text flows around.
22
/// It's similar to `shape-margin` but specifically for exclusions (text wrapping).
23
///
24
/// # Example
25
/// ```css
26
/// .element {
27
///     -azul-exclusion-margin: 10.5;
28
/// }
29
/// ```
30
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
31
#[repr(C)]
32
pub struct StyleExclusionMargin {
33
    pub inner: FloatValue,
34
}
35

            
36
impl Default for StyleExclusionMargin {
37
3
    fn default() -> Self {
38
3
        Self {
39
3
            inner: FloatValue::const_new(0),
40
3
        }
41
3
    }
42
}
43

            
44
impl StyleExclusionMargin {
45
23
    #[must_use] pub const fn is_initial(&self) -> bool {
46
23
        self.inner.number == 0
47
23
    }
48
}
49

            
50
impl PrintAsCssValue for StyleExclusionMargin {
51
22
    fn print_as_css_value(&self) -> String {
52
22
        format!("{}", self.inner.get())
53
22
    }
54
}
55

            
56
impl FormatAsCssValue for StyleExclusionMargin {
57
6
    fn format_as_css_value(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58
6
        write!(f, "{}", self.inner.get())
59
6
    }
60
}
61

            
62
impl FormatAsRustCode for StyleExclusionMargin {
63
14
    fn format_as_rust_code(&self, _tabs: usize) -> String {
64
14
        format!(
65
14
            "StyleExclusionMargin {{ inner: FloatValue::const_new({}) }}",
66
14
            self.inner.get()
67
        )
68
14
    }
69
}
70

            
71
#[cfg(feature = "parser")]
72
#[derive(Clone, PartialEq, Eq)]
73
pub enum StyleExclusionMarginParseError {
74
    FloatValue(ParseFloatError),
75
}
76

            
77
#[cfg(feature = "parser")]
78
impl_debug_as_display!(StyleExclusionMarginParseError);
79

            
80
#[cfg(feature = "parser")]
81
impl_display! { StyleExclusionMarginParseError, {
82
    FloatValue(e) => format!("Invalid -azul-exclusion-margin value: {}", e),
83
}}
84

            
85
#[cfg(feature = "parser")]
86
impl_from!(ParseFloatError, StyleExclusionMarginParseError::FloatValue);
87

            
88
#[cfg(feature = "parser")]
89
#[derive(Debug, Clone, PartialEq, Eq)]
90
#[repr(C, u8)]
91
pub enum StyleExclusionMarginParseErrorOwned {
92
    FloatValue(AzString),
93
}
94

            
95
#[cfg(feature = "parser")]
96
impl StyleExclusionMarginParseError {
97
9
    #[must_use] pub fn to_contained(&self) -> StyleExclusionMarginParseErrorOwned {
98
9
        match self {
99
9
            Self::FloatValue(e) => {
100
9
                StyleExclusionMarginParseErrorOwned::FloatValue(format!("{e}").into())
101
            }
102
        }
103
9
    }
104
}
105

            
106
#[cfg(feature = "parser")]
107
impl StyleExclusionMarginParseErrorOwned {
108
6
    #[must_use] pub fn to_shared(&self) -> StyleExclusionMarginParseError {
109
6
        match self {
110
            Self::FloatValue(_) => {
111
                // ParseFloatError can't be reconstructed from its display string,
112
                // so we create one by parsing a known-invalid string
113
6
                StyleExclusionMarginParseError::FloatValue("".parse::<f32>().unwrap_err())
114
            }
115
        }
116
6
    }
117
}
118

            
119
#[cfg(feature = "parser")]
120
/// # Errors
121
///
122
/// Returns an error if `input` is not a valid CSS `exclusion-margin` value.
123
104
pub fn parse_style_exclusion_margin(
124
104
    input: &str,
125
104
) -> Result<StyleExclusionMargin, StyleExclusionMarginParseError> {
126
104
    parse_float_value(input)
127
104
        .map(|inner| StyleExclusionMargin { inner })
128
104
        .map_err(StyleExclusionMarginParseError::FloatValue)
129
104
}
130

            
131
/// `-azul-hyphenation-language` property: specifies language for hyphenation
132
///
133
/// This property defines the language code (BCP 47 format) used for automatic
134
/// hyphenation. Examples: "en-US", "de-DE", "fr-FR"
135
///
136
/// # Example
137
/// ```css
138
/// .element {
139
///     -azul-hyphenation-language: "en-US";
140
/// }
141
/// ```
142
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
143
#[repr(C)]
144
pub struct StyleHyphenationLanguage {
145
    pub inner: AzString,
146
}
147

            
148
impl Default for StyleHyphenationLanguage {
149
2
    fn default() -> Self {
150
2
        Self {
151
2
            inner: AzString::from_const_str("en-US"),
152
2
        }
153
2
    }
154
}
155

            
156
impl StyleHyphenationLanguage {
157
15
    #[must_use] pub fn is_initial(&self) -> bool {
158
15
        self.inner.as_str() == "en-US"
159
15
    }
160
}
161

            
162
impl PrintAsCssValue for StyleHyphenationLanguage {
163
19
    fn print_as_css_value(&self) -> String {
164
19
        format!("\"{}\"", self.inner.as_str())
165
19
    }
166
}
167

            
168
impl FormatAsCssValue for StyleHyphenationLanguage {
169
5
    fn format_as_css_value(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
170
5
        write!(f, "\"{}\"", self.inner.as_str())
171
5
    }
172
}
173

            
174
impl FormatAsRustCode for StyleHyphenationLanguage {
175
12
    fn format_as_rust_code(&self, _tabs: usize) -> String {
176
12
        format!(
177
12
            "StyleHyphenationLanguage {{ inner: AzString::from_const_str(\"{}\") }}",
178
12
            self.inner.as_str()
179
        )
180
12
    }
181
}
182

            
183
#[cfg(feature = "parser")]
184
#[derive(Clone, PartialEq, Eq)]
185
pub enum StyleHyphenationLanguageParseError {
186
    InvalidString(String),
187
}
188

            
189
#[cfg(feature = "parser")]
190
impl_debug_as_display!(StyleHyphenationLanguageParseError);
191

            
192
#[cfg(feature = "parser")]
193
impl_display! { StyleHyphenationLanguageParseError, {
194
    InvalidString(e) => format!("Invalid -azul-hyphenation-language value: {}", e),
195
}}
196

            
197
#[cfg(feature = "parser")]
198
#[derive(Debug, Clone, PartialEq, Eq)]
199
#[repr(C, u8)]
200
pub enum StyleHyphenationLanguageParseErrorOwned {
201
    InvalidString(AzString),
202
}
203

            
204
#[cfg(feature = "parser")]
205
impl StyleHyphenationLanguageParseError {
206
13
    #[must_use] pub fn to_contained(&self) -> StyleHyphenationLanguageParseErrorOwned {
207
13
        match self {
208
13
            Self::InvalidString(e) => {
209
13
                StyleHyphenationLanguageParseErrorOwned::InvalidString(e.clone().into())
210
            }
211
        }
212
13
    }
213
}
214

            
215
#[cfg(feature = "parser")]
216
impl StyleHyphenationLanguageParseErrorOwned {
217
11
    #[must_use] pub fn to_shared(&self) -> StyleHyphenationLanguageParseError {
218
11
        match self {
219
11
            Self::InvalidString(e) => StyleHyphenationLanguageParseError::InvalidString(e.to_string()),
220
        }
221
11
    }
222
}
223

            
224
#[cfg(feature = "parser")]
225
/// # Errors
226
///
227
/// Returns an error if `input` is not a valid CSS `hyphenation-language` value.
228
95
pub fn parse_style_hyphenation_language(
229
95
    input: &str,
230
95
) -> Result<StyleHyphenationLanguage, StyleHyphenationLanguageParseError> {
231
    // Remove surrounding quotes if present. Require len >= 2 so a lone quote
232
    // (where starts_with and ends_with match the *same* char) is not stripped to
233
    // `&s[1..0]`, which would panic instead of failing validation below.
234
95
    let trimmed = input.trim();
235
95
    let unquoted = if trimmed.len() >= 2
236
82
        && ((trimmed.starts_with('"') && trimmed.ends_with('"'))
237
65
            || (trimmed.starts_with('\'') && trimmed.ends_with('\'')))
238
    {
239
26
        &trimmed[1..trimmed.len() - 1]
240
    } else {
241
69
        trimmed
242
    };
243

            
244
    // Basic BCP 47 validation: non-empty, ASCII alphanumeric + hyphens, no leading/trailing hyphens
245
95
    if unquoted.is_empty()
246
3000335
        || !unquoted.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')
247
48
        || unquoted.starts_with('-')
248
43
        || unquoted.ends_with('-')
249
    {
250
54
        return Err(StyleHyphenationLanguageParseError::InvalidString(
251
54
            unquoted.to_string(),
252
54
        ));
253
41
    }
254

            
255
41
    Ok(StyleHyphenationLanguage {
256
41
        inner: AzString::from_string(unquoted.to_string()),
257
41
    })
258
95
}
259

            
260
#[cfg(test)]
261
mod tests {
262
    // Tests assert that parsed values equal the exact source literals.
263
    #![allow(clippy::float_cmp)]
264
    use super::*;
265

            
266
    #[test]
267
1
    fn test_parse_exclusion_margin() {
268
1
        let margin = parse_style_exclusion_margin("10.5").unwrap();
269
1
        assert_eq!(margin.inner.get(), 10.5);
270

            
271
1
        let margin = parse_style_exclusion_margin("0").unwrap();
272
1
        assert_eq!(margin.inner.get(), 0.0);
273
1
    }
274

            
275
    #[test]
276
1
    fn test_parse_hyphenation_language() {
277
1
        let lang = parse_style_hyphenation_language("\"en-US\"").unwrap();
278
1
        assert_eq!(lang.inner.as_str(), "en-US");
279

            
280
1
        let lang = parse_style_hyphenation_language("'de-DE'").unwrap();
281
1
        assert_eq!(lang.inner.as_str(), "de-DE");
282

            
283
1
        let lang = parse_style_hyphenation_language("fr-FR").unwrap();
284
1
        assert_eq!(lang.inner.as_str(), "fr-FR");
285

            
286
1
        let lang = parse_style_hyphenation_language("zh").unwrap();
287
1
        assert_eq!(lang.inner.as_str(), "zh");
288

            
289
1
        let lang = parse_style_hyphenation_language("sr-Latn-RS").unwrap();
290
1
        assert_eq!(lang.inner.as_str(), "sr-Latn-RS");
291

            
292
        // Double hyphen is permitted by the current ASCII/format rules.
293
1
        let lang = parse_style_hyphenation_language("en--US").unwrap();
294
1
        assert_eq!(lang.inner.as_str(), "en--US");
295
1
    }
296

            
297
    #[test]
298
1
    fn test_parse_hyphenation_language_invalid() {
299
1
        assert!(matches!(
300
1
            parse_style_hyphenation_language(""),
301
            Err(StyleHyphenationLanguageParseError::InvalidString(_))
302
        ));
303
1
        assert!(matches!(
304
1
            parse_style_hyphenation_language("-en"),
305
            Err(StyleHyphenationLanguageParseError::InvalidString(_))
306
        ));
307
1
        assert!(matches!(
308
1
            parse_style_hyphenation_language("en-"),
309
            Err(StyleHyphenationLanguageParseError::InvalidString(_))
310
        ));
311
1
        assert!(matches!(
312
1
            parse_style_hyphenation_language("en_US"),
313
            Err(StyleHyphenationLanguageParseError::InvalidString(_))
314
        ));
315
1
        assert!(matches!(
316
1
            parse_style_hyphenation_language("日本語"),
317
            Err(StyleHyphenationLanguageParseError::InvalidString(_))
318
        ));
319
1
    }
320

            
321
    #[test]
322
1
    fn test_exclusion_margin_default() {
323
1
        let margin = StyleExclusionMargin::default();
324
1
        assert_eq!(margin.inner.get(), 0.0);
325
1
        assert!(margin.is_initial());
326
1
    }
327

            
328
    #[test]
329
1
    fn test_hyphenation_language_default() {
330
1
        let lang = StyleHyphenationLanguage::default();
331
1
        assert_eq!(lang.inner.as_str(), "en-US");
332
1
    }
333
}
334

            
335
#[cfg(test)]
336
mod autotest_generated {
337
    //! Adversarial tests: malformed / huge / unicode parser input, numeric
338
    //! saturation (`FloatValue` encodes `f32 * 1000.0` into an `isize`, so every
339
    //! non-finite input must land on a *finite* encoded value), encode/decode
340
    //! round-trips and predicate invariants.
341
    #![allow(clippy::float_cmp)]
342

            
343
    use super::*;
344

            
345
    /// `FormatAsCssValue` needs a real `Formatter`; this adapter supplies one.
346
    struct AsCss<'a, T: FormatAsCssValue>(&'a T);
347

            
348
    impl<T: FormatAsCssValue> std::fmt::Display for AsCss<'_, T> {
349
        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
350
            self.0.format_as_css_value(f)
351
        }
352
    }
353

            
354
    // ---------------------------------------------------------------------
355
    // StyleExclusionMargin::is_initial (predicate)
356
    // ---------------------------------------------------------------------
357

            
358
    #[test]
359
    fn exclusion_margin_is_initial_true_and_false() {
360
        assert!(StyleExclusionMargin::default().is_initial());
361
        assert!(StyleExclusionMargin {
362
            inner: FloatValue::const_new(0)
363
        }
364
        .is_initial());
365
        assert!(!StyleExclusionMargin {
366
            inner: FloatValue::const_new(1)
367
        }
368
        .is_initial());
369
        assert!(!StyleExclusionMargin {
370
            inner: FloatValue::const_new(-1)
371
        }
372
        .is_initial());
373
    }
374

            
375
    #[test]
376
    fn exclusion_margin_is_initial_on_boundary_encodings() {
377
        // Negative zero and sub-precision magnitudes encode to 0 => "initial".
378
        for v in [0.0_f32, -0.0, 0.0004, -0.0004, f32::MIN_POSITIVE, 1e-30] {
379
            let m = StyleExclusionMargin {
380
                inner: FloatValue::new(v),
381
            };
382
            assert!(m.is_initial(), "{v} should encode to the initial value");
383
            assert_eq!(m.inner.get(), 0.0);
384
        }
385

            
386
        // NaN saturates to 0 in the f32 -> isize cast, so it is *also* "initial".
387
        let nan = StyleExclusionMargin {
388
            inner: FloatValue::new(f32::NAN),
389
        };
390
        assert!(nan.is_initial());
391
        assert!(!nan.inner.get().is_nan());
392

            
393
        // Saturating extremes are deterministic and decidedly not initial.
394
        for v in [f32::INFINITY, f32::NEG_INFINITY, f32::MAX, f32::MIN] {
395
            let m = StyleExclusionMargin {
396
                inner: FloatValue::new(v),
397
            };
398
            assert!(!m.is_initial(), "{v} must not be reported as initial");
399
            assert!(m.inner.get().is_finite());
400
        }
401
    }
402

            
403
    // ---------------------------------------------------------------------
404
    // StyleHyphenationLanguage::is_initial (predicate)
405
    // ---------------------------------------------------------------------
406

            
407
    #[test]
408
    fn hyphenation_is_initial_true_and_false() {
409
        assert!(StyleHyphenationLanguage::default().is_initial());
410
        assert!(StyleHyphenationLanguage {
411
            inner: AzString::from_const_str("en-US"),
412
        }
413
        .is_initial());
414

            
415
        // The comparison is exact and case-sensitive.
416
        for not_initial in ["", " ", "en-us", "EN-US", "en-US ", "en", "de-DE", "en\u{0}US"] {
417
            assert!(
418
                !StyleHyphenationLanguage {
419
                    inner: AzString::from_string(not_initial.to_string()),
420
                }
421
                .is_initial(),
422
                "{not_initial:?} must not be reported as initial"
423
            );
424
        }
425
    }
426

            
427
    #[test]
428
    fn hyphenation_is_initial_on_extreme_strings_does_not_panic() {
429
        for s in [
430
            "\u{1F600}".to_string(),
431
            "e\u{0301}n-US".to_string(), // combining acute on the 'e'
432
            "en-US\u{200B}".to_string(), // zero-width space
433
            "a".repeat(1_000_000),
434
        ] {
435
            let lang = StyleHyphenationLanguage {
436
                inner: AzString::from_string(s.clone()),
437
            };
438
            assert!(!lang.is_initial(), "{s:?} must not be reported as initial");
439
        }
440
    }
441

            
442
    // ---------------------------------------------------------------------
443
    // Formatting / round-trip of the value types
444
    // ---------------------------------------------------------------------
445

            
446
    #[test]
447
    fn exclusion_margin_print_and_format_agree() {
448
        for v in [0.0_f32, 10.5, -3.25, 123.456, f32::INFINITY, f32::NAN] {
449
            let m = StyleExclusionMargin {
450
                inner: FloatValue::new(v),
451
            };
452
            let printed = m.print_as_css_value();
453
            assert_eq!(printed, AsCss(&m).to_string());
454
            // Whatever went in, what comes out is always a finite number.
455
            assert!(!printed.contains("NaN") && !printed.contains("inf"));
456
            assert!(m.format_as_rust_code(0).contains(&printed));
457
        }
458
    }
459

            
460
    #[test]
461
    fn hyphenation_print_and_format_agree() {
462
        for s in ["en-US", "", "a", "\u{1F600}", "quote\"inside"] {
463
            let lang = StyleHyphenationLanguage {
464
                inner: AzString::from_string(s.to_string()),
465
            };
466
            let printed = lang.print_as_css_value();
467
            assert_eq!(printed, AsCss(&lang).to_string());
468
            assert_eq!(printed, format!("\"{s}\""));
469
            assert!(lang.format_as_rust_code(0).contains(s));
470
        }
471
    }
472

            
473
    #[test]
474
    fn exclusion_margin_ord_and_hash_are_consistent_with_value() {
475
        use std::{
476
            collections::hash_map::DefaultHasher,
477
            hash::{Hash, Hasher},
478
        };
479

            
480
        let hash = |m: &StyleExclusionMargin| {
481
            let mut h = DefaultHasher::new();
482
            m.hash(&mut h);
483
            h.finish()
484
        };
485

            
486
        let a = StyleExclusionMargin {
487
            inner: FloatValue::new(1.5),
488
        };
489
        let b = StyleExclusionMargin {
490
            inner: FloatValue::new(1.5),
491
        };
492
        let c = StyleExclusionMargin {
493
            inner: FloatValue::new(2.5),
494
        };
495

            
496
        assert_eq!(a, b);
497
        assert_eq!(hash(&a), hash(&b));
498
        assert!(a < c);
499
        assert!(a.inner.get() < c.inner.get());
500

            
501
        // 1.5 and 1.5004 collide: CSS keeps ~3 decimals of precision.
502
        let d = StyleExclusionMargin {
503
            inner: FloatValue::new(1.5004),
504
        };
505
        assert_eq!(a, d);
506
        assert_eq!(hash(&a), hash(&d));
507
    }
508

            
509
    // ---------------------------------------------------------------------
510
    // parse_style_exclusion_margin (parser)
511
    // ---------------------------------------------------------------------
512

            
513
    #[cfg(feature = "parser")]
514
    #[test]
515
    fn parse_exclusion_margin_valid_minimal() {
516
        assert_eq!(
517
            parse_style_exclusion_margin("0").unwrap(),
518
            StyleExclusionMargin::default()
519
        );
520
        assert_eq!(parse_style_exclusion_margin("1").unwrap().inner.get(), 1.0);
521
    }
522

            
523
    #[cfg(feature = "parser")]
524
    #[test]
525
    fn parse_exclusion_margin_empty_and_whitespace_only() {
526
        for input in ["", " ", "   ", "\t\n", "\r\n\t ", "\u{00A0}"] {
527
            assert!(
528
                parse_style_exclusion_margin(input).is_err(),
529
                "{input:?} must not parse"
530
            );
531
        }
532
    }
533

            
534
    #[cfg(feature = "parser")]
535
    #[test]
536
    fn parse_exclusion_margin_garbage_is_rejected() {
537
        for input in [
538
            "abc", "px", "10px", "1,5", "1_000", "0x10", "--5", "5-", "+-1", ".", "-", "+", "e5",
539
            "1e", "1.2.3", "null", "None", "{}", "()", "/*10*/", "10 20", "\0", "\u{7}\u{1b}[0m",
540
        ] {
541
            assert!(
542
                parse_style_exclusion_margin(input).is_err(),
543
                "{input:?} must be rejected"
544
            );
545
        }
546
    }
547

            
548
    #[cfg(feature = "parser")]
549
    #[test]
550
    fn parse_exclusion_margin_leading_trailing_junk() {
551
        // Surrounding ASCII whitespace is trimmed...
552
        assert_eq!(
553
            parse_style_exclusion_margin("  10.5  ").unwrap().inner.get(),
554
            10.5
555
        );
556
        assert_eq!(
557
            parse_style_exclusion_margin("\n\t-3.25\t\n")
558
                .unwrap()
559
                .inner
560
                .get(),
561
            -3.25
562
        );
563
        // ...but any trailing non-numeric junk is fatal, never silently dropped.
564
        for input in ["10.5px", "10.5;garbage", "10.5 !important", "10.5;", "10.5%"] {
565
            assert!(
566
                parse_style_exclusion_margin(input).is_err(),
567
                "{input:?} must be rejected, not truncated to a number"
568
            );
569
        }
570
    }
571

            
572
    #[cfg(feature = "parser")]
573
    #[test]
574
    fn parse_exclusion_margin_boundary_numbers() {
575
        // Signed zeroes all collapse onto the initial value.
576
        for input in ["0", "-0", "+0", "0.0", "-0.0", "0e0"] {
577
            let m = parse_style_exclusion_margin(input).unwrap();
578
            assert_eq!(m.inner.number, 0, "{input:?} should encode to zero");
579
            assert!(m.is_initial());
580
        }
581

            
582
        // Sub-precision magnitudes truncate to zero rather than rounding away.
583
        for input in ["0.0004", "-0.0004", "1e-30", "-1e-30"] {
584
            let m = parse_style_exclusion_margin(input).unwrap();
585
            assert_eq!(m.inner.number, 0, "{input:?} should truncate to zero");
586
        }
587

            
588
        // i64::MAX / f32::MAX overflow the isize encoding and must saturate,
589
        // not wrap or panic.
590
        for input in [
591
            i64::MAX.to_string(),
592
            i64::MIN.to_string(),
593
            f32::MAX.to_string(),
594
            format!("{}", f32::MIN),
595
            "1e38".to_string(),
596
            "-1e38".to_string(),
597
        ] {
598
            let m = parse_style_exclusion_margin(&input).unwrap();
599
            assert!(
600
                m.inner.get().is_finite(),
601
                "{input:?} must decode to a finite value, got {}",
602
                m.inner.get()
603
            );
604
        }
605

            
606
        assert_eq!(
607
            parse_style_exclusion_margin(&f32::MAX.to_string())
608
                .unwrap()
609
                .inner
610
                .number,
611
            isize::MAX
612
        );
613
        assert_eq!(
614
            parse_style_exclusion_margin(&format!("{}", f32::MIN))
615
                .unwrap()
616
                .inner
617
                .number,
618
            isize::MIN
619
        );
620
    }
621

            
622
    #[cfg(feature = "parser")]
623
    #[test]
624
    fn parse_exclusion_margin_nan_and_infinity_never_escape() {
625
        // Rust's f32 parser accepts these; the isize encoding must sanitize them
626
        // so that no NaN/inf ever reaches layout.
627
        let nan = parse_style_exclusion_margin("NaN").unwrap();
628
        assert_eq!(nan.inner.number, 0);
629
        assert!(!nan.inner.get().is_nan());
630
        assert!(nan.is_initial());
631
        assert_eq!(parse_style_exclusion_margin("nan").unwrap().inner.number, 0);
632
        assert_eq!(parse_style_exclusion_margin("-NaN").unwrap().inner.number, 0);
633

            
634
        for input in ["inf", "infinity", "+inf", "INF", "Infinity", "1e400"] {
635
            let m = parse_style_exclusion_margin(input).unwrap();
636
            assert_eq!(m.inner.number, isize::MAX, "{input:?} must saturate");
637
            assert!(m.inner.get().is_finite());
638
        }
639
        for input in ["-inf", "-infinity", "-INF", "-1e400"] {
640
            let m = parse_style_exclusion_margin(input).unwrap();
641
            assert_eq!(m.inner.number, isize::MIN, "{input:?} must saturate");
642
            assert!(m.inner.get().is_finite());
643
        }
644
    }
645

            
646
    #[cfg(feature = "parser")]
647
    #[test]
648
    fn parse_exclusion_margin_unicode_input() {
649
        // Non-ASCII digits/letters are not numbers.
650
        for input in [
651
            "\u{1F600}",         // emoji
652
            "\u{FF15}",          // fullwidth digit five
653
            "1\u{0301}",         // combining acute after a digit
654
            "\u{202E}10.5",      // right-to-left override
655
            "\u{FEFF}10.5",      // BOM
656
            "١٢٣",               // arabic-indic digits
657
            "10.5\u{1F4A9}",     // trailing emoji
658
        ] {
659
            assert!(
660
                parse_style_exclusion_margin(input).is_err(),
661
                "{input:?} must be rejected"
662
            );
663
        }
664

            
665
        // Unicode whitespace is stripped by str::trim; whatever the outcome, the
666
        // parser must never invent a wrong number.
667
        let r = parse_style_exclusion_margin("\u{00A0}10.5\u{2003}");
668
        assert!(r.is_err() || r.unwrap().inner.get() == 10.5);
669
    }
670

            
671
    #[cfg(feature = "parser")]
672
    #[test]
673
    fn parse_exclusion_margin_extremely_long_input() {
674
        // 1M digits overflow f32 -> inf -> saturated isize. Must not hang or panic.
675
        let huge = "9".repeat(1_000_000);
676
        let m = parse_style_exclusion_margin(&huge).unwrap();
677
        assert_eq!(m.inner.number, isize::MAX);
678
        assert!(m.inner.get().is_finite());
679

            
680
        // 1M fractional digits are a legal (if silly) float.
681
        let long_fraction = format!("1.{}", "0".repeat(1_000_000));
682
        assert_eq!(
683
            parse_style_exclusion_margin(&long_fraction).unwrap().inner.get(),
684
            1.0
685
        );
686

            
687
        // 1M garbage bytes still just return Err.
688
        assert!(parse_style_exclusion_margin(&"z".repeat(1_000_000)).is_err());
689
        assert!(parse_style_exclusion_margin(&" ".repeat(1_000_000)).is_err());
690
    }
691

            
692
    #[cfg(feature = "parser")]
693
    #[test]
694
    fn parse_exclusion_margin_deeply_nested_input_does_not_stack_overflow() {
695
        let nested = format!("{}{}", "(".repeat(10_000), ")".repeat(10_000));
696
        assert!(parse_style_exclusion_margin(&nested).is_err());
697

            
698
        let nested_number = format!("{}1{}", "(".repeat(10_000), ")".repeat(10_000));
699
        assert!(parse_style_exclusion_margin(&nested_number).is_err());
700

            
701
        assert!(parse_style_exclusion_margin(&"-".repeat(10_000)).is_err());
702
    }
703

            
704
    #[cfg(feature = "parser")]
705
    #[test]
706
    fn parse_exclusion_margin_round_trips_through_css_and_rust_code() {
707
        for input in ["0", "1", "10.5", "-3.25", "0.001", "123.456", "-0.5", "1000"] {
708
            let parsed = parse_style_exclusion_margin(input).unwrap();
709

            
710
            // encode == decode: printing and re-parsing is a fixed point.
711
            let printed = parsed.print_as_css_value();
712
            let reparsed = parse_style_exclusion_margin(&printed).unwrap();
713
            assert_eq!(
714
                parsed, reparsed,
715
                "{input:?} printed as {printed:?} did not round-trip"
716
            );
717
            assert_eq!(printed, reparsed.print_as_css_value());
718

            
719
            // The generated Rust code embeds the same decoded value.
720
            assert!(parsed.format_as_rust_code(0).contains(&printed));
721
        }
722
    }
723

            
724
    // ---------------------------------------------------------------------
725
    // StyleExclusionMarginParseError::to_contained / ...Owned::to_shared
726
    // ---------------------------------------------------------------------
727

            
728
    #[cfg(feature = "parser")]
729
    #[test]
730
    fn exclusion_margin_error_to_contained_carries_the_message() {
731
        let err = parse_style_exclusion_margin("garbage").unwrap_err();
732
        let StyleExclusionMarginParseErrorOwned::FloatValue(msg) = err.to_contained();
733
        assert!(!msg.as_str().is_empty());
734
        // impl_debug_as_display: Debug and Display must agree, and the Display
735
        // form must name the property.
736
        assert_eq!(format!("{err:?}"), format!("{err}"));
737
        assert!(format!("{err}").contains("-azul-exclusion-margin"));
738
        assert!(format!("{err}").contains(msg.as_str()));
739
    }
740

            
741
    #[cfg(feature = "parser")]
742
    #[test]
743
    fn exclusion_margin_error_to_shared_is_lossy_but_total() {
744
        // to_shared() cannot rebuild a ParseFloatError from its message, so it
745
        // always yields the empty-string error. Pin that: it is the one shape a
746
        // caller can rely on, and it must never panic - not even for a message
747
        // that no ParseFloatError would ever produce.
748
        let empty_err_msg: AzString = format!("{}", "".parse::<f32>().unwrap_err()).into();
749

            
750
        for msg in [
751
            String::new(),
752
            "invalid float literal".to_string(),
753
            "\u{1F600}".to_string(),
754
            "x".repeat(1_000_000),
755
        ] {
756
            let owned = StyleExclusionMarginParseErrorOwned::FloatValue(msg.clone().into());
757
            let shared = owned.to_shared();
758
            assert_eq!(
759
                shared.to_contained(),
760
                StyleExclusionMarginParseErrorOwned::FloatValue(empty_err_msg.clone()),
761
                "to_shared() should normalise {msg:?} onto the empty-string error"
762
            );
763
        }
764

            
765
        // Consequently the Owned -> shared -> Owned round-trip is *not* the
766
        // identity for a non-empty-string error; only the empty-string one is a
767
        // fixed point.
768
        let empty = parse_style_exclusion_margin("").unwrap_err();
769
        assert_eq!(empty.to_contained().to_shared(), empty);
770
        assert_eq!(empty.to_contained().to_shared().to_contained(), empty.to_contained());
771
    }
772

            
773
    // ---------------------------------------------------------------------
774
    // parse_style_hyphenation_language (parser)
775
    // ---------------------------------------------------------------------
776

            
777
    #[cfg(feature = "parser")]
778
    #[test]
779
    fn parse_hyphenation_language_valid_minimal() {
780
        let lang = parse_style_hyphenation_language("en-US").unwrap();
781
        assert_eq!(lang.inner.as_str(), "en-US");
782
        assert!(lang.is_initial());
783
        assert_eq!(parse_style_hyphenation_language("a").unwrap().inner.as_str(), "a");
784
    }
785

            
786
    #[cfg(feature = "parser")]
787
    #[test]
788
    fn parse_hyphenation_language_empty_and_whitespace_only() {
789
        for input in ["", " ", "   ", "\t\n", "\r\n\t ", "\"\"", "''", "\" \""] {
790
            assert!(
791
                parse_style_hyphenation_language(input).is_err(),
792
                "{input:?} must not parse"
793
            );
794
        }
795
    }
796

            
797
    #[cfg(feature = "parser")]
798
    #[test]
799
    fn parse_hyphenation_language_garbage_is_rejected() {
800
        for input in [
801
            "en_US",     // underscore is not BCP 47
802
            "-en",       // leading hyphen
803
            "en-",       // trailing hyphen
804
            "en US",     // interior space
805
            "\" en-US \"", // interior space after unquoting
806
            "en;US",
807
            "en/US",
808
            "en.US",
809
            "en*",
810
            "<script>",
811
            "en\0US",    // interior NUL
812
            "en\nUS",
813
            "'en-US\"",  // mismatched quotes: quotes survive into the tag
814
            "\"en-US'",
815
            "\"en-US",   // unterminated
816
            "en-US\"",
817
        ] {
818
            assert!(
819
                matches!(
820
                    parse_style_hyphenation_language(input),
821
                    Err(StyleHyphenationLanguageParseError::InvalidString(_))
822
                ),
823
                "{input:?} must be rejected"
824
            );
825
        }
826
    }
827

            
828
    #[cfg(feature = "parser")]
829
    #[test]
830
    fn parse_hyphenation_language_leading_trailing_junk() {
831
        // Surrounding whitespace is trimmed, inside and outside of quotes.
832
        assert_eq!(
833
            parse_style_hyphenation_language("  en-US  ").unwrap().inner.as_str(),
834
            "en-US"
835
        );
836
        assert_eq!(
837
            parse_style_hyphenation_language("\t\"de-DE\"\n").unwrap().inner.as_str(),
838
            "de-DE"
839
        );
840
        // Trailing junk is fatal, never silently dropped.
841
        for input in ["en-US;", "en-US !important", "\"en-US\";", "en-US /*c*/"] {
842
            assert!(
843
                parse_style_hyphenation_language(input).is_err(),
844
                "{input:?} must be rejected, not truncated"
845
            );
846
        }
847
    }
848

            
849
    #[cfg(feature = "parser")]
850
    #[test]
851
    fn parse_hyphenation_language_unicode_input() {
852
        for input in [
853
            "日本語",
854
            "\u{1F600}",
855
            "\"\u{1F600}\"",
856
            "e\u{0301}n-US",   // combining acute
857
            "en-US\u{200B}",   // zero-width space
858
            "\u{FEFF}en-US",   // BOM
859
            "en-US",        // fullwidth latin
860
            "ру-RU",
861
        ] {
862
            assert!(
863
                matches!(
864
                    parse_style_hyphenation_language(input),
865
                    Err(StyleHyphenationLanguageParseError::InvalidString(_))
866
                ),
867
                "{input:?} must be rejected without panicking"
868
            );
869
        }
870

            
871
        // A multibyte char right inside the quotes must not split a char boundary.
872
        let r = parse_style_hyphenation_language("\"日本語\"");
873
        assert!(r.is_err());
874
    }
875

            
876
    #[cfg(feature = "parser")]
877
    #[test]
878
    fn parse_hyphenation_language_accepts_any_ascii_alphanumeric_tag() {
879
        // The validation is "ASCII alphanumeric + interior hyphens", so purely
880
        // numeric and nonsense-but-ASCII tags are accepted today. Characterises
881
        // the current (lax) behaviour so a future tightening is a visible change.
882
        for input in ["0", "123", "NaN", "inf", "zzzzzz", "sr-Latn-RS", "x-private"] {
883
            let lang = parse_style_hyphenation_language(input).unwrap();
884
            assert_eq!(lang.inner.as_str(), input);
885
        }
886
        assert_eq!(
887
            parse_style_hyphenation_language(&i64::MAX.to_string())
888
                .unwrap()
889
                .inner
890
                .as_str(),
891
            i64::MAX.to_string()
892
        );
893
        // ...but a negative number starts with a hyphen and is therefore rejected.
894
        assert!(parse_style_hyphenation_language(&i64::MIN.to_string()).is_err());
895
        assert!(parse_style_hyphenation_language("-0").is_err());
896
    }
897

            
898
    #[cfg(feature = "parser")]
899
    #[test]
900
    fn parse_hyphenation_language_extremely_long_input() {
901
        let huge = "a".repeat(1_000_000);
902
        assert_eq!(
903
            parse_style_hyphenation_language(&huge).unwrap().inner.as_str(),
904
            huge
905
        );
906

            
907
        let huge_quoted = format!("\"{huge}\"");
908
        assert_eq!(
909
            parse_style_hyphenation_language(&huge_quoted)
910
                .unwrap()
911
                .inner
912
                .as_str(),
913
            huge
914
        );
915

            
916
        // A 1M-char run of hyphens is rejected (leading hyphen), not hung on.
917
        assert!(parse_style_hyphenation_language(&"-".repeat(1_000_000)).is_err());
918
        // 1M non-ASCII bytes: rejected, and the error carries the whole input.
919
        assert!(parse_style_hyphenation_language(&"é".repeat(1_000_000)).is_err());
920
    }
921

            
922
    #[cfg(feature = "parser")]
923
    #[test]
924
    fn parse_hyphenation_language_deeply_nested_input_does_not_stack_overflow() {
925
        let nested = format!("{}en-US{}", "(".repeat(10_000), ")".repeat(10_000));
926
        assert!(parse_style_hyphenation_language(&nested).is_err());
927

            
928
        let nested_quotes = format!("{}en-US{}", "\"".repeat(10_000), "\"".repeat(10_000));
929
        assert!(parse_style_hyphenation_language(&nested_quotes).is_err());
930
    }
931

            
932
    #[cfg(feature = "parser")]
933
    #[test]
934
    fn parse_hyphenation_language_round_trips_through_css_and_rust_code() {
935
        for input in ["en-US", "de-DE", "zh", "sr-Latn-RS", "en--US", "x-private", "0"] {
936
            let parsed = parse_style_hyphenation_language(input).unwrap();
937

            
938
            // print_as_css_value() re-quotes; re-parsing must strip the quotes
939
            // back to exactly the same tag (encode == decode).
940
            let printed = parsed.print_as_css_value();
941
            assert_eq!(printed, format!("\"{input}\""));
942
            let reparsed = parse_style_hyphenation_language(&printed).unwrap();
943
            assert_eq!(parsed, reparsed, "{input:?} did not round-trip");
944
            assert_eq!(printed, reparsed.print_as_css_value());
945

            
946
            // Single quotes are an equally valid encoding of the same value.
947
            assert_eq!(
948
                parse_style_hyphenation_language(&format!("'{input}'")).unwrap(),
949
                parsed
950
            );
951

            
952
            assert!(parsed.format_as_rust_code(0).contains(input));
953
        }
954
    }
955

            
956
    // ---------------------------------------------------------------------
957
    // StyleHyphenationLanguageParseError::to_contained / ...Owned::to_shared
958
    // ---------------------------------------------------------------------
959

            
960
    #[cfg(feature = "parser")]
961
    #[test]
962
    fn hyphenation_error_to_contained_carries_the_offending_string() {
963
        let err = parse_style_hyphenation_language("en_US").unwrap_err();
964
        let StyleHyphenationLanguageParseErrorOwned::InvalidString(msg) = err.to_contained();
965
        assert_eq!(msg.as_str(), "en_US");
966
        assert_eq!(format!("{err:?}"), format!("{err}"));
967
        assert!(format!("{err}").contains("-azul-hyphenation-language"));
968
        assert!(format!("{err}").contains("en_US"));
969
    }
970

            
971
    #[cfg(feature = "parser")]
972
    #[test]
973
    fn hyphenation_error_round_trips_losslessly() {
974
        for msg in [
975
            String::new(),
976
            "en_US".to_string(),
977
            "\u{1F600}".to_string(),
978
            "\0".to_string(),
979
            "x".repeat(1_000_000),
980
        ] {
981
            let shared = StyleHyphenationLanguageParseError::InvalidString(msg.clone());
982
            let owned = shared.to_contained();
983
            assert_eq!(owned.to_shared(), shared, "{msg:?} lost data on round-trip");
984
            assert_eq!(owned.to_shared().to_contained(), owned);
985

            
986
            let owned_direct =
987
                StyleHyphenationLanguageParseErrorOwned::InvalidString(msg.clone().into());
988
            assert_eq!(owned_direct, owned);
989
            let StyleHyphenationLanguageParseErrorOwned::InvalidString(inner) = owned_direct;
990
            assert_eq!(inner.as_str(), msg);
991
        }
992
    }
993

            
994
    #[cfg(feature = "parser")]
995
    #[test]
996
    fn hyphenation_error_for_empty_input_reports_the_unquoted_string() {
997
        // The error carries the *unquoted* text, not the raw input.
998
        let err = parse_style_hyphenation_language("\"\"").unwrap_err();
999
        assert_eq!(
            err.to_contained(),
            StyleHyphenationLanguageParseErrorOwned::InvalidString(AzString::from_const_str(""))
        );
        assert!(err.to_contained().to_shared() == err);
    }
    // ---------------------------------------------------------------------
    // Regression: a lone quote must fail validation, not panic.
    // ---------------------------------------------------------------------
    /// A lone quote character must return `Err`, not panic.
    ///
    /// `parse_style_hyphenation_language("\"")` used to see a string that both
    /// starts and ends with `"`, slice `&trimmed[1..trimmed.len() - 1]` ==
    /// `&s[1..0]`, and panic with "slice index starts at 1 but ends at 0". Any
    /// CSS input of `-azul-hyphenation-language: ";` reached it. The `len() >= 2`
    /// guard now keeps a single-quote input intact so it fails BCP-47
    /// validation cleanly.
    #[cfg(feature = "parser")]
    #[test]
    fn parse_hyphenation_language_lone_quote_must_not_panic() {
        for input in ["\"", "'", " \" ", "\t'\n"] {
            assert!(
                parse_style_hyphenation_language(input).is_err(),
                "{input:?} must return Err, not panic"
            );
        }
    }
}