const std = @import("std"); // 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, insert: []const u8, insert_pair: Pair, delete_backward, }; pub const Snapshot = struct { bytes: []const u8, cursor_byte: usize, cursor_cell: usize, selection: ?Selection, }; 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, }; } pub fn dispatch(self: *Buffer, command: Command) !void { switch (command) { .move_left => self.moveLeft(), .move_right => self.moveRight(), .insert => |text| try self.insert(text), .insert_pair => |pair| try self.insertPair(pair), .delete_backward => self.deleteBackward(), } } 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 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; } 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, pub fn init(allocator: std.mem.Allocator) Session { return .{ .allocator = allocator }; } pub fn deinit(self: *Session) void { if (self.buffer) |*buffer| buffer.deinit(); self.* = undefined; } pub fn openFixture(self: *Session, fixture: []const u8) !void { if (self.buffer) |*buffer| buffer.deinit(); self.buffer = try Buffer.openFromBytes(self.allocator, fixture); } pub fn dispatch(self: *Session, command: Command) !void { if (self.buffer) |*buffer| return buffer.dispatch(command); return error.NoBufferOpen; } pub fn snapshot(self: *const Session) !Snapshot { if (self.buffer) |*buffer| return buffer.snapshot(); return error.NoBufferOpen; } }; 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: 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" { var session = Session.init(std.testing.allocator); defer session.deinit(); try std.testing.expectError(error.NoBufferOpen, session.dispatch(.move_right)); try std.testing.expectError(error.NoBufferOpen, session.snapshot()); } 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); }