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) have right_bp = left_bp + 1.
  • Right-associative operators (2 ^ 3 ^ 2 == 2 ^ (3 ^ 2)) have right_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)