diff --git a/docs/plans/cli_calculator.md b/docs/plans/cli_calculator.md index 668d11d..8e7c829 100644 --- a/docs/plans/cli_calculator.md +++ b/docs/plans/cli_calculator.md @@ -269,9 +269,10 @@ Format the final `BigDecimal` result based on CLI flags: 1. Error messages: clear diagnostics for malformed expressions (e.g., "Unexpected token '*' at position 5"). 2. Edge cases: division by zero, negative sqrt, overflow, empty expression. -3. Performance: ensure the tokenizer/parser overhead is negligible compared to BigDecimal computation. -4. Documentation and examples in README. -5. Build and distribute as a single binary. +3. Upgrade to ArgMojo v0.2.0 (once available in pixi). See [ArgMojo v0.2.0 Upgrade Tasks](#argmojo-v020-upgrade-tasks) below. +4. Performance: ensure the tokenizer/parser overhead is negligible compared to BigDecimal computation. +5. Documentation and examples in README. +6. Build and distribute as a single binary. ### Phase 4: Interactive REPL @@ -303,6 +304,71 @@ decimo> exit 1. Detect full-width digits/operators for CJK users while parsing. +--- + +### ArgMojo v0.2.0 Upgrade Tasks + +> **Prerequisite:** ArgMojo ≥ v0.2.0 is available as a pixi package. +> +> Reference: + +Once ArgMojo v0.2.0 lands in pixi, apply the following changes to `decimo`: + +#### 1. Auto-show help when no positional arg is given + +ArgMojo v0.2.0 automatically displays help when a required positional argument is missing — no code change needed on our side. Remove the `.required()` guard if it interferes, or verify the behaviour works out of the box. + +**Current (v0.1.x):** missing `expr` prints a raw error. +**After:** missing `expr` prints the full help text. + +#### 2. Shell-quoting tips via `add_tip()` + +Replace the inline description workaround with ArgMojo's dedicated `add_tip()` API. Tips render as a separate section at the bottom of `--help` output. + +```mojo +cmd.add_tip('If your expression contains *, ( or ), wrap it in quotes:') +cmd.add_tip(' decimo "2 * (3 + 4)"') +cmd.add_tip('Or use noglob: noglob decimo 2*(3+4)') +cmd.add_tip("Or add to ~/.zshrc: alias decimo='noglob decimo'") +``` + +Remove the corresponding note that is currently embedded in the `Command` description string. + +#### 3. Negative number passthrough + +Enable `allow_negative_numbers()` so that expressions like `decimo -3+4` or `decimo -3.14` are treated as math, not as unknown CLI flags. + +```mojo +cmd.allow_negative_numbers() +``` + +#### 4. Rename `Arg` → `Argument` + +`Arg` is kept as an alias in v0.2.0, so this is optional but recommended for consistency with the new API naming. + +```mojo +# Before +from argmojo import Arg, Command +# After +from argmojo import Argument, Command +``` + +#### 5. Colored error messages from ArgMojo + +ArgMojo v0.2.0 produces ANSI-colored stderr errors for its own parse errors (e.g., unknown flags). Our custom `display.mojo` colors still handle calculator-level errors. Verify that both layers look consistent (same RED styling). + +#### 6. Subcommands (Phase 4 REPL prep) + +Although not needed immediately, the new `add_subcommand()` API could later support: + +- `decimo repl` — launch interactive REPL +- `decimo eval "expr"` — explicit one-shot evaluation (current default) +- `decimo help ` — extended help on functions, constants, etc. + +This is deferred to Phase 4 planning. + +--- + ## Design Decisions ### All Numbers Are `BigDecimal` diff --git a/src/cli/calculator/__init__.mojo b/src/cli/calculator/__init__.mojo index 77e6d30..3e8a023 100644 --- a/src/cli/calculator/__init__.mojo +++ b/src/cli/calculator/__init__.mojo @@ -44,3 +44,4 @@ from .tokenizer import ( ) from .parser import parse_to_rpn from .evaluator import evaluate_rpn, evaluate +from .display import print_error, print_warning, print_hint diff --git a/src/cli/calculator/display.mojo b/src/cli/calculator/display.mojo new file mode 100644 index 0000000..a7009c3 --- /dev/null +++ b/src/cli/calculator/display.mojo @@ -0,0 +1,151 @@ +# ===----------------------------------------------------------------------=== # +# Copyright 2025 Yuhao Zhu +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ===----------------------------------------------------------------------=== # + +""" +Display utilities for the Decimo CLI calculator. + +Provides coloured error and warning output to stderr, and a +visual caret indicator that points at the offending position +in an expression. Modelled after ArgMojo's colour system. + +```text + decimo "1 + @ * 2" + Error: unexpected character '@' + 1 + @ * 2 + ^ +``` +""" + +from sys import stderr + +# ── ANSI colour codes ──────────────────────────────────────────────────────── + +comptime RESET = "\x1b[0m" +comptime BOLD = "\x1b[1m" + +# Bright foreground colours. +comptime RED = "\x1b[91m" +comptime GREEN = "\x1b[92m" +comptime YELLOW = "\x1b[93m" +comptime BLUE = "\x1b[94m" +comptime MAGENTA = "\x1b[95m" +comptime CYAN = "\x1b[96m" +comptime WHITE = "\x1b[97m" +comptime ORANGE = "\x1b[33m" # dark yellow — renders as orange on most terminals + +# Semantic aliases. +comptime ERROR_COLOR = RED +comptime WARNING_COLOR = ORANGE +comptime HINT_COLOR = YELLOW +comptime CARET_COLOR = GREEN + + +# ── Public API ─────────────────────────────────────────────────────────────── + + +fn print_error(message: String): + """Print a coloured error message to stderr. + + Format: ``Error: `` + + The label ``Error`` is displayed in bold red. The message text + follows in the default terminal colour. + """ + _write_stderr( + BOLD + ERROR_COLOR + "Error" + RESET + BOLD + ": " + RESET + message + ) + + +fn print_error(message: String, expr: String, position: Int): + """Print a coloured error message with a caret pointing at + the offending position in `expr`. + + Example output (colours omitted for docstring): + + ```text + Error: unexpected character '@' + 1 + @ * 2 + ^ + ``` + + Args: + message: Human-readable error description. + expr: The original expression string. + position: 0-based column index to place the caret indicator. + """ + _write_stderr( + BOLD + ERROR_COLOR + "Error" + RESET + BOLD + ": " + RESET + message + ) + _write_caret(expr, position) + + +fn print_warning(message: String): + """Print a coloured warning message to stderr. + + Format: ``Warning: `` + + The label ``Warning`` is displayed in bold orange/yellow. + """ + _write_stderr( + BOLD + WARNING_COLOR + "Warning" + RESET + BOLD + ": " + RESET + message + ) + + +fn print_warning(message: String, expr: String, position: Int): + """Print a coloured warning message with a caret indicator.""" + _write_stderr( + BOLD + WARNING_COLOR + "Warning" + RESET + BOLD + ": " + RESET + message + ) + _write_caret(expr, position) + + +fn print_hint(message: String): + """Print a coloured hint message to stderr. + + Format: ``Hint: `` + + The label ``Hint`` is displayed in bold cyan. + """ + _write_stderr( + BOLD + HINT_COLOR + "Hint" + RESET + BOLD + ": " + RESET + message + ) + + +# ── Internal helpers ───────────────────────────────────────────────────────── + + +fn _write_stderr(msg: String): + """Write a line to stderr.""" + print(msg, file=stderr) + + +fn _write_caret(expr: String, position: Int): + """Print the expression line and a green caret (^) under the + given column position to stderr. + + ```text + 1 + @ * 2 + ^ + ``` + """ + # Expression line — indented by 2 spaces. + _write_stderr(" " + expr) + + # Caret line — spaces + coloured '^'. + var caret_col = position if position >= 0 else 0 + if caret_col > len(expr): + caret_col = len(expr) + _write_stderr(" " + " " * caret_col + CARET_COLOR + "^" + RESET) diff --git a/src/cli/calculator/evaluator.mojo b/src/cli/calculator/evaluator.mojo index 09e4b8f..e8fc4b1 100644 --- a/src/cli/calculator/evaluator.mojo +++ b/src/cli/calculator/evaluator.mojo @@ -43,7 +43,9 @@ from .tokenizer import tokenize # ===----------------------------------------------------------------------=== # -fn _call_func(name: String, mut stack: List[BDec], precision: Int) raises: +fn _call_func( + name: String, mut stack: List[BDec], precision: Int, position: Int +) raises: """Pop argument(s) from `stack`, call the named Decimo function, and push the result back. @@ -53,11 +55,22 @@ fn _call_func(name: String, mut stack: List[BDec], precision: Int) raises: Two-argument functions: root(x, n) — the n-th root of x. log(x, base) — logarithm of x with the given base. + + Args: + name: The function name. + stack: The operand stack (modified in place). + precision: Decimal precision for the computation. + position: 0-based column of the function token in the source + expression, used for diagnostic messages. """ if name == "root": # root(x, n): x was pushed first, then n if len(stack) < 2: - raise Error("root() requires two arguments: root(x, n)") + raise Error( + "Error at position " + + String(position) + + ": root() requires two arguments, e.g. root(27, 3)" + ) var n_val = stack.pop() var x_val = stack.pop() stack.append(x_val.root(n_val, precision)) @@ -66,7 +79,11 @@ fn _call_func(name: String, mut stack: List[BDec], precision: Int) raises: if name == "log": # log(x, base): x was pushed first, then base if len(stack) < 2: - raise Error("log() requires two arguments: log(x, base)") + raise Error( + "Error at position " + + String(position) + + ": log() requires two arguments, e.g. log(100, 10)" + ) var base_val = stack.pop() var x_val = stack.pop() stack.append(x_val.log(base_val, precision)) @@ -74,16 +91,52 @@ fn _call_func(name: String, mut stack: List[BDec], precision: Int) raises: # All remaining functions take exactly one argument if len(stack) < 1: - raise Error(name + "() requires one argument") + raise Error( + "Error at position " + + String(position) + + ": " + + name + + "() requires one argument" + ) var a = stack.pop() if name == "sqrt": + if a.is_negative(): + raise Error( + "Error at position " + + String(position) + + ": sqrt() is undefined for negative numbers (got " + + String(a) + + ")" + ) stack.append(a.sqrt(precision)) elif name == "cbrt": stack.append(a.cbrt(precision)) elif name == "ln": + if a.is_negative() or a.is_zero(): + raise Error( + "Error at position " + + String(position) + + ": ln() is undefined for " + + ( + "zero" if a.is_zero() else "negative numbers (got " + + String(a) + + ")" + ) + ) stack.append(a.ln(precision)) elif name == "log10": + if a.is_negative() or a.is_zero(): + raise Error( + "Error at position " + + String(position) + + ": log10() is undefined for " + + ( + "zero" if a.is_zero() else "negative numbers (got " + + String(a) + + ")" + ) + ) stack.append(a.log10(precision)) elif name == "exp": stack.append(a.exp(precision)) @@ -100,7 +153,13 @@ fn _call_func(name: String, mut stack: List[BDec], precision: Int) raises: elif name == "abs": stack.append(abs(a)) else: - raise Error("Unknown function: " + name) + raise Error( + "Error at position " + + String(position) + + ": unknown function '" + + name + + "'" + ) # ===----------------------------------------------------------------------=== # @@ -113,6 +172,10 @@ fn evaluate_rpn(rpn: List[Token], precision: Int) raises -> BDec: All numbers are BigDecimal. Division uses `true_divide` with the caller-supplied precision. + + Raises: + Error: On division by zero, missing operands, or other runtime + errors — with source position when available. """ var stack = List[BDec]() @@ -128,57 +191,101 @@ fn evaluate_rpn(rpn: List[Token], precision: Int) raises -> BDec: elif rpn[i].value == "e": stack.append(BDec.e(precision)) else: - raise Error("Unknown constant: " + rpn[i].value) + raise Error( + "Error at position " + + String(rpn[i].position) + + ": unknown constant '" + + rpn[i].value + + "'" + ) elif kind == TOKEN_UNARY_MINUS: if len(stack) < 1: - raise Error("Invalid expression: missing operand for negation") + raise Error( + "Error at position " + + String(rpn[i].position) + + ": missing operand for negation" + ) var a = stack.pop() stack.append(-a) elif kind == TOKEN_PLUS: if len(stack) < 2: - raise Error("Invalid expression: missing operand") + raise Error( + "Error at position " + + String(rpn[i].position) + + ": missing operand for '+'" + ) var b = stack.pop() var a = stack.pop() stack.append(a + b) elif kind == TOKEN_MINUS: if len(stack) < 2: - raise Error("Invalid expression: missing operand") + raise Error( + "Error at position " + + String(rpn[i].position) + + ": missing operand for '-'" + ) var b = stack.pop() var a = stack.pop() stack.append(a - b) elif kind == TOKEN_STAR: if len(stack) < 2: - raise Error("Invalid expression: missing operand") + raise Error( + "Error at position " + + String(rpn[i].position) + + ": missing operand for '*'" + ) var b = stack.pop() var a = stack.pop() stack.append(a * b) elif kind == TOKEN_SLASH: if len(stack) < 2: - raise Error("Invalid expression: missing operand") + raise Error( + "Error at position " + + String(rpn[i].position) + + ": missing operand for '/'" + ) var b = stack.pop() + if b.is_zero(): + raise Error( + "Error at position " + + String(rpn[i].position) + + ": division by zero" + ) var a = stack.pop() stack.append(a.true_divide(b, precision)) elif kind == TOKEN_CARET: if len(stack) < 2: - raise Error("Invalid expression: missing operand") + raise Error( + "Error at position " + + String(rpn[i].position) + + ": missing operand for '^'" + ) var b = stack.pop() var a = stack.pop() stack.append(a.power(b, precision)) elif kind == TOKEN_FUNC: - _call_func(rpn[i].value, stack, precision) + _call_func(rpn[i].value, stack, precision, rpn[i].position) else: - raise Error("Unexpected token in RPN evaluation") + raise Error( + "Error at position " + + String(rpn[i].position) + + ": unexpected token in evaluation" + ) if len(stack) != 1: - raise Error("Invalid expression: too many values remaining") + raise Error( + "Invalid expression: expected a single result but got " + + String(len(stack)) + + " values" + ) return stack.pop() diff --git a/src/cli/calculator/parser.mojo b/src/cli/calculator/parser.mojo index 59eb0fe..ee09a97 100644 --- a/src/cli/calculator/parser.mojo +++ b/src/cli/calculator/parser.mojo @@ -44,6 +44,10 @@ fn parse_to_rpn(tokens: List[Token]) raises -> List[Token]: 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 + operators — with position information when available. """ var output = List[Token]() var op_stack = List[Token]() @@ -68,7 +72,11 @@ fn parse_to_rpn(tokens: List[Token]) raises -> List[Token]: break output.append(op_stack.pop()) if not found_lparen: - raise Error("Misplaced comma or mismatched parentheses") + raise Error( + "Error at position " + + String(tokens[i].position) + + ": misplaced ',' outside of a function call" + ) # Operators: shunt by precedence / associativity elif ( @@ -114,7 +122,11 @@ fn parse_to_rpn(tokens: List[Token]) raises -> List[Token]: break output.append(op_stack.pop()) if not found_lparen: - raise Error("Mismatched parentheses: missing '('") + raise Error( + "Error at position " + + String(tokens[i].position) + + ": unmatched ')'" + ) _ = op_stack.pop() # Discard the '(' # If a function sits on top of the stack, pop it to output @@ -128,7 +140,9 @@ fn parse_to_rpn(tokens: List[Token]) raises -> List[Token]: while len(op_stack) > 0: var top = op_stack.pop() if top.kind == TOKEN_LPAREN: - raise Error("Mismatched parentheses: missing ')'") + raise Error( + "Error at position " + String(top.position) + ": unmatched '('" + ) output.append(top^) return output^ diff --git a/src/cli/calculator/tokenizer.mojo b/src/cli/calculator/tokenizer.mojo index 243e347..945750f 100644 --- a/src/cli/calculator/tokenizer.mojo +++ b/src/cli/calculator/tokenizer.mojo @@ -48,18 +48,25 @@ struct Token(Copyable, ImplicitlyCopyable, Movable): var kind: Int var value: String + var position: Int + """0-based column index in the original expression where this token + starts. Used to produce clear diagnostics such as + ``Error at position 5: unexpected '*'``.""" - fn __init__(out self, kind: Int, value: String = ""): + fn __init__(out self, kind: Int, value: String = "", position: Int = 0): self.kind = kind self.value = value + self.position = position fn __copyinit__(out self, other: Self): self.kind = other.kind self.value = other.value + self.position = other.position fn __moveinit__(out self, deinit other: Self): self.kind = other.kind self.value = other.value^ + self.position = other.position fn is_operator(self) -> Bool: """Returns True if this token is a binary or unary operator.""" @@ -155,6 +162,15 @@ fn tokenize(expr: String) raises -> List[Token]: Handles: numbers (integer and decimal), operators (+, -, *, /, ^), parentheses, commas, function calls (sqrt, ln, …), built-in constants (pi, e), 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. + + Raises: + Error: On empty/whitespace-only input (without position info), + unknown identifiers, or unexpected characters (with the + column position included in the message). """ var tokens = List[Token]() var expr_bytes = expr.as_string_slice().as_bytes() @@ -189,7 +205,11 @@ fn tokenize(expr: String) raises -> List[Token]: for j in range(start, i): num_bytes.append(ptr[j]) tokens.append( - Token(TOKEN_NUMBER, String(unsafe_from_utf8=num_bytes^)) + Token( + TOKEN_NUMBER, + String(unsafe_from_utf8=num_bytes^), + position=start, + ) ) continue @@ -206,25 +226,32 @@ fn tokenize(expr: String) raises -> List[Token]: # Check if it is a known constant if _is_known_constant(name): - tokens.append(Token(TOKEN_CONST, name^)) + tokens.append(Token(TOKEN_CONST, name^, position=start)) continue # Check if it is a known function if _is_known_function(name): - tokens.append(Token(TOKEN_FUNC, name^)) + tokens.append(Token(TOKEN_FUNC, name^, position=start)) continue - raise Error("Unknown identifier '" + name + "' in expression") + raise Error( + "Error at position " + + String(start) + + ": unknown identifier '" + + name + + "'" + ) # --- Operators and parentheses --- if c == 43: # '+' - tokens.append(Token(TOKEN_PLUS, "+")) + tokens.append(Token(TOKEN_PLUS, "+", position=i)) i += 1 continue if c == 45: # '-' # Determine if this minus is unary or binary. # Unary if: at the start, or after an operator, or after '(' or ',' + var pos = i var is_unary = len(tokens) == 0 if not is_unary: var last_kind = tokens[len(tokens) - 1].kind @@ -239,47 +266,56 @@ fn tokenize(expr: String) raises -> List[Token]: or last_kind == TOKEN_COMMA ) if is_unary: - tokens.append(Token(TOKEN_UNARY_MINUS, "neg")) + tokens.append(Token(TOKEN_UNARY_MINUS, "neg", position=pos)) else: - tokens.append(Token(TOKEN_MINUS, "-")) + tokens.append(Token(TOKEN_MINUS, "-", position=pos)) i += 1 continue if c == 42: # '*' # Support '**' as an alias for '^' if i + 1 < n and ptr[i + 1] == 42: - tokens.append(Token(TOKEN_CARET, "^")) + tokens.append(Token(TOKEN_CARET, "^", position=i)) i += 2 else: - tokens.append(Token(TOKEN_STAR, "*")) + tokens.append(Token(TOKEN_STAR, "*", position=i)) i += 1 continue if c == 47: # '/' - tokens.append(Token(TOKEN_SLASH, "/")) + tokens.append(Token(TOKEN_SLASH, "/", position=i)) i += 1 continue if c == 94: # '^' - tokens.append(Token(TOKEN_CARET, "^")) + tokens.append(Token(TOKEN_CARET, "^", position=i)) i += 1 continue if c == 44: # ',' - tokens.append(Token(TOKEN_COMMA, ",")) + tokens.append(Token(TOKEN_COMMA, ",", position=i)) i += 1 continue if c == 40: # '(' - tokens.append(Token(TOKEN_LPAREN, "(")) + tokens.append(Token(TOKEN_LPAREN, "(", position=i)) i += 1 continue if c == 41: # ')' - tokens.append(Token(TOKEN_RPAREN, ")")) + tokens.append(Token(TOKEN_RPAREN, ")", position=i)) i += 1 continue - raise Error("Unexpected character '" + chr(Int(c)) + "' in expression") + raise Error( + "Error at position " + + String(i) + + ": unexpected character '" + + chr(Int(c)) + + "'" + ) + + if len(tokens) == 0: + raise Error("Empty expression") return tokens^ diff --git a/src/cli/main.mojo b/src/cli/main.mojo index 4188659..b5ad368 100644 --- a/src/cli/main.mojo +++ b/src/cli/main.mojo @@ -9,14 +9,38 @@ # ./decimo "100 * 12 - 23/17" -p 50 # ===----------------------------------------------------------------------=== # +from sys import exit + from argmojo import Arg, Command -from calculator import evaluate +from calculator.tokenizer import tokenize +from calculator.parser import parse_to_rpn +from calculator.evaluator import evaluate_rpn +from calculator.display import print_error + +fn main(): + try: + _run() + except e: + # Should not reach here — _run() handles all expected errors. + # This is a last-resort safety net that still avoids the ugly + # "Unhandled exception caught during execution:" message. + print_error(String(e)) + exit(1) -fn main() raises: + +fn _run() raises: var cmd = Command( "decimo", - "Arbitrary-precision CLI calculator powered by Decimo.", + ( + "Arbitrary-precision CLI calculator powered by Decimo.\n" + "\n" + "Note: if your expression contains *, ( or ), your shell may\n" + "intercept them before decimo runs. Use quotes or noglob:\n" + ' decimo "2 * (3 + 4)" # with quotes\n' + " noglob decimo 2*(3+4) # with noglob\n" + " alias decimo='noglob decimo' # add to ~/.zshrc" + ), version="0.1.0", ) @@ -88,16 +112,77 @@ fn main() raises: var pad = result.get_flag("pad") var delimiter = result.get_string("delimiter") - var value = evaluate(expr, precision) + # ── Phase 1: Tokenize & parse ────────────────────────────────────────── + try: + var tokens = tokenize(expr) + var rpn = parse_to_rpn(tokens^) + + # ── Phase 2: Evaluate ──────────────────────────────────────────── + # Syntax was fine — any error here is a math error (division by + # zero, negative sqrt, …). No glob hint needed. + try: + var value = evaluate_rpn(rpn^, precision) + + 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)) + except eval_err: + _display_calc_error(String(eval_err), expr) + exit(1) + + except parse_err: + _display_calc_error(String(parse_err), expr) + exit(1) + + +fn _display_calc_error(error_msg: String, expr: String): + """Parse a calculator error message and display it with colours + and a caret indicator. + + The calculator engine produces errors in two forms: + + 1. ``Error at position N: `` — with position info. + 2. ```` — without position info. + + This function detects form (1), extracts the position, and calls + `print_error(description, expr, position)` so the user sees a + visual caret under the offending column. For form (2) it falls + back to a plain coloured error. + """ + comptime PREFIX = "Error at position " + + if error_msg.startswith(PREFIX): + # Find the colon after the position number. + var after_prefix = len(PREFIX) + var colon_pos = -1 + for i in range(after_prefix, len(error_msg)): + if error_msg[byte=i] == ":": + colon_pos = i + break + + if colon_pos > after_prefix: + # Extract position number and description. + var pos_str = String(error_msg[after_prefix:colon_pos]) + var description = String(error_msg[colon_pos + 2 :]) # skip ": " + + try: + var pos = Int(pos_str) + print_error(description, expr, pos) + return + except: + pass # fall through to plain display - 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)) + # Fallback: no position info — just show the message. + print_error(error_msg) fn _pad_to_precision(plain: String, precision: Int) -> String: diff --git a/tests/cli/test_error_handling.mojo b/tests/cli/test_error_handling.mojo new file mode 100644 index 0000000..8b89f55 --- /dev/null +++ b/tests/cli/test_error_handling.mojo @@ -0,0 +1,298 @@ +"""Test error handling and edge cases for the Decimo CLI calculator. + +Phase 3 items 1 & 2: clear diagnostics for malformed expressions, +and proper handling of edge cases (empty expression, division by zero, +negative sqrt, etc.). +""" + +import testing + +from calculator import evaluate +from calculator.tokenizer import tokenize + + +# ===----------------------------------------------------------------------=== # +# Helper: assert that an expression raises an error whose message +# contains the expected substring. +# ===----------------------------------------------------------------------=== # + + +fn assert_error_contains(expr: String, expected_substr: String) raises: + """Evaluate `expr` and assert that it raises an Error containing + `expected_substr` in its message. + """ + try: + var result = evaluate(expr) + raise Error( + "Expected an error for '" + + expr + + "' but got result: " + + String(result) + ) + except e: + var msg = String(e) + if expected_substr not in msg: + raise Error( + "Error message for '" + + expr + + "' was '" + + msg + + "', expected it to contain '" + + expected_substr + + "'" + ) + + +fn assert_tokenize_error_contains(expr: String, expected_substr: String) raises: + """Tokenize `expr` and assert that it raises an Error containing + `expected_substr`. + """ + try: + var tokens = tokenize(expr) + raise Error( + "Expected a tokenizer error for '" + + expr + + "' but got " + + String(len(tokens)) + + " tokens" + ) + except e: + var msg = String(e) + if expected_substr not in msg: + raise Error( + "Tokenizer error for '" + + expr + + "' was '" + + msg + + "', expected it to contain '" + + expected_substr + + "'" + ) + + +# ===----------------------------------------------------------------------=== # +# Tests: empty and whitespace-only expressions +# ===----------------------------------------------------------------------=== # + + +fn test_empty_expression() raises: + assert_tokenize_error_contains("", "Empty expression") + + +fn test_whitespace_only() raises: + assert_tokenize_error_contains(" ", "Empty expression") + + +fn test_tabs_only() raises: + assert_tokenize_error_contains("\t\t", "Empty expression") + + +# ===----------------------------------------------------------------------=== # +# Tests: unknown identifiers +# ===----------------------------------------------------------------------=== # + + +fn test_unknown_identifier() raises: + assert_error_contains("foo + 1", "unknown identifier 'foo'") + + +fn test_unknown_identifier_position() raises: + assert_error_contains("1 + bar", "position 4") + + +# ===----------------------------------------------------------------------=== # +# Tests: unexpected characters +# ===----------------------------------------------------------------------=== # + + +fn test_unexpected_character() raises: + assert_error_contains("1 @ 2", "unexpected character '@'") + + +fn test_unexpected_character_position() raises: + assert_error_contains("1 + 2 # 3", "position 6") + + +# ===----------------------------------------------------------------------=== # +# Tests: mismatched parentheses +# ===----------------------------------------------------------------------=== # + + +fn test_missing_closing_paren() raises: + assert_error_contains("(1 + 2", "unmatched '('") + + +fn test_missing_opening_paren() raises: + assert_error_contains("1 + 2)", "unmatched ')'") + + +fn test_nested_missing_close() raises: + assert_error_contains("((1+2) * 3", "unmatched '('") + + +fn test_extra_closing_paren() raises: + assert_error_contains("(1+2))", "unmatched ')'") + + +# ===----------------------------------------------------------------------=== # +# Tests: division by zero +# ===----------------------------------------------------------------------=== # + + +fn test_division_by_zero() raises: + assert_error_contains("1/0", "division by zero") + + +fn test_division_by_zero_expression() raises: + assert_error_contains("10 / (5-5)", "division by zero") + + +fn test_division_by_zero_decimal() raises: + assert_error_contains("1 / 0.0", "division by zero") + + +# ===----------------------------------------------------------------------=== # +# Tests: negative sqrt +# ===----------------------------------------------------------------------=== # + + +fn test_sqrt_negative() raises: + assert_error_contains("sqrt(-4)", "sqrt() is undefined for negative") + + +fn test_sqrt_negative_expression() raises: + assert_error_contains("sqrt(-1)", "sqrt() is undefined for negative") + + +# ===----------------------------------------------------------------------=== # +# Tests: logarithm of zero / negative +# ===----------------------------------------------------------------------=== # + + +fn test_ln_zero() raises: + assert_error_contains("ln(0)", "ln() is undefined for zero") + + +fn test_ln_negative() raises: + assert_error_contains("ln(-1)", "ln() is undefined for negative") + + +fn test_log10_zero() raises: + assert_error_contains("log10(0)", "log10() is undefined for zero") + + +fn test_log10_negative() raises: + assert_error_contains("log10(-5)", "log10() is undefined for negative") + + +# ===----------------------------------------------------------------------=== # +# Tests: misplaced commas +# ===----------------------------------------------------------------------=== # + + +fn test_comma_outside_function() raises: + assert_error_contains("1, 2", "misplaced ','") + + +# ===----------------------------------------------------------------------=== # +# Tests: trailing operators +# ===----------------------------------------------------------------------=== # + + +fn test_trailing_plus() raises: + assert_error_contains("1 +", "missing operand for '+'") + + +fn test_trailing_star() raises: + assert_error_contains("1 *", "missing operand for '*'") + + +fn test_trailing_slash() raises: + assert_error_contains("1 /", "missing operand for '/'") + + +fn test_leading_star() raises: + assert_error_contains("* 1", "missing operand for '*'") + + +fn test_leading_slash() raises: + assert_error_contains("/ 1", "missing operand for '/'") + + +# ===----------------------------------------------------------------------=== # +# Tests: consecutive operators (not unary minus) +# ===----------------------------------------------------------------------=== # + + +fn test_double_plus() raises: + """1 ++ should fail: the second + has no left operand.""" + assert_error_contains("1 ++ 2", "missing operand") + + +fn test_double_star() raises: + """1 * * 2 should fail.""" + assert_error_contains("1 * * 2", "missing operand") + + +# ===----------------------------------------------------------------------=== # +# Tests: position information in errors +# ===----------------------------------------------------------------------=== # + + +fn test_position_in_unknown_char() raises: + """The '@' is at position 4 in '1 + @'.""" + assert_error_contains("1 + @", "position 4") + + +fn test_position_in_div_by_zero() raises: + """The '/' is at position 1 in '1/0'.""" + assert_error_contains("1/0", "position 1") + + +fn test_position_in_sqrt_negative() raises: + """'sqrt' starts at position 0 in 'sqrt(-1)'.""" + assert_error_contains("sqrt(-1)", "position 0") + + +# ===----------------------------------------------------------------------=== # +# Tests: edge cases that should still work +# ===----------------------------------------------------------------------=== # + + +fn test_negative_zero() raises: + """Negation of zero should not raise an error.""" + var result = String(evaluate("-0")) + testing.assert_true( + result == "0" or result == "-0", + "-0 should evaluate without error, got: " + result, + ) + + +fn test_deeply_nested_parens() raises: + """((((1)))) should be fine.""" + testing.assert_equal(String(evaluate("((((1))))")), "1", "((((1))))") + + +fn test_many_operations() raises: + """1+2+3+4+5+6+7+8+9+10 = 55.""" + testing.assert_equal( + String(evaluate("1+2+3+4+5+6+7+8+9+10")), "55", "sum 1..10" + ) + + +fn test_function_of_constant() raises: + """Compute sqrt(pi) — should not crash.""" + var result = String(evaluate("sqrt(pi)", precision=10)) + testing.assert_true( + result.startswith("1.77245385"), + "sqrt(pi) starts correctly: " + result, + ) + + +# ===----------------------------------------------------------------------=== # +# Main +# ===----------------------------------------------------------------------=== # + + +fn 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 a954658..bbbfbf2 100644 --- a/tests/cli/test_tokenizer.mojo +++ b/tests/cli/test_tokenizer.mojo @@ -187,8 +187,13 @@ fn test_invalid_character() raises: fn test_empty_string() raises: - var toks = tokenize("") - testing.assert_equal(len(toks), 0, "empty string produces no tokens") + """Empty string should raise an error since Phase 3.""" + var raised = False + try: + _ = tokenize("") + except: + raised = True + testing.assert_true(raised, "empty string should raise an error") # ===----------------------------------------------------------------------=== #