-
-
Notifications
You must be signed in to change notification settings - Fork 131
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
chore(book): add simple calculator example (#439)
* chore(book): add simple calculator example * fix(book): footnote * Apply suggestions from code review Co-authored-by: Jérome Eertmans <[email protected]> * Partially apply a suggestion from code review * fix(book): add clone instructions to the simple calculator example * chore(book): add the contributor info to the simple calculator example --------- Co-authored-by: ynn <[email protected]> Co-authored-by: Jérome Eertmans <[email protected]>
- Loading branch information
1 parent
1bf0b73
commit 8d83949
Showing
8 changed files
with
326 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,143 @@ | ||
# Simple calculator | ||
|
||
<sub>This page (including the images) was contributed by [ynn](https://github.com/your-diary).</sub> | ||
|
||
When you implement an interpreter for a [domain-specific language (DSL)](https://en.wikipedia.org/wiki/Domain-specific_language), or any programming language, the process typically involves the following steps: | ||
|
||
1. **Lexing**: Splitting the input stream (i.e., source code string) into tokens via a lexer. | ||
|
||
2. **Parsing**: Converting the tokens into an [abstract syntax tree (AST)](https://en.wikipedia.org/wiki/Abstract_syntax_tree) via a parser. | ||
|
||
3. **Evaluation**: Evaluating the AST to produce the result. | ||
|
||
In this example, we implement a simple calculator that evaluates arithmetic expressions such as `1 + 2 * 3` or `((1 + 2) * 3 + 4) * 2 + 4 / 3`. | ||
|
||
We use `logos` as the lexer generator and [`chumsky`](https://github.com/zesterer/chumsky) as the parser generator. | ||
|
||
![flow chart](/assets/calculator_example_flow.png) | ||
|
||
## 1. Try It | ||
|
||
Before diving into the implementation details, let's play with it[^1]. | ||
|
||
```bash | ||
$ cargo run --example calculator '1 + 7 * (3 - 4) / 2' | ||
``` | ||
|
||
[^1]: You first need to clone [this repository](https://github.com/maciejhirsz/logos). | ||
|
||
**Output**: | ||
|
||
``` | ||
[AST] | ||
Add( | ||
Int( | ||
1, | ||
), | ||
Div( | ||
Mul( | ||
Int( | ||
7, | ||
), | ||
Sub( | ||
Int( | ||
3, | ||
), | ||
Int( | ||
4, | ||
), | ||
), | ||
), | ||
Int( | ||
2, | ||
), | ||
), | ||
) | ||
[result] | ||
-2 | ||
``` | ||
|
||
~~~admonish note title="Full Code" collapsible=true | ||
```rust,no_run,noplayground | ||
{{#include ../../../examples/calculator.rs:all}} | ||
``` | ||
~~~ | ||
|
||
## 2. Lexer | ||
|
||
Our calculator supports the following tokens: | ||
|
||
- Integer literals: `0`, `1`, `15`, etc; | ||
|
||
- Unary operator: `-`; | ||
|
||
- Binary operators: `+`, `-`, `*`, `/`; | ||
|
||
- Parenthesized expressions: `(3 + 5) * 2`, `((1 + 2) * 3 + 4) * 2 + 3 / 2`, etc. | ||
|
||
```rust,no_run,noplayground | ||
{{#include ../../../examples/calculator.rs:tokens}} | ||
``` | ||
|
||
## 3. Parser | ||
|
||
While it is easy enough to manually implement a parser in this case (e.g., [Pratt parsing](https://en.wikipedia.org/wiki/Operator-precedence_parser#Pratt_parsing)), let's just use [`chumsky`](https://github.com/zesterer/chumsky) crate, which is one of the most popular parser generator libraries in Rust. | ||
|
||
### 3.1 AST Definition | ||
|
||
First, we define the AST. | ||
|
||
```rust,no_run,noplayground | ||
{{#include ../../../examples/calculator.rs:ast}} | ||
``` | ||
|
||
Note that | ||
|
||
- We name the enum not `AST` but `Expr` because an AST is just nested expressions. | ||
|
||
- There is no `Parenthesized` variant because parentheses only affect the order of operations (i.e., precedence), which is reflected in the AST structure. | ||
|
||
- `Box` is used as [a recursive enum is not allowed in Rust](https://stackoverflow.com/questions/25296195/why-are-recursive-struct-types-illegal-in-rust). | ||
|
||
### 3.2 Parser Implementation | ||
|
||
Next, we define the parser. The code may look a bit complicated if you are not familiar with parser combinator libraries, but it is actually quite simple. See [Chumsky's official tutorial](https://github.com/zesterer/chumsky/blob/main/tutorial.md) for the details. | ||
|
||
```rust,no_run,noplayground | ||
{{#include ../../../examples/calculator.rs:parser}} | ||
``` | ||
|
||
## 4. Evaluator | ||
|
||
Evaluating the AST is straightforward. We just implement it using [depth-first search (DFS)](https://en.wikipedia.org/wiki/Depth-first_search) such that the mathematical operations are processed in the correct order. | ||
|
||
```rust,no_run,noplayground | ||
{{#include ../../../examples/calculator.rs:evaluator}} | ||
``` | ||
|
||
**Example** | ||
|
||
Evaluating `1 + 3 * 12` will proceed as below. | ||
|
||
![how evaluator works](/assets/calculator_example_how_evaluator_works.png) | ||
|
||
## 5. `main()` Function | ||
|
||
Finally, we put everything together in the `main()` function. | ||
|
||
```rust,no_run,noplayground | ||
{{#include ../../../examples/calculator.rs:main}} | ||
``` | ||
|
||
## 6. Extend the Calculator | ||
|
||
Now that you've implemented a basic calculator, try extending its functionality with the following tasks: | ||
|
||
- **Handle zero-division gracefully**: The current evaluator panics when zero-division occurs. Change the return type of the evaluator from `isize` to `Result<isize, String>`, making it possible to return an error message. | ||
|
||
- **Add support for the modulo operator (`%`)**: Update the lexer, parser, and evaluator to handle expressions like `10 % 3`. | ||
|
||
- **Add support for built-in functions**: Implement built-in functions such as `abs(x)`, `pow(x, y)` or `rand()`. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,174 @@ | ||
//! Simple calculator. | ||
//! | ||
//! Usage: | ||
//! cargo run --example calculator <arithmetic expression> | ||
//! | ||
//! Example: | ||
//! cargo run --example calculator '1 + 7 * (3 - 4) / 2' | ||
//! | ||
//! Following constructs are supported: | ||
//! - integer literals: `0`, `1`, `15`, etc. | ||
//! - unary operator: `-` | ||
//! - binary operators: `+`, `-`, `*`, `/` | ||
//! - parentheses: `(`, `)` | ||
/* ANCHOR: all */ | ||
use std::env; | ||
|
||
use chumsky::prelude::*; | ||
use logos::Logos; | ||
|
||
/* ANCHOR: tokens */ | ||
#[derive(Logos, Debug, PartialEq, Eq, Hash, Clone)] | ||
#[logos(skip r"[ \t\n]+")] | ||
#[logos(error = String)] | ||
enum Token { | ||
#[token("+")] | ||
Plus, | ||
|
||
#[token("-")] | ||
Minus, | ||
|
||
#[token("*")] | ||
Multiply, | ||
|
||
#[token("/")] | ||
Divide, | ||
|
||
#[token("(")] | ||
LParen, | ||
|
||
#[token(")")] | ||
RParen, | ||
|
||
#[regex("[0-9]+", |lex| lex.slice().parse::<isize>().unwrap())] | ||
Integer(isize), | ||
} | ||
/* ANCHOR_END: tokens */ | ||
|
||
/* ANCHOR: ast */ | ||
#[derive(Debug)] | ||
enum Expr { | ||
// Integer literal. | ||
Int(isize), | ||
|
||
// Unary minus. | ||
Neg(Box<Expr>), | ||
|
||
// Binary operators. | ||
Add(Box<Expr>, Box<Expr>), | ||
Sub(Box<Expr>, Box<Expr>), | ||
Mul(Box<Expr>, Box<Expr>), | ||
Div(Box<Expr>, Box<Expr>), | ||
} | ||
/* ANCHOR_END: ast */ | ||
|
||
/* ANCHOR: evaluator */ | ||
impl Expr { | ||
fn eval(&self) -> isize { | ||
match self { | ||
Expr::Int(n) => *n, | ||
Expr::Neg(rhs) => -rhs.eval(), | ||
Expr::Add(lhs, rhs) => lhs.eval() + rhs.eval(), | ||
Expr::Sub(lhs, rhs) => lhs.eval() - rhs.eval(), | ||
Expr::Mul(lhs, rhs) => lhs.eval() * rhs.eval(), | ||
Expr::Div(lhs, rhs) => lhs.eval() / rhs.eval(), | ||
} | ||
} | ||
} | ||
/* ANCHOR_END: evaluator */ | ||
|
||
#[allow(clippy::let_and_return)] | ||
/* ANCHOR: parser */ | ||
fn parser() -> impl Parser<Token, Expr, Error = Simple<Token>> { | ||
recursive(|p| { | ||
let atom = { | ||
let parenthesized = p | ||
.clone() | ||
.delimited_by(just(Token::LParen), just(Token::RParen)); | ||
|
||
let integer = select! { | ||
Token::Integer(n) => Expr::Int(n), | ||
}; | ||
|
||
parenthesized.or(integer) | ||
}; | ||
|
||
let unary = just(Token::Minus) | ||
.repeated() | ||
.then(atom) | ||
.foldr(|_op, rhs| Expr::Neg(Box::new(rhs))); | ||
|
||
let binary_1 = unary | ||
.clone() | ||
.then( | ||
just(Token::Multiply) | ||
.or(just(Token::Divide)) | ||
.then(unary) | ||
.repeated(), | ||
) | ||
.foldl(|lhs, (op, rhs)| match op { | ||
Token::Multiply => Expr::Mul(Box::new(lhs), Box::new(rhs)), | ||
Token::Divide => Expr::Div(Box::new(lhs), Box::new(rhs)), | ||
_ => unreachable!(), | ||
}); | ||
|
||
let binary_2 = binary_1 | ||
.clone() | ||
.then( | ||
just(Token::Plus) | ||
.or(just(Token::Minus)) | ||
.then(binary_1) | ||
.repeated(), | ||
) | ||
.foldl(|lhs, (op, rhs)| match op { | ||
Token::Plus => Expr::Add(Box::new(lhs), Box::new(rhs)), | ||
Token::Minus => Expr::Sub(Box::new(lhs), Box::new(rhs)), | ||
_ => unreachable!(), | ||
}); | ||
|
||
binary_2 | ||
}) | ||
.then_ignore(end()) | ||
} | ||
/* ANCHOR_END: parser */ | ||
|
||
/* ANCHOR: main */ | ||
fn main() { | ||
//reads the input expression from the command line | ||
let input = env::args() | ||
.nth(1) | ||
.expect("Expected expression argument (e.g. `1 + 7 * (3 - 4) / 5`)"); | ||
|
||
//creates a lexer instance from the input | ||
let lexer = Token::lexer(&input); | ||
|
||
//splits the input into tokens, using the lexer | ||
let mut tokens = vec![]; | ||
for (token, span) in lexer.spanned() { | ||
match token { | ||
Ok(token) => tokens.push(token), | ||
Err(e) => { | ||
println!("lexer error at {:?}: {}", span, e); | ||
return; | ||
} | ||
} | ||
} | ||
|
||
//parses the tokens to construct an AST | ||
let ast = match parser().parse(tokens) { | ||
Ok(expr) => { | ||
println!("[AST]\n{:#?}", expr); | ||
expr | ||
} | ||
Err(e) => { | ||
println!("parse error: {:#?}", e); | ||
return; | ||
} | ||
}; | ||
|
||
//evaluates the AST to get the result | ||
println!("\n[result]\n{}", ast.eval()); | ||
} | ||
/* ANCHOR_END: main */ | ||
/* ANCHOR_END: all */ |