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)
}