I’m sharing this to show how I resurrected TinyExpr in Rust, proved behavioral equivalence, and learned from the edge cases that broke along the way.
I ported TinyExpr, a tiny C mathematical expression parser, to Rust.
At first, the project looked deceptively simple.
Tokenize an expression.
Parse it.
Build an AST.
Evaluate it.
Done.
Except it wasn't.
The difficult part wasn't getting:
2 + 3 * 4
to return:
14
The difficult part was answering a much more important question:
How do you know a port is actually faithful to the original implementation?
That question turned a small parser rewrite into a deep investigation of grammar, operator associativity, floating-point behavior, error handling, optimization, variables, combinatorics, and edge cases.
This is the story of my TinyExpr → Rust port for Code Resurrection 2026.
The project
The original TinyExpr is a lightweight C expression parser and evaluator.
It can evaluate expressions like:
2 + 3 * 4
sqrt(100)
pow(2, 10)
sin(pi / 2)
x * y + 5
The original implementation is small, but it packs a surprising amount of behavior into that small codebase.
My goal wasn't to rewrite it line by line.
I wanted to preserve its observable behavior while redesigning the implementation around Rust's type system.
The architecture became:
Expression
│
▼
Lexer
│
▼
Parser
│
▼
AST
│
▼
Optimizer
│
▼
Evaluator
│
▼
Result
The Rust implementation uses:
- enums instead of tagged integer flags
-
Resultinstead of C-style error handling - ownership instead of manual memory management
-
HashMapfor variables - a typed AST
- recursive-descent parsing
- constant-folding optimization
- zero
unsafeRust
The code was cleaner.
But clean code isn't proof of compatibility.
The first mistake: assuming the math was the hard part
My first instinct was to test obvious expressions:
1 + 2
2 * 3
sqrt(16)
sin(pi / 2)
pow(2, 10)
Everything worked.
That created a dangerous illusion.
A parser can pass every "normal" test while still being behaviorally different from its predecessor.
For a port, these are not enough:
2 + 3
and:
sqrt(16)
I needed to test the weird stuff.
So I started treating the original TinyExpr behavior as a specification.
The test suite became the specification
Instead of asking:
"Does my Rust implementation look correct?"
I asked:
"Does my Rust implementation behave like TinyExpr?"
That changed how I wrote the tests.
I built coverage around several categories:
Arithmetic
1
2+1
3*2*4
3-2-4
3/2/4
Precedence
100^.5+1
sqrt 100 * 7
Unary operators
-.5
--.5
---.5
Functions
sin
cos
sqrt
pow
atan2
log
ln
Variables
x+x+x-y
x*y^3
cos x + sin y
Sequences
1,2
1,2,3
(1,2),3
Invalid expressions
1+
(1
1**1
a+5
Numerical edge cases
0/0
1/0
fac(-1)
ncr(2,4)
And then I added optimization tests.
Because parsing the right expression isn't enough.
The optimizer has to preserve the same result too.
Then I found the first important difference
One of the most interesting compatibility issues was floating-point constants.
The original test expectations used values such as:
3.14159
while Rust's f64::consts::PI evaluates to:
3.141592653589793
Mathematically, both represent π to different precisions.
But a compatibility test doesn't care about what should happen mathematically.
It cares about what the implementation actually produces.
My first test failed with:
expected 3.14159,
got 3.141592653589793
The difference was:
0.000002653589793...
That forced me to revisit the testing strategy.
The lesson was simple:
A compatibility test must distinguish between an intentional numerical difference and an actual semantic difference.
I adjusted the comparison to use a floating-point tolerance rather than requiring exact equality.
That also fixed the corresponding optimization test for:
pi * 2
The six-hour problem wasn't actually six hours
One of the biggest challenges wasn't a single compiler error.
It was figuring out which behavior belonged to the original implementation and which behavior was an artifact of my Rust design.
TinyExpr has some particularly interesting semantics around:
^
and unary operators.
For example:
2^3^4
isn't universally interpreted the same way by every expression language.
This implementation follows the original/default TinyExpr behavior:
(2^3)^4
rather than:
2^(3^4)
That means operator associativity isn't just a parser implementation detail.
It's part of the API.
The same applies to expressions such as:
-2^2
and:
-2^-3^-4
A "cleaner" grammar isn't necessarily a compatible grammar.
That became one of the central principles of the project:
When porting software, don't silently fix semantics that users may depend on.
The edge cases became the real specification
Some of the most useful tests were the ones that looked ridiculous.
For example:
100^--.5+1
and:
100^---+-++---++-+-.5+1
These aren't expressions anyone should casually write.
But that's exactly why they're valuable.
A normal application test tells you whether the implementation works for normal usage.
A compatibility test tells you where the implementation's boundaries actually are.
And those boundaries are often where ports break.
Scientific notation exposed a limitation
Another interesting discovery was scientific notation.
The original TinyExpr test suite contains expressions using values such as:
1e3
But the Rust lexer in this implementation currently recognizes numeric literals using digits and . without exponent notation.
So:
1e3
doesn't become:
1000
Instead, it gets tokenized differently and ultimately fails.
Rather than hiding that difference, I made it an explicit test:
#[test]
fn scientific_notation_is_not_supported() {
assert!(interp("1e3").is_err());
assert!(interp("5e-5").is_err());
assert!(interp("1.0e3").is_err());
}
That distinction matters.
There is a huge difference between:
"I forgot to test this."
and:
"This behavior isn't supported, and I have a test documenting it."
The second is an engineering decision.
Then combinatorics got interesting
TinyExpr includes:
fac()
ncr()
npr()
for factorials, combinations, and permutations.
These exposed another subtle difference.
The original C implementation performs integer-style operations and has overflow behavior that occurs much earlier than an IEEE-754 f64 would naturally overflow.
The Rust implementation uses f64 directly.
So something like:
ncr(300,100)
can remain finite in Rust even though the original implementation treats the corresponding integer calculation as overflowing.
That meant I couldn't simply write:
assert!(value.is_infinite());
for every upstream overflow case.
I had to distinguish:
original integer overflow
from:
actual f64 overflow
That resulted in separate tests documenting the divergence.
This is one of the places where I decided that being explicit about a behavioral difference was better than pretending the implementations were identical.
Optimization had to be tested independently
The project also includes constant folding.
For example:
5 + 5
can become:
10
before evaluation.
Likewise:
pow(2,2)
can become:
4
The optimizer therefore has its own correctness requirement.
I tested that a constant expression becomes an AST number directly:
5 + 5
↓
Number(10)
But I also tested something more important:
Does optimized evaluation agree with normal evaluation?
Because an optimizer that produces the wrong answer faster is still wrong.
Variables gave me another compatibility problem
Variables look simple:
x * y
But variable behavior is part of the language semantics.
I tested expressions against changing environments:
x = 0, y = 2
x = 1, y = 2
x = 2, y = 2
...
and checked expressions such as:
cos x + sin y
x+x+x-y
x*y^3
I also tested unknown identifiers.
For example:
xx*y^3
can parse successfully as an identifier and then fail during evaluation because xx isn't bound.
That's different from a parser-level syntax error.
Again:
Parsing and evaluation are separate stages, and the tests need to know which stage is responsible for the failure.
The test results
After the implementation and fixes, the project reached:
30 passed
for the library's unit tests.
The smoke/conformance suite reached:
13 passed
2 ignored
0 failed
The two ignored tests correspond to functionality that the current Rust API doesn't implement yet:
custom user-defined functions
closures
I deliberately left those as ignored tests rather than pretending the feature exists.
That's important because an ignored test isn't a failure.
It's a documented boundary.
Why I didn't just delete the unsupported tests
This was a deliberate decision.
The original TinyExpr supports registering custom functions and closures.
My Rust port currently has a closed set of built-in functions.
So instead of deleting the corresponding coverage, I left explicit ignored tests explaining:
this feature exists upstream
this port doesn't implement it yet
this test should become active when the API exists
That gives future development a target.
It also makes the limitation visible.
The benchmark isn't the proof
I also added Criterion benchmarks.
They measure common expression workloads such as:
- arithmetic
- nested expressions
- function evaluation
- variable lookup
- expression trees
It's tempting to make performance the headline.
But that's not what mattered most.
A fast parser that changes the semantics of:
2^3^4
isn't a successful compatibility port.
The priority was:
Correctness
↓
Behavioral compatibility
↓
Test coverage
↓
Optimization
↓
Performance
Not the other way around.
The architecture is where Rust made the biggest difference
The original implementation is C.
That means concepts such as:
memory ownership
raw pointers
manual cleanup
error codes
tagged structures
have to be handled explicitly.
In Rust, I could redesign these around the type system.
| TinyExpr C | TinyExpr Rust |
|---|---|
malloc/free |
Ownership + Drop
|
| Raw pointers | References / owned values |
NULL |
Option |
| Error codes | Result |
| Tagged structures | Enums |
| Manual cleanup | Automatic cleanup |
The result isn't simply "the same C code written with Rust syntax."
It's a Rust implementation of the same language behavior.
That distinction matters.
What I would do differently
If I started this project again, I would change one major thing.
I would build the compatibility tests first.
My first instinct was:
Port code
↓
Make it compile
↓
Write tests
↓
Fix failures
I now think the better order is:
Study original behavior
↓
Build compatibility tests
↓
Port architecture
↓
Run tests
↓
Investigate divergences
↓
Optimize
Because when you're porting software, the biggest danger isn't a compiler error.
It's an implementation that compiles, looks reasonable, and is subtly wrong.
What I learned from TinyExpr
The biggest lesson wasn't about Rust.
It was about software archaeology.
When you resurrect an old project, you aren't simply translating code.
You're reconstructing intent.
You have to ask:
- What behavior is intentional?
- What behavior is accidental?
- What behavior is part of the public API?
- What edge cases did the original authors implicitly support?
- Which differences are acceptable in the new implementation?
- Which differences would break compatibility?
And sometimes the answer isn't obvious from the source.
The tests become historical evidence.
The parser becomes a specification.
The strange edge cases become documentation.
And failures become clues.
The most important distinction: port vs rewrite
This project taught me that there are two very different goals:
Rewrite
"How would I design this library today?"
Port
"How do I preserve what this library already means?"
Those goals can produce completely different implementations.
For TinyExpr, I chose the second.
I wanted the internals to be idiomatic Rust.
But I wanted the language semantics to remain recognizably TinyExpr.
That's why I preserved things like:
left-associative power
unary operator behavior
sequence expressions
built-in functions
variable evaluation
while changing the underlying architecture.
The impact of a tiny project
TinyExpr isn't a massive framework.
That's exactly why I liked it for Code Resurrection.
It demonstrates something important:
Software doesn't have to be huge to be worth preserving.
A small parser can sit underneath calculators, configuration systems, scientific tools, games, simulations, embedded applications, and countless internal utilities.
Preserving a tiny piece of software is also an exercise in preserving the assumptions built around it.
The code may be old.
The behavior may not be.
The final result
I started with a C expression evaluator.
I ended with a Rust implementation containing:
- a lexer
- recursive-descent parser
- typed AST
- evaluator
- optimizer
- built-in mathematical functions
- variable support
- structured errors
- compatibility tests
- smoke tests
- examples
- Criterion benchmarks
- zero
unsafeRust
And more importantly, I ended with a much better understanding of what it actually means to port software faithfully.
The hardest bugs weren't syntax errors.
They were assumptions.
The lesson I'll take into my next port
If I had to reduce the entire project to one sentence, it would be this:
Don't prove that your new implementation works. Prove that it still means the same thing.
That's a much harder problem.
But it's also a much more interesting one.
And that's what made resurrecting TinyExpr worth doing.
Built for Code Resurrection 2026
This project was built as part of Code Resurrection 2026, with the goal of taking an existing piece of software and giving it a modern implementation without losing the behavior that made the original useful.
Project: tinyexpr-rs
Original project: TinyExpr
Port: C → Rust
Focus: behavioral compatibility, safety, maintainability, testing, and performance
The project is not perfect—and that's intentional.
The remaining limitations are documented rather than hidden.
Because in a resurrection project, knowing what you haven't reproduced yet is just as important as knowing what you have.
One final thought
The most surprising thing about this project was how little of the work was actually typing Rust.
The real work was asking:
"What does this tiny piece of C actually promise?"
Then writing enough evidence to defend the answer.
That's the part of software resurrection I didn't expect to enjoy as much as I did.
And it's probably the part I'll remember longest.
Kausalya-s673
/
tinyexpr-rs
Idiomatic Rust port of TinyExpr with recursive-descent parsing, AST optimization, benchmarking and full test suite.
tinyexpr-rs
A safe, idiomatic Rust port of the original TinyExpr mathematical expression parser and evaluator.
Built as part of Code Resurrection 2026, this project preserves the grammar and behavior of the original C implementation while redesigning the internals to leverage Rust's ownership model, type system, and modern error handling.
Overview
TinyExpr is a lightweight recursive-descent parser capable of parsing and evaluating mathematical expressions such as
2 + 3 * 4
sqrt(16)
pow(2, 10)
sin(pi / 2)
fac(5)
ncr(5,2)
This project reimplements TinyExpr in Rust while preserving the original parser grammar and evaluation semantics wherever practical.
Unlike a direct line-by-line translation, the implementation embraces idiomatic Rust design using enums, pattern matching, ownership, and Result-based error handling.
Features
- Recursive-descent parser
- Expression evaluation
- Constant folding optimization
- Built-in mathematical functions
- Variables
- Built-in constants (
pi,e) - Comprehensive unit and integration tests
- Smoke test suite based on the original TinyExpr tests
- …

Top comments (0)