ajhahn.de
← Flash
Flash 309 lines
// readline — the flibc raw line editor's pure core, ported to Flash from its
// hand-written Zig (the second FlashOS module after `tokenize`).
//
// The kernel console stays "dumb": all line editing lives in userland as a
// per-byte state machine. This port carries the pure, host-tested core — the
// byte transition, the cursor-aware edit ops, and the command-history ring —
// and leaves out the SVC-driven drivers (inline-asm, gated to
// aarch64-freestanding) and the host test blocks, exactly as the `tokenize`
// port left out its tests.
//
// First port to exercise the expression/statement grammar tier end to end: a
// `switch` with a value list (`'\r', '\n'`), an inclusive range
// (`0x20...0x7e`), an inline `if`-expression arm, and `label: { … }` block arms
// whose value is a `break :blk …`; a typed union literal (`Action{ .echo = byte }`);
// `[*]mut volatile u8` for the alignment-safe byte copy; struct field default values
// (`len usize = 0`, `bytes [HIST_LINE_CAP]u8 = undefined`, `stash HistSlot = .{}`);
// struct methods over `*State` / `*History`; and optional returns (`?[]u8`).
// Doc comments are carried through verbatim. The `//` rationale comments are
// Flash-source notes (ordinary comments are not re-emitted); the core lowers to
// Zig whose token stream matches the reference, modulo Flash's canonical layout
// (mandatory braces, expanded containers).

/// 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.zig 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 {
    n := #min(dst.len, src.len)
    const d [*]mut volatile u8 = #ptrCast(dst.ptr)
    for i in 0..n {
        d[i] = src[i]
    }
    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
        for i in self.pos..self.len {
            v[i - 1] = v[i]
        }
        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) {
        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 {
        m := self.slots.len
        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) {
        self.nav = 0
        if self.slots.len == 0 || line.len == 0 {
            return
        }
        if self.count > 0 && eql(self.entry(1), line) {
            return
        }
        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) {
        self.nav = 0
    }

    fn eql(a []u8, b []u8) bool {
        if a.len != b.len {
            return false
        }
        for i in 0..a.len {
            if a[i] != b[i] {
                return false
            }
        }
        return true
    }
}