blob: d53870839e758867293520d8cdc966845db20490 [file] [log] [blame] [view]
# Math Expression Evaluator — Design Document
## Requirements Summary
- Language: Go
- Operators: `+`, `-`, `*`, `/` with parentheses
- Numbers: floating point (e.g. `3.14`, `42`, `0.5`)
- **Functions: user-defined with `f(x) = x + 1` syntax**
- Interface: CLI REPL
- Error handling: print error message, continue REPL
### Function Definition Requirements
- **Syntax:** `f(x) = x + 1` — name, parenthesized params, `=`, body expression
- **Multiple params:** `f(x, y) = x + y`
- **Function calls:** Allowed anywhere a number can appear; arguments are arbitrary expressions
- **Persistence:** Definitions persist across REPL lines
- **Cross-calling:** Functions can call other user-defined functions (late binding)
- **Built-ins:** None
- **Redefinition:** Not allowed (error)
- **Output on define:** Print "defined <name>"
## Approaches Considered
### 1. Recursive-Descent with AST (chosen)
- **Lexer → Parser → AST → Evaluator → REPL**
- Clean separation: each stage is independently testable
- AST is a reusable intermediate representation
- Easy to extend (new operators, pretty-printing, optimization)
- Well-suited for 2 precedence levels + parentheses
### 2. Recursive-Descent with Direct Evaluation
- Parser evaluates inline — no AST
- Fewer types, less code
- Couples parsing and evaluation — harder to test, extend
### 3. Shunting-Yard Algorithm
- Converts to RPN then evaluates
- Good for many precedence levels; overkill here
- Harder to produce clear error messages
**Decision:** Approach 1. The AST adds minimal overhead but provides clean boundaries.
## Architecture
```
Input string
┌───────┐
│ Lexer │ string → []Token
└───┬───┘
┌────────┐
│ Parser │ []Token → Statement (ExprStmt | FuncDef)
└───┬────┘
┌───────────┐
│ Evaluator │ stateful: function registry + expression evaluation
└───┬───────┘
┌──────┐
│ REPL │ read line → parse → route (define or eval) → print
└──────┘
```
## Component Interfaces
### Token (data type)
```go
package token
type Type int
const (
Number Type = iota // numeric literal
Plus // +
Minus // -
Star // *
Slash // /
LParen // (
RParen // )
Ident // identifier (e.g. f, x, myFunc)
Comma // ,
Equals // =
EOF // end of input
)
type Token struct {
Type Type
Literal string // raw text, e.g. "3.14", "+", "f"
Pos int // position in input (for error messages)
}
```
### Lexer
```go
package lexer
// Tokenize converts an input string into a slice of tokens.
// Returns an error if the input contains invalid characters.
// Recognizes: numbers, operators, parens, identifiers, comma, equals.
func Tokenize(input string) ([]token.Token, error)
```
### AST (data types)
```go
package ast
// Node is the interface all expression AST nodes implement.
type Node interface {
node() // sealed marker method
}
// NumberLit represents a numeric literal.
type NumberLit struct {
Value float64
}
// BinaryExpr represents a binary operation (e.g. 1 + 2).
type BinaryExpr struct {
Op token.Type // Plus, Minus, Star, Slash
Left Node
Right Node
}
// Ident represents a variable reference (function parameter).
type Ident struct {
Name string
}
// FuncCall represents a function call (e.g. f(1+2, 3)).
type FuncCall struct {
Name string
Args []Node
}
// Statement is the interface for top-level parsed constructs.
type Statement interface {
stmt() // sealed marker method
}
// ExprStmt wraps an expression used as a statement.
type ExprStmt struct {
Expr Node
}
// FuncDef represents a function definition: name(params) = body
type FuncDef struct {
Name string
Params []string
Body Node
}
```
### Parser
```go
package parser
// Parse converts a slice of tokens into an expression AST.
// Kept for backward compatibility.
func Parse(tokens []token.Token) (ast.Node, error)
// ParseLine converts a slice of tokens into a Statement.
// Distinguishes function definitions from expressions.
func ParseLine(tokens []token.Token) (ast.Statement, error)
```
Grammar (extended):
```
line → funcdef | expr
funcdef → IDENT '(' params ')' '=' expr
params → IDENT (',' IDENT)*
expr → term (('+' | '-') term)*
term → factor (('*' | '/') factor)*
factor → NUMBER | IDENT '(' args ')' | IDENT | '(' expr ')'
args → expr (',' expr)*
```
**Definition detection:** Scan token stream for `Equals` token. If present → parse as function definition. If absent → parse as expression. This works because `=` is not valid in expressions.
### Evaluator
```go
package evaluator
// Evaluator holds function definitions and evaluates expressions.
type Evaluator struct {
funcs map[string]*ast.FuncDef
}
// New creates a new Evaluator with an empty function registry.
func New() *Evaluator
// Define registers a function definition.
// Returns an error if a function with the same name is already defined.
func (e *Evaluator) Define(def *ast.FuncDef) error
// Eval evaluates an expression AST node.
// env provides variable bindings (function parameters).
// Pass nil for top-level evaluation.
func (e *Evaluator) Eval(node ast.Node, env map[string]float64) (float64, error)
```
**Function call evaluation:**
1. Look up function name in registry
2. Evaluate each argument expression in caller's environment
3. Check argument count matches parameter count
4. Create new environment: `param[i] → argValue[i]`
5. Evaluate function body in new environment
**Late binding:** Function body references are resolved at call time, not definition time. This naturally supports cross-function calls as long as the called function is defined before the call is evaluated.
### REPL
```go
package repl
// Run starts the read-eval-print loop, reading from r and writing to w.
// Maintains function registry across lines.
func Run(r io.Reader, w io.Writer)
```
**Line processing flow:**
1. Tokenize line
2. `ParseLine()` → `Statement`
3. Switch on statement type:
- `*ast.FuncDef` → `evaluator.Define(def)`, print "defined <name>"
- `*ast.ExprStmt` → `evaluator.Eval(expr, nil)`, print result
## Package Layout
```
matheval/
├── cmd/
│ └── matheval/
│ └── main.go # entry point, calls repl.Run
├── token/
│ └── token.go # Token type and constants
├── lexer/
│ ├── lexer.go # Tokenize function
│ └── lexer_test.go
├── ast/
│ └── ast.go # AST node types + Statement types
├── parser/
│ ├── parser.go # Parse + ParseLine functions
│ └── parser_test.go
├── evaluator/
│ ├── evaluator.go # Evaluator struct with Define + Eval
│ └── evaluator_test.go
├── repl/
│ ├── repl.go # REPL loop with state
│ └── repl_test.go
├── docs/
│ ├── design.md
│ └── plan.md
├── go.mod
└── README.md
```
## Error Handling
- Lexer: returns error for invalid characters (e.g. `@`, `#`)
- Parser: returns error for syntax errors (unexpected token, mismatched parens, malformed definitions)
- Evaluator: returns error for division by zero, undefined function, undefined variable, argument count mismatch, function redefinition
- REPL: catches any error, prints it, prompts for next input
## Key Design Decisions
1. **Statement vs Node separation** — `Statement` interface separates top-level constructs (definitions vs expressions) from expression nodes. This keeps the expression evaluator clean.
2. **Stateful Evaluator struct** — replaces the previous stateless `Eval()` function. Required to hold the function registry. The `Eval` method still takes an explicit environment for testability.
3. **Late binding** — function bodies reference other functions by name; resolved at call time. Simple and correct.
4. **Backward-compatible Parse()** — existing `Parse()` function kept. New `ParseLine()` added for the REPL.
5. **Sealed AST interface** — unexported marker method prevents external implementations, keeping the node set closed.
6. **Position tracking in tokens** — enables precise error messages.
7. **REPL takes io.Reader/io.Writer** — makes it testable without stdin/stdout.
8. **Definition detection via Equals scan** — simple and unambiguous since `=` cannot appear in expressions.