ajhahn.de
← FlashOS
Flash 560 lines
// edit — the full-screen text editor for /bin/edit.
//
// The second interactive consumer of the navigation scaffold (after /bin/less)
// and the first writer: where less proved read-only paging over the pure
// pager core, edit proves mutation over the pure gap-buffer core. It slurps a
// file into a heap-backed gap buffer, takes over the console with
// screen.enter(), turns keys from flibc.readKey()'s VT100 decoder into edits
// and motions through flibc.gapbuf, and writes the buffer back on ctrl-O. It is
// the first real consumer of the heap (brk/sbrk + flibc malloc); the kernel
// already provides everything, so this slot is pure userland.
//
// Architecture: the editing logic lives in three pure, host-tested cores —
// gapbuf.GapBuf (storage), gapbuf.LineIndex (lines + cursor motions),
// gapbuf.Viewport (scroll) — and grep_match.find (search). This file is the
// driver: argv, slurp, the screen dance, the render loop, and the save path.
// The interactive loop cannot run under QEMU (no PL011-RX stdin), so the cores'
// host tests are the correctness proof and the Pi is the only live witness.
//
// Cursor invariant: the gap buffer's gap_start always equals the logical cursor
// offset `cur`. Navigation moves the gap to the new cursor; an insert/delete
// acts at the gap and then re-reads cur from the buffer. So byteAt() during
// render and linearize() at save both see the text in logical order regardless.
//
// Save = unlink + create + write (not in-place): the FAT32 backend's write only
// *grows* file_size — it has no truncate — so overwriting a file that got
// shorter would leave a stale tail at the wrong size. Recreating the file gives
// the correct fresh size every time, at the cost of a small unlink<->write crash
// window (single-user hobby OS; atomic temp+rename is future work).
//
// Current limits (deferred): one logical line == one screen row
// (horizontal scroll, no soft-wrap); no undo; tabs render as a single space and
// other non-printables as '?' (display only — save preserves the raw bytes);
// fixed 24x80 geometry; line index capped at MAX_LINES. Kept out of the CI
// FSH_SCRIPT — interactive, so the boot baseline stays deterministic.

use flibc
use console_ui
use gapbuf
use grep_match

link "flibc_start"
link "flibc_mem"

// Assumed serial-terminal geometry (no window-size ioctl exists). Row 1 is the
// header, row ROWS the status/prompt line, the rest content.
const ROWS usize = 24
const HEADER usize = 1
const STATUS usize = 1
const CONTENT usize = ROWS - HEADER - STATUS // visible content rows (22)
const COLS usize = 80

const MAX_LINES usize = 4096 // line-index slots (on the stack)
const SLURP usize = 4096 // file read chunk
const INITIAL_CAP usize = 64 * 1024 // first gap-buffer block (no fstat to size it)
const MAX_CAP usize = 4 * 1024 * 1024 // refuse to grow past this — clean stop, no OOM zombie

fn sink(bytes []u8) void {
    _ = flibc.sys.write_fd(1, bytes.ptr, bytes.len)
}

// Editor state. The gap buffer's storage is on the heap (malloc, grown on a
// full gap); the line index slots are caller-owned on main's stack.
const Ed = struct {
    gb gapbuf.GapBuf,
    li gapbuf.LineIndex,
    vp gapbuf.Viewport,
    cur usize, // logical cursor offset (== gb.gap_start, by invariant)
    cap usize, // current storage capacity in bytes
    dirty bool,
    is_new bool, // file did not exist at open — skip the unlink on save
    path [*:0]u8,
}

export fn main(argc usize, argv argv) noreturn {
    if argc < 2 || argv[1] == null {
        sink("usage: edit <file>\n")
        flibc.exit()
    }
    path := argv[1].?

    // Allocate the initial gap-buffer block on the heap (the first heap user).
    const cap usize = INITIAL_CAP
    const store_ptr = flibc.malloc(cap)
    if store_ptr == null {
        sink("edit: out of memory\n")
        flibc.exit()
    }
    const store = store_ptr.?[0..cap]

    var slots [MAX_LINES]u32 = undefined
    var ed Ed = .{
        .gb = gapbuf.GapBuf.init(store),
        .li = .{ .lines = slots[0..], .n = 0, .total = 0 },
        .vp = .{ .rows = CONTENT, .cols = COLS },
        .cur = 0,
        .cap = cap,
        .dirty = false,
        .is_new = false,
        .path = path,
    }

    // Slurp the file into the buffer (the fd is not held past the read). A
    // non-existent file opens as an empty [New File] buffer.
    slurp(&ed)

    // Cursor home at the top; index the lines.
    ed.cur = 0
    ed.gb.moveGap(0)
    ed.li.rebuild(ed.gb)

    // Take over the console: raw mode (echo off) + alternate screen, and show
    // the cursor (screen.enter hides it — a pager wants that, an editor does not).
    _ = flibc.sys.set_console_mode(0)
    console_ui.screen.enter(sink)
    sink("\x1b[?25h")
    render(&ed)

    loop(&ed)

    // Every exit path restores the shell view. Mode stays 0 (the shell's own
    // baseline; fsh re-asserts it after wait() as a backstop), matching less.
    console_ui.screen.leave(sink)
    flibc.exit()
}

// The key loop. Returns when the user exits (ctrl-X, or ctrl-C/EOF).
fn loop(ed *mut Ed) void {
    while true {
        ev := flibc.readKey()
        switch ev.key {
            .eof, .ctrl_c => return,
            .ctrl_x => {
                if exitConfirmed(ed) {
                    return
                }
            },
            .ctrl_o => {
                _ = save(ed)
            },
            .ctrl_w => search(ed),
            .up => moveTo(ed, ed.li.moveUp(ed.cur)),
            .down => moveTo(ed, ed.li.moveDown(ed.cur)),
            .left => moveTo(ed, gapbuf.moveLeft(ed.cur)),
            .right => moveTo(ed, gapbuf.moveRight(ed.cur, ed.gb.len())),
            .home => moveTo(ed, ed.li.home(ed.cur)),
            .end => moveTo(ed, ed.li.end(ed.cur)),
            .page_up => pageMove(ed, true),
            .page_down => pageMove(ed, false),
            .enter => {
                _ = insertByte(ed, '\n')
            },
            .backspace => deleteBack(ed),
            .delete => deleteFwd(ed),
            .char => {
                _ = insertByte(ed, ev.ch)
            },
            else => {}, // tab / escape / none — ignored for now
        }
        render(ed)
    }
}

// ---- mutation (keeps the gap_start == cur invariant, re-indexes lines) ------

// Grow the storage by doubling, preserving content + cursor. False if the cap
// is hit or the heap rejects the allocation (a clean stop, not a crash).
fn grow(ed *mut Ed) bool {
    const newcap = ed.cap * 2
    if newcap > MAX_CAP {
        return false
    }
    if flibc.malloc(newcap) |raw| {
        ed.gb.growInto(raw[0..newcap])
        ed.cap = newcap
        return true
    }
    return false
}

fn insertByte(ed *mut Ed, b u8) bool {
    if ed.gb.gapLen() == 0 {
        if !grow(ed) {
            return false
        }
    }
    _ = ed.gb.insert(b)
    ed.cur = ed.gb.cursor()
    ed.dirty = true
    ed.li.rebuild(ed.gb)
    return true
}

fn deleteBack(ed *mut Ed) void {
    if ed.gb.deleteBack() {
        ed.cur = ed.gb.cursor()
        ed.dirty = true
        ed.li.rebuild(ed.gb)
    }
}

fn deleteFwd(ed *mut Ed) void {
    if ed.gb.deleteFwd() {
        // cursor (gap_start) unchanged by a forward delete
        ed.dirty = true
        ed.li.rebuild(ed.gb)
    }
}

// ---- navigation ------------------------------------------------------------

fn moveTo(ed *mut Ed, to usize) void {
    ed.cur = to
    ed.gb.moveGap(to)
}

// Page up/down by a content window of lines, one moveUp/moveDown step at a time
// so the column-clamp logic stays in the line index.
fn pageMove(ed *mut Ed, up bool) void {
    var i usize = 0
    var pos = ed.cur
    while i < CONTENT {
        pos = if (up) ed.li.moveUp(pos) else ed.li.moveDown(pos)
        i += 1
    }
    moveTo(ed, pos)
}

// ---- save (unlink + create + write — shrink correctness) -------------------

// Write the buffer back to its path. Returns false on any failure (reported on
// the status line by the caller's next render). Empty buffers create an empty
// file. The old file is unlinked first so the recreated file always carries the
// correct, possibly smaller, size.
fn save(ed *mut Ed) bool {
    const total = ed.gb.len()

    if !ed.is_new {
        _ = flibc.sys.unlink(ed.path)
    }
    const fd = flibc.sys.create(ed.path)
    if fd < 0 {
        return false
    }

    var ok bool = true
    if total > 0 {
        // Linearize into a fresh heap block (page-aligned, so the copy is
        // alignment-safe) and stream it to the file. The block is abandoned
        // (free() is a no-op) — saves are infrequent and reaped on exit.
        if flibc.malloc(total) |raw| {
            const buf = raw[0..total]
            _ = ed.gb.linearize(buf)
            var off usize = 0
            while off < total {
                const w = flibc.sys.write_fd(fd, buf[off..].ptr, total - off)
                if w <= 0 {
                    ok = false
                    break
                }
                off += #intCast(w)
            }
        } else {
            ok = false
        }
    }

    _ = flibc.sys.close(fd)
    if ok {
        ed.is_new = false
        ed.dirty = false
    }
    return ok
}

// ctrl-X: confirm a save if the buffer is dirty. Returns true when the editor
// should exit (saved, or discarded), false to keep editing (cancelled).
fn exitConfirmed(ed *mut Ed) bool {
    if !ed.dirty {
        return true
    }
    const c = confirm(" save modified buffer?  y = save   n = discard   esc = cancel")
    if c == 'y' {
        return save(ed)
    }
    if c == 'n' {
        return true
    }
    return false
}

// ---- search (ctrl-W — reuses grep_match.find over a linearized snapshot) ----

fn search(ed *mut Ed) void {
    var pbuf [COLS]u8 = undefined
    if promptLine(" search: ", pbuf[0..]) |plen| {
        if plen == 0 {
            return
        }
        const total = ed.gb.len()
        if total == 0 {
            return
        }
        if flibc.malloc(total) |raw| {
            const snap = raw[0..total]
            _ = ed.gb.linearize(snap)
            const needle = pbuf[0..plen]
            // Search forward from just past the cursor; wrap to the top if the
            // tail has no hit, so a repeated ctrl-W cycles through all matches.
            var hit = grep_match.find(snap, needle, ed.cur + 1)
            if hit == null {
                hit = grep_match.find(snap, needle, 0)
            }
            if hit |at| {
                moveTo(ed, at)
            }
        }
    }
}

// ---- prompts (status-line input) -------------------------------------------

// Edit a short string on the status row. Returns its length, or null if the
// user cancelled (escape / ctrl-C). Used for the search pattern.
fn promptLine(label []u8, buf []mut u8) ?usize {
    var len usize = 0
    while true {
        console_ui.screen.moveTo(sink, #intCast(ROWS), 1)
        sink("\x1b[2K") // erase the status line
        sink(label)
        sink(buf[0..len])
        const ev = flibc.readKey()
        switch ev.key {
            .enter => return len,
            .escape, .ctrl_c => return null,
            .backspace => {
                if len > 0 {
                    len -= 1
                }
            },
            .char => {
                if len < buf.len {
                    buf[len] = ev.ch
                    len += 1
                }
            },
            else => {},
        }
    }
}

// Draw a one-line prompt and read a single decision key. Returns 'y', 'n', or 0
// (cancel: escape / ctrl-C / anything else).
fn confirm(msg []u8) u8 {
    console_ui.screen.moveTo(sink, #intCast(ROWS), 1)
    sink("\x1b[2K")
    sink(msg)
    const ev = flibc.readKey()
    if ev.key == .char {
        if ev.ch == 'y' || ev.ch == 'Y' {
            return 'y'
        }
        if ev.ch == 'n' || ev.ch == 'N' {
            return 'n'
        }
    }
    return 0
}

// ---- rendering -------------------------------------------------------------

// Repaint the whole screen and park the visible cursor at the edit position.
// scrollTo runs first so the content rows and the cursor share one viewport.
fn render(ed *mut Ed) void {
    const rc = ed.li.locate(ed.cur)
    ed.vp.scrollTo(rc.row, rc.col)

    console_ui.screen.clear(sink)
    renderHeader(ed)
    renderContent(ed)
    renderStatus(ed, rc)

    const trow u16 = #intCast(HEADER + ed.vp.screenRow(rc.row) + 1)
    const tcol u16 = #intCast(ed.vp.screenCol(rc.col) + 1)
    console_ui.screen.moveTo(sink, trow, tcol)
}

// Row 1: program, filename, and a modified / new-file marker, padded to COLS.
fn renderHeader(ed *Ed) void {
    var hb [COLS]u8 = undefined
    const dst [*]mut volatile u8 = hb[0..].ptr
    var i usize = 0
    i = put(dst, i, "edit: ")
    i = putc(dst, i, baseName(ed.path))
    if ed.is_new {
        i = put(dst, i, "  [New File]")
    } else if ed.dirty {
        i = put(dst, i, "  *")
    }
    while i < COLS {
        dst[i] = ' '
        i += 1
    }
    sink(hb[0..COLS])
    sink("\n")
}

// Rows 2..ROWS-1: the visible content window, each logical line clipped to the
// horizontal viewport and to COLS, non-printables substituted. '~' past EOF.
fn renderContent(ed *Ed) void {
    var rb [COLS]u8 = undefined
    var row usize = 0
    while row < CONTENT {
        const idx = ed.vp.top + row
        if idx < ed.li.n {
            sink(rb[0..buildRow(ed, idx, rb[0..])])
        } else {
            sink("~")
        }
        sink("\n")
        row += 1
    }
}

// Fill `buf` with line `idx`, starting at the viewport's left column, clipped to
// COLS, each byte mapped to one display cell. Returns the count written.
fn buildRow(ed *Ed, idx usize, buf []mut u8) usize {
    const dst [*]mut volatile u8 = buf.ptr
    const start = ed.li.lineStart(idx)
    const llen = ed.li.lineLen(idx)
    var w usize = 0
    var c = ed.vp.left
    while c < llen && w < COLS {
        dst[w] = displayByte(ed.gb.byteAt(start + c))
        w += 1
        c += 1
    }
    return w
}

// Row ROWS: cursor position and the key legend. No trailing newline (the
// alt-screen must not scroll). Cleared by the next frame's screen.clear.
fn renderStatus(ed *Ed, rc gapbuf.RowCol) void {
    sink(" ")
    emitDec(rc.row + 1)
    sink(":")
    emitDec(rc.col + 1)
    if ed.dirty {
        sink(" [modified]")
    }
    sink("   ^O write  ^W find  ^X exit")
}

// '\t' -> single space, other non-printables -> '?', printables verbatim. Keeps
// every byte to one display cell so a column is a byte offset.
fn displayByte(b u8) u8 {
    if b == '\t' {
        return ' '
    }
    if b >= 0x20 && b < 0x7f {
        return b
    }
    return '?'
}

// ---- small helpers (mirror less.flash) -------------------------------------

// Copy a static string through the volatile dst (strict-align safe), returning
// the new write offset.
fn put(dst [*]mut volatile u8, at usize, s []u8) usize {
    var i = at
    for ch in s {
        if i >= COLS {
            break
        }
        dst[i] = ch
        i += 1
    }
    return i
}

// Like put, but for a runtime slice (the file's base name).
fn putc(dst [*]mut volatile u8, at usize, s []u8) usize {
    return put(dst, at, s)
}

// Last path component as a slice into the argv string (no copy).
fn baseName(path cstr) []u8 {
    var len usize = 0
    while path[len] != 0 {
        len += 1
    }
    var start usize = 0
    var i usize = 0
    while i < len {
        if path[i] == '/' {
            start = i + 1
        }
        i += 1
    }
    return path[start..len]
}

fn emitDec(v u64) void {
    var buf [20]u8 = undefined
    sink(buf[0..u64dec(buf[0..], v)])
}

fn u64dec(out []mut u8, v u64) usize {
    if v == 0 {
        out[0] = '0'
        return 1
    }
    var tmp [20]u8 = undefined
    var n usize = 0
    var x u64 = v
    while x != 0 {
        tmp[n] = '0' + #as(u8, #intCast(x % 10))
        n += 1
        x /= 10
    }
    var i usize = 0
    while i < n {
        out[i] = tmp[n - 1 - i]
        i += 1
    }
    return n
}

// ---- file slurp ------------------------------------------------------------

// Read the whole file into the gap buffer, growing on demand. fd is opened,
// drained, and closed here — never held across the edit loop. A file that
// cannot be opened leaves an empty buffer flagged [New File].
fn slurp(ed *mut Ed) void {
    const fd = flibc.sys.open(ed.path)
    if fd < 0 {
        ed.is_new = true
        return
    }
    var tmp [SLURP]u8 = undefined
    while true {
        const r = flibc.sys.read(fd, tmp[0..].ptr, SLURP)
        if r <= 0 {
            break
        }
        const got usize = #intCast(r)
        var fed usize = 0
        while fed < got {
            fed += ed.gb.insertSlice(tmp[fed..got])
            if fed < got {
                // Gap exhausted mid-chunk — grow and keep feeding.
                if !grow(ed) {
                    break // MAX_CAP hit: load what fit, the rest is dropped
                }
            }
        }
    }
    _ = flibc.sys.close(fd)
}