use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use mf2_locale_data::number::Selection;
use mf2_resource::LineIndex;
use serde::Deserialize;
use crate::error::{Error, Result};
use crate::lint::{Level, Lint};
pub const FILE_NAME: &str = "mf2.toml";
#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields, default)]
#[non_exhaustive]
pub struct Config {
pub source_locale: String,
pub fallback: BTreeMap<String, Vec<String>>,
pub catalog: CatalogConfig,
pub locale_data: LocaleDataConfig,
pub lints: BTreeMap<Lint, Level>,
pub functions: BTreeMap<String, String>,
}
impl Default for Config {
fn default() -> Self {
Config {
source_locale: "en".to_owned(),
fallback: BTreeMap::new(),
catalog: CatalogConfig::default(),
locale_data: LocaleDataConfig::default(),
lints: BTreeMap::new(),
functions: BTreeMap::new(),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields, default)]
#[non_exhaustive]
pub struct CatalogConfig {
pub strip: BTreeSet<Strip>,
pub missing: Missing,
}
impl Default for CatalogConfig {
fn default() -> Self {
CatalogConfig {
strip: [Strip::Cold, Strip::Ids].into_iter().collect(),
missing: Missing::Fallback,
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Deserialize)]
#[serde(rename_all = "kebab-case")]
#[non_exhaustive]
pub enum Strip {
Cold,
Ids,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, Deserialize)]
#[serde(rename_all = "kebab-case")]
#[non_exhaustive]
pub enum Missing {
#[default]
Fallback,
Id,
Empty,
}
#[derive(Clone, Debug, PartialEq, Eq, Default, Deserialize)]
#[serde(deny_unknown_fields, default)]
#[non_exhaustive]
pub struct LocaleDataConfig {
pub currencies: DataSet,
pub units: DataSet,
}
#[derive(Clone, Debug, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum DataSet {
#[default]
Used,
All,
Listed(BTreeSet<String>),
}
impl DataSet {
pub fn with_used(&self, used: &Selection) -> Selection {
match (self, used) {
(DataSet::All, _) => Selection::All,
(DataSet::Used, used) => used.clone(),
(DataSet::Listed(listed), Selection::All) => Selection::Listed(listed.clone()),
(DataSet::Listed(listed), Selection::Listed(used)) => {
Selection::Listed(listed.iter().chain(used).cloned().collect())
}
}
}
}
impl<'de> Deserialize<'de> for DataSet {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
#[derive(Deserialize)]
#[serde(untagged)]
enum Raw {
Word(String),
List(BTreeSet<String>),
}
match Raw::deserialize(d)? {
Raw::Word(w) if w == "used" => Ok(DataSet::Used),
Raw::Word(w) if w == "all" => Ok(DataSet::All),
Raw::Word(w) => Err(serde::de::Error::custom(format!(
"expected \"used\", \"all\" or a list of codes, not {w:?}"
))),
Raw::List(list) => Ok(DataSet::Listed(list)),
}
}
}
impl Config {
pub fn load(dir: &Path) -> Result<Config> {
let path = dir.join(FILE_NAME);
match std::fs::read_to_string(&path) {
Ok(text) => Config::parse(&text, &path),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Config::default()),
Err(source) => Err(Error::io(path, source)),
}
}
pub fn parse(text: &str, path: &Path) -> Result<Config> {
let config: Config = toml::from_str(text).map_err(|e| config_error(text, path, &e))?;
config.validate(path)?;
Ok(config)
}
pub fn to_toml(&self) -> String {
toml::to_string_pretty(self).unwrap_or_default()
}
pub fn level(&self, lint: Lint) -> Level {
self.lints
.get(&lint)
.copied()
.unwrap_or_else(|| lint.default_level())
}
pub fn chain(&self, locale: &str) -> Vec<String> {
let mut chain: Vec<String> = match self.fallback.get(locale) {
Some(listed) => listed.clone(),
None => truncations(locale),
};
if locale != self.source_locale && !chain.contains(&self.source_locale) {
chain.push(self.source_locale.clone());
}
chain.retain(|l| l != locale);
let mut seen = BTreeSet::new();
chain.retain(|l| seen.insert(l.clone()));
chain
}
fn validate(&self, path: &Path) -> Result<()> {
let bad = |message: String| Error::Config {
path: path.to_path_buf(),
message,
};
if self.source_locale.is_empty() {
return Err(bad("source_locale must be a BCP 47 tag".to_owned()));
}
for (&lint, &level) in &self.lints {
if level < lint.floor() {
return Err(bad(format!(
"[lints] {lint} = {level:?}: this lint cannot be set below \
\"{}\" — the rest of the build relies on it",
lint.floor()
)));
}
}
for (locale, chain) in &self.fallback {
if chain.iter().any(|l| l == locale) {
return Err(bad(format!(
"[fallback] {locale:?}: a locale may not fall back to itself"
)));
}
}
for (identifier, path_to_fn) in &self.functions {
if identifier.is_empty() || path_to_fn.is_empty() {
return Err(bad(format!(
"[functions] {identifier:?}: needs a Rust path to a \
`&'static dyn Function`"
)));
}
}
Ok(())
}
}
fn truncations(locale: &str) -> Vec<String> {
let mut out = Vec::new();
let mut rest = locale;
while let Some(cut) = rest.rfind('-') {
rest = &rest[..cut];
if !rest.is_empty() {
out.push(rest.to_owned());
}
}
out
}
fn config_error(text: &str, path: &Path, e: &toml::de::Error) -> Error {
let message = match e.span() {
Some(span) => {
let index = LineIndex::new(text);
let at = index.position(text, u32::try_from(span.start).unwrap_or(u32::MAX));
format!("{}:{}: {}", at.line, at.column, e.message())
}
None => e.message().to_owned(),
};
Error::Config {
path: path.to_path_buf(),
message,
}
}
mod serialize {
use super::{CatalogConfig, Config, DataSet, LocaleDataConfig, Missing, Strip};
use serde::ser::{Serialize, SerializeMap, SerializeSeq, Serializer};
impl Serialize for Config {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
let mut m = s.serialize_map(None)?;
m.serialize_entry("source_locale", &self.source_locale)?;
if !self.fallback.is_empty() {
m.serialize_entry("fallback", &self.fallback)?;
}
m.serialize_entry("catalog", &self.catalog)?;
m.serialize_entry("locale_data", &self.locale_data)?;
if !self.lints.is_empty() {
let named: std::collections::BTreeMap<&str, String> = self
.lints
.iter()
.map(|(l, v)| (l.name(), v.to_string()))
.collect();
m.serialize_entry("lints", &named)?;
}
if !self.functions.is_empty() {
m.serialize_entry("functions", &self.functions)?;
}
m.end()
}
}
impl Serialize for CatalogConfig {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
let mut m = s.serialize_map(Some(2))?;
m.serialize_entry("strip", &self.strip)?;
m.serialize_entry("missing", &self.missing)?;
m.end()
}
}
impl Serialize for LocaleDataConfig {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
let mut m = s.serialize_map(Some(2))?;
m.serialize_entry("currencies", &self.currencies)?;
m.serialize_entry("units", &self.units)?;
m.end()
}
}
impl Serialize for Strip {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(match self {
Strip::Cold => "cold",
Strip::Ids => "ids",
})
}
}
impl Serialize for Missing {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(match self {
Missing::Fallback => "fallback",
Missing::Id => "id",
Missing::Empty => "empty",
})
}
}
impl Serialize for DataSet {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
match self {
DataSet::Used => s.serialize_str("used"),
DataSet::All => s.serialize_str("all"),
DataSet::Listed(list) => {
let mut seq = s.serialize_seq(Some(list.len()))?;
for code in list {
seq.serialize_element(code)?;
}
seq.end()
}
}
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Layout {
pub root: PathBuf,
pub locales: PathBuf,
}
impl Layout {
pub fn new(root: impl Into<PathBuf>) -> Layout {
let root = root.into();
let locales = root.join("locales");
Layout { root, locales }
}
pub fn locales(&self) -> Result<Vec<String>> {
let mut tags = BTreeSet::new();
let dir = std::fs::read_dir(&self.locales)
.map_err(|source| Error::io(self.locales.clone(), source))?;
for entry in dir {
let entry = entry.map_err(|source| Error::io(self.locales.clone(), source))?;
let path = entry.path();
let kind = entry
.file_type()
.map_err(|source| Error::io(path.clone(), source))?;
let name = entry.file_name();
let name = name.to_string_lossy();
let tag = if kind.is_dir() {
name.as_ref()
} else if let Some(tag) = name.strip_suffix(".json") {
tag
} else {
continue;
};
if !is_tag(tag) {
return Err(Error::Layout(format!(
"{}: {tag:?} is not a locale tag — `locales/` holds one \
directory or one .json file per tag, and nothing else",
self.locales.display()
)));
}
tags.insert(tag.to_owned());
}
if tags.is_empty() {
return Err(Error::Layout(format!(
"{}: no locales — expected a directory or a .json file per tag",
self.locales.display()
)));
}
Ok(tags.into_iter().collect())
}
}
fn is_tag(name: &str) -> bool {
!name.is_empty()
&& name.len() <= 64
&& name
.split('-')
.all(|part| !part.is_empty() && part.chars().all(|c| c.is_ascii_alphanumeric()))
}
#[cfg(test)]
mod tests {
use super::*;
fn set(codes: &[&str]) -> BTreeSet<String> {
codes.iter().map(|c| (*c).to_owned()).collect()
}
#[test]
fn an_explicit_list_wins_over_a_variables_every_code() {
let listed = DataSet::Listed(set(&["EUR", "USD"]));
assert_eq!(
listed.with_used(&Selection::All),
Selection::Listed(set(&["EUR", "USD"]))
);
assert_eq!(
listed.with_used(&Selection::Listed(set(&["JPY"]))),
Selection::Listed(set(&["EUR", "JPY", "USD"]))
);
assert_eq!(DataSet::Used.with_used(&Selection::All), Selection::All);
assert_eq!(
DataSet::All.with_used(&Selection::Listed(set(&["JPY"]))),
Selection::All
);
}
}