diff --git a/docs/plans/cli_calculator.md b/docs/plans/cli_calculator.md index 88e59ff..9e092cf 100644 --- a/docs/plans/cli_calculator.md +++ b/docs/plans/cli_calculator.md @@ -369,17 +369,22 @@ Error: division by zero decimo> exit ``` -| # | Task | Status | Notes | -| --- | --------------------------------------- | :----: | -------------------------------------------------------------------------- | -| 4.1 | No-args + TTY → launch REPL | ✓ | Replace "no expression" error with REPL auto-launch when terminal detected | -| 4.2 | Read-eval-print loop | ✓ | `read_line()` via `getchar()`; one expression per line | -| 4.3 | Custom prompt (`decimo>`) | ✓ | Coloured prompt to stderr so results can be piped | -| 4.4 | `ans` variable (previous result) | ✗ | Injected as a constant into the evaluator; starts as `0` | -| 4.5 | Variable assignment (`x = expr`) | ✗ | Parse `name = expr` syntax; store in a name→BigDecimal map | -| 4.6 | Meta-commands (`:precision N`, `:vars`) | ✗ | `:` prefix avoids collision with expressions | -| 4.7 | Graceful exit (`exit`, `quit`, Ctrl-D) | ✓ | | -| 4.8 | Error recovery (don't crash session) | ✓ | Catch exceptions per-line, display error, continue loop | -| 4.9 | History (if Mojo gets readline support) | ✗ | Future — depends on Mojo FFI evolution | +| # | Task | Status | Notes | +| ---- | ------------------------------------------ | :----: | --------------------------------------------------------------------------- | +| 4.1 | No-args + TTY → launch REPL | ✓ | Replace "no expression" error with REPL auto-launch when terminal detected | +| 4.2 | Read-eval-print loop | ✓ | `read_line()` via `getchar()`; one expression per line | +| 4.3 | Custom prompt (`decimo>`) | ✓ | Coloured prompt to stderr so results can be piped | +| 4.4 | `ans` variable (previous result) | ✓ | Stored in `Dict[String, Decimal]`; updated after each successful evaluation | +| 4.5 | Variable assignment (`x = expr`) | ✓ | `name = expr` detection in REPL; protected names (pi, e, functions, ans) | +| 4.6 | Meta-commands (`:precision N`, `:vars`) | ✗ | `:` prefix avoids collision with expressions, allow short aliases | +| 4.7 | One-line quick setting | ✗ | `:p 100 s down` sets precision, scientific notation, and round_down mode | +| 4.8 | Same-line temp precision setting | ✗ | `2*sqrt(1.23):p 100 s down` for a temporary setting for the expression | +| 4.9 | Print settings (`:settings`) | ✗ | Display current precision, formatting options, etc. | +| 4.10 | Variable listing (`:vars` and `:variable`) | ✗ | List all user-defined variables and their values | +| 4.11 | Everything in the REPL is case-insensitive | ✗ | Map all input chars to lower case at pre-tokenizer stage | +| 4.12 | Graceful exit (`exit`, `quit`, Ctrl-D) | ✓ | | +| 4.13 | Error recovery (don't crash session) | ✓ | Catch exceptions per-line, display error, continue loop | +| 4.14 | History (if Mojo gets readline support) | ✗ | Future — depends on Mojo FFI evolution | ### Phase 5: Future Enhancements @@ -387,11 +392,12 @@ decimo> exit 2. Detect full-width digits/operators for CJK users while parsing. 3. Response files (`@expressions.txt`) — when Mojo compiler bug is fixed, use ArgMojo's `cmd.response_file_prefix("@")`. -| # | Task | Status | Notes | -| --- | ------------------------------------------- | :----: | ------------------------------------------------------------------------------ | -| 5.1 | Build and distribute as single binary | ✗ | Defer until REPL is stable; Homebrew, GitHub Releases, `curl \| sh` installer | -| 5.2 | Full-width digit/operator detection for CJK | ✗ | Tokenizer-level handling for CJK users | -| 5.3 | Response files (`@expressions.txt`) | ✗ | Blocked on Mojo compiler bug; `cmd.response_file_prefix("@")` ready when fixed | +| # | Task | Status | Notes | +| --- | ------------------------------------------- | :----: | --------------------------------------------------------------------------------- | +| 5.1 | Full-width digit/operator detection for CJK | ✗ | Tokenizer-level handling for CJK users | +| 5.2 | Full-width to half-width normalization | ✗ | Pre-tokenizer normalization step to convert full-width chars to ASCII equivalents | +| 5.3 | Build and distribute as single binary | ✗ | Defer until REPL is stable; Homebrew, GitHub Releases, `curl \| sh` installer | +| 5.4 | Response files (`@expressions.txt`) | ✗ | Blocked on Mojo compiler bug; `cmd.response_file_prefix("@")` ready when fixed | ## Design Decisions diff --git a/docs/todo.md b/docs/todo.md index 737ec72..b9da8ae 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -8,9 +8,10 @@ This is a to-do list for Decimo. - [ ] Implement different methods for adding decimo types with `Int` types so that an implicit conversion is not required. - [ ] Use debug mode to check for unnecessary zero words before all arithmetic operations. This will help ensure that there are no zero words, which can simplify the speed of checking for zero because we only need to check the first word. - [ ] Check the `floor_divide()` function of `BigUInt`. Currently, the speed of division between imilar-sized numbers are okay, but the speed of 2n-by-n, 4n-by-n, and 8n-by-n divisions decreases unproportionally. This is likely due to the segmentation of the dividend in the Burnikel-Ziegler algorithm. +- [ ] Consider using `Decimal` as the struct name instead of `BigDecimal`, and use `comptime BigDecimal = Decimal` to create an alias for the `Decimal` struct. This just switches the alias and the struct name, but it may be more intuitive to use `Decimal` as the struct name since it is more consistent with Python's `decimal.Decimal`. Moreover, hovering over `Decimal` will show the docstring of the struct, which is more intuitive than hovering over `BigDecimal` to see the docstring of the struct. - [x] (PR #127, #128, #131) Make all default constructor "safe", which means that the words are checked and normalized to ensure that there are no zero words and that the number is in a valid state. This will help prevent bugs and ensure that all `BigUInt` instances are in a consistent state. Also allow users to create "unsafe" `BigUInt` instances if they want to, but there must be a key-word only argument, e.g., `raw_words`. -- [x] (#31) The `exp()` function performs slower than Python's counterpart in specific cases. Detailed investigation reveals the bottleneck stems from multiplication operations between decimals with significant fractional components. These operations currently rely on UInt256 arithmetic, which introduces performance overhead. Optimization of the `multiply()` function is required to address these performance bottlenecks, particularly for high-precision decimal multiplication with many digits after the decimal point. +- [x] (#31) The `exp()` function performs slower than Python's counterpart in specific cases. Detailed investigation reveals the bottleneck stems from multiplication operations between decimals with significant fractional components. These operations currently rely on UInt256 arithmetic, which introduces performance overhead. Optimization of the `multiply()` function is required to address these performance bottlenecks, particularly for high-precision decimal multiplication with many digits after the decimal point. Internally, also use `Decimal` instead of `BigDecimal` or `BDec` to be consistent. - [x] Implement different methods for augmented arithmetic assignments to improve memeory-efficiency and performance. - [x] Implement a method `remove_leading_zeros` for `BigUInt`, which removes the zero words from the most significant end of the number. - [x] Use debug mode to check for uninitialized `BigUInt` before all arithmetic operations. This will help ensure that there are no uninitialized `BigUInt`. diff --git a/src/cli/calculator/__init__.mojo b/src/cli/calculator/__init__.mojo index 83fda28..0b8b0a0 100644 --- a/src/cli/calculator/__init__.mojo +++ b/src/cli/calculator/__init__.mojo @@ -41,10 +41,16 @@ from .tokenizer import ( TOKEN_FUNC, TOKEN_CONST, TOKEN_COMMA, + TOKEN_VARIABLE, ) from .parser import parse_to_rpn from .evaluator import evaluate_rpn, evaluate -from .engine import evaluate_and_print, display_calc_error, pad_to_precision +from .engine import ( + evaluate_and_print, + evaluate_and_return, + display_calc_error, + pad_to_precision, +) from .display import print_error, print_warning, print_hint, write_prompt from .io import ( stdin_is_tty, diff --git a/src/cli/calculator/engine.mojo b/src/cli/calculator/engine.mojo index 2b58d46..5100af0 100644 --- a/src/cli/calculator/engine.mojo +++ b/src/cli/calculator/engine.mojo @@ -22,7 +22,9 @@ used by both one-shot/pipe/file modes (main.mojo) and the interactive REPL (repl.mojo). """ +from decimo import Decimal from decimo.rounding_mode import RoundingMode +from std.collections import Dict from .tokenizer import tokenize from .parser import parse_to_rpn from .evaluator import evaluate_rpn, final_round @@ -38,6 +40,7 @@ def evaluate_and_print( delimiter: String, rounding_mode: RoundingMode, show_expr_on_error: Bool = False, + variables: Dict[String, Decimal] = Dict[String, Decimal](), ) raises: """Tokenize, parse, evaluate, and print one expression. @@ -54,12 +57,13 @@ def evaluate_and_print( rounding_mode: Rounding mode for the final result. show_expr_on_error: If True, show the expression with a caret indicator on error. If False, show only the error message. + variables: A name→value mapping of user-defined variables. """ try: - var tokens = tokenize(expr) + var tokens = tokenize(expr, variables) var rpn = parse_to_rpn(tokens^) var value = final_round( - evaluate_rpn(rpn^, precision), precision, rounding_mode + evaluate_rpn(rpn^, precision, variables), precision, rounding_mode ) if scientific: @@ -149,3 +153,45 @@ def pad_to_precision(plain: String, precision: Int) -> String: return plain return plain + "0" * (precision - frac_len) + + +def evaluate_and_return( + expr: String, + precision: Int, + scientific: Bool, + engineering: Bool, + pad: Bool, + delimiter: String, + rounding_mode: RoundingMode, + variables: Dict[String, Decimal] = Dict[String, Decimal](), +) raises -> Decimal: + """Tokenize, parse, evaluate, print, and return the result. + + Like `evaluate_and_print` but also returns the `Decimal` value so the + REPL can store it in `ans` or a named variable. + + On error, displays a coloured diagnostic and raises to signal failure + to the caller. + """ + try: + var tokens = tokenize(expr, variables) + var rpn = parse_to_rpn(tokens^) + var value = final_round( + evaluate_rpn(rpn^, precision, variables), precision, rounding_mode + ) + + if scientific: + print(value.to_string(scientific=True, delimiter=delimiter)) + elif engineering: + print(value.to_string(engineering=True, delimiter=delimiter)) + elif pad: + print( + pad_to_precision(value.to_string(force_plain=True), precision) + ) + else: + print(value.to_string(delimiter=delimiter)) + + return value^ + except e: + display_calc_error(String(e), expr) + raise e^ diff --git a/src/cli/calculator/evaluator.mojo b/src/cli/calculator/evaluator.mojo index 66f8b52..bbfc7eb 100644 --- a/src/cli/calculator/evaluator.mojo +++ b/src/cli/calculator/evaluator.mojo @@ -20,8 +20,9 @@ RPN evaluator for the Decimo CLI calculator. Evaluates a Reverse Polish Notation token list using BigDecimal arithmetic. """ -from decimo import BDec +from decimo import Decimal from decimo.rounding_mode import RoundingMode +from std.collections import Dict from .tokenizer import ( Token, @@ -34,6 +35,7 @@ from .tokenizer import ( TOKEN_CARET, TOKEN_FUNC, TOKEN_CONST, + TOKEN_VARIABLE, ) from .parser import parse_to_rpn from .tokenizer import tokenize @@ -45,7 +47,7 @@ from .tokenizer import tokenize def _call_func( - name: String, mut stack: List[BDec], precision: Int, position: Int + name: String, mut stack: List[Decimal], precision: Int, position: Int ) raises: """Pop argument(s) from `stack`, call the named Decimo function, and push the result back. @@ -168,7 +170,11 @@ def _call_func( # ===----------------------------------------------------------------------=== # -def evaluate_rpn(rpn: List[Token], precision: Int) raises -> BDec: +def evaluate_rpn( + rpn: List[Token], + precision: Int, + variables: Dict[String, Decimal] = Dict[String, Decimal](), +) raises -> Decimal: """Evaluate an RPN token list using BigDecimal arithmetic. Internally uses `working_precision = precision + GUARD_DIGITS` for all @@ -176,6 +182,13 @@ def evaluate_rpn(rpn: List[Token], precision: Int) raises -> BDec: responsible for rounding the final result to `precision` significant digits (see `final_round`). + Args: + rpn: The Reverse Polish Notation token list. + precision: Number of significant digits. + variables: A name→value mapping of user-defined variables (e.g. + `ans`, `x`). If a TOKEN_VARIABLE token's name is not found + in this dict, an error is raised. + Raises: Error: On division by zero, missing operands, or other runtime errors — with source position when available. @@ -186,19 +199,19 @@ def evaluate_rpn(rpn: List[Token], precision: Int) raises -> BDec: # accumulated rounding errors from intermediate operations. comptime GUARD_DIGITS = 9 var working_precision = precision + GUARD_DIGITS - var stack = List[BDec]() + var stack = List[Decimal]() for i in range(len(rpn)): var kind = rpn[i].kind if kind == TOKEN_NUMBER: - stack.append(BDec.from_string(rpn[i].value)) + stack.append(Decimal.from_string(rpn[i].value)) elif kind == TOKEN_CONST: if rpn[i].value == "pi": - stack.append(BDec.pi(working_precision)) + stack.append(Decimal.pi(working_precision)) elif rpn[i].value == "e": - stack.append(BDec.e(working_precision)) + stack.append(Decimal.e(working_precision)) else: raise Error( "Error at position " @@ -208,6 +221,19 @@ def evaluate_rpn(rpn: List[Token], precision: Int) raises -> BDec: + "'" ) + elif kind == TOKEN_VARIABLE: + var var_name = rpn[i].value + if var_name in variables: + stack.append(variables[var_name].copy()) + else: + raise Error( + "Error at position " + + String(rpn[i].position) + + ": undefined variable '" + + var_name + + "'" + ) + elif kind == TOKEN_UNARY_MINUS: if len(stack) < 1: raise Error( @@ -306,10 +332,10 @@ def evaluate_rpn(rpn: List[Token], precision: Int) raises -> BDec: def final_round( - value: BDec, + value: Decimal, precision: Int, rounding_mode: RoundingMode = RoundingMode.half_even(), -) raises -> BDec: +) raises -> Decimal: """Round a BigDecimal to `precision` significant digits. This should be called on the result of `evaluate_rpn` before @@ -327,7 +353,7 @@ def evaluate( expr: String, precision: Int = 50, rounding_mode: RoundingMode = RoundingMode.half_even(), -) raises -> BDec: +) raises -> Decimal: """Evaluate a math expression string and return a BigDecimal result. This is the main entry point for the calculator engine. diff --git a/src/cli/calculator/parser.mojo b/src/cli/calculator/parser.mojo index 66b5f58..0446979 100644 --- a/src/cli/calculator/parser.mojo +++ b/src/cli/calculator/parser.mojo @@ -34,16 +34,17 @@ from .tokenizer import ( TOKEN_FUNC, TOKEN_CONST, TOKEN_COMMA, + TOKEN_VARIABLE, ) def parse_to_rpn(tokens: List[Token]) raises -> List[Token]: - """Converts infix tokens to Reverse Polish Notation using - Dijkstra's shunting-yard algorithm. + """Converts infix tokens to Reverse Polish Notation using Dijkstra's + shunting-yard algorithm. - Supports binary operators (+, -, *, /, ^), unary minus, - function calls (sqrt, ln, …), constants (pi, e), and commas - for multi-argument functions like root(x, n). + Supports binary operators (+, -, *, /, ^), unary minus, function calls + (sqrt, ln, …), constants (pi, e), and commas for multi-argument functions + like root(x, n). Raises: Error: On mismatched parentheses, misplaced commas, or trailing @@ -55,8 +56,12 @@ def parse_to_rpn(tokens: List[Token]) raises -> List[Token]: for i in range(len(tokens)): var kind = tokens[i].kind - # Numbers and constants go straight to output - if kind == TOKEN_NUMBER or kind == TOKEN_CONST: + # Numbers, constants, and variables go straight to output + if ( + kind == TOKEN_NUMBER + or kind == TOKEN_CONST + or kind == TOKEN_VARIABLE + ): output.append(tokens[i]) # Functions are pushed onto the operator stack diff --git a/src/cli/calculator/repl.mojo b/src/cli/calculator/repl.mojo index 87822c0..27a641d 100644 --- a/src/cli/calculator/repl.mojo +++ b/src/cli/calculator/repl.mojo @@ -21,27 +21,32 @@ Launched when `decimo` is invoked with no expression and stdin is a TTY. Reads one expression per line, evaluates it, prints the result, and loops until the user types `exit`, `quit`, or presses Ctrl-D. -Architecture notes for future PRs: - -- `ans` variable (4.4): The evaluator will need a `variables` dict - passed into `evaluate_rpn`. The REPL will inject `ans` after each - successful evaluation. The tokenizer already treats unknown identifiers - as errors, so it will need a `known_names: Set[String]` parameter. +Features: +- `ans` — automatically holds the result of the last successful evaluation. +- Variable assignment — `x = ` stores a named value for later use. +- Error recovery — display error and continue, don't crash the session. -- Variable assignment (4.5): The REPL will detect `name = expr` syntax - *before* calling the evaluator (simple string split on first `=` that - is not inside parentheses). The result is stored in the variables dict. +Architecture notes for future PRs: - Meta-commands (4.6): Lines starting with `:` are intercepted before evaluation. Examples: `:precision 100`, `:vars`, `:help`. """ from std.sys import stderr +from std.collections import Dict +from decimo import Decimal from decimo.rounding_mode import RoundingMode -from .display import write_prompt -from .engine import evaluate_and_print +from .display import BOLD, RESET, YELLOW +from .display import write_prompt, print_error +from .engine import evaluate_and_return from .io import read_line, strip, is_comment_or_blank +from .tokenizer import ( + is_alpha_or_underscore, + is_alnum_or_underscore, + is_known_function, + is_known_constant, +) def run_repl( @@ -57,11 +62,18 @@ def run_repl( Prints a welcome banner, then loops: prompt → read → eval → print. Errors are caught per-line and displayed without crashing the session. The loop exits on `exit`, `quit`, or EOF (Ctrl-D). + + Maintains a variable store with: + - `ans`: automatically updated after each successful evaluation. + - User-defined variables via `name = expr` assignment syntax. """ _print_banner( precision, scientific, engineering, pad, delimiter, rounding_mode ) + var variables = Dict[String, Decimal]() + variables["ans"] = Decimal() # "0" by default, updated after each eval + while True: write_prompt("decimo> ") @@ -81,23 +93,138 @@ def run_repl( if line == "exit" or line == "quit": break - # Evaluate the expression. evaluate_and_print displays the - # error itself before raising, so we catch and continue. - # Mojo has no typed exceptions, so we cannot selectively catch - # only user-input errors here. - try: - evaluate_and_print( - line, - precision, - scientific, - engineering, - pad, - delimiter, - rounding_mode, - show_expr_on_error=True, - ) - except: - continue # error already displayed; proceed to next prompt + # Check for variable assignment: `name = expr` + var assignment = _parse_assignment(line) + + if assignment: + var var_name = assignment.value()[0] + var expr = assignment.value()[1] + + # Validate the variable name + var err = _validate_variable_name(var_name) + if err: + print_error(err.value()) + continue + + # Evaluate the expression and store the result + try: + var result = evaluate_and_return( + expr, + precision, + scientific, + engineering, + pad, + delimiter, + rounding_mode, + variables, + ) + variables[var_name] = result.copy() + variables["ans"] = result^ + except: + continue # error already displayed + else: + # Regular expression — evaluate and update ans + try: + var result = evaluate_and_return( + line, + precision, + scientific, + engineering, + pad, + delimiter, + rounding_mode, + variables, + ) + variables["ans"] = result^ + except: + continue # error already displayed + + +def _parse_assignment(line: String) -> Optional[Tuple[String, String]]: + """Detect `name = expr` assignment syntax. + + Returns (variable_name, expression) if the line is an assignment, + or None if it is a regular expression. + + The first `=` that is not `==` and is preceded by a valid identifier + (with optional whitespace) triggers assignment mode. If the identifier + is a function name followed by `(`, it is not an assignment (e.g. + `sqrt(2)` is not `sqrt = ...`). + """ + var line_bytes = StringSlice(line).as_bytes() + var n = len(line_bytes) + + # Skip leading whitespace to find the identifier start + var i = 0 + while i < n and (line_bytes[i] == 32 or line_bytes[i] == 9): + i += 1 + + if i >= n: + return None + + # Must start with alpha or underscore + if not is_alpha_or_underscore(line_bytes[i]): + return None + + var name_start = i + i += 1 + while i < n and is_alnum_or_underscore(line_bytes[i]): + i += 1 + var name_end = i + + # Skip whitespace after name + while i < n and (line_bytes[i] == 32 or line_bytes[i] == 9): + i += 1 + + # Check for '=' (but not '==') + if i >= n or line_bytes[i] != 61: # '=' + return None + if i + 1 < n and line_bytes[i + 1] == 61: # '==' + return None + + # Extract name and expression + var name_bytes = List[UInt8](capacity=name_end - name_start) + for j in range(name_start, name_end): + name_bytes.append(line_bytes[j]) + var var_name = String(unsafe_from_utf8=name_bytes^) + + var expr_start = i + 1 + # Skip whitespace after '=' + while expr_start < n and ( + line_bytes[expr_start] == 32 or line_bytes[expr_start] == 9 + ): + expr_start += 1 + + if expr_start >= n: + return None # `x =` with no expression — treat as regular expression + + var expr_bytes = List[UInt8](capacity=n - expr_start) + for j in range(expr_start, n): + expr_bytes.append(line_bytes[j]) + var expr = String(unsafe_from_utf8=expr_bytes^) + + return (var_name^, expr^) + + +def _validate_variable_name(name: String) -> Optional[String]: + """Validate a variable name for assignment. + + Returns an error message if the name is invalid, or None if valid. + Rejects: + - `ans` (read-only built-in) + - Built-in function names (sqrt, sin, etc.) + - Built-in constant names (pi, e) + """ + if name == "ans": + return ( + "cannot assign to 'ans' (read-only; it always holds the last" + " result)" + ) + if is_known_function(name): + return "cannot assign to '" + name + "' (built-in function)" + if is_known_constant(name): + return "cannot assign to '" + name + "' (built-in constant)" + return None def _print_banner( @@ -109,14 +236,17 @@ def _print_banner( rounding_mode: RoundingMode, ): """Prints the REPL welcome banner to stderr.""" - print( - "Decimo — arbitrary-precision calculator", - file=stderr, - ) - print( - "Type an expression, or 'exit' to quit.", - file=stderr, + comptime message = ( + BOLD + + YELLOW + + "Decimo — arbitrary-precision calculator, written in pure Mojo 🔥\n" + + RESET + + """Type an expression to evaluate, e.g., `pi + sin(-ln(1.23)) * sqrt(e^2)`. +You can assign variables with `name = expression`, e.g., `x = 1.023^365`. +You can use `ans` to refer to the last result. +Type 'exit' or 'quit', or press Ctrl-D, to quit.""" ) + print(message, file=stderr) # Build settings line: "Precision: N. Engineering notation." var settings = "Precision: " + String(precision) + "." diff --git a/src/cli/calculator/tokenizer.mojo b/src/cli/calculator/tokenizer.mojo index c8170c4..600494e 100644 --- a/src/cli/calculator/tokenizer.mojo +++ b/src/cli/calculator/tokenizer.mojo @@ -20,6 +20,9 @@ Tokenizer for the Decimo CLI calculator. Converts an expression string into a list of tokens for the parser. """ +from std.collections import Dict +from decimo import Decimal + # ===----------------------------------------------------------------------=== # # Token kinds # ===----------------------------------------------------------------------=== # @@ -36,6 +39,7 @@ comptime TOKEN_CARET = 8 comptime TOKEN_FUNC = 9 comptime TOKEN_CONST = 10 comptime TOKEN_COMMA = 11 +comptime TOKEN_VARIABLE = 12 # ===----------------------------------------------------------------------=== # @@ -122,7 +126,7 @@ struct Token(Copyable, ImplicitlyCopyable, Movable): # Known function names and built-in constants. -def _is_known_function(name: String) -> Bool: +def is_known_function(name: String) -> Bool: """Returns True if `name` is a recognized function.""" return ( name == "sqrt" @@ -141,32 +145,42 @@ def _is_known_function(name: String) -> Bool: ) -def _is_known_constant(name: String) -> Bool: +def is_known_constant(name: String) -> Bool: """Returns True if `name` is a recognized constant.""" return name == "pi" or name == "e" -def _is_alpha_or_underscore(c: UInt8) -> Bool: +def is_alpha_or_underscore(c: UInt8) -> Bool: """Returns True if c is a-z, A-Z, or '_'.""" return (c >= 65 and c <= 90) or (c >= 97 and c <= 122) or c == 95 -def _is_alnum_or_underscore(c: UInt8) -> Bool: +def is_alnum_or_underscore(c: UInt8) -> Bool: """Returns True if c is a-z, A-Z, 0-9, or '_'.""" - return _is_alpha_or_underscore(c) or (c >= 48 and c <= 57) + return is_alpha_or_underscore(c) or (c >= 48 and c <= 57) -def tokenize(expr: String) raises -> List[Token]: +def tokenize( + expr: String, + known_variables: Dict[String, Decimal] = Dict[String, Decimal](), +) raises -> List[Token]: """Converts an expression string into a list of tokens. Handles: numbers (integer and decimal), operators (+, -, *, /, ^), parentheses, commas, function calls (sqrt, ln, …), built-in - constants (pi, e), and distinguishes unary minus from binary minus. + constants (pi, e), user-defined variables, and distinguishes unary + minus from binary minus. Each token records its 0-based column position in the source expression so that downstream stages can emit user-friendly diagnostics that pinpoint where the problem is. + Args: + expr: The expression string to tokenize. + known_variables: Optional name→value mapping of user-defined + variables. Identifiers matching a key are emitted as + TOKEN_VARIABLE tokens instead of raising an error. + Raises: Error: On empty/whitespace-only input (without position info), unknown identifiers, or unexpected characters (with the @@ -214,10 +228,10 @@ def tokenize(expr: String) raises -> List[Token]: continue # --- Alphabetical identifier: function name or constant --- - if _is_alpha_or_underscore(c): + if is_alpha_or_underscore(c): var start = i i += 1 - while i < n and _is_alnum_or_underscore(ptr[i]): + while i < n and is_alnum_or_underscore(ptr[i]): i += 1 var id_bytes = List[UInt8](capacity=i - start) for j in range(start, i): @@ -225,15 +239,20 @@ def tokenize(expr: String) raises -> List[Token]: var name = String(unsafe_from_utf8=id_bytes^) # Check if it is a known constant - if _is_known_constant(name): + if is_known_constant(name): tokens.append(Token(TOKEN_CONST, name^, position=start)) continue # Check if it is a known function - if _is_known_function(name): + if is_known_function(name): tokens.append(Token(TOKEN_FUNC, name^, position=start)) continue + # Check if it is a known variable + if name in known_variables: + tokens.append(Token(TOKEN_VARIABLE, name^, position=start)) + continue + raise Error( "Error at position " + String(start) diff --git a/src/decimo/__init__.mojo b/src/decimo/__init__.mojo index 1bf013c..5ecf9d1 100644 --- a/src/decimo/__init__.mojo +++ b/src/decimo/__init__.mojo @@ -26,7 +26,7 @@ from decimo import Decimal, BInt, RoundingMode # Core types from .decimal128.decimal128 import Decimal128, Dec128 -from .bigint.bigint import BigInt, BInt +from .bigint.bigint import BigInt, BInt, Integer from .biguint.biguint import BigUInt, BUInt from .bigdecimal.bigdecimal import BigDecimal, BDec, Decimal from .bigfloat.bigfloat import BigFloat, BFlt, Float diff --git a/src/decimo/bigint/bigint.mojo b/src/decimo/bigint/bigint.mojo index 05c128c..da0eeb3 100644 --- a/src/decimo/bigint/bigint.mojo +++ b/src/decimo/bigint/bigint.mojo @@ -48,6 +48,8 @@ from decimo.errors import ( # Type aliases comptime BInt = BigInt """An arbitrary-precision signed integer, similar to Python's `int`.""" +comptime Integer = BigInt +"""An arbitrary-precision signed integer, similar to Python's `int`.""" struct BigInt( diff --git a/src/decimo/prelude.mojo b/src/decimo/prelude.mojo index 8a3f950..c34b432 100644 --- a/src/decimo/prelude.mojo +++ b/src/decimo/prelude.mojo @@ -28,7 +28,7 @@ from decimo.prelude import * import decimo as dm from decimo.decimal128.decimal128 import Decimal128, Dec128 from decimo.bigdecimal.bigdecimal import BigDecimal, BDec, Decimal -from decimo.bigint.bigint import BigInt, BInt +from decimo.bigint.bigint import BigInt, BInt, Integer from decimo.rounding_mode import ( RoundingMode, ROUND_DOWN, diff --git a/tests/cli/test_evaluator.mojo b/tests/cli/test_evaluator.mojo index 7a4927d..c6b52fb 100644 --- a/tests/cli/test_evaluator.mojo +++ b/tests/cli/test_evaluator.mojo @@ -1,8 +1,13 @@ """Test the evaluator: end-to-end expression evaluation with BigDecimal.""" from std import testing +from std.collections import Dict from calculator import evaluate +from calculator.tokenizer import tokenize +from calculator.parser import parse_to_rpn +from calculator.evaluator import evaluate_rpn, final_round +from decimo import Decimal from decimo.rounding_mode import RoundingMode @@ -434,6 +439,75 @@ def test_rounding_half_up_division() raises: testing.assert_equal(result, "0.6667", "2/3 half_up p=4") +# ===----------------------------------------------------------------------=== # +# Tests: variable evaluation (Phase 4) +# ===----------------------------------------------------------------------=== # + + +def _eval_with_vars( + expr: String, variables: Dict[String, Decimal], precision: Int = 50 +) raises -> Decimal: + """Helper to evaluate an expression with variables.""" + var tokens = tokenize(expr, variables) + var rpn = parse_to_rpn(tokens^) + return final_round(evaluate_rpn(rpn^, precision, variables), precision) + + +def test_variable_simple() raises: + """Simple variable reference.""" + var vars = Dict[String, Decimal]() + vars["x"] = Decimal(42) + testing.assert_equal(String(_eval_with_vars("x", vars)), "42") + + +def test_variable_in_arithmetic() raises: + """Variable used in arithmetic.""" + var vars = Dict[String, Decimal]() + vars["x"] = Decimal(10) + testing.assert_equal(String(_eval_with_vars("x + 5", vars)), "15") + + +def test_variable_ans() raises: + """The 'ans' variable works like any other variable.""" + var vars = Dict[String, Decimal]() + vars["ans"] = Decimal(100) + testing.assert_equal(String(_eval_with_vars("ans * 2", vars)), "200") + + +def test_multiple_variables() raises: + """Multiple variables in one expression.""" + var vars = Dict[String, Decimal]() + vars["x"] = Decimal(3) + vars["y"] = Decimal(4) + testing.assert_equal(String(_eval_with_vars("x + y", vars)), "7") + + +def test_variable_with_function() raises: + """Variable as argument to a function.""" + var vars = Dict[String, Decimal]() + vars["x"] = Decimal(4) + testing.assert_equal(String(_eval_with_vars("sqrt(x)", vars)), "2") + + +def test_variable_in_power() raises: + """Variable as base in exponentiation.""" + var vars = Dict[String, Decimal]() + vars["x"] = Decimal(2) + testing.assert_equal(String(_eval_with_vars("x ^ 10", vars)), "1024") + + +def test_undefined_variable_raises() raises: + """Referencing an undefined variable raises an error.""" + var vars = Dict[String, Decimal]() + vars["x"] = Decimal(1) + var raised = False + try: + _ = _eval_with_vars("y + 1", vars) + except: + raised = True + testing.assert_true(raised, "should raise on undefined variable 'y'") + + # ===----------------------------------------------------------------------=== # # Main # ===----------------------------------------------------------------------=== # diff --git a/tests/cli/test_repl.mojo b/tests/cli/test_repl.mojo new file mode 100644 index 0000000..d8d970b --- /dev/null +++ b/tests/cli/test_repl.mojo @@ -0,0 +1,136 @@ +"""Test REPL helpers: _parse_assignment, _validate_variable_name.""" + +from std import testing + +from calculator.repl import _parse_assignment, _validate_variable_name + + +# ===----------------------------------------------------------------------=== # +# Tests: _parse_assignment +# ===----------------------------------------------------------------------=== # + + +def test_simple_assignment() raises: + """Simple `x = 1` is recognized as assignment.""" + var result = _parse_assignment("x = 1") + testing.assert_true(Bool(result), "should parse as assignment") + testing.assert_equal(result.value()[0], "x") + testing.assert_equal(result.value()[1], "1") + + +def test_assignment_with_expression() raises: + """Assignment with a compound expression.""" + var result = _parse_assignment("total = 2 + 3 * 4") + testing.assert_true(Bool(result), "should parse as assignment") + testing.assert_equal(result.value()[0], "total") + testing.assert_equal(result.value()[1], "2 + 3 * 4") + + +def test_assignment_whitespace_around_eq() raises: + """Whitespace around `=` is handled.""" + var result = _parse_assignment("y = 42") + testing.assert_true(Bool(result), "should parse with whitespace") + testing.assert_equal(result.value()[0], "y") + testing.assert_equal(result.value()[1], "42") + + +def test_assignment_underscore_name() raises: + """Variable names with underscores are valid.""" + var result = _parse_assignment("my_var = 10") + testing.assert_true(Bool(result), "underscore name ok") + testing.assert_equal(result.value()[0], "my_var") + testing.assert_equal(result.value()[1], "10") + + +def test_double_eq_not_assignment() raises: + """`==` is not treated as assignment.""" + var result = _parse_assignment("x == 5") + testing.assert_false(Bool(result), "`==` is not assignment") + + +def test_no_eq_not_assignment() raises: + """A plain expression without `=` is not assignment.""" + var result = _parse_assignment("2 + 3") + testing.assert_false(Bool(result), "no `=` means not assignment") + + +def test_number_start_not_assignment() raises: + """Lines starting with a number are not assignments.""" + var result = _parse_assignment("3x = 5") + testing.assert_false(Bool(result), "starts with digit") + + +def test_empty_expr_not_assignment() raises: + """`x =` (no expression after `=`) returns None.""" + var result = _parse_assignment("x = ") + testing.assert_false(Bool(result), "empty expression after =") + + +def test_blank_line_not_assignment() raises: + """Blank input is not assignment.""" + var result = _parse_assignment("") + testing.assert_false(Bool(result), "blank line") + + +def test_leading_whitespace() raises: + """Leading whitespace before the name is handled.""" + var result = _parse_assignment(" x = 7") + testing.assert_true(Bool(result), "leading spaces ok") + testing.assert_equal(result.value()[0], "x") + testing.assert_equal(result.value()[1], "7") + + +# ===----------------------------------------------------------------------=== # +# Tests: _validate_variable_name +# ===----------------------------------------------------------------------=== # + + +def test_valid_name() raises: + """A normal user name is accepted.""" + var err = _validate_variable_name("total") + testing.assert_false(Bool(err), "normal name should be valid") + + +def test_reject_ans() raises: + """`ans` is rejected as a variable name.""" + var err = _validate_variable_name("ans") + testing.assert_true(Bool(err), "ans should be rejected") + + +def test_reject_function_sqrt() raises: + """Built-in function name `sqrt` is rejected.""" + var err = _validate_variable_name("sqrt") + testing.assert_true(Bool(err), "sqrt should be rejected") + + +def test_reject_function_sin() raises: + """Built-in function name `sin` is rejected.""" + var err = _validate_variable_name("sin") + testing.assert_true(Bool(err), "sin should be rejected") + + +def test_reject_constant_pi() raises: + """Built-in constant `pi` is rejected.""" + var err = _validate_variable_name("pi") + testing.assert_true(Bool(err), "pi should be rejected") + + +def test_reject_constant_e() raises: + """Built-in constant `e` is rejected.""" + var err = _validate_variable_name("e") + testing.assert_true(Bool(err), "e should be rejected") + + +def test_valid_name_with_underscore() raises: + """Names with underscores are valid.""" + var err = _validate_variable_name("my_var") + testing.assert_false(Bool(err), "underscore name is valid") + + +# ===----------------------------------------------------------------------=== # +# Main +# ===----------------------------------------------------------------------=== # + + +def main() raises: + testing.TestSuite.discover_tests[__functions_in_module()]().run() diff --git a/tests/cli/test_tokenizer.mojo b/tests/cli/test_tokenizer.mojo index 870e3d5..a567a40 100644 --- a/tests/cli/test_tokenizer.mojo +++ b/tests/cli/test_tokenizer.mojo @@ -1,6 +1,8 @@ """Test the tokenizer: lexical analysis of expression strings.""" from std import testing +from std.collections import Dict +from decimo import Decimal from calculator.tokenizer import ( Token, @@ -17,6 +19,7 @@ from calculator.tokenizer import ( TOKEN_FUNC, TOKEN_CONST, TOKEN_COMMA, + TOKEN_VARIABLE, ) @@ -329,6 +332,73 @@ def test_unknown_identifier() raises: testing.assert_true(raised, "should raise on unknown identifier 'foo'") +# ===----------------------------------------------------------------------=== # +# Tests: variable tokens (Phase 4) +# ===----------------------------------------------------------------------=== # + + +def _make_vars(*names: String) -> Dict[String, Decimal]: + """Build a Dict[String, Decimal] from variadic name arguments.""" + var result = Dict[String, Decimal]() + for i in range(len(names)): + result[names[i]] = Decimal() + return result^ + + +def test_variable_token() raises: + """Known variable names produce TOKEN_VARIABLE tokens.""" + var vars = _make_vars("x", "y") + var toks = tokenize("x + y", vars) + testing.assert_equal(len(toks), 3, "x + y token count") + assert_token(toks, 0, TOKEN_VARIABLE, "x", "variable x") + assert_token(toks, 1, TOKEN_PLUS, "+", "plus") + assert_token(toks, 2, TOKEN_VARIABLE, "y", "variable y") + + +def test_variable_ans() raises: + """The special variable 'ans' is tokenized correctly.""" + var vars = _make_vars("ans") + var toks = tokenize("ans * 2", vars) + testing.assert_equal(len(toks), 3, "ans * 2 token count") + assert_token(toks, 0, TOKEN_VARIABLE, "ans", "variable ans") + + +def test_variable_vs_function() raises: + """Functions take priority over variable names.""" + var vars = _make_vars("sqrt") + var toks = tokenize("sqrt(4)", vars) + assert_token(toks, 0, TOKEN_FUNC, "sqrt", "function, not variable") + + +def test_variable_vs_constant() raises: + """Constants take priority over variable names.""" + var vars = _make_vars("pi") + var toks = tokenize("pi", vars) + assert_token(toks, 0, TOKEN_CONST, "pi", "constant, not variable") + + +def test_unknown_without_known_variables() raises: + """Without known variables, unknown identifiers still raise.""" + var raised = False + try: + _ = tokenize("x + 1") + except: + raised = True + testing.assert_true(raised, "should raise on unknown 'x' without vars") + + +def test_variable_in_expression() raises: + """Variables work in complex expressions.""" + var vars = _make_vars("x", "ans") + var toks = tokenize("x^2 + ans", vars) + testing.assert_equal(len(toks), 5, "x^2 + ans token count") + assert_token(toks, 0, TOKEN_VARIABLE, "x", "var x") + assert_token(toks, 1, TOKEN_CARET, "^", "caret") + assert_token(toks, 2, TOKEN_NUMBER, "2", "number 2") + assert_token(toks, 3, TOKEN_PLUS, "+", "plus") + assert_token(toks, 4, TOKEN_VARIABLE, "ans", "var ans") + + # ===----------------------------------------------------------------------=== # # Main # ===----------------------------------------------------------------------=== #