DEV Community

Cover image for Resurrecting TinyExpr in Rust: What a Hackathon Taught Me About Proving a Port Works
Kausalya S
Kausalya S

Posted on

Resurrecting TinyExpr in Rust: What a Hackathon Taught Me About Proving a Port Works

What if “modernizing” legacy software didn't mean rewriting it and hoping nothing breaks?

What if we could take a small, battle-tested C library, rebuild it in a modern language, and prove that the new implementation still behaves like the original?

That was the challenge I took on during Code Resurrection 2026.

Our project: TinyExpr → tinyexpr-rs, a safe, idiomatic Rust port of the original TinyExpr mathematical expression parser and evaluator.

And the surprising part?

The hardest part wasn't writing the parser.

It was proving that we hadn't accidentally changed what the parser meant.

The Problem: Legacy Code Still Has Value

TinyExpr is a lightweight mathematical expression engine.

Give it an expression like:

2 + 3 * 4

and it evaluates it to:

14

It supports much more than basic arithmetic:

sqrt(16)
sin(pi / 2)
pow(2, 10)
x*x + y*y

The original implementation is written in C.

For Code Resurrection, instead of simply translating the C code line by line, I wanted to answer a more interesting question:

Can we resurrect the functionality while redesigning the implementation around modern Rust principles?

That meant moving from C's manual memory management and raw-pointer-oriented design toward:

Rust ownership
enums
pattern matching
Result-based error handling
strongly typed AST structures
safe evaluation
zero unsafe Rust

The goal wasn't just a rewrite.

It was a behavior-preserving reimplementation.

From C to Rust

The architecture became a clean pipeline:

Expression
    ↓
Lexer
    ↓
Parser
    ↓
AST
    ↓
Optimizer
    ↓
Evaluator
    ↓
Result

Enter fullscreen mode Exit fullscreen mode

The lexer converts the input into tokens.

The recursive-descent parser converts those tokens into an Abstract Syntax Tree.

The optimizer performs constant folding.

The evaluator walks the resulting AST and produces the final value.

This separation made the implementation easier to reason about and test.

For example:

2 + 3 * 4
Enter fullscreen mode Exit fullscreen mode

initially becomes something conceptually like:


+
├── 2
└── *
    ├── 3
    └── 4
Enter fullscreen mode Exit fullscreen mode

The optimizer can then reduce the constant expression to:

14
Enter fullscreen mode Exit fullscreen mode

before evaluation.

But "It Compiles" Isn't Proof

This was where the project became much more interesting.

A port can compile.

A port can have unit tests.

A port can produce the right answer for ten examples.

And still be behaviorally different from the original.

So instead of asking:

"Does my Rust implementation work?"

I asked:

"Does my Rust implementation preserve the behavior that matters?"

That changed how I approached testing.

I built a smoke-test suite based on TinyExpr's original test suite and used it to exercise:

arithmetic
operator precedence
associativity
unary operators
variables
mathematical functions
invalid expressions
NaN behavior
infinity behavior
combinatorics
optimization
power associativity
edge cases

The final test run reached:

30 unit tests + 13 smoke tests passing, with 2 intentionally ignored tests for unsupported custom-function/closure functionality.

That distinction matters.

I didn't want to hide unsupported functionality behind a green test report.

Then Things Broke

And this is where the real engineering happened.

One of the smoke tests initially failed because of something that looked almost trivial:


expected 3.14159
got      3.141592653589793

Enter fullscreen mode Exit fullscreen mode

Another failed around:

pi * 2
Enter fullscreen mode Exit fullscreen mode

The Rust implementation was producing the full precision of f64.

The original compatibility tests were using rounded expected values.

So the problem wasn't simply:

"My calculation is wrong."

It was:

"What exactly are we defining as behavioral equivalence?"

That forced me to think carefully about floating-point comparison rather than blindly matching truncated constants.

Eventually, the tests were adjusted to use appropriate numerical tolerance.

That was an important lesson:

Compatibility isn't always byte-for-byte equality. Sometimes it means preserving semantics within the numerical model of the target language.

The Edge Cases Were the Real Challenge

The most interesting differences weren't always obvious.

For example, the Rust lexer doesn't currently support scientific notation in the same way:

1e3

doesn't become:

1000

in this implementation.

Instead, the lexer sees the numeric portion and identifier separately.

So rather than silently pretending the feature existed, the test suite explicitly documents the limitation.

The same philosophy applied to custom functions and closures.

The original TinyExpr supports mechanisms for registering user-defined callbacks.

Our current Rust implementation uses a fixed set of built-in functions.

So those tests remain explicitly ignored rather than being falsely marked as passing.

That's a small testing decision, but I think it represents an important engineering principle:

A trustworthy test suite should tell you what the software can do, what it cannot do, and why.

One of My Biggest Lessons: Ports Are Not Translations

A naive port looks like:

C code

Rust syntax

Done

Our approach was closer to:

Original behavior

Understand semantics

Design Rust architecture

Implement

Compare behavior

Find differences

Investigate

Fix or document

Test again

That distinction is huge.

For example, the original C implementation uses concepts such as:

{% embed 
C   Rust
malloc/free Ownership + Drop
Raw pointers    References / owned values
NULL    Option
Error codes Result
Tagged flags    Enums
Manual cleanup  Automatic memory management
 %}
Enter fullscreen mode Exit fullscreen mode

The result isn't just C code rewritten using Rust syntax.

It's a redesign around Rust's strengths.

Why TinyExpr Matters

At first glance, a mathematical expression parser seems small.

But that's exactly why it was such a good resurrection target.

TinyExpr sits at an interesting boundary:

small enough to understand, but rich enough to expose real language-engineering problems.

Parsing isn't just about recognizing numbers.

It's about answering questions like:

What binds more tightly?
Is exponentiation left- or right-associative?
How does unary minus interact with powers?
What happens when an expression is malformed?
How should variables be resolved?
What should happen with 0 / 0?
How should mathematical domain errors behave?
When is constant folding safe?
What counts as equivalent floating-point output?

Those are real compiler and interpreter problems hiding inside a tiny project.

The Innovation Wasn't "We Rewrote C in Rust"

The innovation, for me, was the methodology.

We treated legacy software as something that could be understood, tested, reconstructed, and modernized rather than simply abandoned.

TinyExpr became a case study in how older software can be brought into a modern ecosystem while preserving the behavior that existing users depend on.

And that idea scales far beyond expression parsing.

Imagine applying the same approach to:

legacy scientific libraries
old developer tools
embedded utilities
abandoned command-line programs
small infrastructure libraries
educational software
older algorithms that are still useful but difficult to maintain

The goal isn't always to replace legacy software.

Sometimes the goal is to give it a safer future.

What I Would Do Differently

If I could restart the project, I would invest more time in defining the compatibility contract before writing the implementation.

Especially around:

floating-point tolerances
unsupported language features
error semantics
exact edge-case behavior
differences between C and Rust numerical behavior

That would have saved debugging time later.

But the debugging itself was valuable.

Because every unexpected failure forced us to understand the original behavior more deeply.

What Code Resurrection Changed for Me

Before this project, I might have described a successful port as:

"The Rust version produces the expected output."

Now I'd describe it differently:

A successful port is one where you understand the original behavior well enough to explain every important difference in the new implementation.

That mindset completely changed how I approached the project.

We didn't just resurrect TinyExpr.

We built tinyexpr-rs as a safer, more maintainable Rust implementation while using tests to challenge our assumptions about compatibility.

And that's what I think makes legacy resurrection exciting.

The code may be old.

The ideas don't have to be.

What's Next?

There are still areas where tinyexpr-rs can evolve:

better parser diagnostics with source spans
more expression simplification
user-defined functions
additional mathematical operators
a REPL
WebAssembly support
broader compatibility testing

But the foundation is there.

A small C expression engine has become a modern Rust project with a clear architecture, automated tests, optimization, benchmarks, and documented compatibility decisions.

Final Thought

The most valuable thing I built during Code Resurrection wasn't just a parser.

It was a way of thinking about legacy software.

Don't ask only, "Can we rewrite it?"

Ask:

"Can we understand it deeply enough to rebuild it, test it, and give it another life?"

That's what Code Resurrection meant to me.

And that's what tinyexpr-rs represents:
old functionality, new foundations, and a future worth maintaining.``

Top comments (0)