use std::borrow::Borrow;
#[derive(Clone, Debug, Eq, thiserror::Error, PartialEq)]
#[error("{label} {reason}")]
pub struct FluentIdentifierError {
label: &'static str,
reason: String,
}
impl FluentIdentifierError {
fn new(label: &'static str, reason: impl Into<String>) -> Self {
Self {
label,
reason: reason.into(),
}
}
pub fn label(&self) -> &'static str {
self.label
}
pub fn reason(&self) -> &str {
&self.reason
}
}
macro_rules! fluent_string_type {
($name:ident, $label:literal) => {
#[derive(
Clone,
Debug,
derive_more::AsRef,
derive_more::Display,
Eq,
Hash,
Ord,
PartialEq,
PartialOrd,
)]
#[as_ref(str)]
pub struct $name(String);
impl $name {
pub fn try_new(value: impl Into<String>) -> Result<Self, FluentIdentifierError> {
let value = value.into();
validate_identifier_like(&value, $label)?;
Ok(Self(value))
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_string(self) -> String {
self.0
}
#[allow(dead_code)]
pub(crate) fn from_valid_static(value: &'static str) -> Self {
Self(value.to_string())
}
}
impl Borrow<str> for $name {
fn borrow(&self) -> &str {
self.as_str()
}
}
impl serde::Serialize for $name {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(self.as_str())
}
}
impl<'de> serde::Deserialize<'de> for $name {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = <String as serde::Deserialize>::deserialize(deserializer)?;
Self::try_new(value).map_err(serde::de::Error::custom)
}
}
};
}
fluent_string_type!(FluentArgumentName, "Fluent argument name");
fluent_string_type!(FluentVariantKey, "Fluent variant key");
fluent_string_type!(FluentDomain, "Fluent domain");
fluent_string_type!(FluentMessageId, "Fluent message id");
#[derive(
Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, serde::Deserialize, serde::Serialize,
)]
pub struct FluentMessageKey {
owner: FluentDomain,
domain: FluentDomain,
id: FluentEntryId,
}
impl FluentMessageKey {
pub fn new(owner: FluentDomain, domain: FluentDomain, id: FluentEntryId) -> Self {
Self { owner, domain, id }
}
pub fn owner(&self) -> &FluentDomain {
&self.owner
}
pub fn domain(&self) -> &FluentDomain {
&self.domain
}
pub fn id(&self) -> &FluentEntryId {
&self.id
}
pub fn into_parts(self) -> (FluentDomain, FluentDomain, FluentEntryId) {
(self.owner, self.domain, self.id)
}
}
#[derive(
Clone, Debug, derive_more::AsRef, derive_more::Display, Eq, Hash, Ord, PartialEq, PartialOrd,
)]
#[as_ref(str)]
pub struct FluentEntryId(String);
impl FluentEntryId {
pub fn try_new(value: impl Into<String>) -> Result<Self, FluentIdentifierError> {
let value = value.into();
if let Some(term_name) = value.strip_prefix('-') {
validate_identifier_like(term_name, "Fluent entry id")?;
} else {
validate_identifier_like(&value, "Fluent entry id")?;
}
Ok(Self(value))
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_string(self) -> String {
self.0
}
pub(crate) fn from_valid_static(value: &'static str) -> Self {
Self(value.to_string())
}
}
impl Borrow<str> for FluentEntryId {
fn borrow(&self) -> &str {
self.as_str()
}
}
impl serde::Serialize for FluentEntryId {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(self.as_str())
}
}
impl<'de> serde::Deserialize<'de> for FluentEntryId {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = <String as serde::Deserialize>::deserialize(deserializer)?;
Self::try_new(value).map_err(serde::de::Error::custom)
}
}
fn validate_identifier_like(value: &str, label: &'static str) -> Result<(), FluentIdentifierError> {
if value.is_empty() {
return Err(FluentIdentifierError::new(label, "must not be empty"));
}
let mut chars = value.chars();
let Some(first) = chars.next() else {
return Err(FluentIdentifierError::new(label, "must not be empty"));
};
if !first.is_ascii_alphabetic() {
return Err(FluentIdentifierError::new(
label,
"must start with an ASCII letter",
));
}
if let Some(invalid) = chars.find(|ch| !(ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-')))
{
return Err(FluentIdentifierError::new(
label,
format!(
"contains invalid character '{invalid}'; use ASCII letters, digits, '_' or '-'"
),
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fluent_identifier_newtypes_accept_current_generated_shapes() {
assert_eq!(
FluentMessageId::try_new("status-Ready")
.expect("message id")
.as_str(),
"status-Ready"
);
assert_eq!(
FluentArgumentName::try_new("display_name")
.expect("argument")
.as_str(),
"display_name"
);
assert_eq!(
FluentVariantKey::try_new("custom-key")
.expect("variant key")
.as_str(),
"custom-key"
);
assert_eq!(
FluentDomain::try_new("es-fluent-lang")
.expect("domain")
.as_str(),
"es-fluent-lang"
);
assert_eq!(
FluentEntryId::try_new("-shared-term")
.expect("term")
.as_str(),
"-shared-term"
);
}
#[test]
fn fluent_identifier_newtypes_can_be_used_for_borrowed_str_map_lookup() {
let mut domains = std::collections::HashMap::new();
domains.insert(
FluentDomain::try_new("app-domain").expect("domain"),
"domain value",
);
assert_eq!(domains.get("app-domain"), Some(&"domain value"));
}
#[test]
fn fluent_identifier_newtypes_reject_invalid_values() {
let empty = FluentArgumentName::try_new("").expect_err("empty value should fail");
assert_eq!(empty.to_string(), "Fluent argument name must not be empty");
assert!(FluentArgumentName::try_new("1value").is_err());
assert!(FluentArgumentName::try_new("display name").is_err());
assert!(FluentMessageId::try_new("_message").is_err());
assert!(FluentEntryId::try_new("-").is_err());
assert!(FluentEntryId::try_new("-_term").is_err());
}
}