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