Skip to main content

azul_css/props/style/
exclusion.rs

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
6use std::num::ParseFloatError;
7
8#[cfg(feature = "parser")]
9use crate::macros::*;
10use 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)]
32pub struct StyleExclusionMargin {
33    pub inner: FloatValue,
34}
35
36impl Default for StyleExclusionMargin {
37    fn default() -> Self {
38        Self {
39            inner: FloatValue::const_new(0),
40        }
41    }
42}
43
44impl StyleExclusionMargin {
45    #[must_use] pub const fn is_initial(&self) -> bool {
46        self.inner.number == 0
47    }
48}
49
50impl PrintAsCssValue for StyleExclusionMargin {
51    fn print_as_css_value(&self) -> String {
52        format!("{}", self.inner.get())
53    }
54}
55
56impl FormatAsCssValue for StyleExclusionMargin {
57    fn format_as_css_value(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58        write!(f, "{}", self.inner.get())
59    }
60}
61
62impl FormatAsRustCode for StyleExclusionMargin {
63    fn format_as_rust_code(&self, _tabs: usize) -> String {
64        format!(
65            "StyleExclusionMargin {{ inner: FloatValue::const_new({}) }}",
66            self.inner.get()
67        )
68    }
69}
70
71#[cfg(feature = "parser")]
72#[derive(Clone, PartialEq, Eq)]
73pub enum StyleExclusionMarginParseError {
74    FloatValue(ParseFloatError),
75}
76
77#[cfg(feature = "parser")]
78impl_debug_as_display!(StyleExclusionMarginParseError);
79
80#[cfg(feature = "parser")]
81impl_display! { StyleExclusionMarginParseError, {
82    FloatValue(e) => format!("Invalid -azul-exclusion-margin value: {}", e),
83}}
84
85#[cfg(feature = "parser")]
86impl_from!(ParseFloatError, StyleExclusionMarginParseError::FloatValue);
87
88#[cfg(feature = "parser")]
89#[derive(Debug, Clone, PartialEq, Eq)]
90#[repr(C, u8)]
91pub enum StyleExclusionMarginParseErrorOwned {
92    FloatValue(AzString),
93}
94
95#[cfg(feature = "parser")]
96impl StyleExclusionMarginParseError {
97    #[must_use] pub fn to_contained(&self) -> StyleExclusionMarginParseErrorOwned {
98        match self {
99            Self::FloatValue(e) => {
100                StyleExclusionMarginParseErrorOwned::FloatValue(format!("{e}").into())
101            }
102        }
103    }
104}
105
106#[cfg(feature = "parser")]
107impl StyleExclusionMarginParseErrorOwned {
108    #[must_use] pub fn to_shared(&self) -> StyleExclusionMarginParseError {
109        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                StyleExclusionMarginParseError::FloatValue("".parse::<f32>().unwrap_err())
114            }
115        }
116    }
117}
118
119#[cfg(feature = "parser")]
120/// # Errors
121///
122/// Returns an error if `input` is not a valid CSS `exclusion-margin` value.
123pub fn parse_style_exclusion_margin(
124    input: &str,
125) -> Result<StyleExclusionMargin, StyleExclusionMarginParseError> {
126    parse_float_value(input)
127        .map(|inner| StyleExclusionMargin { inner })
128        .map_err(StyleExclusionMarginParseError::FloatValue)
129}
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)]
144pub struct StyleHyphenationLanguage {
145    pub inner: AzString,
146}
147
148impl Default for StyleHyphenationLanguage {
149    fn default() -> Self {
150        Self {
151            inner: AzString::from_const_str("en-US"),
152        }
153    }
154}
155
156impl StyleHyphenationLanguage {
157    #[must_use] pub fn is_initial(&self) -> bool {
158        self.inner.as_str() == "en-US"
159    }
160}
161
162impl PrintAsCssValue for StyleHyphenationLanguage {
163    fn print_as_css_value(&self) -> String {
164        format!("\"{}\"", self.inner.as_str())
165    }
166}
167
168impl FormatAsCssValue for StyleHyphenationLanguage {
169    fn format_as_css_value(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
170        write!(f, "\"{}\"", self.inner.as_str())
171    }
172}
173
174impl FormatAsRustCode for StyleHyphenationLanguage {
175    fn format_as_rust_code(&self, _tabs: usize) -> String {
176        format!(
177            "StyleHyphenationLanguage {{ inner: AzString::from_const_str(\"{}\") }}",
178            self.inner.as_str()
179        )
180    }
181}
182
183#[cfg(feature = "parser")]
184#[derive(Clone, PartialEq, Eq)]
185pub enum StyleHyphenationLanguageParseError {
186    InvalidString(String),
187}
188
189#[cfg(feature = "parser")]
190impl_debug_as_display!(StyleHyphenationLanguageParseError);
191
192#[cfg(feature = "parser")]
193impl_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)]
200pub enum StyleHyphenationLanguageParseErrorOwned {
201    InvalidString(AzString),
202}
203
204#[cfg(feature = "parser")]
205impl StyleHyphenationLanguageParseError {
206    #[must_use] pub fn to_contained(&self) -> StyleHyphenationLanguageParseErrorOwned {
207        match self {
208            Self::InvalidString(e) => {
209                StyleHyphenationLanguageParseErrorOwned::InvalidString(e.clone().into())
210            }
211        }
212    }
213}
214
215#[cfg(feature = "parser")]
216impl StyleHyphenationLanguageParseErrorOwned {
217    #[must_use] pub fn to_shared(&self) -> StyleHyphenationLanguageParseError {
218        match self {
219            Self::InvalidString(e) => StyleHyphenationLanguageParseError::InvalidString(e.to_string()),
220        }
221    }
222}
223
224#[cfg(feature = "parser")]
225/// # Errors
226///
227/// Returns an error if `input` is not a valid CSS `hyphenation-language` value.
228pub fn parse_style_hyphenation_language(
229    input: &str,
230) -> 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    let trimmed = input.trim();
235    let unquoted = if trimmed.len() >= 2
236        && ((trimmed.starts_with('"') && trimmed.ends_with('"'))
237            || (trimmed.starts_with('\'') && trimmed.ends_with('\'')))
238    {
239        &trimmed[1..trimmed.len() - 1]
240    } else {
241        trimmed
242    };
243
244    // Basic BCP 47 validation: non-empty, ASCII alphanumeric + hyphens, no leading/trailing hyphens
245    if unquoted.is_empty()
246        || !unquoted.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')
247        || unquoted.starts_with('-')
248        || unquoted.ends_with('-')
249    {
250        return Err(StyleHyphenationLanguageParseError::InvalidString(
251            unquoted.to_string(),
252        ));
253    }
254
255    Ok(StyleHyphenationLanguage {
256        inner: AzString::from_string(unquoted.to_string()),
257    })
258}
259
260#[cfg(test)]
261mod tests {
262    // Tests assert that parsed values equal the exact source literals.
263    #![allow(clippy::float_cmp)]
264    use super::*;
265
266    #[test]
267    fn test_parse_exclusion_margin() {
268        let margin = parse_style_exclusion_margin("10.5").unwrap();
269        assert_eq!(margin.inner.get(), 10.5);
270
271        let margin = parse_style_exclusion_margin("0").unwrap();
272        assert_eq!(margin.inner.get(), 0.0);
273    }
274
275    #[test]
276    fn test_parse_hyphenation_language() {
277        let lang = parse_style_hyphenation_language("\"en-US\"").unwrap();
278        assert_eq!(lang.inner.as_str(), "en-US");
279
280        let lang = parse_style_hyphenation_language("'de-DE'").unwrap();
281        assert_eq!(lang.inner.as_str(), "de-DE");
282
283        let lang = parse_style_hyphenation_language("fr-FR").unwrap();
284        assert_eq!(lang.inner.as_str(), "fr-FR");
285
286        let lang = parse_style_hyphenation_language("zh").unwrap();
287        assert_eq!(lang.inner.as_str(), "zh");
288
289        let lang = parse_style_hyphenation_language("sr-Latn-RS").unwrap();
290        assert_eq!(lang.inner.as_str(), "sr-Latn-RS");
291
292        // Double hyphen is permitted by the current ASCII/format rules.
293        let lang = parse_style_hyphenation_language("en--US").unwrap();
294        assert_eq!(lang.inner.as_str(), "en--US");
295    }
296
297    #[test]
298    fn test_parse_hyphenation_language_invalid() {
299        assert!(matches!(
300            parse_style_hyphenation_language(""),
301            Err(StyleHyphenationLanguageParseError::InvalidString(_))
302        ));
303        assert!(matches!(
304            parse_style_hyphenation_language("-en"),
305            Err(StyleHyphenationLanguageParseError::InvalidString(_))
306        ));
307        assert!(matches!(
308            parse_style_hyphenation_language("en-"),
309            Err(StyleHyphenationLanguageParseError::InvalidString(_))
310        ));
311        assert!(matches!(
312            parse_style_hyphenation_language("en_US"),
313            Err(StyleHyphenationLanguageParseError::InvalidString(_))
314        ));
315        assert!(matches!(
316            parse_style_hyphenation_language("日本語"),
317            Err(StyleHyphenationLanguageParseError::InvalidString(_))
318        ));
319    }
320
321    #[test]
322    fn test_exclusion_margin_default() {
323        let margin = StyleExclusionMargin::default();
324        assert_eq!(margin.inner.get(), 0.0);
325        assert!(margin.is_initial());
326    }
327
328    #[test]
329    fn test_hyphenation_language_default() {
330        let lang = StyleHyphenationLanguage::default();
331        assert_eq!(lang.inner.as_str(), "en-US");
332    }
333}
334
335#[cfg(test)]
336mod 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!(
1000            err.to_contained(),
1001            StyleHyphenationLanguageParseErrorOwned::InvalidString(AzString::from_const_str(""))
1002        );
1003        assert!(err.to_contained().to_shared() == err);
1004    }
1005
1006    // ---------------------------------------------------------------------
1007    // Regression: a lone quote must fail validation, not panic.
1008    // ---------------------------------------------------------------------
1009
1010    /// A lone quote character must return `Err`, not panic.
1011    ///
1012    /// `parse_style_hyphenation_language("\"")` used to see a string that both
1013    /// starts and ends with `"`, slice `&trimmed[1..trimmed.len() - 1]` ==
1014    /// `&s[1..0]`, and panic with "slice index starts at 1 but ends at 0". Any
1015    /// CSS input of `-azul-hyphenation-language: ";` reached it. The `len() >= 2`
1016    /// guard now keeps a single-quote input intact so it fails BCP-47
1017    /// validation cleanly.
1018    #[cfg(feature = "parser")]
1019    #[test]
1020    fn parse_hyphenation_language_lone_quote_must_not_panic() {
1021        for input in ["\"", "'", " \" ", "\t'\n"] {
1022            assert!(
1023                parse_style_hyphenation_language(input).is_err(),
1024                "{input:?} must return Err, not panic"
1025            );
1026        }
1027    }
1028}