use std::cell::{OnceCell, RefCell};
use std::collections::HashSet;
use std::collections::hash_map::{Entry, HashMap};
use std::fmt;
use std::ptr;
use std::rc::Rc;
use fluent_bundle::{FluentArgs, FluentError, FluentResource, FluentValue};
use fluent_syntax::ast;
use unic_langid::LanguageIdentifier;
use crate::{Args, Catalog, Lang, Phrases, Source};
pub(crate) fn owns(catalog: &Catalog, id: &str) -> bool {
catalog.prefix().is_none_or(|prefix| id.starts_with(prefix))
}
pub(crate) type Bundle = fluent_bundle::FluentBundle<FluentResource>;
type Texts = HashMap<Box<str>, Option<Rc<str>>>;
type Selectors = Rc<[Box<str>]>;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Language {
lang: Lang,
name: &'static str,
}
impl Language {
pub const fn new(lang: Lang, name: &'static str) -> Language {
Language { lang, name }
}
pub fn lang(&self) -> Lang {
self.lang
}
pub fn name(&self) -> &str {
self.name
}
}
#[derive(Clone)]
pub struct Phrasebook {
pub(crate) inner: Rc<Inner>,
}
impl Phrasebook {
pub fn builder() -> Builder {
Builder {
languages: Vec::new(),
fallback: None,
catalogs: Vec::new(),
use_isolating: false,
locale_numbers: true,
}
}
pub fn phrases(&self, lang: Lang) -> Phrases {
Phrases::new(self.clone(), self.resolve(lang))
}
pub fn languages(&self) -> &[Language] {
&self.inner.languages
}
pub fn fallback(&self) -> Lang {
self.inner.fallback
}
pub fn has_catalog(&self, catalog: &Catalog) -> bool {
self.inner
.catalogs
.iter()
.any(|added| ptr::eq(*added, catalog))
}
pub fn supports(&self, lang: Lang) -> bool {
self.inner.index(lang).is_some()
}
pub fn negotiate<'a>(&self, preferred: impl IntoIterator<Item = &'a str>) -> Lang {
for tag in preferred {
let Some(wanted) = Lang::parse(tag) else {
continue;
};
if self.supports(wanted) {
return wanted;
}
let primary = wanted.primary();
if let Some(language) = self
.inner
.languages
.iter()
.find(|l| l.lang.primary() == primary)
{
return language.lang;
}
}
self.inner.fallback
}
pub(crate) fn resolve(&self, lang: Lang) -> Lang {
if self.supports(lang) {
lang
} else {
self.inner.fallback
}
}
pub(crate) fn ptr_eq(&self, other: &Phrasebook) -> bool {
Rc::ptr_eq(&self.inner, &other.inner)
}
}
impl PartialEq for Phrasebook {
fn eq(&self, other: &Self) -> bool {
self.ptr_eq(other)
}
}
impl fmt::Debug for Phrasebook {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Phrasebook")
.field("languages", &self.inner.languages)
.field("fallback", &self.inner.fallback)
.field(
"catalogs",
&self
.inner
.catalogs
.iter()
.map(|c| c.name())
.collect::<Vec<_>>(),
)
.finish()
}
}
#[must_use = "a builder does nothing until built"]
pub struct Builder {
languages: Vec<Language>,
fallback: Option<Lang>,
catalogs: Vec<&'static Catalog>,
use_isolating: bool,
locale_numbers: bool,
}
impl Builder {
pub fn language(mut self, lang: Lang, name: &'static str) -> Self {
if self.languages.iter().all(|l| l.lang != lang) {
self.languages.push(Language { lang, name });
}
self
}
pub fn languages(self, languages: impl IntoIterator<Item = (Lang, &'static str)>) -> Self {
languages
.into_iter()
.fold(self, |builder, (lang, name)| builder.language(lang, name))
}
pub fn fallback(mut self, lang: Lang) -> Self {
self.fallback = Some(lang);
self
}
pub fn catalog(mut self, catalog: &'static Catalog) -> Self {
self.catalogs.push(catalog);
self
}
pub fn use_isolating(mut self, on: bool) -> Self {
self.use_isolating = on;
self
}
pub fn locale_numbers(mut self, on: bool) -> Self {
self.locale_numbers = on;
self
}
pub fn build(self) -> Phrasebook {
#[cfg(feature = "koine03")]
for language in &self.languages {
if !crate::koine03::is_known(language.lang) {
tracing::warn!(lang = %language.lang, "phrasebook: koine does not know the declared language");
}
}
let fallback = self
.fallback
.or_else(|| self.languages.first().map(|l| l.lang))
.unwrap_or(Lang::EN_US);
let mut langs = self.languages.iter().map(|l| l.lang).collect::<Vec<_>>();
if !langs.contains(&fallback) {
if !self.languages.is_empty() {
tracing::warn!(%fallback, "phrasebook: the fallback language is not declared");
}
langs.push(fallback);
}
let bundles = langs.iter().map(|_| OnceCell::new()).collect();
Phrasebook {
inner: Rc::new(Inner {
languages: self.languages.into(),
fallback,
langs: langs.into(),
bundles,
catalogs: self.catalogs.into(),
use_isolating: self.use_isolating,
locale_numbers: self.locale_numbers,
texts: RefCell::new(HashMap::new()),
logged: RefCell::new(HashMap::new()),
errors_logged: RefCell::new(HashSet::new()),
}),
}
}
}
pub(crate) struct Inner {
languages: Box<[Language]>,
fallback: Lang,
langs: Box<[Lang]>,
bundles: Box<[OnceCell<LangBundle>]>,
catalogs: Box<[&'static Catalog]>,
use_isolating: bool,
pub(crate) locale_numbers: bool,
texts: RefCell<HashMap<Lang, Texts>>,
logged: RefCell<HashMap<Lang, HashSet<Box<str>>>>,
errors_logged: RefCell<HashSet<Box<str>>>,
}
impl Inner {
fn index(&self, lang: Lang) -> Option<usize> {
self.langs.iter().position(|&l| l == lang)
}
fn bundle(&self, lang: Lang) -> Option<&LangBundle> {
let index = self.index(lang)?;
Some(self.bundles[index].get_or_init(|| self.build_bundle(lang)))
}
fn build_bundle(&self, lang: Lang) -> LangBundle {
let mut bundle = new_bundle(lang, self.use_isolating, self.locale_numbers);
let mut seen = HashMap::<String, usize>::new();
for (index, catalog) in self.catalogs.iter().enumerate() {
let Some(source) = catalog.source(lang) else {
continue;
};
let resource = parse(source, |line, error| {
tracing::error!(
catalog = catalog.name(),
%lang,
line,
"phrasebook: syntax error: {error}"
);
});
for (key, name) in resource.entries().filter_map(entry_id) {
if !owns(catalog, name) {
tracing::warn!(
catalog = catalog.name(),
%lang,
id = key,
prefix = catalog.prefix().unwrap_or_default(),
"phrasebook: id outside the catalog's prefix"
);
}
match seen.entry(key.to_owned()) {
Entry::Vacant(e) => {
e.insert(index);
}
Entry::Occupied(e) if *e.get() == index => {
tracing::warn!(
catalog = catalog.name(),
%lang,
id = key,
"phrasebook: id defined twice; the first wins"
);
}
Entry::Occupied(e) => {
let first = self.catalogs[*e.get()].name();
if catalog.prefix().is_some() && owns(catalog, name) {
tracing::debug!(
catalog = catalog.name(),
by = first,
%lang,
id = key,
"phrasebook: message overridden by an earlier catalog"
);
} else {
tracing::warn!(
catalog = catalog.name(),
by = first,
%lang,
id = key,
"phrasebook: id already defined by an earlier catalog, which wins"
);
}
}
}
}
let _ = bundle.add_resource(resource);
}
LangBundle {
bundle,
selectors: RefCell::new(HashMap::new()),
}
}
pub(crate) fn format(
&self,
lang: Lang,
id: &str,
args: Option<&Args<'_>>,
quiet: bool,
) -> Option<String> {
if let Some(text) = self.format_in(lang, id, args) {
return Some(text);
}
if lang == self.fallback {
return None;
}
let text = self.format_in(self.fallback, id, args)?;
if !quiet && self.first_miss(lang, id) {
tracing::warn!(
%lang,
fallback = %self.fallback,
id,
"phrasebook: message missing, using the fallback language"
);
}
Some(text)
}
pub(crate) fn text(&self, lang: Lang, id: &str, quiet: bool) -> Option<Rc<str>> {
if let Some(text) = self.texts.borrow().get(&lang).and_then(|t| t.get(id)) {
return text.clone();
}
let text = self.format(lang, id, None, quiet).map(Rc::<str>::from);
self.texts
.borrow_mut()
.entry(lang)
.or_default()
.insert(Box::from(id), text.clone());
text
}
fn format_in(&self, lang: Lang, id: &str, args: Option<&Args<'_>>) -> Option<String> {
let mut errors = Vec::new();
let text = self.bundle(lang)?.format(id, args, &mut errors)?;
if !errors.is_empty() && self.errors_logged.borrow_mut().insert(Box::from(id)) {
for error in errors {
tracing::warn!(%lang, id, "phrasebook: format error: {error}");
}
}
Some(text)
}
pub(crate) fn first_miss(&self, lang: Lang, id: &str) -> bool {
let mut logged = self.logged.borrow_mut();
let logged = logged.entry(lang).or_default();
!logged.contains(id) && logged.insert(Box::from(id))
}
}
struct LangBundle {
bundle: Bundle,
selectors: RefCell<HashMap<Box<str>, Selectors>>,
}
impl LangBundle {
fn format(
&self,
id: &str,
args: Option<&Args<'_>>,
errors: &mut Vec<FluentError>,
) -> Option<String> {
let message = self.bundle.get_message(id)?;
let pattern = message.value()?;
let owned;
let args = match args {
Some(args) if args.has_strings() => {
let selectors = self.selectors(id, pattern);
owned = numbered(&args.inner, &selectors);
owned.as_ref().or(Some(&args.inner))
}
Some(args) => Some(&args.inner),
None => None,
};
Some(
self.bundle
.format_pattern(pattern, args, errors)
.into_owned(),
)
}
fn selectors(&self, id: &str, pattern: &ast::Pattern<&str>) -> Rc<[Box<str>]> {
if let Some(selectors) = self.selectors.borrow().get(id) {
return selectors.clone();
}
let mut out = Vec::new();
selectors(pattern, &mut out);
let selectors: Rc<[Box<str>]> = out.into_iter().map(Box::from).collect();
self.selectors
.borrow_mut()
.insert(Box::from(id), selectors.clone());
selectors
}
}
pub(crate) fn new_bundle(lang: Lang, use_isolating: bool, locale_numbers: bool) -> Bundle {
let langid = lang
.to_string()
.parse::<LanguageIdentifier>()
.unwrap_or_default();
let mut bundle = Bundle::new(vec![langid]);
bundle.set_use_isolating(use_isolating);
let _ = bundle.add_builtins();
#[cfg(all(feature = "browser", target_arch = "wasm32"))]
if locale_numbers {
bundle.set_formatter(Some(crate::intl::format));
}
#[cfg(not(all(feature = "browser", target_arch = "wasm32")))]
let _ = locale_numbers;
bundle
}
pub(crate) fn parse(
source: &Source,
mut report: impl FnMut(usize, &dyn fmt::Display),
) -> FluentResource {
match FluentResource::try_new(source.ftl().to_owned()) {
Ok(resource) => resource,
Err((resource, errors)) => {
for error in errors {
let line = line_of(source.ftl(), error.pos.start);
report(line, &error);
}
resource
}
}
}
fn line_of(text: &str, offset: usize) -> usize {
let offset = offset.min(text.len());
text.as_bytes()[..offset]
.iter()
.filter(|&&b| b == b'\n')
.count()
+ 1
}
pub(crate) fn entry_id<'s>(entry: &ast::Entry<&'s str>) -> Option<(String, &'s str)> {
match entry {
ast::Entry::Message(m) => Some((m.id.name.to_owned(), m.id.name)),
ast::Entry::Term(t) => Some((format!("-{}", t.id.name), t.id.name)),
_ => None,
}
}
pub(crate) fn selectors<'s>(pattern: &ast::Pattern<&'s str>, out: &mut Vec<&'s str>) {
for element in &pattern.elements {
if let ast::PatternElement::Placeable { expression } = element {
expression_selectors(expression, out);
}
}
}
fn expression_selectors<'s>(expression: &ast::Expression<&'s str>, out: &mut Vec<&'s str>) {
match expression {
ast::Expression::Select { selector, variants } => {
if let ast::InlineExpression::VariableReference { id } = selector
&& !out.contains(&id.name)
{
out.push(id.name);
}
for variant in variants {
selectors(&variant.value, out);
}
}
ast::Expression::Inline(ast::InlineExpression::Placeable { expression }) => {
expression_selectors(expression, out);
}
ast::Expression::Inline(_) => {}
}
}
fn numbered<'a>(args: &'a FluentArgs<'_>, selected: &[Box<str>]) -> Option<FluentArgs<'a>> {
let is_selected = |name: &str| selected.iter().any(|s| **s == *name);
let spelled = |name: &str, value: &FluentValue<'_>| {
let FluentValue::String(text) = value else {
return false;
};
is_selected(name) && matches!(FluentValue::try_number(text), FluentValue::Number(_))
};
if !args.iter().any(|(name, value)| spelled(name, value)) {
return None;
}
let mut out = FluentArgs::with_capacity(args.iter().count());
for (name, value) in args.iter() {
let value = match value {
FluentValue::String(text) if is_selected(name) => FluentValue::try_number(text),
value => value.clone(),
};
out.set(name, value);
}
Some(out)
}