Skip to main content

azul_css/props/layout/
wrapping.rs

1//! CSS properties for writing modes and clearing.
2//!
3//! Key types:
4//! - [`LayoutWritingMode`] — `writing-mode` (`horizontal-tb`, `vertical-rl`, `vertical-lr`)
5//! - [`LayoutClear`] — `clear` (`none`, `left`, `right`, `both`)
6//!
7//! Parse functions are gated behind the `parser` feature and are consumed
8//! by the CSS property system in `property.rs`.
9
10use alloc::string::{String, ToString};
11use crate::corety::AzString;
12
13use crate::props::formatter::PrintAsCssValue;
14
15// --- writing-mode (LayoutWritingMode) ---
16
17// +spec:writing-modes:ec496c - writing-mode property: horizontal-tb, vertical-rl, vertical-lr block flow directions
18// +spec:writing-modes:fdc4cc - writing-mode property: horizontal-tb | vertical-rl | vertical-lr
19// +spec:writing-modes:aeb9bb - writing-mode property determines block flow direction
20/// Represents a `writing-mode` attribute
21// +spec:writing-modes:a7f174 - line orientation: in vertical-lr the line-over (ascender) side is block-end, not block-start
22#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
23#[repr(C)]
24// +spec:block-formatting-context:387117 - writing-mode specifies horizontal/vertical line layout and block progression direction
25// +spec:block-formatting-context:3815e7 - vertical-rl writing mode supported via VerticalRl variant
26// +spec:block-formatting-context:9d7cd4 - vertical writing mode support (VerticalRl, VerticalLr)
27#[derive(Default)]
28pub enum LayoutWritingMode {
29    /// Top-to-bottom block flow, left-to-right inline direction (Latin, etc.).
30    #[default]
31    HorizontalTb,
32    /// Right-to-left block flow, top-to-bottom inline direction (CJK vertical).
33    VerticalRl,
34    // +spec:writing-modes:f35728 - vertical-lr writing mode for left-to-right block flow (Manchu, Mongolian)
35    /// Left-to-right block flow, top-to-bottom inline direction (Mongolian).
36    VerticalLr,
37}
38
39
40impl LayoutWritingMode {
41    /// Returns true if the writing mode is vertical (`VerticalRl` or `VerticalLr`)
42    #[must_use] pub const fn is_vertical(self) -> bool {
43        matches!(self, Self::VerticalRl | Self::VerticalLr)
44    }
45}
46
47impl core::fmt::Debug for LayoutWritingMode {
48    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
49        write!(f, "{}", self.print_as_css_value())
50    }
51}
52
53impl core::fmt::Display for LayoutWritingMode {
54    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
55        write!(f, "{}", self.print_as_css_value())
56    }
57}
58
59impl PrintAsCssValue for LayoutWritingMode {
60    fn print_as_css_value(&self) -> String {
61        match self {
62            Self::HorizontalTb => "horizontal-tb".to_string(),
63            Self::VerticalRl => "vertical-rl".to_string(),
64            Self::VerticalLr => "vertical-lr".to_string(),
65        }
66    }
67}
68
69#[cfg(feature = "parser")]
70#[derive(Clone, PartialEq, Eq)]
71pub enum LayoutWritingModeParseError<'a> {
72    InvalidValue(&'a str),
73}
74
75#[cfg(feature = "parser")]
76impl_debug_as_display!(LayoutWritingModeParseError<'a>);
77#[cfg(feature = "parser")]
78impl_display! { LayoutWritingModeParseError<'a>, {
79    InvalidValue(e) => format!("Invalid writing-mode value: \"{}\"", e),
80}}
81
82#[cfg(feature = "parser")]
83#[derive(Debug, Clone, PartialEq, Eq)]
84#[repr(C, u8)]
85pub enum LayoutWritingModeParseErrorOwned {
86    InvalidValue(AzString),
87}
88
89#[cfg(feature = "parser")]
90impl LayoutWritingModeParseError<'_> {
91    #[must_use] pub fn to_contained(&self) -> LayoutWritingModeParseErrorOwned {
92        match self {
93            LayoutWritingModeParseError::InvalidValue(s) => {
94                LayoutWritingModeParseErrorOwned::InvalidValue((*s).to_string().into())
95            }
96        }
97    }
98}
99
100#[cfg(feature = "parser")]
101impl LayoutWritingModeParseErrorOwned {
102    #[must_use] pub fn to_shared(&self) -> LayoutWritingModeParseError<'_> {
103        match self {
104            Self::InvalidValue(s) => {
105                LayoutWritingModeParseError::InvalidValue(s.as_str())
106            }
107        }
108    }
109}
110
111#[cfg(feature = "parser")]
112/// # Errors
113///
114/// Returns an error if `input` is not a valid CSS `writing-mode` value.
115pub fn parse_layout_writing_mode(
116    input: &str,
117) -> Result<LayoutWritingMode, LayoutWritingModeParseError<'_>> {
118    let input = input.trim();
119    match input {
120        "horizontal-tb" => Ok(LayoutWritingMode::HorizontalTb),
121        "vertical-rl" => Ok(LayoutWritingMode::VerticalRl),
122        // +spec:writing-modes:23147f - SVG1.1 tb-lr maps to vertical-lr
123        "vertical-lr" | "tb-lr" => Ok(LayoutWritingMode::VerticalLr),
124        _ => Err(LayoutWritingModeParseError::InvalidValue(input)),
125    }
126}
127
128// --- clear (LayoutClear) ---
129
130/// Represents a `clear` attribute
131#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
132#[repr(C)]
133#[derive(Default)]
134pub enum LayoutClear {
135    /// No clearing; element is not moved below preceding floats.
136    #[default]
137    None,
138    /// Element is moved below preceding left floats.
139    Left,
140    /// Element is moved below preceding right floats.
141    Right,
142    /// Element is moved below all preceding floats.
143    Both,
144}
145
146
147impl core::fmt::Debug for LayoutClear {
148    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
149        write!(f, "{}", self.print_as_css_value())
150    }
151}
152
153impl core::fmt::Display for LayoutClear {
154    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
155        write!(f, "{}", self.print_as_css_value())
156    }
157}
158
159impl PrintAsCssValue for LayoutClear {
160    fn print_as_css_value(&self) -> String {
161        match self {
162            Self::None => "none".to_string(),
163            Self::Left => "left".to_string(),
164            Self::Right => "right".to_string(),
165            Self::Both => "both".to_string(),
166        }
167    }
168}
169
170#[cfg(feature = "parser")]
171#[derive(Clone, PartialEq, Eq)]
172pub enum LayoutClearParseError<'a> {
173    InvalidValue(&'a str),
174}
175
176#[cfg(feature = "parser")]
177impl_debug_as_display!(LayoutClearParseError<'a>);
178#[cfg(feature = "parser")]
179impl_display! { LayoutClearParseError<'a>, {
180    InvalidValue(e) => format!("Invalid clear value: \"{}\"", e),
181}}
182
183#[cfg(feature = "parser")]
184#[derive(Debug, Clone, PartialEq, Eq)]
185#[repr(C, u8)]
186pub enum LayoutClearParseErrorOwned {
187    InvalidValue(AzString),
188}
189
190#[cfg(feature = "parser")]
191impl LayoutClearParseError<'_> {
192    #[must_use] pub fn to_contained(&self) -> LayoutClearParseErrorOwned {
193        match self {
194            LayoutClearParseError::InvalidValue(s) => {
195                LayoutClearParseErrorOwned::InvalidValue((*s).to_string().into())
196            }
197        }
198    }
199}
200
201#[cfg(feature = "parser")]
202impl LayoutClearParseErrorOwned {
203    #[must_use] pub fn to_shared(&self) -> LayoutClearParseError<'_> {
204        match self {
205            Self::InvalidValue(s) => {
206                LayoutClearParseError::InvalidValue(s.as_str())
207            }
208        }
209    }
210}
211
212#[cfg(feature = "parser")]
213/// # Errors
214///
215/// Returns an error if `input` is not a valid CSS `clear` value.
216pub fn parse_layout_clear(input: &str) -> Result<LayoutClear, LayoutClearParseError<'_>> {
217    let input = input.trim();
218    match input {
219        "none" => Ok(LayoutClear::None),
220        "left" => Ok(LayoutClear::Left),
221        "right" => Ok(LayoutClear::Right),
222        "both" => Ok(LayoutClear::Both),
223        _ => Err(LayoutClearParseError::InvalidValue(input)),
224    }
225}
226
227#[cfg(all(test, feature = "parser"))]
228mod tests {
229    use super::*;
230
231    // LayoutWritingMode tests
232    #[test]
233    fn test_parse_writing_mode_horizontal_tb() {
234        assert_eq!(
235            parse_layout_writing_mode("horizontal-tb").unwrap(),
236            LayoutWritingMode::HorizontalTb
237        );
238    }
239
240    #[test]
241    fn test_parse_writing_mode_vertical_rl() {
242        assert_eq!(
243            parse_layout_writing_mode("vertical-rl").unwrap(),
244            LayoutWritingMode::VerticalRl
245        );
246    }
247
248    #[test]
249    fn test_parse_writing_mode_vertical_lr() {
250        assert_eq!(
251            parse_layout_writing_mode("vertical-lr").unwrap(),
252            LayoutWritingMode::VerticalLr
253        );
254    }
255
256    #[test]
257    fn test_parse_writing_mode_invalid() {
258        assert!(parse_layout_writing_mode("invalid").is_err());
259        assert!(parse_layout_writing_mode("horizontal").is_err());
260    }
261
262    #[test]
263    fn test_parse_writing_mode_whitespace() {
264        assert_eq!(
265            parse_layout_writing_mode("  vertical-rl  ").unwrap(),
266            LayoutWritingMode::VerticalRl
267        );
268    }
269
270    // LayoutClear tests
271    #[test]
272    fn test_parse_layout_clear_none() {
273        assert_eq!(parse_layout_clear("none").unwrap(), LayoutClear::None);
274    }
275
276    #[test]
277    fn test_parse_layout_clear_left() {
278        assert_eq!(parse_layout_clear("left").unwrap(), LayoutClear::Left);
279    }
280
281    #[test]
282    fn test_parse_layout_clear_right() {
283        assert_eq!(parse_layout_clear("right").unwrap(), LayoutClear::Right);
284    }
285
286    #[test]
287    fn test_parse_layout_clear_both() {
288        assert_eq!(parse_layout_clear("both").unwrap(), LayoutClear::Both);
289    }
290
291    #[test]
292    fn test_parse_layout_clear_invalid() {
293        assert!(parse_layout_clear("invalid").is_err());
294        assert!(parse_layout_clear("all").is_err());
295    }
296
297    #[test]
298    fn test_parse_layout_clear_whitespace() {
299        assert_eq!(parse_layout_clear("  both  ").unwrap(), LayoutClear::Both);
300    }
301
302    // Print tests
303    #[test]
304    fn test_print_writing_mode() {
305        assert_eq!(
306            LayoutWritingMode::HorizontalTb.print_as_css_value(),
307            "horizontal-tb"
308        );
309        assert_eq!(
310            LayoutWritingMode::VerticalRl.print_as_css_value(),
311            "vertical-rl"
312        );
313        assert_eq!(
314            LayoutWritingMode::VerticalLr.print_as_css_value(),
315            "vertical-lr"
316        );
317    }
318
319    #[test]
320    fn test_print_layout_clear() {
321        assert_eq!(LayoutClear::None.print_as_css_value(), "none");
322        assert_eq!(LayoutClear::Left.print_as_css_value(), "left");
323        assert_eq!(LayoutClear::Right.print_as_css_value(), "right");
324        assert_eq!(LayoutClear::Both.print_as_css_value(), "both");
325    }
326}
327
328#[cfg(test)]
329mod autotest_generated {
330    use super::*;
331
332    const ALL_WRITING_MODES: [LayoutWritingMode; 3] = [
333        LayoutWritingMode::HorizontalTb,
334        LayoutWritingMode::VerticalRl,
335        LayoutWritingMode::VerticalLr,
336    ];
337
338    const ALL_CLEARS: [LayoutClear; 4] = [
339        LayoutClear::None,
340        LayoutClear::Left,
341        LayoutClear::Right,
342        LayoutClear::Both,
343    ];
344
345    // --- LayoutWritingMode::is_vertical (predicate) ---
346
347    #[test]
348    fn is_vertical_known_true_and_false() {
349        assert!(!LayoutWritingMode::HorizontalTb.is_vertical());
350        assert!(LayoutWritingMode::VerticalRl.is_vertical());
351        assert!(LayoutWritingMode::VerticalLr.is_vertical());
352    }
353
354    #[test]
355    fn is_vertical_default_is_horizontal() {
356        assert!(!LayoutWritingMode::default().is_vertical());
357        assert_eq!(LayoutWritingMode::default(), LayoutWritingMode::HorizontalTb);
358    }
359
360    #[test]
361    fn is_vertical_is_const_evaluable() {
362        const HORIZONTAL: bool = LayoutWritingMode::HorizontalTb.is_vertical();
363        const VERTICAL: bool = LayoutWritingMode::VerticalRl.is_vertical();
364        const _: () = assert!(!HORIZONTAL && VERTICAL);
365    }
366
367    #[test]
368    fn is_vertical_agrees_with_css_keyword_prefix() {
369        // Invariant: is_vertical() must agree with the serialized keyword, so the
370        // predicate can never drift away from the CSS value it claims to describe.
371        for mode in ALL_WRITING_MODES {
372            let css = mode.print_as_css_value();
373            assert_eq!(
374                mode.is_vertical(),
375                css.starts_with("vertical-"),
376                "is_vertical() disagrees with keyword {css}"
377            );
378        }
379    }
380
381    #[test]
382    fn is_vertical_is_pure_and_does_not_consume() {
383        // `self`-by-value on a Copy enum: repeated calls must be stable.
384        let mode = LayoutWritingMode::VerticalLr;
385        assert!(mode.is_vertical());
386        assert!(mode.is_vertical());
387        assert_eq!(mode, LayoutWritingMode::VerticalLr);
388    }
389
390    // --- Debug / Display / PrintAsCssValue (serializers) ---
391
392    #[test]
393    fn writing_mode_debug_display_and_css_value_all_agree() {
394        for mode in ALL_WRITING_MODES {
395            let css = mode.print_as_css_value();
396            assert!(!css.is_empty());
397            assert_eq!(format!("{mode:?}"), css);
398            assert_eq!(format!("{mode}"), css);
399        }
400    }
401
402    #[test]
403    fn clear_debug_display_and_css_value_all_agree() {
404        for clear in ALL_CLEARS {
405            let css = clear.print_as_css_value();
406            assert!(!css.is_empty());
407            assert_eq!(format!("{clear:?}"), css);
408            assert_eq!(format!("{clear}"), css);
409        }
410    }
411
412    #[test]
413    fn serializing_defaults_does_not_panic() {
414        assert_eq!(LayoutWritingMode::default().print_as_css_value(), "horizontal-tb");
415        assert_eq!(LayoutClear::default().print_as_css_value(), "none");
416    }
417
418    #[test]
419    fn serialized_keywords_are_distinct() {
420        // Two variants sharing a keyword would silently collapse on re-parse.
421        let modes: alloc::collections::BTreeSet<String> = ALL_WRITING_MODES
422            .iter()
423            .map(PrintAsCssValue::print_as_css_value)
424            .collect();
425        assert_eq!(modes.len(), ALL_WRITING_MODES.len());
426
427        let clears: alloc::collections::BTreeSet<String> = ALL_CLEARS
428            .iter()
429            .map(PrintAsCssValue::print_as_css_value)
430            .collect();
431        assert_eq!(clears.len(), ALL_CLEARS.len());
432    }
433
434    #[test]
435    fn display_impl_ignores_width_and_precision_flags() {
436        // The Display impl forwards through `write!(f, "{}", String)`, which writes
437        // straight to the underlying buffer and drops the outer formatter's flags.
438        // Padding/truncation therefore does NOT apply — pinning the real behaviour so
439        // callers never build a stylesheet assuming `{:>20}` aligns.
440        assert_eq!(format!("{:>20}", LayoutClear::Both), "both");
441        assert_eq!(format!("{:.2}", LayoutClear::Both), "both");
442        assert_eq!(format!("{:>20}", LayoutWritingMode::VerticalRl), "vertical-rl");
443    }
444
445    // --- ordering / hashing invariants on the plain enums ---
446
447    #[test]
448    fn writing_mode_ord_follows_declaration_order() {
449        assert!(LayoutWritingMode::HorizontalTb < LayoutWritingMode::VerticalRl);
450        assert!(LayoutWritingMode::VerticalRl < LayoutWritingMode::VerticalLr);
451    }
452
453    #[test]
454    fn clear_ord_follows_declaration_order() {
455        assert!(LayoutClear::None < LayoutClear::Left);
456        assert!(LayoutClear::Left < LayoutClear::Right);
457        assert!(LayoutClear::Right < LayoutClear::Both);
458    }
459
460    #[test]
461    fn equal_values_hash_equally() {
462        use core::hash::{Hash, Hasher};
463        use std::collections::hash_map::DefaultHasher;
464
465        fn hash_of<T: Hash>(value: &T) -> u64 {
466            let mut hasher = DefaultHasher::new();
467            value.hash(&mut hasher);
468            hasher.finish()
469        }
470
471        // Hash must agree with Eq: independently-constructed equal values hash alike.
472        assert_eq!(
473            hash_of(&LayoutWritingMode::VerticalRl),
474            hash_of(&ALL_WRITING_MODES[1])
475        );
476        assert_eq!(hash_of(&LayoutClear::Both), hash_of(&ALL_CLEARS[3]));
477        assert_ne!(
478            hash_of(&LayoutWritingMode::HorizontalTb),
479            hash_of(&LayoutWritingMode::VerticalRl)
480        );
481    }
482
483    // --- parsers ---
484
485    #[cfg(feature = "parser")]
486    #[test]
487    fn parse_writing_mode_valid_minimal() {
488        assert_eq!(
489            parse_layout_writing_mode("horizontal-tb"),
490            Ok(LayoutWritingMode::HorizontalTb)
491        );
492    }
493
494    #[cfg(feature = "parser")]
495    #[test]
496    fn parse_writing_mode_accepts_svg_tb_lr_alias() {
497        // SVG 1.1 `tb-lr` is an accepted alias for `vertical-lr`.
498        assert_eq!(
499            parse_layout_writing_mode("tb-lr"),
500            Ok(LayoutWritingMode::VerticalLr)
501        );
502        // ...but the other SVG 1.1 writing-mode aliases are NOT mapped.
503        for unsupported in ["lr", "lr-tb", "rl", "rl-tb", "tb"] {
504            assert!(
505                parse_layout_writing_mode(unsupported).is_err(),
506                "{unsupported} unexpectedly parsed"
507            );
508        }
509    }
510
511    #[cfg(feature = "parser")]
512    #[test]
513    fn parse_clear_valid_minimal() {
514        assert_eq!(parse_layout_clear("none"), Ok(LayoutClear::None));
515    }
516
517    #[cfg(feature = "parser")]
518    #[test]
519    fn parse_empty_input_errors_with_empty_payload() {
520        let err = parse_layout_writing_mode("").unwrap_err();
521        assert_eq!(err, LayoutWritingModeParseError::InvalidValue(""));
522        let err = parse_layout_clear("").unwrap_err();
523        assert_eq!(err, LayoutClearParseError::InvalidValue(""));
524    }
525
526    #[cfg(feature = "parser")]
527    #[test]
528    fn parse_whitespace_only_input_errors_after_trimming() {
529        for blank in ["   ", "\t\n", "\r\n\r\n", "\u{0c}", " \t \n \r "] {
530            assert_eq!(
531                parse_layout_writing_mode(blank).unwrap_err(),
532                LayoutWritingModeParseError::InvalidValue(""),
533                "whitespace {blank:?} should trim to the empty payload"
534            );
535            assert_eq!(
536                parse_layout_clear(blank).unwrap_err(),
537                LayoutClearParseError::InvalidValue("")
538            );
539        }
540    }
541
542    #[cfg(feature = "parser")]
543    #[test]
544    fn parse_garbage_returns_err_and_echoes_trimmed_input() {
545        for garbage in [
546            "invalid",
547            "!!!",
548            "\u{0}",
549            "none\u{0}",
550            "{}[]();",
551            "-",
552            "--",
553            "vertical-",
554            "-rl",
555            "vertical rl",
556            "vertical - rl",
557            "vertical_rl",
558            "verticalrl",
559        ] {
560            let err = parse_layout_writing_mode(garbage).unwrap_err();
561            assert_eq!(err, LayoutWritingModeParseError::InvalidValue(garbage.trim()));
562            let err = parse_layout_clear(garbage).unwrap_err();
563            assert_eq!(err, LayoutClearParseError::InvalidValue(garbage.trim()));
564        }
565    }
566
567    #[cfg(feature = "parser")]
568    #[test]
569    fn parse_rejects_css_wide_keywords() {
570        // These are handled (if at all) by the caller in property.rs, not here —
571        // the value parser itself must not silently accept them as keywords.
572        for wide in ["inherit", "initial", "unset", "revert", "revert-layer"] {
573            assert!(parse_layout_writing_mode(wide).is_err(), "{wide} accepted");
574            assert!(parse_layout_clear(wide).is_err(), "{wide} accepted");
575        }
576    }
577
578    #[cfg(feature = "parser")]
579    #[test]
580    fn parse_is_ascii_case_sensitive() {
581        // NOTE: CSS keywords are ASCII case-insensitive per css-values-4, but these
582        // parsers match case-sensitively. Locking in the CURRENT behaviour; see the
583        // report for the spec deviation.
584        for upper in ["HORIZONTAL-TB", "Vertical-Rl", "VERTICAL-LR", "TB-LR"] {
585            assert!(
586                parse_layout_writing_mode(upper).is_err(),
587                "{upper} parsed — case-insensitive matching was added, update this test"
588            );
589        }
590        for upper in ["NONE", "Left", "RIGHT", "Both"] {
591            assert!(
592                parse_layout_clear(upper).is_err(),
593                "{upper} parsed — case-insensitive matching was added, update this test"
594            );
595        }
596    }
597
598    #[cfg(feature = "parser")]
599    #[test]
600    fn parse_leading_trailing_ascii_whitespace_is_trimmed() {
601        assert_eq!(
602            parse_layout_writing_mode("  vertical-rl  "),
603            Ok(LayoutWritingMode::VerticalRl)
604        );
605        assert_eq!(
606            parse_layout_writing_mode("\t\nvertical-lr\r\n"),
607            Ok(LayoutWritingMode::VerticalLr)
608        );
609        assert_eq!(parse_layout_clear("\t both \n"), Ok(LayoutClear::Both));
610    }
611
612    #[cfg(feature = "parser")]
613    #[test]
614    fn parse_rejects_trailing_junk_after_valid_keyword() {
615        for junk in ["both;garbage", "both;", "both both", "both!important", "both,"] {
616            assert_eq!(
617                parse_layout_clear(junk).unwrap_err(),
618                LayoutClearParseError::InvalidValue(junk.trim())
619            );
620        }
621        assert!(parse_layout_writing_mode("vertical-rl;garbage").is_err());
622    }
623
624    #[cfg(feature = "parser")]
625    #[test]
626    fn parse_trims_unicode_whitespace_beyond_css_whitespace() {
627        // `str::trim` uses the Unicode White_Space property, which is a SUPERSET of
628        // CSS whitespace (space, tab, LF, CR, FF). So NBSP-padded keywords parse
629        // here even though a spec-conformant CSS tokenizer would reject them.
630        // Asserting the CURRENT behaviour; see the report.
631        assert_eq!(
632            parse_layout_clear("\u{00a0}both\u{00a0}"),
633            Ok(LayoutClear::Both)
634        );
635        assert_eq!(
636            parse_layout_writing_mode("\u{2003}vertical-rl\u{2003}"),
637            Ok(LayoutWritingMode::VerticalRl)
638        );
639        // Zero-width space and BOM are NOT White_Space, so they stay in the payload.
640        assert_eq!(
641            parse_layout_clear("\u{200b}both").unwrap_err(),
642            LayoutClearParseError::InvalidValue("\u{200b}both")
643        );
644        assert_eq!(
645            parse_layout_clear("\u{feff}both").unwrap_err(),
646            LayoutClearParseError::InvalidValue("\u{feff}both")
647        );
648    }
649
650    #[cfg(feature = "parser")]
651    #[test]
652    fn parse_unicode_input_does_not_panic_on_char_boundaries() {
653        for weird in [
654            "\u{1F600}",
655            "🙂🙃",
656            "e\u{0301}",              // combining acute
657            "n\u{0303}one",           // combining tilde inside a keyword
658            "none",               // fullwidth
659            "نص",                     // RTL
660            "\u{202E}both",           // RTL override
661            "both\u{0301}",
662        ] {
663            let err = parse_layout_writing_mode(weird).unwrap_err();
664            assert_eq!(err, LayoutWritingModeParseError::InvalidValue(weird.trim()));
665            let err = parse_layout_clear(weird).unwrap_err();
666            assert_eq!(err, LayoutClearParseError::InvalidValue(weird.trim()));
667            // The borrowed payload must still be valid UTF-8 we can round-trip.
668            assert_eq!(err.to_contained().to_shared(), err);
669        }
670    }
671
672    #[cfg(feature = "parser")]
673    #[test]
674    fn parse_boundary_numeric_strings_are_rejected() {
675        for numeric in [
676            "0",
677            "-0",
678            "0.0",
679            "NaN",
680            "nan",
681            "inf",
682            "-inf",
683            "infinity",
684            "1e999",
685            "-1e-999",
686            "9223372036854775807",  // i64::MAX
687            "-9223372036854775808", // i64::MIN
688            "18446744073709551616", // u64::MAX + 1
689            "179769313486231570000000000000000000000000000000000000000000000000000000000000000",
690        ] {
691            assert!(parse_layout_writing_mode(numeric).is_err(), "{numeric} parsed");
692            assert!(parse_layout_clear(numeric).is_err(), "{numeric} parsed");
693        }
694    }
695
696    #[cfg(feature = "parser")]
697    #[test]
698    fn parse_extremely_long_input_does_not_panic_or_hang() {
699        let long = "a".repeat(1_000_000);
700        let err = parse_layout_clear(&long).unwrap_err();
701        assert_eq!(err, LayoutClearParseError::InvalidValue(long.as_str()));
702
703        // A megabyte of whitespace must trim down to the empty payload, not hang.
704        let blank = " ".repeat(1_000_000);
705        assert_eq!(
706            parse_layout_writing_mode(&blank).unwrap_err(),
707            LayoutWritingModeParseError::InvalidValue("")
708        );
709
710        // A valid keyword buried in a megabyte of padding still parses.
711        let padded = format!("{blank}vertical-rl{blank}");
712        assert_eq!(
713            parse_layout_writing_mode(&padded),
714            Ok(LayoutWritingMode::VerticalRl)
715        );
716
717        // A near-miss keyword repeated: still exactly one Err, no quadratic blowup.
718        let repeated = "both ".repeat(200_000);
719        assert!(parse_layout_clear(&repeated).is_err());
720    }
721
722    #[cfg(feature = "parser")]
723    #[test]
724    fn parse_deeply_nested_input_does_not_stack_overflow() {
725        let nested = "(".repeat(10_000) + &")".repeat(10_000);
726        assert!(parse_layout_clear(&nested).is_err());
727        assert!(parse_layout_writing_mode(&nested).is_err());
728
729        let brackets = "[".repeat(10_000);
730        assert_eq!(
731            parse_layout_clear(&brackets).unwrap_err(),
732            LayoutClearParseError::InvalidValue(brackets.as_str())
733        );
734    }
735
736    #[cfg(feature = "parser")]
737    #[test]
738    fn parse_error_payload_borrows_the_trimmed_slice_not_the_whole_input() {
739        let input = "   bogus-value   ";
740        let LayoutClearParseError::InvalidValue(payload) = parse_layout_clear(input).unwrap_err();
741        // The error echoes the TRIMMED slice, not the raw input.
742        assert_eq!(payload, "bogus-value");
743        assert_eq!(payload.len(), 11);
744        // ...and it is a borrowed subslice of the original buffer, not a copy.
745        assert!(input.as_ptr() <= payload.as_ptr());
746    }
747
748    // --- round-trip: serialize -> parse -> same value ---
749
750    #[test]
751    #[cfg(feature = "parser")]
752    fn writing_mode_round_trips_through_css_value() {
753        for mode in ALL_WRITING_MODES {
754            let css = mode.print_as_css_value();
755            assert_eq!(parse_layout_writing_mode(&css), Ok(mode), "round-trip {css}");
756            // Display and Debug are the same wire format, so they round-trip too.
757            assert_eq!(parse_layout_writing_mode(&format!("{mode}")), Ok(mode));
758            assert_eq!(parse_layout_writing_mode(&format!("{mode:?}")), Ok(mode));
759        }
760    }
761
762    #[test]
763    #[cfg(feature = "parser")]
764    fn clear_round_trips_through_css_value() {
765        for clear in ALL_CLEARS {
766            let css = clear.print_as_css_value();
767            assert_eq!(parse_layout_clear(&css), Ok(clear), "round-trip {css}");
768            assert_eq!(parse_layout_clear(&format!("{clear}")), Ok(clear));
769            assert_eq!(parse_layout_clear(&format!("{clear:?}")), Ok(clear));
770        }
771    }
772
773    // --- error getters: to_contained / to_shared ---
774
775    #[cfg(feature = "parser")]
776    #[test]
777    fn writing_mode_error_to_contained_preserves_the_value() {
778        let err = parse_layout_writing_mode("bogus").unwrap_err();
779        assert_eq!(
780            err.to_contained(),
781            LayoutWritingModeParseErrorOwned::InvalidValue("bogus".into())
782        );
783    }
784
785    #[cfg(feature = "parser")]
786    #[test]
787    fn clear_error_to_contained_preserves_the_value() {
788        let err = parse_layout_clear("bogus").unwrap_err();
789        assert_eq!(
790            err.to_contained(),
791            LayoutClearParseErrorOwned::InvalidValue("bogus".into())
792        );
793    }
794
795    #[cfg(feature = "parser")]
796    #[test]
797    fn error_to_contained_outlives_the_parsed_input() {
798        // The whole point of to_contained(): escape the input's lifetime.
799        let owned = {
800            let scratch = String::from("temporary-garbage");
801            parse_layout_clear(&scratch).unwrap_err().to_contained()
802        };
803        assert_eq!(
804            owned,
805            LayoutClearParseErrorOwned::InvalidValue("temporary-garbage".into())
806        );
807        assert_eq!(
808            owned.to_shared(),
809            LayoutClearParseError::InvalidValue("temporary-garbage")
810        );
811    }
812
813    #[cfg(feature = "parser")]
814    #[test]
815    fn error_shared_owned_round_trip_is_lossless() {
816        for value in [
817            "",
818            " ",
819            "\u{0}",
820            "🙂",
821            "e\u{0301}",
822            "quote\"inside",
823            "back\\slash",
824            "new\nline",
825        ] {
826            let shared = LayoutClearParseError::InvalidValue(value);
827            let owned = shared.to_contained();
828            assert_eq!(owned.to_shared(), shared, "clear round-trip {value:?}");
829            // to_contained -> to_shared -> to_contained is idempotent.
830            assert_eq!(owned.to_shared().to_contained(), owned);
831
832            let shared = LayoutWritingModeParseError::InvalidValue(value);
833            let owned = shared.to_contained();
834            assert_eq!(owned.to_shared(), shared, "writing-mode round-trip {value:?}");
835            assert_eq!(owned.to_shared().to_contained(), owned);
836        }
837    }
838
839    #[cfg(feature = "parser")]
840    #[test]
841    fn error_round_trip_survives_a_huge_payload() {
842        let huge = "x".repeat(100_000);
843        let shared = LayoutClearParseError::InvalidValue(huge.as_str());
844        let owned = shared.to_contained();
845        let LayoutClearParseError::InvalidValue(back) = owned.to_shared();
846        assert_eq!(back.len(), 100_000);
847        assert_eq!(back, huge.as_str());
848    }
849
850    #[cfg(feature = "parser")]
851    #[test]
852    fn error_to_shared_on_default_azstring_does_not_panic() {
853        // AzString::default() is the empty &'static str — the degenerate case for
854        // the unchecked from_utf8 inside AzString::as_str().
855        let owned = LayoutClearParseErrorOwned::InvalidValue(AzString::default());
856        assert_eq!(owned.to_shared(), LayoutClearParseError::InvalidValue(""));
857
858        let owned = LayoutWritingModeParseErrorOwned::InvalidValue(AzString::default());
859        assert_eq!(
860            owned.to_shared(),
861            LayoutWritingModeParseError::InvalidValue("")
862        );
863    }
864
865    #[cfg(feature = "parser")]
866    #[test]
867    fn error_display_and_debug_include_the_offending_value() {
868        let err = parse_layout_writing_mode("  bogus  ").unwrap_err();
869        let display = format!("{err}");
870        assert_eq!(display, "Invalid writing-mode value: \"bogus\"");
871        // impl_debug_as_display!: Debug must be identical to Display.
872        assert_eq!(format!("{err:?}"), display);
873
874        let err = parse_layout_clear("bogus").unwrap_err();
875        let display = format!("{err}");
876        assert_eq!(display, "Invalid clear value: \"bogus\"");
877        assert_eq!(format!("{err:?}"), display);
878    }
879
880    #[cfg(feature = "parser")]
881    #[test]
882    fn error_display_does_not_panic_on_empty_or_unicode_payloads() {
883        for value in ["", "\u{0}", "🙂", "\u{202E}"] {
884            let err = LayoutClearParseError::InvalidValue(value);
885            assert!(format!("{err}").contains(value));
886            let owned = err.to_contained();
887            assert!(format!("{:?}", owned.to_shared()).contains(value));
888        }
889    }
890}