DEV Community

Kausalya S
Kausalya S

Posted on

I thought porting TinyExpr to Rust would be easy. Then the tests started disagreeing.

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
Enter fullscreen mode Exit fullscreen mode

to return:

14
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The Rust implementation uses:

  • enums instead of tagged integer flags
  • Result instead of C-style error handling
  • ownership instead of manual memory management
  • HashMap for variables
  • a typed AST
  • recursive-descent parsing
  • constant-folding optimization
  • zero unsafe Rust

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

and:

sqrt(16)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Precedence

100^.5+1
sqrt 100 * 7
Enter fullscreen mode Exit fullscreen mode

Unary operators

-.5
--.5
---.5
Enter fullscreen mode Exit fullscreen mode

Functions

sin
cos
sqrt
pow
atan2
log
ln
Enter fullscreen mode Exit fullscreen mode

Variables

x+x+x-y
x*y^3
cos x + sin y
Enter fullscreen mode Exit fullscreen mode

Sequences

1,2
1,2,3
(1,2),3
Enter fullscreen mode Exit fullscreen mode

Invalid expressions

1+
(1
1**1
a+5
Enter fullscreen mode Exit fullscreen mode

Numerical edge cases

0/0
1/0
fac(-1)
ncr(2,4)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

while Rust's f64::consts::PI evaluates to:

3.141592653589793
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The difference was:

0.000002653589793...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

^
Enter fullscreen mode Exit fullscreen mode

and unary operators.

For example:

2^3^4
Enter fullscreen mode Exit fullscreen mode

isn't universally interpreted the same way by every expression language.

This implementation follows the original/default TinyExpr behavior:

(2^3)^4
Enter fullscreen mode Exit fullscreen mode

rather than:

2^(3^4)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

and:

-2^-3^-4
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

and:

100^---+-++---++-+-.5+1
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

But the Rust lexer in this implementation currently recognizes numeric literals using digits and . without exponent notation.

So:

1e3
Enter fullscreen mode Exit fullscreen mode

doesn't become:

1000
Enter fullscreen mode Exit fullscreen mode

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());
}
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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());
Enter fullscreen mode Exit fullscreen mode

for every upstream overflow case.

I had to distinguish:

original integer overflow
Enter fullscreen mode Exit fullscreen mode

from:

actual f64 overflow
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

can become:

10
Enter fullscreen mode Exit fullscreen mode

before evaluation.

Likewise:

pow(2,2)
Enter fullscreen mode Exit fullscreen mode

can become:

4
Enter fullscreen mode Exit fullscreen mode

The optimizer therefore has its own correctness requirement.

I tested that a constant expression becomes an AST number directly:

5 + 5
      ↓
   Number(10)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
...
Enter fullscreen mode Exit fullscreen mode

and checked expressions such as:

cos x + sin y
x+x+x-y
x*y^3
Enter fullscreen mode Exit fullscreen mode

I also tested unknown identifiers.

For example:

xx*y^3
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

for the library's unit tests.

The smoke/conformance suite reached:

13 passed
2 ignored
0 failed
Enter fullscreen mode Exit fullscreen mode

The two ignored tests correspond to functionality that the current Rust API doesn't implement yet:

custom user-defined functions
closures
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

isn't a successful compatibility port.

The priority was:

Correctness
    ↓
Behavioral compatibility
    ↓
Test coverage
    ↓
Optimization
    ↓
Performance
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

I now think the better order is:

Study original behavior
        ↓
Build compatibility tests
        ↓
Port architecture
        ↓
Run tests
        ↓
Investigate divergences
        ↓
Optimize
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 unsafe Rust

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.

GitHub logo 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)