Skip to main content

use_script/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::fmt;
5
6/// A normalized 4-letter writing script subtag.
7#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
8pub struct ScriptCode {
9    value: String,
10}
11
12impl ScriptCode {
13    /// Parses and normalizes a script subtag.
14    #[must_use]
15    pub fn new(input: &str) -> Option<Self> {
16        parse_script_code(input)
17    }
18
19    /// Returns the normalized script subtag.
20    #[must_use]
21    pub fn as_str(&self) -> &str {
22        &self.value
23    }
24
25    /// Consumes the script code and returns the normalized string.
26    #[must_use]
27    pub fn into_string(self) -> String {
28        self.value
29    }
30}
31
32impl AsRef<str> for ScriptCode {
33    fn as_ref(&self) -> &str {
34        self.as_str()
35    }
36}
37
38impl fmt::Display for ScriptCode {
39    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
40        formatter.write_str(self.as_str())
41    }
42}
43
44/// Parses a script subtag and normalizes it to title case.
45#[must_use]
46pub fn parse_script_code(input: &str) -> Option<ScriptCode> {
47    normalize_script_code(input).map(|value| ScriptCode { value })
48}
49
50/// Returns `true` when the input is a 4-letter script subtag.
51#[must_use]
52pub fn is_script_code(input: &str) -> bool {
53    normalize_script_code(input).is_some()
54}
55
56/// Normalizes a 4-letter script subtag to title case.
57#[must_use]
58pub fn normalize_script_code(input: &str) -> Option<String> {
59    let trimmed = input.trim();
60    if trimmed.len() != 4 || !trimmed.bytes().all(|byte| byte.is_ascii_alphabetic()) {
61        return None;
62    }
63
64    let mut normalized = String::with_capacity(4);
65    for (index, character) in trimmed.chars().enumerate() {
66        if index == 0 {
67            normalized.push(character.to_ascii_uppercase());
68        } else {
69            normalized.push(character.to_ascii_lowercase());
70        }
71    }
72
73    Some(normalized)
74}
75
76#[cfg(test)]
77mod tests {
78    use super::{ScriptCode, is_script_code, normalize_script_code, parse_script_code};
79
80    #[test]
81    fn accepts_common_script_examples() {
82        for script in ["Latn", "Cyrl", "Arab", "Hans", "Hant"] {
83            assert!(is_script_code(script));
84            assert_eq!(parse_script_code(script).unwrap().as_str(), script);
85        }
86    }
87
88    #[test]
89    fn normalizes_to_title_case() {
90        assert_eq!(normalize_script_code("latn"), Some("Latn".to_string()));
91        assert_eq!(normalize_script_code("CYRL"), Some("Cyrl".to_string()));
92        assert_eq!(ScriptCode::new(" hAnT ").unwrap().as_str(), "Hant");
93    }
94
95    #[test]
96    fn rejects_invalid_script_shapes() {
97        for script in ["", "Lat", "Latnn", "La1n", "Latn-US", "漢字"] {
98            assert!(!is_script_code(script));
99            assert!(parse_script_code(script).is_none());
100        }
101    }
102}