From 6eb14b64209b9d24554c587f610d7e300ef809ea Mon Sep 17 00:00:00 2001 From: salmonik Date: Sat, 1 Aug 2026 13:14:07 +0200 Subject: shell: keyword redirection + post-filter flags (ADR 0002) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement the shell experience redesign: one canonical grammar for every command — 'cmd args [USE src] [TIL marker] [filters...] [output kw dst]'. - Keyword redirection (uppercase-only, aliases valid, fully reserved): WRITING/WRITE overwrite, TO append, USE/USING input source, TIL/UNTIL heredoc/terminator (exact full-line marker). > >> < << are removed; '>' is plain text. - Post-filter stages applied left to right over a line stream, each repeatable: -h N (head kills the upstream), -t N (tail holds a rolling window, flushes at EOF/^C), -g PAT (smart-case literal substring), -p (screenful pager, terminal only). A file sink replaces the screen; -p is a no-op with one. - C-style quoting: "..." groups and literalizes, \x escapes one char; quoted/escaped text is never an operator, never split. - Input consumers: cat/hexdump, path-less only (stdin until ^D, USE f, TIL m heredoc, USE f TIL m truncation). Other commands reject input keywords; paths + input keyword is a syntax error. - ^D is EOF everywhere and never powers off; poweroff/exit are the only shutdown verbs. - help rewritten; test-disk.sh and sendkeys.py use the keyword syntax. Verified in QEMU: 26 grammar checks on fresh and reused disks, disk round trip, pager pacing, -h stopping /dev/mem at N lines, ^C flushing a -t window. Two bugs found during verification and fixed: 1. WRITING over an existing file wrote after stale NUL padding — fs::size() is stale after set_len(0); the sink writes at offset 0 after truncation. 2. USE f TIL m leaked the post-marker remainder — the final partial-line emit ran after the marker break; guarded with a stopped flag. --- CONTEXT.md | 208 ++- .../0002-keyword-redirection-and-post-filters.md | 76 + scripts/sendkeys.py | 2 +- scripts/test-disk.sh | 4 +- shell/src/lib.rs | 1458 +++++++++++++++----- 5 files changed, 1416 insertions(+), 332 deletions(-) create mode 100644 docs/adr/0002-keyword-redirection-and-post-filters.md diff --git a/CONTEXT.md b/CONTEXT.md index 1c73656..7514a6f 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -168,8 +168,8 @@ Workspace of four crates, all built for the `x86_64-unknown-uefi` target: (`ls`/`cat`/`hexdump`/`mkdir`/`touch`/`rm`, `echo >`/`>>`; devices regenerate on read, `/dev/mem` is raw physical RAM) ✅ **^C / ^D** — Ctrl+C cancels the line or aborts a running stream - (`cat`/`hexdump` check between chunks), Ctrl+D on an empty line - powers off (EOF) + (`cat`/`hexdump` check between chunks); Ctrl+D is EOF (no poweroff — + removed this session; see session notes below) ✅ **tty + vt (terminal) — the 1:1 UNIX console split**: `tty` owns the line discipline (canonical echo/buffer/^C/^D for the shell; raw mode translates keys to the classic terminal byte protocol), `vt` owns the @@ -205,6 +205,32 @@ Workspace of four crates, all built for the `x86_64-unknown-uefi` target: **Files survive reboot** — verified by poweroff → `mdir`/`mtype` on the image and by a fresh boot seeing the prior session's files. +✅ **Shell grammar (ADR 0002)** — one canonical grammar for every + command: `cmd args [USE src] [TIL marker] [filters…] [output kw dst]`. + Keyword redirection (`WRITING`/`WRITE` overwrite, `TO` append, + `USE`/`USING` input source, `TIL`/`UNTIL` heredoc/terminator; + uppercase-only, aliases valid, fully reserved), C-style quoting + (`"…"` groups, `\x` escapes — quoted text is never an operator), + post-filter stages applied left to right (`-h N` head kills the + upstream, `-t N` tail holds back and flushes at EOF/^C, `-g PAT` + smart-case literal substring, `-p` screenful pager; all repeatable), + `>`/`<`/`<<`/`>>` removed (`>` is plain text), file sinks replace the + screen, `cat`/`hexdump` consume input when path-less (stdin heredoc, + `cat USE a TO b` copy, `USE f TIL m` truncation). ^D is EOF + everywhere and never powers off. `help` rewritten; `test-disk.sh` + and `sendkeys.py` updated to the keyword syntax. + + Verified in QEMU (26 grammar checks on a fresh *and* a reused disk, + disk round trip, pager pacing): head kills at N lines, ^C flushes a + `-t` window, smart-case grep, chained/repeated filters, `WRITING`+ + `TO`, `USE..TO` copy, `USE..TIL`, heredoc → file, quoting, `-p`. + Two bugs found and fixed during verification: (1) `WRITING` over an + existing file wrote after stale NUL padding — `fs::size()` returns + the node-cached size, which is stale after `set_len(0)`; the sink + now writes at literal offset 0 after truncation; (2) `USE f TIL m` + leaked everything after the marker — the final partial-line emit + ran even after the marker break; fixed with a `stopped` flag. + QEMU setup: `-drive id=disk,if=none,format=raw,file=disk.img` + `-device ide-hd,drive=disk,bus=ide.0` puts the data disk on AHCI port 0; the ESP gets `bus=ide.1`. (The implicit `-drive file=fat:rw:esp` @@ -223,7 +249,7 @@ Workspace of four crates, all built for the `x86_64-unknown-uefi` target: - `make test-disk` / `./scripts/test-disk.sh` — full automated round trip: build → deploy → ensure image → boot QEMU headless → drive the shell via `sendkey` through the monitor (`ls /disk`, `mkdir`, - `echo >`, `cat`, `poweroff`) → fsck-clean check → `mtype` verifies + `echo WRITING`, `cat`, `poweroff`) → fsck-clean check → `mtype` verifies the written file survived poweroff - `./scripts/sendkeys.py` — types lines into the guest via the QEMU monitor (`--sock`, US layout, named keys for punctuation; bare @@ -269,7 +295,7 @@ Workspace of four crates, all built for the `x86_64-unknown-uefi` target: 128 chars ≈ 16 KB, sequence numbers, wrap/drop accounting. - **`klog` command** = thin alias for `cat /dev/klog` plus filters `-h` (head) and `-t` (tail); no log levels, no timestamps, nothing - fancier. `/dev/klog` is a char device so `klog > /disk/…/boot.log` + fancier. `/dev/klog` is a char device so `klog WRITING /disk/…/boot.log` persists a boot log via the existing redirection. - **Disk recognition scope — keep simple for now**: no pre-EBS UEFI block-device census, no GPT/MBR partition parsing, no NVMe driver. @@ -280,3 +306,177 @@ Workspace of four crates, all built for the `x86_64-unknown-uefi` target: ## Target audience Learning exercise and toy. Not a Linux replacement. Not POSIX-compatible. + +--- + +## Session notes — shell experience redesign (implemented) + +Working from `shell_experience`. Plan as stated: + +- Commands accept **post-filters** as trailing flags: `-h N` (head), + `-t N` (tail), `-g PAT` (grep). Flag placement (at the end of the + command) is respected. +- Redirection uses **keywords**, not `> << < >>`: + `WRITING`/`WRITE` → overwrite, `UNTIL`/`TIL` → heredoc, + `USE`/`USING` → input, `TO` → append. +- **No pipes.** `head`/`tail`/`grep` needs are covered by the flags; + a pager flag is a maybe. + +Resolved this session: + +- **Post-filter order = written order, left to right.** Each flag is a + filter stage applied to the command's output as a line stream: + `cat file -h 50 -t 10 -g pat` = "lines containing `pat` among the last + 10 lines of the first 50 lines". (The `shell_experience` prose for + that example was loose; the left-to-right rule is canonical.) +- **`-t N` holds back.** The tail stage buffers a rolling window of the + last N lines and emits nothing until its upstream ends — EOF, or the + user's ^C, which flushes the window to the screen. `cat /dev/mem -g + 100 -t 10` therefore shows nothing while it runs and prints the last 10 + matches when ^C ends it. +- **`-h N` truncates.** Once N lines have passed through, the head stage + kills the upstream command — `cat /dev/mem -h 5` peeks 5 lines and + stops instead of flooding. +- **`-g PAT` = smart-case literal substring.** Match is case-insensitive + unless PAT contains an uppercase letter, in which case it is + case-sensitive (editor-style smart case). No regex, no wildcards. +- **Post-filters repeat.** `-h`, `-t` and `-g` may each appear more than + once; every instance is a separate stage in the left-to-right chain + (so `-h 10 -h 5` = first 5, `-t 10 -t 5` = last 5, `-g foo -g bar` = + lines containing both). After `-g`, the next token is the pattern + verbatim (`-g -h` = pattern "-h"). +- **Redirection keywords pair strictly.** At most one input keyword and + one output keyword per command line (both may appear together); the + input keyword must precede the output keyword. `USING f TO g` is + accepted, `TO g USING f` is a syntax error, and so is any second + keyword of the same kind (`TO a WRITE b`). +- **Trailing-region grammar (canonical order):** + `cmd args [USE src] [TIL marker] [filters…] [output kw dst]` — + input keywords first, then filter flags, then the output keyword. + Rationale: "we feed the command input, parse it with filters, put + that into output". `USE` must precede `TIL`; filters come after the + input keywords and before the output keyword. +- **`TIL` is a terminator, not a source.** Unlike `USE` (which names an + input source), `TIL ` ends the input at a marker, so it may + combine with `USE` — two "input" keywords are allowed, in the fixed + order `USE` then `TIL`. +- **`TIL` truncates the source at the marker.** The marker line is not + part of the input and everything from the marker on is discarded — + `USE f TIL m` reads only the text of `f` before `m`. Marker match is + an exact full-line match. +- **^D is EOF everywhere; ^D/^C no longer power off.** Inside a `TIL` + input block, ^D ends the input early exactly like a file's EOF. + Shutting down via ^D or ^C is removed: at the prompt, ^D on an empty + line is a no-op (fresh prompt); ^C keeps its line-cancel / + stream-abort role; `poweroff`/`exit` remain the only shutdown verbs. +- **Input consumers: `cat` and `hexdump` only, and only when path-less.** + With no path (or with an input keyword), they read the input stream + (`cat USE a TO b` copies a→b; `cat TIL END` is the classic heredoc). + All other commands reject input keywords with a syntax error — a + command declares whether it takes input. `USE`/`TIL` are general + building blocks; more consumers will come later. +- **Keywords: uppercase-only, aliases valid.** `WRITE`==`WRITING`, + `TIL`==`UNTIL`, `USE`==`USING`; lowercase forms are ordinary text, so + `echo use the force` can never redirect. Keywords are reserved + operators — a keyword is always an operator, never an argument + (collision escape: quoting, a future building block). +- **`-p` = pause flag (pager), more-style.** A stage that pauses at + every screenful of output and waits for a keypress; engages only when + output exceeds one screenful, no args, no scrollback/search. As a + stage it chains with the others — `-h N -p -g pat -p` lets the user + see intermediate steps of the filtering. +- **`> >> < <<` are gone.** Keywords are the *only* redirection syntax; + `>` is now ordinary text (`echo a > b` prints `a > b`). Collateral: + `scripts/test-disk.sh` and the `help` text must be rewritten to the + keyword syntax; the milestone bullets above describing `echo >`/`>>` + record the old behavior (superseded by these notes). +- **Quoting (C): `"..."` groups + literalizes; `\x` escapes one char.** + Quoted/escaped text is always literal — never an operator, never + split. So `echo foo "WRITING" bar` prints `foo WRITING bar`; to + print literal quote characters: `echo foo \"WRITING\" bar`. Also + enables spaces in filenames and multi-word `-g` patterns + (`-g "a b"`). Quote chars are delimiters, not content. + +Assumed edge semantics (flag if wrong): + +- Filtered output goes to both terminal (GOP) and serial, identically. + A file sink (WRITING/TO) replaces terminal output entirely — screen + is silent, as `echo >` behaved before. +- `-p` pauses terminal output only; with a file sink it is a no-op. +- `-h N` / `-t N` require N ≥ 1; otherwise syntax error. +- `cat p USE s` (path args *and* an input keyword) is a syntax error — + the input source is ambiguous. `cat USE s` and path-less `cat TIL m` + are the valid input forms. +- ADR 0002 (`docs/adr/0002-keyword-redirection-and-post-filters.md`) + captures the keyword-redirection + post-filter decision; the ADR is + the authoritative statement, these notes are the working detail. + +Open questions (to resolve this session): + +1. ~~Order of application of `-h`/`-t`/`-g`~~ — **resolved**: left to + right. +2. ~~`-h`/`-t` streaming semantics~~ — **resolved**: `-t` hold-back + + ^C flush; `-h` truncates the upstream. +3. ~~`-h`/`-t` semantics on infinite streams (e.g. `cat /dev/mem`)~~ — + **resolved**: folded into 2. +4. ~~`-g` pattern semantics (literal substring vs regex; case + sensitivity)~~ — **resolved**: smart-case literal substring; + repeatable stages. +5. ~~Keyword details~~ — **resolved**: uppercase-only, both aliases + valid, keywords fully reserved; quoting (also resolved this + session) is the escape mechanism. +6. ~~Pager flag: in or out~~ — **resolved**: `-p` pause flag, in. +7. ~~Which commands consume input~~ — **resolved**: `cat` + `hexdump`, + path-less only; other commands reject input keywords. +8. ~~Fate of the existing `>` / `>>` redirection~~ — **resolved**: full + removal; `>` is plain text; scripts/help to be rewritten. + +## Implementation (this session — done, verified in QEMU) + +Everything above is now implemented in `shell/src/lib.rs`: + +- **Tokenizer** — `"…"` groups text (delimiter, not content), `\x` + escapes one char, a token containing any quoted/escaped char is + flagged literal and can never be an operator or flag. +- **Parser** — canonical grammar for every command; keywords + uppercase-only with aliases, fully reserved (quoted forms are + literal data); strict ordering enforced (USE before TIL, input + before filters, output keyword last) with syntax errors otherwise; + `-h`/`-t` need N ≥ 1, `-g` takes the next token verbatim; unknown + flags before the trailing region are ordinary arguments. +- **Filter pipeline** — stages over a line stream, left to right, + each repeatable: `Head` kills the upstream (producers check the + `line()` return), `Tail` holds a rolling window and flushes through + the remaining stages at end-of-stream (EOF, ^C, or head kill), + `Grep` is smart-case literal substring, `Pause` is a terminal-only + screenful pager (^C aborts; dropped entirely with a file sink). +- **Sinks** — terminal (GOP + serial, identical) or file; a file sink + replaces the screen, opens lazily on first output, `WRITING` + truncates and writes from offset 0, `TO` appends at current size. + Diagnostics (errors, usage) always go to the terminal, never into + the pipeline or a file. +- **Input consumers** — `cat`/`hexdump`, path-less only: no keyword = + keyboard until ^D, `USE f` = file, `TIL m` = keyboard heredoc, + `USE f TIL m` = file truncated at the exact marker line (marker and + everything after discarded). Other commands reject input keywords. +- **^D** — EOF everywhere, at the prompt it is a no-op (fresh + prompt); ^C keeps line-cancel / stream-abort; `poweroff`/`exit` + are the only shutdown verbs. +- **Collateral** — `help` text rewritten; `scripts/test-disk.sh` and + the `sendkeys.py` docstring updated to keyword syntax. + +Bugs found by QEMU verification and fixed: + +1. `WRITING` over an existing file left stale NUL padding — + `fs::size()` returns the node-cached size, stale after + `set_len(0)`; the overwrite path now writes at literal offset 0 + (the pre-redesign echo wrote at 0 too). +2. `cat USE f TIL m` leaked everything after the marker — the + final-partial-line emit ran even after the marker break; fixed + with a `stopped` flag in `cat_file_until`/`hexdump_file_until`. + +Verified: 26 grammar checks pass on a fresh disk *and* on a reused +one (rewriting existing files), `make test-disk` round trip is +fsck-clean, the `-p` pager pauses at screenfuls and resumes on +keypress, `-h` stops `/dev/mem` at exactly N lines, and ^C flushes a +`-t` window on an infinite stream. diff --git a/docs/adr/0002-keyword-redirection-and-post-filters.md b/docs/adr/0002-keyword-redirection-and-post-filters.md new file mode 100644 index 0000000..1584077 --- /dev/null +++ b/docs/adr/0002-keyword-redirection-and-post-filters.md @@ -0,0 +1,76 @@ +# ADR 0002 — Keyword Redirection and Post-Filter Flags Instead of Pipes + +## Status + +Accepted. + +## Context + +The shell (a freestanding, no_std kernel component) needs the data-flow +features every shell has: redirecting command output to files, feeding +input from files or typed blocks, and trimming output to `head`/`tail`/ +`grep`/pager views. The obvious model — Unix pipes plus `> < << >>` +operators — was considered and rejected. The project is explicitly +not POSIX-compatible, is TempleOS-inspired, and values minimal machinery +and natural-language-flavoured input over familiarity. The old shell +had only an ad-hoc `>`/`>>` parse baked into `cmd_echo`, no head/tail/ +grep at all, and ^D at the prompt powered the machine off. + +## Decision + +One canonical command-line grammar, applied uniformly to every command: + +``` +cmd args [USE src] [TIL marker] [filters…] [output kw dst] +``` + +- **Redirection uses reserved uppercase keywords**, never symbols: + `WRITING`/`WRITE` overwrite, `TO` appends, `USE`/`USING` names the + input source, `TIL`/`UNTIL` truncates the input at an exact full-line + marker (marker and everything after it is discarded). At most one + input keyword (USE and TIL may pair, USE first) and one output + keyword; input keywords precede the filters, the output keyword + comes last. +- **Filters are trailing flags** applied left to right as stages over a + line stream: `-h N` (emit first N lines, then kill the upstream + command), `-t N` (hold back a rolling window of the last N lines; + emit only when the stream ends — EOF or ^C, which flushes), + `-g PAT` (smart-case literal substring match: case-insensitive unless + PAT contains an uppercase letter), `-p` (pause at each screenful, + `more`-style, only when output exceeds one screenful). Every flag + may repeat; each instance is another stage. After `-g`, the next + token is the pattern verbatim. +- **There are no pipes.** The four built-in filters cover the cases + pipes are usually built for; arbitrary command composition is + deliberately out of scope. +- **`> >> < <<` are removed** — `>` is now ordinary text. The shell + prints to both GOP and serial; a file sink replaces terminal output + entirely. +- **^D is EOF everywhere and no longer powers off** (^C keeps its + line-cancel / stream-abort role; `poweroff`/`exit` are the only + shutdown verbs). +- **Quoting (`"..."` groups and literalizes, `\x` escapes one char)** + is the escape hatch: quoted/escaped text is never an operator, which + makes keywords expressible as data and enables spaces in filenames. + +## Consequences + +- **Positive:** One grammar, one parser — the per-command redirection + hacks (`cmd_echo`'s `>` scan) disappear. Filters compose uniformly + with any command and with each other, including intermediate views + (`-h N -p -g pat -p` shows each stage's result). No pipe machinery, + no process/stream abstraction to build. Lowercase text can never + collide with operators, so ordinary words stay ordinary. +- **Positive:** `tail` on an infinite stream (`cat /dev/mem -g 100 + -t 10`) is expressible without a pipe; ^C flushes the held-back + window instead of losing it. +- **Negative:** Breaks existing muscle memory and tooling: POSIX users + must learn the keywords; `scripts/test-disk.sh` and the `help` text + must be rewritten from `echo >` to the keyword syntax. +- **Negative:** Keywords are reserved, so printing text like `WRITING` + requires quoting; unknown commands cannot be filtered with flags + beyond the four built-ins; there is no way to compose arbitrary + commands (accepted — the shell has no external commands today). +- **Negative:** `-t` on a stream that never ends shows nothing until + the user ^C's — an intentional trade-off for simplicity (no pager + machinery, no infinite buffering beyond the window). diff --git a/scripts/sendkeys.py b/scripts/sendkeys.py index 5d1692c..1c97b33 100755 --- a/scripts/sendkeys.py +++ b/scripts/sendkeys.py @@ -6,7 +6,7 @@ Each positional argument is one line: it is typed character by character test script without a display. Usage: - sendkeys.py "ls /disk" "echo hi > /disk/a.txt" "poweroff" + sendkeys.py "ls /disk" "echo hi WRITING /disk/a.txt" "poweroff" sendkeys.py --sock /tmp/qmon.sock "date" Environment: QMON_SOCK (default: /tmp/qmon.sock) diff --git a/scripts/test-disk.sh b/scripts/test-disk.sh index f7337ef..11a9b05 100755 --- a/scripts/test-disk.sh +++ b/scripts/test-disk.sh @@ -99,14 +99,14 @@ VOL_UID="$(grep -a 'fat: mounted' "$LOG" | sed -n 's/.*at \/disk\/\([^ )]*\).*/\ [ -n "$VOL_UID" ] || fail "no FAT32 volume was mounted (look for 'fat: mounted' in the log)" echo " mounted volume UID: $VOL_UID" -echo "==> 4/6 drive the shell (ls / mkdir / echo > / cat / poweroff)" +echo "==> 4/6 drive the shell (ls / mkdir / echo WRITING / cat / poweroff)" # The filename is deliberately >13 chars so the FAT LFN path gets # multi-entry entries (they're stored last-first on disk). TESTFILE="disk-test-$STAMP.txt" python3 "$SCRIPT_DIR/sendkeys.py" --sock "$SOCK" \ "ls /disk" \ "mkdir /disk/$VOL_UID/sub" \ - "echo disk-test-$STAMP > /disk/$VOL_UID/sub/$TESTFILE" \ + "echo disk-test-$STAMP WRITING /disk/$VOL_UID/sub/$TESTFILE" \ "cat /disk/$VOL_UID/sub/$TESTFILE" \ "poweroff" diff --git a/shell/src/lib.rs b/shell/src/lib.rs index 9644f01..bc43faf 100644 --- a/shell/src/lib.rs +++ b/shell/src/lib.rs @@ -1,10 +1,32 @@ #![no_std] -extern crate alloc; +//! Command shell — reads from PS/2 keyboard, writes to GOP + serial. +//! +//! One canonical grammar for every command (ADR 0002): +//! +//! ```text +//! cmd args [USE src] [TIL marker] [filters…] [output kw dst] +//! ``` +//! +//! - **Keywords** (uppercase-only, reserved operators): `WRITING`/`WRITE` +//! overwrite a file, `TO` appends, `USE`/`USING` names an input source, +//! `TIL`/`UNTIL` ends input at an exact full-line marker. +//! - **Filters** are trailing flags applied left to right as stages over a +//! line stream: `-h N` (first N lines, then kill the upstream), +//! `-t N` (hold back a rolling window of the last N lines, emit only at +//! end of stream), `-g PAT` (smart-case literal substring), `-p` (pause +//! at each screenful). Each may repeat. +//! - **Quoting** (`"..."` groups + literalizes, `\x` escapes one char) +//! makes quoted/escaped text literal — never an operator, never split. +//! - `>` and friends are gone; `>` is ordinary text. +//! - ^D is EOF everywhere and never powers off; `poweroff`/`exit` are the +//! only shutdown verbs. -use alloc::string::ToString; +extern crate alloc; -/// Minimal command shell — reads from PS/2 keyboard, writes to GOP + serial. +use alloc::collections::VecDeque; +use alloc::string::{String, ToString}; +use alloc::vec::Vec; use rsnix::tty::{self, LineInput}; use rsnix::{gop_print, gop_println, serial_print, serial_println}; @@ -30,137 +52,683 @@ pub fn run() -> ! { // Read one line from keyboard (tty canonical mode) match tty::read_line() { LineInput::Interrupted => continue, // ^C — fresh prompt - LineInput::Eof => { - // ^D on an empty line: EOF, exit the shell. - cmd_poweroff(); - } + LineInput::Eof => continue, // ^D on an empty line — fresh prompt (no poweroff) LineInput::Line(line) => { - // Parse and execute let trimmed = line.trim(); if trimmed.is_empty() { continue; } + execute(trimmed); + } + } + } +} + +// --------------------------------------------------------------------------- +// Diagnostics go straight to terminal + serial, never through the filter +// pipeline and never into a file sink. +// --------------------------------------------------------------------------- + +fn term_err(msg: &str) { + gop_println!("{}", msg); + serial_println!("{}", msg); +} - let (cmd, args) = split_first_word(trimmed); +// --------------------------------------------------------------------------- +// Parsing +// --------------------------------------------------------------------------- + +/// One lexical token: its text plus whether any quoting/escaping was used +/// (a token containing quoted/escaped text is never an operator or flag). +struct Token { + text: String, + literal: bool, +} - match cmd { - "help" => cmd_help(), - "echo" => cmd_echo(args), - "clear" => cmd_clear(), - "halt" => cmd_halt(), - "poweroff" | "exit" => cmd_poweroff(), - "hello" => { - gop_println!("World!"); - serial_println!("World!"); +/// Split a command line into words, handling `"..."` groups and `\x` +/// escapes. Quotes are delimiters, not content; escapes prevent splitting +/// and literalize one character (`\"` → `"`, `\ ` → space). +fn tokenize(s: &str) -> Result, &'static str> { + let mut toks: Vec = Vec::new(); + let mut cur = String::new(); + let mut cur_literal = false; + let mut in_quote = false; + let mut chars = s.chars(); + while let Some(c) = chars.next() { + match c { + '"' => { + cur_literal = true; + in_quote = !in_quote; + } + '\\' => { + cur_literal = true; + match chars.next() { + Some(e) => cur.push(e), + None => cur.push('\\'), // trailing backslash: literal + } + } + c if c.is_whitespace() && !in_quote => { + if !cur.is_empty() { + toks.push(Token { + text: core::mem::take(&mut cur), + literal: cur_literal, + }); + cur_literal = false; + } + } + c => cur.push(c), + } + } + if in_quote { + return Err("unterminated quote"); + } + if !cur.is_empty() { + toks.push(Token { + text: cur, + literal: cur_literal, + }); + } + Ok(toks) +} + +fn is_keyword(s: &str) -> bool { + matches!(s, "WRITING" | "WRITE" | "TO" | "USE" | "USING" | "TIL" | "UNTIL") +} + +/// How the command's input is fed. Only `cat`/`hexdump`, and only when +/// path-less, may carry an input keyword. +#[derive(Clone)] +enum Input { + None, + /// `USE f` — read from a file. + Use(String), + /// `TIL m` — read from the keyboard until an exact line equals `m`. + Til(String), + /// `USE f TIL m` — read `f` until an exact line equals `m`. + UseTil(String, String), +} + +impl Input { + fn present(&self) -> bool { + !matches!(self, Input::None) + } +} + +/// A post-filter stage. Applied left to right over the line stream. +#[derive(Clone)] +enum Filter { + Head(usize), + Tail(usize), + Grep(String), + Pause, +} + +/// Output destination. +#[derive(Clone)] +enum Sink { + Write(String), + Append(String), +} + +struct Parsed { + cmd: String, + args: Vec, + input: Input, + filters: Vec, + output: Option, +} + +/// Parse one command line into the canonical grammar. Errors are the +/// syntax-error message to show the user. +fn parse(s: &str) -> Result { + let toks = tokenize(s).map_err(|e| e.to_string())?; + if toks.is_empty() { + return Err("empty command".into()); + } + let cmd = toks[0].text.clone(); + + let mut i = 1; + let mut args: Vec = Vec::new(); + + let mut filters: Vec = Vec::new(); + let mut output: Option = None; + let mut trailing = false; // a filter flag or output keyword has been seen + let mut src: Option = None; + let mut til: Option = None; + let mut til_seen = false; + + while i < toks.len() { + let t = &toks[i]; + let text = t.text.as_str(); + + if !t.literal { + match text { + "USE" | "USING" => { + if trailing { + return Err("USE must come before the filters".into()); + } + if src.is_some() || til_seen { + return Err("duplicate input keyword".into()); } - "info" => cmd_info(), - "mem" => cmd_mem(), - "mmap" => cmd_mmap(), - "stress" => cmd_stress(args), - "date" => cmd_date(), - "pci" => cmd_pci(), - "ls" => cmd_ls(args), - "cat" => cmd_cat(args), - "hexdump" => cmd_hexdump(args), - "mkdir" => cmd_mkdir(args), - "touch" => cmd_touch(args), - "rm" => cmd_rm(args), - "edit" => cmd_edit(args), - _ => { - gop_println!("unknown command: {}", cmd); - serial_println!("unknown command: {}", cmd); + i += 1; + let path = toks.get(i).ok_or("USE needs a source path")?; + if !path.literal && is_keyword(&path.text) { + return Err("USE needs a source path".into()); } + src = Some(path.text.clone()); + i += 1; + continue; } + "TIL" | "UNTIL" => { + if trailing { + return Err("TIL must come before the filters".into()); + } + if til_seen { + return Err("duplicate TIL".into()); + } + i += 1; + let m = toks.get(i).ok_or("TIL needs a marker")?; + if !m.literal && is_keyword(&m.text) { + return Err("TIL needs a marker".into()); + } + til = Some(m.text.clone()); + til_seen = true; + i += 1; + continue; + } + "WRITING" | "WRITE" | "TO" => { + if output.is_some() { + return Err("duplicate output keyword".into()); + } + i += 1; + let dst = toks.get(i).ok_or("output keyword needs a destination")?; + if !dst.literal && is_keyword(&dst.text) { + return Err("output keyword needs a destination".into()); + } + output = Some(if text == "TO" { + Sink::Append(dst.text.clone()) + } else { + Sink::Write(dst.text.clone()) + }); + trailing = true; + i += 1; + continue; + } + "-h" | "-t" => { + trailing = true; + i += 1; + let n = toks.get(i).ok_or("flag needs a count")?; + let n: usize = n.text.parse().map_err(|_| "count must be a number ≥ 1")?; + if n < 1 { + return Err("count must be ≥ 1".into()); + } + filters.push(if text == "-h" { + Filter::Head(n) + } else { + Filter::Tail(n) + }); + i += 1; + continue; + } + "-g" => { + trailing = true; + i += 1; + let pat = toks.get(i).ok_or("-g needs a pattern")?; + filters.push(Filter::Grep(pat.text.clone())); + i += 1; + continue; + } + "-p" => { + trailing = true; + filters.push(Filter::Pause); + i += 1; + continue; + } + _ => {} } } + + // Ordinary token. Once the trailing region has started (a filter + // flag or the output keyword), stray arguments are syntax errors. + if trailing { + return Err(alloc::format!("unexpected argument '{}'", text)); + } + args.push(text.to_string()); + i += 1; } + + let input = match (src, til) { + (Some(s), Some(t)) => Input::UseTil(s, t), + (Some(s), None) => Input::Use(s), + (None, Some(t)) => Input::Til(t), + (None, None) => Input::None, + }; + + Ok(Parsed { + cmd, + args, + input, + filters, + output, + }) } // --------------------------------------------------------------------------- -// Line input is handled by the tty line discipline (rsnix::tty). +// Filter pipeline + output sink // --------------------------------------------------------------------------- -/// Split a command line into the first word and the rest. -fn split_first_word(s: &str) -> (&str, &str) { - let s = s.trim_start(); - if let Some(i) = s.find(char::is_whitespace) { - (&s[..i], s[i..].trim_start()) +/// Per-stage mutable state. +enum StageState { + /// Lines still allowed through before the upstream is killed. + Head(usize), + /// Rolling window of the last N lines. + Tail(VecDeque), + Grep, + Pause { shown: usize, screen: usize }, +} + +/// Where the filtered line stream ends up. +enum OutSink { + Term, + File { + path: String, + h: Option, + pos: u64, + overwrite: bool, + }, +} + +/// Smart-case literal substring match: case-insensitive unless the pattern +/// contains an uppercase letter. +fn smart_match(pat: &str, line: &str) -> bool { + if pat.chars().any(|c| c.is_uppercase()) { + line.contains(pat) } else { - (s, "") + let lp = pat.to_ascii_lowercase(); + let ll = line.to_ascii_lowercase(); + ll.contains(&lp) } } -// --------------------------------------------------------------------------- -// Built-in commands -// --------------------------------------------------------------------------- +/// Pager pause: terminal only, waits for a keypress; ^C aborts the stream. +fn pager_wait() -> bool { + gop_print!("\n— more —"); + match tty::read_byte() { + 0x03 => { + // ^C — abort the stream (stream-abort role). + gop_println!(); + false + } + _ => { + // Any key continues; swallow queued tail bytes (e.g. the + // rest of an arrow-key escape sequence). + while tty::try_read_byte().is_some() {} + true + } + } +} -fn cmd_help() { - gop_println!("available commands:"); - gop_println!(" help — show this message"); - gop_println!(" echo — echo arguments"); - gop_println!(" clear — clear screen"); - gop_println!(" hello — print 'World!'"); - gop_println!(" info — show system info"); - gop_println!(" mem — show memory info"); - gop_println!(" mmap — dump the UEFI memory map"); - gop_println!(" stress — stress-test heap/page allocators"); - gop_println!(" date — show current date/time (RTC)"); - gop_println!(" pci — list PCI devices"); - gop_println!(" ls — list a directory"); - gop_println!(" cat — print files (e.g. cat /dev/rtc)"); - gop_println!(" hexdump — hex-dump a file (e.g. hexdump /dev/mem)"); - gop_println!(" mkdir — create a directory"); - gop_println!(" touch — create an empty file"); - gop_println!(" rm — remove a file or empty dir"); - gop_println!(" edit — full-screen editor (^S save, ^X exit, ^W search)"); - gop_println!(" echo > /dev/null, > file, >> file"); - gop_println!(" halt — halt the system (hlt loop)"); - gop_println!(" poweroff — shut down via ACPI (ResetSystem)"); - gop_println!(" exit — alias for poweroff"); - serial_println!("available commands:"); - serial_println!( - " help, echo, clear, hello, info, mem, mmap, stress, date, pci, ls, cat, hexdump, mkdir, touch, rm, edit, halt, poweroff, exit" - ); +/// The filter chain plus the output sink. Producers push one line at a +/// time; `line()` returns false when the upstream must stop (head limit or +/// pager ^C). At end of stream call `end()` to flush held-back tails. +struct Pipeline { + stages: Vec<(Filter, StageState)>, + sink: OutSink, } -fn cmd_echo(args: &str) { - // Redirection: `text > path` (overwrite) or `text >> path` (append). - if let Some(idx) = args.find('>') { - let append = args[idx..].starts_with(">>"); - let text = &args[..idx]; - let path = args[idx + 1 + append as usize..].trim(); - let path = path.split_whitespace().next().unwrap_or(""); - if !path.is_empty() { - let content = alloc::format!("{}\n", text.trim_end()); - let result = rsnix::fs::ensure_file(path).and_then(|h| { - if append { - rsnix::fs::write(h, content.as_bytes(), rsnix::fs::size(h)).map(|_| ()) - } else { +impl Pipeline { + fn new(filters: &[Filter], output: &Option) -> Self { + let file_sink = output.is_some(); + let mut stages: Vec<(Filter, StageState)> = Vec::new(); + for f in filters { + // With a file sink the pager is a no-op. + if file_sink && matches!(f, Filter::Pause) { + continue; + } + let st = match f { + Filter::Head(n) => StageState::Head(*n), + Filter::Tail(n) => StageState::Tail(VecDeque::with_capacity(*n)), + Filter::Grep(_) => StageState::Grep, + Filter::Pause => StageState::Pause { + shown: 0, + screen: rsnix::vt::size().0.saturating_sub(1).max(1), + }, + }; + stages.push((f.clone(), st)); + } + let sink = match output { + Some(Sink::Write(p)) => OutSink::File { + path: p.clone(), + h: None, + pos: 0, + overwrite: true, + }, + Some(Sink::Append(p)) => OutSink::File { + path: p.clone(), + h: None, + pos: 0, + overwrite: false, + }, + None => OutSink::Term, + }; + Pipeline { stages, sink } + } + + /// Push one line through the stages. Returns false when the producer + /// must stop (head killed the stream, or ^C at the pager). + fn line(&mut self, l: &str) -> bool { + self.push_through(0, l) + } + + /// Run one line through the stages starting at `start`, delivering to + /// the sink. `start` lets `finish()` replay a drained tail window + /// through only the stages after it. + fn push_through(&mut self, start: usize, l: &str) -> bool { + let mut cur = alloc::vec![l.to_string()]; + for (i, (f, st)) in self.stages.iter_mut().enumerate() { + if i < start { + continue; + } + let mut out = Vec::new(); + match f { + Filter::Head(_) => { + let StageState::Head(rem) = st else { + unreachable!("stage state does not match filter") + }; + if *rem == 0 { + // Head limit reached: kill the upstream. The line + // that would have overflowed is dropped. + return false; + } + for l in cur.drain(..) { + *rem -= 1; + out.push(l); + } + } + Filter::Tail(n) => { + let StageState::Tail(buf) = st else { + unreachable!("stage state does not match filter") + }; + for l in cur.drain(..) { + if buf.len() == *n { + buf.pop_front(); + } + buf.push_back(l); + } + // Held back — nothing reaches the downstream yet. + return true; + } + Filter::Grep(pat) => { + let StageState::Grep = st else { + unreachable!("stage state does not match filter") + }; + for l in cur.drain(..) { + if smart_match(pat, &l) { + out.push(l); + } + } + } + Filter::Pause => { + let StageState::Pause { shown, screen } = st else { + unreachable!("stage state does not match filter") + }; + for l in cur.drain(..) { + if *shown > 0 && *shown % *screen == 0 + && !pager_wait() { + return false; + } + *shown += 1; + out.push(l); + } + } + } + cur = out; + } + for l in &cur { + self.emit(l); + } + true + } + + /// Flush held-back tail windows through the remaining stages. Called + /// at end of stream: EOF, ^C, or after a head kill. + fn finish(&mut self) { + loop { + let mut any = false; + for i in 0..self.stages.len() { + let drained: Vec = match &mut self.stages[i] { + (Filter::Tail(_), StageState::Tail(buf)) if !buf.is_empty() => { + buf.drain(..).collect() + } + _ => continue, + }; + any = true; + for l in drained { + self.push_through(i + 1, &l); + } + } + if !any { + break; + } + } + } + + /// End of stream: flush tails, done. File writes are synchronous. + fn end(&mut self) { + self.finish(); + } + + fn emit(&mut self, l: &str) { + match &self.sink { + OutSink::Term => { + gop_println!("{}", l); + serial_println!("{}", l); + } + OutSink::File { .. } => {} + } + if let OutSink::File { h: None, .. } = &self.sink { + self.open_sink(); + } + if let OutSink::File { h: Some(h), pos, .. } = &mut self.sink { + let mut bytes = l.as_bytes().to_vec(); + bytes.push(b'\n'); + if let Ok(n) = rsnix::fs::write(*h, &bytes, *pos) { + *pos += n as u64; + } + } + } + + /// Lazily open the file sink on first output (a command that emits + /// nothing touches no file). WRITING truncates and writes from offset + /// 0 (the node's cached size is stale after truncation); TO appends at + /// the file's current size. + fn open_sink(&mut self) { + let (path, overwrite) = match &self.sink { + OutSink::File { path, overwrite, .. } => (path.clone(), *overwrite), + _ => return, + }; + match rsnix::fs::ensure_file(&path) { + Ok(h) => { + let pos = if overwrite { let _ = rsnix::fs::set_len(h, 0); - rsnix::fs::write(h, content.as_bytes(), 0).map(|_| ()) + 0 + } else { + rsnix::fs::size(h) + }; + self.sink = OutSink::File { + path, + h: Some(h), + pos, + overwrite, + }; + } + Err(()) => { + term_err(&alloc::format!("cannot write '{}'", path)); + self.sink = OutSink::Term; + } + } + } +} + +/// Emit every line of a pre-built multi-line string (interior blank lines +/// preserved; a trailing newline does not produce an extra empty line). +fn emit_string(pipe: &mut Pipeline, s: &str) { + let mut rest = s; + loop { + match rest.find('\n') { + Some(idx) => { + if !pipe.line(&rest[..idx]) { + return; } - }); - match result { - Ok(()) => {} - Err(()) => { - gop_println!("echo: cannot write '{}'", path); - serial_println!("echo: cannot write '{}'", path); + rest = &rest[idx + 1..]; + } + None => { + if !rest.is_empty() { + let _ = pipe.line(rest); } + return; } + } + } +} + +// --------------------------------------------------------------------------- +// Execution +// --------------------------------------------------------------------------- + +/// Commands that may consume an input stream (`USE`/`TIL`). +fn accepts_input(cmd: &str) -> bool { + matches!(cmd, "cat" | "hexdump") +} + +fn execute(s: &str) { + let parsed = match parse(s) { + Ok(p) => p, + Err(e) => { + term_err(&e); + return; + } + }; + let cmd = parsed.cmd.as_str(); + + if parsed.input.present() { + if !accepts_input(cmd) { + term_err(&alloc::format!("{}: does not accept input", cmd)); + return; + } + if !parsed.args.is_empty() { + term_err(&alloc::format!( + "{}: input keyword with path arguments is ambiguous", + cmd + )); return; } } - gop_println!("{}", args); - serial_println!("{}", args); + + match cmd { + "help" => cmd_help(&parsed), + "echo" => cmd_echo(&parsed), + "clear" => cmd_clear(), + "halt" => cmd_halt(), + "poweroff" | "exit" => cmd_poweroff(), + "hello" => cmd_hello(&parsed), + "info" => cmd_info(&parsed), + "mem" => cmd_mem(&parsed), + "mmap" => cmd_mmap(&parsed), + "stress" => cmd_stress(&parsed), + "date" => cmd_date(&parsed), + "pci" => cmd_pci(&parsed), + "ls" => cmd_ls(&parsed), + "cat" => cmd_cat(&parsed), + "hexdump" => cmd_hexdump(&parsed), + "mkdir" => cmd_mkdir(&parsed), + "touch" => cmd_touch(&parsed), + "rm" => cmd_rm(&parsed), + "edit" => cmd_edit(&parsed), + _ => term_err(&alloc::format!("unknown command: {}", cmd)), + } +} + +// --------------------------------------------------------------------------- +// Built-in commands +// --------------------------------------------------------------------------- + +fn cmd_help(_p: &Parsed) { + let mut pipe = Pipeline::new(&_p.filters, &_p.output); + let text = "available commands: + help — show this message + echo — echo arguments + clear — clear screen + hello — print 'World!' + info — show system info + mem — show memory info + mmap — dump the UEFI memory map + stress — stress-test heap/page allocators + date — show current date/time (RTC) + pci — list PCI devices + ls — list a directory + cat — print files (cat USE a TO b copies a→b; cat TIL END is a heredoc) + hexdump — hex-dump a file or input stream + mkdir — create a directory + touch — create an empty file + rm — remove a file or empty dir + edit — full-screen editor (^S save, ^X exit, ^W search) + halt — halt the system (hlt loop) + poweroff — shut down via ACPI (ResetSystem) + exit — alias for poweroff + +redirection keywords (uppercase, after args): + USE/USING read command input from a file (cat/hexdump only) + TIL/UNTIL read input until an exact marker line (heredoc) + WRITING/WRITE write output to a file (overwrite) + TO append output to a file + a file sink replaces the screen; > >> < << are gone (> is plain text) + +post-filter flags (trailing, applied left to right): + -h N first N lines (kills the command after N) + -t N last N lines (shows nothing until the stream ends; ^C flushes) + -g PAT smart-case literal substring match (repeatable) + -p pause at each screenful (more-style; ^C aborts) + +quoting: \"...\" groups text; \\x escapes one char. Quoted text is never +an operator: echo \"WRITING\" prints WRITING. +^D = EOF; ^C cancels a line or aborts a stream. poweroff/exit shut down."; + emit_string(&mut pipe, text); + pipe.end(); +} + +fn cmd_echo(p: &Parsed) { + let mut pipe = Pipeline::new(&p.filters, &p.output); + let line = p.args.join(" "); + let _ = pipe.line(&line); + pipe.end(); +} + +fn cmd_hello(p: &Parsed) { + let mut pipe = Pipeline::new(&p.filters, &p.output); + let _ = pipe.line("World!"); + pipe.end(); +} + +fn cmd_clear() { + rsnix::gop::clear_screen(); +} + +fn cmd_info(p: &Parsed) { + let mut pipe = Pipeline::new(&p.filters, &p.output); + let mut s = String::from("rs-dos v"); + s.push_str(env!("CARGO_PKG_VERSION")); + s.push_str(" — rsnix kernel"); + let _ = pipe.line(&s); + let _ = pipe.line("arch: x86_64, freestanding, no userspace"); + pipe.end(); } -fn cmd_ls(args: &str) { - let path = args.split_whitespace().next().unwrap_or("/"); - let mut out = alloc::string::String::new(); +fn cmd_ls(p: &Parsed) { + let path = p.args.first().map(|s| s.as_str()).unwrap_or("/"); + let mut pipe = Pipeline::new(&p.filters, &p.output); match rsnix::fs::resolve(path) { - None => { - out.push_str(&alloc::format!("ls: {}: no such file or directory\n", path)); - } + None => term_err(&alloc::format!("ls: {}: no such file or directory", path)), Some(h) => { let list = if rsnix::fs::kind(h) == rsnix::fs::FileType::Dir { rsnix::fs::children(h) @@ -172,208 +740,476 @@ fn cmd_ls(args: &str) { )] }; for (name, kind, size) in list { - match kind { - rsnix::fs::FileType::Dir => out.push_str(&alloc::format!("{}/\n", name)), - rsnix::fs::FileType::Char => out.push_str(&alloc::format!("{}@\n", name)), - rsnix::fs::FileType::Regular => { - out.push_str(&alloc::format!("{} {} B\n", name, size)) + let l = match kind { + rsnix::fs::FileType::Dir => alloc::format!("{}/", name), + rsnix::fs::FileType::Char => alloc::format!("{}@", name), + rsnix::fs::FileType::Regular => alloc::format!("{} {} B", name, size), + }; + if !pipe.line(&l) { + break; + } + } + } + } + pipe.end(); +} + +// --------------------------------------------------------------------------- +// cat — file and stream consumers +// --------------------------------------------------------------------------- + +fn cmd_cat(p: &Parsed) { + let mut pipe = Pipeline::new(&p.filters, &p.output); + match &p.input { + Input::None => { + if p.args.is_empty() { + // Path-less `cat` with no input keyword reads the keyboard + // until ^D. + cat_stdin(&mut pipe); + } else { + for path in &p.args { + if !cat_file(&mut pipe, path) { + break; } } } } + Input::Use(path) => { + cat_file(&mut pipe, path); + } + Input::Til(marker) => { + cat_stdin_until(&mut pipe, marker); + } + Input::UseTil(path, marker) => { + cat_file_until(&mut pipe, path, marker); + } } - gop_print!("{}", out); - serial_print!("{}", out); + pipe.end(); } -fn cmd_cat(args: &str) { - let paths: alloc::vec::Vec<&str> = args.split_whitespace().collect(); - if paths.is_empty() { - gop_println!("usage: cat [path...]"); - serial_println!("usage: cat [path...]"); - return; +fn cat_stdin(pipe: &mut Pipeline) -> bool { + loop { + match tty::read_line() { + LineInput::Line(l) => { + if !pipe.line(l.as_str()) { + return false; + } + } + LineInput::Interrupted => return false, // ^C aborts the stream + LineInput::Eof => return true, // ^D = EOF + } + } +} + +fn cat_stdin_until(pipe: &mut Pipeline, marker: &str) -> bool { + loop { + match tty::read_line() { + LineInput::Line(l) => { + // Marker line is not part of the input. + if l.as_str() == marker { + return true; + } + if !pipe.line(l.as_str()) { + return false; + } + } + LineInput::Interrupted => return false, + LineInput::Eof => return true, + } } +} + +fn cat_file(pipe: &mut Pipeline, path: &str) -> bool { + let h = match rsnix::fs::resolve(path) { + Some(h) => h, + None => { + term_err(&alloc::format!("cat: {}: no such file", path)); + return true; // keep going with the remaining paths + } + }; let mut buf = [0u8; 512]; - for p in paths { - match rsnix::fs::resolve(p) { - None => { - let msg = alloc::format!("cat: {}: no such file\n", p); - gop_print!("{}", msg); - serial_print!("{}", msg); + let mut pos = 0u64; + let mut pending = String::new(); + loop { + if tty::interrupt_pressed() { + return false; // ^C aborts everything + } + match rsnix::fs::read(h, &mut buf, pos) { + Ok(0) | Err(_) => break, + Ok(n) => { + pos += n as u64; + pending.push_str(&String::from_utf8_lossy(&buf[..n])); + while let Some(idx) = pending.find('\n') { + let line: String = pending.drain(..=idx).collect(); + let line = line.trim_end_matches('\n'); + if !pipe.line(line) { + return false; + } + } } - Some(h) => { - let mut pos = 0u64; - loop { - if rsnix::tty::interrupt_pressed() { - gop_println!("^C"); - serial_println!("^C"); - return; + } + } + if !pending.is_empty() && !pipe.line(&pending) { + return false; + } + true +} + +fn cat_file_until(pipe: &mut Pipeline, path: &str, marker: &str) -> bool { + let h = match rsnix::fs::resolve(path) { + Some(h) => h, + None => { + term_err(&alloc::format!("cat: {}: no such file", path)); + return true; + } + }; + let mut buf = [0u8; 512]; + let mut pos = 0u64; + let mut pending = String::new(); + let mut stopped = false; + 'outer: loop { + if tty::interrupt_pressed() { + return false; + } + match rsnix::fs::read(h, &mut buf, pos) { + Ok(0) | Err(_) => break, + Ok(n) => { + pos += n as u64; + pending.push_str(&String::from_utf8_lossy(&buf[..n])); + while let Some(idx) = pending.find('\n') { + let line: String = pending.drain(..=idx).collect(); + let line = line.trim_end_matches('\n'); + // Marker line and everything after is discarded. + if line == marker { + stopped = true; + break 'outer; } - match rsnix::fs::read(h, &mut buf, pos) { - Ok(0) | Err(_) => break, - Ok(n) => { - let chunk = core::str::from_utf8(&buf[..n]).unwrap_or("?"); - gop_print!("{}", chunk); - serial_print!("{}", chunk); - pos += n as u64; - } + if !pipe.line(line) { + return false; } } } } } + if !stopped && !pending.is_empty() && pending != marker && !pipe.line(&pending) { + return false; + } + true } -fn cmd_hexdump(args: &str) { - let path = args.split_whitespace().next().unwrap_or(""); - if path.is_empty() { - gop_println!("usage: hexdump "); - serial_println!("usage: hexdump "); - return; +// --------------------------------------------------------------------------- +// hexdump — hex-dump files and input streams +// --------------------------------------------------------------------------- + +struct HexCtx<'a> { + pipe: &'a mut Pipeline, + pos: u64, + row: [u8; 16], + row_len: usize, + alive: bool, +} + +fn format_row(addr: u64, row: &[u8; 16]) -> String { + let mut out = String::new(); + use core::fmt::Write as _; + let _ = core::write!(out, "{:08x} ", addr); + for &b in row { + let _ = core::write!(out, "{:02x} ", b); + } + out.push_str(" |"); + for &b in row { + out.push(if b.is_ascii_graphic() || b == b' ' { + b as char + } else { + '.' + }); + } + out.push('|'); + out +} + +fn format_partial(addr: u64, row: &[u8]) -> String { + let mut out = String::new(); + use core::fmt::Write as _; + let _ = core::write!(out, "{:08x} ", addr); + for i in 0..16 { + if i < row.len() { + let _ = core::write!(out, "{:02x} ", row[i]); + } else { + out.push_str(" "); + } + } + out.push_str(" |"); + for &b in row { + out.push(if b.is_ascii_graphic() || b == b' ' { + b as char + } else { + '.' + }); + } + out.push('|'); + out +} + +impl HexCtx<'_> { + fn feed(&mut self, data: &[u8]) { + if !self.alive { + return; + } + for &b in data { + self.row[self.row_len] = b; + self.row_len += 1; + if self.row_len == 16 { + let line = format_row(self.pos, &self.row); + self.alive = self.pipe.line(&line); + self.pos += 16; + self.row_len = 0; + if !self.alive { + return; + } + } + } } + + /// Emit the trailing partial row, if any. + fn flush(&mut self) { + if self.alive && self.row_len > 0 { + let line = format_partial(self.pos, &self.row[..self.row_len]); + let _ = self.pipe.line(&line); + } + } +} + +fn cmd_hexdump(p: &Parsed) { + let mut pipe = Pipeline::new(&p.filters, &p.output); + match &p.input { + Input::None => { + if p.args.is_empty() { + hexdump_stdin(&mut pipe); + } else { + for path in &p.args { + if !hexdump_file(&mut pipe, path) { + break; + } + } + } + } + Input::Use(path) => { + hexdump_file(&mut pipe, path); + } + Input::Til(marker) => { + hexdump_stdin_until(&mut pipe, marker); + } + Input::UseTil(path, marker) => { + hexdump_file_until(&mut pipe, path, marker); + } + } + pipe.end(); +} + +fn hexdump_file(pipe: &mut Pipeline, path: &str) -> bool { let h = match rsnix::fs::resolve(path) { Some(h) => h, None => { - gop_println!("hexdump: {}: no such file", path); - serial_println!("hexdump: {}: no such file", path); - return; + term_err(&alloc::format!("hexdump: {}: no such file", path)); + return true; } }; + let mut ctx = HexCtx { + pipe, + pos: 0, + row: [0u8; 16], + row_len: 0, + alive: true, + }; let mut buf = [0u8; 512]; let mut pos = 0u64; loop { - if rsnix::tty::interrupt_pressed() { - gop_println!("^C"); - serial_println!("^C"); - return; + if tty::interrupt_pressed() { + ctx.flush(); + return false; } - let n = match rsnix::fs::read(h, &mut buf, pos) { + match rsnix::fs::read(h, &mut buf, pos) { Ok(0) | Err(_) => break, - Ok(n) => n, - }; - let mut out = alloc::string::String::new(); - use core::fmt::Write as _; - for i in (0..n).step_by(16) { - let _ = core::write!(out, "{:08x} ", pos + i as u64); - for j in 0..16 { - if i + j < n { - let _ = core::write!(out, "{:02x} ", buf[i + j]); - } else { - out.push_str(" "); + Ok(n) => { + pos += n as u64; + ctx.feed(&buf[..n]); + if !ctx.alive { + break; } } - out.push_str(" |"); - for j in 0..16 { - if i + j >= n { + } + } + ctx.flush(); + ctx.alive +} + +fn hexdump_stdin(pipe: &mut Pipeline) { + let mut ctx = HexCtx { + pipe, + pos: 0, + row: [0u8; 16], + row_len: 0, + alive: true, + }; + loop { + match tty::read_line() { + LineInput::Line(l) => { + ctx.feed(l.as_str().as_bytes()); + if !ctx.alive { + break; + } + ctx.feed(b"\n"); + if !ctx.alive { break; } - let b = buf[i + j]; - out.push(if b.is_ascii_graphic() || b == b' ' { - b as char - } else { - '.' - }); } - out.push_str("|\n"); + LineInput::Interrupted => break, + LineInput::Eof => break, } - gop_print!("{}", out); - serial_print!("{}", out); - pos += n as u64; } + ctx.flush(); } -// --------------------------------------------------------------------------- -// edit — run the built-in editor (rsdosedit) on a file -// --------------------------------------------------------------------------- +fn hexdump_stdin_until(pipe: &mut Pipeline, marker: &str) { + let mut ctx = HexCtx { + pipe, + pos: 0, + row: [0u8; 16], + row_len: 0, + alive: true, + }; + loop { + match tty::read_line() { + LineInput::Line(l) => { + if l.as_str() == marker { + break; + } + ctx.feed(l.as_str().as_bytes()); + if !ctx.alive { + break; + } + ctx.feed(b"\n"); + if !ctx.alive { + break; + } + } + LineInput::Interrupted => break, + LineInput::Eof => break, + } + } + ctx.flush(); +} -fn cmd_edit(args: &str) { - let path = args.trim(); - if path.is_empty() { - gop_println!("usage: edit "); - serial_println!("usage: edit "); - return; +fn hexdump_file_until(pipe: &mut Pipeline, path: &str, marker: &str) { + let h = match rsnix::fs::resolve(path) { + Some(h) => h, + None => { + term_err(&alloc::format!("hexdump: {}: no such file", path)); + return; + } + }; + let mut ctx = HexCtx { + pipe, + pos: 0, + row: [0u8; 16], + row_len: 0, + alive: true, + }; + let mut buf = [0u8; 512]; + let mut pos = 0u64; + let mut pending = String::new(); + let mut stopped = false; + 'outer: loop { + if tty::interrupt_pressed() { + break; + } + match rsnix::fs::read(h, &mut buf, pos) { + Ok(0) | Err(_) => break, + Ok(n) => { + pos += n as u64; + pending.push_str(&String::from_utf8_lossy(&buf[..n])); + while let Some(idx) = pending.find('\n') { + let line: String = pending.drain(..=idx).collect(); + let line = line.trim_end_matches('\n'); + if line == marker { + stopped = true; + break 'outer; + } + ctx.feed(line.as_bytes()); + ctx.feed(b"\n"); + if !ctx.alive { + break 'outer; + } + } + } + } } - if rsnix::fs::ensure_file(path).is_err() { - gop_println!("edit: cannot create {}", path); - serial_println!("edit: cannot create {}", path); - return; + if ctx.alive && !stopped && !pending.is_empty() && pending != marker { + ctx.feed(pending.as_bytes()); } - rsnix::gop::clear_screen(); - rsdosedit::run(path); - rsnix::gop::clear_screen(); + ctx.flush(); } -fn cmd_mkdir(args: &str) { - let path = args.split_whitespace().next().unwrap_or(""); +// --------------------------------------------------------------------------- +// Other commands +// --------------------------------------------------------------------------- + +fn cmd_mkdir(p: &Parsed) { + let path = p.args.first().map(|s| s.as_str()).unwrap_or(""); if path.is_empty() { - gop_println!("usage: mkdir "); - serial_println!("usage: mkdir "); + term_err("usage: mkdir "); return; } match rsnix::fs::mkdir(path) { Ok(()) => {} - Err(()) => { - gop_println!("mkdir: cannot create directory '{}'", path); - serial_println!("mkdir: cannot create directory '{}'", path); - } + Err(()) => term_err(&alloc::format!("mkdir: cannot create directory '{}'", path)), } } -fn cmd_touch(args: &str) { - let path = args.split_whitespace().next().unwrap_or(""); +fn cmd_touch(p: &Parsed) { + let path = p.args.first().map(|s| s.as_str()).unwrap_or(""); if path.is_empty() { - gop_println!("usage: touch "); - serial_println!("usage: touch "); + term_err("usage: touch "); return; } match rsnix::fs::touch(path) { Ok(()) => {} - Err(()) => { - gop_println!("touch: cannot create '{}'", path); - serial_println!("touch: cannot create '{}'", path); - } + Err(()) => term_err(&alloc::format!("touch: cannot create '{}'", path)), } } -fn cmd_rm(args: &str) { - let path = args.split_whitespace().next().unwrap_or(""); +fn cmd_rm(p: &Parsed) { + let path = p.args.first().map(|s| s.as_str()).unwrap_or(""); if path.is_empty() { - gop_println!("usage: rm "); - serial_println!("usage: rm "); + term_err("usage: rm "); return; } match rsnix::fs::remove_path(path) { Ok(()) => {} - Err(()) => { - gop_println!( - "rm: cannot remove '{}' (missing, dir not empty, or busy)", - path - ); - serial_println!( - "rm: cannot remove '{}' (missing, dir not empty, or busy)", - path - ); - } + Err(()) => term_err(&alloc::format!( + "rm: cannot remove '{}' (missing, dir not empty, or busy)", + path + )), } } -fn cmd_clear() { +fn cmd_edit(p: &Parsed) { + let path = p.args.first().map(|s| s.as_str()).unwrap_or(""); + if path.is_empty() { + term_err("usage: edit "); + return; + } + if rsnix::fs::ensure_file(path).is_err() { + term_err(&alloc::format!("edit: cannot create {}", path)); + return; + } + rsnix::gop::clear_screen(); + rsdosedit::run(path); rsnix::gop::clear_screen(); } -fn cmd_info() { - // Test that the global allocator works: build a String on the heap - let mut s = alloc::string::String::from("rs-dos v"); - s.push_str(env!("CARGO_PKG_VERSION")); - s.push_str(" — rsnix kernel"); - gop_println!("{}", s); - gop_println!("arch: x86_64, freestanding, no userspace"); - serial_println!("{}", s); - serial_println!("arch: x86_64, freestanding, no userspace"); - // String is freed (dropped) when this scope ends — tests dealloc too -} - -fn cmd_mem() { +fn cmd_mem(p: &Parsed) { let (map, key) = rsnix::memory::get_map(); let mut total_kb = 0u64; @@ -402,98 +1238,76 @@ fn cmd_mem() { } } - // Build a big heap string with format! to test the allocator - let mut out = alloc::string::String::new(); - use alloc::fmt::Write; - let _ = writeln!(out, "memory map (key=0x{:x}, {} entries)", key, map.len()); - let _ = writeln!( - out, + let h = rsnix::memory::heap_stats(); + let ps = rsnix::memory::page_stats(); + + let mut pipe = Pipeline::new(&p.filters, &p.output); + let _ = pipe.line(&alloc::format!("memory map (key=0x{:x}, {} entries)", key, map.len())); + let _ = pipe.line(&alloc::format!( " usable {:4} MB Conventional", conventional_kb / 1024 - ); - let _ = writeln!( - out, + )); + let _ = pipe.line(&alloc::format!( " boot code {:4} MB BootServicesCode", boot_code_kb / 1024 - ); - let _ = writeln!( - out, + )); + let _ = pipe.line(&alloc::format!( " boot data {:4} MB BootServicesData", boot_data_kb / 1024 - ); - let _ = writeln!( - out, + )); + let _ = pipe.line(&alloc::format!( " loader {:4} MB LoaderCode + LoaderData", loader_kb / 1024 - ); - let _ = writeln!( - out, + )); + let _ = pipe.line(&alloc::format!( " runtime {:4} MB RuntimeServices", runtime_kb / 1024 - ); - let _ = writeln!(out, " ACPI {:4} MB", acpi_kb / 1024); - let _ = writeln!(out, " MMIO {:4} MB", mmio_kb / 1024); - let _ = writeln!(out, " reserved {:4} MB", reserved_kb / 1024); - let _ = writeln!( - out, + )); + let _ = pipe.line(&alloc::format!(" ACPI {:4} MB", acpi_kb / 1024)); + let _ = pipe.line(&alloc::format!(" MMIO {:4} MB", mmio_kb / 1024)); + let _ = pipe.line(&alloc::format!(" reserved {:4} MB", reserved_kb / 1024)); + let _ = pipe.line(&alloc::format!( " raw total {:4} MB (incl. PCIe MMIO)", total_kb / 1024 - ); - - // Heap (free-list allocator with coalescing) - let h = rsnix::memory::heap_stats(); - let _ = writeln!(out, "heap:"); - let _ = writeln!( - out, + )); + let _ = pipe.line("heap:"); + let _ = pipe.line(&alloc::format!( " range {:#x}..{:#x} {} KB total", h.heap_start, h.heap_end, h.heap_size / 1024 - ); - let _ = writeln!( - out, + )); + let _ = pipe.line(&alloc::format!( " free {} KB in {} block(s), allocated {} KB", h.free_bytes / 1024, h.free_blocks, h.heap_size.saturating_sub(h.free_bytes) / 1024 - ); - - // Physical pages (bitmap allocator) - let ps = rsnix::memory::page_stats(); - let _ = writeln!(out, "pages:"); - let _ = writeln!( - out, + )); + let _ = pipe.line("pages:"); + let _ = pipe.line(&alloc::format!( " span base {:#x}, {} MB covered", ps.base_page * rsnix::memory::PAGE_SIZE, ps.span_bytes / 1024 / 1024 - ); - let _ = writeln!( - out, + )); + let _ = pipe.line(&alloc::format!( " free {} pages ({} MB), {} used", ps.free_pages, ps.free_pages * rsnix::memory::PAGE_SIZE / 1024 / 1024, ps.used_pages - ); - - gop_print!("{}", out); - serial_print!("{}", out); + )); + pipe.end(); } // --------------------------------------------------------------------------- // mmap — dump every entry of the UEFI memory map with type names // --------------------------------------------------------------------------- -fn cmd_mmap() { +fn cmd_mmap(p: &Parsed) { let (map, key) = rsnix::memory::get_map(); - let mut out = alloc::string::String::new(); - use alloc::fmt::Write; - let _ = writeln!(out, "memory map (key=0x{:x}, {} entries):", key, map.len()); - let _ = writeln!( - out, - " # type start pages size" - ); + let mut pipe = Pipeline::new(&p.filters, &p.output); + let _ = pipe.line(&alloc::format!("memory map (key=0x{:x}, {} entries):", key, map.len())); + let _ = pipe.line(" # type start pages size"); for (i, d) in map.iter().enumerate() { - let _ = writeln!( - out, + let l = alloc::format!( " {:>2} {:<16} {:#014x} {:>9} {:>8} KB", i, rsnix::uefi::memory_type_name(d.kind), @@ -501,9 +1315,11 @@ fn cmd_mmap() { d.number_of_pages, d.number_of_pages * 4 ); + if !pipe.line(&l) { + break; + } } - gop_print!("{}", out); - serial_print!("{}", out); + pipe.end(); } // --------------------------------------------------------------------------- @@ -511,28 +1327,24 @@ fn cmd_mmap() { // Pushes the heap past its base region so growth into the page allocator // is exercised; also verifies a clean return to the free-list after. // --------------------------------------------------------------------------- -fn cmd_stress(args: &str) { - let mut it = args.split_whitespace(); +fn cmd_stress(p: &Parsed) { + let mut it = p.args.iter(); let chunks: usize = match it.next().and_then(|s| s.parse().ok()) { Some(n) if (1..=64).contains(&n) => n, _ => { - gop_println!("usage: stress "); - serial_println!("usage: stress "); + term_err("usage: stress "); return; } }; let kb: usize = match it.next().and_then(|s| s.parse().ok()) { Some(n) if n > 0 && n <= 1024 * 1024 => n, _ => { - gop_println!("usage: stress "); - serial_println!("usage: stress "); + term_err("usage: stress "); return; } }; - let mut out = alloc::string::String::new(); - use alloc::fmt::Write; - + let mut pipe = Pipeline::new(&p.filters, &p.output); let mut allocated = 0usize; { let mut blocks: [Option>; 64] = core::array::from_fn(|_| None); @@ -545,39 +1357,34 @@ fn cmd_stress(args: &str) { allocated += 1; } Err(_) => { - let _ = writeln!(out, " block {}: out of memory, stopping", i); + let _ = pipe.line(&alloc::format!(" block {}: out of memory, stopping", i)); break; } } } let h = rsnix::memory::heap_stats(); let ps = rsnix::memory::page_stats(); - let _ = writeln!( - out, + let _ = pipe.line(&alloc::format!( " held {} x {} KB = {} KB", allocated, kb, allocated * kb - ); - let _ = writeln!( - out, + )); + let _ = pipe.line(&alloc::format!( " heap free {} KB ({} blocks), pages free {} MB", h.free_bytes / 1024, h.free_blocks, ps.free_pages * 4 / 1024 - ); + )); // blocks dropped here → everything freed } let h = rsnix::memory::heap_stats(); - let _ = writeln!( - out, + let _ = pipe.line(&alloc::format!( " freed: heap free {} KB in {} blocks", h.free_bytes / 1024, h.free_blocks - ); - - gop_print!("{}", out); - serial_print!("{}", out); + )); + pipe.end(); } fn cmd_halt() -> ! { @@ -586,8 +1393,9 @@ fn cmd_halt() -> ! { rsnix::power::halt() } -fn cmd_date() { +fn cmd_date(p: &Parsed) { let t = rsnix::rtc::now(); + let mut pipe = Pipeline::new(&p.filters, &p.output); let line = alloc::format!( "{}-{:02}-{:02} {:02}:{:02}:{:02}", t.year, @@ -597,19 +1405,17 @@ fn cmd_date() { t.minute, t.second ); - gop_println!("{}", line); - serial_println!("{}", line); + let _ = pipe.line(&line); + pipe.end(); } -fn cmd_pci() { +fn cmd_pci(p: &Parsed) { let devices = rsnix::pci::probe(); - let mut out = alloc::string::String::new(); - use alloc::fmt::Write; - let _ = writeln!(out, "pci: {} device(s)", devices.len()); - let _ = writeln!(out, " bus dev fn class vendor:device"); + let mut pipe = Pipeline::new(&p.filters, &p.output); + let _ = pipe.line(&alloc::format!("pci: {} device(s)", devices.len())); + let _ = pipe.line(" bus dev fn class vendor:device"); for d in &devices { - let _ = writeln!( - out, + let l = alloc::format!( " {:02x}:{:02x}.{} {:<16} {:04x}:{:04x}", d.bus, d.device, @@ -618,9 +1424,11 @@ fn cmd_pci() { d.vendor, d.device_id ); + if !pipe.line(&l) { + break; + } } - gop_print!("{}", out); - serial_print!("{}", out); + pipe.end(); } fn cmd_poweroff() -> ! { -- cgit v1.2.3