Type (2.5 + 3) * 4 - 10 / -2 and get 27. Behind that one line hide three classic problems: split the text into meaningful pieces, honor operator precedence, and compute the result without crashing on garbage. Build a calculator like this once, and you've built a tiny compiler front end.
The Shape of the Whole Problem
A calculator is a three-stage pipeline, and the top-level function reads exactly like one: one stage per line.
pub fn calculate(
input: &str,
) -> Result<f64, CalcError> {
let tokens = tokenize(input)?;
let rpn = to_rpn(tokens)?;
eval_rpn(rpn).map(|n| n.0)
}
The input is a borrowed string slice: we only read it, never own it. The output is a Result whose error half is our own enum CalcError (we'll declare it below). Each stage feeds the next: tokenize turns "2+3" into [2, +, 3], to_rpn rearranges that into [2, 3, +], and eval_rpn folds it down to 5.0. The ? after the first two stages short-circuits the chain the moment anything fails — so a malformed number never reaches the evaluator. The final .map(|n| n.0) unwraps our Number newtype back into a plain f64 for the caller.
Tokenization: Text In, Meaning Out
Raw input is a soup of characters. The tokenizer walks it once and emits Tokens, where a Token is exactly one of four things.
pub enum Token {
Number(Number),
Op(Operator),
LParen,
RParen,
}
A token is either a number, an operator, or one of the two parentheses. Describing them as an enum lets later stages match on a token in a way the compiler checks: every case is handled. Nothing else can sneak in disguised as a "token."
The walk itself iterates over a Peekable iterator of characters and matches on each one.
while let Some(&ch) = chars.peek() {
match ch {
// ...one arm per kind of character
}
}
peek lets you look at the next character before deciding to consume it. That distinction is the whole trick behind multi-character tokens: we need to know what's coming before we commit to reading it.
Numbers are the case that stretches across several characters.
'0'..='9' | '.' => {
let n = consume_number(&mut chars)?;
tokens.push(Token::Number(n));
}
A digit or a . means a number is starting, so we hand the iterator to consume_number, which pulls characters until it hits a non-digit. The range pattern '0'..='9' catches any ASCII digit in one stroke, and ? propagates a parse error like 1.2.3 as a clean error.
Parentheses and whitespace are simpler.
'(' => {
tokens.push(Token::LParen);
chars.next();
}
')' => {
tokens.push(Token::RParen);
chars.next();
}
c if c.is_whitespace() => {
chars.next();
}
Each parenthesis pushes its token and then calls chars.next() to actually step past the character we only peeked at. Whitespace pushes nothing — it just advances — so spaces in the input simply vanish. Had we forgotten chars.next(), the loop would spin forever on the same peeked character.
Everything else is an error.
_ => return Err(
CalcError::UnexpectedCharacter(ch),
),
The catch-all arm grabs any character we didn't recognize and returns a typed error instead of crashing. Notice: nowhere in parsing do we call unwrap() — bad input is data, not a bug, and the function signature already promised we'd report it.
Errors Are Values, Not Panics
The lazy version of this program would unwrap() everywhere and blow up on 1 / abc. The honest version makes every failure a variant of a single enum.
pub enum CalcError {
InvalidNumber(String),
UnexpectedCharacter(char),
UnmatchedOpenParen,
UnmatchedCloseParen,
DivisionByZero,
NotEnoughOperands,
InvalidExpression,
}
Each variant names a distinct way a computation can go wrong, and some carry data — the offending string or character — so the message can be specific. Because every stage returns Result<_, CalcError>, the compiler forces the caller to handle failure rather than skip it silently.
To turn a variant into something a human reads, we implement Display — its body just maps each variant to a phrase.
fn fmt(
&self,
f: &mut Formatter,
) -> fmt::Result {
match self {
DivisionByZero =>
write!(f, "Division by zero"),
// ...one arm per variant
}
}
Display is the standard trait behind {} formatting, so once it exists the REPL can println!("Error: {e}") for any CalcError. The signature is fixed by the trait itself: we're handed a Formatter to write into and asked to return fmt::Result. Each arm writes its own fixed phrase, except the variants with data, which splice in their payload — the error type owns its own wording.
Number parsing flows into this same error type through FromStr, handing the hard part off to the standard f64 parser.
fn from_str(
s: &str,
) -> Result<Self, CalcError> {
s.parse::<f64>()
.map(Number)
.map_err(|_| {
CalcError::InvalidNumber(
s.to_string(),
)
})
}
Implementing FromStr is what makes s.parse() work for our Number, and choosing CalcError as the associated Err type means a failed parse instantly speaks our domain's error language. The hard part we delegate to the built-in f64 parser, then .map(Number) wraps success in our newtype. And .map_err swaps the standard parse error for InvalidNumber, keeping the original text so the message can show exactly what failed to parse. Now s.parse() anywhere in the code yields our error for free.
The Number Newtype That Behaves Like a Number
We could pass a bare f64 around the code. Instead we wrap it in a single-field tuple struct.
pub struct Number(pub f64);
This "newtype" costs nothing at runtime but gives us a distinct type the compiler can reason about: you won't accidentally mix a Number up with some other f64. And it doubles as a home for domain-specific methods.
The first such method handles one quirk of floats.
impl Number {
pub fn is_zero(self) -> bool {
self.0.abs() < f64::EPSILON
}
}
Comparing floats with == 0.0 is brittle, so is_zero asks a different question: does the value lie within a tiny tolerance of zero? f64::EPSILON is that tolerance, the smallest meaningful gap between floats near 1. The division guard downstream will lean on exactly this.
To keep the evaluator readable, we teach Number real arithmetic by implementing the traits from std::ops.
impl Add for Number {
type Output = Self;
fn add(self, rhs: Self) -> Self {
Self(self.0 + rhs.0)
}
}
Implementing Add is what lets us write a + b directly on two Numbers. The method just unwraps both inner floats, adds them, and wraps the result back up. Sub, Mul, Div, and Neg follow the same pattern — and the payoff arrives in the evaluator, where a + b reads like math instead of Number(a.0 + b.0).
Shunting Yard: Human Notation into Machine Notation
Here's the heart of it. 2 + 3 * 4 is 14, not 20: multiplication binds tighter. Humans encode this precedence with rules; computers prefer Reverse Polish Notation, where the order is the rule: 2 3 4 * + has no precedence and no parentheses. Dijkstra's Shunting Yard algorithm does the translation using an output queue and an operator stack.
The trickiest part is how precedence is represented. We declare the operators in a deliberate order.
pub enum Operator {
Add,
Sub,
Mul,
Div,
}
Then we derive PartialOrd, and Rust orders enum variants by their declaration position. By listing them from low precedence to high, we get Add < Mul for free — no comparison function, no precedence table. The less "grippy" operators simply come first.
When a new operator arrives, we peek at the top of the operator stack and decide whether it's time for it to move off to the output.
while let Some(Token::Op(top)) =
op_stack.last()
{
if top >= op {
output.push(op_stack.pop().unwrap());
} else {
break;
}
}
while let keeps looking at the top operator without removing it: last() borrows rather than pops. If the operator on the stack binds tighter or equally, we pop it and emit it; otherwise we break and leave it in place. The top >= op check — on that same order we derived for free — is what guarantees * moves to the output before the + that follows it. unwrap() is safe here because while let just confirmed that last() returned a value.
Once the higher-precedence operators are flushed, the new one is pushed onto the stack — and the arm is done.
Token::Op(ref op) => {
// ...the flush loop above
op_stack.push(token);
}
The current operator now sits atop the stack, waiting until something with equal-or-higher precedence (or the end of input) pushes it out in turn. This "flush then push" dance is the entire precedence mechanism.
Parentheses override all of this. A closing parenthesis pops the stack down to its match.
Token::RParen => loop {
match op_stack.pop() {
Some(Token::LParen) => break,
Some(op) => output.push(op),
None => return Err(
CalcError::UnmatchedCloseParen,
),
}
},
We pop operators into the output until we reach the matching LParen, which we discard: its only job was to mark a boundary. Running off the end of the stack (None) means there was no opening parenthesis to match — so we report UnmatchedCloseParen. A leftover ( discovered at the very end is the mirror error, UnmatchedOpenParen.
Evaluating RPN Is Almost Trivial
The whole point of RPN is that evaluation takes no cleverness — just a stack. See a number, push it onto the stack; see an operator, pop two, combine them, and push the result back. Let's start with popping the operands.
let b = stack
.pop()
.ok_or(NotEnoughOperands)?;
let a = stack
.pop()
.ok_or(NotEnoughOperands)?;
We pop twice, and ok_or turns an empty stack into a NotEnoughOperands error instead of a panic — this is what catches input like 1 +. Order matters enormously here: the second pop is the left operand. Swap them and a - b silently becomes b - a.
Division gets a guard before it runs; the other three operators run without one.
let result = match op {
Operator::Div => {
if b.is_zero() {
return Err(DivisionByZero);
}
a / b
}
Operator::Add => a + b,
Operator::Sub => a - b,
Operator::Mul => a * b,
};
We check b.is_zero() — with that same tolerance-based method — before dividing, so 1 / 0 returns a clean error rather than the float inf. Only once the guard passes do we run a / b, which dispatches to our impl Div for Number. Addition, subtraction, and multiplication can't fail on finite floats, so they map straight to the operator traits we implemented.
Whatever the arm produced gets pushed back onto the stack — and the arm is done.
Token::Op(op) => {
// ...popped b, popped a, computed result
stack.push(result);
}
The freshly computed value is now ready to serve as an operand for the next operator — exactly as RPN intends.
When the tokens run out, a correct expression leaves exactly one value behind.
match stack.len() {
1 => Ok(stack[0]),
_ => Err(CalcError::InvalidExpression),
}
One value means the expression balanced perfectly. Anything else — zero values, or several stranded numbers like 1 2 3 + — means the input was malformed, so we return InvalidExpression rather than guess.
The REPL Wrapper
The user-facing loop is deliberately dumb: read a line, hand it to calculate, print the result or the error message.
match calculate(input) {
Ok(result) => println!("= {result}"),
Err(e) => println!("Error: {e}"),
}
Success prints = 27; failure prints Error: Division by zero straight from the Display implementation. The loop itself knows absolutely nothing about tokens or precedence — all the logic lives in the library, and main is just I/O.
Two small touches make it pipe-friendly.
print!("> ");
stdout().flush()?;
We flush after printing the > prompt, because print! (with no newline) would otherwise sit in the buffer and the prompt would never appear. Leaving the loop when a read returns zero bytes handles end-of-file cleanly — so if you pipe a file of expressions into the program, it just works.
Where to Grow From Here
The core is honest, but a real expression engine grows in a few obvious directions.
- Functions and variables —
sin(x),pi,let r = 5. Add token kinds and a small symbol table; the shape of the pipeline doesn't change. - Right-associative operators — exponentiation (
2 ^ 3 ^ 2=512, not64) requires the precedence check for that one operator to use>rather than>=. - Better numbers — swap
f64for a decimal type if0.1 + 0.2matters to you; since everything flows throughNumber, you change one struct. - Errors with positions — remember where the bad character was, so you can underline it.
Why Build This
A calculator is the smallest program that forces you to think like a language designer: separate scanning from parsing, parsing from evaluation, model errors as data, and lean on the type system rather than fight it. A couple hundred lines later, "tokenizer," "RPN," and "precedence" stop being textbook words — they're functions you wrote. Next time you read about a real compiler, you'll recognize this skeleton.