Add headless session and UTF-8 buffer core

This commit is contained in:
slhx agent
2026-06-21 01:44:11 +02:00
parent 760afbf471
commit 108c0533a8
2 changed files with 272 additions and 0 deletions
+5
View File
@@ -1,4 +1,5 @@
const std = @import("std"); const std = @import("std");
const session = @import("session.zig");
pub const version = "0.1.0-dev"; pub const version = "0.1.0-dev";
@@ -62,6 +63,10 @@ pub fn main(init: std.process.Init) !u8 {
return 0; return 0;
} }
test {
_ = session;
}
test "regular: help text names the binary and smoke boundary" { 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, "mim - mobile-first terminal code editor") != null);
try std.testing.expect(std.mem.indexOf(u8, help_text, "canonical smoke path") != null); try std.testing.expect(std.mem.indexOf(u8, help_text, "canonical smoke path") != null);
+267
View File
@@ -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());
}