A Rust Calculator, Part 1: Breaking Down the Problem
Making a calculator application using Rust
Rust is a language I have been seeing more and more lately. I keep running into interesting projects written in it, and it seems like a good time to become more familiar with how it works.
Even in a time where LLMs can write a lot of code for us, I still think there is value, usefulness, and not least fun in being able to make small programs yourself, one classic project when learning programming is a calculator.
I must admit that I have never made a proper calculator application myself in any of the languages I have learned before. It seems like a fun type of project though, and it is small enough that it should be possible to understand every part of it.
Because when you think about it how do you actually turn something like this:
2 + 35 * (3 + 2)
into something that a computer can understand and process correctly?
That turns out to be a little more interesting.
A calculator needs to understand that multiplication happens before addition, that parentheses change the order of operations, and that a number such as 35 is one thing rather than two separate characters.
The calculator architecture
One common way to structure a calculator like this is to split it into three parts:
- The lexer
- The parser
- The evaluator
The lexer turns text into tokens.
The parser works out how those tokens fit together.
The evaluator performs the actual calculation.
For now, I will go into the lexer, then later move on to the rest and finally the goal is to assemble it into a full calculator, at which point I (and you) will have learnt a lot hopefully.
What is a token?
A token is simply a small piece of input with a known meaning.
For example, this expression:
3 + 4 * (2 - 1)
can be split into these pieces:
3
+
4
*
(
2
-
1
)
The lexer does not need to understand multiplication precedence or parentheses yet. It only needs to recognise each individual piece.
In pseudocode, the overall process looks like this:
Look at the next character.
If it is whitespace:
Ignore it.
If it starts a number:
Keep reading until the number ends.
If it is an operator or parenthesis:
Create the matching token.
Otherwise:
Return an error.
That is the main idea behind the lexer.
Representing tokens in Rust
Before the lexer can create tokens, I need to define the types of tokens that exist.
Rust has enums. But not just any enums, these are not your standard C++ enums. An enum in rust lets us define a fixed set of possible values.
enum Token {
Number(f64),
Plus,
Minus,
Star,
Slash,
Lpar,
Rpar,
Power,
}
A number token stores a value:
Token::Number(3.0)
while symbols such as + and ( do not need additional information:
Token::Plus
Token::Lpar
Token::Power
The Power token is for the ** operator.
Eventually, this input:
3 + 4 * (2 - 1) ** 2
should become something similar to:
Number(3)
Plus
Number(4)
Star
Lpar
Number(2)
Minus
Number(1)
Rpar
Power
Number(2)Reading the input character by character
Rust lets us iterate through every character in a string with:
input.chars()
For example, this input:
3 + 4
contains these characters:
'3'
' '
'+'
' '
'4'
However, the lexer sometimes needs to look at the next character before deciding what to do.
For example, when it sees *, it needs to check whether the next character is another *.
That is why I use a Peekable iterator:
let mut chars = input.chars().peekable();
This gives access to peek(), which lets the lexer inspect the next character without consuming it.
In pseudocode:
Create an iterator over all characters.
While there is another character:
Look at it.
Decide what kind of token it belongs to.
Consume it when appropriate.
The corresponding Rust loop looks like this:
while let Some(&c) = chars.peek() {
match c {
// Handle the current character here
_ => {}
}
}
The while let syntax means that the loop continues as long as peek() returns a character.
Ignoring whitespace
One simple thing that is quite self-explanatory: whitespace should not affect the calculation.
These should all mean the same thing:
3+43 + 43 + 4
In pseudocode it is simply like this:
If the current character is whitespace:
Consume it.
Do not create a token.
In Rust:
' ' | '\t' | '\n' | '\r' => {
chars.next();
}
The | means “or”, so this matches normal spaces, tabs, and line breaks.
Handling simple operators
The easiest tokens are the operators with a single character.
For example, the logic for + is:
If the character is +:
Consume it.
Add a Plus token.
In Rust:
'+' => {
chars.next();
tokens.push(Token::Plus)
}
The same idea works for subtraction, division, and parentheses.
'-' => {
chars.next();
tokens.push(Token::Minus)
}'(' => {
chars.next();
tokens.push(Token::Lpar)
}
This is one of the nice things about a lexer. The code maps quite directly to the explanation:
When I see this character:
Save the matching token.Handling * and **
The star operator needs one extra step.
A single star means multiplication:
4 * 2
Two stars mean exponentiation:
4 ** 2
The logic is:
Consume the first star.
If the next character is another star:
Consume that too.
Create a Power token.
Otherwise:
Create a normal multiplication token.
In Rust, this becomes:
'*' => {
chars.next();
if chars.peek() == Some(&'*') {
chars.next();
tokens.push(Token::Power);
} else {
tokens.push(Token::Star);
}
}
This is where peekable() becomes especially useful. The lexer can inspect the next character without accidentally consuming something that belongs to the next token.
Reading numbers
Numbers need a little more thought.
When the lexer sees:
35
it should not treat it as two separate numbers:
Number(3)
Number(5)
Instead, it should keep reading until the number ends.
The pseudocode is:
Start with an empty number string.
While the next character is a digit or decimal point:
Add it to the number string.
Consume the character.
Convert the collected string into a number.
For an input like:
123.45
the lexer gradually builds:
"1"
"12"
"123"
"123."
"123.4"
"123.45"
Rust can then convert the collected string into a floating-point value:
num.parse::<f64>()
Using f64 is a reasonable choice for this project because it allows decimal values. It is worth remembering that floating-point numbers cannot represent every decimal value perfectly, but that is not a problem for a small learning calculator.
Handling invalid input
The lexer should not silently ignore characters it does not understand.
For example, this should not be accepted:
3 + hello
Instead, the calculator should return a useful error.
In pseudocode:
If the character does not match anything known:
Return an error explaining which character was unexpected.
In Rust:
_ => return Err(format!("Unexpected input, what did you do? character: {c}")),
The underscore means “anything else”.
Putting it all together
After building the lexer piece by piece, this is the full version I have written so far:
#[derive(Debug, PartialEq)]
//All the different types of input tokens we know.
enum Token {
Number(f64),
Plus,
Minus,
Star,
Slash,
Lpar,
Rpar,
Power,
}
// The Lexer, takes input and turns it into a token vector
fn lex(input: &str) -> Result<Vec<Token>, String> {
let mut tokens = Vec::new();
let mut chars = input.chars().peekable();
// As long as chars.peek() return some value, keep looping
while let Some(&c) = chars.peek() {
match c {
//If whitespace, we don't care and just go to next
' ' | '\t' | '\n' | '\r' => {
chars.next();
}
//If it's a token, we do!
'+' => {
chars.next();
tokens.push(Token::Plus)
}
'-' => {
chars.next();
tokens.push(Token::Minus)
}
'*' => {
//A bit of a special case since I want ** to be 'To the power of'
chars.next(); //We consume it
if chars.peek() == Some(&'*') {
//If there is another star
chars.next(); //Consume it as well
tokens.push(Token::Power);
} else {
tokens.push(Token::Star);
}
// tokens.push(Token::Star)
}
'/' => {
chars.next();
tokens.push(Token::Slash)
}
'(' => {
chars.next();
tokens.push(Token::Lpar)
}
')' => {
chars.next();
tokens.push(Token::Rpar)
}
//If it's a number, we need to parse it and accmulate them until we hit a non-number character
'0'..='9' | '.' => {
let mut num = String::new();
while let Some(&d) = chars.peek() {
//As long as it's a number or a dot, we keep accumulating
if d.is_ascii_digit() || d == '.' {
num.push(d); // Push the character to our number string
chars.next(); // Consume the character
} else {
break;
}
}
let value = num
.parse::<f64>()
.map_err(|_| format!("Invalid number: {}", num))?; // Try to parse the accumulated string into a f64, if it fails return an error
tokens.push(Token::Number(value));
}
//If it's not any of these then I simply don't know what to do with it, so I return an error
_ => return Err(format!("Unexpected input, what did you do? character: {c}")),
}
}
Ok(tokens)
}
fn main() {
print!("{:?}", lex("3 + 4 * (2 - 1) ** 2"));
}
The lexer now understands numbers, parentheses, whitespace, the four basic arithmetic operators, and ** for exponentiation.
The next step is the parser.