diff --git a/README.md b/README.md index f966d49..190a105 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,9 @@ A robust internationalization solution for Rust featuring compile-time validation, ISO 639-1 compliance, and TOML-based translation management. +**This library prioritizes ergonomics over raw performance.** +Our goal is not to be *blazingly fast* but to provide the most user-friendly experience for implementing translations—whether you're a first-time user or an experienced developer. If you require maximum performance, consider alternative libraries, a custom implementation, or even hard-coded values on the stack. + ## Table of Contents 📖 - [Features](#features-) @@ -63,7 +66,7 @@ The translation files have three rules The load configuration such as `seek_mode` and `overlap` is not relevant here, as previously specified, these configuration values only get applied once by reversing the translations conveniently. -To load translations you make use of the `translatable::translation` macro, that macro requires two +To load translations you make use of the `translatable::translation` macro, that macro requires at least two parameters to be passed. The first parameter consists of the language which can be passed dynamically as a variable or an expression @@ -74,15 +77,22 @@ The second parameter consists of the path, which can be passed dynamically as a that resolves to an `impl Into` with the format `path.to.translation`, or statically with the following syntax `static path::to::translation`. +The rest of parameters are `meta-variable patterns` also known as `key = value` parameters or key-value pairs, +these are processed as replaces, *or format if the call is all-static*. When a template (`{}`) is found with +the name of a key inside it gets replaced for whatever is the `Display` implementation of the value. This meaning +that the value must always implement `Display`. Otherwise, if you want to have a `{}` inside your translation, +you can escape it the same way `format!` does, by using `{{}}`. Just like object construction works in rust, if +you have a parameter like `x = x`, you can shorten it to `x`. + Depending on whether the parameters are static or dynamic the macro will act different, differing whether the checks are compile-time or run-time, the following table is a macro behavior matrix. | Parameters | Compile-Time checks | Return type | |----------------------------------------------------|----------------------------------------------------------|-----------------------------------------------------------------------------------| -| `static language` + `static path` (most optimized) | Path existence, Language validity, \*Template validation | `&'static str` (stack) if there are no templates or `String` (heap) if there are. | +| `static language` + `static path` (most optimized) | Path existence, Language validity | `&'static str` (stack) if there are no templates or `String` (heap) if there are. | | `dynamic language` + `dynamic path` | None | `Result` (heap) | | `static language` + `dynamic path` | Language validity | `Result` (heap) | -| `dynamic language` + `static path` (commonly used) | Path existence, \*Template validation | `Result` (heap) | +| `dynamic language` + `static path` (commonly used) | Path existence | `Result` (heap) | - For the error handling, if you want to integrate this with `thiserror` you can use a `#[from] translatable::TranslationError`, as a nested error, all the errors implement display, for optimization purposes there are not the same amount of errors with @@ -91,11 +101,6 @@ dynamic parameters than there are with static parameters. - The runtime errors implement a `cause()` method that returns a heap allocated `String` with the error reason, essentially the error display. -- Template validation in the static parameter handling means variable existence, since templates are generated as a `format!` -call which processes expressions found in scope. It's always recommended to use full paths in translation templates -to avoid needing to make variables in scope, unless the calls are contextual, in that case there is nothing that can -be done to avoid making variables. - ## Example implementation 📂 The following examples are an example application structure for a possible @@ -129,22 +134,21 @@ es = "¡Hola {name}!" ### Example application usage -Notice how that template is in scope, whole expressions can be used -in the templates such as `path::to::function()`, or other constants. +Notice how there is a template, this template is being replaced by the +`name = "john"` key value pair passed as third parameter. ```rust extern crate translatable; use translatable::translation; fn main() { - let dynamic_lang = "es"; - let dynamic_path = "common.greeting" - let name = "john"; - - assert!(translation!("es", static common::greeting) == "¡Hola john!"); - assert!(translation!("es", dynamic_path).unwrap() == "¡Hola john!".into()); - assert!(translation!(dynamic_lang, static common::greeting).unwrap() == "¡Hola john!".into()); - assert!(translation!(dynamic_lang, dynamic_path).unwrap() == "¡Hola john!".into()); + let dynamic_lang = "es"; + let dynamic_path = "common.greeting" + + assert!(translation!("es", static common::greeting) == "¡Hola john!", name = "john"); + assert!(translation!("es", dynamic_path).unwrap() == "¡Hola john!".into(), name = "john"); + assert!(translation!(dynamic_lang, static common::greeting).unwrap() == "¡Hola john!".into(), name = "john"); + assert!(translation!(dynamic_lang, dynamic_path).unwrap() == "¡Hola john!".into(), name = "john"); } ``` diff --git a/translatable/tests/test.rs b/translatable/tests/test.rs index 8a3783f..e8a3856 100644 --- a/translatable/tests/test.rs +++ b/translatable/tests/test.rs @@ -2,34 +2,31 @@ use translatable::translation; #[test] fn both_static() { - let name = "john"; - let result = translation!("es", static common::greeting); + let result = translation!("es", static common::greeting, name = "john"); assert!(result == "¡Hola john!") } #[test] fn language_static_path_dynamic() { - let name = "john"; - let result = translation!("es", "common.greeting"); + let result = translation!("es", "common.greeting", name = "john"); assert!(result.unwrap() == "¡Hola john!".to_string()) } #[test] fn language_dynamic_path_static() { - let name = "john"; let language = "es"; - let result = translation!(language, static common::greeting); + let name = "john"; + let result = translation!(language, static common::greeting, name = name); assert!(result.unwrap() == "¡Hola john!".to_string()) } #[test] fn both_dynamic() { - let name = "john"; let language = "es"; - let result = translation!(language, "common.greeting"); + let result = translation!(language, "common.greeting", lol = 10, name = "john"); assert!(result.unwrap() == "¡Hola john!".to_string()) } diff --git a/translatable_proc/src/macros.rs b/translatable_proc/src/macros.rs index cab7b92..2ac902b 100644 --- a/translatable_proc/src/macros.rs +++ b/translatable_proc/src/macros.rs @@ -1,8 +1,15 @@ +use std::collections::HashMap; +use std::fmt::Display; + use proc_macro2::TokenStream; -use quote::quote; +use quote::{ToTokens, quote}; use syn::parse::{Parse, ParseStream}; +use syn::punctuated::Punctuated; use syn::token::Static; -use syn::{Expr, ExprLit, ExprPath, Lit, Result as SynResult, Token}; +use syn::{ + Expr, ExprLit, ExprPath, Ident, Lit, MetaNameValue, Path, Result as SynResult, Token, + parse_quote, +}; use crate::translations::generation::{ load_lang_dynamic, load_lang_static, load_translation_dynamic, load_translation_static, @@ -24,6 +31,10 @@ pub struct RawMacroArgs { static_marker: Option, /// Translation path (either static path or dynamic expression) path: Expr, + /// Optional comma separator for additional arguments + _comma2: Option, + /// Format arguments for string interpolation + format_kwargs: Punctuated, } /// Represents the type of translation path resolution @@ -48,15 +59,73 @@ pub struct TranslationArgs { language: LanguageType, /// Path resolution type path: PathType, + /// Format arguments for string interpolation + format_kwargs: HashMap, } impl Parse for RawMacroArgs { fn parse(input: ParseStream) -> SynResult { + let language = input.parse()?; + let _comma = input.parse()?; + let static_marker = input.parse()?; + let path = input.parse()?; + + // Parse optional comma before format arguments + let _comma2 = if input.peek(Token![,]) { Some(input.parse()?) } else { None }; + + let mut format_kwargs = Punctuated::new(); + + // Parse format arguments if comma was present + if _comma2.is_some() { + while !input.is_empty() { + let lookahead = input.lookahead1(); + + // Handle both identifier-based and arbitrary key-value pairs + if lookahead.peek(Ident) { + let key: Ident = input.parse()?; + let eq_token: Token![=] = input.parse().unwrap_or(Token![=](key.span())); + let mut value = input.parse::(); + + if let Ok(value) = &mut value { + let key_string = key.to_string(); + if key_string == value.to_token_stream().to_string() { + // let warning = format!( + // "redundant field initialier, use + // `{key_string}` instead of `{key_string} = {key_string}`" + // ); + + // Generate warning for redundant initializer + *value = parse_quote! {{ + // compile_warn!(#warning); + // !!! https://internals.rust-lang.org/t/pre-rfc-add-compile-warning-macro/9370 !!! + #value + }} + } + } + + let value = value.unwrap_or(parse_quote!(#key)); + + format_kwargs.push(MetaNameValue { path: Path::from(key), eq_token, value }); + } else { + format_kwargs.push(input.parse()?); + } + + // Continue parsing while commas are present + if input.peek(Token![,]) { + input.parse::()?; + } else { + break; + } + } + }; + Ok(RawMacroArgs { - language: input.parse()?, - _comma: input.parse()?, - static_marker: input.parse()?, - path: input.parse()?, + language, + _comma, + static_marker, + path, + _comma2, + format_kwargs, }) } } @@ -68,6 +137,7 @@ impl From for TranslationArgs { TranslationArgs { // Extract language specification language: match val.language { + // Handle string literals for compile-time validation Expr::Lit(ExprLit { lit: Lit::Str(lit_str), .. }) => { LanguageType::CompileTimeLiteral(lit_str.value()) }, @@ -96,6 +166,23 @@ impl From for TranslationArgs { // Preserve dynamic path expressions path => PathType::OnScopeExpression(quote!(#path)), }, + + // Convert format arguments to HashMap with string keys + format_kwargs: val + .format_kwargs + .iter() + .map(|pair| { + ( + // Extract key as identifier or stringified path + pair.path + .get_ident() + .map(|i| i.to_string()) + .unwrap_or_else(|| pair.path.to_token_stream().to_string()), + // Store value as token stream + pair.value.to_token_stream(), + ) + }) + .collect(), } } } @@ -111,7 +198,7 @@ impl From for TranslationArgs { /// - Runtime translation resolution logic /// - Compile errors for invalid inputs pub fn translation_macro(args: TranslationArgs) -> TokenStream { - let TranslationArgs { language, path } = args; + let TranslationArgs { language, path, format_kwargs } = args; // Process language specification let (lang_expr, static_lang) = match language { @@ -129,8 +216,8 @@ pub fn translation_macro(args: TranslationArgs) -> TokenStream { // Process translation path let translation_expr = match path { - PathType::CompileTimePath(p) => load_translation_static(static_lang, p), - PathType::OnScopeExpression(p) => load_translation_dynamic(static_lang, p), + PathType::CompileTimePath(p) => load_translation_static(static_lang, p, format_kwargs), + PathType::OnScopeExpression(p) => load_translation_dynamic(static_lang, p, format_kwargs), }; match (lang_expr, translation_expr) { @@ -142,7 +229,7 @@ pub fn translation_macro(args: TranslationArgs) -> TokenStream { } /// Helper function to create compile error tokens -fn error_token(e: &impl std::fmt::Display) -> TokenStream { +fn error_token(e: &impl Display) -> TokenStream { let msg = format!("{e:#}"); quote! { compile_error!(#msg) } } diff --git a/translatable_proc/src/translations/generation.rs b/translatable_proc/src/translations/generation.rs index 0b6f361..aaddabb 100644 --- a/translatable_proc/src/translations/generation.rs +++ b/translatable_proc/src/translations/generation.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use proc_macro2::TokenStream; use quote::quote; use strum::IntoEnumIterator; @@ -7,6 +9,76 @@ use super::errors::TranslationError; use crate::data::translations::load_translations; use crate::languages::Iso639a; +/// Generates compile-time string replacement logic for a single format +/// argument. +/// +/// Implements a three-step replacement strategy to safely handle nested +/// templates: +/// 1. Temporarily replace `{{key}}` with `\x01{key}\x01` to protect wrapper +/// braces +/// 2. Replace `{key}` with the provided value +/// 3. Restore original `{key}` syntax from temporary markers +/// +/// # Arguments +/// * `key` - Template placeholder name (without braces) +/// * `value` - Expression to substitute, must implement `std::fmt::Display` +/// +/// # Example +/// For key = "name" and value = `user.first_name`: +/// ```rust +/// let template = "{{name}} is a user"; +/// +/// template +/// .replace("{{name}}", "\x01{name}\x01") +/// .replace("{name}", &format!("{:#}", "Juan")) +/// .replace("\x01{name}\x01", "{name}"); +/// ``` +fn kwarg_static_replaces(key: &str, value: &TokenStream) -> TokenStream { + quote! { + .replace( + format!("{{{{{}}}}}", #key).as_str(), // Replace {{key}} -> a temporary placeholder + format!("\x01{{{}}}\x01", #key).as_str() + ) + .replace( + format!("{{{}}}", #key).as_str(), // Replace {key} -> value + format!("{:#}", #value).as_str() + ) + .replace( + format!("\x01{{{}}}\x01", #key).as_str(), // Restore {key} from the placeholder + format!("{{{}}}", #key).as_str() + ) + } +} + +/// Generates runtime-safe template substitution chain for multiple format +/// arguments. +/// +/// Creates an iterator of chained replacement operations that will be applied +/// sequentially at runtime while preserving nested template syntax. +/// +/// # Arguments +/// * `format_kwargs` - Key/value pairs where: +/// - Key: Template placeholder name +/// - Value: Runtime expression implementing `Display` +/// +/// # Note +/// The replacement order is important to prevent accidental substitution in +/// nested templates. All replacements are wrapped in `Option::map` to handle +/// potential `None` values from translation lookup. +fn kwarg_dynamic_replaces(format_kwargs: &HashMap) -> Vec { + format_kwargs + .iter() + .map(|(key, value)| { + let static_replaces = kwarg_static_replaces(key, value); + quote! { + .map(|translation| translation + #static_replaces + ) + } + }) + .collect::>() +} + /// Parses a static language string into an Iso639a enum instance with /// compile-time validation. /// @@ -65,11 +137,13 @@ pub fn load_lang_dynamic(lang: TokenStream) -> Result, path: String, + format_kwargs: HashMap, ) -> Result { let translation_object = load_translations()? .iter() .find_map(|association| association.translation_table().get_path(path.split('.').collect())) .ok_or(TranslationError::PathNotFound(path.to_string()))?; + let replaces = kwarg_dynamic_replaces(&format_kwargs); Ok(match static_lang { Some(language) => { @@ -77,7 +151,15 @@ pub fn load_translation_static( .get(&language) .ok_or(TranslationError::LanguageNotAvailable(language, path))?; - quote! { #translation } + let static_replaces = format_kwargs + .iter() + .map(|(key, value)| kwarg_static_replaces(key, value)) + .collect::>(); + + quote! {{ + #translation + #(#static_replaces)* + }} }, None => { @@ -95,6 +177,7 @@ pub fn load_translation_static( .ok_or(translatable::Error::LanguageNotAvailable(language, #path.to_string())) .cloned() .map(|translation| translation.to_string()) + #(#replaces)* } else { Err(translatable::Error::InvalidLanguage(language)) } @@ -114,6 +197,7 @@ pub fn load_translation_static( pub fn load_translation_dynamic( static_lang: Option, path: TokenStream, + format_kwargs: HashMap, ) -> Result { let nestings = load_translations()? .iter() @@ -137,6 +221,8 @@ pub fn load_translation_dynamic( )); }; + let replaces = kwarg_dynamic_replaces(&format_kwargs); + Ok(match static_lang { Some(language) => { let language = format!("{language:?}").to_lowercase(); @@ -149,6 +235,7 @@ pub fn load_translation_dynamic( .get(#language) .ok_or(translatable::Error::LanguageNotAvailable(#language.to_string(), path)) .cloned() + #(#replaces)* } else { Err(translatable::Error::PathNotFound(path)) } @@ -165,6 +252,7 @@ pub fn load_translation_dynamic( .get(&language) .ok_or(translatable::Error::LanguageNotAvailable(language, path)) .cloned() + #(#replaces)* } else { Err(translatable::Error::PathNotFound(path)) }