use std::collections::BTreeMap;
use fluent_bundle::{
FluentArgs, FluentError, FluentResource, FluentValue, concurrent::FluentBundle,
};
use gatekeep::{DenialReason, DenyShape, Locale, ReasonCatalog, ReasonValue};
use thiserror::Error;
use unic_langid::{LanguageIdentifier, LanguageIdentifierError};
const DEFAULT_HIDDEN_MESSAGE: &str = "not found";
const DEFAULT_HIDDEN_MESSAGE_ID: &str = "not-found";
pub struct FluentCatalog {
bundles: BTreeMap<String, FluentBundle<FluentResource>>,
fallback_locale: Option<String>,
hidden_message_id: String,
hidden_fallback: String,
}
impl Default for FluentCatalog {
fn default() -> Self {
Self::new()
}
}
impl FluentCatalog {
#[must_use]
pub fn new() -> Self {
Self {
bundles: BTreeMap::new(),
fallback_locale: None,
hidden_message_id: DEFAULT_HIDDEN_MESSAGE_ID.to_owned(),
hidden_fallback: DEFAULT_HIDDEN_MESSAGE.to_owned(),
}
}
pub fn set_fallback_locale(
&mut self,
locale: impl AsRef<str>,
) -> Result<(), FluentCatalogError> {
let locale = parse_locale(locale.as_ref())?;
self.fallback_locale = Some(locale.to_string());
Ok(())
}
pub fn with_fallback_locale(
mut self,
locale: impl AsRef<str>,
) -> Result<Self, FluentCatalogError> {
self.set_fallback_locale(locale)?;
Ok(self)
}
pub fn set_hidden_message(
&mut self,
message_id: impl Into<String>,
fallback: impl Into<String>,
) -> Result<(), FluentCatalogError> {
self.hidden_message_id = validate_message_id(message_id.into())?;
self.hidden_fallback = validate_hidden_fallback(fallback.into())?;
Ok(())
}
pub fn with_hidden_message(
mut self,
message_id: impl Into<String>,
fallback: impl Into<String>,
) -> Result<Self, FluentCatalogError> {
self.set_hidden_message(message_id, fallback)?;
Ok(self)
}
pub fn add_resource(
&mut self,
locale: impl AsRef<str>,
source: impl Into<String>,
) -> Result<(), FluentCatalogError> {
let locale = parse_locale(locale.as_ref())?;
let locale_key = locale.to_string();
let resource = parse_resource(&locale_key, source.into())?;
let bundle = self
.bundles
.entry(locale_key.clone())
.or_insert_with(|| FluentBundle::new_concurrent(vec![locale]));
bundle
.add_resource(resource)
.map_err(|errors| FluentCatalogError::Resource {
locale: locale_key,
errors: display_errors(errors),
})
}
pub fn with_resource(
mut self,
locale: impl AsRef<str>,
source: impl Into<String>,
) -> Result<Self, FluentCatalogError> {
self.add_resource(locale, source)?;
Ok(self)
}
#[must_use]
pub fn render_reason(&self, reason: &DenialReason, locale: &Locale) -> String {
if reason.shape == DenyShape::Hidden {
return self.render_hidden(locale);
}
self.try_render_reason(reason, locale)
.unwrap_or_else(|| reason.code.as_str().to_owned())
}
#[must_use]
pub fn try_render_reason(&self, reason: &DenialReason, locale: &Locale) -> Option<String> {
if reason.shape == DenyShape::Hidden {
return self.try_render_hidden(locale);
}
for locale_key in self.locale_candidates(locale) {
if let Some(rendered) = self.render_from_bundle(reason, &locale_key) {
return Some(rendered);
}
}
None
}
fn render_hidden(&self, locale: &Locale) -> String {
self.try_render_hidden(locale)
.unwrap_or_else(|| self.hidden_fallback.clone())
}
fn try_render_hidden(&self, locale: &Locale) -> Option<String> {
for locale_key in self.locale_candidates(locale) {
if let Some(rendered) = self.render_message(&self.hidden_message_id, None, &locale_key)
{
return Some(rendered);
}
}
None
}
fn locale_candidates(&self, locale: &Locale) -> Vec<String> {
let mut candidates = Vec::new();
push_locale_candidate(&mut candidates, locale.as_str());
if let Some((language, _rest)) = locale.as_str().split_once('-') {
push_locale_candidate(&mut candidates, language);
}
if let Some(fallback_locale) = &self.fallback_locale {
push_locale_candidate(&mut candidates, fallback_locale);
if let Some((language, _rest)) = fallback_locale.split_once('-') {
push_locale_candidate(&mut candidates, language);
}
}
candidates
}
fn render_from_bundle(&self, reason: &DenialReason, locale_key: &str) -> Option<String> {
let args = fluent_args(reason);
self.render_message(reason.code.as_str(), Some(&args), locale_key)
}
fn render_message(
&self,
message_id: &str,
args: Option<&FluentArgs<'_>>,
locale_key: &str,
) -> Option<String> {
let bundle = self.bundles.get(locale_key)?;
let message = bundle.get_message(message_id)?;
let pattern = message.value()?;
let mut errors = Vec::new();
let rendered = bundle.format_pattern(pattern, args, &mut errors);
errors.is_empty().then(|| rendered.into_owned())
}
}
impl ReasonCatalog for FluentCatalog {
fn render(&self, reason: &DenialReason, locale: &Locale) -> String {
self.render_reason(reason, locale)
}
}
#[derive(Debug, Error, PartialEq)]
pub enum FluentCatalogError {
#[error("invalid fluent locale {locale}: {source}")]
InvalidLocale {
locale: String,
source: LanguageIdentifierError,
},
#[error("failed to parse fluent resource for {locale}: {}", errors.join("; "))]
Parse {
locale: String,
errors: Vec<String>,
},
#[error("failed to add fluent resource for {locale}: {}", errors.join("; "))]
Resource {
locale: String,
errors: Vec<String>,
},
#[error("{field} must not be empty")]
EmptyMessageId {
field: &'static str,
},
#[error("{field} must not be empty")]
EmptyFallbackMessage {
field: &'static str,
},
}
fn parse_locale(locale: &str) -> Result<LanguageIdentifier, FluentCatalogError> {
locale
.parse::<LanguageIdentifier>()
.map_err(|source| FluentCatalogError::InvalidLocale {
locale: locale.to_owned(),
source,
})
}
fn parse_resource(locale: &str, source: String) -> Result<FluentResource, FluentCatalogError> {
FluentResource::try_new(source).map_err(|(_resource, errors)| FluentCatalogError::Parse {
locale: locale.to_owned(),
errors: errors.into_iter().map(|error| error.to_string()).collect(),
})
}
fn display_errors(errors: Vec<FluentError>) -> Vec<String> {
errors.into_iter().map(|error| error.to_string()).collect()
}
fn validate_message_id(value: String) -> Result<String, FluentCatalogError> {
if value.trim().is_empty() {
Err(FluentCatalogError::EmptyMessageId {
field: "hidden message id",
})
} else {
Ok(value)
}
}
fn validate_hidden_fallback(value: String) -> Result<String, FluentCatalogError> {
if value.trim().is_empty() {
Err(FluentCatalogError::EmptyFallbackMessage {
field: "hidden fallback message",
})
} else {
Ok(value)
}
}
fn push_locale_candidate(candidates: &mut Vec<String>, locale: &str) {
if let Ok(locale) = locale.parse::<LanguageIdentifier>() {
let candidate = locale.to_string();
if !candidates.contains(&candidate) {
candidates.push(candidate);
}
}
}
fn fluent_args(reason: &DenialReason) -> FluentArgs<'static> {
let mut args = FluentArgs::with_capacity(reason.params.len());
for (key, value) in &reason.params {
args.set(key.as_str().to_owned(), fluent_value(value));
}
args
}
fn fluent_value(value: &ReasonValue) -> FluentValue<'static> {
match value {
ReasonValue::Str(value) => FluentValue::from(value.clone()),
ReasonValue::Int(value) => FluentValue::from(*value),
ReasonValue::Fact(fact) => FluentValue::from(fact.as_str().to_owned()),
ReasonValue::Outcome(value) => json_value(value),
}
}
fn json_value(value: &serde_json::Value) -> FluentValue<'static> {
match value {
serde_json::Value::Null => FluentValue::from("null"),
serde_json::Value::Bool(value) => FluentValue::from(value.to_string()),
serde_json::Value::Number(value) => number_value(value),
serde_json::Value::String(value) => FluentValue::from(value.clone()),
serde_json::Value::Array(_) | serde_json::Value::Object(_) => {
FluentValue::from(value.to_string())
}
}
}
fn number_value(value: &serde_json::Number) -> FluentValue<'static> {
value
.as_i64()
.map_or_else(|| unsigned_or_float_value(value), FluentValue::from)
}
fn unsigned_or_float_value(value: &serde_json::Number) -> FluentValue<'static> {
value.as_u64().map_or_else(
|| float_value(value),
|value| {
i64::try_from(value)
.map_or_else(|_| FluentValue::from(value.to_string()), FluentValue::from)
},
)
}
fn float_value(value: &serde_json::Number) -> FluentValue<'static> {
value
.as_f64()
.map_or_else(|| FluentValue::from(value.to_string()), FluentValue::from)
}