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/// An owned, fully scoped Fluent message key used by CLI and runner metadata.
105#[derive(
106    Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, serde::Deserialize, serde::Serialize,
107)]
108pub struct FluentMessageKey {
109    owner: FluentDomain,
110    domain: FluentDomain,
111    id: FluentEntryId,
112}
113
114impl FluentMessageKey {
115    pub fn new(owner: FluentDomain, domain: FluentDomain, id: FluentEntryId) -> Self {
116        Self { owner, domain, id }
117    }
118
119    pub fn owner(&self) -> &FluentDomain {
120        &self.owner
121    }
122
123    pub fn domain(&self) -> &FluentDomain {
124        &self.domain
125    }
126
127    pub fn id(&self) -> &FluentEntryId {
128        &self.id
129    }
130
131    pub fn into_parts(self) -> (FluentDomain, FluentDomain, FluentEntryId) {
132        (self.owner, self.domain, self.id)
133    }
134}
135
136/// A Fluent entry identifier, covering both message IDs and term IDs.
137#[derive(
138    Clone, Debug, derive_more::AsRef, derive_more::Display, Eq, Hash, Ord, PartialEq, PartialOrd,
139)]
140#[as_ref(str)]
141pub struct FluentEntryId(String);
142
143impl FluentEntryId {
144    /// Validates and creates the entry identifier.
145    pub fn try_new(value: impl Into<String>) -> Result<Self, FluentIdentifierError> {
146        let value = value.into();
147        if let Some(term_name) = value.strip_prefix('-') {
148            validate_identifier_like(term_name, "Fluent entry id")?;
149        } else {
150            validate_identifier_like(&value, "Fluent entry id")?;
151        }
152        Ok(Self(value))
153    }
154
155    /// Returns the entry identifier as a string slice.
156    pub fn as_str(&self) -> &str {
157        &self.0
158    }
159
160    /// Returns the owned string.
161    pub fn into_string(self) -> String {
162        self.0
163    }
164
165    pub(crate) fn from_valid_static(value: &'static str) -> Self {
166        Self(value.to_string())
167    }
168}
169
170impl Borrow<str> for FluentEntryId {
171    fn borrow(&self) -> &str {
172        self.as_str()
173    }
174}
175
176impl serde::Serialize for FluentEntryId {
177    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
178    where
179        S: serde::Serializer,
180    {
181        serializer.serialize_str(self.as_str())
182    }
183}
184
185impl<'de> serde::Deserialize<'de> for FluentEntryId {
186    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
187    where
188        D: serde::Deserializer<'de>,
189    {
190        let value = <String as serde::Deserialize>::deserialize(deserializer)?;
191        Self::try_new(value).map_err(serde::de::Error::custom)
192    }
193}
194
195fn validate_identifier_like(value: &str, label: &'static str) -> Result<(), FluentIdentifierError> {
196    if value.is_empty() {
197        return Err(FluentIdentifierError::new(label, "must not be empty"));
198    }
199
200    let mut chars = value.chars();
201    let Some(first) = chars.next() else {
202        return Err(FluentIdentifierError::new(label, "must not be empty"));
203    };
204    if !first.is_ascii_alphabetic() {
205        return Err(FluentIdentifierError::new(
206            label,
207            "must start with an ASCII letter",
208        ));
209    }
210
211    if let Some(invalid) = chars.find(|ch| !(ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-')))
212    {
213        return Err(FluentIdentifierError::new(
214            label,
215            format!(
216                "contains invalid character '{invalid}'; use ASCII letters, digits, '_' or '-'"
217            ),
218        ));
219    }
220
221    Ok(())
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    #[test]
229    fn fluent_identifier_newtypes_accept_current_generated_shapes() {
230        assert_eq!(
231            FluentMessageId::try_new("status-Ready")
232                .expect("message id")
233                .as_str(),
234            "status-Ready"
235        );
236        assert_eq!(
237            FluentArgumentName::try_new("display_name")
238                .expect("argument")
239                .as_str(),
240            "display_name"
241        );
242        assert_eq!(
243            FluentVariantKey::try_new("custom-key")
244                .expect("variant key")
245                .as_str(),
246            "custom-key"
247        );
248        assert_eq!(
249            FluentDomain::try_new("es-fluent-lang")
250                .expect("domain")
251                .as_str(),
252            "es-fluent-lang"
253        );
254        assert_eq!(
255            FluentEntryId::try_new("-shared-term")
256                .expect("term")
257                .as_str(),
258            "-shared-term"
259        );
260    }
261
262    #[test]
263    fn fluent_identifier_newtypes_can_be_used_for_borrowed_str_map_lookup() {
264        let mut domains = std::collections::HashMap::new();
265        domains.insert(
266            FluentDomain::try_new("app-domain").expect("domain"),
267            "domain value",
268        );
269
270        assert_eq!(domains.get("app-domain"), Some(&"domain value"));
271    }
272
273    #[test]
274    fn fluent_identifier_newtypes_reject_invalid_values() {
275        let empty = FluentArgumentName::try_new("").expect_err("empty value should fail");
276        assert_eq!(empty.to_string(), "Fluent argument name must not be empty");
277
278        assert!(FluentArgumentName::try_new("1value").is_err());
279        assert!(FluentArgumentName::try_new("display name").is_err());
280        assert!(FluentMessageId::try_new("_message").is_err());
281        assert!(FluentEntryId::try_new("-").is_err());
282        assert!(FluentEntryId::try_new("-_term").is_err());
283    }
284}