use std::collections::BTreeMap;
use toml::de::DeTable;
use super::plural::PluralCategory;
use crate::diagnostics::Diagnostic;
use crate::doc::{self, Doc};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Piece {
Text(String),
Arg(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Template(pub(crate) Vec<Piece>);
impl Template {
pub(crate) fn parse(text: &str) -> Result<Self, String> {
let mut pieces = Vec::new();
let mut literal = String::new();
let mut chars = text.chars().peekable();
while let Some(c) = chars.next() {
match c {
'{' if chars.peek() == Some(&'{') => {
chars.next();
literal.push('{');
}
'}' if chars.peek() == Some(&'}') => {
chars.next();
literal.push('}');
}
'{' => {
let mut name = String::new();
let mut closed = false;
for c in chars.by_ref() {
if c == '}' {
closed = true;
break;
}
name.push(c);
}
let valid = closed
&& !name.is_empty()
&& name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-');
if !valid {
return Err(format!("invalid placeholder `{{{name}` in `{text}`; write it like {{count}}"));
}
if !literal.is_empty() {
pieces.push(Piece::Text(std::mem::take(&mut literal)));
}
pieces.push(Piece::Arg(name));
}
'}' => {
return Err(format!("unmatched `}}` in `{text}`; write }}}} for a literal brace"));
}
other => literal.push(other),
}
}
if !literal.is_empty() {
pieces.push(Piece::Text(literal));
}
Ok(Self(pieces))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Message {
Plain(Template),
Plural(BTreeMap<PluralCategory, Template>),
}
#[derive(Debug, Clone)]
pub(crate) struct Locale {
pub(crate) code: String,
pub(crate) name: String,
pub(crate) fallback: Option<String>,
pub(crate) messages: BTreeMap<String, Message>,
}
pub(crate) fn parse(file: &str, text: &str, report: &mut Vec<Diagnostic>) -> Option<Locale> {
let doc = Doc::new(file, text);
let root = match doc.parse() {
Ok(root) => root,
Err(diagnostic) => {
report.push(diagnostic);
return None;
}
};
let Some(meta_value) = doc::get(&root, "meta") else {
report.push(Diagnostic::error(None, format!("{file}: missing [meta] table with `name` and `code`")));
return None;
};
let meta = match doc.table(meta_value, "meta") {
Ok(meta) => meta,
Err(diagnostic) => {
report.push(diagnostic);
return None;
}
};
let field = |key: &str, report: &mut Vec<Diagnostic>| -> Option<String> {
let value = doc::get(meta, key)?;
match doc.string(value, &format!("meta.{key}")) {
Ok(text) => Some(text.to_owned()),
Err(diagnostic) => {
report.push(diagnostic);
None
}
}
};
let (Some(name), Some(code)) = (field("name", report), field("code", report)) else {
report.push(Diagnostic::error(None, format!("{file}: [meta] needs `name` and `code`")));
return None;
};
let fallback = field("fallback", report);
for (key, value) in meta {
if !["name", "code", "fallback"].contains(&key.get_ref().as_ref()) {
report.push(doc.error(&value.span(), format!("unknown key `meta.{}`", key.get_ref())));
}
}
let mut messages = BTreeMap::new();
for (key, value) in &root {
let key = key.get_ref().as_ref();
if key == "meta" {
continue;
}
match doc.table(value, key) {
Ok(table) => collect(&doc, key, table, &mut messages, report),
Err(_) => report.push(doc.error(
&value.span(),
format!("`{key}` must be a section such as [{key}]; messages live inside sections"),
)),
}
}
Some(Locale { code, name, fallback, messages })
}
fn collect(
doc: &Doc<'_>,
prefix: &str,
table: &DeTable<'_>,
messages: &mut BTreeMap<String, Message>,
report: &mut Vec<Diagnostic>,
) {
for (key, value) in table {
let full_key = format!("{prefix}.{}", key.get_ref());
if let Some(text) = value.get_ref().as_str() {
match Template::parse(text) {
Ok(template) => {
messages.insert(full_key, Message::Plain(template));
}
Err(message) => report.push(doc.error(&value.span(), message)),
}
continue;
}
let Some(inner) = value.get_ref().as_table() else {
report
.push(doc.error(&value.span(), format!("`{full_key}` must be a string, a plural table or a section")));
continue;
};
let is_plural = !inner.is_empty()
&& inner.iter().all(|(k, v)| PluralCategory::from_name(k.get_ref()).is_some() && v.get_ref().is_str());
if !is_plural {
collect(doc, &full_key, inner, messages, report);
continue;
}
let mut forms = BTreeMap::new();
for (category, form) in inner {
let (Some(category), Some(text)) = (PluralCategory::from_name(category.get_ref()), form.get_ref().as_str())
else {
continue;
};
match Template::parse(text) {
Ok(template) => {
forms.insert(category, template);
}
Err(message) => report.push(doc.error(&form.span(), message)),
}
}
if forms.contains_key(&PluralCategory::Other) {
messages.insert(full_key, Message::Plural(forms));
} else {
report.push(doc.error(&value.span(), format!("plural message `{full_key}` needs an `other` form")));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn templates_split_text_and_args() {
let template = Template::parse("Hi {name}, {{literal}}").expect("valid");
assert_eq!(
template.0,
vec![Piece::Text("Hi ".to_owned()), Piece::Arg("name".to_owned()), Piece::Text(", {literal}".to_owned()),]
);
assert!(Template::parse("broken {").is_err());
assert!(Template::parse("Hello {name").is_err(), "a placeholder must be closed");
assert!(Template::parse("stray }").is_err());
}
#[test]
fn flattens_sections_and_detects_plurals() {
let text = r#"
[meta]
name = "English"
code = "en"
[files]
count = { one = "{n} file", other = "{n} files" }
[files.actions]
open = "Open"
bad = { one = "only one" }
"#;
let mut report = Vec::new();
let locale = parse("en.toml", text, &mut report).expect("usable");
assert_eq!(locale.code, "en");
assert!(matches!(locale.messages.get("files.count"), Some(Message::Plural(_))));
assert!(matches!(locale.messages.get("files.actions.open"), Some(Message::Plain(_))));
assert!(!locale.messages.contains_key("files.actions.bad"));
assert_eq!(report.len(), 1);
assert!(report[0].message.contains("needs an `other` form"));
assert_eq!(report[0].location.as_ref().map(|l| l.line), Some(10));
}
#[test]
fn unknown_meta_keys_are_reported() {
let mut report = Vec::new();
let locale = parse("x.toml", "[meta]\nname = \"X\"\ncode = \"x\"\nfallbak = \"en\"\n", &mut report);
assert!(locale.is_some_and(|locale| locale.fallback.is_none()));
assert_eq!(report.len(), 1);
assert!(report[0].message.contains("unknown key `meta.fallbak`"));
assert_eq!(report[0].location.as_ref().map(|l| l.line), Some(4));
}
#[test]
fn meta_is_required() {
let mut report = Vec::new();
assert!(parse("x.toml", "[a]\nb = \"c\"\n", &mut report).is_none());
assert!(parse("x.toml", "[meta]\nname = \"X\"\n", &mut report).is_none());
}
}