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    codegen::format::FormatAsRustCode,
12    corety::AzString,
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]
46    pub const fn is_initial(&self) -> bool {
47        self.inner.number == 0
48    }
49}
50
51impl PrintAsCssValue for StyleExclusionMargin {
52    fn print_as_css_value(&self) -> String {
53        format!("{}", self.inner.get())
54    }
55}
56
57impl FormatAsCssValue for StyleExclusionMargin {
58    fn format_as_css_value(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59        write!(f, "{}", self.inner.get())
60    }
61}
62
63impl FormatAsRustCode for StyleExclusionMargin {
64    fn format_as_rust_code(&self, _tabs: usize) -> String {
65        format!(
66            "StyleExclusionMargin {{ inner: FloatValue::const_new({}) }}",
67            self.inner.get()
68        )
69    }
70}
71
72#[cfg(feature = "parser")]
73#[derive(Clone, PartialEq, Eq)]
74pub enum StyleExclusionMarginParseError {
75    FloatValue(ParseFloatError),
76}
77
78#[cfg(feature = "parser")]
79impl_debug_as_display!(StyleExclusionMarginParseError);
80
81#[cfg(feature = "parser")]
82impl_display! { StyleExclusionMarginParseError, {
83    FloatValue(e) => format!("Invalid -azul-exclusion-margin value: {}", e),
84}}
85
86#[cfg(feature = "parser")]
87impl_from!(ParseFloatError, StyleExclusionMarginParseError::FloatValue);
88
89#[cfg(feature = "parser")]
90#[derive(Debug, Clone, PartialEq, Eq)]
91#[repr(C, u8)]
92pub enum StyleExclusionMarginParseErrorOwned {
93    FloatValue(AzString),
94}
95
96#[cfg(feature = "parser")]
97impl StyleExclusionMarginParseError {
98    #[must_use]
99    pub fn to_contained(&self) -> StyleExclusionMarginParseErrorOwned {
100        match self {
101            Self::FloatValue(e) => {
102                StyleExclusionMarginParseErrorOwned::FloatValue(format!("{e}").into())
103            }
104        }
105    }
106}
107
108#[cfg(feature = "parser")]
109impl StyleExclusionMarginParseErrorOwned {
110    #[must_use]
111    pub fn to_shared(&self) -> StyleExclusionMarginParseError {
112        match self {
113            Self::FloatValue(_) => {
114                // ParseFloatError can't be reconstructed from its display string,
115                // so we create one by parsing a known-invalid string
116                StyleExclusionMarginParseError::FloatValue("".parse::<f32>().unwrap_err())
117            }
118        }
119    }
120}
121
122#[cfg(feature = "parser")]
123/// # Errors
124///
125/// Returns an error if `input` is not a valid CSS `exclusion-margin` value.
126pub fn parse_style_exclusion_margin(
127    input: &str,
128) -> Result<StyleExclusionMargin, StyleExclusionMarginParseError> {
129    parse_float_value(input)
130        .map(|inner| StyleExclusionMargin { inner })
131        .map_err(StyleExclusionMarginParseError::FloatValue)
132}
133
134/// `-azul-hyphenation-language` property: specifies language for hyphenation
135///
136/// This property defines the language code (BCP 47 format) used for automatic
137/// hyphenation. Examples: "en-US", "de-DE", "fr-FR"
138///
139/// # Example
140/// ```css
141/// .element {
142///     -azul-hyphenation-language: "en-US";
143/// }
144/// ```
145#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
146#[repr(C)]
147pub struct StyleHyphenationLanguage {
148    pub inner: AzString,
149}
150
151impl Default for StyleHyphenationLanguage {
152    fn default() -> Self {
153        Self {
154            inner: AzString::from_const_str("en-US"),
155        }
156    }
157}
158
159impl StyleHyphenationLanguage {
160    #[must_use]
161    pub fn is_initial(&self) -> bool {
162        self.inner.as_str() == "en-US"
163    }
164}
165
166impl PrintAsCssValue for StyleHyphenationLanguage {
167    fn print_as_css_value(&self) -> String {
168        format!("\"{}\"", self.inner.as_str())
169    }
170}
171
172impl FormatAsCssValue for StyleHyphenationLanguage {
173    fn format_as_css_value(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
174        write!(f, "\"{}\"", self.inner.as_str())
175    }
176}
177
178impl FormatAsRustCode for StyleHyphenationLanguage {
179    fn format_as_rust_code(&self, _tabs: usize) -> String {
180        format!(
181            "StyleHyphenationLanguage {{ inner: AzString::from_const_str(\"{}\") }}",
182            self.inner.as_str()
183        )
184    }
185}
186
187#[cfg(feature = "parser")]
188#[derive(Clone, PartialEq, Eq)]
189pub enum StyleHyphenationLanguageParseError {
190    InvalidString(String),
191}
192
193#[cfg(feature = "parser")]
194impl_debug_as_display!(StyleHyphenationLanguageParseError);
195
196#[cfg(feature = "parser")]
197impl_display! { StyleHyphenationLanguageParseError, {
198    InvalidString(e) => format!("Invalid -azul-hyphenation-language value: {}", e),
199}}
200
201#[cfg(feature = "parser")]
202#[derive(Debug, Clone, PartialEq, Eq)]
203#[repr(C, u8)]
204pub enum StyleHyphenationLanguageParseErrorOwned {
205    InvalidString(AzString),
206}
207
208#[cfg(feature = "parser")]
209impl StyleHyphenationLanguageParseError {
210    #[must_use]
211    pub fn to_contained(&self) -> StyleHyphenationLanguageParseErrorOwned {
212        match self {
213            Self::InvalidString(e) => {
214                StyleHyphenationLanguageParseErrorOwned::InvalidString(e.clone().into())
215            }
216        }
217    }
218}
219
220#[cfg(feature = "parser")]
221impl StyleHyphenationLanguageParseErrorOwned {
222    #[must_use]
223    pub fn to_shared(&self) -> StyleHyphenationLanguageParseError {
224        match self {
225            Self::InvalidString(e) => {
226                StyleHyphenationLanguageParseError::InvalidString(e.to_string())
227            }
228        }
229    }
230}
231
232#[cfg(feature = "parser")]
233/// # Errors
234///
235/// Returns an error if `input` is not a valid CSS `hyphenation-language` value.
236pub fn parse_style_hyphenation_language(
237    input: &str,
238) -> Result<StyleHyphenationLanguage, StyleHyphenationLanguageParseError> {
239    // Remove surrounding quotes if present. Require len >= 2 so a lone quote
240    // (where starts_with and ends_with match the *same* char) is not stripped to
241    // `&s[1..0]`, which would panic instead of failing validation below.
242    let trimmed = input.trim();
243    let unquoted = if trimmed.len() >= 2
244        && ((trimmed.starts_with('"') && trimmed.ends_with('"'))
245            || (trimmed.starts_with('\'') && trimmed.ends_with('\'')))
246    {
247        &trimmed[1..trimmed.len() - 1]
248    } else {
249        trimmed
250    };
251
252    // Basic BCP 47 validation: non-empty, ASCII alphanumeric + hyphens, no leading/trailing hyphens
253    if unquoted.is_empty()
254        || !unquoted
255            .bytes()
256            .all(|b| b.is_ascii_alphanumeric() || b == b'-')
257        || unquoted.starts_with('-')
258        || unquoted.ends_with('-')
259    {
260        return Err(StyleHyphenationLanguageParseError::InvalidString(
261            unquoted.to_string(),
262        ));
263    }
264
265    Ok(StyleHyphenationLanguage {
266        inner: AzString::from_string(unquoted.to_string()),
267    })
268}
269
270#[cfg(test)]
271mod tests {
272    // Tests assert that parsed values equal the exact source literals.
273    #![allow(clippy::float_cmp)]
274    use super::*;
275
276    #[test]
277    fn test_parse_exclusion_margin() {
278        let margin = parse_style_exclusion_margin("10.5").unwrap();
279        assert_eq!(margin.inner.get(), 10.5);
280
281        let margin = parse_style_exclusion_margin("0").unwrap();
282        assert_eq!(margin.inner.get(), 0.0);
283    }
284
285    #[test]
286    fn test_parse_hyphenation_language() {
287        let lang = parse_style_hyphenation_language("\"en-US\"").unwrap();
288        assert_eq!(lang.inner.as_str(), "en-US");
289
290        let lang = parse_style_hyphenation_language("'de-DE'").unwrap();
291        assert_eq!(lang.inner.as_str(), "de-DE");
292
293        let lang = parse_style_hyphenation_language("fr-FR").unwrap();
294        assert_eq!(lang.inner.as_str(), "fr-FR");
295
296        let lang = parse_style_hyphenation_language("zh").unwrap();
297        assert_eq!(lang.inner.as_str(), "zh");
298
299        let lang = parse_style_hyphenation_language("sr-Latn-RS").unwrap();
300        assert_eq!(lang.inner.as_str(), "sr-Latn-RS");
301
302        // Double hyphen is permitted by the current ASCII/format rules.
303        let lang = parse_style_hyphenation_language("en--US").unwrap();
304        assert_eq!(lang.inner.as_str(), "en--US");
305    }
306
307    #[test]
308    fn test_parse_hyphenation_language_invalid() {
309        assert!(matches!(
310            parse_style_hyphenation_language(""),
311            Err(StyleHyphenationLanguageParseError::InvalidString(_))
312        ));
313        assert!(matches!(
314            parse_style_hyphenation_language("-en"),
315            Err(StyleHyphenationLanguageParseError::InvalidString(_))
316        ));
317        assert!(matches!(
318            parse_style_hyphenation_language("en-"),
319            Err(StyleHyphenationLanguageParseError::InvalidString(_))
320        ));
321        assert!(matches!(
322            parse_style_hyphenation_language("en_US"),
323            Err(StyleHyphenationLanguageParseError::InvalidString(_))
324        ));
325        assert!(matches!(
326            parse_style_hyphenation_language("日本語"),
327            Err(StyleHyphenationLanguageParseError::InvalidString(_))
328        ));
329    }
330
331    #[test]
332    fn test_exclusion_margin_default() {
333        let margin = StyleExclusionMargin::default();
334        assert_eq!(margin.inner.get(), 0.0);
335        assert!(margin.is_initial());
336    }
337
338    #[test]
339    fn test_hyphenation_language_default() {
340        let lang = StyleHyphenationLanguage::default();
341        assert_eq!(lang.inner.as_str(), "en-US");
342    }
343}
344
345#[cfg(test)]
346mod autotest_generated {
347    //! Adversarial tests: malformed / huge / unicode parser input, numeric
348    //! saturation (`FloatValue` encodes `f32 * 1000.0` into an `isize`, so every
349    //! non-finite input must land on a *finite* encoded value), encode/decode
350    //! round-trips and predicate invariants.
351    #![allow(clippy::float_cmp)]
352
353    use super::*;
354
355    /// `FormatAsCssValue` needs a real `Formatter`; this adapter supplies one.
356    struct AsCss<'a, T: FormatAsCssValue>(&'a T);
357
358    impl<T: FormatAsCssValue> std::fmt::Display for AsCss<'_, T> {
359        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
360            self.0.format_as_css_value(f)
361        }
362    }
363
364    // ---------------------------------------------------------------------
365    // StyleExclusionMargin::is_initial (predicate)
366    // ---------------------------------------------------------------------
367
368    #[test]
369    fn exclusion_margin_is_initial_true_and_false() {
370        assert!(StyleExclusionMargin::default().is_initial());
371        assert!(StyleExclusionMargin {
372            inner: FloatValue::const_new(0)
373        }
374        .is_initial());
375        assert!(!StyleExclusionMargin {
376            inner: FloatValue::const_new(1)
377        }
378        .is_initial());
379        assert!(!StyleExclusionMargin {
380            inner: FloatValue::const_new(-1)
381        }
382        .is_initial());
383    }
384
385    #[test]
386    fn exclusion_margin_is_initial_on_boundary_encodings() {
387        // Negative zero and sub-precision magnitudes encode to 0 => "initial".
388        for v in [0.0_f32, -0.0, 0.0004, -0.0004, f32::MIN_POSITIVE, 1e-30] {
389            let m = StyleExclusionMargin {
390                inner: FloatValue::new(v),
391            };
392            assert!(m.is_initial(), "{v} should encode to the initial value");
393            assert_eq!(m.inner.get(), 0.0);
394        }
395
396        // NaN saturates to 0 in the f32 -> isize cast, so it is *also* "initial".
397        let nan = StyleExclusionMargin {
398            inner: FloatValue::new(f32::NAN),
399        };
400        assert!(nan.is_initial());
401        assert!(!nan.inner.get().is_nan());
402
403        // Saturating extremes are deterministic and decidedly not initial.
404        for v in [f32::INFINITY, f32::NEG_INFINITY, f32::MAX, f32::MIN] {
405            let m = StyleExclusionMargin {
406                inner: FloatValue::new(v),
407            };
408            assert!(!m.is_initial(), "{v} must not be reported as initial");
409            assert!(m.inner.get().is_finite());
410        }
411    }
412
413    // ---------------------------------------------------------------------
414    // StyleHyphenationLanguage::is_initial (predicate)
415    // ---------------------------------------------------------------------
416
417    #[test]
418    fn hyphenation_is_initial_true_and_false() {
419        assert!(StyleHyphenationLanguage::default().is_initial());
420        assert!(StyleHyphenationLanguage {
421            inner: AzString::from_const_str("en-US"),
422        }
423        .is_initial());
424
425        // The comparison is exact and case-sensitive.
426        for not_initial in [
427            "",
428            " ",
429            "en-us",
430            "EN-US",
431            "en-US ",
432            "en",
433            "de-DE",
434            "en\u{0}US",
435        ] {
436            assert!(
437                !StyleHyphenationLanguage {
438                    inner: AzString::from_string(not_initial.to_string()),
439                }
440                .is_initial(),
441                "{not_initial:?} must not be reported as initial"
442            );
443        }
444    }
445
446    #[test]
447    fn hyphenation_is_initial_on_extreme_strings_does_not_panic() {
448        for s in [
449            "\u{1F600}".to_string(),
450            "e\u{0301}n-US".to_string(), // combining acute on the 'e'
451            "en-US\u{200B}".to_string(), // zero-width space
452            "a".repeat(1_000_000),
453        ] {
454            let lang = StyleHyphenationLanguage {
455                inner: AzString::from_string(s.clone()),
456            };
457            assert!(!lang.is_initial(), "{s:?} must not be reported as initial");
458        }
459    }
460
461    // ---------------------------------------------------------------------
462    // Formatting / round-trip of the value types
463    // ---------------------------------------------------------------------
464
465    #[test]
466    fn exclusion_margin_print_and_format_agree() {
467        for v in [0.0_f32, 10.5, -3.25, 123.456, f32::INFINITY, f32::NAN] {
468            let m = StyleExclusionMargin {
469                inner: FloatValue::new(v),
470            };
471            let printed = m.print_as_css_value();
472            assert_eq!(printed, AsCss(&m).to_string());
473            // Whatever went in, what comes out is always a finite number.
474            assert!(!printed.contains("NaN") && !printed.contains("inf"));
475            assert!(m.format_as_rust_code(0).contains(&printed));
476        }
477    }
478
479    #[test]
480    fn hyphenation_print_and_format_agree() {
481        for s in ["en-US", "", "a", "\u{1F600}", "quote\"inside"] {
482            let lang = StyleHyphenationLanguage {
483                inner: AzString::from_string(s.to_string()),
484            };
485            let printed = lang.print_as_css_value();
486            assert_eq!(printed, AsCss(&lang).to_string());
487            assert_eq!(printed, format!("\"{s}\""));
488            assert!(lang.format_as_rust_code(0).contains(s));
489        }
490    }
491
492    #[test]
493    fn exclusion_margin_ord_and_hash_are_consistent_with_value() {
494        use std::{
495            collections::hash_map::DefaultHasher,
496            hash::{Hash, Hasher},
497        };
498
499        let hash = |m: &StyleExclusionMargin| {
500            let mut h = DefaultHasher::new();
501            m.hash(&mut h);
502            h.finish()
503        };
504
505        let a = StyleExclusionMargin {
506            inner: FloatValue::new(1.5),
507        };
508        let b = StyleExclusionMargin {
509            inner: FloatValue::new(1.5),
510        };
511        let c = StyleExclusionMargin {
512            inner: FloatValue::new(2.5),
513        };
514
515        assert_eq!(a, b);
516        assert_eq!(hash(&a), hash(&b));
517        assert!(a < c);
518        assert!(a.inner.get() < c.inner.get());
519
520        // 1.5 and 1.5004 collide: CSS keeps ~3 decimals of precision.
521        let d = StyleExclusionMargin {
522            inner: FloatValue::new(1.5004),
523        };
524        assert_eq!(a, d);
525        assert_eq!(hash(&a), hash(&d));
526    }
527
528    // ---------------------------------------------------------------------
529    // parse_style_exclusion_margin (parser)
530    // ---------------------------------------------------------------------
531
532    #[cfg(feature = "parser")]
533    #[test]
534    fn parse_exclusion_margin_valid_minimal() {
535        assert_eq!(
536            parse_style_exclusion_margin("0").unwrap(),
537            StyleExclusionMargin::default()
538        );
539        assert_eq!(parse_style_exclusion_margin("1").unwrap().inner.get(), 1.0);
540    }
541
542    #[cfg(feature = "parser")]
543    #[test]
544    fn parse_exclusion_margin_empty_and_whitespace_only() {
545        for input in ["", " ", "   ", "\t\n", "\r\n\t ", "\u{00A0}"] {
546            assert!(
547                parse_style_exclusion_margin(input).is_err(),
548                "{input:?} must not parse"
549            );
550        }
551    }
552
553    #[cfg(feature = "parser")]
554    #[test]
555    fn parse_exclusion_margin_garbage_is_rejected() {
556        for input in [
557            "abc",
558            "px",
559            "10px",
560            "1,5",
561            "1_000",
562            "0x10",
563            "--5",
564            "5-",
565            "+-1",
566            ".",
567            "-",
568            "+",
569            "e5",
570            "1e",
571            "1.2.3",
572            "null",
573            "None",
574            "{}",
575            "()",
576            "/*10*/",
577            "10 20",
578            "\0",
579            "\u{7}\u{1b}[0m",
580        ] {
581            assert!(
582                parse_style_exclusion_margin(input).is_err(),
583                "{input:?} must be rejected"
584            );
585        }
586    }
587
588    #[cfg(feature = "parser")]
589    #[test]
590    fn parse_exclusion_margin_leading_trailing_junk() {
591        // Surrounding ASCII whitespace is trimmed...
592        assert_eq!(
593            parse_style_exclusion_margin("  10.5  ")
594                .unwrap()
595                .inner
596                .get(),
597            10.5
598        );
599        assert_eq!(
600            parse_style_exclusion_margin("\n\t-3.25\t\n")
601                .unwrap()
602                .inner
603                .get(),
604            -3.25
605        );
606        // ...but any trailing non-numeric junk is fatal, never silently dropped.
607        for input in [
608            "10.5px",
609            "10.5;garbage",
610            "10.5 !important",
611            "10.5;",
612            "10.5%",
613        ] {
614            assert!(
615                parse_style_exclusion_margin(input).is_err(),
616                "{input:?} must be rejected, not truncated to a number"
617            );
618        }
619    }
620
621    #[cfg(feature = "parser")]
622    #[test]
623    fn parse_exclusion_margin_boundary_numbers() {
624        // Signed zeroes all collapse onto the initial value.
625        for input in ["0", "-0", "+0", "0.0", "-0.0", "0e0"] {
626            let m = parse_style_exclusion_margin(input).unwrap();
627            assert_eq!(m.inner.number, 0, "{input:?} should encode to zero");
628            assert!(m.is_initial());
629        }
630
631        // Sub-precision magnitudes truncate to zero rather than rounding away.
632        for input in ["0.0004", "-0.0004", "1e-30", "-1e-30"] {
633            let m = parse_style_exclusion_margin(input).unwrap();
634            assert_eq!(m.inner.number, 0, "{input:?} should truncate to zero");
635        }
636
637        // i64::MAX / f32::MAX overflow the isize encoding and must saturate,
638        // not wrap or panic.
639        for input in [
640            i64::MAX.to_string(),
641            i64::MIN.to_string(),
642            f32::MAX.to_string(),
643            format!("{}", f32::MIN),
644            "1e38".to_string(),
645            "-1e38".to_string(),
646        ] {
647            let m = parse_style_exclusion_margin(&input).unwrap();
648            assert!(
649                m.inner.get().is_finite(),
650                "{input:?} must decode to a finite value, got {}",
651                m.inner.get()
652            );
653        }
654
655        assert_eq!(
656            parse_style_exclusion_margin(&f32::MAX.to_string())
657                .unwrap()
658                .inner
659                .number,
660            isize::MAX
661        );
662        assert_eq!(
663            parse_style_exclusion_margin(&format!("{}", f32::MIN))
664                .unwrap()
665                .inner
666                .number,
667            isize::MIN
668        );
669    }
670
671    #[cfg(feature = "parser")]
672    #[test]
673    fn parse_exclusion_margin_nan_and_infinity_never_escape() {
674        // Rust's f32 parser accepts these; the isize encoding must sanitize them
675        // so that no NaN/inf ever reaches layout.
676        let nan = parse_style_exclusion_margin("NaN").unwrap();
677        assert_eq!(nan.inner.number, 0);
678        assert!(!nan.inner.get().is_nan());
679        assert!(nan.is_initial());
680        assert_eq!(parse_style_exclusion_margin("nan").unwrap().inner.number, 0);
681        assert_eq!(
682            parse_style_exclusion_margin("-NaN").unwrap().inner.number,
683            0
684        );
685
686        for input in ["inf", "infinity", "+inf", "INF", "Infinity", "1e400"] {
687            let m = parse_style_exclusion_margin(input).unwrap();
688            assert_eq!(m.inner.number, isize::MAX, "{input:?} must saturate");
689            assert!(m.inner.get().is_finite());
690        }
691        for input in ["-inf", "-infinity", "-INF", "-1e400"] {
692            let m = parse_style_exclusion_margin(input).unwrap();
693            assert_eq!(m.inner.number, isize::MIN, "{input:?} must saturate");
694            assert!(m.inner.get().is_finite());
695        }
696    }
697
698    #[cfg(feature = "parser")]
699    #[test]
700    fn parse_exclusion_margin_unicode_input() {
701        // Non-ASCII digits/letters are not numbers.
702        for input in [
703            "\u{1F600}",     // emoji
704            "\u{FF15}",      // fullwidth digit five
705            "1\u{0301}",     // combining acute after a digit
706            "\u{202E}10.5",  // right-to-left override
707            "\u{FEFF}10.5",  // BOM
708            "١٢٣",           // arabic-indic digits
709            "10.5\u{1F4A9}", // trailing emoji
710        ] {
711            assert!(
712                parse_style_exclusion_margin(input).is_err(),
713                "{input:?} must be rejected"
714            );
715        }
716
717        // Unicode whitespace is stripped by str::trim; whatever the outcome, the
718        // parser must never invent a wrong number.
719        let r = parse_style_exclusion_margin("\u{00A0}10.5\u{2003}");
720        assert!(r.is_err() || r.unwrap().inner.get() == 10.5);
721    }
722
723    #[cfg(feature = "parser")]
724    #[test]
725    fn parse_exclusion_margin_extremely_long_input() {
726        // 1M digits overflow f32 -> inf -> saturated isize. Must not hang or panic.
727        let huge = "9".repeat(1_000_000);
728        let m = parse_style_exclusion_margin(&huge).unwrap();
729        assert_eq!(m.inner.number, isize::MAX);
730        assert!(m.inner.get().is_finite());
731
732        // 1M fractional digits are a legal (if silly) float.
733        let long_fraction = format!("1.{}", "0".repeat(1_000_000));
734        assert_eq!(
735            parse_style_exclusion_margin(&long_fraction)
736                .unwrap()
737                .inner
738                .get(),
739            1.0
740        );
741
742        // 1M garbage bytes still just return Err.
743        assert!(parse_style_exclusion_margin(&"z".repeat(1_000_000)).is_err());
744        assert!(parse_style_exclusion_margin(&" ".repeat(1_000_000)).is_err());
745    }
746
747    #[cfg(feature = "parser")]
748    #[test]
749    fn parse_exclusion_margin_deeply_nested_input_does_not_stack_overflow() {
750        let nested = format!("{}{}", "(".repeat(10_000), ")".repeat(10_000));
751        assert!(parse_style_exclusion_margin(&nested).is_err());
752
753        let nested_number = format!("{}1{}", "(".repeat(10_000), ")".repeat(10_000));
754        assert!(parse_style_exclusion_margin(&nested_number).is_err());
755
756        assert!(parse_style_exclusion_margin(&"-".repeat(10_000)).is_err());
757    }
758
759    #[cfg(feature = "parser")]
760    #[test]
761    fn parse_exclusion_margin_round_trips_through_css_and_rust_code() {
762        for input in [
763            "0", "1", "10.5", "-3.25", "0.001", "123.456", "-0.5", "1000",
764        ] {
765            let parsed = parse_style_exclusion_margin(input).unwrap();
766
767            // encode == decode: printing and re-parsing is a fixed point.
768            let printed = parsed.print_as_css_value();
769            let reparsed = parse_style_exclusion_margin(&printed).unwrap();
770            assert_eq!(
771                parsed, reparsed,
772                "{input:?} printed as {printed:?} did not round-trip"
773            );
774            assert_eq!(printed, reparsed.print_as_css_value());
775
776            // The generated Rust code embeds the same decoded value.
777            assert!(parsed.format_as_rust_code(0).contains(&printed));
778        }
779    }
780
781    // ---------------------------------------------------------------------
782    // StyleExclusionMarginParseError::to_contained / ...Owned::to_shared
783    // ---------------------------------------------------------------------
784
785    #[cfg(feature = "parser")]
786    #[test]
787    fn exclusion_margin_error_to_contained_carries_the_message() {
788        let err = parse_style_exclusion_margin("garbage").unwrap_err();
789        let StyleExclusionMarginParseErrorOwned::FloatValue(msg) = err.to_contained();
790        assert!(!msg.as_str().is_empty());
791        // impl_debug_as_display: Debug and Display must agree, and the Display
792        // form must name the property.
793        assert_eq!(format!("{err:?}"), format!("{err}"));
794        assert!(format!("{err}").contains("-azul-exclusion-margin"));
795        assert!(format!("{err}").contains(msg.as_str()));
796    }
797
798    #[cfg(feature = "parser")]
799    #[test]
800    fn exclusion_margin_error_to_shared_is_lossy_but_total() {
801        // to_shared() cannot rebuild a ParseFloatError from its message, so it
802        // always yields the empty-string error. Pin that: it is the one shape a
803        // caller can rely on, and it must never panic - not even for a message
804        // that no ParseFloatError would ever produce.
805        let empty_err_msg: AzString = format!("{}", "".parse::<f32>().unwrap_err()).into();
806
807        for msg in [
808            String::new(),
809            "invalid float literal".to_string(),
810            "\u{1F600}".to_string(),
811            "x".repeat(1_000_000),
812        ] {
813            let owned = StyleExclusionMarginParseErrorOwned::FloatValue(msg.clone().into());
814            let shared = owned.to_shared();
815            assert_eq!(
816                shared.to_contained(),
817                StyleExclusionMarginParseErrorOwned::FloatValue(empty_err_msg.clone()),
818                "to_shared() should normalise {msg:?} onto the empty-string error"
819            );
820        }
821
822        // Consequently the Owned -> shared -> Owned round-trip is *not* the
823        // identity for a non-empty-string error; only the empty-string one is a
824        // fixed point.
825        let empty = parse_style_exclusion_margin("").unwrap_err();
826        assert_eq!(empty.to_contained().to_shared(), empty);
827        assert_eq!(
828            empty.to_contained().to_shared().to_contained(),
829            empty.to_contained()
830        );
831    }
832
833    // ---------------------------------------------------------------------
834    // parse_style_hyphenation_language (parser)
835    // ---------------------------------------------------------------------
836
837    #[cfg(feature = "parser")]
838    #[test]
839    fn parse_hyphenation_language_valid_minimal() {
840        let lang = parse_style_hyphenation_language("en-US").unwrap();
841        assert_eq!(lang.inner.as_str(), "en-US");
842        assert!(lang.is_initial());
843        assert_eq!(
844            parse_style_hyphenation_language("a")
845                .unwrap()
846                .inner
847                .as_str(),
848            "a"
849        );
850    }
851
852    #[cfg(feature = "parser")]
853    #[test]
854    fn parse_hyphenation_language_empty_and_whitespace_only() {
855        for input in ["", " ", "   ", "\t\n", "\r\n\t ", "\"\"", "''", "\" \""] {
856            assert!(
857                parse_style_hyphenation_language(input).is_err(),
858                "{input:?} must not parse"
859            );
860        }
861    }
862
863    #[cfg(feature = "parser")]
864    #[test]
865    fn parse_hyphenation_language_garbage_is_rejected() {
866        for input in [
867            "en_US",       // underscore is not BCP 47
868            "-en",         // leading hyphen
869            "en-",         // trailing hyphen
870            "en US",       // interior space
871            "\" en-US \"", // interior space after unquoting
872            "en;US",
873            "en/US",
874            "en.US",
875            "en*",
876            "<script>",
877            "en\0US", // interior NUL
878            "en\nUS",
879            "'en-US\"", // mismatched quotes: quotes survive into the tag
880            "\"en-US'",
881            "\"en-US", // unterminated
882            "en-US\"",
883        ] {
884            assert!(
885                matches!(
886                    parse_style_hyphenation_language(input),
887                    Err(StyleHyphenationLanguageParseError::InvalidString(_))
888                ),
889                "{input:?} must be rejected"
890            );
891        }
892    }
893
894    #[cfg(feature = "parser")]
895    #[test]
896    fn parse_hyphenation_language_leading_trailing_junk() {
897        // Surrounding whitespace is trimmed, inside and outside of quotes.
898        assert_eq!(
899            parse_style_hyphenation_language("  en-US  ")
900                .unwrap()
901                .inner
902                .as_str(),
903            "en-US"
904        );
905        assert_eq!(
906            parse_style_hyphenation_language("\t\"de-DE\"\n")
907                .unwrap()
908                .inner
909                .as_str(),
910            "de-DE"
911        );
912        // Trailing junk is fatal, never silently dropped.
913        for input in ["en-US;", "en-US !important", "\"en-US\";", "en-US /*c*/"] {
914            assert!(
915                parse_style_hyphenation_language(input).is_err(),
916                "{input:?} must be rejected, not truncated"
917            );
918        }
919    }
920
921    #[cfg(feature = "parser")]
922    #[test]
923    fn parse_hyphenation_language_unicode_input() {
924        for input in [
925            "日本語",
926            "\u{1F600}",
927            "\"\u{1F600}\"",
928            "e\u{0301}n-US", // combining acute
929            "en-US\u{200B}", // zero-width space
930            "\u{FEFF}en-US", // BOM
931            "en-US",     // fullwidth latin
932            "ру-RU",
933        ] {
934            assert!(
935                matches!(
936                    parse_style_hyphenation_language(input),
937                    Err(StyleHyphenationLanguageParseError::InvalidString(_))
938                ),
939                "{input:?} must be rejected without panicking"
940            );
941        }
942
943        // A multibyte char right inside the quotes must not split a char boundary.
944        let r = parse_style_hyphenation_language("\"日本語\"");
945        assert!(r.is_err());
946    }
947
948    #[cfg(feature = "parser")]
949    #[test]
950    fn parse_hyphenation_language_accepts_any_ascii_alphanumeric_tag() {
951        // The validation is "ASCII alphanumeric + interior hyphens", so purely
952        // numeric and nonsense-but-ASCII tags are accepted today. Characterises
953        // the current (lax) behaviour so a future tightening is a visible change.
954        for input in [
955            "0",
956            "123",
957            "NaN",
958            "inf",
959            "zzzzzz",
960            "sr-Latn-RS",
961            "x-private",
962        ] {
963            let lang = parse_style_hyphenation_language(input).unwrap();
964            assert_eq!(lang.inner.as_str(), input);
965        }
966        assert_eq!(
967            parse_style_hyphenation_language(&i64::MAX.to_string())
968                .unwrap()
969                .inner
970                .as_str(),
971            i64::MAX.to_string()
972        );
973        // ...but a negative number starts with a hyphen and is therefore rejected.
974        assert!(parse_style_hyphenation_language(&i64::MIN.to_string()).is_err());
975        assert!(parse_style_hyphenation_language("-0").is_err());
976    }
977
978    #[cfg(feature = "parser")]
979    #[test]
980    fn parse_hyphenation_language_extremely_long_input() {
981        let huge = "a".repeat(1_000_000);
982        assert_eq!(
983            parse_style_hyphenation_language(&huge)
984                .unwrap()
985                .inner
986                .as_str(),
987            huge
988        );
989
990        let huge_quoted = format!("\"{huge}\"");
991        assert_eq!(
992            parse_style_hyphenation_language(&huge_quoted)
993                .unwrap()
994                .inner
995                .as_str(),
996            huge
997        );
998
999        // A 1M-char run of hyphens is rejected (leading hyphen), not hung on.
1000        assert!(parse_style_hyphenation_language(&"-".repeat(1_000_000)).is_err());
1001        // 1M non-ASCII bytes: rejected, and the error carries the whole input.
1002        assert!(parse_style_hyphenation_language(&"é".repeat(1_000_000)).is_err());
1003    }
1004
1005    #[cfg(feature = "parser")]
1006    #[test]
1007    fn parse_hyphenation_language_deeply_nested_input_does_not_stack_overflow() {
1008        let nested = format!("{}en-US{}", "(".repeat(10_000), ")".repeat(10_000));
1009        assert!(parse_style_hyphenation_language(&nested).is_err());
1010
1011        let nested_quotes = format!("{}en-US{}", "\"".repeat(10_000), "\"".repeat(10_000));
1012        assert!(parse_style_hyphenation_language(&nested_quotes).is_err());
1013    }
1014
1015    #[cfg(feature = "parser")]
1016    #[test]
1017    fn parse_hyphenation_language_round_trips_through_css_and_rust_code() {
1018        for input in [
1019            "en-US",
1020            "de-DE",
1021            "zh",
1022            "sr-Latn-RS",
1023            "en--US",
1024            "x-private",
1025            "0",
1026        ] {
1027            let parsed = parse_style_hyphenation_language(input).unwrap();
1028
1029            // print_as_css_value() re-quotes; re-parsing must strip the quotes
1030            // back to exactly the same tag (encode == decode).
1031            let printed = parsed.print_as_css_value();
1032            assert_eq!(printed, format!("\"{input}\""));
1033            let reparsed = parse_style_hyphenation_language(&printed).unwrap();
1034            assert_eq!(parsed, reparsed, "{input:?} did not round-trip");
1035            assert_eq!(printed, reparsed.print_as_css_value());
1036
1037            // Single quotes are an equally valid encoding of the same value.
1038            assert_eq!(
1039                parse_style_hyphenation_language(&format!("'{input}'")).unwrap(),
1040                parsed
1041            );
1042
1043            assert!(parsed.format_as_rust_code(0).contains(input));
1044        }
1045    }
1046
1047    // ---------------------------------------------------------------------
1048    // StyleHyphenationLanguageParseError::to_contained / ...Owned::to_shared
1049    // ---------------------------------------------------------------------
1050
1051    #[cfg(feature = "parser")]
1052    #[test]
1053    fn hyphenation_error_to_contained_carries_the_offending_string() {
1054        let err = parse_style_hyphenation_language("en_US").unwrap_err();
1055        let StyleHyphenationLanguageParseErrorOwned::InvalidString(msg) = err.to_contained();
1056        assert_eq!(msg.as_str(), "en_US");
1057        assert_eq!(format!("{err:?}"), format!("{err}"));
1058        assert!(format!("{err}").contains("-azul-hyphenation-language"));
1059        assert!(format!("{err}").contains("en_US"));
1060    }
1061
1062    #[cfg(feature = "parser")]
1063    #[test]
1064    fn hyphenation_error_round_trips_losslessly() {
1065        for msg in [
1066            String::new(),
1067            "en_US".to_string(),
1068            "\u{1F600}".to_string(),
1069            "\0".to_string(),
1070            "x".repeat(1_000_000),
1071        ] {
1072            let shared = StyleHyphenationLanguageParseError::InvalidString(msg.clone());
1073            let owned = shared.to_contained();
1074            assert_eq!(owned.to_shared(), shared, "{msg:?} lost data on round-trip");
1075            assert_eq!(owned.to_shared().to_contained(), owned);
1076
1077            let owned_direct =
1078                StyleHyphenationLanguageParseErrorOwned::InvalidString(msg.clone().into());
1079            assert_eq!(owned_direct, owned);
1080            let StyleHyphenationLanguageParseErrorOwned::InvalidString(inner) = owned_direct;
1081            assert_eq!(inner.as_str(), msg);
1082        }
1083    }
1084
1085    #[cfg(feature = "parser")]
1086    #[test]
1087    fn hyphenation_error_for_empty_input_reports_the_unquoted_string() {
1088        // The error carries the *unquoted* text, not the raw input.
1089        let err = parse_style_hyphenation_language("\"\"").unwrap_err();
1090        assert_eq!(
1091            err.to_contained(),
1092            StyleHyphenationLanguageParseErrorOwned::InvalidString(AzString::from_const_str(""))
1093        );
1094        assert!(err.to_contained().to_shared() == err);
1095    }
1096
1097    // ---------------------------------------------------------------------
1098    // Regression: a lone quote must fail validation, not panic.
1099    // ---------------------------------------------------------------------
1100
1101    /// A lone quote character must return `Err`, not panic.
1102    ///
1103    /// `parse_style_hyphenation_language("\"")` used to see a string that both
1104    /// starts and ends with `"`, slice `&trimmed[1..trimmed.len() - 1]` ==
1105    /// `&s[1..0]`, and panic with "slice index starts at 1 but ends at 0". Any
1106    /// CSS input of `-azul-hyphenation-language: ";` reached it. The `len() >= 2`
1107    /// guard now keeps a single-quote input intact so it fails BCP-47
1108    /// validation cleanly.
1109    #[cfg(feature = "parser")]
1110    #[test]
1111    fn parse_hyphenation_language_lone_quote_must_not_panic() {
1112        for input in ["\"", "'", " \" ", "\t'\n"] {
1113            assert!(
1114                parse_style_hyphenation_language(input).is_err(),
1115                "{input:?} must return Err, not panic"
1116            );
1117        }
1118    }
1119}