const std = @import("std"); const panel_mod = @import("panel.zig"); // Headless editor state for the protocol-first core. // req: coding/001, session/003 test { _ = Buffer; _ = Session; } pub const Cursor = struct { byte: usize = 0, cell: usize = 0, }; pub const Selection = struct { anchor: usize, cursor: usize, }; pub const Pair = struct { open: []const u8, close: []const u8, }; pub const Command = union(enum) { move_left, move_right, move_up, move_down, move_word_forward, move_word_back, move_word_end, insert: []const u8, insert_pair: Pair, delete_backward, delete_forward, delete_line, change_line, open_line_below, open_line_above, replace_char: []const u8, move_line_start, move_line_end, }; pub const Snapshot = struct { bytes: []const u8, cursor_byte: usize, cursor_cell: usize, selection: ?Selection, panel_depth: usize, active_panel_index: ?usize, active_panel_title: ?[]const u8, }; pub const Buffer = struct { allocator: std.mem.Allocator, bytes: std.ArrayList(u8), cursor: Cursor = .{}, selection: ?Selection = null, pub fn openFromBytes(allocator: std.mem.Allocator, fixture: []const u8) !Buffer { var bytes = std.ArrayList(u8).empty; try bytes.appendSlice(allocator, fixture); return .{ .allocator = allocator, .bytes = bytes }; } pub fn deinit(self: *Buffer) void { self.bytes.deinit(self.allocator); self.* = undefined; } pub fn snapshot(self: *const Buffer) Snapshot { return .{ .bytes = self.bytes.items, .cursor_byte = self.cursor.byte, .cursor_cell = self.cursor.cell, .selection = self.selection, .panel_depth = 0, .active_panel_index = null, .active_panel_title = null, }; } pub fn dispatch(self: *Buffer, command: Command) !void { switch (command) { .move_left => self.moveLeft(), .move_right => self.moveRight(), .move_up => self.moveUp(), .move_down => self.moveDown(), .move_word_forward => self.moveWordForward(), .move_word_back => self.moveWordBack(), .move_word_end => self.moveWordEnd(), .insert => |text| try self.insert(text), .insert_pair => |pair| try self.insertPair(pair), .delete_backward => self.deleteBackward(), .delete_forward => self.deleteForward(), .delete_line => self.deleteLine(), .change_line => try self.changeLine(), .open_line_below => try self.openLineBelow(), .open_line_above => try self.openLineAbove(), .replace_char => |text| try self.replaceChar(text), .move_line_start => self.moveLineStart(), .move_line_end => self.moveLineEnd(), } } pub fn moveLeft(self: *Buffer) void { self.cursor.byte = previousBoundary(self.bytes.items, self.cursor.byte); self.refreshCell(); } pub fn moveRight(self: *Buffer) void { self.cursor.byte = nextBoundary(self.bytes.items, self.cursor.byte); self.refreshCell(); } pub fn moveUp(self: *Buffer) void { const current = self.currentLineRange(false); if (current.start == 0) return; const previous_end = current.start - 1; var previous_start = previous_end; while (previous_start > 0 and self.bytes.items[previous_start - 1] != '\n') previous_start -= 1; self.cursor.byte = byteForCell(self.bytes.items, previous_start, previous_end, self.cursor.cell); self.refreshCell(); } pub fn moveDown(self: *Buffer) void { const current = self.currentLineRange(false); if (current.end >= self.bytes.items.len) return; const next_start = current.end + 1; if (next_start > self.bytes.items.len) return; var next_end = next_start; while (next_end < self.bytes.items.len and self.bytes.items[next_end] != '\n') next_end += 1; self.cursor.byte = byteForCell(self.bytes.items, next_start, next_end, self.cursor.cell); self.refreshCell(); } pub fn moveWordForward(self: *Buffer) void { var at = self.cursor.byte; while (at < self.bytes.items.len and !isWordSeparator(self.bytes.items[at])) at = nextBoundary(self.bytes.items, at); while (at < self.bytes.items.len and isWordSeparator(self.bytes.items[at])) at = nextBoundary(self.bytes.items, at); self.cursor.byte = at; self.refreshCell(); } pub fn moveWordBack(self: *Buffer) void { var at = self.cursor.byte; while (at > 0 and isWordSeparator(self.bytes.items[previousBoundary(self.bytes.items, at)])) at = previousBoundary(self.bytes.items, at); while (at > 0 and !isWordSeparator(self.bytes.items[previousBoundary(self.bytes.items, at)])) at = previousBoundary(self.bytes.items, at); self.cursor.byte = at; self.refreshCell(); } pub fn moveWordEnd(self: *Buffer) void { var at = self.cursor.byte; while (at < self.bytes.items.len and isWordSeparator(self.bytes.items[at])) at = nextBoundary(self.bytes.items, at); while (at < self.bytes.items.len) { const next = nextBoundary(self.bytes.items, at); if (next >= self.bytes.items.len or isWordSeparator(self.bytes.items[next])) break; at = next; } self.cursor.byte = at; self.refreshCell(); } pub fn insert(self: *Buffer, text: []const u8) !void { if (!std.unicode.utf8ValidateSlice(text)) return error.InvalidUtf8Insertion; try self.bytes.insertSlice(self.allocator, self.cursor.byte, text); self.cursor.byte += text.len; self.refreshCell(); self.selection = null; } pub fn insertPair(self: *Buffer, pair: Pair) !void { if (!std.unicode.utf8ValidateSlice(pair.open) or !std.unicode.utf8ValidateSlice(pair.close)) return error.InvalidUtf8Insertion; const combined = try std.mem.concat(self.allocator, u8, &.{ pair.open, pair.close }); defer self.allocator.free(combined); try self.bytes.insertSlice(self.allocator, self.cursor.byte, combined); self.cursor.byte += pair.open.len; self.refreshCell(); self.selection = null; } pub fn deleteBackward(self: *Buffer) void { if (self.cursor.byte == 0) return; const start = previousBoundary(self.bytes.items, self.cursor.byte); self.bytes.replaceRangeAssumeCapacity(start, self.cursor.byte - start, ""); self.cursor.byte = start; self.refreshCell(); self.selection = null; } pub fn deleteForward(self: *Buffer) void { if (self.cursor.byte >= self.bytes.items.len) return; const end = nextBoundary(self.bytes.items, self.cursor.byte); self.bytes.replaceRangeAssumeCapacity(self.cursor.byte, end - self.cursor.byte, ""); self.refreshCell(); self.selection = null; } pub fn replaceChar(self: *Buffer, text: []const u8) !void { if (!std.unicode.utf8ValidateSlice(text)) return error.InvalidUtf8Insertion; if (self.cursor.byte < self.bytes.items.len) self.deleteForward(); try self.insert(text); self.moveLeft(); } pub fn deleteLine(self: *Buffer) void { const range = self.currentLineRange(true); self.bytes.replaceRangeAssumeCapacity(range.start, range.end - range.start, ""); self.cursor.byte = @min(range.start, self.bytes.items.len); self.refreshCell(); self.selection = null; } pub fn changeLine(self: *Buffer) !void { const range = self.currentLineRange(false); self.bytes.replaceRangeAssumeCapacity(range.start, range.end - range.start, ""); self.cursor.byte = @min(range.start, self.bytes.items.len); self.refreshCell(); self.selection = null; } pub fn openLineBelow(self: *Buffer) !void { const range = self.currentLineRange(false); const has_line_break = range.end < self.bytes.items.len and self.bytes.items[range.end] == '\n'; const at = if (has_line_break) range.end + 1 else self.bytes.items.len; try self.bytes.insertSlice(self.allocator, at, "\n"); self.cursor.byte = if (has_line_break) at else at + 1; self.refreshCell(); self.selection = null; } pub fn openLineAbove(self: *Buffer) !void { const range = self.currentLineRange(false); try self.bytes.insertSlice(self.allocator, range.start, "\n"); self.cursor.byte = range.start; self.refreshCell(); self.selection = null; } pub fn moveLineStart(self: *Buffer) void { self.cursor.byte = self.currentLineRange(false).start; self.refreshCell(); } pub fn moveLineEnd(self: *Buffer) void { self.cursor.byte = self.currentLineRange(false).end; self.refreshCell(); } const LineRange = struct { start: usize, end: usize }; fn currentLineRange(self: *const Buffer, include_newline: bool) LineRange { var start = self.cursor.byte; while (start > 0 and self.bytes.items[start - 1] != '\n') start -= 1; var end = self.cursor.byte; while (end < self.bytes.items.len and self.bytes.items[end] != '\n') end += 1; if (include_newline and end < self.bytes.items.len and self.bytes.items[end] == '\n') end += 1; return .{ .start = start, .end = end }; } fn refreshCell(self: *Buffer) void { self.cursor.cell = cellWidth(self.bytes.items[0..self.cursor.byte]); } }; pub const Session = struct { allocator: std.mem.Allocator, buffer: ?Buffer = null, panels: panel_mod.Stack, pub fn init(allocator: std.mem.Allocator) Session { return .{ .allocator = allocator, .panels = panel_mod.Stack.init(allocator) }; } pub fn deinit(self: *Session) void { if (self.buffer) |*buffer| buffer.deinit(); self.panels.deinit(); self.* = undefined; } pub fn openFixture(self: *Session, fixture: []const u8) !void { try self.openFixtureAt(fixture, 0); } pub fn openFixtureAt(self: *Session, fixture: []const u8, cursor_byte: usize) !void { if (self.buffer) |*buffer| buffer.deinit(); var buffer = try Buffer.openFromBytes(self.allocator, fixture); buffer.cursor.byte = boundaryAtOrBefore(buffer.bytes.items, @min(cursor_byte, buffer.bytes.items.len)); buffer.refreshCell(); self.buffer = buffer; } pub fn dispatch(self: *Session, command: Command) !void { if (self.buffer) |*buffer| return buffer.dispatch(command); return error.NoBufferOpen; } pub fn openPanel(self: *Session, title: []const u8) !void { try self.panels.open(title); } pub fn openListPanel(self: *Session, title: []const u8, items: []const []const u8) !void { try self.panels.openList(title, items); } pub fn closePanel(self: *Session) !void { try self.panels.closeActive(); } pub fn nextPanel(self: *Session) !void { try self.panels.next(); } pub fn previousPanel(self: *Session) !void { try self.panels.previous(); } pub fn filterListPanel(self: *Session, filter: []const u8) !void { try self.panels.filterList(filter); } pub fn listPanelDown(self: *Session) !void { try self.panels.listDown(); } pub fn listPanelUp(self: *Session) !void { try self.panels.listUp(); } pub fn selectListPanel(self: *Session) ![]const u8 { return self.panels.selectList(); } pub fn activeListItem(self: *const Session) ![]const u8 { return self.panels.activeListItem(); } pub fn cancelListPanel(self: *Session) !void { try self.panels.cancelList(); } pub fn panelPathAlloc(self: *const Session, allocator: std.mem.Allocator) ![]u8 { return self.panels.pathAlloc(allocator); } pub fn panelSummaryAlloc(self: *const Session, allocator: std.mem.Allocator) ![]u8 { return self.panels.summaryAlloc(allocator); } pub fn activeListRowsAlloc(self: *const Session, allocator: std.mem.Allocator, max_rows: usize) ![][]u8 { return self.panels.activeListRowsAlloc(allocator, max_rows); } pub fn snapshot(self: *const Session) !Snapshot { const panel_state = self.panels.state(); if (self.buffer) |*buffer| { var snap = buffer.snapshot(); snap.panel_depth = panel_state.depth; snap.active_panel_index = panel_state.active_index; snap.active_panel_title = panel_state.active_title; return snap; } return .{ .bytes = "", .cursor_byte = 0, .cursor_cell = 0, .selection = null, .panel_depth = panel_state.depth, .active_panel_index = panel_state.active_index, .active_panel_title = panel_state.active_title, }; } pub fn moveToByte(self: *Session, byte: usize) !void { if (self.buffer) |*buffer| { if (byte > buffer.bytes.items.len) return error.SelectionOutOfBounds; buffer.cursor.byte = byte; buffer.refreshCell(); return; } return error.NoActiveBuffer; } pub fn selectRange(self: *Session, start: usize, end: usize) !void { if (self.buffer) |*buffer| { if (start > end or end > buffer.bytes.items.len) return error.SelectionOutOfBounds; buffer.selection = .{ .anchor = start, .cursor = end }; buffer.cursor.byte = end; buffer.refreshCell(); return; } return error.NoActiveBuffer; } pub fn clearSelection(self: *Session) void { if (self.buffer) |*buffer| buffer.selection = null; } pub fn replaceRange(self: *Session, start: usize, end: usize, bytes: []const u8) !void { if (!std.unicode.utf8ValidateSlice(bytes)) return error.InvalidUtf8Insertion; if (self.buffer) |*buffer| { if (start > end or end > buffer.bytes.items.len) return error.SelectionOutOfBounds; try buffer.bytes.replaceRange(buffer.allocator, start, end - start, bytes); buffer.cursor.byte = start + bytes.len; buffer.refreshCell(); buffer.selection = null; return; } return error.NoActiveBuffer; } }; fn isWordSeparator(byte: u8) bool { return std.ascii.isWhitespace(byte) or std.mem.indexOfScalar(u8, "(){}[]<>.,;:+-*/=\"'`", byte) != null; } fn byteForCell(bytes: []const u8, start: usize, end: usize, target_cell: usize) usize { var at = start; var best = start; while (at < end) { const rel = cellWidth(bytes[start..at]); if (rel > target_cell) break; best = at; at = nextBoundary(bytes, at); } if (cellWidth(bytes[start..@min(at, end)]) <= target_cell) return @min(at, end); return best; } pub fn boundaryAtOrBefore(bytes: []const u8, cursor: usize) usize { var i = @min(cursor, bytes.len); if (i == bytes.len) return i; while (i > 0 and isContinuation(bytes[i])) : (i -= 1) {} return i; } pub fn previousBoundary(bytes: []const u8, cursor: usize) usize { if (cursor == 0) return 0; var i = @min(cursor, bytes.len) - 1; while (i > 0 and isContinuation(bytes[i])) : (i -= 1) {} return i; } pub fn nextBoundary(bytes: []const u8, cursor: usize) usize { if (cursor >= bytes.len) return bytes.len; const len = std.unicode.utf8ByteSequenceLength(bytes[cursor]) catch 1; return @min(bytes.len, cursor + len); } pub fn cellWidth(bytes: []const u8) usize { var width: usize = 0; var i: usize = 0; while (i < bytes.len) { const len = std.unicode.utf8ByteSequenceLength(bytes[i]) catch { i += 1; width += 1; continue; }; if (i + len > bytes.len) { width += 1; break; } const cp = std.unicode.utf8Decode(bytes[i .. i + len]) catch { i += 1; width += 1; continue; }; width += codepointCellWidth(cp); i += len; } return width; } fn isContinuation(byte: u8) bool { return (byte & 0b1100_0000) == 0b1000_0000; } fn codepointCellWidth(cp: u21) usize { if (cp == 0) return 0; if (cp < 0x20 or (cp >= 0x7f and cp < 0xa0)) return 0; if (isCombining(cp)) return 0; if (isWide(cp)) return 2; return 1; } fn isCombining(cp: u21) bool { return (cp >= 0x0300 and cp <= 0x036f) or (cp >= 0x1ab0 and cp <= 0x1aff) or (cp >= 0x1dc0 and cp <= 0x1dff) or (cp >= 0x20d0 and cp <= 0x20ff) or (cp >= 0xfe20 and cp <= 0xfe2f); } fn isWide(cp: u21) bool { return (cp >= 0x1100 and cp <= 0x115f) or (cp >= 0x2329 and cp <= 0x232a) or (cp >= 0x2e80 and cp <= 0xa4cf) or (cp >= 0xac00 and cp <= 0xd7a3) or (cp >= 0xf900 and cp <= 0xfaff) or (cp >= 0xfe10 and cp <= 0xfe19) or (cp >= 0xfe30 and cp <= 0xfe6f) or (cp >= 0xff00 and cp <= 0xff60) or (cp >= 0xffe0 and cp <= 0xffe6) or (cp >= 0x1f300 and cp <= 0x1faff) or (cp >= 0x20000 and cp <= 0x3fffd); } test "regular: opening a fixture at a cursor byte keeps exact utf8 boundary" { var session = Session.init(std.testing.allocator); defer session.deinit(); try session.openFixtureAt("one\nabcdTARGET\n", 8); const snap = try session.snapshot(); try std.testing.expectEqual(@as(usize, 8), snap.cursor_byte); } test "regular: session opens fixture and dispatches multibyte edits" { var session = Session.init(std.testing.allocator); defer session.deinit(); try session.openFixture("let café = 1\n"); try session.dispatch(.move_right); try session.dispatch(.move_right); try session.dispatch(.move_right); try session.dispatch(.{ .insert = "🔥" }); const snap = try session.snapshot(); try std.testing.expectEqualStrings("let🔥 café = 1\n", snap.bytes); try std.testing.expectEqual(@as(usize, 7), snap.cursor_byte); try std.testing.expectEqual(@as(usize, 5), snap.cursor_cell); } test "regular: delete backward removes whole UTF-8 codepoint" { var buffer = try Buffer.openFromBytes(std.testing.allocator, "aéb"); defer buffer.deinit(); buffer.moveRight(); buffer.moveRight(); buffer.deleteBackward(); const snap = buffer.snapshot(); try std.testing.expectEqualStrings("ab", snap.bytes); try std.testing.expectEqual(@as(usize, 1), snap.cursor_byte); try std.testing.expectEqual(@as(usize, 1), snap.cursor_cell); } test "regular: cell width treats combining marks as zero and wide glyphs as two" { try std.testing.expectEqual(@as(usize, 1), cellWidth("e\u{0301}")); try std.testing.expectEqual(@as(usize, 2), cellWidth("界")); try std.testing.expectEqual(@as(usize, 2), cellWidth("🔥")); } test "adversarial: cursor movement never lands inside a multibyte codepoint" { var buffer = try Buffer.openFromBytes(std.testing.allocator, "aé🔥z"); defer buffer.deinit(); buffer.moveRight(); try std.testing.expectEqual(@as(usize, 1), buffer.cursor.byte); buffer.moveRight(); try std.testing.expectEqual(@as(usize, 3), buffer.cursor.byte); buffer.moveRight(); try std.testing.expectEqual(@as(usize, 7), buffer.cursor.byte); buffer.moveLeft(); try std.testing.expectEqual(@as(usize, 3), buffer.cursor.byte); } test "adversarial: invalid inserted UTF-8 is rejected and existing bytes are preserved" { var buffer = try Buffer.openFromBytes(std.testing.allocator, "safe"); defer buffer.deinit(); const bad = [_]u8{ 0xc3, 0x28 }; try std.testing.expectError(error.InvalidUtf8Insertion, buffer.insert(&bad)); try std.testing.expectEqualStrings("safe", buffer.snapshot().bytes); try std.testing.expectEqual(@as(usize, 0), buffer.snapshot().cursor_byte); } test "adversarial: dispatch requires an open buffer but state remains inspectable" { var session = Session.init(std.testing.allocator); defer session.deinit(); try std.testing.expectError(error.NoBufferOpen, session.dispatch(.move_right)); const snap = try session.snapshot(); try std.testing.expectEqual(@as(usize, 0), snap.bytes.len); try std.testing.expectEqual(@as(usize, 0), snap.panel_depth); } test "regular: pair insertion places cursor between delimiters" { var buffer = try Buffer.openFromBytes(std.testing.allocator, "call"); defer buffer.deinit(); buffer.moveRight(); buffer.moveRight(); buffer.moveRight(); buffer.moveRight(); try buffer.insertPair(.{ .open = "(", .close = ")" }); const snap = buffer.snapshot(); try std.testing.expectEqualStrings("call()", snap.bytes); try std.testing.expectEqual(@as(usize, 5), snap.cursor_byte); try std.testing.expectEqual(@as(usize, 5), snap.cursor_cell); } test "adversarial: invalid UTF-8 pair insertion is rejected and existing bytes are preserved" { var buffer = try Buffer.openFromBytes(std.testing.allocator, "safe"); defer buffer.deinit(); const bad = [_]u8{ 0xc3, 0x28 }; try std.testing.expectError(error.InvalidUtf8Insertion, buffer.insertPair(.{ .open = &bad, .close = ")" })); try std.testing.expectEqualStrings("safe", buffer.snapshot().bytes); try std.testing.expectEqual(@as(usize, 0), buffer.snapshot().cursor_byte); } test "regular: session opens closes and switches generic panel stack" { var session = Session.init(std.testing.allocator); defer session.deinit(); try session.openPanel("files"); try session.openPanel("diagnostics"); try session.previousPanel(); var snap = try session.snapshot(); try std.testing.expectEqual(@as(usize, 2), snap.panel_depth); try std.testing.expectEqualStrings("files", snap.active_panel_title.?); try session.closePanel(); snap = try session.snapshot(); try std.testing.expectEqual(@as(usize, 1), snap.panel_depth); try std.testing.expectEqualStrings("diagnostics", snap.active_panel_title.?); const path = try session.panelPathAlloc(std.testing.allocator); defer std.testing.allocator.free(path); try std.testing.expectEqualStrings("[diagnostics]", path); } test "adversarial: session panel operations fail clearly on empty or invalid state" { var session = Session.init(std.testing.allocator); defer session.deinit(); try std.testing.expectError(error.NoPanelOpen, session.closePanel()); try std.testing.expectError(error.NoPanelOpen, session.nextPanel()); try std.testing.expectError(error.InvalidPanelTitle, session.openPanel("bad title")); const snap = try session.snapshot(); try std.testing.expectEqual(@as(usize, 0), snap.panel_depth); } test "regular: buffer moves by line word and line edges" { var buffer = try Buffer.openFromBytes(std.testing.allocator, "one\nab cd\nxy"); defer buffer.deinit(); buffer.moveDown(); try std.testing.expectEqual(@as(usize, 4), buffer.cursor.byte); buffer.moveRight(); buffer.moveRight(); buffer.moveDown(); try std.testing.expectEqual(@as(usize, 12), buffer.cursor.byte); buffer.moveUp(); try std.testing.expectEqual(@as(usize, 9), buffer.cursor.byte); buffer.moveLineStart(); try std.testing.expectEqual(@as(usize, 4), buffer.cursor.byte); buffer.moveWordForward(); try std.testing.expectEqual(@as(usize, 7), buffer.cursor.byte); buffer.moveWordEnd(); try std.testing.expectEqual(@as(usize, 8), buffer.cursor.byte); buffer.moveWordBack(); try std.testing.expectEqual(@as(usize, 7), buffer.cursor.byte); buffer.moveLineEnd(); try std.testing.expectEqual(@as(usize, 9), buffer.cursor.byte); } test "regular: buffer line operations replace delete and open around utf8" { var buffer = try Buffer.openFromBytes(std.testing.allocator, "éx\nsecond"); defer buffer.deinit(); try buffer.replaceChar("A"); var snap = buffer.snapshot(); try std.testing.expectEqualStrings("Ax\nsecond", snap.bytes); try std.testing.expectEqual(@as(usize, 0), snap.cursor_byte); try buffer.openLineBelow(); try buffer.insert("below"); snap = buffer.snapshot(); try std.testing.expectEqualStrings("Ax\nbelow\nsecond", snap.bytes); buffer.moveLineStart(); try buffer.changeLine(); try buffer.insert("changed"); snap = buffer.snapshot(); try std.testing.expectEqualStrings("Ax\nchanged\nsecond", snap.bytes); buffer.deleteLine(); snap = buffer.snapshot(); try std.testing.expectEqualStrings("Ax\nsecond", snap.bytes); }