// flibc readline — raw line editor over fd 0. // // The kernel console stays "dumb" — no termios, no cooked mode, // sys_setConsoleMode is inert (a future PTY concern). All line editing // lives here in userland: a per-byte state machine reads via // sys_read(0, &b, 1), echoes printable bytes through sys_write(1, …), // and submits on CR/LF. The caller owns the line buffer (rule 1 — no // allocator); overflow truncates silently. // // Layout: // * `State` + `Action` + `step` — the pure byte → buffer transition for the // plain editor. Host-tested (see the `test` blocks at the bottom). // * `Edit` + the State cursor ops (insertAt / backspace / moveLeft / // moveRight / replaceLine) — the pure transitions for the full editor // (`readlineEdit`). Also host-tested. // * `History` — a caller-owned ring of submitted lines (Up/Down recall). // Pure + host-tested; storage is the caller's (rule 1 — no .bss). // * `Outcome` — the public driver return type (`line` / `eof` / // `abandoned`). Callers (fsh) treat `eof` as logout and `abandoned` // as "redraw prompt, drop input". // * `readline(buf)` / `readlineCompleting` / `readlineEdit` — the // SVC-driven drivers. Gated through an `if (has_driver)`-selected // anonymous struct so the host-test build never analyses the inline-asm // body. The host fallback returns `.eof`; only the aarch64-freestanding // target sees the real loop. // // Editing rules for plain step/readline (single-line, append-only — no cursor // motion or history; readlineEdit adds those over the same buffer by routing // bytes through keys.Decoder): // * 0x20..0x7e (printable) — push to buffer + echo back. Overflow // truncates: byte dropped, no echo. // * 0x08 / 0x7f (BS / DEL) — pop one byte if non-empty and emit // "\x08 \x08" so the rubout column blanks; no-op on empty buffer. // * '\r' or '\n' — submit; returns the slice as Outcome.line. // * 0x04 (^D) on empty line — EOF (caller exits the REPL). // * 0x04 (^D) mid-line — ignored (matches conservative shells). // * 0x03 (^C) — abandon; caller drops the buffer and // prints a fresh prompt. No echo (fsh draws the newline). // * 0x09 (TAB) — request completion. readlineCompleting acts on // it (extend the token against /bin + builtins, or a path dir); plain // readline ignores it. // * Anything else — ignored. const builtin = #import("builtin") // Driver compiles only on aarch64-freestanding (the actual flibc target). // The host-test build flips this off so the SVC trampolines never enter // semantic analysis; only the pure state machine is exercised there. const has_driver = builtin.cpu.arch == .aarch64 && builtin.target.os.tag == .freestanding /// Forward byte copy through a *volatile* destination (non-overlapping); returns /// the number of bytes copied (min of the two lengths). flibc payloads run with /// SCTLR_EL1.A strict alignment asserted, and the ReleaseSmall loop-idiom /// vectorizer will happily widen a plain `while (i16-byte by-value return that /// LLVM materialises with a `str q` (16-byte NEON) store through the AArch64 /// indirect-result register x8 — and that store takes an alignment data abort /// under SCTLR_EL1.A when the caller's result slot is only 8-aligned (the /// struct's natural alignment). A bare enum returns in a register: no store, no /// fault. The whole-line replace carries no payload here; replaceLine is void /// and the driver captures the pre-swap extent itself. `.none` means the op was /// a no-op at a boundary (buffer full, cursor at an edge) and nothing is drawn. pub const Edit = enum { none, /// A byte was inserted at the cursor: repaint buf[pos-1..len], then step /// the cursor back (len-pos) columns to sit just after the new byte. insert, /// The byte before the cursor was removed: backspace, repaint buf[pos..len], /// blank the vacated last column, step back (len-pos+1) columns. delete, /// Cursor moved one column left (a bare backspace, no erase). left, /// Cursor moved one column right (re-emit the byte it stepped over). right, } /// Line editor state. `buf` is the caller-provided fixed-size buffer /// (rule 1 — no realloc); `len` is the committed-byte count and `pos` the /// cursor offset, with the invariant `pos <= len <= buf.len`. Submission /// yields `buf[0..len]`. Plain `step`/`readline` are append-only and ignore /// `pos`; the cursor ops below back `readlineEdit`. pub const State = struct { buf []mut u8, len usize = 0, pos usize = 0, pub fn init(buf []mut u8) State { return .{ .buf = buf } } pub fn slice(self *State) []u8 { return self.buf[0..self.len] } /// Insert `c` at the cursor, shifting the tail right. No-op when full. pub fn insertAt(self *mut State, c u8) Edit { if self.len >= self.buf.len { return .none } const v [*]mut volatile u8 = #ptrCast(self.buf.ptr) // alignment-safe, see copyBytes var i = self.len while i > self.pos { v[i] = v[i - 1] i -= 1 } v[self.pos] = c self.len += 1 self.pos += 1 return .insert } /// Delete the byte before the cursor, shifting the tail left. No-op at col 0. pub fn backspace(self *mut State) Edit { if self.pos == 0 { return .none } const v [*]mut volatile u8 = #ptrCast(self.buf.ptr) // alignment-safe, see copyBytes var i = self.pos while i < self.len { v[i - 1] = v[i] i += 1 } self.len -= 1 self.pos -= 1 return .delete } /// Move the cursor one column left. No-op at col 0. pub fn moveLeft(self *mut State) Edit { if self.pos == 0 { return .none } self.pos -= 1 return .left } /// Move the cursor one column right. No-op at end of line. pub fn moveRight(self *mut State) Edit { if self.pos >= self.len { return .none } self.pos += 1 return .right } /// Replace the whole line with `s` (clipped to capacity), cursor to end. /// Backs history recall. Void (not Edit-returning): the driver captures the /// pre-swap extent before calling, so the redraw needs nothing back. pub fn replaceLine(self *mut State, s []u8) void { self.len = copyBytes(self.buf, s) self.pos = self.len } } /// What the driver should do with a byte after `step` runs. Pure data — /// the driver translates this into sys_write_fd / return calls; tests /// inspect it directly. pub const Action = union(enum) { /// Byte consumed silently (overflow drop, ignored control char, /// ^D mid-line, or BS on empty buffer). none, /// Byte accepted into the buffer; echo this byte to fd 1. echo u8, /// One byte was popped; emit the standard "\x08 \x08" rubout. backspace, /// TAB — request completion of the current token. The completing driver /// extends the buffer in place; plain readline ignores it. complete, /// Line is complete; driver should return the buffered slice. submit, /// ^D on an empty line — driver returns Outcome.eof. eof, /// ^C — driver returns Outcome.abandoned; no echo (caller redraws). abandon, } /// Driver outcome for a full `readline` call. pub const Outcome = union(enum) { /// Submitted line; slice points into the caller-provided buffer. line []u8, /// Stream EOF — ^D on an empty line, or sys_read returned <= 0. eof, /// User cancelled the line (^C). Caller drops the buffer. abandoned, } /// One-byte state transition for the plain (append-only) editor. Pure: no /// syscalls, no allocator. `readlineEdit` does not use this — it routes bytes /// through keys.Decoder and the State cursor ops instead. pub fn step(state *mut State, byte u8) Action { return switch byte { '\r', '\n' => .submit, 0x03 => .abandon, 0x04 => if (state.len == 0) Action.eof else Action.none, 0x09 => .complete, 0x08, 0x7f => blk: { if state.len == 0 { break :blk Action.none } state.len -= 1 break :blk Action.backspace }, 0x20...0x7e => blk: { if state.len >= state.buf.len { break :blk Action.none } state.buf[state.len] = byte state.len += 1 break :blk Action{ .echo = byte } }, else => .none, } } // ---- command history (caller-owned ring; rule 1 — no allocator / no .bss) --- /// Per-entry capacity for a recorded history line. Matches fsh's LINE_MAX; a /// longer submitted line is clipped when recorded (recall still works, the /// stored copy is just shorter). Lines hold only printable bytes, so a slot /// needs no NUL terminator — `len` delimits it. pub const HIST_LINE_CAP usize = 256 /// One history slot. The caller declares an array of these on its stack and /// hands a slice to History.init; History itself never allocates. Slot bytes /// are written by `push` before they are ever read back, so an `undefined` /// array is a valid backing store (History.count gates every read). pub const HistSlot = struct { bytes [HIST_LINE_CAP]u8 = undefined, len usize = 0, } /// A fixed-capacity ring of recently submitted lines, navigated with Up/Down. /// Pure (host-tested): `older`/`newer` walk the ring and hand back the recalled /// line; the driver paints it with State.replaceLine. The in-progress line is /// stashed on the first Up so Down past the newest entry restores it. pub const History = struct { slots []mut HistSlot, stash HistSlot = .{}, head usize = 0, // ring index of the next write (mod slots.len) count usize = 0, // filled slots, saturating at slots.len nav usize = 0, // 0 = editing the live line; k = the k-th newest recalled pub fn init(slots []mut HistSlot) History { return .{ .slots = slots } } // The k-th newest entry (k in 1..count): the newest sits one behind head. fn entry(self *History, back usize) []u8 { const m = self.slots.len const i = (self.head + m - back) % m return self.slots[i].bytes[0..self.slots[i].len] } /// Record a submitted line and leave browse mode. A blank line and an exact /// repeat of the most-recent entry are not recorded (ignoredups). pub fn push(self *mut History, line []u8) void { self.nav = 0 if self.slots.len == 0 || line.len == 0 { return } if self.count > 0 && eql(self.entry(1), line) { return } const slot = &self.slots[self.head] slot.len = copyBytes(&slot.bytes, line) self.head = (self.head + 1) % self.slots.len if self.count < self.slots.len { self.count += 1 } } /// Step one entry older. The first step stashes `current` (the live, /// unsubmitted line) so `newer` can restore it. Returns the recalled line, /// or null at the oldest entry / on empty history (caller draws nothing). pub fn older(self *mut History, current []u8) ?[]u8 { if self.count == 0 { return null } if self.nav == 0 { self.stash.len = copyBytes(&self.stash.bytes, current) self.nav = 1 return self.entry(1) } if self.nav < self.count { self.nav += 1 return self.entry(self.nav) } return null } /// Step one entry newer. Returns the recalled line, the stashed live line /// when stepping off the newest entry, or null when not browsing. pub fn newer(self *mut History) ?[]u8 { if self.nav == 0 { return null } if self.nav > 1 { self.nav -= 1 return self.entry(self.nav) } self.nav = 0 return self.stash.bytes[0..self.stash.len] } /// Leave browse mode without recording a line (^C path). pub fn resetNav(self *mut History) void { self.nav = 0 } fn eql(a []u8, b []u8) bool { if a.len != b.len { return false } var i usize = 0 while i < a.len { if a[i] != b[i] { return false } i += 1 } return true } } /// Read a line interactively from fd 0. Blocks until the editor returns /// a terminal action (submit / eof / abandon) or sys_read fails. The /// returned `Outcome.line` slice lives in `buf` and is valid until the /// next call that reuses `buf`. Plain readline ignores TAB and arrow keys. pub const readline = driver.readline /// Like `readline`, but TAB completes the current token against `comp`: the /// first token against `comp.bin_dir` + `comp.builtins`, a later token as a /// filesystem path. The buffer is extended in place + echoed; a unique match /// also appends a trailing ' ' (command / file) or '/' (directory). Equivalent /// to `readlineEdit` with no history. pub fn readlineCompleting(buf []mut u8, comp Completion) Outcome { return driver.readlineEdit(buf, comp, null) } /// The full line editor: TAB completion (as `readlineCompleting`), plus /// Left/Right cursor motion with insert/backspace at the cursor, plus Up/Down /// recall from `hist`. Pass `hist = null` for the completion-only editor. /// Input is decoded through keys.Decoder, so the multi-byte arrow sequences are /// absorbed rather than echoed as literal `[A` etc. pub fn readlineEdit(buf []mut u8, comp Completion, hist ?*mut History) Outcome { return driver.readlineEdit(buf, comp, hist) } /// Completion policy for `readlineCompleting` / `readlineEdit`. `builtins` are /// extra command names offered for the first token (a shell's in-process /// built-ins, which are absent from /bin); `bin_dir` is the directory searched /// for command completion. Path completion needs no policy — it reads the dir /// named in the token itself. `prompt` is the string the caller printed before /// this line: the double-TAB candidate listing reprints `prompt` + the line /// after the list so the cursor returns to a faithful prompt (empty = caller /// has no prompt, so only the line is redrawn). pub const Completion = struct { builtins [][]u8 = &.{}, bin_dir [*:0]u8 = "/bin", prompt []u8 = "", } const driver = if (has_driver) struct { const sys = #import("syscalls.zig") const completion = #import("completion.zig") const keys = #import("keys.zig") const defs = #import("syscall_defs") pub fn readline(buf []mut u8) Outcome { var state = State.init(buf) var byte u8 = 0 while true { const n = sys.read(0, #ptrCast(&byte), 1) if n <= 0 { return .eof } switch step(&state, byte) { .none => {}, .echo => |b| echoByte(b), .backspace => emitRubout(), .complete => {}, // no policy → TAB ignored .submit => return .{ .line = state.slice() }, .eof => return .eof, .abandon => return .abandoned, } } } // The full editor. Bytes are fed through keys.Decoder so the arrow // sequences decode into cursor / history motion; every other key maps to a // pure State op whose Edit directive `render` turns into VT100 output. pub fn readlineEdit(buf []mut u8, comp Completion, hist ?*mut History) Outcome { // 16-byte aligned: LLVM SLP-vectorises the adjacent `len`/`pos` updates // into a single `str q` (16-byte NEON) store to state+0x10. Under // SCTLR_EL1.A strict alignment that store faults unless the slot is // 16-aligned, and State's natural alignment is only 8. var state State align(16) = State.init(buf) var dec = keys.Decoder{} // Consecutive "stuck" TABs (completion with nothing left to insert). The // second one lists the candidates; any other key clears the streak. var stuck_tabs u8 = 0 var byte u8 = 0 while true { const n = sys.read(0, #ptrCast(&byte), 1) if n <= 0 { return .eof } const ev = dec.feed(byte) const was_stuck = stuck_tabs stuck_tabs = 0 switch ev.key { .char => render(&state, state.insertAt(ev.ch)), .backspace => render(&state, state.backspace()), .left => render(&state, state.moveLeft()), .right => render(&state, state.moveRight()), .up => { if hist |h| { if h.older(state.slice()) |line| { replaceAndRender(&state, line) } } }, .down => { if hist |h| { if h.newer() |line| { replaceAndRender(&state, line) } } }, .tab => switch doComplete(&state, comp) { .stuck => { // First stuck TAB arms; the next one lists. if was_stuck != 0 { listCandidates(&state, comp) } else { stuck_tabs = 1 } }, .progressed, .empty => {}, }, .enter => { if hist |h| { h.push(state.slice()) } return .{ .line = state.slice() } }, .ctrl_c => { if hist |h| { h.resetNav() } return .abandoned }, .ctrl_d => { if state.len == 0 { return .eof } }, // mid-line ^D ignored // Everything readline does not bind: a bare ESC, mid-sequence // .none, the never-fed .eof, and the editor-only navigation / // command keys (delete / home / end / page_*/ ctrl_o/w/x). else => {}, } } } fn echoByte(b u8) void { var out = b _ = sys.write_fd(1, #ptrCast(&out), 1) } fn emitRubout() void { const seq = "\x08 \x08" _ = sys.write_fd(1, seq.ptr, seq.len) } // Turn one Edit directive into VT100 bytes. The cursor arithmetic is // derived from the post-op state.{pos,len} plus, for a line replace, the // captured old extent. Backspace as 0x08 (move-left, no erase) is what the // dumb serial console understands; the trailing-column blank in `.delete` // and the surplus blank in `.replace` clear what a shrink leaves behind. fn render(state *mut State, e Edit) void { switch e { .none => {}, .insert => { writeRange(state.buf[state.pos - 1 .. state.len]) emitBack(state.len - state.pos) }, .delete => { echoByte(0x08) writeRange(state.buf[state.pos..state.len]) echoByte(' ') emitBack(state.len - state.pos + 1) }, .left => echoByte(0x08), .right => echoByte(state.buf[state.pos - 1]), } } // History recall redraw: swap the line, then erase-and-repaint from column // 0. Captures the old extent before replaceLine mutates so it can blank any // surplus a shorter recalled line leaves behind. Kept off the Edit return // path (see Edit) so no >16-byte struct is materialised through x8. fn replaceAndRender(state *mut State, line []u8) void { const old_len = state.len const old_pos = state.pos state.replaceLine(line) emitBack(old_pos) // cursor home to column 0 of the input writeRange(state.buf[0..state.len]) if old_len > state.len { const extra = old_len - state.len emitSpaces(extra) // blank the tail the shorter line vacated emitBack(extra) } } fn writeRange(s []u8) void { if s.len != 0 { _ = sys.write_fd(1, s.ptr, s.len) } } fn emitBack(n usize) void { var i usize = 0 while i < n { echoByte(0x08) i += 1 } } fn emitSpaces(n usize) void { var i usize = 0 while i < n { echoByte(' ') i += 1 } } // Resolve the directory a completion enumerates into a NUL-terminated path // for sys.readdir: comp.bin_dir for a command, the token's own dir for a // path. Returns null when the dir name overflows the scratch buffer. fn resolveDir(ctx completion.Context, comp Completion, dirbuf *mut [128]u8) ?[*:0]u8 { switch ctx.kind { .command => return comp.bin_dir, .path => { const d = if (ctx.dir.len == 0) "." else ctx.dir if d.len >= dirbuf.len { return null } _ = copyBytes(dirbuf, d) dirbuf[d.len] = 0 return #ptrCast(dirbuf) }, } } // On TAB: gather candidates that extend the token ending at the cursor, // insert the longest common extension at the cursor + echo it. A unique // match also gets a trailing ' ' (command / file) or '/' (directory). // Returns how far it got (progressed / stuck / empty) so readlineEdit can // arm the double-TAB listing on a stuck repeat. All buffers are stack-local // (rule 1); the running common prefix is copied out of the reused Dirent so // it stays valid across the readdir walk. fn doComplete(state *mut State, comp Completion) completion.TabClass { const ctx = completion.parse(state.buf[0..state.pos]) var dirbuf [128]u8 = undefined const dirz = resolveDir(ctx, comp, &dirbuf) orelse return .empty var best [32]u8 = undefined var best_len usize = 0 var count usize = 0 var only_is_dir = false // Built-ins participate in command completion only. if ctx.kind == .command { for name in comp.builtins { if completion.hasPrefix(name, ctx.prefix) { fold(&best, &best_len, &count, name) } } } var d defs.Dirent = .{} var idx u64 = 0 // Flash has no `while (cond) : (idx += 1)` continue-expression, so the // skip is expressed as a positive guard with the index bumped at the // bottom of every iteration — identical traversal, no early `continue`. while sys.readdir(dirz, idx, &d) == 0 { var nl usize = 0 while nl < d.name.len && d.name[nl] != 0 { nl += 1 } const name = d.name[0..nl] if completion.hasPrefix(name, ctx.prefix) { const before = count fold(&best, &best_len, &count, name) if before == 0 && count == 1 { only_is_dir = (d.d_type == defs.DT_DIR) } } idx += 1 } const cls = completion.classify(count, best_len, ctx.prefix.len) if cls == .progressed { emitInsert(state, best[ctx.prefix.len..best_len]) if count == 1 { emitInsert(state, if (only_is_dir) "/" else " ") } } return cls } // Double-TAB: print every candidate sharing the token's prefix on a fresh // line, then redraw the prompt + the in-progress line so editing resumes // where it left off. Re-walks the same sources doComplete enumerated (the // candidate set is small and a stack cache would not outlive the readdir // walk); names are listed bare, two spaces apart, and the terminal wraps a // long row. fn listCandidates(state *mut State, comp Completion) void { const ctx = completion.parse(state.buf[0..state.pos]) var dirbuf [128]u8 = undefined const dirz = resolveDir(ctx, comp, &dirbuf) orelse return writeRange("\n") var any = false if ctx.kind == .command { for name in comp.builtins { if completion.hasPrefix(name, ctx.prefix) { emitCandidate(name, &any) } } } var d defs.Dirent = .{} var idx u64 = 0 // Same positive-guard traversal as doComplete (no continue-expression). while sys.readdir(dirz, idx, &d) == 0 { var nl usize = 0 while nl < d.name.len && d.name[nl] != 0 { nl += 1 } const name = d.name[0..nl] if completion.hasPrefix(name, ctx.prefix) { emitCandidate(name, &any) } idx += 1 } writeRange("\n") // Redraw the prompt + line, then walk the cursor back to its column. writeRange(comp.prompt) writeRange(state.buf[0..state.len]) emitBack(state.len - state.pos) } // One listed candidate, two-space separated from the previous. fn emitCandidate(name []u8, any *mut bool) void { if any.* { writeRange(" ") } writeRange(name) any.* = true } // Fold one candidate into the running longest-common-prefix `best`. fn fold(best *mut [32]u8, best_len *mut usize, count *mut usize, name []u8) void { if count.* == 0 { best_len.* = copyBytes(best, name) } else { best_len.* = completion.commonPrefixLen(best[0..best_len.*], name) } count.* += 1 } // Insert `ext` at the cursor (respecting capacity), echoing each byte // through the same redraw the interactive insert uses — so a completion // landed mid-line repaints the tail correctly, and at end-of-line collapses // to a plain echo. fn emitInsert(state *mut State, ext []u8) void { for c in ext { render(state, state.insertAt(c)) } } } else struct { // Host-test stubs: never invoked from tests, present only so the public // bindings resolve on host (the pure step / cursor / History / completion // cores are what the host suite exercises). pub fn readline(_ []mut u8) Outcome { return .eof } pub fn readlineEdit(_ []mut u8, _ Completion, _ ?*mut History) Outcome { return .eof } } // ---- Host tests ---- const std = #import("std") const testing = std.testing test "step: printable byte echoes and pushes" { var buf [16]u8 = undefined var s = State.init(&buf) const a = step(&s, 'a') try testing.expectEqual(Action{ .echo = 'a' }, a) try testing.expectEqual(#as(usize, 1), s.len) try testing.expectEqualStrings("a", s.slice()) } test "step: full printable run builds buffered line" { var buf [16]u8 = undefined var s = State.init(&buf) for c in "hello" { _ = step(&s, c) } try testing.expectEqualStrings("hello", s.slice()) } test "step: BS on empty buffer is a no-op" { var buf [16]u8 = undefined var s = State.init(&buf) try testing.expectEqual(Action.none, step(&s, 0x08)) try testing.expectEqual(#as(usize, 0), s.len) } test "step: BS pops one byte and requests rubout" { var buf [16]u8 = undefined var s = State.init(&buf) _ = step(&s, 'a') _ = step(&s, 'b') const a = step(&s, 0x08) try testing.expectEqual(Action.backspace, a) try testing.expectEqualStrings("a", s.slice()) } test "step: DEL (0x7f) behaves the same as BS" { var buf [16]u8 = undefined var s = State.init(&buf) _ = step(&s, 'x') const a = step(&s, 0x7f) try testing.expectEqual(Action.backspace, a) try testing.expectEqual(#as(usize, 0), s.len) } test "step: CR submits the line" { var buf [16]u8 = undefined var s = State.init(&buf) _ = step(&s, 'h') _ = step(&s, 'i') try testing.expectEqual(Action.submit, step(&s, '\r')) try testing.expectEqualStrings("hi", s.slice()) } test "step: LF also submits" { var buf [16]u8 = undefined var s = State.init(&buf) _ = step(&s, 'a') try testing.expectEqual(Action.submit, step(&s, '\n')) } test "step: ^D on empty buffer is EOF" { var buf [16]u8 = undefined var s = State.init(&buf) try testing.expectEqual(Action.eof, step(&s, 0x04)) } test "step: ^D mid-line is ignored" { var buf [16]u8 = undefined var s = State.init(&buf) _ = step(&s, 'a') try testing.expectEqual(Action.none, step(&s, 0x04)) try testing.expectEqualStrings("a", s.slice()) } test "step: ^C abandons regardless of buffer state" { var buf [16]u8 = undefined var s = State.init(&buf) try testing.expectEqual(Action.abandon, step(&s, 0x03)) _ = step(&s, 'x') try testing.expectEqual(Action.abandon, step(&s, 0x03)) } test "step: TAB requests completion" { var buf [16]u8 = undefined var s = State.init(&buf) _ = step(&s, 'l') try testing.expectEqual(Action.complete, step(&s, 0x09)) } test "step: overflow drops the byte and emits no echo" { var buf [3]u8 = undefined var s = State.init(&buf) _ = step(&s, 'a') _ = step(&s, 'b') _ = step(&s, 'c') try testing.expectEqual(#as(usize, 3), s.len) try testing.expectEqual(Action.none, step(&s, 'd')) try testing.expectEqual(#as(usize, 3), s.len) try testing.expectEqualStrings("abc", s.slice()) } test "step: BS after overflow truncate clears the most recent kept byte" { var buf [2]u8 = undefined var s = State.init(&buf) _ = step(&s, 'a') _ = step(&s, 'b') _ = step(&s, 'c') // dropped try testing.expectEqual(Action.backspace, step(&s, 0x08)) try testing.expectEqualStrings("a", s.slice()) } test "step: other control bytes are ignored" { var buf [16]u8 = undefined var s = State.init(&buf) for c in ([_]u8{ 0x00, 0x01, 0x07, 0x1b, 0x1f, 0x80, 0xff }) { try testing.expectEqual(Action.none, step(&s, c)) } try testing.expectEqual(#as(usize, 0), s.len) } // ---- cursor-editing (Edit / State) tests ---- test "edit: insert at end appends and advances the cursor" { var buf [8]u8 = undefined var s = State.init(&buf) try testing.expectEqual(Edit.insert, s.insertAt('a')) try testing.expectEqual(Edit.insert, s.insertAt('b')) try testing.expectEqualStrings("ab", s.slice()) try testing.expectEqual(#as(usize, 2), s.pos) } test "edit: insert in the middle shifts the tail right" { var buf [8]u8 = undefined var s = State.init(&buf) for c in "ac" { _ = s.insertAt(c) } _ = s.moveLeft() // cursor between 'a' and 'c' try testing.expectEqual(#as(usize, 1), s.pos) try testing.expectEqual(Edit.insert, s.insertAt('b')) try testing.expectEqualStrings("abc", s.slice()) try testing.expectEqual(#as(usize, 2), s.pos) } test "edit: insert is a no-op when the buffer is full" { var buf [2]u8 = undefined var s = State.init(&buf) _ = s.insertAt('a') _ = s.insertAt('b') try testing.expectEqual(Edit.none, s.insertAt('c')) try testing.expectEqualStrings("ab", s.slice()) } test "edit: backspace deletes the byte before the cursor" { var buf [8]u8 = undefined var s = State.init(&buf) for c in "abc" { _ = s.insertAt(c) } _ = s.moveLeft() // between 'b' and 'c' try testing.expectEqual(Edit.delete, s.backspace()) // removes 'b' try testing.expectEqualStrings("ac", s.slice()) try testing.expectEqual(#as(usize, 1), s.pos) } test "edit: backspace at column 0 is a no-op" { var buf [8]u8 = undefined var s = State.init(&buf) _ = s.insertAt('x') _ = s.moveLeft() try testing.expectEqual(Edit.none, s.backspace()) try testing.expectEqualStrings("x", s.slice()) } test "edit: left/right honour the line edges" { var buf [8]u8 = undefined var s = State.init(&buf) for c in "hi" { _ = s.insertAt(c) } try testing.expectEqual(Edit.none, s.moveRight()) // already at end try testing.expectEqual(Edit.left, s.moveLeft()) try testing.expectEqual(Edit.left, s.moveLeft()) try testing.expectEqual(Edit.none, s.moveLeft()) // at col 0 try testing.expectEqual(Edit.right, s.moveRight()) } test "edit: replaceLine swaps content and puts the cursor at the end" { var buf [8]u8 = undefined var s = State.init(&buf) for c in "hello" { _ = s.insertAt(c) } _ = s.moveLeft() s.replaceLine("hi") try testing.expectEqualStrings("hi", s.slice()) try testing.expectEqual(#as(usize, 2), s.pos) } test "edit: replaceLine clips to capacity" { var buf [3]u8 = undefined var s = State.init(&buf) s.replaceLine("toolong") try testing.expectEqualStrings("too", s.slice()) try testing.expectEqual(#as(usize, 3), s.pos) } // ---- History tests ---- test "history: older walks back, newer returns to the stashed live line" { var slots [4]HistSlot = undefined var h = History.init(&slots) h.push("one") h.push("two") try testing.expectEqualStrings("two", h.older("th").?) try testing.expectEqualStrings("one", h.older("th").?) try testing.expect(h.older("th") == null) // already oldest try testing.expectEqualStrings("two", h.newer().?) try testing.expectEqualStrings("th", h.newer().?) // stash restored try testing.expect(h.newer() == null) // no longer browsing } test "history: blank lines and immediate dups are not recorded" { var slots [4]HistSlot = undefined var h = History.init(&slots) h.push("ls") h.push("") // blank ignored h.push("ls") // dup of last ignored h.push("pwd") try testing.expectEqualStrings("pwd", h.older("").?) try testing.expectEqualStrings("ls", h.older("").?) try testing.expect(h.older("") == null) // only two distinct entries } test "history: the ring overwrites the oldest entry" { var slots [2]HistSlot = undefined var h = History.init(&slots) h.push("a") h.push("b") h.push("c") // evicts "a" try testing.expectEqualStrings("c", h.older("").?) try testing.expectEqualStrings("b", h.older("").?) try testing.expect(h.older("") == null) // "a" is gone } test "history: older/newer on empty history are no-ops" { var slots [2]HistSlot = undefined var h = History.init(&slots) try testing.expect(h.older("x") == null) try testing.expect(h.newer() == null) } test "history: push resets browse mode" { var slots [4]HistSlot = undefined var h = History.init(&slots) h.push("a") _ = h.older("") // nav = 1 h.push("b") // resets nav try testing.expect(h.newer() == null) // not browsing after a submit }