diff --git a/src/main.zig b/src/main.zig index 11f68ca..8b8caf6 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const session = @import("session.zig"); pub const version = "0.1.0-dev"; @@ -62,6 +63,10 @@ pub fn main(init: std.process.Init) !u8 { return 0; } +test { + _ = session; +} + test "regular: help text names the binary and smoke boundary" { try std.testing.expect(std.mem.indexOf(u8, help_text, "mim - mobile-first terminal code editor") != null); try std.testing.expect(std.mem.indexOf(u8, help_text, "canonical smoke path") != null); diff --git a/src/session.zig b/src/session.zig new file mode 100644 index 0000000..8ff9f1a --- /dev/null +++ b/src/session.zig @@ -0,0 +1,267 @@ +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 Command = union(enum) { + move_left, + move_right, + insert: []const u8, + 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), + .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 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()); +}