pub mod tool_descriptions;
pub use tool_descriptions::{ToolDescriptions, default_search_dirs, detect_locale};
use fluent_bundle::concurrent::FluentBundle;
use fluent_bundle::{FluentArgs, FluentResource, FluentValue};
use std::sync::OnceLock;
use std::sync::atomic::{AtomicU8, Ordering};
use unic_langid::{LanguageIdentifier, langid};
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum Lang {
#[default]
En,
Ko,
}
impl Lang {
pub fn parse(s: &str) -> Option<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"en" | "en-us" | "en_us" | "english" => Some(Lang::En),
"ko" | "ko-kr" | "ko_kr" | "korean" | "한국어" => Some(Lang::Ko),
_ => None,
}
}
pub fn id(self) -> LanguageIdentifier {
match self {
Lang::En => langid!("en-US"),
Lang::Ko => langid!("ko-KR"),
}
}
pub fn code(self) -> &'static str {
match self {
Lang::En => "en",
Lang::Ko => "ko",
}
}
pub fn display_name(self) -> &'static str {
match self {
Lang::En => "English",
Lang::Ko => "한국어 (Korean)",
}
}
pub fn all() -> &'static [Lang] {
&[Lang::En, Lang::Ko]
}
fn as_u8(self) -> u8 {
match self {
Lang::En => 0,
Lang::Ko => 1,
}
}
fn from_u8(n: u8) -> Self {
match n {
1 => Lang::Ko,
_ => Lang::En,
}
}
}
const EN_ONBOARD: &str = include_str!("../../i18n/en/onboard.ftl");
const KO_ONBOARD: &str = include_str!("../../i18n/ko/onboard.ftl");
fn bundles_for(lang: Lang) -> &'static [&'static str] {
match lang {
Lang::En => &[EN_ONBOARD],
Lang::Ko => &[KO_ONBOARD],
}
}
pub struct I18n {
bundle: FluentBundle<FluentResource>,
lang: Lang,
fallback: Option<FluentBundle<FluentResource>>,
}
impl I18n {
pub fn new(lang: Lang) -> Self {
let bundle = build_bundle(lang);
let fallback = if matches!(lang, Lang::En) {
None
} else {
Some(build_bundle(Lang::En))
};
Self {
bundle,
lang,
fallback,
}
}
pub fn lang(&self) -> Lang {
self.lang
}
pub fn t(&self, key: &str) -> String {
self.t_with(key, None)
}
pub fn t_args(&self, key: &str, args: &FluentArgs) -> String {
self.t_with(key, Some(args))
}
fn t_with(&self, key: &str, args: Option<&FluentArgs>) -> String {
if let Some(s) = format_message(&self.bundle, key, args) {
return s;
}
if let Some(fallback) = &self.fallback {
if let Some(s) = format_message(fallback, key, args) {
return s;
}
}
key.to_string()
}
}
fn build_bundle(lang: Lang) -> FluentBundle<FluentResource> {
let mut bundle = FluentBundle::new_concurrent(vec![lang.id()]);
bundle.set_use_isolating(false);
for src in bundles_for(lang) {
let resource = FluentResource::try_new((*src).to_string())
.expect("translation bundle has invalid Fluent syntax — fix at compile time");
bundle
.add_resource(resource)
.expect("duplicate keys in translation bundle");
}
bundle
}
fn format_message(
bundle: &FluentBundle<FluentResource>,
key: &str,
args: Option<&FluentArgs>,
) -> Option<String> {
let msg = bundle.get_message(key)?;
let pattern = msg.value()?;
let mut errors = vec![];
let s = bundle
.format_pattern(pattern, args, &mut errors)
.into_owned();
if !errors.is_empty() {
tracing::debug!(?errors, key, "fluent format errors");
}
Some(s)
}
pub fn detect_lang(cli_flag: Option<&str>, config_lang: Option<&str>) -> Lang {
if let Some(s) = cli_flag {
if let Some(l) = Lang::parse(s) {
return l;
}
}
if let Ok(s) = std::env::var("CONSTRUCT_LANG") {
if let Some(l) = Lang::parse(&s) {
return l;
}
}
if let Some(s) = config_lang {
if let Some(l) = Lang::parse(s) {
return l;
}
}
for var in ["LC_ALL", "LANG"] {
if let Ok(s) = std::env::var(var) {
if let Some(l) = lang_from_locale(&s) {
return l;
}
}
}
Lang::En
}
fn lang_from_locale(s: &str) -> Option<Lang> {
let head: String = s.chars().take(2).collect();
Lang::parse(&head)
}
static EN_BUNDLE: OnceLock<I18n> = OnceLock::new();
static KO_BUNDLE: OnceLock<I18n> = OnceLock::new();
static ACTIVE: AtomicU8 = AtomicU8::new(0);
fn bundle_for(lang: Lang) -> &'static I18n {
match lang {
Lang::En => EN_BUNDLE.get_or_init(|| I18n::new(Lang::En)),
Lang::Ko => KO_BUNDLE.get_or_init(|| I18n::new(Lang::Ko)),
}
}
pub fn init(lang: Lang) {
set_lang(lang);
}
pub fn set_lang(lang: Lang) {
ACTIVE.store(lang.as_u8(), Ordering::Release);
}
pub fn lang() -> Lang {
Lang::from_u8(ACTIVE.load(Ordering::Acquire))
}
pub fn t(key: &str) -> String {
bundle_for(lang()).t(key)
}
pub fn t_args(key: &str, args: &FluentArgs) -> String {
bundle_for(lang()).t_args(key, args)
}
#[macro_export]
macro_rules! t {
($key:expr) => { $crate::i18n::t($key) };
($key:expr, $($name:ident = $value:expr),+ $(,)?) => {{
let mut args = ::fluent_bundle::FluentArgs::new();
$( args.set(stringify!($name), $crate::i18n::IntoFluentValue::into_fluent_value($value)); )+
$crate::i18n::t_args($key, &args)
}};
}
pub trait IntoFluentValue {
fn into_fluent_value(self) -> FluentValue<'static>;
}
impl IntoFluentValue for String {
fn into_fluent_value(self) -> FluentValue<'static> {
FluentValue::from(self)
}
}
impl IntoFluentValue for &str {
fn into_fluent_value(self) -> FluentValue<'static> {
FluentValue::from(self.to_string())
}
}
impl IntoFluentValue for &String {
fn into_fluent_value(self) -> FluentValue<'static> {
FluentValue::from(self.clone())
}
}
impl IntoFluentValue for u16 {
fn into_fluent_value(self) -> FluentValue<'static> {
FluentValue::from(i64::from(self))
}
}
impl IntoFluentValue for u32 {
fn into_fluent_value(self) -> FluentValue<'static> {
FluentValue::from(i64::from(self))
}
}
impl IntoFluentValue for u64 {
fn into_fluent_value(self) -> FluentValue<'static> {
FluentValue::from(self as i64)
}
}
impl IntoFluentValue for i32 {
fn into_fluent_value(self) -> FluentValue<'static> {
FluentValue::from(i64::from(self))
}
}
impl IntoFluentValue for i64 {
fn into_fluent_value(self) -> FluentValue<'static> {
FluentValue::from(self)
}
}
impl IntoFluentValue for usize {
fn into_fluent_value(self) -> FluentValue<'static> {
FluentValue::from(self as i64)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn from_str_handles_common_aliases() {
assert_eq!(Lang::parse("en"), Some(Lang::En));
assert_eq!(Lang::parse("EN"), Some(Lang::En));
assert_eq!(Lang::parse("en-US"), Some(Lang::En));
assert_eq!(Lang::parse("english"), Some(Lang::En));
assert_eq!(Lang::parse("ko"), Some(Lang::Ko));
assert_eq!(Lang::parse("ko-KR"), Some(Lang::Ko));
assert_eq!(Lang::parse("ko_KR"), Some(Lang::Ko));
assert_eq!(Lang::parse("korean"), Some(Lang::Ko));
assert_eq!(Lang::parse("한국어"), Some(Lang::Ko));
assert_eq!(Lang::parse("ja"), None);
assert_eq!(Lang::parse(""), None);
}
#[test]
fn locale_string_matched_on_two_letter_prefix() {
assert_eq!(lang_from_locale("ko_KR.UTF-8"), Some(Lang::Ko));
assert_eq!(lang_from_locale("en_US.UTF-8"), Some(Lang::En));
assert_eq!(lang_from_locale("en"), Some(Lang::En));
assert_eq!(lang_from_locale("ja_JP.UTF-8"), None);
}
#[test]
fn english_bundle_resolves_known_key() {
let i = I18n::new(Lang::En);
assert_eq!(i.t("welcome-title"), "Welcome to the Construct.");
}
#[test]
fn korean_bundle_resolves_known_key() {
let i = I18n::new(Lang::Ko);
assert_eq!(i.t("welcome-title"), "Construct에 오신 것을 환영합니다.");
}
#[test]
fn missing_key_returns_the_key_itself() {
let en = I18n::new(Lang::En);
assert_eq!(en.t("nonexistent-key"), "nonexistent-key");
let ko = I18n::new(Lang::Ko);
assert_eq!(ko.t("nonexistent-key"), "nonexistent-key");
}
#[test]
fn args_substitute_into_pattern() {
let i = I18n::new(Lang::En);
let mut args = FluentArgs::new();
args.set("path", "/tmp/foo");
let s = i.t_args("workspace-confirmed", &args);
assert!(s.contains("/tmp/foo"), "rendered string was: {s:?}");
}
}