use std::collections::BTreeMap;
use tatara_lisp::{Atom, Sexp};
use crate::lex::{lex, Token, TokenKind};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Yakugo {
pub tag: String,
pub name: String,
pub locales: Vec<String>,
words: BTreeMap<String, String>,
}
impl Yakugo {
#[must_use]
pub fn canon(&self, word: &str) -> Option<&str> {
self.words.get(word).map(String::as_str)
}
#[must_use]
pub fn len(&self) -> usize {
self.words.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.words.is_empty()
}
#[must_use]
pub fn answers_to(&self, locale: &str) -> bool {
let norm = normalize_locale(locale);
self.locales
.iter()
.any(|l| norm == *l || norm.starts_with(&format!("{l}-")))
}
}
fn normalize_locale(locale: &str) -> String {
locale
.split('.')
.next()
.unwrap_or(locale)
.replace('_', "-")
.to_lowercase()
}
pub fn parse_pack(src: &str) -> Result<Yakugo, String> {
let mut tag = String::new();
let mut name = String::new();
let mut locales = Vec::new();
let mut words = BTreeMap::new();
let mut in_words = false;
for (n, raw) in src.lines().enumerate() {
let line = raw.split(';').next().unwrap_or("").trim();
if line.is_empty() {
continue;
}
let at = |what: &str| format!("{what} (line {})", n + 1);
if let Some(rest) = line.strip_prefix("(defyakugo ") {
let parts = quoted(rest);
if parts.len() < 2 {
return Err(at("defyakugo needs a tag and a name, both quoted"));
}
tag = parts[0].clone();
name = parts[1].clone();
} else if let Some(rest) = line.strip_prefix("(locales ") {
locales = quoted(rest);
if locales.is_empty() {
return Err(at("locales must list at least one locale tag"));
}
} else if line.starts_with("(words") {
in_words = true;
} else if in_words && line.starts_with('(') {
let localized = quoted(line);
let canonical: Vec<&str> = line
.trim_matches(|c| c == '(' || c == ')')
.split_whitespace()
.collect();
let Some(canon) = canonical.last() else {
return Err(at("a words entry needs a canonical blue word"));
};
if localized.len() != 1 {
return Err(at("a words entry is (\"localized\" canonical)"));
}
words.insert(
localized[0].clone(),
(*canon).trim_end_matches(')').to_owned(),
);
}
}
if tag.is_empty() {
return Err("pack has no (defyakugo …) header".to_owned());
}
if words.is_empty() {
return Err(format!("pack \"{tag}\" translates nothing"));
}
if locales.is_empty() {
locales.push(tag.clone());
}
Ok(Yakugo {
tag,
name,
locales,
words,
})
}
fn quoted(s: &str) -> Vec<String> {
let mut out = Vec::new();
let mut cur = String::new();
let mut inside = false;
for c in s.chars() {
match c {
'"' if inside => {
out.push(std::mem::take(&mut cur));
inside = false;
}
'"' => inside = true,
_ if inside => cur.push(c),
_ => {}
}
}
out
}
pub const BUILTIN_PACKS: &[(&str, &str)] = &[
("ja", include_str!("../packs/ja.lisp")),
("fr", include_str!("../packs/fr.lisp")),
("es", include_str!("../packs/es.lisp")),
("pt", include_str!("../packs/pt.lisp")),
("de", include_str!("../packs/de.lisp")),
("ru", include_str!("../packs/ru.lisp")),
("zh", include_str!("../packs/zh.lisp")),
("ar", include_str!("../packs/ar.lisp")),
("hi", include_str!("../packs/hi.lisp")),
("sw", include_str!("../packs/sw.lisp")),
("math", include_str!("../packs/math.lisp")),
];
pub fn builtin_packs() -> Result<Vec<Yakugo>, String> {
BUILTIN_PACKS
.iter()
.map(|(tag, src)| parse_pack(src).map_err(|e| format!("pack {tag}: {e}")))
.collect()
}
pub fn pack_for_locale(locale: &str) -> Result<Option<Yakugo>, String> {
Ok(builtin_packs()?.into_iter().find(|p| p.answers_to(locale)))
}
#[must_use]
pub fn apply(tokens: Vec<Token>, pack: &Yakugo) -> Vec<Token> {
tokens
.into_iter()
.map(|t| match &t.kind {
TokenKind::Ident(name) => match pack.canon(name) {
Some(canon) => Token {
kind: TokenKind::Ident(canon.to_owned()),
span: t.span,
},
None => t,
},
_ => t,
})
.collect()
}
pub fn canonical_tokens(src: &str, pack: &Yakugo) -> Result<Vec<Token>, crate::lex::LexError> {
Ok(apply(lex(src)?, pack))
}
#[must_use]
pub fn rewrite_symbols(form: Sexp, pack: &Yakugo) -> Sexp {
match form {
Sexp::Atom(Atom::Symbol(name)) => {
let canon = pack.canon(&name).map_or(name, ToOwned::to_owned);
Sexp::Atom(Atom::Symbol(canon))
}
Sexp::List(items) => Sexp::List(
items
.into_iter()
.map(|f| rewrite_symbols(f, pack))
.collect(),
),
Sexp::Quote(inner) => Sexp::Quote(Box::new(rewrite_symbols(*inner, pack))),
Sexp::Quasiquote(inner) => Sexp::Quasiquote(Box::new(rewrite_symbols(*inner, pack))),
Sexp::Unquote(inner) => Sexp::Unquote(Box::new(rewrite_symbols(*inner, pack))),
Sexp::UnquoteSplice(inner) => Sexp::UnquoteSplice(Box::new(rewrite_symbols(*inner, pack))),
other => other,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn pack(tag: &str) -> Yakugo {
let (_, src) = BUILTIN_PACKS
.iter()
.find(|(t, _)| *t == tag)
.unwrap_or_else(|| panic!("no builtin pack {tag}"));
parse_pack(src).unwrap_or_else(|e| panic!("pack {tag}: {e}"))
}
#[test]
fn every_builtin_pack_loads() {
let packs = builtin_packs().expect("all builtin packs must parse");
assert_eq!(
packs.len(),
BUILTIN_PACKS.len(),
"a pack was dropped during loading"
);
for p in &packs {
assert!(
p.len() >= 15,
"pack {} translates only {} terms — a pack that omits a \
structural keyword leaves that keyword English-only, which is \
a half-translated surface rather than a language",
p.tag,
p.len()
);
}
}
#[test]
fn every_language_produces_the_identical_ast() {
let english = crate::parse_program("def double(n)\n n * 2\nend\ndouble(21)")
.expect("english must parse");
for (tag, _) in BUILTIN_PACKS {
let p = pack(tag);
let src = translate("def double(n)\n n * 2\nend\ndouble(21)", &p);
let got = crate::parse_program_in(&src, &p)
.unwrap_or_else(|e| panic!("{tag}: {src:?} failed to parse: {e}"));
assert_eq!(
format!("{got:?}"),
format!("{english:?}"),
"{tag} produced a DIFFERENT tree — the translation changed the \
program's meaning, which is the one thing it must never do.\n\
source was:\n{src}"
);
}
}
fn translate(src: &str, pack: &Yakugo) -> String {
let mut out = src.to_owned();
let mut pairs: Vec<(&String, &String)> = pack.words.iter().map(|(l, c)| (c, l)).collect();
pairs.sort_by_key(|(c, _)| std::cmp::Reverse(c.len()));
for (canon, localized) in pairs {
out = replace_word(&out, canon, localized);
}
out
}
fn replace_word(hay: &str, needle: &str, with: &str) -> String {
let mut out = String::new();
let mut rest = hay;
while let Some(i) = rest.find(needle) {
let before_ok = i == 0
|| !rest[..i]
.chars()
.next_back()
.is_some_and(|c| c.is_alphanumeric() || c == '_');
let after = &rest[i + needle.len()..];
let after_ok = !after
.chars()
.next()
.is_some_and(|c| c.is_alphanumeric() || c == '_');
out.push_str(&rest[..i]);
if before_ok && after_ok {
out.push_str(with);
} else {
out.push_str(needle);
}
rest = after;
}
out.push_str(rest);
out
}
#[test]
fn a_pack_does_not_rewrite_string_literals() {
let p = pack("fr");
let fin = p
.words
.iter()
.find(|(_, c)| c.as_str() == "end")
.map(|(l, _)| l.clone())
.expect("fr must translate end");
let src = format!("def f()\n \"{fin}\"\nend");
let canonical = crate::parse_program(&src.replace("def", "def")).is_ok();
assert!(canonical, "setup: the program must parse");
let toks = canonical_tokens(&src, &p).expect("lex");
let strings: Vec<_> = toks
.iter()
.filter_map(|t| match &t.kind {
TokenKind::Str(s) => Some(s.clone()),
_ => None,
})
.collect();
assert!(
strings.contains(&fin),
"the string literal {fin:?} was rewritten by the pack; only Ident \
tokens may be translated: {strings:?}"
);
}
#[test]
fn the_ast_rewrite_agrees_with_the_token_rewrite() {
for (tag, _) in BUILTIN_PACKS {
let p = pack(tag);
let english = "def double(n)\n n * 2\nend";
let localized = translate(english, &p);
let via_tokens = crate::parse_program_in(&localized, &p)
.unwrap_or_else(|e| panic!("{tag}: token path: {e}"));
let via_ast: Vec<Sexp> = crate::parse_program(english)
.expect("english parses")
.into_iter()
.map(|f| rewrite_symbols(f, &p))
.collect();
assert_eq!(
format!("{via_tokens:?}"),
format!("{via_ast:?}"),
"{tag}: the token pass and the AST pass disagree — one pack must \
mean the same thing at every phase"
);
}
}
#[test]
fn the_ast_rewrite_descends_into_quotes() {
let p = pack("fr");
let fin = p
.words
.iter()
.find(|(_, c)| c.as_str() == "end")
.map(|(l, _)| l.clone())
.expect("fr translates end");
let quoted = Sexp::Quote(Box::new(Sexp::Atom(Atom::Symbol(fin))));
let out = rewrite_symbols(quoted, &p);
assert_eq!(
format!("{out:?}"),
format!(
"{:?}",
Sexp::Quote(Box::new(Sexp::Atom(Atom::Symbol("end".into()))))
),
"a quoted symbol was left untranslated — macro-emitted trees are \
mostly quoted, so skipping them would make the build phase a no-op"
);
}
#[test]
fn locales_match_across_spelling_conventions() {
let p = pack("fr");
assert!(p.answers_to("fr_FR.UTF-8"), "POSIX form must match");
assert!(p.answers_to("fr-CA"), "macOS regional form must match");
assert!(p.answers_to("fr"), "bare tag must match");
assert!(
!p.answers_to("de_DE.UTF-8"),
"another language must NOT match"
);
assert!(!p.answers_to("frisian"), "a mere prefix must not match");
}
#[test]
fn a_malformed_pack_is_a_typed_error_naming_the_problem() {
assert!(parse_pack("").is_err(), "an empty pack must not load");
let e = parse_pack("(defyakugo \"xx\" \"x\")\n(locales \"xx\")\n(words\n)")
.expect_err("a pack translating nothing must not load");
assert!(e.contains("xx"), "the error must name the pack: {e}");
}
#[test]
fn pack_for_locale_finds_and_misses_honestly() {
assert!(
pack_for_locale("ja_JP.UTF-8")
.expect("packs load")
.is_some_and(|p| p.tag == "ja"),
"a Japanese locale must resolve to the ja pack"
);
assert!(
pack_for_locale("xx_YY").expect("packs load").is_none(),
"an unknown locale must resolve to NOTHING rather than a default — \
silently falling back would run a program in a language the \
author did not write it in"
);
}
}