A Rust Adventure — Building i18n-hunt

A while back I was working on a new feature. Copy changed once, then twice, then again. The platform was already multilingual — JSON translation files, keys mapped to every label and message.

By the time we shipped, some of those keys were referencing flows that no longer existed. Components removed, labels renamed, messages replaced. The keys stayed.

So we asked a simple question: what if a tool could find them automatically?


Starting with Regex

The first version was rough. We scanned source files for patterns like t("form.email") — string literals passed to the translation function. Simple, fast to build, good enough to validate the idea.

Then we hit dynamic keys:

t(`action.${result}`);

Regex can't reason about what result will be at runtime. You can guess, but guessing creates false positives — keys marked as used that aren't, or the opposite. And on top of that, keys belong to namespaces:

const { t } = useTranslation("Auth/Login");
t("title"); // this is Auth/Login.title, not just "title"

Regex doesn't understand that relationship. It just sees text.


Moving to AST

AST — Abstract Syntax Tree — parses code into a structure you can actually traverse. Instead of matching patterns, you inspect function calls, arguments, imports, namespaces.

That changes everything. Now when we see useTranslation("Auth/Login"), we know that subsequent t() calls belong to that namespace. And dynamic keys don't break the tool — we classify them explicitly and handle them safely instead of silently guessing.

Detection is still an ongoing challenge. JS/TS projects use i18n in too many different ways to ever fully solve it. But AST gave us a foundation we can actually improve over time.


The delivery problem

i18n-hunt is written in Rust. The people who need it write JavaScript.

The goal was simple: npm run i18n-hunt. No Rust installation, no Cargo, no compiling from source.

Rust is compiled, so that means separate binaries per platform. For the alpha:

I set up a GitHub Actions pipeline to build each target on release, then created an npm wrapper that detects OS and architecture and runs the right binary.

From the user's side it feels like any other npm package. Under the hood, Rust CLI. That part was especially satisfying — two worlds I care about, connected.


Detection + cleanup

Finding unused keys is useful. But then manually hunting them down in JSON files and deleting them one by one? That's exactly the kind of work the tool was supposed to eliminate.

So i18n-hunt ships with --fix:

npm run i18n-hunt --fix

It removes unused keys directly from your translation files. That stale legacy block that nobody touches?

// before
{
  "title": "Login",
  "form": { "email": "Email", "password": "Password", "submit": "Continue" },
  "legacy": { "oldLoginMessage": "Welcome back" }
}

// after
{
  "title": "Login",
  "form": { "email": "Email", "password": "Password", "submit": "Continue" }
}

Gone. No grepping. No second-guessing.


Where it's at

This is still alpha. Detection patterns keep improving. The npm package will eventually download only the binary for your platform instead of bundling all of them. There's room to grow.

But the core idea works — and building it taught me more about Rust, ASTs, CLI tooling, and cross-platform packaging than I expected going in.

If you're working on a JS/TS project with a translation file that hasn't been cleaned in a while, give it a try.