ajhahn.de
← FlashOS
Flash 979 lines
// 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 (i<n) dst[i]=src[i]` copy into a
/// `str q` (16-byte NEON) store — which takes an alignment data abort when the
/// destination is only 8-aligned, as a history slot or the line buffer routinely
/// is. Volatile accesses are never widened or merged, so this is alignment-safe
/// by construction; the buffers are at most one line, so the byte-wise cost is
/// irrelevant. Mirrors the hand-rolled providers in src/utilc.zig and
/// flibc/mem.flash that dodge the same hazard. The cursor shift loops below cast
/// the line buffer the same way for the same reason.
fn copyBytes(dst []mut u8, src []u8) usize {
    const n = #min(dst.len, src.len)
    const d [*]mut volatile u8 = #ptrCast(dst.ptr)
    var i usize = 0
    while i < n {
        d[i] = src[i]
        i += 1
    }
    return n
}

/// A cursor-aware edit directive returned by the State cursor ops. A plain
/// enum on purpose: a payload-carrying union is a >16-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
}