From 68bf3035b715dba7e73912cab6f6284f182cf963 Mon Sep 17 00:00:00 2001 From: stifskere Date: Thu, 27 Mar 2025 13:51:56 +0100 Subject: [PATCH 1/7] feat: kwarg parsing for format --- translatable/tests/test.rs | 2 +- translatable_proc/src/macros.rs | 87 +++++++++++++++++-- .../src/translations/generation.rs | 4 + 3 files changed, 83 insertions(+), 10 deletions(-) diff --git a/translatable/tests/test.rs b/translatable/tests/test.rs index 8a3783f..8f1965d 100644 --- a/translatable/tests/test.rs +++ b/translatable/tests/test.rs @@ -29,7 +29,7 @@ fn language_dynamic_path_static() { fn both_dynamic() { let name = "john"; let language = "es"; - let result = translation!(language, "common.greeting"); + let result = translation!(language, "common.greeting", lol = 10, name); assert!(result.unwrap() == "¡Hola john!".to_string()) } diff --git a/translatable_proc/src/macros.rs b/translatable_proc/src/macros.rs index cab7b92..a4c8b7e 100644 --- a/translatable_proc/src/macros.rs +++ b/translatable_proc/src/macros.rs @@ -1,8 +1,11 @@ +use std::collections::HashMap; + use proc_macro2::TokenStream; -use quote::quote; +use quote::{quote, ToTokens}; 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::{parse_quote, Expr, ExprLit, ExprPath, Lit, MetaNameValue, Path, Result as SynResult, Token, Ident}; use crate::translations::generation::{ load_lang_dynamic, load_lang_static, load_translation_dynamic, load_translation_static, @@ -24,6 +27,10 @@ pub struct RawMacroArgs { static_marker: Option, /// Translation path (either static path or dynamic expression) path: Expr, + + _comma2: Option, + + format_kwargs: Punctuated, } /// Represents the type of translation path resolution @@ -48,15 +55,58 @@ pub struct TranslationArgs { language: LanguageType, /// Path resolution type path: PathType, + + 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()?; + + let _comma2 = if input.peek(Token![,]) { + Some(input.parse()?) + } else { + None + }; + + let mut format_kwargs = Punctuated::new(); + + if _comma2.is_some() { + while !input.is_empty() { + let lookahead = input.lookahead1(); + + if lookahead.peek(Ident) { + let key: Ident = input.parse()?; + let eq_token: Token![=] = input.parse().unwrap_or(Token![=](key.span())); + let value: Expr = input.parse().unwrap_or(parse_quote!(#key)); + + format_kwargs.push(MetaNameValue { + path: Path::from(key), + eq_token, + value + }); + } else { + format_kwargs.push(input.parse()?); + } + + 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, }) } } @@ -96,6 +146,25 @@ impl From for TranslationArgs { // Preserve dynamic path expressions path => PathType::OnScopeExpression(quote!(#path)), }, + + format_kwargs: val + .format_kwargs + .iter() + .map(|pair| ( + pair + .path + .get_ident() + .map(|i| i.to_string()) + .unwrap_or_else(|| pair + .path + .to_token_stream() + .to_string() + ), + pair + .value + .to_token_stream() + )) + .collect() } } } @@ -111,7 +180,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 +198,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) { diff --git a/translatable_proc/src/translations/generation.rs b/translatable_proc/src/translations/generation.rs index 0b6f361..3d3f900 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; @@ -65,6 +67,7 @@ pub fn load_lang_dynamic(lang: TokenStream) -> Result, path: String, + format_kwargs: HashMap ) -> Result { let translation_object = load_translations()? .iter() @@ -114,6 +117,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() From 9573dadc271c5dfc69a4911c4a6c347b9ab35ec7 Mon Sep 17 00:00:00 2001 From: stifskere Date: Thu, 27 Mar 2025 16:01:48 +0100 Subject: [PATCH 2/7] feat: implement templating on the macros --- translatable/tests/test.rs | 12 ++---- translatable_proc/src/macros.rs | 3 +- .../src/translations/generation.rs | 43 ++++++++++++++++++- 3 files changed, 47 insertions(+), 11 deletions(-) diff --git a/translatable/tests/test.rs b/translatable/tests/test.rs index 8f1965d..fca37d3 100644 --- a/translatable/tests/test.rs +++ b/translatable/tests/test.rs @@ -2,34 +2,30 @@ 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 result = translation!(language, static common::greeting, name = "john"); assert!(result.unwrap() == "¡Hola john!".to_string()) } #[test] fn both_dynamic() { - let name = "john"; let language = "es"; - let result = translation!(language, "common.greeting", lol = 10, name); + 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 a4c8b7e..1a4267d 100644 --- a/translatable_proc/src/macros.rs +++ b/translatable_proc/src/macros.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::fmt::Display; use proc_macro2::TokenStream; use quote::{quote, ToTokens}; @@ -211,7 +212,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 3d3f900..b65a347 100644 --- a/translatable_proc/src/translations/generation.rs +++ b/translatable_proc/src/translations/generation.rs @@ -3,12 +3,26 @@ use std::collections::HashMap; use proc_macro2::TokenStream; use quote::quote; use strum::IntoEnumIterator; -use syn::{Expr, parse2}; +use syn::{parse2, parse_str, Error as SynError, Expr, Ident}; use super::errors::TranslationError; use crate::data::translations::load_translations; use crate::languages::Iso639a; +fn kwarg_dynamic_replaces(format_kwargs: HashMap) -> Vec { + format_kwargs + .iter() + .map(|(key, value)| + quote! { + .map(|translation| translation.replace( + format!("{{{}}}", #key).as_str(), + format!("{:#}", #value).as_str() + )) + } + ) + .collect::>() +} + /// Parses a static language string into an Iso639a enum instance with /// compile-time validation. /// @@ -80,7 +94,26 @@ pub fn load_translation_static( .get(&language) .ok_or(TranslationError::LanguageNotAvailable(language, path))?; - quote! { #translation } + if format_kwargs.is_empty() { + quote! { #translation } + } else { + let format_kwargs = format_kwargs + .iter() + .map(|(key, value)| { + let key: Ident = parse_str(&key)?; + Ok::(quote! { + #[doc(hidden)] + let #key = #value; + }) + }) + .collect::, _>>()?; + + quote! {{ + #(#format_kwargs)* + + format!(#translation) + }} + } }, None => { @@ -88,6 +121,7 @@ pub fn load_translation_static( let key = format!("{key:?}").to_lowercase(); quote! { (#key, #value) } }); + let replaces = kwarg_dynamic_replaces(format_kwargs); quote! {{ if valid_lang { @@ -98,6 +132,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)) } @@ -141,6 +176,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(); @@ -153,6 +190,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)) } @@ -169,6 +207,7 @@ pub fn load_translation_dynamic( .get(&language) .ok_or(translatable::Error::LanguageNotAvailable(language, path)) .cloned() + #(#replaces)* } else { Err(translatable::Error::PathNotFound(path)) } From 18764dc12d45a0452f7fb500ea27fb88b5b55606 Mon Sep 17 00:00:00 2001 From: stifskere Date: Thu, 27 Mar 2025 16:31:42 +0100 Subject: [PATCH 3/7] feat: possibly inefficient {} escaping --- .../src/translations/generation.rs | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/translatable_proc/src/translations/generation.rs b/translatable_proc/src/translations/generation.rs index b65a347..b1e4ee0 100644 --- a/translatable_proc/src/translations/generation.rs +++ b/translatable_proc/src/translations/generation.rs @@ -14,10 +14,20 @@ fn kwarg_dynamic_replaces(format_kwargs: HashMap) -> Vec 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() + ) + ) } ) .collect::>() From 471200955a9a757819885ab83d000683daa0253e Mon Sep 17 00:00:00 2001 From: stifskere Date: Thu, 27 Mar 2025 17:35:48 +0100 Subject: [PATCH 4/7] docs: readme --- README.md | 41 +++++++++++++++++++++++++---------------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index f966d49..c856ddd 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,6 +77,12 @@ 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 `{{}}`. + 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. @@ -82,7 +91,7 @@ the checks are compile-time or run-time, the following table is a macro behavior | `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. | | `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,10 +100,11 @@ 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. +- Template validation in static parameter handling means purely variable existence, an all-static invocation +generates a quoted translation (`""`), essentially the same value you can find in your translation file, so if the +invocation is all-static the macro will generate a `format!` call, which implicitly validates the variable +existence, if the variable is found outer scope the macro may use that. In the case where any of the +parameters is dynamic, the macro will return an error if some replacement couldn't be found. ## Example implementation 📂 @@ -129,22 +139,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"); } ``` From 15034253474096c7da4440bf488e28eeaca08566 Mon Sep 17 00:00:00 2001 From: stifskere Date: Thu, 27 Mar 2025 19:20:17 +0100 Subject: [PATCH 5/7] feat: completly remove template validation, as it's kinda pointless. --- README.md | 11 +-- .../src/translations/generation.rs | 72 +++++++++---------- 2 files changed, 37 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index c856ddd..190a105 100644 --- a/README.md +++ b/README.md @@ -81,14 +81,15 @@ The rest of parameters are `meta-variable patterns` also known as `key = value` 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 `{{}}`. +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 | `Result` (heap) | @@ -100,12 +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 static parameter handling means purely variable existence, an all-static invocation -generates a quoted translation (`""`), essentially the same value you can find in your translation file, so if the -invocation is all-static the macro will generate a `format!` call, which implicitly validates the variable -existence, if the variable is found outer scope the macro may use that. In the case where any of the -parameters is dynamic, the macro will return an error if some replacement couldn't be found. - ## Example implementation 📂 The following examples are an example application structure for a possible diff --git a/translatable_proc/src/translations/generation.rs b/translatable_proc/src/translations/generation.rs index b1e4ee0..c7c1a14 100644 --- a/translatable_proc/src/translations/generation.rs +++ b/translatable_proc/src/translations/generation.rs @@ -3,33 +3,40 @@ use std::collections::HashMap; use proc_macro2::TokenStream; use quote::quote; use strum::IntoEnumIterator; -use syn::{parse2, parse_str, Error as SynError, Expr, Ident}; +use syn::{parse2, Expr}; use super::errors::TranslationError; use crate::data::translations::load_translations; use crate::languages::Iso639a; -fn kwarg_dynamic_replaces(format_kwargs: HashMap) -> Vec { +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() + ) + } +} + +fn kwarg_dynamic_replaces(format_kwargs: &HashMap) -> Vec { format_kwargs .iter() - .map(|(key, value)| + .map(|(key, value)| { + let static_replaces = kwarg_static_replaces(key, value); quote! { .map(|translation| translation - .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() - ) + #static_replaces ) } - ) + }) .collect::>() } @@ -97,6 +104,7 @@ pub fn load_translation_static( .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) => { @@ -104,26 +112,15 @@ pub fn load_translation_static( .get(&language) .ok_or(TranslationError::LanguageNotAvailable(language, path))?; - if format_kwargs.is_empty() { - quote! { #translation } - } else { - let format_kwargs = format_kwargs - .iter() - .map(|(key, value)| { - let key: Ident = parse_str(&key)?; - Ok::(quote! { - #[doc(hidden)] - let #key = #value; - }) - }) - .collect::, _>>()?; - - quote! {{ - #(#format_kwargs)* - - format!(#translation) - }} - } + let static_replaces = format_kwargs + .iter() + .map(|(key, value)| kwarg_static_replaces(key, value)) + .collect::>(); + + quote! {{ + #translation + #(#static_replaces)* + }} }, None => { @@ -131,7 +128,6 @@ pub fn load_translation_static( let key = format!("{key:?}").to_lowercase(); quote! { (#key, #value) } }); - let replaces = kwarg_dynamic_replaces(format_kwargs); quote! {{ if valid_lang { @@ -186,7 +182,7 @@ pub fn load_translation_dynamic( )); }; - let replaces = kwarg_dynamic_replaces(format_kwargs); + let replaces = kwarg_dynamic_replaces(&format_kwargs); Ok(match static_lang { Some(language) => { From 953d5f939a9fa1391bd0100b2df376f1655b6696 Mon Sep 17 00:00:00 2001 From: stifskere Date: Thu, 27 Mar 2025 19:57:10 +0100 Subject: [PATCH 6/7] feat: pre process compile warning, wait for addition in the std. --- translatable/tests/test.rs | 3 ++- translatable_proc/src/macros.rs | 19 ++++++++++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/translatable/tests/test.rs b/translatable/tests/test.rs index fca37d3..e8a3856 100644 --- a/translatable/tests/test.rs +++ b/translatable/tests/test.rs @@ -17,7 +17,8 @@ fn language_static_path_dynamic() { #[test] fn language_dynamic_path_static() { let language = "es"; - let result = translation!(language, static common::greeting, name = "john"); + let name = "john"; + let result = translation!(language, static common::greeting, name = name); assert!(result.unwrap() == "¡Hola john!".to_string()) } diff --git a/translatable_proc/src/macros.rs b/translatable_proc/src/macros.rs index 1a4267d..8e0ed62 100644 --- a/translatable_proc/src/macros.rs +++ b/translatable_proc/src/macros.rs @@ -82,7 +82,24 @@ impl Parse for RawMacroArgs { if lookahead.peek(Ident) { let key: Ident = input.parse()?; let eq_token: Token![=] = input.parse().unwrap_or(Token![=](key.span())); - let value: Expr = input.parse().unwrap_or(parse_quote!(#key)); + 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}`" +// ); + + *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), From 385b4a43bdd7fdfac7862895a988161b7c2b59ed Mon Sep 17 00:00:00 2001 From: Chiko Date: Fri, 28 Mar 2025 16:20:30 +0000 Subject: [PATCH 7/7] docs: kwargs documentation --- translatable_proc/src/macros.rs | 70 +++++++++---------- .../src/translations/generation.rs | 45 +++++++++++- 2 files changed, 77 insertions(+), 38 deletions(-) diff --git a/translatable_proc/src/macros.rs b/translatable_proc/src/macros.rs index 8e0ed62..2ac902b 100644 --- a/translatable_proc/src/macros.rs +++ b/translatable_proc/src/macros.rs @@ -2,11 +2,14 @@ use std::collections::HashMap; use std::fmt::Display; use proc_macro2::TokenStream; -use quote::{quote, ToTokens}; +use quote::{ToTokens, quote}; use syn::parse::{Parse, ParseStream}; use syn::punctuated::Punctuated; use syn::token::Static; -use syn::{parse_quote, Expr, ExprLit, ExprPath, Lit, MetaNameValue, Path, Result as SynResult, Token, Ident}; +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, @@ -28,9 +31,9 @@ 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, } @@ -56,8 +59,8 @@ pub struct TranslationArgs { language: LanguageType, /// Path resolution type path: PathType, - - format_kwargs: HashMap + /// Format arguments for string interpolation + format_kwargs: HashMap, } impl Parse for RawMacroArgs { @@ -67,18 +70,17 @@ impl Parse for RawMacroArgs { let static_marker = input.parse()?; let path = input.parse()?; - let _comma2 = if input.peek(Token![,]) { - Some(input.parse()?) - } else { - None - }; + // Parse optional comma before format arguments + let _comma2 = if input.peek(Token![,]) { Some(input.parse()?) } else { None }; let mut format_kwargs = Punctuated::new(); - if _comma2.is_some() { + // 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())); @@ -87,10 +89,12 @@ impl Parse for RawMacroArgs { 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}`" -// ); + // 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 !!! @@ -101,15 +105,12 @@ impl Parse for RawMacroArgs { let value = value.unwrap_or(parse_quote!(#key)); - format_kwargs.push(MetaNameValue { - path: Path::from(key), - eq_token, - value - }); + 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 { @@ -136,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()) }, @@ -165,24 +167,22 @@ impl From for TranslationArgs { path => PathType::OnScopeExpression(quote!(#path)), }, + // Convert format arguments to HashMap with string keys format_kwargs: val .format_kwargs .iter() - .map(|pair| ( - pair - .path - .get_ident() - .map(|i| i.to_string()) - .unwrap_or_else(|| pair - .path - .to_token_stream() - .to_string() - ), - pair - .value - .to_token_stream() - )) - .collect() + .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(), } } } diff --git a/translatable_proc/src/translations/generation.rs b/translatable_proc/src/translations/generation.rs index c7c1a14..aaddabb 100644 --- a/translatable_proc/src/translations/generation.rs +++ b/translatable_proc/src/translations/generation.rs @@ -3,12 +3,36 @@ use std::collections::HashMap; use proc_macro2::TokenStream; use quote::quote; use strum::IntoEnumIterator; -use syn::{parse2, Expr}; +use syn::{Expr, parse2}; 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( @@ -26,6 +50,21 @@ fn kwarg_static_replaces(key: &str, value: &TokenStream) -> TokenStream { } } +/// 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() @@ -98,7 +137,7 @@ pub fn load_lang_dynamic(lang: TokenStream) -> Result, path: String, - format_kwargs: HashMap + format_kwargs: HashMap, ) -> Result { let translation_object = load_translations()? .iter() @@ -158,7 +197,7 @@ pub fn load_translation_static( pub fn load_translation_dynamic( static_lang: Option, path: TokenStream, - format_kwargs: HashMap + format_kwargs: HashMap, ) -> Result { let nestings = load_translations()? .iter()