fluent-zero-build 0.2.0

Build-time code generator for fluent-zero. It compiles Fluent (.ftl) files into static, zero-allocation Rust code and Perfect Hash Maps.
Documentation

fluent-zero

Crates.io Docs.rs License

Zero-allocation, high-performance Fluent localization for Rust.

fluent-zero is a specialized localization loader designed for high-performance applications, such as GUI clients (egui, iced, winit) and Game Development (Bevy, Fyrox).

Unlike other loaders that prioritize template engine integration or hot-reloading, fluent-zero prioritizes runtime speed and memory efficiency. It generates static code at build time to allow for O(1) lookups that return &'static str whenever possible, eliminating the heap allocation overhead typical of localization libraries.

⚑ Why fluent-zero?

Most Fluent implementations (like fluent-templates) wrap the standard fluent-bundle. When you request a translation, they look it up in a HashMap, parse the pattern, and allocate a new String on the heap to return the resultβ€”even if the text is static.

In an immediate-mode GUI (like egui) running at 60 FPS, looking up 50 strings per frame results in 3,000 allocations per second. This causes allocator contention and Garbage Collection-like micro-stutters.

fluent-zero solves this by pre-computing the cache at compile time.

Feature fluent-templates fluent-zero
Static Text Lookup Heap Allocation (String) Zero Allocation (&'static str)
Lookup Speed HashMap + AST traversal Perfect Hash Function (PHF)
Memory Usage Full AST loaded on start Lazy / Zero-Cost Abstraction
Best For Web Servers (Tera/Askama) Desktop GUIs & Games

πŸš€ Usage

1. Installation

You need both the runtime library and the build-time code generator.

[dependencies]
fluent-zero = "0.2"
unic-langid = "0.9"

[build-dependencies]
fluent-zero-build = "0.2"

2. File Structure

Organize your Fluent files using standard locale directories:

assets/
└── locales/
    β”œβ”€β”€ en-US/
    β”‚   └── main.ftl
    β”œβ”€β”€ fr-FR/
    β”‚   └── main.ftl
    └── de/
        └── main.ftl

3. Build Script (build.rs)

Configure the code generator to read your locales directory. This will generate the static PHF maps and Rust code required for the zero-allocation cache inside your OUT_DIR.

fn main() {
    // Generates static_cache.rs in your OUT_DIR
    fluent_zero_build::generate_static_cache("assets/locales");
}

For more control, use the builder. try_generate() returns a Result<(), fluent_zero_build::Error> (missing OUT_DIR, unreadable files, FTL syntax errors, duplicate message IDs, an invalid or missing fallback locale, missing translations under MissingTranslations::Deny) instead of panicking like generate():

fn main() -> Result<(), Box<dyn std::error::Error>> {
    fluent_zero_build::FluentZeroBuilder::new("assets/locales")
        // Only needed if fluent-zero is renamed in Cargo.toml,
        // e.g. `fz = { package = "fluent-zero", version = "..." }`.
        .crate_path("::fz")
        .try_generate()?;
    Ok(())
}

Keep fluent-zero and fluent-zero-build on matching releases. The generated code checks that it was produced for the runtime it is compiled against; a mismatched pair fails to compile with a message saying which crate to upgrade.

4. Application Code

In your lib.rs (or main.rs), you must include the generated file. This brings the CATALOG static (plus the plain CACHE and LOCALES maps, the FALLBACK locale and the key list used for compile-time key checking) into scope, which the t! macro relies on.

use fluent_zero::{t, set_lang};

// 1. Include the generated code from build.rs
include!(concat!(env!("OUT_DIR"), "/static_cache.rs"));

fn main() {
    // 2. (Optional) Set the runtime language. Defaults to en-US.
    // The parse() method comes from unic_langid::LanguageIdentifier
    set_lang("fr-FR".parse().expect("Invalid lang ID"));

    // 3. Use the t! macro for lookups.

    // CASE A: Static String
    // Returns &'static str. ZERO ALLOCATION.
    let title = t!("app-title");

    // CASE B: Dynamic String (with variables)
    // Returns Cow<'static, str>. Allocates only if variables are resolved.
    let welcome = t!("welcome-user", {
        "name" => "Alice",
        "unread_count" => 5
    });

    // CASE C: Attributes, addressed as `message.attribute`
    // Static attributes are zero-allocation too; arguments work as in CASE B.
    let tooltip = t!("login.tooltip");

    println!("{}", title);
    println!("{}", welcome);
    println!("{}", tooltip);
}

Attributes (.tooltip, .placeholder, .aria-label, ...) are looked up with the conventional message.attribute key and follow the same fallback as message values: the current language's negotiated fallback chain, then the fallback locale (en-US by default), then the key itself. t_in! resolves them the same way against its LocaleState. A message with only attributes (no value) works for its attributes:

login = Log in
    .tooltip = Sign in to your account
search =
    .placeholder = Search

5. Locale Negotiation & Fallback

set_lang resolves a fallback chain once, so lookups never parse or negotiate. The chain drops variants, script and region in turn (sr-Latn-RS β†’ sr-Latn β†’ sr-RS β†’ sr), and the build step maps each language (and script-implying tags such as zh-TW) to the best locale your crate actually ships, using fluent-langneg with CLDR likely subtags. So with these directories:

set_lang(...) en-US/, fr-FR/, pt-PT/, zh-Hant/
fr, fr-CA fr-FR
pt-BR pt-PT
zh-TW, zh-HK zh-Hant
de the fallback locale (en-US)

A message missing from the chosen locale comes from the fallback locale, then the key itself. The fallback defaults to en-US; change it in build.rs (the build fails if no such locale directory exists, so a typo cannot silently disable it):

fn main() {
    fluent_zero_build::FluentZeroBuilder::new("assets/locales")
        .fallback_locale("en-GB")
        .generate();
}

The generated code exports it as FALLBACK.

6. Per-Call Locale (servers, multi-user tools)

t! reads the global language. To render for several users at once, build a LocaleState per user (or request) and use t_in!, which bypasses the global language entirely:

use fluent_zero::{LocaleState, t_in};

let lang = LocaleState::new("pt-BR".parse()?); // resolves the chain once
let title = t_in!(&lang, "app-title");          // still zero-allocation
let welcome = t_in!(&lang, "welcome-user", { "name" => "Alice" });

7. Compile-Time Key Checking

A mistyped key never reaches your users as a raw key on screen; it fails the build.

String literals are checked by t! and t_in!. Every literal key is checked at compile time against the message IDs and message.attribute keys found in any locale, on stable Rust and without a proc-macro. Existing t!("literal") and t_in!(&lang, "literal") calls compile unchanged, and the check costs nothing at run time (it runs in a const item: a binary search over a sorted key list the build script generates).

let title = t!("app-titel");
// error[E0080]: evaluation panicked: unknown Fluent message key passed to `t!`: app-titel

Only literals are checked: a key held in a variable or constant is looked up at run time as before. To pass a key that is deliberately missing from every locale, bind it to a variable first.

Every key is also a constant. The generated code contains a keys module with one pub const per message ID and per attribute key (login.tooltip becomes LOGIN_TOOLTIP), documented with its source in the fallback locale (en-US unless configured), so your IDE can autocomplete keys and show the text on hover:

let title = t!(keys::APP_TITLE);
let welcome = t!(keys::WELCOME_USER, { "name" => "Alice" });
let tooltip = t!(keys::LOGIN_TOOLTIP);

Constant names are derived deterministically:

  • ASCII letters are uppercased and ASCII digits kept; every other character (-, _, ., anything non-ASCII) becomes _, so app-title becomes APP_TITLE.
  • A name that would not start with an ASCII letter is prefixed with KEY_.
  • If several IDs map to the same name (foo-bar and foo_bar), they are sorted by byte order: the first keeps the plain name and each later one gets _2, _3, ... (skipping any name that another ID maps to on its own), and the build prints a cargo:warning naming the constant it received.

Note: the generated file now defines a keys module and a hidden __FLUENT_ZERO_KEYS constant next to CATALOG, CACHE and LOCALES, so it cannot be included into a module that already has its own item named keys. Keep fluent-zero and fluent-zero-build on matching releases: the generated code checks that it was produced for the runtime it is compiled against.

8. Public API

fluent_zero::get_lang() returns an Arc<LocaleState> snapshot of the active language; .id() gives the LanguageIdentifier, .key() its string form (e.g. "fr-FR") and .chain() the locale keys a lookup tries.

The public API of fluent-zero is t!, t_in!, set_lang, get_lang, LocaleState, Catalog (the type of the generated CATALOG), CacheEntry, DEFAULT_FALLBACK, join_charsets, the diagnostics (set_error_handler, clear_error_handler, LookupError, FormatErrors, ErrorHandler) and the re-exported LanguageIdentifier and FluentArgs. The hidden fluent_zero::__private module exists for the macros and the generated code and may change in any release.

🩺 Diagnostics

Runtime errors

Lookups never panic: a missing key renders as the key itself, and a missing argument renders as {$name}. To see these problems in QA or telemetry, install a handler. It is a plain fn pointer, read only when something went wrong, so successful lookups (and the static fast path) cost exactly the same with or without it.

use fluent_zero::{LookupError, set_error_handler};

set_error_handler(|error: &LookupError| match error {
    LookupError::MissingKey { key, lang } => eprintln!("untranslated {key} ({lang})"),
    other => eprintln!("i18n: {other}"),
});

LookupError covers a key found in neither the current nor the fallback language (MissingKey; for a message.attribute key this includes a message that lacks that attribute), a bare lookup of a message that has only attributes (MissingValue; look up message.attribute instead), and partial formatting such as a missing argument (Format, whose errors field lists the fluent-bundle errors as std::error::Errors). A key missing from the current language but found through its fallback chain or in the fallback locale is not an error. Misses through t_in! are reported too, with the LocaleState's language. clear_error_handler() removes the handler.

Alternatively, enable the off-by-default tracing feature to log every LookupError with tracing::warn! (target fluent_zero):

fluent-zero = { version = "0.2", features = ["tracing"] }

Build errors

FluentZeroBuilder::try_generate() returns every build failure as a fluent_zero_build::Error (Parse, DuplicateId, InvalidFallbackLocale, MissingFallbackLocale, MissingTranslations, Io, ...); generate() panics with the same message, prefixed with fluent-zero-build: . An .ftl syntax error reports the file, line and column of each error and the offending line:

fluent-zero-build: failed to parse assets/locales/en-US/main.ftl:
assets/locales/en-US/main.ftl:2:8: Expected a token starting with "="
2 | broken Value
  |        ^

Translation coverage

The builder warns, once per locale, about message IDs present in the fallback locale (en-US unless configured with .fallback_locale(...)) but missing from another locale (those fall back to it at runtime). Coverage is per message: attributes are not counted separately.

warning: my-app@0.1.0: fr-FR is missing 12 of 40 messages from en-US: about, greeting, menu-file, menu-open, title and 7 more

Turn this into a build error, or silence it:

use fluent_zero_build::{FluentZeroBuilder, MissingTranslations};

FluentZeroBuilder::new("assets/locales")
    .missing_translations(MissingTranslations::Deny) // or Allow; default Warn
    .generate();

πŸ“¦ Library Support & Nested Translations

fluent-zero supports a modular architecture where libraries and dependencies manage their own translations independently, but share their end results with the caller.

While the translation data is isolated per crate (compile-time), the language selection is global (runtime). When your application calls fluent_zero::set_lang("fr-FR"), all UI plugins, logging dependencies, and nested widgets will instantly switch contexts without manual propagation. Each crate negotiates that language against its own locales and uses its own fallback locale, so a library shipping only fr/ still answers fr-FR.


πŸ”  Enterprise Font Subsetting (DAG IPC)

When deploying to environments (like WASM or games) that bundle custom fonts, you must reliably compute the unique characters used across all your dependencies to prevent missing glyphs at runtime.

fluent-zero achieves this with a hermetic, build-system-agnostic pipeline (100% compatible with sccache, Bazel, and Nix). It utilizes Cargo's native IPC (Inter-Package Communication) via the DAG to safely bubble up characters from dependencies without requiring brittle cargo_metadata JSON scraping or workspace directory-walking.

Step 1: Opt-in your Dependencies

For Cargo to authorize data bubbling up to your main application, any UI dependency or plugin using fluent-zero must declare a globally unique links key in its Cargo.toml.

[package]
name = "my-ui-library"
version = "0.1.0"
links = "my_ui_library" # <-- **REQUIRED FOR IPC BUBBLING**

Step 2: Configure the Application build.rs

In your top-level application, configure fluent-zero-build to export the charset. The builder will automatically read the injected IPC variables from Cargo (DEP_<LINKS>_FLUENT_CHARSET_PATH) and merge all dependency characters into a single master file.

use std::{env, path::Path, process::Command};

use anyhow::Context as _;

fn main() -> anyhow::Result<()> {
    let out_dir = env::var("OUT_DIR").context("OUT_DIR environment variable not set")?;
    let out_dir_path = Path::new(&out_dir);
    let charset_path = out_dir_path.join("fluent_chars.txt");

    // 1. Enterprise Dependency Aggregation via Cargo IPC
    // The builder natively aggregates character sets exposed by dependencies.
    fluent_zero_build::FluentZeroBuilder::new("assets/locales")
        .export_charset(&charset_path)
        .generate();

    // 2. Run the Python Subsetter securely locked inside the OUT_DIR phase
    let manifest_dir = env::var("CARGO_MANIFEST_DIR").context("CARGO_MANIFEST_DIR not set")?;
    let script_path = Path::new(&manifest_dir).join("scripts/subset_fonts.py");
    let fonts_dir = Path::new(&manifest_dir).join("assets/fonts");

    let status = Command::new("python3")
        .arg(&script_path)
        .arg(&charset_path)
        .arg(&fonts_dir)
        .arg(out_dir_path)
        .status()
        .context("Failed to execute python subsetting script")?;

    anyhow::ensure!(
        status.success(),
        "Python font subsetting failed with exit status: {status}"
    );

    // Tell Cargo to re-run this script if fonts or scripts change
    println!("cargo:rerun-if-changed={}", fonts_dir.display());
    println!("cargo:rerun-if-changed={}", script_path.display());

    Ok(())
}

Write the exported file inside OUT_DIR, as above. A relative path is resolved against the package root, which writes into your source tree and breaks hermetic builds (sccache, Bazel, Nix); the builder still honours it, but emits a cargo:warning.

Limitations of links

links was designed for native libraries, and two of its rules carry over:

  • One package per links value in the whole dependency graph. Cargo refuses to build a graph in which two packages declare the same links key, so two semver-incompatible versions of the same UI library (say my-ui-library 1.x and 2.x pulled in by different dependents) cannot coexist.
  • DEP_<LINKS>_* variables only reach direct dependents. If your application depends on widgets, which depends on my-ui-library, the application never sees my-ui-library's charset unless widgets also runs fluent-zero-build in its build script (and declares its own links key). Its builder merges the dependency's characters into its own exported charset and relays them upward, even if widgets has no .ftl files itself.

Alternative without links

Every crate that runs the builder also gets a CHARSET constant in its generated code. A library can re-export it (include! already makes it pub), and the application can join them at runtime or in a build step with fluent_zero::join_charsets, with no links key anywhere:

let complete_charset = fluent_zero::join_charsets(&[
    crate::CHARSET,
    my_ui_library::CHARSET,
    widgets::CHARSET,
]);

The trade-off is that the application has to list each crate explicitly, and the joined set is only available to code that can depend on those crates (not to the application's own build script).

Step 3: The Python Subsetter Script

You will need the fonttools library (pip install fonttools) to strip out unneeded glyphs. Place this production-ready script in scripts/subset_fonts.py.

#!/usr/bin/env python3
import sys
import subprocess
from pathlib import Path

def main():
    if len(sys.argv) != 4:
        print("Usage: subset_fonts.py <charset_path> <fonts_dir> <out_dir>")
        sys.exit(1)

    charset_path = Path(sys.argv[1]).resolve()
    fonts_dir = Path(sys.argv[2]).resolve()
    out_dir = Path(sys.argv[3]).resolve()

    if not charset_path.exists():
        sys.exit(f"Error: Charset file not found at {charset_path}")

    # Read the unified character set
    text = charset_path.read_text(encoding="utf-8")

    # Enterprise Safety: Always include basic ASCII (32-126) for debug text and fallbacks
    basic_ascii = "".join(chr(i) for i in range(32, 127))
    master_text = text + basic_ascii

    # Write out a temporary file for pyftsubset to consume safely
    out_dir.mkdir(parents=True, exist_ok=True)
    temp_text_path = out_dir / "subset_target.txt"
    temp_text_path.write_text(master_text, encoding="utf-8")

    # Iterate and subset all fonts in the directory
    for font_file in fonts_dir.glob("*.ttf"):
        out_font = out_dir / font_file.name

        # pyftsubset CLI arguments for safe GUI subsetting
        args = [
            "pyftsubset",
            str(font_file),
            f"--text-file={temp_text_path}",
            f"--output-file={out_font}",
            "--layout-features=*",
            "--glyph-names",
            "--symbol-cmap",
            "--legacy-cmap",
            "--notdef-glyph",
            "--notdef-outline",
            "--recommended-glyphs",
            "--name-IDs=*",
            "--name-legacy",
            "--name-languages=*",
            "--desubroutinize"
        ]

        print(f"Subsetting {font_file.name} -> {out_font.name}...")
        subprocess.run(args, check=True)

if __name__ == "__main__":
    main()

Your optimized .ttf files are now securely located in OUT_DIR and can be seamlessly embedded into your binary using include_bytes!(concat!(env!("OUT_DIR"), "/my_font.ttf")).


↔️ Bidi Isolation Marks

Like fluent-bundle, the generated code wraps placeables ({ $name }, { 5 }) in the Unicode isolation marks U+2068 / U+2069 so right-to-left arguments cannot scramble left-to-right text, and vice versa. When enabled, the marks are added to CHARSET if any message can emit them. Many GUI font stacks render them as boxes or odd gaps; if your UI is left-to-right only, turn them off:

fluent_zero_build::FluentZeroBuilder::new("assets/locales")
    .use_isolating(false)
    .generate();

The setting applies to static strings formatted at build time and to the runtime bundles alike, so both stay byte-identical.


🧠 How it Works

  1. Build Time: fluent-zero-build scans your .ftl files. It identifies which messages are static (their output cannot depend on runtime arguments: plain or multiline text, string and number literals, references to other static messages and terms, and selects over static selectors) and which are dynamic. Static messages are formatted at build time by the same fluent-bundle the runtime uses, so the embedded string is byte-identical to what the runtime would produce.
  2. Code Gen: It generates a Rust module containing Perfect Hash Maps (via phf) for every locale.
  • Static messages are compiled directly into the binary's read-only data section (.rodata).
  • Dynamic messages are stored as raw FTL strings, wrapped in LazyLock.
  1. Run Time:
  • When you call t!("hello"), fluent-zero checks the PHF map.
  • If it finds a static entry, it returns a reference to the binary data instantly. No parsing. No allocation.
  • If it finds a dynamic entry, it initializes the heavy FluentBundle (only once) and performs the variable substitution.

πŸ› οΈ Example: using with egui

This crate shines in immediate mode GUIs. Because t! returns Cow<'static, str>, you can pass the result directly to widgets without .to_string() clones.

impl eframe::App for MyApp {
    fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
        egui::CentralPanel::default().show(ctx, |ui| {
            // These calls are effectively free (nanoseconds).
            // They do not allocate memory.
            ui.heading(t!("menu_title"));

            if ui.button(t!("btn_submit")).clicked() {
                // ...
            }

            // Only this allocates, and only when 'count' changes if the UI is smart
            ui.label(t!("items_remaining", {
                "count" => self.items.len()
            }));
        });
    }
}

⚠️ Trade-offs

While fluent-zero is faster at runtime, it comes with trade-offs compared to fluent-templates:

  1. Compile Times: Because it generates Rust code for every string in your FTL files, heavily localized applications may see increased compile times.
  2. Binary Size: Static strings are embedded into the binary executable code.
  3. Flexibility: You cannot easily load new FTL files from the filesystem at runtime without restarting the application (the cache is baked in).

Stability and MSRV Policy

  • Versioning: the crates follow Semantic Versioning. While on 0.x, breaking changes bump the minor version (0.1 to 0.2) and are listed in CHANGELOG.md; patch releases are non-breaking.
  • Generated code: fluent-zero and fluent-zero-build are released together with the same version. Use matching versions; the generated code is only guaranteed to compile against the runtime of the same release.
  • MSRV: the minimum supported Rust version is Rust 1.88, declared as rust-version in each crate's Cargo.toml and tested in CI. It is only raised in a minor release (never a patch release) and the change is noted in CHANGELOG.md.
  • Security: see SECURITY.md for supported versions and how to report a vulnerability privately.

Performance

cargo bench -p fluent-zero-integration benchmarks static, dynamic, fallback and missing-key lookups against real generated code, and cargo test -p fluent-zero-integration --test zero_alloc asserts with a counting allocator that static lookups (including through the en-US fallback) perform zero heap allocations.

License

This project is licensed under the MIT license.

Notice

This crate is not related to Mozilla Project Fluent in any official capacity. All usage is at your own risk.