es_fluent_shared/
fluent.rs1use 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, derive_more::AsRef, derive_more::Display, Eq, Hash, Ord, PartialEq, PartialOrd,
107)]
108#[as_ref(str)]
109pub struct FluentEntryId(String);
110
111impl FluentEntryId {
112 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 pub fn as_str(&self) -> &str {
125 &self.0
126 }
127
128 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}