1use std::borrow::Borrow;
4
5#[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 pub fn label(&self) -> &'static str {
23 self.label
24 }
25
26 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 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 pub fn as_str(&self) -> &str {
58 &self.0
59 }
60
61 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#[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#[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 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 pub fn as_str(&self) -> &str {
157 &self.0
158 }
159
160 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}