Skip to main content

perl_test_generators/
unicode.rs

1//! Generators for Unicode strings exercising various edge cases.
2//!
3//! Produces strings with BMP characters, supplementary planes, surrogate
4//! boundaries, combining marks, and RTL scripts — useful for testing
5//! UTF-8/UTF-16 position mappers and LSP protocol encoding.
6
7use proptest::prelude::*;
8
9/// Generate a Unicode string suitable for testing UTF-8/UTF-16 conversion.
10///
11/// Mixes ASCII, BMP non-ASCII, supplementary plane characters, and
12/// combining marks.
13pub fn unicode_string() -> impl Strategy<Value = String> {
14    prop_oneof![
15        // Pure ASCII
16        prop::collection::vec(
17            prop_oneof![
18                prop::char::range('a', 'z'),
19                prop::char::range('A', 'Z'),
20                prop::char::range('0', '9'),
21                Just(' '),
22                Just('\t'),
23            ],
24            0..=50_usize,
25        )
26        .prop_map(|chars| chars.into_iter().collect()),
27        // BMP with non-ASCII (Latin-1 supplement, CJK, etc.)
28        prop::collection::vec(prop::char::range('\u{00C0}', '\u{FFFF}'), 0..=50_usize)
29            .prop_map(|chars| chars.into_iter().collect()),
30        // Supplementary plane characters (emoji, rare scripts)
31        prop::collection::vec(prop::char::range('\u{10000}', '\u{10FFFF}'), 0..=30_usize)
32            .prop_map(|chars| chars.into_iter().collect()),
33        // Mix of all ranges
34        prop::collection::vec(
35            prop_oneof![
36                prop::char::range('a', 'z'),
37                prop::char::range('A', 'Z'),
38                prop::char::range('0', '9'),
39                prop::char::range('\u{00C0}', '\u{024F}'),
40                prop::char::range('\u{4E00}', '\u{9FFF}'),
41                prop::char::range('\u{10000}', '\u{10FFFF}'),
42                Just('\t'),
43                Just('\n'),
44            ],
45            0..=100_usize,
46        )
47        .prop_map(|chars| chars.into_iter().collect()),
48    ]
49}
50
51/// Generate a non-empty Unicode string.
52///
53/// Useful when call sites need at least one code point and should avoid
54/// separate assumptions/filters in their property tests.
55pub fn non_empty_unicode_string() -> impl Strategy<Value = String> {
56    unicode_string().prop_filter("string must be non-empty", |value| !value.is_empty())
57}
58
59#[cfg(test)]
60mod tests {
61    use super::*;
62
63    proptest! {
64        #[test]
65        fn unicode_string_is_valid_utf8(s in unicode_string()) {
66            // If it compiled as String, it's valid UTF-8. Verify round-trip.
67            let bytes = s.as_bytes();
68            match std::str::from_utf8(bytes) {
69                Ok(roundtrip) => prop_assert_eq!(s.as_str(), roundtrip),
70                Err(err) => prop_assert!(false, "generated string was not valid UTF-8: {err}"),
71            }
72        }
73
74        #[test]
75        fn utf16_len_agrees_with_encode_utf16(s in unicode_string()) {
76            let encoded: Vec<u16> = s.encode_utf16().collect();
77            let from_utf16 = String::from_utf16_lossy(&encoded);
78            prop_assert_eq!(s, from_utf16);
79        }
80
81        #[test]
82        fn non_empty_unicode_string_never_empty(s in non_empty_unicode_string()) {
83            prop_assert!(!s.is_empty(), "non-empty strategy produced an empty string");
84        }
85
86        #[test]
87        fn unicode_string_char_count_bounded(s in unicode_string()) {
88            // Each arm produces at most 100 chars
89            let count = s.chars().count();
90            prop_assert!(count <= 100, "string has more chars than expected: {count}");
91        }
92
93        #[test]
94        fn unicode_string_chars_are_not_surrogates(s in unicode_string()) {
95            // Rust chars are guaranteed to not be surrogate code points;
96            // this test documents and enforces that invariant explicitly.
97            for ch in s.chars() {
98                let cp = ch as u32;
99                prop_assert!(
100                    !(0xD800..=0xDFFF).contains(&cp),
101                    "surrogate code point U+{cp:04X} found in generated string"
102                );
103            }
104        }
105
106        #[test]
107        fn non_empty_unicode_string_has_positive_byte_length(s in non_empty_unicode_string()) {
108            prop_assert!(!s.is_empty(), "non-empty string must have at least one byte");
109        }
110
111        #[test]
112        fn unicode_string_utf8_byte_len_is_at_least_char_count(s in unicode_string()) {
113            // Every char encodes to at least 1 byte in UTF-8.
114            let char_count = s.chars().count();
115            let byte_len = s.len();
116            prop_assert!(
117                byte_len >= char_count,
118                "byte length {byte_len} < char count {char_count}"
119            );
120        }
121
122        #[test]
123        fn non_empty_unicode_string_first_char_is_valid(s in non_empty_unicode_string()) {
124            let first = s.chars().next();
125            prop_assert!(first.is_some(), "non-empty string must have a first char");
126        }
127
128        #[test]
129        fn unicode_string_all_chars_are_valid_scalar_values(s in unicode_string()) {
130            // Every char produced by the strategy must be a valid Unicode scalar value.
131            for ch in s.chars() {
132                let cp = ch as u32;
133                prop_assert!(
134                    cp <= 0x10_FFFF,
135                    "code point U+{cp:X} exceeds Unicode maximum"
136                );
137            }
138        }
139
140        #[test]
141        fn unicode_string_is_not_null_terminated(s in unicode_string()) {
142            // The string must not contain embedded NUL bytes (which would break
143            // null-terminated C-string assumptions in FFI-adjacent code).
144            // Note: Rust Strings *can* contain NUL chars legally, but none of our
145            // generation arms produce them, so this is a documentation test.
146            // The generator only uses char::range arms that exclude '\0'.
147            for ch in s.chars() {
148                prop_assert!(ch != '\0', "unexpected NUL char in generated string");
149            }
150        }
151    }
152}