#[cfg(feature = "schema")]
use schemars::JsonSchema;
use serde::de::Error as _;
use serde::{Deserialize, Deserializer, Serialize};
use serde_yaml::{Mapping, Value};
#[cfg(feature = "schema")]
use std::borrow::Cow;
use std::collections::HashMap;
#[derive(Debug, Default, Deserialize, Serialize, Clone)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub struct RawLocale {
pub locale: String,
#[serde(default)]
pub dates: RawDateTerms,
#[serde(default)]
pub roles: HashMap<String, RawRoleTerm>,
#[serde(default)]
pub terms: HashMap<String, RawTermValue>,
#[serde(default)]
pub locators: HashMap<String, RawLocatorTerm>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub locale_schema_version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub evaluation: Option<crate::locale::types::EvaluationConfig>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub messages: HashMap<String, String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub date_formats: HashMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub number_formats: Option<crate::locale::types::NumberFormats>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub grammar_options: Option<crate::locale::types::GrammarOptions>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub legacy_term_aliases: HashMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vocab: Option<RawVocab>,
}
#[derive(Debug, Default, Deserialize, Serialize, Clone)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
pub struct RawVocab {
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub genre: HashMap<String, String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub medium: HashMap<String, String>,
}
#[derive(Debug, Default, Deserialize, Serialize, Clone)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub struct RawDateTerms {
#[serde(default)]
pub months: RawMonthNames,
#[serde(default)]
pub seasons: Vec<String>,
#[serde(default)]
pub uncertainty_term: Option<String>,
#[serde(default)]
pub open_ended_term: Option<String>,
#[serde(default)]
pub am: Option<String>,
#[serde(default)]
pub pm: Option<String>,
#[serde(default)]
pub timezone_utc: Option<String>,
#[serde(default)]
pub before_era: Option<String>,
#[serde(default)]
pub ad: Option<String>,
#[serde(default)]
pub bc: Option<String>,
#[serde(default)]
pub bce: Option<String>,
#[serde(default)]
pub ce: Option<String>,
}
#[derive(Debug, Default, Deserialize, Serialize, Clone)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
pub struct RawMonthNames {
#[serde(default)]
pub long: Vec<String>,
#[serde(default)]
pub short: Vec<String>,
}
#[derive(Debug, Default, Deserialize, Serialize, Clone)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
pub struct RawRoleTerm {
#[serde(default)]
pub long: Option<RawTermValue>,
#[serde(default)]
pub short: Option<RawTermValue>,
#[serde(default)]
pub verb: Option<RawTermValue>,
#[serde(default, rename = "verb-short")]
pub verb_short: Option<RawTermValue>,
}
#[derive(Debug, Default, Deserialize, Serialize, Clone)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub struct RawLocatorTerm {
#[serde(default)]
pub long: Option<RawTermValue>,
#[serde(default)]
pub short: Option<RawTermValue>,
#[serde(default)]
pub symbol: Option<RawTermValue>,
#[serde(default)]
pub gender: Option<crate::locale::types::GrammaticalGender>,
}
#[derive(Debug, Clone, Serialize)]
pub enum RawTermValue {
Simple(String),
SingularPlural {
singular: RawGenderedString,
plural: RawGenderedString,
},
Gendered {
#[serde(default)]
masculine: Option<String>,
#[serde(default)]
feminine: Option<String>,
#[serde(default)]
neuter: Option<String>,
#[serde(default)]
common: Option<String>,
},
Forms(HashMap<String, RawTermValue>),
}
#[derive(Debug, Clone, Serialize)]
pub enum RawGenderedString {
Simple(String),
Gendered {
#[serde(default)]
masculine: Option<String>,
#[serde(default)]
feminine: Option<String>,
#[serde(default)]
neuter: Option<String>,
#[serde(default)]
common: Option<String>,
},
}
impl Default for RawTermValue {
fn default() -> Self {
RawTermValue::Simple(String::new())
}
}
#[cfg(feature = "schema")]
impl JsonSchema for RawGenderedString {
fn schema_name() -> Cow<'static, str> {
"RawGenderedString".into()
}
fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
schemars::json_schema!({
"description": "A raw string that may include gender-specific variants.",
"anyOf": [
{
"description": "Plain string value.",
"type": "string"
},
{
"description": "Gender-specific values.",
"type": "object",
"properties": gender_slot_schema_properties(),
"additionalProperties": false,
"minProperties": 1
}
]
})
}
}
#[cfg(feature = "schema")]
impl JsonSchema for RawTermValue {
fn schema_name() -> Cow<'static, str> {
"RawTermValue".into()
}
fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
generator.subschema_for::<RawGenderedString>();
schemars::json_schema!({
"description": "A term value that can be a simple string or have singular/plural forms.",
"anyOf": [
{
"description": "Simple string value.",
"type": "string"
},
{
"description": "Singular/plural forms.",
"type": "object",
"properties": {
"singular": { "$ref": "#/$defs/RawGenderedString" },
"plural": { "$ref": "#/$defs/RawGenderedString" }
},
"required": ["singular", "plural"],
"additionalProperties": false
},
{
"description": "Gender-specific values.",
"type": "object",
"properties": gender_slot_schema_properties(),
"additionalProperties": false,
"minProperties": 1
},
{
"description": "Form-keyed value (for terms with long/short or nested forms).",
"type": "object",
"additionalProperties": {
"$ref": "#/$defs/RawTermValue"
}
}
]
})
}
}
impl<'de> Deserialize<'de> for RawTermValue {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = Value::deserialize(deserializer)?;
Self::from_value(value).map_err(D::Error::custom)
}
}
impl<'de> Deserialize<'de> for RawGenderedString {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = Value::deserialize(deserializer)?;
Self::from_value(value).map_err(D::Error::custom)
}
}
impl RawTermValue {
pub fn as_string(&self) -> Option<&str> {
match self {
RawTermValue::Simple(s) => Some(s),
_ => None,
}
}
fn from_value(value: Value) -> Result<Self, String> {
match value {
Value::String(s) => Ok(Self::Simple(s)),
Value::Mapping(map) => {
if let Some((singular, plural)) = parse_singular_plural_map(&map)? {
return Ok(Self::SingularPlural { singular, plural });
}
if let Some(gendered) = parse_gendered_map(&map)? {
return Ok(gendered);
}
let forms = map_to_term_values(map)?;
Ok(Self::Forms(forms))
}
other => Err(format!(
"expected string or mapping for locale term, found {}",
value_kind(&other)
)),
}
}
}
impl RawGenderedString {
fn from_value(value: Value) -> Result<Self, String> {
match value {
Value::String(s) => Ok(Self::Simple(s)),
Value::Mapping(map) => parse_gendered_string_map(&map)?
.ok_or_else(|| "expected string or gender-specific mapping".to_string()),
other => Err(format!(
"expected string or mapping for gendered locale string, found {}",
value_kind(&other)
)),
}
}
}
fn parse_singular_plural_map(
map: &Mapping,
) -> Result<Option<(RawGenderedString, RawGenderedString)>, String> {
if !contains_only_keys(map, &["singular", "plural"])? {
return Ok(None);
}
if map.is_empty() {
return Ok(None);
}
let Some(singular) = map.get(Value::String("singular".to_string())) else {
return Ok(None);
};
let Some(plural) = map.get(Value::String("plural".to_string())) else {
return Ok(None);
};
Ok(Some((
RawGenderedString::from_value(singular.clone())?,
RawGenderedString::from_value(plural.clone())?,
)))
}
fn parse_gendered_map(map: &Mapping) -> Result<Option<RawTermValue>, String> {
parse_gender_slots(map).map(|slots| {
slots.map(
|(masculine, feminine, neuter, common)| RawTermValue::Gendered {
masculine,
feminine,
neuter,
common,
},
)
})
}
fn parse_gendered_string_map(map: &Mapping) -> Result<Option<RawGenderedString>, String> {
parse_gender_slots(map).map(|slots| {
slots.map(
|(masculine, feminine, neuter, common)| RawGenderedString::Gendered {
masculine,
feminine,
neuter,
common,
},
)
})
}
type GenderSlots = (
Option<String>,
Option<String>,
Option<String>,
Option<String>,
);
fn parse_gender_slots(map: &Mapping) -> Result<Option<GenderSlots>, String> {
let (has_gender_key, has_non_gender_key) = inspect_gender_keys(map)?;
if !has_gender_key {
return Ok(None);
}
if has_non_gender_key {
return Err("gendered locale terms cannot mix gender keys with other keys".to_string());
}
let masculine = map
.get(Value::String("masculine".to_string()))
.map(parse_optional_string_value)
.transpose()?
.flatten();
let feminine = map
.get(Value::String("feminine".to_string()))
.map(parse_optional_string_value)
.transpose()?
.flatten();
let neuter = map
.get(Value::String("neuter".to_string()))
.map(parse_optional_string_value)
.transpose()?
.flatten();
let common = map
.get(Value::String("common".to_string()))
.map(parse_optional_string_value)
.transpose()?
.flatten();
Ok(Some((masculine, feminine, neuter, common)))
}
fn contains_only_keys(map: &Mapping, allowed: &[&str]) -> Result<bool, String> {
for key in map.keys() {
let Value::String(key) = key else {
return Err("locale term keys must be strings".to_string());
};
if !allowed.contains(&key.as_str()) {
return Ok(false);
}
}
Ok(true)
}
fn map_to_term_values(map: Mapping) -> Result<HashMap<String, RawTermValue>, String> {
map.into_iter()
.map(|(key, value)| {
let Value::String(key) = key else {
return Err("locale term keys must be strings".to_string());
};
Ok((key, RawTermValue::from_value(value)?))
})
.collect()
}
fn parse_optional_string_value(value: &Value) -> Result<Option<String>, String> {
match value {
Value::Null => Ok(None),
Value::String(value) => Ok(Some(value.clone())),
other => Err(format!(
"expected string in gendered locale term, found {}",
value_kind(other)
)),
}
}
fn inspect_gender_keys(map: &Mapping) -> Result<(bool, bool), String> {
let mut has_gender_key = false;
let mut has_non_gender_key = false;
for key in map.keys() {
let Value::String(key) = key else {
return Err("locale term keys must be strings".to_string());
};
match key.as_str() {
"masculine" | "feminine" | "neuter" | "common" => has_gender_key = true,
_ => has_non_gender_key = true,
}
}
Ok((has_gender_key, has_non_gender_key))
}
#[cfg(feature = "schema")]
fn gender_slot_schema_properties() -> serde_json::Value {
serde_json::json!({
"masculine": { "type": ["string", "null"] },
"feminine": { "type": ["string", "null"] },
"neuter": { "type": ["string", "null"] },
"common": { "type": ["string", "null"] }
})
}
fn value_kind(value: &Value) -> &'static str {
match value {
Value::Null => "null",
Value::Bool(_) => "boolean",
Value::Number(_) => "number",
Value::String(_) => "string",
Value::Sequence(_) => "sequence",
Value::Mapping(_) => "mapping",
Value::Tagged(_) => "tagged value",
}
}
#[derive(Debug, Default, Deserialize, Serialize, Clone)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(rename_all = "kebab-case", default)]
pub struct RawLocaleOverride {
pub messages: HashMap<String, String>,
pub grammar_options: Option<crate::locale::types::GrammarOptions>,
pub legacy_term_aliases: HashMap<String, String>,
}
impl From<RawLocaleOverride> for super::types::LocaleOverride {
fn from(raw: RawLocaleOverride) -> Self {
super::types::LocaleOverride {
messages: raw.messages,
grammar_options: raw.grammar_options,
legacy_term_aliases: raw.legacy_term_aliases,
}
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing,
clippy::todo,
clippy::unimplemented,
clippy::unreachable,
clippy::get_unwrap,
reason = "Panicking is acceptable and often desired in tests."
)]
mod tests {
use super::{RawGenderedString, RawTermValue};
#[cfg(feature = "schema")]
use crate::locale::RawLocale;
#[test]
fn test_gender_slots_accept_explicit_null_values() {
let parsed: RawTermValue = serde_yaml::from_str(
r#"
masculine: editor
feminine: editora
common: null
"#,
)
.expect("gendered term with null slot should parse");
match parsed {
RawTermValue::Gendered {
masculine,
feminine,
common,
..
} => {
assert_eq!(masculine.as_deref(), Some("editor"));
assert_eq!(feminine.as_deref(), Some("editora"));
assert_eq!(common, None);
}
other => panic!("expected gendered term, got {other:?}"),
}
}
#[test]
fn test_all_null_gender_slots_still_parse() {
let parsed: RawGenderedString = serde_yaml::from_str(
r#"
masculine: null
feminine: null
common: null
"#,
)
.expect("all-null gender map should parse");
match parsed {
RawGenderedString::Gendered {
masculine,
feminine,
common,
..
} => {
assert!(masculine.is_none());
assert!(feminine.is_none());
assert!(common.is_none());
}
other => panic!("expected gendered string, got {other:?}"),
}
}
#[test]
fn test_malformed_gender_map_reports_targeted_error() {
let error = serde_yaml::from_str::<RawTermValue>(
r#"
masculine: editor
femine: editora
"#,
)
.expect_err("mixed gender-like map should fail");
assert!(
error
.to_string()
.contains("gendered locale terms cannot mix gender keys")
);
}
#[cfg(feature = "schema")]
#[test]
fn test_raw_term_schema_remains_untagged() {
let schema = schemars::schema_for!(RawLocale);
let schema_text = serde_json::to_string(&schema).expect("schema should serialize");
assert!(schema_text.contains("\"RawTermValue\""));
assert!(schema_text.contains("\"type\":\"string\""));
assert!(schema_text.contains("\"$ref\":\"#/$defs/RawGenderedString\""));
assert!(!schema_text.contains("\"Simple\""));
}
}