From 692a823521a666949918269225e957d025538aba Mon Sep 17 00:00:00 2001 From: ZHU Yuhao Date: Sun, 12 Apr 2026 15:31:34 +0200 Subject: [PATCH 1/6] REPL --- benches/cli/bench_cli.sh | 8 +- docs/changelog.md | 1 + docs/plans/cli_calculator.md | 100 ++++++------ src/cli/calculator/__init__.mojo | 4 +- src/cli/calculator/display.mojo | 22 ++- src/cli/calculator/io.mojo | 65 ++++++-- src/cli/calculator/repl.mojo | 225 ++++++++++++++++++++++++++ src/cli/calculator/tokenizer.mojo | 2 +- src/cli/main.mojo | 23 +-- src/decimo/bigdecimal/bigdecimal.mojo | 40 ++--- src/decimo/bigfloat/mpfr_wrapper.mojo | 12 +- src/decimo/bigint/bigint.mojo | 2 +- src/decimo/errors.mojo | 12 +- 13 files changed, 395 insertions(+), 121 deletions(-) create mode 100644 src/cli/calculator/repl.mojo diff --git a/benches/cli/bench_cli.sh b/benches/cli/bench_cli.sh index 87bcd64..33213f8 100644 --- a/benches/cli/bench_cli.sh +++ b/benches/cli/bench_cli.sh @@ -276,10 +276,10 @@ bench_compare "exp(1)" 50 \ "e(1)" \ "${PY_MP};print(mp.exp(1))" -bench_compare "sin(1)" 50 \ - "sin(1)" \ - "s(1)" \ - "${PY_MP};print(mp.sin(1))" +bench_compare "sin(3.1415926535897932384626433833)" 50 \ + "sin(3.1415926535897932384626433833)" \ + "s(3.1415926535897932384626433833)" \ + "${PY_MP};print(mp.sin(3.1415926535897932384626433833))" bench_compare "cos(0)" 50 \ "cos(0)" \ diff --git a/docs/changelog.md b/docs/changelog.md index 9f393a9..e15c24a 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -12,6 +12,7 @@ This is a list of changes for the Decimo package (formerly DeciMojo). 1. Add **file mode**: use `--file` / `-F` flag to evaluate expressions from a file, one per line (e.g. `decimo -F expressions.dm -P 50`). Comments (`#`), inline comments, and blank lines are skipped. All CLI flags (precision, formatting, rounding) apply to every expression. 1. Add **shell completion** documentation for Bash, Zsh, and Fish (`decimo --completions bash|zsh|fish`). 1. Add **CLI performance benchmarks** (`benches/cli/bench_cli.sh`) comparing correctness and timing against `bc` and `python3` across 47 comparisons β€” all results match to 15 significant digits; `decimo` is 3–4Γ— faster than `python3 -c`. +1. Add **interactive REPL**: launch with `decimo` (no arguments, TTY attached). Features coloured `decimo>` prompt on stderr, per-line error recovery with caret diagnostics, comment/blank-line skipping, and graceful exit via `exit`, `quit`, or Ctrl-D. All CLI flags (`-P`, `--scientific`, etc.) apply to the REPL session. ### πŸ¦‹ Changed in v0.10.0 diff --git a/docs/plans/cli_calculator.md b/docs/plans/cli_calculator.md index afa71e4..88e59ff 100644 --- a/docs/plans/cli_calculator.md +++ b/docs/plans/cli_calculator.md @@ -33,8 +33,8 @@ Rows are sorted by implementation priority for `decimo` (top = implement first). 1. **Basic arithmetic + High-precision + Large integers + Pipeline** (Phase 1) β€” These are the raison d'Γͺtre of `decimo`. Decimo already provides arbitrary-precision `BigDecimal`; wiring up tokenizer β†’ parser β†’ evaluator gives immediate value. Pipeline/batch is nearly free once one-shot works (just loop over stdin lines). 2. **Built-in math functions** (Phase 2) β€” `sqrt`, `ln`, `exp`, `sin`, `cos`, `tan`, `root` already exist in the Decimo API. Adding them mostly means extending the tokenizer/parser to recognize function names. 3. **Polish & ArgMojo integration** (Phase 3) β€” Error diagnostics, edge-case handling, and exploiting ArgMojo v0.5.0 features (shell completions, argument groups, numeric range validation, etc.). Mostly CLI UX refinement. -4. **Interactive REPL + Subcommands** (Phase 4) β€” Requires a read-eval-print loop, `ans` tracking, named variable storage, session-level precision management, and CLI restructuring with subcommands. More engineering effort, less urgency. -5. **Future enhancements** (Phase 5) β€” CJK full-width detection, response files, unit conversion, matrix, symbolic. Out of scope for now. +4. **Interactive REPL** (Phase 4) β€” Requires a read-eval-print loop, `ans` tracking, named variable storage, session-level precision management. No subcommands β€” mode is determined by invocation context (same as `bc`, `python3`, `calc`). +5. **Future enhancements** (Phase 5) β€” Binary distribution, CJK full-width detection, response files, unit conversion, matrix, symbolic. Out of scope for now. ## Usage Design @@ -326,25 +326,31 @@ Format the final `BigDecimal` result based on CLI flags: | 3.11 | `Parsable.run()` override | βœ— | Move eval logic into `DecimoArgs.run()` for cleaner separation | | 3.12 | Performance validation | βœ“ | `benches/cli/bench_cli.sh`; 47 correctness checks + timing vs `bc` and `python3` | | 3.13 | Documentation (user manual for CLI) | βœ“ | `docs/user_manual_cli.md`; includes shell completions setup and performance data | -| 3.14 | Build and distribute as single binary | βœ— | | -| 3.15 | Allow negative expressions | βœ“ | `allow_hyphen=True` on `Positional`; `decimo "-3*pi*(sin(1))"` works | -| 3.16 | Make short names upper cases to avoid expression collisions | βœ“ | `-P`, `-S`, `-E`, `-D`, `-R`; `--pad` has no short name; `-e`, `-pi`, `-sin(1)` all work | -| 3.17 | Define `allow_hyphen_values` in declarative API | βœ— | When argmojo supports it | - -### Phase 4: Interactive REPL & Subcommands - -1. Restructure CLI with subcommands: `decimo eval "expr"` (default), `decimo repl`, `decimo help functions`. -2. Persistent flags (`--precision`, `--scientific`, etc.) across subcommands. -3. Subcommand dispatch via `parse_full()`. -4. No-args + TTY detection β†’ launch REPL directly. -5. Read-eval-print loop: read a line from stdin, evaluate, print result, repeat. -6. Custom prompt (`decimo>`). -7. `ans` variable to reference the previous result. -8. Variable assignment: `x = sqrt(2)`, usable in subsequent expressions. -9. Session-level precision: settable via `decimo -p 100` at launch or `:precision 100` command mid-session. -10. Graceful exit: `exit`, `quit`, `Ctrl-D`. -11. Clear error messages without crashing the session (e.g., "Error: division by zero", then continue). -12. History (if Mojo gets readline-like support). +| 3.14 | Allow negative expressions | βœ“ | `allow_hyphen=True` on `Positional`; `decimo "-3*pi*(sin(1))"` works | +| 3.15 | Make short names upper cases to avoid expression collisions | βœ“ | `-P`, `-S`, `-E`, `-D`, `-R`; `--pad` has no short name; `-e`, `-pi`, `-sin(1)` all work | +| 3.16 | Define `allow_hyphen_values` in declarative API | βœ— | When argmojo supports it | + +### Phase 4: Interactive REPL + +No subcommands β€” mode is determined by invocation context (same as `bc`, `python3`, `calc`): + +| Invocation | Mode | +| ---------------------------------- | -------- | +| `decimo "expr"` | One-shot | +| `echo "expr" \| decimo` | Pipe | +| `decimo -F file.dm` | File | +| `decimo` (no args, stdin is a TTY) | REPL | + +Design rationale: subcommands (`decimo eval`, `decimo repl`) create collision risk with expression identifiers (e.g. `log`, `exp`) and multi-arg function names. Every comparable tool (`bc`, `dc`, `calc`, `qalc`, `python3`) uses the same zero-subcommand pattern. + +**REPL features:** + +1. Read-eval-print loop: read a line, evaluate, print result, repeat. +2. `ans` β€” automatically holds the previous result. +3. Variable assignment β€” `x = ` stores a named value. +4. Meta-commands with `:` prefix β€” avoids collision with expressions. +5. Error recovery β€” display error and continue, don't crash the session. +6. Exit via `exit`, `quit`, or Ctrl-D. ```bash $ decimo @@ -356,37 +362,36 @@ decimo> x = sqrt(2) 1.41421356237309504880168872420969807856967187537694 decimo> x ^ 2 2 +decimo> :precision 100 +Precision set to 100 decimo> 1/0 Error: division by zero decimo> exit ``` -| # | Task | Status | Notes | -| ---- | ---------------------------------------- | :----: | --------------------------------------------------------------------------------------------------------------- | -| 4.1 | Subcommand restructure | βœ— | `decimo eval "expr"` (default), `decimo repl`, `decimo help functions`; use `subcommands()` hook | -| 4.2 | Persistent flags across subcommands | βœ— | `precision`, `--scientific`, etc. as `persistent=True`; both `decimo repl -p 100` and `decimo -p 100 repl` work | -| 4.3 | `parse_full()` for subcommand dispatch | βœ— | Typed struct + `ParseResult.subcommand` for dispatching to eval/repl/help handlers | -| 4.4 | No-args + TTY β†’ launch REPL directly | βœ— | Replace `help_on_no_arguments()` with REPL auto-launch when terminal detected | -| 4.5 | Read-eval-print loop | βœ— | | -| 4.6 | Custom prompt (`decimo>`) | βœ— | | -| 4.7 | `ans` variable (previous result) | βœ— | | -| 4.8 | Variable assignment (`x = expr`) | βœ— | | -| 4.9 | Session-level precision (`:precision N`) | βœ— | | -| 4.10 | Graceful exit (`exit`, `quit`, Ctrl-D) | βœ— | | -| 4.11 | Error recovery (don't crash session) | βœ— | | -| 4.12 | Interactive prompting for missing values | βœ— | Use `.prompt()` on subcommand args for interactive precision input, etc. | -| 4.13 | Subcommand aliases | βœ— | `command_aliases(["e"])` for `eval`, `command_aliases(["r"])` for `repl` | -| 4.14 | Hidden subcommands | βœ— | Hide `debug` / internal subcommands from help | +| # | 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 | ### Phase 5: Future Enhancements -1. Detect full-width digits/operators for CJK users while parsing. -2. Response files (`@expressions.txt`) β€” when Mojo compiler bug is fixed, use ArgMojo's `cmd.response_file_prefix("@")`. +1. Build and distribute as a single binary (Homebrew, GitHub Releases, etc.) β€” defer until REPL is stable so first-run experience is complete. +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 | Full-width digit/operator detection for CJK | βœ— | Tokenizer-level handling for CJK users | -| 5.2 | Response files (`@expressions.txt`) | βœ— | Blocked on Mojo compiler bug; `cmd.response_file_prefix("@")` ready when fixed | +| 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 | ## Design Decisions @@ -404,13 +409,12 @@ This is the natural choice for a calculator: users expect `7 / 2` to be `3.5`, n `decimo` automatically detects its mode based on how it is invoked: -| Invocation | Mode | -| ------------------------------------- | ------------------------------------- | -| `decimo "expr"` | One-shot: evaluate and exit | -| `echo "expr" \| decimo` | Pipe: read stdin line by line | -| `decimo -F file.dm` | File: read and evaluate each line | -| `decimo` (no args, terminal is a TTY) | REPL: interactive session | -| `decimo -i` | REPL: force interactive even if piped | +| Invocation | Mode | +| ------------------------------------- | --------------------------------- | +| `decimo "expr"` | One-shot: evaluate and exit | +| `echo "expr" \| decimo` | Pipe: read stdin line by line | +| `decimo -F file.dm` | File: read and evaluate each line | +| `decimo` (no args, terminal is a TTY) | REPL: interactive session | ## Notes diff --git a/src/cli/calculator/__init__.mojo b/src/cli/calculator/__init__.mojo index b416218..d18e7e7 100644 --- a/src/cli/calculator/__init__.mojo +++ b/src/cli/calculator/__init__.mojo @@ -44,9 +44,10 @@ from .tokenizer import ( ) from .parser import parse_to_rpn from .evaluator import evaluate_rpn, evaluate -from .display import print_error, print_warning, print_hint +from .display import print_error, print_warning, print_hint, write_prompt from .io import ( stdin_is_tty, + read_line, read_stdin, split_into_lines, strip_comment, @@ -57,3 +58,4 @@ from .io import ( read_file_text, file_exists, ) +from .repl import run_repl diff --git a/src/cli/calculator/display.mojo b/src/cli/calculator/display.mojo index ab96b81..e3d65a3 100644 --- a/src/cli/calculator/display.mojo +++ b/src/cli/calculator/display.mojo @@ -59,9 +59,9 @@ comptime CARET_COLOR = GREEN def print_error(message: String): """Print a coloured error message to stderr. - Format: ``Error: `` + Format: `Error: ` - The label ``Error`` is displayed in bold red. The message text + The label `Error` is displayed in bold red. The message text follows in the default terminal colour. """ _write_stderr( @@ -95,9 +95,9 @@ def print_error(message: String, expr: String, position: Int): def print_warning(message: String): """Print a coloured warning message to stderr. - Format: ``Warning: `` + Format: `Warning: ` - The label ``Warning`` is displayed in bold orange/yellow. + The label `Warning` is displayed in bold orange/yellow. """ _write_stderr( BOLD + WARNING_COLOR + "Warning" + RESET + BOLD + ": " + RESET + message @@ -115,15 +115,25 @@ def print_warning(message: String, expr: String, position: Int): def print_hint(message: String): """Print a coloured hint message to stderr. - Format: ``Hint: `` + Format: `Hint: ` - The label ``Hint`` is displayed in bold cyan. + The label `Hint` is displayed in bold cyan. """ _write_stderr( BOLD + HINT_COLOR + "Hint" + RESET + BOLD + ": " + RESET + message ) +def write_prompt(prompt: String): + """Write a REPL prompt to stderr (no trailing newline). + + The prompt is written to stderr so that stdout remains clean for + piping results. + """ + var styled = BOLD + GREEN + prompt + RESET + print(styled, end="", file=stderr, flush=True) + + # ── Internal helpers ───────────────────────────────────────────────────────── diff --git a/src/cli/calculator/io.mojo b/src/cli/calculator/io.mojo index 09d599d..3b8b0a2 100644 --- a/src/cli/calculator/io.mojo +++ b/src/cli/calculator/io.mojo @@ -21,7 +21,7 @@ Provides functions for detecting whether stdin is a pipe or terminal, reading lines from stdin, reading expression files, and line-level text processing (comment stripping, whitespace handling). -The text-processing primitives (``strip_comment``, ``is_blank``, ``strip``) +The text-processing primitives (`strip_comment`, `is_blank`, `strip`) are designed to be composable and reusable across all input modes β€” pipe, file, and future REPL. """ @@ -45,11 +45,42 @@ def stdin_is_tty() -> Bool: # ===----------------------------------------------------------------------=== # +def read_line() -> Optional[String]: + """Reads a single line from stdin (up to and including the newline). + + Returns the line content (without the trailing newline), or None + on EOF (e.g. Ctrl-D on an empty line). + + This is designed for REPL use: it reads one character at a time + via `getchar()` and stops at `\\n` or EOF. + """ + var chars = List[UInt8]() + + while True: + var c = external_call["getchar", Int32]() + if c < 0: # EOF + if len(chars) == 0: + return None + break + if UInt8(c) == 10: # '\n' + break + chars.append(UInt8(c)) + + # Strip trailing \r if present (Windows line endings from copy-paste) + if len(chars) > 0 and chars[len(chars) - 1] == 13: + _ = chars.pop() + + if len(chars) == 0: + return String("") + + return String(unsafe_from_utf8=chars^) + + def read_stdin() -> String: """Reads all data from stdin and return it as a String. - Uses the C ``getchar()`` function to read one byte at a time until - EOF. This avoids FFI conflicts with the POSIX ``read()`` syscall. + Uses the C `getchar()` function to read one byte at a time until + EOF. This avoids FFI conflicts with the POSIX `read()` syscall. Returns an empty string if stdin is empty. """ var chunks = List[UInt8]() @@ -75,7 +106,7 @@ def read_stdin() -> String: def split_into_lines(text: String) -> List[String]: """Splits a string into individual lines. - Handles both ``\\n`` and ``\\r\\n`` line endings. + Handles both `\\n` and `\\r\\n` line endings. Trailing empty lines from a final newline are not included. """ var lines = List[String]() @@ -104,13 +135,13 @@ def split_into_lines(text: String) -> List[String]: def strip_comment(line: String) -> String: - """Removes a ``#``-style comment from a line. + """Removes a `#`-style comment from a line. - Returns everything before the first ``#`` character. If there is - no ``#``, the line is returned unchanged. + Returns everything before the first `#` character. If there is + no `#`, the line is returned unchanged. This is a composable primitive β€” use it in combination with - ``strip()`` and ``is_blank()`` for full line processing. + `strip()` and `is_blank()` for full line processing. Examples:: @@ -139,7 +170,7 @@ def is_blank(line: String) -> Bool: """Returns True if the line is empty or contains only whitespace (spaces and tabs). - This is a composable primitive β€” combine with ``strip_comment()`` + This is a composable primitive β€” combine with `strip_comment()` to check for comment-or-blank lines. """ var n = len(line) @@ -159,9 +190,9 @@ def is_blank(line: String) -> Bool: def is_comment_or_blank(line: String) -> Bool: """Returns True if the line is blank, whitespace-only, or a comment - (first non-whitespace character is ``#``). + (first non-whitespace character is `#`). - Equivalent to ``is_blank(strip_comment(line))``. Provided as a + Equivalent to `is_blank(strip_comment(line))`. Provided as a convenience for callers that do not need the intermediate results. """ return is_blank(strip_comment(line)) @@ -199,7 +230,7 @@ def strip(s: String) -> String: def filter_expression_lines(lines: List[String]) -> List[String]: """Filters a list of lines to only those that are valid expressions. - Removes blank lines and comment lines (starting with ``#``). + Removes blank lines and comment lines (starting with `#`). Also strips inline comments and leading/trailing whitespace from each expression line. """ @@ -219,12 +250,12 @@ def filter_expression_lines(lines: List[String]) -> List[String]: def read_file_text(path: String) raises -> String: """Reads the entire contents of a file and returns it as a String. - Uses POSIX ``open()`` + ``dup2()`` + ``getchar()`` to read the file + Uses POSIX `open()` + `dup2()` + `getchar()` to read the file by temporarily redirecting stdin. This avoids FFI signature conflicts - with Mojo's stdlib and ArgMojo for ``read``/``fclose``. + with Mojo's stdlib and ArgMojo for `read`/`fclose`. - The original stdin is saved via ``dup()`` before redirection and - restored afterwards, so callers (e.g. a future REPL ``:load`` command) + The original stdin is saved via `dup()` before redirection and + restored afterwards, so callers (e.g. a future REPL `:load` command) can continue reading from the real stdin after this call returns. Args: @@ -280,7 +311,7 @@ def read_file_text(path: String) raises -> String: def file_exists(path: String) -> Bool: """Returns True if the given path exists as a readable file. - Uses the POSIX ``access()`` syscall with ``R_OK`` (4). + Uses the POSIX `access()` syscall with `R_OK` (4). """ var c_path = _to_cstr(path) # access(path, R_OK=4) returns 0 on success diff --git a/src/cli/calculator/repl.mojo b/src/cli/calculator/repl.mojo new file mode 100644 index 0000000..5af1bd5 --- /dev/null +++ b/src/cli/calculator/repl.mojo @@ -0,0 +1,225 @@ +# ===----------------------------------------------------------------------=== # +# 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. +# ===----------------------------------------------------------------------=== # + +""" +Interactive REPL (Read-Eval-Print Loop) for the Decimo CLI calculator. + +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. + +- 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. + +- Meta-commands (4.6): Lines starting with `:` are intercepted before + evaluation. Examples: `:precision 100`, `:vars`, `:help`. +""" + +from std.sys import stderr + +from decimo.rounding_mode import RoundingMode +from .tokenizer import tokenize +from .parser import parse_to_rpn +from .evaluator import evaluate_rpn, final_round +from .display import print_error, print_hint, write_prompt +from .io import read_line, strip, is_comment_or_blank + + +def run_repl( + precision: Int, + scientific: Bool, + engineering: Bool, + pad: Bool, + delimiter: String, + rounding_mode: RoundingMode, +) raises: + """Run the interactive 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). + """ + _print_banner(precision, scientific, engineering, pad, delimiter) + + while True: + write_prompt("decimo> ") + + var maybe_line = read_line() + if not maybe_line: + # EOF (Ctrl-D) β€” exit gracefully + print(file=stderr) # newline after the prompt + break + + var line = strip(maybe_line.value()) + + # Skip blank lines and comments + if is_comment_or_blank(line): + continue + + # Exit commands + if line == "exit" or line == "quit": + break + + # Evaluate the expression β€” errors are caught and printed, + # then the loop continues. + try: + _evaluate_and_print( + line, + precision, + scientific, + engineering, + pad, + delimiter, + rounding_mode, + ) + except: + pass # error already displayed by _evaluate_and_print + + +def _evaluate_and_print( + expr: String, + precision: Int, + scientific: Bool, + engineering: Bool, + pad: Bool, + delimiter: String, + rounding_mode: RoundingMode, +) raises: + """Evaluate one expression and print the result. + + On error, displays a coloured diagnostic with caret and re-raises + so the REPL loop knows to continue. + """ + try: + var tokens = tokenize(expr) + var rpn = parse_to_rpn(tokens^) + + try: + var value = final_round( + evaluate_rpn(rpn^, precision), 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)) + except eval_err: + _display_calc_error(String(eval_err), expr) + raise eval_err^ + + except parse_err: + _display_calc_error(String(parse_err), expr) + raise parse_err^ + + +def _print_banner( + precision: Int, + scientific: Bool, + engineering: Bool, + pad: Bool, + delimiter: String, +): + """Print the REPL welcome banner to stderr.""" + print( + "Decimo β€” arbitrary-precision calculator", + file=stderr, + ) + print( + "Type an expression, or 'exit' to quit.", + file=stderr, + ) + + # Build settings line: "Precision: N. Engineering notation." + var settings = "Precision: " + String(precision) + "." + if scientific: + settings += " Scientific notation." + elif engineering: + settings += " Engineering notation." + if pad: + settings += " Zero-padded." + if delimiter: + settings += " Delimiter: '" + delimiter + "'." + print(settings, file=stderr) + + +def _display_calc_error(error_msg: String, expr: String): + """Parse and display an error with optional caret indicator. + + Handles two error formats: + 1. `Error at position N: description` β†’ caret display + 2. `description` β†’ plain error + """ + comptime PREFIX = "Error at position " + + if error_msg.startswith(PREFIX): + 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: + var pos_str = String(error_msg[byte=after_prefix:colon_pos]) + var description = String(error_msg[byte = colon_pos + 2 :]) + + try: + var pos = Int(pos_str) + print_error(description, expr, pos) + return + except: + pass + + print_error(error_msg) + + +def _pad_to_precision(plain: String, precision: Int) -> String: + """Pad trailing zeros so the fractional part has exactly + `precision` digits. + """ + if precision <= 0: + return plain + + var dot_pos = -1 + for i in range(len(plain)): + if plain[byte=i] == ".": + dot_pos = i + break + + if dot_pos < 0: + return plain + "." + "0" * precision + + var frac_len = len(plain) - dot_pos - 1 + if frac_len >= precision: + return plain + + return plain + "0" * (precision - frac_len) diff --git a/src/cli/calculator/tokenizer.mojo b/src/cli/calculator/tokenizer.mojo index 14037fe..c8170c4 100644 --- a/src/cli/calculator/tokenizer.mojo +++ b/src/cli/calculator/tokenizer.mojo @@ -51,7 +51,7 @@ struct Token(Copyable, ImplicitlyCopyable, Movable): 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 '*'``.""" + `Error at position 5: unexpected '*'`.""" def __init__(out self, kind: Int, value: String = "", position: Int = 0): self.kind = kind diff --git a/src/cli/main.mojo b/src/cli/main.mojo index 006a79a..18cdc99 100644 --- a/src/cli/main.mojo +++ b/src/cli/main.mojo @@ -26,6 +26,7 @@ from calculator.io import ( filter_expression_lines, read_file_text, ) +from calculator.repl import run_repl struct DecimoArgs(Parsable): @@ -191,16 +192,16 @@ def _run() raises: rounding_mode, ) else: - # No expression, no file, no pipe β€” show help. - print_error("no expression provided") - print( - "Usage: decimo [OPTIONS] [EXPR]\n" - " echo 'EXPR' | decimo [OPTIONS]\n" - " decimo -F FILE [OPTIONS]\n" - "\n" - "Run 'decimo --help' for more information." + # ── REPL mode ─────────────────────────────────────────────────── + # No expression, no file, no pipe β€” launch interactive session. + run_repl( + precision, + scientific, + engineering, + pad, + delimiter, + rounding_mode, ) - exit(1) # ===----------------------------------------------------------------------=== # @@ -347,8 +348,8 @@ def _display_calc_error(error_msg: String, expr: String): The calculator engine produces errors in two forms: - 1. ``Error at position N: `` β€” with position info. - 2. ```` β€” without position info. + 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 diff --git a/src/decimo/bigdecimal/bigdecimal.mojo b/src/decimo/bigdecimal/bigdecimal.mojo index e4dbcdc..d7dc9f9 100644 --- a/src/decimo/bigdecimal/bigdecimal.mojo +++ b/src/decimo/bigdecimal/bigdecimal.mojo @@ -586,20 +586,20 @@ struct BigDecimal( ) -> String: """Returns string representation of the number. - This method follows CPython's ``Decimal.__str__`` logic exactly for + This method follows CPython's `Decimal.__str__` logic exactly for the default and scientific-notation paths. - - Engineering notation (``engineering=True``) is tried first. - The exponent is always a multiple of 3 (e.g. ``12.34E+6``, - ``500E-3``). Trailing zeros in the mantissa are stripped. + - Engineering notation (`engineering=True`) is tried first. + The exponent is always a multiple of 3 (e.g. `12.34E+6`, + `500E-3`). Trailing zeros in the mantissa are stripped. - Scientific notation is used when: - 1. ``scientific`` parameter is True, OR + 1. `scientific` parameter is True, OR 2. The internal exponent > 0 (i.e., scale < 0), OR 3. There are more than 6 leading zeros after the decimal - point (adjusted exponent < -6), unless ``force_plain=True``. + point (adjusted exponent < -6), unless `force_plain=True`. - Otherwise, plain (fixed-point) notation is used. - When both ``engineering`` and ``scientific`` are True, engineering + When both `engineering` and `scientific` are True, engineering notation takes precedence. Args: @@ -610,13 +610,13 @@ struct BigDecimal( notation (exponent is a multiple of 3, trailing zeros stripped). force_plain: If True, suppress the CPython-compatible - auto-detection of scientific notation (the ``scale < 0`` and - ``leftdigits <= -6`` rules are not applied). Useful when a + auto-detection of scientific notation (the `scale < 0` and + `leftdigits <= -6` rules are not applied). Useful when a guaranteed fixed-point string is needed regardless of - magnitude. Has no effect when ``scientific`` or - ``engineering`` is True. + magnitude. Has no effect when `scientific` or + `engineering` is True. delimiter: A string inserted every 3 digits in both the integer - and fractional parts (e.g. ``"_"`` gives ``1_234.567_89``). + and fractional parts (e.g. `"_"` gives `1_234.567_89`). An empty string (default) disables grouping. line_width: The maximum line width for the string representation. If 0, the string is returned as a single line. @@ -2424,18 +2424,18 @@ struct BigDecimal( def _insert_digit_separators(s: String, delimiter: String) -> String: - """Insert ``delimiter`` every 3 digits in both the integer and + """Insert `delimiter` every 3 digits in both the integer and fractional parts of a numeric string. - The function is aware of an optional leading ``-`` sign and a trailing - exponent suffix (``E+3``, ``E-12``, …). Only the mantissa digits are + The function is aware of an optional leading `-` sign and a trailing + exponent suffix (`E+3`, `E-12`, …). Only the mantissa digits are grouped; the sign and exponent are preserved verbatim. - Examples (with ``delimiter = "_"``): - ``"1234567"`` β†’ ``"1_234_567"`` - ``"1234567.891011"`` β†’ ``"1_234_567.891_011"`` - ``"12.345678E+6"`` β†’ ``"12.345_678E+6"`` - ``"-0.00123"`` β†’ ``"-0.001_23"`` + Examples (with `delimiter = "_"`): + `"1234567"` β†’ `"1_234_567"` + `"1234567.891011"` β†’ `"1_234_567.891_011"` + `"12.345678E+6"` β†’ `"12.345_678E+6"` + `"-0.00123"` β†’ `"-0.001_23"` """ if not delimiter: return s diff --git a/src/decimo/bigfloat/mpfr_wrapper.mojo b/src/decimo/bigfloat/mpfr_wrapper.mojo index 3342524..79caf8d 100644 --- a/src/decimo/bigfloat/mpfr_wrapper.mojo +++ b/src/decimo/bigfloat/mpfr_wrapper.mojo @@ -131,13 +131,13 @@ fn mpfrw_get_raw_digits( """Exports MPFR value as raw digit string via mpfr_get_str. Returns a pointer to a null-terminated pure digit string (no dot, no - exponent notation). Negative values have a ``-`` prefix. The decimal - exponent is written to ``out_exp``. + exponent notation). Negative values have a `-` prefix. The decimal + exponent is written to `out_exp`. - Meaning: raw = ``"31415..."`` with exp = 1 β†’ value = 0.31415… Γ— 10^1. + Meaning: raw = `"31415..."` with exp = 1 β†’ value = 0.31415… Γ— 10^1. The digit string is allocated by MPFR and must be freed with - ``mpfrw_free_raw_str``. + `mpfrw_free_raw_str`. Args: handle: MPFR handle index. @@ -151,10 +151,10 @@ fn mpfrw_get_raw_digits( fn mpfrw_free_raw_str(addr: Int): - """Frees a digit string returned by ``mpfrw_get_raw_digits``. + """Frees a digit string returned by `mpfrw_get_raw_digits`. Args: - addr: Raw address returned by ``mpfrw_get_raw_digits``. + addr: Raw address returned by `mpfrw_get_raw_digits`. """ external_call["mpfrw_free_raw_str", NoneType](addr) diff --git a/src/decimo/bigint/bigint.mojo b/src/decimo/bigint/bigint.mojo index 5540873..05c128c 100644 --- a/src/decimo/bigint/bigint.mojo +++ b/src/decimo/bigint/bigint.mojo @@ -753,7 +753,7 @@ struct BigInt( def to_decimal_string(self, line_width: Int = 0) -> String: """Returns the decimal string representation of the BigInt. - Deprecated: Use ``to_string(line_width=...)`` instead. + Deprecated: Use `to_string(line_width=...)` instead. Args: line_width: The maximum line width for the output. diff --git a/src/decimo/errors.mojo b/src/decimo/errors.mojo index d73ccc3..0194251 100644 --- a/src/decimo/errors.mojo +++ b/src/decimo/errors.mojo @@ -27,7 +27,7 @@ ValueError: description of what went wrong File name and line number are automatically captured at the raise site using `call_location()`. The absolute path is automatically shortened to a relative -path (e.g. ``./src/...``, ``./tests/...``) for readability and privacy. +path (e.g. `./src/...`, `./tests/...`) for readability and privacy. Function name must be provided manually since Mojo does not have a built-in way to get the current function name at runtime. """ @@ -85,13 +85,13 @@ failures, missing native libraries).""" def _shorten_path(full_path: String) -> String: """Shorten an absolute file path to a relative path. - Looks for known directory markers (``src/``, ``tests/``, ``benches/``) and - returns a ``./``-prefixed relative path from the rightmost marker found. + Looks for known directory markers (`src/`, `tests/`, `benches/`) and + returns a `./`-prefixed relative path from the rightmost marker found. If no marker is found, returns just the filename. - Uses ``rfind`` (reverse search) to handle paths that contain a marker more - than once, e.g. ``/home/user/src/projects/decimo/src/decimo/bigint.mojo`` - correctly shortens to ``./src/decimo/bigint.mojo``. When more than one + Uses `rfind` (reverse search) to handle paths that contain a marker more + than once, e.g. `/home/user/src/projects/decimo/src/decimo/bigint.mojo` + correctly shortens to `./src/decimo/bigint.mojo`. When more than one marker type appears, the rightmost position wins to produce the shortest possible relative path. From aa493e06c68f14b68f436a208e1746d6e8d25a3f Mon Sep 17 00:00:00 2001 From: ZHU Yuhao Date: Sun, 12 Apr 2026 16:03:12 +0200 Subject: [PATCH 2/6] Update bench --- benches/cli/bench_cli.sh | 111 +++++++++++++++++++++++++++++---------- 1 file changed, 84 insertions(+), 27 deletions(-) diff --git a/benches/cli/bench_cli.sh b/benches/cli/bench_cli.sh index 33213f8..3ee188f 100644 --- a/benches/cli/bench_cli.sh +++ b/benches/cli/bench_cli.sh @@ -3,9 +3,13 @@ # CLI Calculator Benchmarks β€” Correctness & Performance # # Compares decimo against bc and python3 on every expression: -# 1. Correctness β€” first 15 significant digits must agree +# 1. Correctness β€” all significant digits must agree at full precision +# (minus 1 guard digit for last-digit rounding differences) # 2. Performance β€” average wall-clock latency over $ITERATIONS runs # +# bc is the golden reference (mismatches fail the script). +# python3/mpmath is informational (mismatches are shown but do not fail). +# # Usage: # bash benches/cli/bench_cli.sh # ITERATIONS=20 bash benches/cli/bench_cli.sh @@ -37,10 +41,17 @@ HAS_PY=false; command -v python3 &>/dev/null && HAS_PY=true # ── Counters ─────────────────────────────────────────────────────────────── -COMPARISONS=0 -MATCHES=0 -MISMATCHES=0 -ERRORS=0 +# bc is the golden reference β€” mismatches here fail the script. +BC_COMPARISONS=0 +BC_MATCHES=0 +BC_MISMATCHES=0 +BC_ERRORS=0 + +# python3 is informational β€” mismatches are reported but do not fail. +PY_COMPARISONS=0 +PY_MATCHES=0 +PY_MISMATCHES=0 +PY_ERRORS=0 # ── Helpers ──────────────────────────────────────────────────────────────── @@ -67,9 +78,9 @@ elapsed_ms() { } # Extract a canonical comparison key from a numeric string: -# adjusted base-10 exponent + first 15 significant digits. +# adjusted base-10 exponent + ALL significant digits. # This ensures values that differ only by exponent (e.g. 1E+10 vs 1E+11) -# are correctly detected as a MISMATCH. +# are correctly detected as a MISMATCH, and full-precision agreement is verified. sig_digits() { local s="${1#-}" # strip sign; check_match handles sign separately local explicit_exp=0 @@ -112,10 +123,14 @@ sig_digits() { adjusted_exp=$(( explicit_exp - first_nonzero - 1 )) fi - echo "${adjusted_exp}:$(echo "$digits" | cut -c1-15)" + echo "${adjusted_exp}:${digits}" } -# Compare two results by leading significant digits. +# Compare two results by all significant digits. +# Both keys are "adjusted_exp:digits". The exponents must match exactly. +# The digit strings are compared up to the length of the shorter one, +# minus 1 guard digit (the very last digit often differs between tools +# due to rounding vs truncation β€” standard in MP arithmetic). check_match() { local a="$1" b="$2" local sign_a="" sign_b="" @@ -125,7 +140,26 @@ check_match() { local sa sb sa=$(sig_digits "$a") sb=$(sig_digits "$b") - if [[ "$sa" == "$sb" ]]; then echo "MATCH"; else echo "MISMATCH"; fi + + # Split into exponent and digit parts + local exp_a="${sa%%:*}" digits_a="${sa#*:}" + local exp_b="${sb%%:*}" digits_b="${sb#*:}" + + # Exponents must match exactly + if [[ "$exp_a" != "$exp_b" ]]; then echo "MISMATCH"; return 0; fi + + # Compare digits up to (shorter length - 1) to allow last-digit rounding + local len_a=${#digits_a} len_b=${#digits_b} + local min_len=$len_a + if (( len_b < min_len )); then min_len=$len_b; fi + local cmp_len=$(( min_len - 1 )) + if (( cmp_len < 1 )); then cmp_len=1; fi + + if [[ "${digits_a:0:$cmp_len}" == "${digits_b:0:$cmp_len}" ]]; then + echo "MATCH" + else + echo "MISMATCH" + fi return 0 } @@ -134,17 +168,30 @@ preview() { if (( ${#1} > PREVIEW )); then echo "${1:0:$PREVIEW}..."; else echo "$1"; fi } -# Record a comparison result. +# Record a comparison result for a specific tool. record() { - local tag="$1" - if [[ "$tag" == "ERROR" ]]; then - ERRORS=$((ERRORS + 1)) + local tool="$1" tag="$2" + if [[ "$tool" == "bc" ]]; then + if [[ "$tag" == "ERROR" ]]; then + BC_ERRORS=$((BC_ERRORS + 1)) + else + BC_COMPARISONS=$((BC_COMPARISONS + 1)) + if [[ "$tag" == "MATCH" ]]; then + BC_MATCHES=$((BC_MATCHES + 1)) + else + BC_MISMATCHES=$((BC_MISMATCHES + 1)) + fi + fi else - COMPARISONS=$((COMPARISONS + 1)) - if [[ "$tag" == "MATCH" ]]; then - MATCHES=$((MATCHES + 1)) + if [[ "$tag" == "ERROR" ]]; then + PY_ERRORS=$((PY_ERRORS + 1)) else - MISMATCHES=$((MISMATCHES + 1)) + PY_COMPARISONS=$((PY_COMPARISONS + 1)) + if [[ "$tag" == "MATCH" ]]; then + PY_MATCHES=$((PY_MATCHES + 1)) + else + PY_MISMATCHES=$((PY_MISMATCHES + 1)) + fi fi fi return 0 @@ -171,7 +218,8 @@ bench_compare() { d_ms=$(elapsed_ms "$BINARY" "$d_expr" -P "$prec") printf " %-10s %-38s %8s ms\n" "decimo:" "$(preview "$d_result")" "$d_ms" if [[ "$d_result" == "ERROR" ]]; then - record "ERROR" + [[ -n "$bc_expr" ]] && record bc "ERROR" + [[ -n "$py_code" ]] && record py "ERROR" echo "" return fi @@ -189,7 +237,7 @@ bench_compare() { tag=$(check_match "$d_result" "$b_result") fi printf " %-10s %-38s %8s ms %s\n" "bc:" "$(preview "$b_result")" "$b_ms" "$tag" - record "$tag" + record bc "$tag" fi # ── python3 ── @@ -204,7 +252,7 @@ bench_compare() { tag=$(check_match "$d_result" "$p_result") fi printf " %-10s %-38s %8s ms %s\n" "python3:" "$(preview "$p_result")" "$p_ms" "$tag" - record "$tag" + record py "$tag" fi echo "" @@ -276,10 +324,12 @@ bench_compare "exp(1)" 50 \ "e(1)" \ "${PY_MP};print(mp.exp(1))" +# NOTE: mpmath diverges from decimo & WolframAlpha at digit ~21 for sin(near-pi). +# See docs/internal_notes.md. Kept here as a reference comparison. bench_compare "sin(3.1415926535897932384626433833)" 50 \ "sin(3.1415926535897932384626433833)" \ "s(3.1415926535897932384626433833)" \ - "${PY_MP};print(mp.sin(3.1415926535897932384626433833))" + "${PY_MP};print(mp.sin(mp.mpf('3.1415926535897932384626433833')))" bench_compare "cos(0)" 50 \ "cos(0)" \ @@ -359,14 +409,21 @@ echo "" # ── Summary ──────────────────────────────────────────────────────────────── echo "============================================================" -printf " Summary: %d comparisons β€” %d MATCH, %d MISMATCH" \ - "$COMPARISONS" "$MATCHES" "$MISMATCHES" -if (( ERRORS > 0 )); then - printf ", %d ERROR (tool missing or failed)" "$ERRORS" +printf " bc (golden): %d comparisons β€” %d MATCH, %d MISMATCH" \ + "$BC_COMPARISONS" "$BC_MATCHES" "$BC_MISMATCHES" +if (( BC_ERRORS > 0 )); then + printf ", %d ERROR" "$BC_ERRORS" +fi +echo "" +printf " python3 (ref): %d comparisons β€” %d MATCH, %d MISMATCH" \ + "$PY_COMPARISONS" "$PY_MATCHES" "$PY_MISMATCHES" +if (( PY_ERRORS > 0 )); then + printf ", %d ERROR" "$PY_ERRORS" fi echo "" echo "============================================================" -if (( MISMATCHES > 0 )); then +if (( BC_MISMATCHES > 0 )); then + echo "FAIL: bc (golden reference) mismatches detected." exit 1 fi From 057c5684130e6828220d1c49eca3bfde92a27e31 Mon Sep 17 00:00:00 2001 From: ZHU Yuhao Date: Sun, 12 Apr 2026 16:14:03 +0200 Subject: [PATCH 3/6] Print rounding mode in banner --- src/cli/calculator/repl.mojo | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/cli/calculator/repl.mojo b/src/cli/calculator/repl.mojo index 5af1bd5..6337021 100644 --- a/src/cli/calculator/repl.mojo +++ b/src/cli/calculator/repl.mojo @@ -60,7 +60,9 @@ def run_repl( Errors are caught per-line and displayed without crashing the session. The loop exits on `exit`, `quit`, or EOF (Ctrl-D). """ - _print_banner(precision, scientific, engineering, pad, delimiter) + _print_banner( + precision, scientific, engineering, pad, delimiter, rounding_mode + ) while True: write_prompt("decimo> ") @@ -147,6 +149,7 @@ def _print_banner( engineering: Bool, pad: Bool, delimiter: String, + rounding_mode: RoundingMode, ): """Print the REPL welcome banner to stderr.""" print( @@ -168,6 +171,8 @@ def _print_banner( settings += " Zero-padded." if delimiter: settings += " Delimiter: '" + delimiter + "'." + if not (rounding_mode == RoundingMode.half_even()): + settings += " Rounding: " + String(rounding_mode) + "." print(settings, file=stderr) From 25097e170b5a5af4ae79e4a194139da80a9b3324 Mon Sep 17 00:00:00 2001 From: ZHU Yuhao Date: Sun, 12 Apr 2026 17:12:52 +0200 Subject: [PATCH 4/6] Address comments --- benches/cli/bench_cli.sh | 13 ++- src/cli/calculator/__init__.mojo | 1 + src/cli/calculator/engine.mojo | 151 +++++++++++++++++++++++++++++++ src/cli/calculator/repl.mojo | 109 +--------------------- src/cli/main.mojo | 139 ++-------------------------- 5 files changed, 177 insertions(+), 236 deletions(-) create mode 100644 src/cli/calculator/engine.mojo diff --git a/benches/cli/bench_cli.sh b/benches/cli/bench_cli.sh index 3ee188f..db53947 100644 --- a/benches/cli/bench_cli.sh +++ b/benches/cli/bench_cli.sh @@ -41,6 +41,9 @@ HAS_PY=false; command -v python3 &>/dev/null && HAS_PY=true # ── Counters ─────────────────────────────────────────────────────────────── +# Decimo errors β€” any decimo failure is fatal. +DECIMO_ERRORS=0 + # bc is the golden reference β€” mismatches here fail the script. BC_COMPARISONS=0 BC_MATCHES=0 @@ -218,8 +221,7 @@ bench_compare() { d_ms=$(elapsed_ms "$BINARY" "$d_expr" -P "$prec") printf " %-10s %-38s %8s ms\n" "decimo:" "$(preview "$d_result")" "$d_ms" if [[ "$d_result" == "ERROR" ]]; then - [[ -n "$bc_expr" ]] && record bc "ERROR" - [[ -n "$py_code" ]] && record py "ERROR" + DECIMO_ERRORS=$((DECIMO_ERRORS + 1)) echo "" return fi @@ -409,6 +411,9 @@ echo "" # ── Summary ──────────────────────────────────────────────────────────────── echo "============================================================" +if (( DECIMO_ERRORS > 0 )); then + printf " decimo: %d ERROR(s)\n" "$DECIMO_ERRORS" +fi printf " bc (golden): %d comparisons β€” %d MATCH, %d MISMATCH" \ "$BC_COMPARISONS" "$BC_MATCHES" "$BC_MISMATCHES" if (( BC_ERRORS > 0 )); then @@ -423,6 +428,10 @@ fi echo "" echo "============================================================" +if (( DECIMO_ERRORS > 0 )); then + echo "FAIL: decimo evaluation errors detected." + exit 1 +fi if (( BC_MISMATCHES > 0 )); then echo "FAIL: bc (golden reference) mismatches detected." exit 1 diff --git a/src/cli/calculator/__init__.mojo b/src/cli/calculator/__init__.mojo index d18e7e7..83fda28 100644 --- a/src/cli/calculator/__init__.mojo +++ b/src/cli/calculator/__init__.mojo @@ -44,6 +44,7 @@ from .tokenizer import ( ) 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 .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 new file mode 100644 index 0000000..2b58d46 --- /dev/null +++ b/src/cli/calculator/engine.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. +# ===----------------------------------------------------------------------=== # + +""" +Shared evaluation pipeline for the Decimo CLI calculator. + +Provides `evaluate_and_print`, `display_calc_error`, and `pad_to_precision` +used by both one-shot/pipe/file modes (main.mojo) and the interactive REPL +(repl.mojo). +""" + +from decimo.rounding_mode import RoundingMode +from .tokenizer import tokenize +from .parser import parse_to_rpn +from .evaluator import evaluate_rpn, final_round +from .display import print_error + + +def evaluate_and_print( + expr: String, + precision: Int, + scientific: Bool, + engineering: Bool, + pad: Bool, + delimiter: String, + rounding_mode: RoundingMode, + show_expr_on_error: Bool = False, +) raises: + """Tokenize, parse, evaluate, and print one expression. + + On error, displays a coloured diagnostic and raises to signal failure + to the caller. + + Args: + expr: The expression string to evaluate. + precision: Number of significant digits. + scientific: Whether to format in scientific notation. + engineering: Whether to format in engineering notation. + pad: Whether to pad trailing zeros to the specified precision. + delimiter: Digit-group separator (empty string disables grouping). + 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. + """ + try: + var tokens = tokenize(expr) + var rpn = parse_to_rpn(tokens^) + var value = final_round( + evaluate_rpn(rpn^, precision), 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)) + except e: + if show_expr_on_error: + display_calc_error(String(e), expr) + else: + print_error(String(e)) + raise e^ + + +def display_calc_error(error_msg: String, expr: String): + """Parse a calculator error message and display it with colours + and a caret indicator. + + Handles two error formats: + + 1. `Error at position N: description` β€” with position info. + 2. `description` β€” without position info. + + For form (1), extracts the position and calls `print_error` with a + visual caret under the offending column. For form (2) falls back + to a plain coloured error. + """ + comptime PREFIX = "Error at position " + + if error_msg.startswith(PREFIX): + 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: + var pos_str = String(error_msg[byte=after_prefix:colon_pos]) + var description = String( + error_msg[byte = colon_pos + 2 :] + ) # skip ": " + + try: + var pos = Int(pos_str) + print_error(description, expr, pos) + return + except: + pass # fall through to plain display + + # Fallback: no position info β€” just show the message. + print_error(error_msg) + + +def pad_to_precision(plain: String, precision: Int) -> String: + """Pad trailing zeros so the fractional part has exactly + `precision` digits. + + Args: + plain: A plain (fixed-point) numeric string. + precision: Target number of fractional digits. + + Returns: + The string with trailing zeros appended as needed. + """ + if precision <= 0: + return plain + + var dot_pos = -1 + for i in range(len(plain)): + if plain[byte=i] == ".": + dot_pos = i + break + + if dot_pos < 0: + # No decimal point β€” add one with `precision` zeros + return plain + "." + "0" * precision + + var frac_len = len(plain) - dot_pos - 1 + if frac_len >= precision: + return plain + + return plain + "0" * (precision - frac_len) diff --git a/src/cli/calculator/repl.mojo b/src/cli/calculator/repl.mojo index 6337021..0329d46 100644 --- a/src/cli/calculator/repl.mojo +++ b/src/cli/calculator/repl.mojo @@ -39,10 +39,8 @@ Architecture notes for future PRs: from std.sys import stderr from decimo.rounding_mode import RoundingMode -from .tokenizer import tokenize -from .parser import parse_to_rpn -from .evaluator import evaluate_rpn, final_round -from .display import print_error, print_hint, write_prompt +from .display import write_prompt +from .engine import evaluate_and_print from .io import read_line, strip, is_comment_or_blank @@ -86,7 +84,7 @@ def run_repl( # Evaluate the expression β€” errors are caught and printed, # then the loop continues. try: - _evaluate_and_print( + evaluate_and_print( line, precision, scientific, @@ -94,53 +92,10 @@ def run_repl( pad, delimiter, rounding_mode, + show_expr_on_error=True, ) except: - pass # error already displayed by _evaluate_and_print - - -def _evaluate_and_print( - expr: String, - precision: Int, - scientific: Bool, - engineering: Bool, - pad: Bool, - delimiter: String, - rounding_mode: RoundingMode, -) raises: - """Evaluate one expression and print the result. - - On error, displays a coloured diagnostic with caret and re-raises - so the REPL loop knows to continue. - """ - try: - var tokens = tokenize(expr) - var rpn = parse_to_rpn(tokens^) - - try: - var value = final_round( - evaluate_rpn(rpn^, precision), 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)) - except eval_err: - _display_calc_error(String(eval_err), expr) - raise eval_err^ - - except parse_err: - _display_calc_error(String(parse_err), expr) - raise parse_err^ + pass # error already displayed by evaluate_and_print def _print_banner( @@ -174,57 +129,3 @@ def _print_banner( if not (rounding_mode == RoundingMode.half_even()): settings += " Rounding: " + String(rounding_mode) + "." print(settings, file=stderr) - - -def _display_calc_error(error_msg: String, expr: String): - """Parse and display an error with optional caret indicator. - - Handles two error formats: - 1. `Error at position N: description` β†’ caret display - 2. `description` β†’ plain error - """ - comptime PREFIX = "Error at position " - - if error_msg.startswith(PREFIX): - 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: - var pos_str = String(error_msg[byte=after_prefix:colon_pos]) - var description = String(error_msg[byte = colon_pos + 2 :]) - - try: - var pos = Int(pos_str) - print_error(description, expr, pos) - return - except: - pass - - print_error(error_msg) - - -def _pad_to_precision(plain: String, precision: Int) -> String: - """Pad trailing zeros so the fractional part has exactly - `precision` digits. - """ - if precision <= 0: - return plain - - var dot_pos = -1 - for i in range(len(plain)): - if plain[byte=i] == ".": - dot_pos = i - break - - if dot_pos < 0: - return plain + "." + "0" * precision - - var frac_len = len(plain) - dot_pos - 1 - if frac_len >= precision: - return plain - - return plain + "0" * (precision - frac_len) diff --git a/src/cli/main.mojo b/src/cli/main.mojo index 18cdc99..3ff6661 100644 --- a/src/cli/main.mojo +++ b/src/cli/main.mojo @@ -15,10 +15,12 @@ from std.sys import exit from argmojo import Parsable, Option, Flag, Positional, Command from decimo.rounding_mode import RoundingMode -from calculator.tokenizer import tokenize -from calculator.parser import parse_to_rpn -from calculator.evaluator import evaluate_rpn, final_round from calculator.display import print_error +from calculator.engine import ( + evaluate_and_print, + display_calc_error, + pad_to_precision, +) from calculator.io import ( stdin_is_tty, read_stdin, @@ -146,7 +148,7 @@ def _run() raises: # 1. --file flag provided β†’ file mode # 2. Positional expr provided β†’ expression mode (one-shot) # 3. No expr, stdin is piped β†’ pipe mode - # 4. No expr, stdin is a TTY β†’ error (no input) + # 4. No expr, stdin is a TTY β†’ interactive REPL var has_file = len(args.file.value) > 0 var has_expr = len(args.expr.value) > 0 @@ -169,7 +171,7 @@ def _run() raises: elif has_expr: # ── Expression mode (one-shot) ─────────────────────────────────── try: - _evaluate_and_print( + evaluate_and_print( args.expr.value, precision, scientific, @@ -227,7 +229,7 @@ def _run_pipe_mode( for i in range(len(expressions)): try: - _evaluate_and_print( + evaluate_and_print( expressions[i], precision, scientific, @@ -268,7 +270,7 @@ def _run_file_mode( for i in range(len(expressions)): try: - _evaluate_and_print( + evaluate_and_print( expressions[i], precision, scientific, @@ -286,129 +288,6 @@ def _run_file_mode( exit(1) -# ===----------------------------------------------------------------------=== # -# Core evaluation and formatting -# ===----------------------------------------------------------------------=== # - - -def _evaluate_and_print( - expr: String, - precision: Int, - scientific: Bool, - engineering: Bool, - pad: Bool, - delimiter: String, - rounding_mode: RoundingMode, - show_expr_on_error: Bool = False, -) raises: - """Tokenize, parse, evaluate, and print one expression. - - On error, displays a coloured diagnostic and raises to signal failure - to the caller. - """ - try: - var tokens = tokenize(expr) - var rpn = parse_to_rpn(tokens^) - - try: - var value = final_round( - evaluate_rpn(rpn^, precision), 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)) - except eval_err: - if show_expr_on_error: - _display_calc_error(String(eval_err), expr) - else: - print_error(String(eval_err)) - raise eval_err^ - - except parse_err: - if show_expr_on_error: - _display_calc_error(String(parse_err), expr) - else: - print_error(String(parse_err)) - raise parse_err^ - - -def _display_calc_error(error_msg: String, expr: String): - """Parses a calculator error message and displays 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[byte=after_prefix:colon_pos]) - var description = String( - error_msg[byte = colon_pos + 2 :] - ) # skip ": " - - try: - var pos = Int(pos_str) - print_error(description, expr, pos) - return - except: - pass # fall through to plain display - - # Fallback: no position info β€” just show the message. - print_error(error_msg) - - -def _pad_to_precision(plain: String, precision: Int) -> String: - """Pads (or adds) trailing zeros so the fractional part has exactly - `precision` digits. - """ - if precision <= 0: - return plain - - var dot_pos = -1 - for i in range(len(plain)): - if plain[byte=i] == ".": - dot_pos = i - break - - if dot_pos < 0: - # No decimal point β€” add one with `precision` zeros - return plain + "." + "0" * precision - - var frac_len = len(plain) - dot_pos - 1 - if frac_len >= precision: - return plain - - return plain + "0" * (precision - frac_len) - - def _parse_rounding_mode(name: String) -> RoundingMode: """Converts a CLI rounding-mode name (hyphenated) to a RoundingMode value. """ From d9ad4da82a740219ab7d18e8257b6f945172539e Mon Sep 17 00:00:00 2001 From: ZHU Yuhao Date: Sun, 12 Apr 2026 18:31:43 +0200 Subject: [PATCH 5/6] Address comments --- benches/cli/bench_cli.sh | 11 +++++- src/cli/calculator/repl.mojo | 8 ++-- src/cli/main.mojo | 6 +-- tests/cli/test_engine.mojo | 73 ++++++++++++++++++++++++++++++++++++ 4 files changed, 88 insertions(+), 10 deletions(-) create mode 100644 tests/cli/test_engine.mojo diff --git a/benches/cli/bench_cli.sh b/benches/cli/bench_cli.sh index db53947..4b6aeb1 100644 --- a/benches/cli/bench_cli.sh +++ b/benches/cli/bench_cli.sh @@ -36,7 +36,10 @@ if ! [[ -x "$BINARY" ]]; then exit 1 fi -HAS_BC=false; command -v bc &>/dev/null && HAS_BC=true +if ! command -v bc &>/dev/null; then + echo "Error: bc is required (golden reference) but not found." + exit 1 +fi HAS_PY=false; command -v python3 &>/dev/null && HAS_PY=true # ── Counters ─────────────────────────────────────────────────────────────── @@ -227,7 +230,7 @@ bench_compare() { fi # ── bc ── - if [[ -n "$bc_expr" ]] && $HAS_BC; then + if [[ -n "$bc_expr" ]]; then local full_bc="scale=$prec; $bc_expr" local b_result b_ms tag # tr -d '\\\n' removes bc's backslash line-continuations @@ -436,3 +439,7 @@ if (( BC_MISMATCHES > 0 )); then echo "FAIL: bc (golden reference) mismatches detected." exit 1 fi +if (( BC_ERRORS > 0 )); then + echo "FAIL: bc (golden reference) errors detected." + exit 1 +fi diff --git a/src/cli/calculator/repl.mojo b/src/cli/calculator/repl.mojo index 0329d46..1dd5855 100644 --- a/src/cli/calculator/repl.mojo +++ b/src/cli/calculator/repl.mojo @@ -81,8 +81,10 @@ def run_repl( if line == "exit" or line == "quit": break - # Evaluate the expression β€” errors are caught and printed, - # then the loop continues. + # 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, @@ -95,7 +97,7 @@ def run_repl( show_expr_on_error=True, ) except: - pass # error already displayed by evaluate_and_print + continue # error already displayed; proceed to next prompt def _print_banner( diff --git a/src/cli/main.mojo b/src/cli/main.mojo index 3ff6661..3d666ba 100644 --- a/src/cli/main.mojo +++ b/src/cli/main.mojo @@ -16,11 +16,7 @@ from std.sys import exit from argmojo import Parsable, Option, Flag, Positional, Command from decimo.rounding_mode import RoundingMode from calculator.display import print_error -from calculator.engine import ( - evaluate_and_print, - display_calc_error, - pad_to_precision, -) +from calculator.engine import evaluate_and_print from calculator.io import ( stdin_is_tty, read_stdin, diff --git a/tests/cli/test_engine.mojo b/tests/cli/test_engine.mojo new file mode 100644 index 0000000..a8ae2c9 --- /dev/null +++ b/tests/cli/test_engine.mojo @@ -0,0 +1,73 @@ +"""Test the engine helpers: pad_to_precision, display_calc_error.""" + +from std import testing + +from calculator.engine import pad_to_precision + + +# ===----------------------------------------------------------------------=== # +# Tests: pad_to_precision +# ===----------------------------------------------------------------------=== # + + +def test_pad_integer() raises: + """Integer without decimal point gets one added.""" + testing.assert_equal(pad_to_precision("42", 5), "42.00000") + + +def test_pad_short_fraction() raises: + """Fractional part shorter than precision is zero-padded.""" + testing.assert_equal(pad_to_precision("3.14", 10), "3.1400000000") + + +def test_pad_exact_fraction() raises: + """Fractional part already at precision is unchanged.""" + testing.assert_equal(pad_to_precision("1.234", 3), "1.234") + + +def test_pad_long_fraction() raises: + """Fractional part longer than precision is unchanged (no truncation).""" + testing.assert_equal(pad_to_precision("1.23456789", 3), "1.23456789") + + +def test_pad_zero_precision() raises: + """Precision 0 returns the input unchanged.""" + testing.assert_equal(pad_to_precision("42", 0), "42") + + +def test_pad_negative_precision() raises: + """Negative precision returns the input unchanged.""" + testing.assert_equal(pad_to_precision("42", -1), "42") + + +def test_pad_zero_value() raises: + """Zero value gets padded normally.""" + testing.assert_equal(pad_to_precision("0", 3), "0.000") + + +def test_pad_already_has_dot_no_digits() raises: + """Value with dot but no fractional digits gets padded.""" + testing.assert_equal(pad_to_precision("5.", 4), "5.0000") + + +def test_pad_precision_one() raises: + """Precision 1 on integer adds '.0'.""" + testing.assert_equal(pad_to_precision("7", 1), "7.0") + + +# ===----------------------------------------------------------------------=== # +# Main +# ===----------------------------------------------------------------------=== # + + +def main() raises: + test_pad_integer() + test_pad_short_fraction() + test_pad_exact_fraction() + test_pad_long_fraction() + test_pad_zero_precision() + test_pad_negative_precision() + test_pad_zero_value() + test_pad_already_has_dot_no_digits() + test_pad_precision_one() + print("test_engine: all tests passed") From 712dca9572ffe9e2d7ec9714734a6bec3c3222bf Mon Sep 17 00:00:00 2001 From: ZHU Yuhao Date: Sun, 12 Apr 2026 20:26:16 +0200 Subject: [PATCH 6/6] 3rd person singular for docstring --- src/cli/calculator/display.mojo | 12 ++++++------ src/cli/calculator/parser.mojo | 2 +- src/cli/calculator/repl.mojo | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/cli/calculator/display.mojo b/src/cli/calculator/display.mojo index e3d65a3..a162f38 100644 --- a/src/cli/calculator/display.mojo +++ b/src/cli/calculator/display.mojo @@ -93,7 +93,7 @@ def print_error(message: String, expr: String, position: Int): def print_warning(message: String): - """Print a coloured warning message to stderr. + """Prints a coloured warning message to stderr. Format: `Warning: ` @@ -105,7 +105,7 @@ def print_warning(message: String): def print_warning(message: String, expr: String, position: Int): - """Print a coloured warning message with a caret indicator.""" + """Prints a coloured warning message with a caret indicator.""" _write_stderr( BOLD + WARNING_COLOR + "Warning" + RESET + BOLD + ": " + RESET + message ) @@ -113,7 +113,7 @@ def print_warning(message: String, expr: String, position: Int): def print_hint(message: String): - """Print a coloured hint message to stderr. + """Prints a coloured hint message to stderr. Format: `Hint: ` @@ -125,7 +125,7 @@ def print_hint(message: String): def write_prompt(prompt: String): - """Write a REPL prompt to stderr (no trailing newline). + """Writes a REPL prompt to stderr (no trailing newline). The prompt is written to stderr so that stdout remains clean for piping results. @@ -138,12 +138,12 @@ def write_prompt(prompt: String): def _write_stderr(msg: String): - """Write a line to stderr.""" + """Writes a line to stderr.""" print(msg, file=stderr) def _write_caret(expr: String, position: Int): - """Print the expression line and a green caret (^) under the + """Prints the expression line and a green caret (^) under the given column position to stderr. ```text diff --git a/src/cli/calculator/parser.mojo b/src/cli/calculator/parser.mojo index ffa4c91..66b5f58 100644 --- a/src/cli/calculator/parser.mojo +++ b/src/cli/calculator/parser.mojo @@ -38,7 +38,7 @@ from .tokenizer import ( def parse_to_rpn(tokens: List[Token]) raises -> List[Token]: - """Convert infix tokens to Reverse Polish Notation using + """Converts infix tokens to Reverse Polish Notation using Dijkstra's shunting-yard algorithm. Supports binary operators (+, -, *, /, ^), unary minus, diff --git a/src/cli/calculator/repl.mojo b/src/cli/calculator/repl.mojo index 1dd5855..87822c0 100644 --- a/src/cli/calculator/repl.mojo +++ b/src/cli/calculator/repl.mojo @@ -52,7 +52,7 @@ def run_repl( delimiter: String, rounding_mode: RoundingMode, ) raises: - """Run the interactive REPL. + """Runs the interactive REPL. Prints a welcome banner, then loops: prompt β†’ read β†’ eval β†’ print. Errors are caught per-line and displayed without crashing the session. @@ -108,7 +108,7 @@ def _print_banner( delimiter: String, rounding_mode: RoundingMode, ): - """Print the REPL welcome banner to stderr.""" + """Prints the REPL welcome banner to stderr.""" print( "Decimo β€” arbitrary-precision calculator", file=stderr,