es-fluent-shared 0.21.1

Shared runtime-safe types and helpers for the es-fluent ecosystem
Documentation
//! Shared Fluent identifier and domain newtypes.

use std::borrow::Borrow;

/// Error returned when a Fluent identifier-like value is invalid.
#[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(),
        }
    }

    /// Human-readable label for the value that failed validation.
    pub fn label(&self) -> &'static str {
        self.label
    }

    /// Human-readable reason validation failed.
    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 {
            /// Validates and creates the newtype.
            pub fn try_new(value: impl Into<String>) -> Result<Self, FluentIdentifierError> {
                let value = value.into();
                validate_identifier_like(&value, $label)?;
                Ok(Self(value))
            }

            /// Returns the value as a string slice.
            pub fn as_str(&self) -> &str {
                &self.0
            }

            /// Returns the owned string.
            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");

/// An owned, fully scoped Fluent message key used by CLI and runner metadata.
#[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)
    }
}

/// A Fluent entry identifier, covering both message IDs and term IDs.
#[derive(
    Clone, Debug, derive_more::AsRef, derive_more::Display, Eq, Hash, Ord, PartialEq, PartialOrd,
)]
#[as_ref(str)]
pub struct FluentEntryId(String);

impl FluentEntryId {
    /// Validates and creates the entry identifier.
    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))
    }

    /// Returns the entry identifier as a string slice.
    pub fn as_str(&self) -> &str {
        &self.0
    }

    /// Returns the owned string.
    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());
    }
}