Add headless session and UTF-8 buffer core
This commit is contained in:
@@ -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);
|
||||
|
||||
+267
@@ -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());
|
||||
}
|
||||
Reference in New Issue
Block a user