diff --git a/docs/plans/cli_calculator.md b/docs/plans/cli_calculator.md index 9e092cf..9154036 100644 --- a/docs/plans/cli_calculator.md +++ b/docs/plans/cli_calculator.md @@ -327,8 +327,8 @@ Format the final `BigDecimal` result based on CLI flags: | 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 | 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 | +| 3.15 | Make short names upper cases to avoid expression collisions | ✓ | `-P`, `-S`, `-E`, `-R`; `--pad`, `--delimiter` have no short name | +| 3.16 | Define `allow_hyphen_values` in declarative API | ✗ | When argmojo supports it (expected in v0.6.0) | ### Phase 4: Interactive REPL @@ -376,15 +376,20 @@ decimo> exit | 4.3 | Custom prompt (`decimo>`) | ✓ | Coloured prompt to stderr so results can be piped | | 4.4 | `ans` variable (previous result) | ✓ | Stored in `Dict[String, Decimal]`; updated after each successful evaluation | | 4.5 | Variable assignment (`x = expr`) | ✓ | `name = expr` detection in REPL; protected names (pi, e, functions, ans) | -| 4.6 | Meta-commands (`:precision N`, `:vars`) | ✗ | `:` prefix avoids collision with expressions, allow short aliases | -| 4.7 | One-line quick setting | ✗ | `:p 100 s down` sets precision, scientific notation, and round_down mode | -| 4.8 | Same-line temp precision setting | ✗ | `2*sqrt(1.23):p 100 s down` for a temporary setting for the expression | +| 4.6 | Meta-commands (`:precision N`, etc.) | ✓ | `:` prefix avoids collision; settings names + aliases (`:vars` not yet) | +| 4.7 | One-line quick setting | ✓ | `:p 100 s r down` sets precision, scientific, and rounding in one line | +| 4.8 | Same-line temp precision setting | ✓ | `2*sqrt(1.23):p 100 s r down` — temp override via `:` separator | | 4.9 | Print settings (`:settings`) | ✗ | Display current precision, formatting options, etc. | | 4.10 | Variable listing (`:vars` and `:variable`) | ✗ | List all user-defined variables and their values | -| 4.11 | Everything in the REPL is case-insensitive | ✗ | Map all input chars to lower case at pre-tokenizer stage | -| 4.12 | Graceful exit (`exit`, `quit`, Ctrl-D) | ✓ | | -| 4.13 | Error recovery (don't crash session) | ✓ | Catch exceptions per-line, display error, continue loop | -| 4.14 | History (if Mojo gets readline support) | ✗ | Future — depends on Mojo FFI evolution | +| 4.11 | Help command (`:help`) | ✗ | REPL-specific help with available commands and usage examples | +| 4.12 | Better header banner | ✗ | Show title, settings, how to display help `:help`, how to quit `:q` | +| 4.13 | Everything in the REPL is case-insensitive | ✗ | Map all input chars to lower case at pre-tokenizer stage | +| 4.14 | Graceful exit (`exit`, `quit`, Ctrl-D) | ✓ | | +| 4.15 | Error recovery (don't crash session) | ✓ | Catch exceptions per-line, display error, continue loop | +| 4.16 | Line editing (left/right, backspace, del) | ✗ | Implemented via `limo` package; see `docs/plans/line_editor.md` | +| 4.17 | Input history (up/down arrow navigation) | ✗ | Implemented via `limo` package; see `docs/plans/line_editor.md` | + +將所有的設定放在一行,這個靈感主要來自於 argmojo,其實就是把 CLI 的選項直接放到了 REPL 的表達式行中。這個想法我個人是很喜歡的,因為它簡潔明瞭,避免了多行設定的冗長。 ### Phase 5: Future Enhancements diff --git a/docs/plans/line_editor.md b/docs/plans/line_editor.md new file mode 100644 index 0000000..8c5a4ce --- /dev/null +++ b/docs/plans/line_editor.md @@ -0,0 +1,476 @@ +# Limo — Line Editor for Mojo 🔥 + +> Date of initial planning: 2025-07-15 +> Author: Yuhao Zhu +> Scope: A lightweight, reusable line-editing library for Mojo REPL applications +> Location: `src/cli/limo/` (internal package, parallel to `src/cli/calculator/`) +> Future: Extract to standalone repo `forfudan/limo` for general-purpose use +> +> The name "limo" = **li**ne + **mo**jo. +> Name availability: not taken on PyPI or conda-forge (checked 2025-07-15). + +## 1. Motivation + +The decimo REPL (`src/cli/calculator/repl.mojo`) currently reads input via a simple `getchar()` loop in cooked/canonical terminal mode. This means: + +- **No line editing** — users cannot move the cursor left/right, jump to Home/End, or delete characters mid-line. +- **No history** — pressing up/down does nothing; users must retype previous expressions from scratch. +- **No hotkeys** — Ctrl+A (beginning of line), Ctrl+E (end of line), Ctrl+K (kill to end), Ctrl+U (kill to beginning), Ctrl+W (delete word backward) — none of these work. + +These are table-stakes features for any interactive REPL. Every comparable tool — Python, IPython, bc, calc, qalc — provides them, typically via GNU readline or linenoise. + +Mojo has no readline binding. Rather than waiting for one, we can implement a lightweight line editor directly using `tcgetattr`/`tcsetattr` FFI to enter raw terminal mode and handle escape sequences ourselves. + +### Why a separate package? + +The line-editing problem is **completely general** — it has nothing to do with decimal arithmetic. Decoupling it into `limo` provides: + +1. **Clean separation of concerns** — the calculator REPL calls `limo.read_line()` instead of managing terminal state directly. +2. **Reusability** — any Mojo CLI tool (argmojo REPL, database client, shell, etc.) can import limo. +3. **Testability** — terminal I/O logic can be tested independently of the calculator. +4. **Future extraction** — when the API stabilizes, move limo to its own repo and release on conda-forge. + +### Relationship to Termo + +The author previously built a terminal manipulation library at `/Users/ZHU/Programs/termo/`. Termo is a full terminal control library (raw mode, screen control, input parsing, Unicode width) designed for building terminal UI applications like text editors. Limo is **lighter and more focused**: + +| Aspect | Termo | Limo | +| -------------- | ------------------------------------------ | -------------------------------------------- | +| **Scope** | Full terminal control (TUI foundation) | Line editing only (REPL foundation) | +| **Modules** | sys_libc, raw_mode, screen, key, width | terminal, line_editor (2 modules) | +| **Screen** | Full cursor control, colors, scroll, clear | Single-line redraw only | +| **Input** | Full KeyEvent/KeyCode parsing | Subset: arrows, Home/End, backspace, Ctrl+X | +| **Output** | ScreenBuffer for flicker-free full-screen | Direct write for prompt line | +| **Target use** | Text editors, TUI apps | REPLs, interactive CLIs | +| **Code reuse** | Source of truth for FFI and raw mode | Borrows FFI bindings and raw mode from termo | + +Limo **reuses** termo's Phase 1 code (`sys_libc.mojo` TermIOS struct, raw mode enable/disable, byte reading) but does **not** need termo's screen buffer, full color system, or advanced input parsing. It is a purpose-built subset. + +## 2. Cross-Library Comparison: Line Editors + +| Feature | GNU readline | linenoise | rustyline | prompt_toolkit | Proposed limo | +| ------------------------------ | -------------------- | ------------ | ----------------- | -------------- | ----------------------- | +| **Language** | C | C | Rust | Python | Mojo | +| **Lines of code** | ~30,000 | ~1,100 | ~15,000 | ~80,000 | Target: ~2000 (Phase 1) | +| **Dependencies** | ncurses/termcap | None | Several crates | wcwidth | None (libc FFI only) | +| **Raw mode** | ✓ | ✓ | ✓ | ✓ | ✓ | +| **Left/Right cursor** | ✓ | ✓ | ✓ | ✓ | ✓ | +| **Home/End** | ✓ | ✓ | ✓ | ✓ | ✓ | +| **Backspace/Delete** | ✓ | ✓ | ✓ | ✓ | ✓ | +| **Up/Down history** | ✓ | ✓ | ✓ | ✓ | ✓ | +| **Ctrl+A/E (begin/end)** | ✓ | ✓ | ✓ | ✓ | ✓ | +| **Ctrl+K/U (kill line)** | ✓ | ✓ | ✓ | ✓ | ✓ | +| **Ctrl+W (delete word)** | ✓ | ✗ | ✓ | ✓ | ✓ | +| **Ctrl+R (search history)** | ✓ | ✗ | ✓ | ✓ | Phase 3 | +| **Ctrl+L (clear screen)** | ✓ | ✓ | ✓ | ✓ | ✓ | +| **Tab completion** | ✓ (programmable) | ✓ (callback) | ✓ (callback) | ✓ (rich) | Phase 3 (callback) | +| **Syntax highlighting** | ✗ | ✗ | ✓ (`Highlighter`) | ✓ (rich) | Phase 3 | +| **Hints/suggestions** | ✗ | ✓ (callback) | ✓ (Hinter trait) | ✓ | Phase 3 | +| **Multi-line editing** | ✗ | ✗ | ✓ | ✓ | ✗ (not planned) | +| **History persistence (file)** | ✓ (`~/.history`) | ✓ | ✓ | ✓ | Phase 2 | +| **Kill ring (yank/paste)** | ✓ (full Emacs-style) | ✗ | ✓ | ✓ | ✗ (not planned) | +| **Unicode/CJK width** | ✓ (via locale) | ✗ | ✓ | ✓ (wcwidth) | Phase 2 | +| **Customizable key bindings** | ✓ (.inputrc) | ✗ | ✓ | ✓ | Phase 3 (callback) | +| **Windows support** | ✗ | ✗ (fork) | ✓ | ✓ | ✗ (macOS/Linux only) | +| **Platform** | POSIX | POSIX | Cross-platform | Cross-platform | macOS arm64 (+ Linux) | + +**Design model:** Limo is closest to **linenoise** — a minimal, single-file, zero-dependency line editor. The key difference is that limo adds Ctrl+W (word delete) and will support history search and tab completion in later phases. + +## 3. Architecture + +### 3.1 Package Structure + +```txt +src/cli/limo/ +├── __init__.mojo # Public API re-exports +├── terminal.mojo # Raw mode, byte reading, ANSI escape sequences (from termo) +└── line_editor.mojo # LineEditor struct: editing, history, key dispatch +``` + +Only **two modules** plus an `__init__.mojo`. This is intentionally minimal. + +### 3.2 Module Responsibilities + +#### `terminal.mojo` — Terminal Primitives + +Adapted from termo's `sys_libc.mojo` + `raw_mode.mojo`. Contains: + +- `TermIOS` struct (macOS arm64 termios layout, 72 bytes) +- `enable_raw_mode() -> TermIOS` / `disable_raw_mode(TermIOS)` +- `RawModeGuard` — RAII struct for automatic cleanup +- `read_byte() -> UInt8` — blocking single-byte read from stdin +- `write_to_fd(fd, data)` — write bytes to a file descriptor +- Terminal flag constants (ICANON, ECHO, etc.) +- ANSI escape helpers for single-line use: + - `cursor_move_left(n)`, `cursor_move_right(n)` + - `clear_line_from_cursor()` + - `cursor_move_to_column(col)` + +This module does NOT include: ScreenBuffer, full color system, scroll control, alternate screen — those belong in termo. + +#### `line_editor.mojo` — The Line Editor + +The core user-facing struct. Manages: + +- A character buffer (`List[UInt8]`) for the current line +- A cursor position (byte offset into the buffer) +- A history buffer (`List[String]`) with navigation index +- Key dispatch: raw bytes → action (edit, move, history, accept, cancel) + +```txt +┌──────────────────────────────────────────────────────────┐ +│ decimo> 1 + sqrt(2█) * pi │ +│ ↑ ↑ ↑ │ +│ prompt_len cursor end │ +│ │ +│ Buffer: "1 + sqrt(2) * pi" │ +│ Cursor: 10 (after '2') │ +│ History: ["1+2", "sqrt(2)", "pi * e"] │ +│ History index: -1 (current line, not navigating) │ +└──────────────────────────────────────────────────────────┘ +``` + +### 3.3 Key Mapping + +Standard Emacs-style keybindings (same as readline/linenoise defaults): + +| Key / Sequence | Bytes | Action | Phase | +| ------------------ | --------------- | ----------------------------- | ----- | +| **Printable char** | 0x20–0x7E | Insert at cursor | 1 | +| **Enter** | 0x0A or 0x0D | Accept line | 1 | +| **Backspace** | 0x7F | Delete char before cursor | 1 | +| **Delete** | ESC `[` `3` `~` | Delete char at cursor | 1 | +| **Left arrow** | ESC `[` `D` | Move cursor left | 1 | +| **Right arrow** | ESC `[` `C` | Move cursor right | 1 | +| **Home** | ESC `[` `H` | Move cursor to beginning | 1 | +| **End** | ESC `[` `F` | Move cursor to end | 1 | +| **Up arrow** | ESC `[` `A` | Previous history entry | 1 | +| **Down arrow** | ESC `[` `B` | Next history entry | 1 | +| **Ctrl+A** | 0x01 | Move cursor to beginning | 1 | +| **Ctrl+E** | 0x05 | Move cursor to end | 1 | +| **Ctrl+B** | 0x02 | Move cursor left (= Left) | 1 | +| **Ctrl+F** | 0x06 | Move cursor right (= Right) | 1 | +| **Ctrl+K** | 0x0B | Kill from cursor to end | 1 | +| **Ctrl+U** | 0x15 | Kill from beginning to cursor | 1 | +| **Ctrl+W** | 0x17 | Delete word backward | 1 | +| **Ctrl+D** | 0x04 | EOF (if line empty) or delete | 1 | +| **Ctrl+L** | 0x0C | Clear screen and redraw | 1 | +| **Ctrl+C** | 0x03 | Discard line (print newline) | 1 | +| **Ctrl+T** | 0x14 | Transpose chars | 2 | +| **Ctrl+R** | 0x12 | Reverse history search | 3 | +| **Tab** | 0x09 | Trigger completion callback | 3 | +| **Alt+B** | ESC `b` | Move word backward | 2 | +| **Alt+F** | ESC `f` | Move word forward | 2 | +| **Alt+D** | ESC `d` | Delete word forward | 2 | +| **Alt+Backspace** | ESC 0x7F | Delete word backward (= C-W) | 2 | + +### 3.4 Public API + +```mojo +# === limo public API === + +struct LineEditor(Movable): + """A readline-style line editor with history support. + + Usage: + var editor = LineEditor() + while True: + var line = editor.read_line("prompt> ") + if not line: + break # EOF + process(line.value()) + """ + + fn __init__(out self) + fn __init__(out self, max_history: Int) + + fn read_line(mut self, prompt: String) raises -> Optional[String] + """Display prompt, read a line with editing support. + + Returns the edited line on Enter, or None on EOF (Ctrl-D on empty line). + Automatically adds non-empty lines to history. + """ + + fn add_history(mut self, line: String) + """Manually add a line to history (e.g., loaded from file).""" + + fn clear_history(mut self) + """Clear all history entries.""" + + fn get_history(self) -> List[String] + """Return a copy of the history buffer.""" + + fn set_max_history(mut self, max: Int) + """Set maximum number of history entries to retain.""" + + # Phase 2 additions: + fn load_history(mut self, path: String) raises + """Load history from a file (one line per entry).""" + + fn save_history(self, path: String) raises + """Save history to a file.""" + + # Phase 3 additions: + fn set_completion_callback(mut self, callback: fn(String, Int) -> List[String]) + """Register a tab-completion callback. + The callback receives (current_line, cursor_position) and returns + a list of completion candidates.""" +``` + +### 3.5 Integration with Decimo REPL + +Before limo (current): + +```mojo +# src/cli/calculator/io.mojo +def read_line() -> Optional[String]: + # Simple getchar() loop — no editing, no history + ... + +# src/cli/calculator/repl.mojo +def run_repl(...): + while True: + write_prompt("decimo> ") + var line = read_line() + ... +``` + +After limo: + +```mojo +# src/cli/calculator/repl.mojo +from limo import LineEditor + +def run_repl(...): + var editor = LineEditor() + while True: + var line = editor.read_line("decimo> ") + ... +``` + +The change to `repl.mojo` is minimal — replace `write_prompt()` + `read_line()` with a single `editor.read_line(prompt)` call. The `io.mojo` file's `read_line()` remains for pipe/file mode (which does not use raw terminal mode). + +## 4. Implementation Roadmap + +### Phase 1: Core Line Editor (MVP) + +The essential features that make the REPL usable. After this phase, the decimo REPL has arrow-key navigation, backspace, delete, home/end, history, and Ctrl shortcuts. + +| # | Task | Status | Notes | +| ---- | ------------------------------------------------------ | :----: | ------------------------------------------------------------------ | +| 1.1 | Create `src/cli/limo/` package structure | ✗ | `__init__.mojo`, `terminal.mojo`, `line_editor.mojo` | +| 1.2 | Port `TermIOS` struct from termo | ✗ | macOS arm64 layout (72 bytes); adapt to current Mojo version | +| 1.3 | Port `enable_raw_mode` / `disable_raw_mode` from termo | ✗ | Save/restore via `tcgetattr`/`tcsetattr` | +| 1.4 | Port `RawModeGuard` (RAII cleanup) from termo | ✗ | Ensures terminal is restored even on panic | +| 1.5 | Implement `read_byte()` (blocking, stdin) | ✗ | `external_call["read"]` on fd 0 | +| 1.6 | Implement escape sequence detection | ✗ | ESC `[` prefix → parse arrow/Home/End/Delete sequences | +| 1.7 | Implement ANSI cursor helpers | ✗ | `cursor_move_left`, `cursor_move_right`, `clear_line_from_cursor` | +| 1.8 | Implement `LineEditor` struct with buffer + cursor | ✗ | `List[UInt8]` buffer, `Int` cursor pos, `Int` prompt display width | +| 1.9 | Character insertion at cursor position | ✗ | Insert byte(s), advance cursor, redraw from cursor to end | +| 1.10 | Backspace and Delete | ✗ | Remove byte at/before cursor, redraw | +| 1.11 | Left/Right arrow cursor movement | ✗ | Bounds checking, ANSI cursor move | +| 1.12 | Home/End (and Ctrl+A/Ctrl+E) | ✗ | Jump to column 0 or end of buffer | +| 1.13 | Enter (accept line) and Ctrl+C (discard) | ✗ | Return buffer as String; add to history if non-empty | +| 1.14 | Ctrl+D (EOF on empty line, delete otherwise) | ✗ | Match readline behavior | +| 1.15 | Ctrl+K (kill to end) and Ctrl+U (kill to beginning) | ✗ | Truncate buffer; redraw | +| 1.16 | Ctrl+W (delete word backward) | ✗ | Delete backward to previous whitespace boundary | +| 1.17 | Ctrl+L (clear screen and redraw prompt + line) | ✗ | Write ESC `[` `2J` ESC `[` `H`, then redraw | +| 1.18 | History buffer (`List[String]`, configurable max size) | ✗ | FIFO with eviction; default max 1000 entries | +| 1.19 | Up/Down arrow history navigation | ✗ | Save current line, navigate history, restore on return to bottom | +| 1.20 | Line redraw function | ✗ | Clear line → write prompt → write buffer → position cursor | +| 1.21 | Integrate into decimo REPL (`repl.mojo`) | ✗ | Replace `write_prompt` + `read_line` with `editor.read_line` | +| 1.22 | Unit tests for `LineEditor` | ✗ | Buffer manipulation, cursor movement, history navigation | +| 1.23 | Manual integration testing | ✗ | Run `decimo` REPL and verify all keybindings work | + +**Milestone:** `decimo` REPL supports full arrow-key editing and up/down history. + +### Phase 2: Polish + +Quality-of-life improvements and robustness. + +| # | Task | Status | Notes | +| ---- | --------------------------------------- | :----: | ------------------------------------------------------- | +| 2.1 | History persistence (save/load to file) | ✗ | `~/.decimo_history` or configurable path | +| 2.2 | Duplicate history suppression | ✗ | Don't add consecutive duplicate lines | +| 2.3 | Ctrl+T (transpose characters) | ✗ | Swap char before cursor with char at cursor | +| 2.4 | Alt+B / Alt+F (word movement) | ✗ | Move cursor by word boundaries | +| 2.5 | Alt+D (delete word forward) | ✗ | Delete from cursor to next word boundary | +| 2.6 | Alt+Backspace (delete word backward) | ✗ | Same as Ctrl+W | +| 2.7 | CJK/Unicode display width handling | ✗ | Wide chars occupy 2 columns; affects cursor positioning | +| 2.8 | UTF-8 multi-byte input | ✗ | Read continuation bytes for non-ASCII input | +| 2.9 | Handle terminal resize during editing | ✗ | SIGWINCH → re-query terminal width → redraw | +| 2.10 | History prefix search | ✗ | Type prefix, press up → find matching history entry | + +**Milestone:** Robust line editing with word operations, Unicode support, and persistent history. + +### Phase 3: Advanced Features (Future) + +Features that would make limo competitive as a standalone library. + +| # | Task | Status | Notes | +| --- | ------------------------------------------- | :----: | ------------------------------------------------------------------ | +| 3.1 | Ctrl+R (reverse incremental history search) | ✗ | Interactive search through history; display matches | +| 3.2 | Tab completion (callback-based) | ✗ | `set_completion_callback(fn)` for application-specific completions | +| 3.3 | Syntax highlighting (callback-based) | ✗ | `set_highlighter(fn)` to colorize the current line | +| 3.4 | Hints/suggestions (callback-based) | ✗ | Show dim suggestion text after cursor (like fish shell) | +| 3.5 | Customizable key bindings | ✗ | Register callbacks for arbitrary key sequences | +| 3.6 | Extract to standalone repo `forfudan/limo` | ✗ | Own pixi.toml, tests, README, conda-forge release | + +**Milestone:** Feature-complete line editor suitable for general-purpose use. + +## 5. Design Decisions + +| Decision | Choice | Rationale | +| --------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------ | +| Package location | `src/cli/limo/` inside decimo | Start as internal package; extract when API is stable and a second consumer exists | +| Module count | 2 modules + `__init__` | Minimal footprint; `terminal.mojo` for FFI, `line_editor.mojo` for logic | +| Source of FFI code | Adapted from termo | Termo's Phase 1 (`sys_libc.mojo`, `raw_mode.mojo`) is tested and correct for macOS arm64 | +| Buffer representation | `List[UInt8]` | Byte-level buffer; simple and efficient; UTF-8 multi-byte handled in Phase 2 | +| History storage | `List[String]` with max cap | Simple FIFO; most REPLs cap at 1000–5000 entries | +| Default keybindings | Emacs-style (readline defaults) | Universal standard; every developer's muscle memory expects these | +| No Vi mode | Intentional omission | Complexity not justified for a calculator REPL; add in Phase 3 if demand exists | +| No multi-line editing | Intentional omission | Calculator expressions are single-line; multi-line adds significant cursor management complexity | +| Platform | macOS arm64 only (initially) | Matches termo; Linux support requires different termios struct layout (add in Phase 2 if needed) | +| Prompt handling | Prompt is a display-only string | `read_line(prompt)` writes the prompt but does not include it in the returned buffer | +| Ctrl+C behavior | Discard line, print newline | Match readline: Ctrl+C cancels current input but doesn't exit the REPL (ISIG is disabled) | +| EOF on Ctrl+D | Only when buffer is empty | Match readline: Ctrl+D on non-empty line is "delete under cursor" | +| ANSI escape strategy | Direct write (no terminfo) | All modern macOS/Linux terminals support VT100+; terminfo lookup is unnecessary complexity | +| Relationship to termo | Subset, not dependency | Limo copies the relevant FFI code rather than depending on termo; avoids dependency management | + +## 6. Line Redraw Algorithm + +The core rendering operation. Called after every keystroke that modifies the buffer or cursor position. + +```mojo +def _redraw(self): + # 1. Move cursor to beginning of the line (column 0) + write("\r") + + # 2. Write the prompt + write(self.prompt) + + # 3. Write the entire buffer + write(self.buffer) + + # 4. Clear any remaining characters from a previous longer line + write(ESC[K) # clear from cursor to end of line + + # 5. Move cursor back to the correct position + # The cursor should be at: prompt_display_width + cursor_position + # It is currently at: prompt_display_width + buffer_length + # So move left by: buffer_length - cursor_position + var back = len(self.buffer) - self.cursor + if back > 0: + write(ESC[{back}D) +``` + +This is the same approach used by linenoise. It's simple, correct, and fast enough for interactive use (terminal bandwidth is not a bottleneck for single-line redraws). + +For CJK support (Phase 2), `prompt_display_width` and cursor offset calculations must use `char_width()` instead of byte count. + +## 7. History Navigation Algorithm + +```mojo +# State: +# history: List[String] — past accepted lines (oldest first) +# history_index: Int — -1 = current line; 0..len-1 = history position +# saved_line: String — the in-progress line saved when entering history +# buffer: List[UInt8] — current line content +# cursor: Int — cursor position in buffer + +def _history_up(): + if len(history) == 0: + return + if history_index == -1: + # Entering history for the first time — save current line + saved_line = buffer_to_string() + history_index = len(history) - 1 # most recent + elif history_index > 0: + history_index -= 1 # go further back + else: + return # already at oldest entry + + # Load history entry into buffer + set_buffer(history[history_index]) + cursor = len(buffer) # cursor at end + redraw() + +def _history_down(): + if history_index == -1: + return # not in history navigation + if history_index < len(history) - 1: + history_index += 1 + set_buffer(history[history_index]) + else: + # Return to the saved in-progress line + history_index = -1 + set_buffer(saved_line) + cursor = len(buffer) + redraw() +``` + +## 8. Escape Sequence Parsing + +When `read_byte()` returns `0x1B` (ESC), we need to determine whether this is a standalone Escape keypress or the start of a CSI escape sequence. + +```mojo +def _read_escape_sequence() -> Action: + # Read next byte with implicit short timeout (raw mode VTIME) + var b1 = read_byte() + + if b1 == '[': # CSI sequence + var b2 = read_byte() + match b2: + case 'A': return HistoryUp + case 'B': return HistoryDown + case 'C': return CursorRight + case 'D': return CursorLeft + case 'H': return Home + case 'F': return End + case '3': + var b3 = read_byte() + if b3 == '~': return Delete + case _: return Ignore # unknown sequence + + elif b1 == 'b': return WordBackward # Alt+B + elif b1 == 'f': return WordForward # Alt+F + elif b1 == 'd': return DeleteWordForward # Alt+D + elif b1 == 0x7F: return DeleteWordBackward # Alt+Backspace + + return Escape # standalone ESC +``` + +Note: For Phase 1, we can use `VMIN=1, VTIME=0` (blocking) for simplicity. The ESC disambiguation timeout (`VMIN=0, VTIME=1` = 100ms) can be added in Phase 2 if needed. In practice, escape sequences arrive as bursts and the bytes are available immediately after ESC. + +## 9. Complexity Estimate + +| Component | Estimated lines | Notes | +| ------------------ | --------------- | ------------------------------------------------------ | +| `terminal.mojo` | ~200 | TermIOS + raw mode + ANSI helpers (adapted from termo) | +| `line_editor.mojo` | ~350 | Buffer, cursor, history, key dispatch, redraw | +| `__init__.mojo` | ~10 | Re-exports | +| **Total Phase 1** | **~560** | | +| Tests | ~200 | Buffer ops, cursor, history | +| Phase 2 additions | ~150 | Word ops, Unicode, persistence | +| Phase 3 additions | ~200 | Search, completion, highlights | + +This is comparable to linenoise (1,100 lines of C for the full feature set). + +## 10. Risk and Mitigation + +| Risk | Mitigation | +| ------------------------------------------------------- | ----------------------------------------------------------------------- | +| TermIOS struct layout differs on Linux | Start macOS-only; add Linux layout with conditional compilation later | +| Mojo's `external_call` behavior changes across versions | Pin Mojo version in pixi.toml; termo code already tested on 0.26.x | +| Raw mode not restored on crash/panic | `RawModeGuard.__del__` does direct FFI (no raise); always defer cleanup | +| Unicode cursor positioning off-by-one for CJK | Phase 1 is ASCII-only; add `char_width()` in Phase 2 | +| UTF-8 multi-byte characters split across reads | Phase 1 handles printable ASCII only; Phase 2 adds UTF-8 continuation | +| Performance overhead of per-keystroke redraw | Single-line redraw is fast; benchmark if needed | + +## 11. References + +- [linenoise](https://github.com/antirez/linenoise) — Salvatore Sanfilippo's ~1,100 line C readline replacement. The primary design inspiration for limo. +- [rustyline](https://github.com/kkawakam/rustyline) — Rust readline implementation. Reference for the callback-based completion/highlighting API. +- [termo](https://github.com/forfudan/termo) — My own Mojo terminal library. Source of FFI bindings and raw mode code (`/Users/ZHU/Programs/termo/`). +- [VT100 escape codes](https://vt100.net/docs/vt100-ug/chapter3.html) — Canonical reference for ANSI escape sequences. +- [XTerm Control Sequences](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html) — Comprehensive reference for modern terminal escape sequences. diff --git a/docs/user_manual_cli.md b/docs/user_manual_cli.md index 3d02a96..cc05ddb 100644 --- a/docs/user_manual_cli.md +++ b/docs/user_manual_cli.md @@ -16,7 +16,7 @@ - [Scientific Notation (`--scientific`, `-S`)](#scientific-notation---scientific--s) - [Engineering Notation (`--engineering`, `-E`)](#engineering-notation---engineering--e) - [Pad to Precision (`--pad`)](#pad-to-precision---pad) - - [Digit Separator (`--delimiter`, `-D`)](#digit-separator---delimiter--d) + - [Digit Separator (`--delimiter`)](#digit-separator---delimiter) - [Rounding Mode (`--rounding-mode`, `-R`)](#rounding-mode---rounding-mode--r) - [Input Modes](#input-modes) - [Expression Mode (Default)](#expression-mode-default) @@ -218,15 +218,15 @@ decimo "1.5" --pad -P 10 # → 1.5000000000 (10 fractional digits, 11 significant digits) ``` -### Digit Separator (`--delimiter`, `-D`) +### Digit Separator (`--delimiter`) Insert a character every 3 digits for readability. ```bash -decimo "2^64" -D _ +decimo "2^64" --delimiter _ # → 18_446_744_073_709_551_616 -decimo "pi" -P 30 -D _ +decimo "pi" -P 30 --delimiter _ # → 3.141_592_653_589_793_238_462_643_383_28 ``` @@ -294,7 +294,7 @@ printf '# constants\npi\n\ne' | decimo -P 10 # → 2.718281828 ``` -All CLI options (`-P`, `-S`, `-E`, `-D`, `-R`, `--pad`) apply to every line. +All CLI options (`-P`, `-S`, `-E`, `--delimiter`, `-R`, `--pad`) apply to every line. If any expression fails, `decimo` prints the error for that line, continues evaluating the remaining lines, and exits with code 1. @@ -362,7 +362,7 @@ decimo -P 10 "-3*pi" decimo "-3*pi" -P 10 ``` -Because all short option names are uppercase (`-P`, `-S`, `-E`, `-D`, `-R`), expressions like `-e`, `-sin(1)`, and `-pi` are never mistaken for flags: +Because all short option names are uppercase (`-P`, `-S`, `-E`, `-R`), expressions like `-e`, `-sin(1)`, and `-pi` are never mistaken for flags: ```bash # Euler's number, negated @@ -521,7 +521,7 @@ decimo "123456789.987654321" -E # → 123.456789987654321E+6 # Digit separators -decimo "2^100" -D _ +decimo "2^100" --delimiter _ # → 1_267_650_600_228_229_401_496_703_205_376 # Pad trailing zeros @@ -596,7 +596,7 @@ Options: Output in engineering notation (exponent multiple of 3) --pad Pad trailing zeros to the specified precision - -D, --delimiter + --delimiter Digit-group separator inserted every 3 digits (e.g. '_' gives 1_234.567_89) -R, --rounding-mode Rounding mode for the final result (default: half-even) diff --git a/pixi.toml b/pixi.toml index 1929e94..6e8c555 100644 --- a/pixi.toml +++ b/pixi.toml @@ -27,7 +27,8 @@ format = """pixi run mojo format ./src \ &&pixi run ruff format ./python""" # doc -doc = "pixi run mojo doc --diagnose-missing-doc-strings src/decimo > /dev/null" +doc = """pixi run mojo doc --diagnose-missing-doc-strings src/decimo > /dev/null \ +&& pixi run mojo doc --diagnose-missing-doc-strings -I src src/cli/calculator > /dev/null""" # compile the package p = "clear && pixi run package" @@ -81,7 +82,7 @@ dec_debug = """clear && pixi run package && cd benches/decimal128 \ # Only do this when necessary # (argmojo is not compatible with the latest mojo version) fetch = """git clone https://github.com/forfudan/argmojo.git temp/argmojo 2>/dev/null || true \ -&& cd temp/argmojo && git checkout 29b6f54545f850e19d9a9ccfd1185d87f54e92b2 2>/dev/null""" +&&cd temp/argmojo && git checkout 29b6f54545f850e19d9a9ccfd1185d87f54e92b2 2>/dev/null""" # cli calculator bcli = "clear && pixi run buildcli" diff --git a/src/cli/calculator/__init__.mojo b/src/cli/calculator/__init__.mojo index 0b8b0a0..71d07af 100644 --- a/src/cli/calculator/__init__.mojo +++ b/src/cli/calculator/__init__.mojo @@ -66,3 +66,4 @@ from .io import ( file_exists, ) from .repl import run_repl +from .settings import Settings, parse_settings, split_inline_settings diff --git a/src/cli/calculator/display.mojo b/src/cli/calculator/display.mojo index a162f38..b7ac812 100644 --- a/src/cli/calculator/display.mojo +++ b/src/cli/calculator/display.mojo @@ -31,29 +31,43 @@ in an expression. Modelled after ArgMojo's colour system. from std.sys import stderr -# ── ANSI colour codes ──────────────────────────────────────────────────────── +# == ANSI colour codes ======================================================== comptime RESET = "\x1b[0m" +"""ANSI escape code to reset all attributes.""" comptime BOLD = "\x1b[1m" +"""ANSI escape code for bold text.""" # Bright foreground colours. comptime RED = "\x1b[91m" +"""ANSI escape code for bright red.""" comptime GREEN = "\x1b[92m" +"""ANSI escape code for bright green.""" comptime YELLOW = "\x1b[93m" +"""ANSI escape code for bright yellow.""" comptime BLUE = "\x1b[94m" +"""ANSI escape code for bright blue.""" comptime MAGENTA = "\x1b[95m" +"""ANSI escape code for bright magenta.""" comptime CYAN = "\x1b[96m" +"""ANSI escape code for bright cyan.""" comptime WHITE = "\x1b[97m" +"""ANSI escape code for bright white.""" comptime ORANGE = "\x1b[33m" # dark yellow — renders as orange on most terminals +"""ANSI escape code for orange (dark yellow).""" # Semantic aliases. comptime ERROR_COLOR = RED +"""Colour used for error labels.""" comptime WARNING_COLOR = ORANGE +"""Colour used for warning labels.""" comptime HINT_COLOR = YELLOW +"""Colour used for hint labels.""" comptime CARET_COLOR = GREEN +"""Colour used for caret indicators.""" -# ── Public API ─────────────────────────────────────────────────────────────── +# == Public API =============================================================== def print_error(message: String): @@ -63,6 +77,9 @@ def print_error(message: String): The label `Error` is displayed in bold red. The message text follows in the default terminal colour. + + Args: + message: Human-readable error description. """ _write_stderr( BOLD + ERROR_COLOR + "Error" + RESET + BOLD + ": " + RESET + message @@ -98,6 +115,9 @@ def print_warning(message: String): Format: `Warning: ` The label `Warning` is displayed in bold orange/yellow. + + Args: + message: Human-readable warning description. """ _write_stderr( BOLD + WARNING_COLOR + "Warning" + RESET + BOLD + ": " + RESET + message @@ -105,7 +125,13 @@ def print_warning(message: String): def print_warning(message: String, expr: String, position: Int): - """Prints a coloured warning message with a caret indicator.""" + """Prints a coloured warning message with a caret indicator. + + Args: + message: Human-readable warning description. + expr: The original expression string. + position: 0-based column index to place the caret indicator. + """ _write_stderr( BOLD + WARNING_COLOR + "Warning" + RESET + BOLD + ": " + RESET + message ) @@ -117,7 +143,10 @@ def print_hint(message: String): Format: `Hint: ` - The label `Hint` is displayed in bold cyan. + The label `Hint` is displayed in bold yellow. + + Args: + message: Human-readable hint text. """ _write_stderr( BOLD + HINT_COLOR + "Hint" + RESET + BOLD + ": " + RESET + message @@ -129,12 +158,15 @@ def write_prompt(prompt: String): The prompt is written to stderr so that stdout remains clean for piping results. + + Args: + prompt: The prompt string to display. """ var styled = BOLD + GREEN + prompt + RESET print(styled, end="", file=stderr, flush=True) -# ── Internal helpers ───────────────────────────────────────────────────────── +# == Internal helpers ========================================================= def _write_stderr(msg: String): diff --git a/src/cli/calculator/engine.mojo b/src/cli/calculator/engine.mojo index 5100af0..ef83fc2 100644 --- a/src/cli/calculator/engine.mojo +++ b/src/cli/calculator/engine.mojo @@ -96,6 +96,10 @@ def display_calc_error(error_msg: String, expr: String): 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. + + Args: + error_msg: The error message string to parse and display. + expr: The original expression string for caret display. """ comptime PREFIX = "Error at position " @@ -172,6 +176,19 @@ def evaluate_and_return( 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 use scientific notation. + engineering: Whether to use engineering notation. + pad: Whether to zero-pad results. + delimiter: Digit-group delimiter string. + rounding_mode: The rounding mode to apply. + variables: Optional name→value mapping of user-defined variables. + + Returns: + The evaluated Decimal result. """ try: var tokens = tokenize(expr, variables) diff --git a/src/cli/calculator/evaluator.mojo b/src/cli/calculator/evaluator.mojo index bbfc7eb..03b4ba5 100644 --- a/src/cli/calculator/evaluator.mojo +++ b/src/cli/calculator/evaluator.mojo @@ -189,6 +189,9 @@ def evaluate_rpn( `ans`, `x`). If a TOKEN_VARIABLE token's name is not found in this dict, an error is raised. + Returns: + The evaluated Decimal result before final rounding. + Raises: Error: On division by zero, missing operands, or other runtime errors — with source position when available. @@ -341,6 +344,14 @@ def final_round( This should be called on the result of `evaluate_rpn` before displaying it to the user, so that guard digits are removed and the last visible digit is correctly rounded. + + Args: + value: The Decimal value to round. + precision: Number of significant digits. + rounding_mode: The rounding mode to apply. + + Returns: + A new Decimal rounded to the requested precision. """ if value.is_zero(): return value.copy() diff --git a/src/cli/calculator/io.mojo b/src/cli/calculator/io.mojo index 3b8b0a2..a598508 100644 --- a/src/cli/calculator/io.mojo +++ b/src/cli/calculator/io.mojo @@ -36,7 +36,11 @@ from std.ffi import external_call def stdin_is_tty() -> Bool: """Returns True if stdin is connected to a terminal (TTY), - False if it is a pipe or redirected file.""" + False if it is a pipe or redirected file. + + Returns: + True if stdin is a TTY, False otherwise. + """ return external_call["isatty", Int32](Int32(0)) != 0 @@ -53,6 +57,9 @@ def read_line() -> Optional[String]: This is designed for REPL use: it reads one character at a time via `getchar()` and stops at `\\n` or EOF. + + Returns: + The line content without trailing newline, or None on EOF. """ var chars = List[UInt8]() @@ -81,7 +88,9 @@ def read_stdin() -> String: 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. + + Returns: + The full stdin content, or an empty string if stdin is empty. """ var chunks = List[UInt8]() @@ -108,6 +117,12 @@ def split_into_lines(text: String) -> List[String]: Handles both `\\n` and `\\r\\n` line endings. Trailing empty lines from a final newline are not included. + + Args: + text: The input string to split. + + Returns: + A list of line strings without line terminators. """ var lines = List[String]() var start = 0 @@ -143,12 +158,18 @@ def strip_comment(line: String) -> String: This is a composable primitive — use it in combination with `strip()` and `is_blank()` for full line processing. + Args: + line: The input line to process. + + Returns: + The line content before any `#` comment. + Examples:: - strip_comment("1+2 # add") → "1+2 " - strip_comment("# comment") → "" - strip_comment("sqrt(2)") → "sqrt(2)" - strip_comment("") → "" + strip_comment("1+2 # add") → `1+2 `. + strip_comment("# comment") → `""`. + strip_comment("sqrt(2)") → `sqrt(2)`. + strip_comment("") → `""`. """ var n = len(line) if n == 0: @@ -172,6 +193,12 @@ def is_blank(line: String) -> Bool: This is a composable primitive — combine with `strip_comment()` to check for comment-or-blank lines. + + Args: + line: The input line to check. + + Returns: + True if the line is empty or whitespace-only. """ var n = len(line) if n == 0: @@ -194,6 +221,12 @@ def is_comment_or_blank(line: String) -> Bool: Equivalent to `is_blank(strip_comment(line))`. Provided as a convenience for callers that do not need the intermediate results. + + Args: + line: The input line to check. + + Returns: + True if the line is blank or a comment. """ return is_blank(strip_comment(line)) @@ -203,6 +236,12 @@ def strip(s: String) -> String: Removes spaces (32), tabs (9), carriage returns (13), and newlines (10). + + Args: + s: The input string to strip. + + Returns: + The string with leading and trailing whitespace removed. """ var bytes = StringSlice(s).as_bytes() var ptr = bytes.unsafe_ptr() @@ -233,6 +272,12 @@ def filter_expression_lines(lines: List[String]) -> List[String]: Removes blank lines and comment lines (starting with `#`). Also strips inline comments and leading/trailing whitespace from each expression line. + + Args: + lines: The input list of lines to filter. + + Returns: + A new list containing only non-empty expression lines. """ var result = List[String]() for i in range(len(lines)): @@ -261,6 +306,9 @@ def read_file_text(path: String) raises -> String: Args: path: The file path to read. + Returns: + The file contents as a string, or an empty string if the file is empty. + Raises: If the file cannot be opened. """ @@ -312,6 +360,12 @@ 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). + + Args: + path: The file path to check. + + Returns: + True if the file exists and is readable. """ var c_path = _to_cstr(path) # access(path, R_OK=4) returns 0 on success diff --git a/src/cli/calculator/parser.mojo b/src/cli/calculator/parser.mojo index 0446979..0b23a01 100644 --- a/src/cli/calculator/parser.mojo +++ b/src/cli/calculator/parser.mojo @@ -46,6 +46,12 @@ def parse_to_rpn(tokens: List[Token]) raises -> List[Token]: (sqrt, ln, …), constants (pi, e), and commas for multi-argument functions like root(x, n). + Args: + tokens: The list of infix tokens to convert. + + Returns: + A new list of tokens in RPN order. + Raises: Error: On mismatched parentheses, misplaced commas, or trailing operators — with position information when available. diff --git a/src/cli/calculator/repl.mojo b/src/cli/calculator/repl.mojo index 27a641d..eb6ff29 100644 --- a/src/cli/calculator/repl.mojo +++ b/src/cli/calculator/repl.mojo @@ -24,12 +24,11 @@ until the user types `exit`, `quit`, or presses Ctrl-D. Features: - `ans` — automatically holds the result of the last successful evaluation. - Variable assignment — `x = ` stores a named value for later use. +- Meta-commands — lines starting with `:` change session settings without + evaluation. Example: `:p 100 s r down`. +- Inline temp settings — append `:settings` to an expression for one-off + overrides. Example: `sqrt(2):p 100`. - Error recovery — display error and continue, don't crash the session. - -Architecture notes for future PRs: - -- Meta-commands (4.6): Lines starting with `:` are intercepted before - evaluation. Examples: `:precision 100`, `:vars`, `:help`. """ from std.sys import stderr @@ -41,6 +40,7 @@ from .display import BOLD, RESET, YELLOW from .display import write_prompt, print_error from .engine import evaluate_and_return from .io import read_line, strip, is_comment_or_blank +from .settings import Settings, parse_settings, split_inline_settings from .tokenizer import ( is_alpha_or_underscore, is_alnum_or_underscore, @@ -66,10 +66,30 @@ def run_repl( Maintains a variable store with: - `ans`: automatically updated after each successful evaluation. - User-defined variables via `name = expr` assignment syntax. + + Supports: + - Meta-commands (4.6): lines starting with `:` change session settings + globally. Example: `:p 100 s r down`. + - Inline temp settings (4.8): append `:settings` to an expression for + one-off overrides. Example: `sqrt(2):p 100`. + + Args: + precision: Initial number of significant digits. + scientific: Initial scientific notation flag. + engineering: Initial engineering notation flag. + pad: Initial zero-padding flag. + delimiter: Initial digit-group delimiter. + rounding_mode: Initial rounding mode. """ - _print_banner( - precision, scientific, engineering, pad, delimiter, rounding_mode + var settings = Settings( + precision, + scientific, + engineering, + pad, + delimiter, + rounding_mode, ) + _print_banner(settings) var variables = Dict[String, Decimal]() variables["ans"] = Decimal() # "0" by default, updated after each eval @@ -93,7 +113,36 @@ def run_repl( if line == "exit" or line == "quit": break - # Check for variable assignment: `name = expr` + # == Meta-command: line starts with `:` =========================== + if _is_meta_command(line): + var cmd_str = _strip_colon_prefix(line) + try: + parse_settings(cmd_str, settings) + # Print confirmation to stderr + print(String(settings), file=stderr) + except e: + print_error(String(e)) + continue + + # == Check for inline temp settings (4.8): `expr:settings` ======== + var inline = split_inline_settings(line) + if inline: + var expr = inline.value()[0] + var settings_str = inline.value()[1] + + # Build a temp copy of settings and apply overrides + var temp = settings.copy() + try: + parse_settings(settings_str, temp) + except e: + print_error(String(e)) + continue + + # Evaluate expression with temp settings + _eval_line(expr, temp, variables) + continue + + # == Check for variable assignment: `name = expr` ================= var assignment = _parse_assignment(line) if assignment: @@ -110,12 +159,12 @@ def run_repl( try: var result = evaluate_and_return( expr, - precision, - scientific, - engineering, - pad, - delimiter, - rounding_mode, + settings.precision, + settings.scientific, + settings.engineering, + settings.pad, + settings.delimiter, + settings.rounding_mode, variables, ) variables[var_name] = result.copy() @@ -124,20 +173,54 @@ def run_repl( continue # error already displayed else: # Regular expression — evaluate and update ans - try: - var result = evaluate_and_return( - line, - precision, - scientific, - engineering, - pad, - delimiter, - rounding_mode, - variables, - ) - variables["ans"] = result^ - except: - continue # error already displayed + _eval_line(line, settings, variables) + + +def _eval_line( + expr: String, + settings: Settings, + mut variables: Dict[String, Decimal], +): + """Evaluate an expression with the given settings and update `ans`.""" + try: + var result = evaluate_and_return( + expr, + settings.precision, + settings.scientific, + settings.engineering, + settings.pad, + settings.delimiter, + settings.rounding_mode, + variables, + ) + variables["ans"] = result^ + except: + pass # error already displayed by evaluate_and_return + + +def _is_meta_command(line: String) -> Bool: + """Check if a line is a meta-command (starts with `:` after whitespace).""" + var bytes = StringSlice(line).as_bytes() + var n = len(bytes) + var i = 0 + while i < n and (bytes[i] == 32 or bytes[i] == 9): + i += 1 + return i < n and bytes[i] == 58 # ':' + + +def _strip_colon_prefix(line: String) -> String: + """Strip leading whitespace and the `:` prefix from a meta-command.""" + var bytes = StringSlice(line).as_bytes() + var n = len(bytes) + var i = 0 + while i < n and (bytes[i] == 32 or bytes[i] == 9): + i += 1 + if i < n and bytes[i] == 58: # ':' + i += 1 + var result = List[UInt8](capacity=n - i) + for j in range(i, n): + result.append(bytes[j]) + return String(unsafe_from_utf8=result^) def _parse_assignment(line: String) -> Optional[Tuple[String, String]]: @@ -227,14 +310,7 @@ def _validate_variable_name(name: String) -> Optional[String]: return None -def _print_banner( - precision: Int, - scientific: Bool, - engineering: Bool, - pad: Bool, - delimiter: String, - rounding_mode: RoundingMode, -): +def _print_banner(settings: Settings): """Prints the REPL welcome banner to stderr.""" comptime message = ( BOLD @@ -244,20 +320,8 @@ def _print_banner( + """Type an expression to evaluate, e.g., `pi + sin(-ln(1.23)) * sqrt(e^2)`. You can assign variables with `name = expression`, e.g., `x = 1.023^365`. You can use `ans` to refer to the last result. +Use `:` to change settings, e.g., `:p 100 s r down`. Type 'exit' or 'quit', or press Ctrl-D, to quit.""" ) print(message, file=stderr) - - # Build settings line: "Precision: N. Engineering notation." - var settings = "Precision: " + String(precision) + "." - if scientific: - settings += " Scientific notation." - elif engineering: - settings += " Engineering notation." - if pad: - settings += " Zero-padded." - if delimiter: - settings += " Delimiter: '" + delimiter + "'." - if not (rounding_mode == RoundingMode.half_even()): - settings += " Rounding: " + String(rounding_mode) + "." - print(settings, file=stderr) + print(String(settings), file=stderr) diff --git a/src/cli/calculator/settings.mojo b/src/cli/calculator/settings.mojo new file mode 100644 index 0000000..2ac9f8f --- /dev/null +++ b/src/cli/calculator/settings.mojo @@ -0,0 +1,461 @@ +# ===----------------------------------------------------------------------=== # +# 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. +# ===----------------------------------------------------------------------=== # + +""" +REPL settings for the Decimo CLI calculator. + +Bundles all display and computation options (precision, formatting, rounding +mode) into a single `Settings` struct and provides a one-line parser that +matches option names by their long name, short name, and aliases — mirroring +the CLI flag definitions from `DecimoArgs`. + +The parser is the core: split by whitespace, scan each token, match +it against known names, and consume a following value for options. Flags +(scientific, engineering, pad) toggle on each use — repeat to turn off. + + `:p 100 s d` → precision=100, scientific=True, rounding=down + +Name mapping (mirrors DecimoArgs, case-insensitive inside the REPL): + + Options (consume next token as value): + precision p → Int + delimiter → String + rounding-mode r rm round → RoundingMode name + + Flags (toggle on/off each time they appear): + scientific s sci → Bool + engineering e eng → Bool + pad → Bool + + Standalone rounding modes (set directly, no `r` prefix needed): + he hu hd u d c f b + half-even half-up half-down up down ceiling floor bankers + +Rounding-mode values (accepted after `r` or standalone): + half-even half_even he b bankers (default, banker's rounding) + half-up half_up hu + half-down half_down hd + up u + down d + ceiling ceil c + floor f +""" + +from decimo.rounding_mode import RoundingMode + + +# ===----------------------------------------------------------------------=== # +# Settings struct +# ===----------------------------------------------------------------------=== # + + +struct Settings(Copyable, Movable, Writable): + """Mutable bundle of all REPL computation and display options.""" + + var precision: Int + """Number of significant digits for computation results.""" + var scientific: Bool + """Whether to display results in scientific notation.""" + var engineering: Bool + """Whether to display results in engineering notation.""" + var pad: Bool + """Whether to zero-pad results to the full precision.""" + var delimiter: String + """Digit-group delimiter string (empty means no grouping).""" + var rounding_mode: RoundingMode + """The rounding mode for final results.""" + + fn __init__( + out self, + precision: Int = 50, + scientific: Bool = False, + engineering: Bool = False, + pad: Bool = False, + delimiter: String = "", + rounding_mode: RoundingMode = RoundingMode.half_even(), + ): + """Creates a new Settings with the given options. + + Args: + precision: Number of significant digits (default 50). + scientific: Whether to use scientific notation. + engineering: Whether to use engineering notation. + pad: Whether to zero-pad results. + delimiter: Digit-group delimiter string. + rounding_mode: The rounding mode to apply. + """ + self.precision = precision + self.scientific = scientific + self.engineering = engineering + self.pad = pad + self.delimiter = delimiter + self.rounding_mode = rounding_mode + + fn write_to[W: Writer](self, mut writer: W): + """Writes a human-readable summary of the settings. + + Parameters: + W: The writer type. + + Args: + writer: The writer instance. + """ + writer.write("Precision: ", self.precision, ".") + if self.scientific: + writer.write(" Scientific notation.") + elif self.engineering: + writer.write(" Engineering notation.") + if self.pad: + writer.write(" Zero-padded.") + if self.delimiter: + writer.write(" Delimiter: '", self.delimiter, "'.") + if not (self.rounding_mode == RoundingMode.half_even()): + writer.write(" Rounding: ", self.rounding_mode, ".") + + +# ===----------------------------------------------------------------------=== # +# Settings parser (core of 4.7) +# ===----------------------------------------------------------------------=== # + + +def parse_settings(input: String, mut settings: Settings) raises: + """Parses a one-line settings string and apply changes to `settings`. + + Splits by whitespace, scans tokens left-to-right. Each token is + matched against known option/flag names (case-insensitive). Options + consume the next token as their value; flags toggle their Bool value + each time they appear. + + Raises on unknown tokens or missing values. + + This is enlightened by and is a simplified version of ArgMojo's parser. + + Args: + input: The settings string (without the leading `:`). + settings: The Settings struct to modify in place. + """ + var tokens = _split_whitespace(input) + var n = len(tokens) + if n == 0: + return + + var i = 0 + while i < n: + var token = _to_lower(tokens[i]) + + # == Options (consume next token as value) ======================== + if _is_precision_name(token): + i += 1 + if i >= n: + raise Error("expected a value after '" + tokens[i - 1] + "'") + var val = _parse_int(tokens[i], "precision") + if val < 1: + raise Error("precision must be >= 1, got " + String(val)) + settings.precision = val + + elif _is_delimiter_name(token): + i += 1 + if i >= n: + raise Error("expected a value after '" + tokens[i - 1] + "'") + var dval = _to_lower(tokens[i]) + if dval == "off" or dval == "none" or dval == '""' or dval == "''": + settings.delimiter = "" + else: + settings.delimiter = tokens[i] + + elif _is_rounding_mode_name(token): + i += 1 + if i >= n: + raise Error( + "expected a rounding mode after '" + tokens[i - 1] + "'" + ) + settings.rounding_mode = _parse_rounding_mode(_to_lower(tokens[i])) + + # == Flags (toggle; enforce mutual exclusion) ===================== + elif _is_scientific_name(token): + if settings.scientific: + settings.scientific = False + else: + settings.scientific = True + settings.engineering = False # mutually exclusive + + elif _is_engineering_name(token): + if settings.engineering: + settings.engineering = False + else: + settings.engineering = True + settings.scientific = False # mutually exclusive + + elif _is_pad_name(token): + settings.pad = not settings.pad + + # == Standalone rounding modes (no `r` prefix needed) ============ + elif _is_standalone_rounding_mode(token): + settings.rounding_mode = _parse_rounding_mode(token) + + else: + raise Error("unknown setting: '" + tokens[i] + "'") + + i += 1 + + +def format_settings_confirmation(settings: Settings) -> String: + """Returns a human-readable summary of the current settings. + + Args: + settings: The current settings to format. + + Returns: + A formatted string describing the active settings. + """ + return String(settings) + + +# ===----------------------------------------------------------------------=== # +# Inline settings detection (4.8) +# ===----------------------------------------------------------------------=== # + + +def split_inline_settings( + line: String, +) -> Optional[Tuple[String, String]]: + """Detects inline settings in a REPL line. + + If the line contains a `:` that is not at position 0, splits at the + last `:` into (expression, settings_string). + + Args: + line: The REPL input line to examine. + + Returns: + A tuple of (expression, settings_string) if inline settings are + found, or None if there are no inline settings. + + Examples: + `"2*sqrt(1.23):p 100"` → `("2*sqrt(1.23)", "p 100")`. + `"1+2"` → None. + `":p 100"` → None (pure meta-command, handled elsewhere). + """ + var bytes = StringSlice(line).as_bytes() + var n = len(bytes) + + if n == 0: + return None + + # Skip leading whitespace + var start = 0 + while start < n and (bytes[start] == 32 or bytes[start] == 9): + start += 1 + + # If line starts with ':', it's a pure meta-command, not inline + if start < n and bytes[start] == 58: # ':' + return None + + # Find the last ':' — it cannot appear in math expressions, + # so no need to track parentheses. + var colon_pos = -1 + var j = n - 1 + while j > start: + if bytes[j] == 58: # ':' + colon_pos = j + break + j -= 1 + + if colon_pos <= start: + return None + + # Build expression and settings strings + var expr_bytes = List[UInt8](capacity=colon_pos) + for k in range(colon_pos): + expr_bytes.append(bytes[k]) + var expr = String(unsafe_from_utf8=expr_bytes^) + + var settings_start = colon_pos + 1 + var settings_bytes = List[UInt8](capacity=n - settings_start) + for k in range(settings_start, n): + settings_bytes.append(bytes[k]) + var settings_str = String(unsafe_from_utf8=settings_bytes^) + + return (expr^, settings_str^) + + +# ===----------------------------------------------------------------------=== # +# Name matching — mirrors DecimoArgs CLI definitions +# ===----------------------------------------------------------------------=== # + + +fn _is_precision_name(token: String) -> Bool: + """Match: precision, p.""" + return token == "precision" or token == "p" + + +fn _is_delimiter_name(token: String) -> Bool: + """Match: delimiter.""" + return token == "delimiter" + + +fn _is_rounding_mode_name(token: String) -> Bool: + """Match: rounding-mode, r, rm, round, rounding_mode.""" + return ( + token == "rounding-mode" + or token == "r" + or token == "rm" + or token == "round" + or token == "rounding_mode" + ) + + +fn _is_scientific_name(token: String) -> Bool: + """Match: scientific, s, sci.""" + return token == "scientific" or token == "s" or token == "sci" + + +fn _is_engineering_name(token: String) -> Bool: + """Match: engineering, e, eng.""" + return token == "engineering" or token == "e" or token == "eng" + + +fn _is_pad_name(token: String) -> Bool: + """Match: pad.""" + return token == "pad" + + +fn _is_standalone_rounding_mode(token: String) -> Bool: + """Match standalone rounding mode shortcuts: he, hu, hd, u, d, c, f, b, + and their full names.""" + return ( + token == "he" + or token == "hu" + or token == "hd" + or token == "u" + or token == "d" + or token == "c" + or token == "f" + or token == "b" + or token == "half-even" + or token == "half_even" + or token == "half-up" + or token == "half_up" + or token == "half-down" + or token == "half_down" + or token == "up" + or token == "down" + or token == "ceiling" + or token == "ceil" + or token == "floor" + or token == "bankers" + ) + + +# ===----------------------------------------------------------------------=== # +# Value parsers +# ===----------------------------------------------------------------------=== # + + +def _parse_int(s: String, name: String) raises -> Int: + """Parse a string as an integer value for a named setting.""" + try: + return Int(s) + except: + raise Error( + "invalid value for " + name + ": '" + s + "' (expected integer)" + ) + + +def _parse_rounding_mode(s: String) raises -> RoundingMode: + """Parse a rounding-mode name (case-insensitive, hyphen/underscore OK). + + Accepted names: + half-even half_even he (default) + half-up half_up hu + half-down half_down hd + up u + down + ceiling ceil c + floor f + """ + if ( + s == "half-even" + or s == "half_even" + or s == "he" + or s == "b" + or s == "bankers" + ): + return RoundingMode.half_even() + elif s == "half-up" or s == "half_up" or s == "hu": + return RoundingMode.half_up() + elif s == "half-down" or s == "half_down" or s == "hd": + return RoundingMode.half_down() + elif s == "up" or s == "u": + return RoundingMode.up() + elif s == "down" or s == "d": + return RoundingMode.down() + elif s == "ceiling" or s == "ceil" or s == "c": + return RoundingMode.ceiling() + elif s == "floor" or s == "f": + return RoundingMode.floor() + else: + raise Error( + "unknown rounding mode: '" + + s + + "'. Expected: half-even (he/b), half-up (hu), half-down (hd)," + " up (u), down (d), ceiling (c), floor (f)" + ) + + +# ===----------------------------------------------------------------------=== # +# String utilities +# ===----------------------------------------------------------------------=== # + + +fn _split_whitespace(s: String) -> List[String]: + """Split a string by whitespace, returning non-empty tokens.""" + var result = List[String]() + var bytes = StringSlice(s).as_bytes() + var n = len(bytes) + var i = 0 + + while i < n: + # Skip whitespace + while i < n and (bytes[i] == 32 or bytes[i] == 9): + i += 1 + if i >= n: + break + # Collect token + var start = i + while i < n and bytes[i] != 32 and bytes[i] != 9: + i += 1 + # Build token string + var token_bytes = List[UInt8](capacity=i - start) + for j in range(start, i): + token_bytes.append(bytes[j]) + result.append(String(unsafe_from_utf8=token_bytes^)) + + return result^ + + +fn _to_lower(s: String) -> String: + """Convert a string to lowercase (ASCII only).""" + var bytes = StringSlice(s).as_bytes() + var n = len(bytes) + var result = List[UInt8](capacity=n) + for i in range(n): + var c = bytes[i] + if c >= 65 and c <= 90: # A-Z + result.append(c + 32) + else: + result.append(c) + return String(unsafe_from_utf8=result^) diff --git a/src/cli/calculator/tokenizer.mojo b/src/cli/calculator/tokenizer.mojo index 600494e..003457d 100644 --- a/src/cli/calculator/tokenizer.mojo +++ b/src/cli/calculator/tokenizer.mojo @@ -28,18 +28,31 @@ from decimo import Decimal # ===----------------------------------------------------------------------=== # comptime TOKEN_NUMBER = 0 +"""Token kind for numeric literals.""" comptime TOKEN_PLUS = 1 +"""Token kind for the `+` operator.""" comptime TOKEN_MINUS = 2 +"""Token kind for the binary `-` operator.""" comptime TOKEN_STAR = 3 +"""Token kind for the `*` operator.""" comptime TOKEN_SLASH = 4 +"""Token kind for the `/` operator.""" comptime TOKEN_LPAREN = 5 +"""Token kind for `(`.""" comptime TOKEN_RPAREN = 6 +"""Token kind for `)`.""" comptime TOKEN_UNARY_MINUS = 7 +"""Token kind for unary minus.""" comptime TOKEN_CARET = 8 +"""Token kind for the `^` (power) operator.""" comptime TOKEN_FUNC = 9 +"""Token kind for function names (sqrt, ln, etc.).""" comptime TOKEN_CONST = 10 +"""Token kind for built-in constants (pi, e).""" comptime TOKEN_COMMA = 11 +"""Token kind for `,` (argument separator).""" comptime TOKEN_VARIABLE = 12 +"""Token kind for user-defined variables.""" # ===----------------------------------------------------------------------=== # @@ -51,29 +64,52 @@ struct Token(Copyable, ImplicitlyCopyable, Movable): """A token produced by the lexer.""" var kind: Int + """Integer tag identifying the token type (see TOKEN_* constants).""" var value: String + """The textual content of the token.""" 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 '*'`.""" def __init__(out self, kind: Int, value: String = "", position: Int = 0): + """Creates a new Token. + + Args: + kind: The token type tag. + value: The textual content of the token. + position: 0-based column index in the source expression. + """ self.kind = kind self.value = value self.position = position def __init__(out self, *, copy: Self): + """Creates a copy of an existing Token. + + Args: + copy: The token to copy from. + """ self.kind = copy.kind self.value = copy.value self.position = copy.position def __init__(out self, *, deinit take: Self): + """Move-constructs a Token. + + Args: + take: The token to move from. + """ self.kind = take.kind self.value = take.value^ self.position = take.position def is_operator(self) -> Bool: - """Returns True if this token is a binary or unary operator.""" + """Returns True if this token is a binary or unary operator. + + Returns: + True if the token kind is an operator. + """ return ( self.kind == TOKEN_PLUS or self.kind == TOKEN_MINUS @@ -92,6 +128,9 @@ struct Token(Copyable, ImplicitlyCopyable, Movable): | 2 | *, / | Left | | 3 | ^ | Right | | 4 (high) | unary - | Right | + + Returns: + The integer precedence level, or 0 for non-operators. """ if self.kind == TOKEN_PLUS or self.kind == TOKEN_MINUS: return 1 @@ -104,7 +143,11 @@ struct Token(Copyable, ImplicitlyCopyable, Movable): return 0 def is_left_associative(self) -> Bool: - """Returns True if this operator is left-associative.""" + """Returns True if this operator is left-associative. + + Returns: + True for left-associative operators, False for right-associative. + """ if self.kind == TOKEN_UNARY_MINUS or self.kind == TOKEN_CARET: return False return True @@ -127,7 +170,14 @@ struct Token(Copyable, ImplicitlyCopyable, Movable): def is_known_function(name: String) -> Bool: - """Returns True if `name` is a recognized function.""" + """Returns True if `name` is a recognized function. + + Args: + name: The identifier to check. + + Returns: + True if the name matches a built-in function. + """ return ( name == "sqrt" or name == "root" @@ -146,17 +196,38 @@ def is_known_function(name: String) -> Bool: def is_known_constant(name: String) -> Bool: - """Returns True if `name` is a recognized constant.""" + """Returns True if `name` is a recognized constant. + + Args: + name: The identifier to check. + + Returns: + True if the name matches a built-in constant. + """ return name == "pi" or name == "e" def is_alpha_or_underscore(c: UInt8) -> Bool: - """Returns True if c is a-z, A-Z, or '_'.""" + """Returns True if c is a-z, A-Z, or '_'. + + Args: + c: The byte value to check. + + Returns: + True if the byte is an ASCII letter or underscore. + """ return (c >= 65 and c <= 90) or (c >= 97 and c <= 122) or c == 95 def is_alnum_or_underscore(c: UInt8) -> Bool: - """Returns True if c is a-z, A-Z, 0-9, or '_'.""" + """Returns True if c is a-z, A-Z, 0-9, or '_'. + + Args: + c: The byte value to check. + + Returns: + True if the byte is an ASCII alphanumeric character or underscore. + """ return is_alpha_or_underscore(c) or (c >= 48 and c <= 57) @@ -181,6 +252,9 @@ def tokenize( variables. Identifiers matching a key are emitted as TOKEN_VARIABLE tokens instead of raising an error. + Returns: + A list of tokens representing the expression. + Raises: Error: On empty/whitespace-only input (without position info), unknown identifiers, or unexpected characters (with the diff --git a/src/cli/main.mojo b/src/cli/main.mojo index 3d666ba..0944894 100644 --- a/src/cli/main.mojo +++ b/src/cli/main.mojo @@ -74,7 +74,6 @@ struct DecimoArgs(Parsable): var delimiter: Option[ String, long="delimiter", - short="D", help="Digit-group separator inserted every 3 digits (e.g. '_' gives 1_234.567_89)", default="", value_name="CHAR", diff --git a/tests/cli/test_repl.mojo b/tests/cli/test_repl.mojo index d8d970b..c30adfe 100644 --- a/tests/cli/test_repl.mojo +++ b/tests/cli/test_repl.mojo @@ -1,8 +1,14 @@ -"""Test REPL helpers: _parse_assignment, _validate_variable_name.""" +"""Test REPL helpers: _parse_assignment, _validate_variable_name, +_is_meta_command, _strip_colon_prefix.""" from std import testing -from calculator.repl import _parse_assignment, _validate_variable_name +from calculator.repl import ( + _parse_assignment, + _validate_variable_name, + _is_meta_command, + _strip_colon_prefix, +) # ===----------------------------------------------------------------------=== # @@ -127,6 +133,66 @@ def test_valid_name_with_underscore() raises: testing.assert_false(Bool(err), "underscore name is valid") +# ===----------------------------------------------------------------------=== # +# Tests: _is_meta_command +# ===----------------------------------------------------------------------=== # + + +def test_meta_command_colon_prefix() raises: + """`:p 100` is a meta-command.""" + testing.assert_true(_is_meta_command(":p 100"), "colon prefix") + + +def test_meta_command_with_leading_space() raises: + """Leading whitespace before `:` is handled.""" + testing.assert_true(_is_meta_command(" :p 100"), "leading space") + + +def test_meta_command_with_tab() raises: + """Leading tab before `:` is handled.""" + testing.assert_true(_is_meta_command("\t:s"), "leading tab") + + +def test_not_meta_command_expression() raises: + """A plain expression is not a meta-command.""" + testing.assert_false(_is_meta_command("1 + 2"), "plain expression") + + +def test_not_meta_command_empty() raises: + """Empty line is not a meta-command.""" + testing.assert_false(_is_meta_command(""), "empty line") + + +def test_not_meta_command_inline_colon() raises: + """Colon inside expression is not a meta-command.""" + testing.assert_false(_is_meta_command("sqrt(2):p 100"), "inline colon") + + +# ===----------------------------------------------------------------------=== # +# Tests: _strip_colon_prefix +# ===----------------------------------------------------------------------=== # + + +def test_strip_colon_basic() raises: + """`:p 100` → `p 100`.""" + testing.assert_equal(_strip_colon_prefix(":p 100"), "p 100") + + +def test_strip_colon_with_leading_space() raises: + """` :s` → `s`.""" + testing.assert_equal(_strip_colon_prefix(" :s"), "s") + + +def test_strip_colon_only() raises: + """`:` alone → empty string.""" + testing.assert_equal(_strip_colon_prefix(":"), "") + + +def test_strip_colon_with_tab() raises: + """Tab before `:` is stripped.""" + testing.assert_equal(_strip_colon_prefix("\t:p 50"), "p 50") + + # ===----------------------------------------------------------------------=== # # Main # ===----------------------------------------------------------------------=== # diff --git a/tests/cli/test_settings.mojo b/tests/cli/test_settings.mojo new file mode 100644 index 0000000..be7a657 --- /dev/null +++ b/tests/cli/test_settings.mojo @@ -0,0 +1,498 @@ +"""Tests for the settings parser (4.7), meta-command detection, and inline +settings splitting (4.8).""" + +from std import testing + +from decimo.rounding_mode import RoundingMode +from calculator.settings import ( + Settings, + parse_settings, + split_inline_settings, +) + + +# ===----------------------------------------------------------------------=== # +# Tests: Settings struct +# ===----------------------------------------------------------------------=== # + + +def test_settings_defaults() raises: + """Default settings match CLI defaults.""" + var s = Settings() + testing.assert_equal(s.precision, 50) + testing.assert_false(s.scientific, "scientific default") + testing.assert_false(s.engineering, "engineering default") + testing.assert_false(s.pad, "pad default") + testing.assert_equal(s.delimiter, "") + testing.assert_true( + s.rounding_mode == RoundingMode.half_even(), "rounding default" + ) + + +def test_settings_write_to_default() raises: + """Default settings produces a simple summary.""" + var s = Settings() + var text = String(s) + testing.assert_true("Precision: 50." in text, "should contain precision") + + +def test_settings_write_to_custom() raises: + """Custom settings are reflected in the summary.""" + var s = Settings( + precision=100, + scientific=True, + pad=True, + delimiter="_", + ) + var text = String(s) + testing.assert_true("Precision: 100." in text, "precision") + testing.assert_true("Scientific" in text, "scientific") + testing.assert_true("Zero-padded" in text, "pad") + testing.assert_true("_" in text, "delimiter") + + +# ===----------------------------------------------------------------------=== # +# Tests: parse_settings — precision +# ===----------------------------------------------------------------------=== # + + +def test_parse_precision_long() raises: + """`:precision 100` sets precision.""" + var s = Settings() + parse_settings("precision 100", s) + testing.assert_equal(s.precision, 100) + + +def test_parse_precision_short() raises: + """`:p 200` sets precision.""" + var s = Settings() + parse_settings("p 200", s) + testing.assert_equal(s.precision, 200) + + +def test_parse_precision_case_insensitive() raises: + """`:P 300` is case-insensitive.""" + var s = Settings() + parse_settings("P 300", s) + testing.assert_equal(s.precision, 300) + + +def test_parse_precision_missing_value() raises: + """`:p` with no value raises.""" + var s = Settings() + var raised = False + try: + parse_settings("p", s) + except: + raised = True + testing.assert_true(raised, "should raise") + + +def test_parse_precision_invalid_value() raises: + """`:p abc` with non-integer value raises.""" + var s = Settings() + var raised = False + try: + parse_settings("p abc", s) + except: + raised = True + testing.assert_true(raised, "should raise on non-integer") + + +def test_parse_precision_zero() raises: + """`:p 0` rejects zero precision.""" + var s = Settings() + var raised = False + try: + parse_settings("p 0", s) + except: + raised = True + testing.assert_true(raised, "should reject p 0") + + +# ===----------------------------------------------------------------------=== # +# Tests: parse_settings — flags +# ===----------------------------------------------------------------------=== # + + +def test_parse_scientific_short() raises: + """`:s` turns on scientific.""" + var s = Settings() + parse_settings("s", s) + testing.assert_true(s.scientific, "scientific on") + testing.assert_false(s.engineering, "engineering off (mutual exclusion)") + + +def test_parse_scientific_long() raises: + """`:scientific` turns on scientific.""" + var s = Settings() + parse_settings("scientific", s) + testing.assert_true(s.scientific, "scientific on") + + +def test_parse_scientific_alias() raises: + """`:sci` turns on scientific.""" + var s = Settings() + parse_settings("sci", s) + testing.assert_true(s.scientific, "scientific on") + + +def test_parse_engineering_turns_off_scientific() raises: + """`:e` turns off scientific if it was on.""" + var s = Settings(scientific=True) + parse_settings("e", s) + testing.assert_true(s.engineering, "engineering on") + testing.assert_false(s.scientific, "scientific off") + + +def test_parse_pad() raises: + """`:pad` turns on pad.""" + var s = Settings() + parse_settings("pad", s) + testing.assert_true(s.pad, "pad on") + + +def test_parse_no_scientific() raises: + """`:s` again toggles scientific off.""" + var s = Settings(scientific=True) + parse_settings("s", s) + testing.assert_false(s.scientific, "scientific toggled off") + + +def test_parse_no_engineering() raises: + """`:e` again toggles engineering off.""" + var s = Settings(engineering=True) + parse_settings("e", s) + testing.assert_false(s.engineering, "engineering toggled off") + + +def test_parse_no_pad() raises: + """`:pad` again toggles pad off.""" + var s = Settings(pad=True) + parse_settings("pad", s) + testing.assert_false(s.pad, "pad toggled off") + + +# ===----------------------------------------------------------------------=== # +# Tests: parse_settings — delimiter +# ===----------------------------------------------------------------------=== # + + +def test_parse_delimiter_long() raises: + """`:delimiter ,` sets delimiter.""" + var s = Settings() + parse_settings("delimiter ,", s) + testing.assert_equal(s.delimiter, ",") + + +def test_parse_delimiter_off() raises: + """`:delimiter off` clears delimiter.""" + var s = Settings(delimiter="_") + parse_settings("delimiter off", s) + testing.assert_equal(s.delimiter, "") + + +def test_parse_delimiter_none() raises: + """`:delimiter none` clears delimiter.""" + var s = Settings(delimiter=",") + parse_settings("delimiter none", s) + testing.assert_equal(s.delimiter, "") + + +# ===----------------------------------------------------------------------=== # +# Tests: parse_settings — rounding mode +# ===----------------------------------------------------------------------=== # + + +def test_parse_rounding_short() raises: + """`:r down` sets rounding mode.""" + var s = Settings() + parse_settings("r down", s) + testing.assert_true(s.rounding_mode == RoundingMode.down(), "rounding down") + + +def test_parse_rounding_long() raises: + """`:rounding-mode half-up` sets rounding mode.""" + var s = Settings() + parse_settings("rounding-mode half-up", s) + testing.assert_true( + s.rounding_mode == RoundingMode.half_up(), "rounding half-up" + ) + + +def test_parse_rounding_underscore() raises: + """`:r half_even` with underscore form works.""" + var s = Settings() + parse_settings("r half_even", s) + testing.assert_true( + s.rounding_mode == RoundingMode.half_even(), "rounding half-even" + ) + + +def test_parse_rounding_abbreviation() raises: + """`:r hu` short form for half-up.""" + var s = Settings() + parse_settings("r hu", s) + testing.assert_true( + s.rounding_mode == RoundingMode.half_up(), "rounding half-up" + ) + + +def test_parse_rounding_ceiling() raises: + """`:r ceil` alias for ceiling.""" + var s = Settings() + parse_settings("r ceil", s) + testing.assert_true( + s.rounding_mode == RoundingMode.ceiling(), "rounding ceiling" + ) + + +def test_parse_rounding_floor_short() raises: + """`:r f` alias for floor.""" + var s = Settings() + parse_settings("r f", s) + testing.assert_true( + s.rounding_mode == RoundingMode.floor(), "rounding floor" + ) + + +def test_parse_rounding_invalid() raises: + """`:r xyz` raises on unknown rounding mode.""" + var s = Settings() + var raised = False + try: + parse_settings("r xyz", s) + except: + raised = True + testing.assert_true(raised, "should raise on unknown rounding mode") + + +def test_parse_rounding_bankers() raises: + """`:r bankers` alias for half-even.""" + var s = Settings() + parse_settings("r bankers", s) + testing.assert_true( + s.rounding_mode == RoundingMode.half_even(), "rounding bankers" + ) + + +def test_parse_rounding_b() raises: + """`:r b` alias for half-even (banker's rounding).""" + var s = Settings() + parse_settings("r b", s) + testing.assert_true( + s.rounding_mode == RoundingMode.half_even(), "rounding b" + ) + + +# ===----------------------------------------------------------------------=== # +# Tests: parse_settings — standalone rounding modes (no `r` prefix) +# ===----------------------------------------------------------------------=== # + + +def test_standalone_d_is_down() raises: + """`d` directly sets rounding to down.""" + var s = Settings() + parse_settings("d", s) + testing.assert_true(s.rounding_mode == RoundingMode.down(), "d = down") + + +def test_standalone_u_is_up() raises: + """`u` directly sets rounding to up.""" + var s = Settings() + parse_settings("u", s) + testing.assert_true(s.rounding_mode == RoundingMode.up(), "u = up") + + +def test_standalone_he_is_half_even() raises: + """`he` directly sets rounding to half-even.""" + var s = Settings() + s.rounding_mode = RoundingMode.down() # change first + parse_settings("he", s) + testing.assert_true( + s.rounding_mode == RoundingMode.half_even(), "he = half-even" + ) + + +def test_standalone_hu_is_half_up() raises: + """`hu` directly sets rounding to half-up.""" + var s = Settings() + parse_settings("hu", s) + testing.assert_true( + s.rounding_mode == RoundingMode.half_up(), "hu = half-up" + ) + + +def test_standalone_hd_is_half_down() raises: + """`hd` directly sets rounding to half-down.""" + var s = Settings() + parse_settings("hd", s) + testing.assert_true( + s.rounding_mode == RoundingMode.half_down(), "hd = half-down" + ) + + +def test_standalone_c_is_ceiling() raises: + """`c` directly sets rounding to ceiling.""" + var s = Settings() + parse_settings("c", s) + testing.assert_true( + s.rounding_mode == RoundingMode.ceiling(), "c = ceiling" + ) + + +def test_standalone_f_is_floor() raises: + """`f` directly sets rounding to floor.""" + var s = Settings() + parse_settings("f", s) + testing.assert_true(s.rounding_mode == RoundingMode.floor(), "f = floor") + + +def test_standalone_b_is_bankers() raises: + """`b` directly sets rounding to half-even (banker's).""" + var s = Settings() + s.rounding_mode = RoundingMode.down() # change first + parse_settings("b", s) + testing.assert_true( + s.rounding_mode == RoundingMode.half_even(), "b = bankers" + ) + + +def test_standalone_down_word() raises: + """`down` directly sets rounding to down.""" + var s = Settings() + parse_settings("down", s) + testing.assert_true(s.rounding_mode == RoundingMode.down(), "down = down") + + +def test_standalone_in_one_liner() raises: + """`p 100 s d` sets precision, scientific, and rounding down.""" + var s = Settings() + parse_settings("p 100 s d", s) + testing.assert_equal(s.precision, 100) + testing.assert_true(s.scientific, "scientific on") + testing.assert_true(s.rounding_mode == RoundingMode.down(), "rounding down") + + +# ===----------------------------------------------------------------------=== # +# Tests: parse_settings — multi-token (4.7 one-liner) +# ===----------------------------------------------------------------------=== # + + +def test_parse_multi_p_s() raises: + """`:p 100 s` sets precision and scientific.""" + var s = Settings() + parse_settings("p 100 s", s) + testing.assert_equal(s.precision, 100) + testing.assert_true(s.scientific, "scientific on") + + +def test_parse_multi_p_s_r() raises: + """`:p 100 s r down` sets precision, scientific, and rounding.""" + var s = Settings() + parse_settings("p 100 s r down", s) + testing.assert_equal(s.precision, 100) + testing.assert_true(s.scientific, "scientific on") + testing.assert_true(s.rounding_mode == RoundingMode.down(), "rounding down") + + +def test_parse_multi_all() raises: + """Full one-liner with all options.""" + var s = Settings() + parse_settings("p 200 e pad delimiter _ r ceiling", s) + testing.assert_equal(s.precision, 200) + testing.assert_false(s.scientific, "scientific off") + testing.assert_true(s.engineering, "engineering on") + testing.assert_true(s.pad, "pad on") + testing.assert_equal(s.delimiter, "_") + testing.assert_true( + s.rounding_mode == RoundingMode.ceiling(), "rounding ceiling" + ) + + +def test_parse_unknown_token() raises: + """Unknown settings token raises error.""" + var s = Settings() + var raised = False + try: + parse_settings("p 100 xyz", s) + except: + raised = True + testing.assert_true(raised, "should raise on unknown token") + + +def test_parse_empty_string() raises: + """Empty input is a no-op.""" + var s = Settings(precision=42) + parse_settings("", s) + testing.assert_equal(s.precision, 42) + + +def test_parse_whitespace_only() raises: + """Whitespace-only input is a no-op.""" + var s = Settings(precision=42) + parse_settings(" ", s) + testing.assert_equal(s.precision, 42) + + +# ===----------------------------------------------------------------------=== # +# Tests: split_inline_settings (4.8) +# ===----------------------------------------------------------------------=== # + + +def test_inline_basic() raises: + """Basic inline settings detected.""" + var result = split_inline_settings("sqrt(2):p 100") + testing.assert_true(Bool(result), "should detect inline settings") + testing.assert_equal(result.value()[0], "sqrt(2)") + testing.assert_equal(result.value()[1], "p 100") + + +def test_inline_complex() raises: + """Inline settings with multiple options.""" + var result = split_inline_settings("1/3:p 200 s r down") + testing.assert_true(Bool(result), "should detect") + testing.assert_equal(result.value()[0], "1/3") + testing.assert_equal(result.value()[1], "p 200 s r down") + + +def test_inline_with_space() raises: + """Inline settings with space around colon.""" + var result = split_inline_settings("pi :p 100") + testing.assert_true(Bool(result), "should detect") + testing.assert_equal(result.value()[0], "pi ") + testing.assert_equal(result.value()[1], "p 100") + + +def test_inline_meta_command_not_detected() raises: + """Lines starting with `:` are meta-commands, not inline settings.""" + var result = split_inline_settings(":p 100") + testing.assert_false(Bool(result), "meta-command is not inline") + + +def test_inline_no_colon() raises: + """No colon → no inline settings.""" + var result = split_inline_settings("1 + 2 * 3") + testing.assert_false(Bool(result), "no colon") + + +def test_inline_empty() raises: + """Empty line → no inline settings.""" + var result = split_inline_settings("") + testing.assert_false(Bool(result), "empty") + + +def test_inline_colon_inside_parens_ignored() raises: + """Colon after closing paren splits correctly.""" + var result = split_inline_settings("sqrt(2):p 100") + testing.assert_true(Bool(result), "colon after paren") + testing.assert_equal(result.value()[0], "sqrt(2)") + + +# ===----------------------------------------------------------------------=== # +# Main +# ===----------------------------------------------------------------------=== # + + +def main() raises: + testing.TestSuite.discover_tests[__functions_in_module()]().run() diff --git a/tests/decimal128/test_decimal128_from_int.mojo b/tests/decimal128/test_decimal128_from_int.mojo index 5453f2e..9594d8b 100644 --- a/tests/decimal128/test_decimal128_from_int.mojo +++ b/tests/decimal128/test_decimal128_from_int.mojo @@ -60,7 +60,7 @@ def test_from_int() raises: # from_int with scale: use a=integer, b=scale var scale_cases = load_test_cases(toml, "from_int_with_scale_tests") for tc in scale_cases: - var result = Dec128.from_int(Int(tc.a), Int(tc.b)) + var result = Dec128.from_int(Int(tc.a), UInt32(Int(tc.b))) try: testing.assert_equal( lhs=String(result), @@ -218,7 +218,7 @@ def test_from_int_with_scale_advanced() raises: testing.assert_equal(r5.scale(), 25) # Max scale - var r6 = Dec128.from_int(1, Decimal128.MAX_SCALE) + var r6 = Dec128.from_int(1, UInt32(Decimal128.MAX_SCALE)) testing.assert_equal(r6.scale(), Decimal128.MAX_SCALE) # Arithmetic with scaled value: 1.0 / 0.03 diff --git a/tests/test_cli.sh b/tests/test_cli.sh index 72594a6..ec7e407 100644 --- a/tests/test_cli.sh +++ b/tests/test_cli.sh @@ -51,8 +51,8 @@ assert_output "scientific notation" "1.2345678E+4" "$BINARY" "12345.678" --scien # Engineering notation (--engineering / -E) assert_output "engineering notation" "12.345678E+3" "$BINARY" "12345.678" --engineering -# Delimiter flag (-D) -assert_output "delimiter underscore" "1_234_567.89" "$BINARY" "1234567.89" -D "_" +# Delimiter flag (--delimiter) +assert_output "delimiter underscore" "1_234_567.89" "$BINARY" "1234567.89" --delimiter "_" # Rounding mode (--rounding-mode / -R) assert_output "rounding mode ceiling" "0.33334" "$BINARY" "1/3" -P 5 -R ceiling @@ -82,8 +82,8 @@ assert_output "mixed order: flag expr option" "1.732428719E+1" "$BINARY" -S "-3* assert_output "mixed order: option expr flag" "1.732428719E+1" "$BINARY" -P 10 "-3*pi/sin(10)" -S assert_output "engineering before expr" "-12.345678E+3" "$BINARY" -E "-12345.678" assert_output "engineering after expr" "-12.345678E+3" "$BINARY" "-12345.678" -E -assert_output "delimiter before expr" "3.141_592_654" "$BINARY" -D _ -P 10 "pi" -assert_output "delimiter after expr" "3.141_592_654" "$BINARY" "pi" -P 10 -D _ +assert_output "delimiter before expr" "3.141_592_654" "$BINARY" --delimiter _ -P 10 "pi" +assert_output "delimiter after expr" "3.141_592_654" "$BINARY" "pi" -P 10 --delimiter _ assert_output "rounding before expr" "0.33334" "$BINARY" -P 5 -R ceiling "1/3" assert_output "all options before expr" "0.33334" "$BINARY" -P 5 -R ceiling --pad "1/3" assert_output "all options after expr" "0.33334" "$BINARY" "1/3" -P 5 -R ceiling --pad @@ -138,7 +138,7 @@ assert_pipe_output "pipe skip blank lines" \ "3" assert_pipe_output "pipe with scientific" "12345.678" "1.2345678E+4" -S assert_pipe_output "pipe with engineering" "12345.678" "12.345678E+3" -E -assert_pipe_output "pipe with delimiter" "1234567.89" "1_234_567.89" -D "_" +assert_pipe_output "pipe with delimiter" "1234567.89" "1_234_567.89" --delimiter "_" # ── File mode (-F/--file flag) ──────────────────────────────────────────── # All test files live in tests/cli/test_data/ — no temp files needed. @@ -182,9 +182,9 @@ assert_output "file mode basic.dm -S" \ "$(printf '3.1415926535897932384626433832795028841971693993751E0\n2.7182818284590452353602874713526624977572470937E0\n1.4142135623730950488016887242096980785696718753769E0\n1.1986470588235294117647058823529411764705882352941E+3')" \ "$BINARY" -F "$DATA/basic.dm" -S -assert_output "file mode basic.dm -D _" \ +assert_output "file mode basic.dm --delimiter _" \ "$(printf '3.141_592_653_589_793_238_462_643_383_279_502_884_197_169_399_375_1\n2.718_281_828_459_045_235_360_287_471_352_662_497_757_247_093_700_0\n1.414_213_562_373_095_048_801_688_724_209_698_078_569_671_875_376_9\n1_198.647_058_823_529_411_764_705_882_352_941_176_470_588_235_294_1')" \ - "$BINARY" -F "$DATA/basic.dm" -D _ + "$BINARY" -F "$DATA/basic.dm" --delimiter _ # --- Error cases --- # File mode: nonexistent file gives a clear error