Skip to content

Add templating #10

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Mar 28, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 22 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-)
Expand Down Expand Up @@ -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
Expand All @@ -74,15 +77,22 @@ The second parameter consists of the path, which can be passed dynamically as a
that resolves to an `impl Into<String>` 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<String, TranslatableError>` (heap) |
| `static language` + `dynamic path` | Language validity | `Result<String, TranslatableError>` (heap) |
| `dynamic language` + `static path` (commonly used) | Path existence, \*Template validation | `Result<String, TranslatableError>` (heap) |
| `dynamic language` + `static path` (commonly used) | Path existence | `Result<String, TranslatableError>` (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
Expand All @@ -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
Expand Down Expand Up @@ -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");
}
```

13 changes: 5 additions & 8 deletions translatable/tests/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
107 changes: 97 additions & 10 deletions translatable_proc/src/macros.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -24,6 +31,10 @@ pub struct RawMacroArgs {
static_marker: Option<Static>,
/// Translation path (either static path or dynamic expression)
path: Expr,
/// Optional comma separator for additional arguments
_comma2: Option<Token![,]>,
/// Format arguments for string interpolation
format_kwargs: Punctuated<MetaNameValue, Token![,]>,
}

/// Represents the type of translation path resolution
Expand All @@ -48,15 +59,73 @@ pub struct TranslationArgs {
language: LanguageType,
/// Path resolution type
path: PathType,
/// Format arguments for string interpolation
format_kwargs: HashMap<String, TokenStream>,
}

impl Parse for RawMacroArgs {
fn parse(input: ParseStream) -> SynResult<Self> {
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::<Expr>();

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::<Token![,]>()?;
} 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,
})
}
}
Expand All @@ -68,6 +137,7 @@ impl From<RawMacroArgs> 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())
},
Expand Down Expand Up @@ -96,6 +166,23 @@ impl From<RawMacroArgs> 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(),
}
}
}
Expand All @@ -111,7 +198,7 @@ impl From<RawMacroArgs> 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 {
Expand All @@ -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) {
Expand All @@ -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) }
}
Loading