Skip to main content

es_fluent_shared/
fluent.rs

1//! Shared Fluent identifier and domain newtypes.
2
3use std::borrow::Borrow;
4
5/// Error returned when a Fluent identifier-like value is invalid.
6#[derive(Clone, Debug, Eq, thiserror::Error, PartialEq)]
7#[error("{label} {reason}")]
8pub struct FluentIdentifierError {
9    label: &'static str,
10    reason: String,
11}
12
13impl FluentIdentifierError {
14    fn new(label: &'static str, reason: impl Into<String>) -> Self {
15        Self {
16            label,
17            reason: reason.into(),
18        }
19    }
20
21    /// Human-readable label for the value that failed validation.
22    pub fn label(&self) -> &'static str {
23        self.label
24    }
25
26    /// Human-readable reason validation failed.
27    pub fn reason(&self) -> &str {
28        &self.reason
29    }
30}
31
32macro_rules! fluent_string_type {
33    ($name:ident, $label:literal) => {
34        #[derive(
35            Clone,
36            Debug,
37            derive_more::AsRef,
38            derive_more::Display,
39            Eq,
40            Hash,
41            Ord,
42            PartialEq,
43            PartialOrd,
44        )]
45        #[as_ref(str)]
46        pub struct $name(String);
47
48        impl $name {
49            /// Validates and creates the newtype.
50            pub fn try_new(value: impl Into<String>) -> Result<Self, FluentIdentifierError> {
51                let value = value.into();
52                validate_identifier_like(&value, $label)?;
53                Ok(Self(value))
54            }
55
56            /// Returns the value as a string slice.
57            pub fn as_str(&self) -> &str {
58                &self.0
59            }
60
61            /// Returns the owned string.
62            pub fn into_string(self) -> String {
63                self.0
64            }
65
66            #[allow(dead_code)]
67            pub(crate) fn from_valid_static(value: &'static str) -> Self {
68                Self(value.to_string())
69            }
70        }
71
72        impl Borrow<str> for $name {
73            fn borrow(&self) -> &str {
74                self.as_str()
75            }
76        }
77
78        impl serde::Serialize for $name {
79            fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
80            where
81                S: serde::Serializer,
82            {
83                serializer.serialize_str(self.as_str())
84            }
85        }
86
87        impl<'de> serde::Deserialize<'de> for $name {
88            fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
89            where
90                D: serde::Deserializer<'de>,
91            {
92                let value = <String as serde::Deserialize>::deserialize(deserializer)?;
93                Self::try_new(value).map_err(serde::de::Error::custom)
94            }
95        }
96    };
97}
98
99fluent_string_type!(FluentArgumentName, "Fluent argument name");
100fluent_string_type!(FluentVariantKey, "Fluent variant key");
101fluent_string_type!(FluentDomain, "Fluent domain");
102fluent_string_type!(FluentMessageId, "Fluent message id");
103
104/// A Fluent entry identifier, covering both message IDs and term IDs.
105#[derive(
106    Clone, Debug, derive_more::AsRef, derive_more::Display, Eq, Hash, Ord, PartialEq, PartialOrd,
107)]
108#[as_ref(str)]
109pub struct FluentEntryId(String);
110
111impl FluentEntryId {
112    /// Validates and creates the entry identifier.
113    pub fn try_new(value: impl Into<String>) -> Result<Self, FluentIdentifierError> {
114        let value = value.into();
115        if let Some(term_name) = value.strip_prefix('-') {
116            validate_identifier_like(term_name, "Fluent entry id")?;
117        } else {
118            validate_identifier_like(&value, "Fluent entry id")?;
119        }
120        Ok(Self(value))
121    }
122
123    /// Returns the entry identifier as a string slice.
124    pub fn as_str(&self) -> &str {
125        &self.0
126    }
127
128    /// Returns the owned string.
129    pub fn into_string(self) -> String {
130        self.0
131    }
132
133    pub(crate) fn from_valid_static(value: &'static str) -> Self {
134        Self(value.to_string())
135    }
136}
137
138impl Borrow<str> for FluentEntryId {
139    fn borrow(&self) -> &str {
140        self.as_str()
141    }
142}
143
144impl serde::Serialize for FluentEntryId {
145    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
146    where
147        S: serde::Serializer,
148    {
149        serializer.serialize_str(self.as_str())
150    }
151}
152
153impl<'de> serde::Deserialize<'de> for FluentEntryId {
154    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
155    where
156        D: serde::Deserializer<'de>,
157    {
158        let value = <String as serde::Deserialize>::deserialize(deserializer)?;
159        Self::try_new(value).map_err(serde::de::Error::custom)
160    }
161}
162
163fn validate_identifier_like(value: &str, label: &'static str) -> Result<(), FluentIdentifierError> {
164    if value.is_empty() {
165        return Err(FluentIdentifierError::new(label, "must not be empty"));
166    }
167
168    let mut chars = value.chars();
169    let Some(first) = chars.next() else {
170        return Err(FluentIdentifierError::new(label, "must not be empty"));
171    };
172    if !first.is_ascii_alphabetic() {
173        return Err(FluentIdentifierError::new(
174            label,
175            "must start with an ASCII letter",
176        ));
177    }
178
179    if let Some(invalid) = chars.find(|ch| !(ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-')))
180    {
181        return Err(FluentIdentifierError::new(
182            label,
183            format!(
184                "contains invalid character '{invalid}'; use ASCII letters, digits, '_' or '-'"
185            ),
186        ));
187    }
188
189    Ok(())
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    #[test]
197    fn fluent_identifier_newtypes_accept_current_generated_shapes() {
198        assert_eq!(
199            FluentMessageId::try_new("status-Ready")
200                .expect("message id")
201                .as_str(),
202            "status-Ready"
203        );
204        assert_eq!(
205            FluentArgumentName::try_new("display_name")
206                .expect("argument")
207                .as_str(),
208            "display_name"
209        );
210        assert_eq!(
211            FluentVariantKey::try_new("custom-key")
212                .expect("variant key")
213                .as_str(),
214            "custom-key"
215        );
216        assert_eq!(
217            FluentDomain::try_new("es-fluent-lang")
218                .expect("domain")
219                .as_str(),
220            "es-fluent-lang"
221        );
222        assert_eq!(
223            FluentEntryId::try_new("-shared-term")
224                .expect("term")
225                .as_str(),
226            "-shared-term"
227        );
228    }
229
230    #[test]
231    fn fluent_identifier_newtypes_can_be_used_for_borrowed_str_map_lookup() {
232        let mut domains = std::collections::HashMap::new();
233        domains.insert(
234            FluentDomain::try_new("app-domain").expect("domain"),
235            "domain value",
236        );
237
238        assert_eq!(domains.get("app-domain"), Some(&"domain value"));
239    }
240
241    #[test]
242    fn fluent_identifier_newtypes_reject_invalid_values() {
243        let empty = FluentArgumentName::try_new("").expect_err("empty value should fail");
244        assert_eq!(empty.to_string(), "Fluent argument name must not be empty");
245
246        assert!(FluentArgumentName::try_new("1value").is_err());
247        assert!(FluentArgumentName::try_new("display name").is_err());
248        assert!(FluentMessageId::try_new("_message").is_err());
249        assert!(FluentEntryId::try_new("-").is_err());
250        assert!(FluentEntryId::try_new("-_term").is_err());
251    }
252}