Beginner Projects
Command-Line Calculator
A command-line calculator parses mathematical expressions containing numbers, binary operators (+, -, *, /, %, ^), and nested parentheses, evaluates them according to operator precedence and associativity rules, and reports structured syntax or arithmetic errors.
Expression Evaluation Pipeline:
Input String: `"3 + 4 * (10 - 2) ^ 2"`
|
v 1. Lexer (`tokenize`)
`Vec<Token>`: `[Num(3), Plus, Num(4), Star, LParen, Num(10), Minus, Num(2), RParen, Caret, Num(2)]`
|
v 2. Pratt / Precedence-Climbing Parser (`parse_expr(min_bp)`)
Evaluates respecting precedence (`^` right-assoc > `* / %` > `+ -`)
|
v
`Ok(259.0)` (or `Err(CalcError::DivisionByZero)`)
Operator Precedence and Binding Power Table
Using Pratt parsing (precedence climbing), each infix operator is assigned a (left_binding_power, right_binding_power) pair:
- Left-associative operators (
a - b - c == (a - b) - c) haveright_bp = left_bp + 1. - Right-associative operators (
2 ^ 3 ^ 2 == 2 ^ (3 ^ 2)) haveright_bp = left_bp - 1.
| Operator | Operation | Associativity | (left_bp, right_bp) |
|---|---|---|---|
+, - (Binary) |
Addition, Subtraction | Left | (1, 2) |
*, /, % |
Multiplication, Division, Remainder | Left | (3, 4) |
^ |
Exponentiation (f64::powf) |
Right | (6, 5) |
- (Unary Prefix) |
Negation (-x) |
Prefix | prefix_bp = 7 |
Complete Runnable Implementation
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Token {
Number(f64),
Plus,
Minus,
Star,
Slash,
Percent,
Caret,
LParen,
RParen,
}
#[derive(Debug, PartialEq)]
pub enum CalcError {
InvalidCharacter(char),
InvalidNumber(String),
UnexpectedEof,
UnexpectedToken(Token),
UnmatchedRightParen,
MissingRightParen,
DivisionByZero,
}
impl fmt::Display for CalcError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
CalcError::InvalidCharacter(c) => write!(f, "invalid character: '{c}'"),
CalcError::InvalidNumber(s) => write!(f, "invalid number literal: '{s}'"),
CalcError::UnexpectedEof => write!(f, "unexpected end of expression"),
CalcError::UnexpectedToken(t) => write!(f, "unexpected token: {t:?}"),
CalcError::UnmatchedRightParen => write!(f, "unmatched closing parenthesis ')'"),
CalcError::MissingRightParen => write!(f, "missing closing parenthesis ')'"),
CalcError::DivisionByZero => write!(f, "division by zero"),
}
}
}
pub fn tokenize(input: &str) -> Result<Vec<Token>, CalcError> {
let mut tokens = Vec::new();
let mut chars = input.chars().peekable();
while let Some(&ch) = chars.peek() {
match ch {
c if c.is_ascii_whitespace() => {
chars.next();
}
'+' => {
chars.next();
tokens.push(Token::Plus);
}
'-' => {
chars.next();
tokens.push(Token::Minus);
}
'*' => {
chars.next();
tokens.push(Token::Star);
}
'/' => {
chars.next();
tokens.push(Token::Slash);
}
'%' => {
chars.next();
tokens.push(Token::Percent);
}
'^' => {
chars.next();
tokens.push(Token::Caret);
}
'(' => {
chars.next();
tokens.push(Token::LParen);
}
')' => {
chars.next();
tokens.push(Token::RParen);
}
c if c.is_ascii_digit() || c == '.' => {
let mut num_str = String::new();
while let Some(&d) = chars.peek() {
if d.is_ascii_digit() || d == '.' {
num_str.push(d);
chars.next();
} else {
break;
}
}
let val = num_str
.parse::<f64>()
.map_err(|_| CalcError::InvalidNumber(num_str))?;
tokens.push(Token::Number(val));
}
other => return Err(CalcError::InvalidCharacter(other)),
}
}
Ok(tokens)
}
struct Parser<'a> {
tokens: &'a [Token],
pos: usize,
}
impl<'a> Parser<'a> {
fn peek(&self) -> Option<Token> {
self.tokens.get(self.pos).copied()
}
fn next(&mut self) -> Option<Token> {
let tok = self.peek()?;
self.pos += 1;
Some(tok)
}
fn parse_expr(&mut self, min_bp: u8) -> Result<f64, CalcError> {
// 1. Parse prefix / atom (`Number`, unary `-`/`+`, or parenthesized `( expr )`):
let mut lhs = match self.next().ok_or(CalcError::UnexpectedEof)? {
Token::Number(n) => n,
Token::Plus => self.parse_expr(7)?,
Token::Minus => -self.parse_expr(7)?,
Token::LParen => {
let inner = self.parse_expr(0)?;
match self.next() {
Some(Token::RParen) => inner,
_ => return Err(CalcError::MissingRightParen),
}
}
tok => return Err(CalcError::UnexpectedToken(tok)),
};
// 2. Pratt infix loop:
while let Some(op) = self.peek() {
let Some((l_bp, r_bp)) = infix_binding_power(op) else {
break;
};
if l_bp < min_bp {
break;
}
self.next(); // Consume operator
let rhs = self.parse_expr(r_bp)?;
lhs = apply_infix(op, lhs, rhs)?;
}
Ok(lhs)
}
}
fn infix_binding_power(op: Token) -> Option<(u8, u8)> {
match op {
Token::Plus | Token::Minus => Some((1, 2)),
Token::Star | Token::Slash | Token::Percent => Some((3, 4)),
Token::Caret => Some((6, 5)), // Right-associative (`r_bp < l_bp`)
_ => None,
}
}
fn apply_infix(op: Token, lhs: f64, rhs: f64) -> Result<f64, CalcError> {
match op {
Token::Plus => Ok(lhs + rhs),
Token::Minus => Ok(lhs - rhs),
Token::Star => Ok(lhs * rhs),
Token::Slash => {
if rhs == 0.0 {
Err(CalcError::DivisionByZero)
} else {
Ok(lhs / rhs)
}
}
Token::Percent => {
if rhs == 0.0 {
Err(CalcError::DivisionByZero)
} else {
Ok(lhs % rhs)
}
}
Token::Caret => Ok(lhs.powf(rhs)),
_ => unreachable!(),
}
}
pub fn evaluate(expr: &str) -> Result<f64, CalcError> {
let tokens = tokenize(expr)?;
let mut parser = Parser {
tokens: &tokens,
pos: 0,
};
let result = parser.parse_expr(0)?;
if let Some(extra) = parser.peek() {
if extra == Token::RParen {
return Err(CalcError::UnmatchedRightParen);
}
return Err(CalcError::UnexpectedToken(extra));
}
Ok(result)
}
fn main() {
let cases = [
("3 + 4 * (10 - 2) ^ 2", Ok(259.0)),
("2 ^ 3 ^ 2", Ok(512.0)), // Right-associative: 2^(3^2) = 2^9 = 512
("-10 + 25 / 5", Ok(-5.0)),
("10 / (5 - 5)", Err(CalcError::DivisionByZero)),
];
for (expr, expected) in cases {
let actual = evaluate(expr);
assert_eq!(actual, expected);
println!("{expr:<24} => {actual:?}");
}
}
3 + 4 * (10 - 2) ^ 2 => Ok(259.0)
2 ^ 3 ^ 2 => Ok(512.0)
-10 + 25 / 5 => Ok(-5.0)
10 / (5 - 5) => Err(DivisionByZero)