diff --git a/src/session.zig b/src/session.zig index 919c80c..c9fa555 100644 --- a/src/session.zig +++ b/src/session.zig @@ -370,6 +370,44 @@ pub const Session = struct { .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 { diff --git a/src/tui.zig b/src/tui.zig index c4f92a5..7331382 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -45,6 +45,16 @@ const EditSnapshot = struct { cursor_byte: usize, }; +const ObjectRange = struct { + start: usize, + end: usize, +}; + +const PairRange = struct { + open: usize, + close: usize, +}; + pub const Error = error{ ViewportTooSmall, InvalidResize, @@ -845,6 +855,13 @@ pub const Client = struct { } if (std.mem.eql(u8, text, "m")) return self.openPrefix(.match); if (std.mem.eql(u8, text, "g")) return self.openPrefix(.go); + if (text.len == 1) { + if (self.objectRangeForKey(text[0])) |range| { + try self.session.selectRange(range.start, range.end); + self.message = "selected object"; + return; + } else |_| {} + } self.unknownPrefixOrInput("select"); }, .key => |key| switch (key) { @@ -887,7 +904,9 @@ pub const Client = struct { switch (active) { .none => {}, .insert_space => try self.applyInsertSpaceRail(event), - .match => self.applyKnownRailOrMessage(event, "match rail ready"), + .match => self.applyMatchRail(event) catch |err| { + self.message = @errorName(err); + }, .go => self.applyKnownRailOrMessage(event, "go rail ready"), .repeat => self.applyKnownRailOrMessage(event, "repeat rail ready"), .delete => try self.applyDeleteRail(event), @@ -954,6 +973,33 @@ pub const Client = struct { self.message = null; } + fn applyMatchRail(self: *Client, event: input.Event) !void { + const text = eventText(event) orelse return self.unknownPrefixOrInput("normal"); + if (std.mem.eql(u8, text, "m")) return self.jumpToMatch(null); + if (std.mem.eql(u8, text, "s")) { + const range = try self.matchRange(null, false); + try self.session.selectRange(range.start, range.end); + self.message = "selected inside pair"; + return; + } + if (std.mem.eql(u8, text, "a")) { + const range = try self.matchRange(null, true); + try self.session.selectRange(range.start, range.end); + self.message = "selected around pair"; + return; + } + if (text.len == 1 and isPairSelector(text[0])) { + if (self.effectiveMode() == .select) { + const range = try self.matchRange(text[0], true); + try self.session.selectRange(range.start, range.end); + } else { + try self.jumpToMatch(text[0]); + } + return; + } + self.unknownPrefixOrInput("normal"); + } + fn applyDeleteRail(self: *Client, event: input.Event) !void { const text = eventText(event) orelse return self.unknownPrefixOrInput("normal"); if (std.mem.eql(u8, text, "d")) { @@ -963,6 +1009,9 @@ pub const Client = struct { } if (std.mem.eql(u8, text, "h")) return self.applyMutatingProtocol("command delete_backward"); if (std.mem.eql(u8, text, "l")) return self.applyMutatingProtocol("command delete_forward"); + if (text.len == 1) { + if (self.objectRangeForKey(text[0])) |range| return self.deleteRange(range) else |_| {} + } self.unknownPrefixOrInput("normal"); } @@ -987,6 +1036,12 @@ pub const Client = struct { self.message = "insert"; return; } + if (text.len == 1) { + if (self.objectRangeForKey(text[0])) |range| { + try self.changeRange(range); + return; + } else |_| {} + } self.unknownPrefixOrInput("normal"); } @@ -997,6 +1052,13 @@ pub const Client = struct { self.message = "yanked line"; return; } + if (text.len == 1) { + if (self.objectRangeForKey(text[0])) |range| { + try self.yankRange(range); + self.message = "yanked object"; + return; + } else |_| {} + } self.unknownPrefixOrInput("normal"); } @@ -1018,6 +1080,36 @@ pub const Client = struct { }; } + fn deleteRange(self: *Client, range: ObjectRange) !void { + try self.yankRange(range); + try self.recordUndo(); + self.clearRedo(); + self.session.replaceRange(range.start, range.end, "") catch |err| { + self.dropLastUndoSnapshot(); + return err; + }; + } + + fn changeRange(self: *Client, range: ObjectRange) !void { + try self.yankRange(range); + try self.recordUndo(); + self.clearRedo(); + self.session.replaceRange(range.start, range.end, "") catch |err| { + self.dropLastUndoSnapshot(); + return err; + }; + self.mode = .insert; + self.message = "insert"; + } + + fn yankRange(self: *Client, range: ObjectRange) !void { + const snap = try self.session.snapshot(); + if (range.start > range.end or range.end > snap.bytes.len) return error.SelectionOutOfBounds; + const copy = try self.allocator.dupe(u8, snap.bytes[range.start..range.end]); + if (self.yank_bytes) |old| self.allocator.free(old); + self.yank_bytes = copy; + } + fn pasteRegister(self: *Client) !void { const bytes = self.yank_bytes orelse { self.message = "nothing yanked"; @@ -1072,6 +1164,102 @@ pub const Client = struct { return .{ .bytes = try self.allocator.dupe(u8, snap.bytes), .cursor_byte = snap.cursor_byte }; } + fn objectRangeForKey(self: *Client, key: u8) !ObjectRange { + return switch (key) { + 'w' => try self.wordRange(), + 'l' => try self.lineRange(true), + 'i' => try self.indentRange(), + 'p' => try self.parameterRange(), + 'f' => try self.enclosingFormRange(), + 'd' => try self.lineRange(false), + else => error.UnsupportedObject, + }; + } + + fn wordRange(self: *Client) !ObjectRange { + const snap = try self.session.snapshot(); + var start = snap.cursor_byte; + while (start > 0 and !isObjectSeparator(snap.bytes[previousBoundaryLocal(snap.bytes, start)])) start = previousBoundaryLocal(snap.bytes, start); + var end = snap.cursor_byte; + while (end < snap.bytes.len and !isObjectSeparator(snap.bytes[end])) end = nextBoundaryLocal(snap.bytes, end); + return .{ .start = start, .end = end }; + } + + fn lineRange(self: *Client, include_newline: bool) !ObjectRange { + const snap = try self.session.snapshot(); + var start = snap.cursor_byte; + while (start > 0 and snap.bytes[start - 1] != '\n') start -= 1; + var end = snap.cursor_byte; + while (end < snap.bytes.len and snap.bytes[end] != '\n') end += 1; + if (include_newline and end < snap.bytes.len) end += 1; + return .{ .start = start, .end = end }; + } + + fn indentRange(self: *Client) !ObjectRange { + const snap = try self.session.snapshot(); + const current = try self.lineRange(false); + const indent = lineIndent(snap.bytes[current.start..current.end]); + var start = current.start; + while (start > 0) { + const prev_end = start - 1; + var prev_start = prev_end; + while (prev_start > 0 and snap.bytes[prev_start - 1] != '\n') prev_start -= 1; + if (lineContentLen(snap.bytes[prev_start..prev_end]) != 0 and lineIndent(snap.bytes[prev_start..prev_end]) < indent) break; + start = prev_start; + } + var end = current.end; + while (end < snap.bytes.len) { + const next_start = if (end < snap.bytes.len and snap.bytes[end] == '\n') end + 1 else end; + if (next_start >= snap.bytes.len) break; + var next_end = next_start; + while (next_end < snap.bytes.len and snap.bytes[next_end] != '\n') next_end += 1; + if (lineContentLen(snap.bytes[next_start..next_end]) != 0 and lineIndent(snap.bytes[next_start..next_end]) < indent) break; + end = next_end; + } + return .{ .start = start, .end = end }; + } + + fn parameterRange(self: *Client) !ObjectRange { + const around = try self.matchRange('(', false); + const snap = try self.session.snapshot(); + var start = around.start; + var end = around.end; + var at = around.start; + while (at < around.end) : (at += 1) { + if (snap.bytes[at] == ',' and at < snap.cursor_byte) start = at + 1; + if (snap.bytes[at] == ',' and at >= snap.cursor_byte) { + end = at; + break; + } + } + return trimRange(snap.bytes, .{ .start = start, .end = end }); + } + + fn enclosingFormRange(self: *Client) !ObjectRange { + return self.matchRange('{', true) catch self.matchRange('(', true); + } + + fn jumpToMatch(self: *Client, selector: ?u8) !void { + const pair = try self.matchPair(selector); + const snap = try self.session.snapshot(); + const target = if (snap.cursor_byte <= pair.open) pair.close else pair.open; + try self.session.moveToByte(target); + self.message = "matched pair"; + } + + fn matchRange(self: *Client, selector: ?u8, around: bool) !ObjectRange { + const pair = try self.matchPair(selector); + return if (around) .{ .start = pair.open, .end = pair.close + 1 } else .{ .start = pair.open + 1, .end = pair.close }; + } + + fn matchPair(self: *Client, selector: ?u8) !PairRange { + const snap = try self.session.snapshot(); + if (snap.bytes.len == 0) return error.NoMatch; + if (selector) |sel| return findPairForSelector(snap.bytes, snap.cursor_byte, sel) orelse error.NoMatch; + if (findPairAtOrNear(snap.bytes, snap.cursor_byte)) |pair| return pair; + return error.NoMatch; + } + fn insertText(self: *Client, text: []const u8) !void { const line = try std.fmt.allocPrint(self.allocator, "insert {s}", .{text}); defer self.allocator.free(line); @@ -2604,7 +2792,7 @@ test "regular: modal rails expose match go repeat and unknown recovery" { try std.testing.expectEqualStrings("none", client.pendingRailName()); const unknown_frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(unknown_frame); - try std.testing.expect(std.mem.indexOf(u8, unknown_frame, "unknown prefix key") != null); + try std.testing.expect(std.mem.indexOf(u8, unknown_frame, "unknown normal key") != null); try client.handleInput("g"); try std.testing.expectEqualStrings("go", client.pendingRailName()); @@ -2749,3 +2937,236 @@ test "adversarial: replace rejects invalid utf8 and undo preserves content" { defer std.testing.allocator.free(after_undo); try std.testing.expectEqualStrings("safe", after_undo); } + +fn isObjectSeparator(byte: u8) bool { + return std.ascii.isWhitespace(byte) or std.mem.indexOfScalar(u8, "(){}[]<>.,;:+-*/=\"'`", byte) != null; +} + +fn previousBoundaryLocal(bytes: []const u8, cursor: usize) usize { + return session_mod.boundaryAtOrBefore(bytes, if (cursor == 0) 0 else cursor - 1); +} + +fn nextBoundaryLocal(bytes: []const u8, cursor: usize) usize { + var at = cursor + 1; + while (at < bytes.len and (bytes[at] & 0b1100_0000) == 0b1000_0000) at += 1; + return @min(at, bytes.len); +} + +fn lineIndent(bytes: []const u8) usize { + var count: usize = 0; + while (count < bytes.len and (bytes[count] == ' ' or bytes[count] == '\t')) count += 1; + return count; +} + +fn lineContentLen(bytes: []const u8) usize { + for (bytes) |byte| if (!std.ascii.isWhitespace(byte)) return bytes.len; + return 0; +} + +fn trimRange(bytes: []const u8, range: ObjectRange) ObjectRange { + var start = range.start; + var end = range.end; + while (start < end and std.ascii.isWhitespace(bytes[start])) start += 1; + while (end > start and std.ascii.isWhitespace(bytes[end - 1])) end -= 1; + return .{ .start = start, .end = end }; +} + +fn isPairSelector(byte: u8) bool { + return std.mem.indexOfScalar(u8, "({[\"'`", byte) != null; +} + +fn pairChars(selector: u8) struct { open: u8, close: u8, quote: bool } { + return switch (selector) { + '(' => .{ .open = '(', .close = ')', .quote = false }, + '{' => .{ .open = '{', .close = '}', .quote = false }, + '[' => .{ .open = '[', .close = ']', .quote = false }, + '\"' => .{ .open = '\"', .close = '\"', .quote = true }, + '\'' => .{ .open = '\'', .close = '\'', .quote = true }, + '`' => .{ .open = '`', .close = '`', .quote = true }, + else => .{ .open = '(', .close = ')', .quote = false }, + }; +} + +fn findPairForSelector(bytes: []const u8, cursor: usize, selector: u8) ?PairRange { + const chars = pairChars(selector); + if (chars.quote) return findQuotePair(bytes, cursor, chars.open); + var depth: usize = 0; + var open: ?usize = null; + var i: usize = @min(cursor, bytes.len); + while (i > 0) { + i -= 1; + if (bytes[i] == chars.close) depth += 1 else if (bytes[i] == chars.open) { + if (depth == 0) { + open = i; + break; + } + depth -= 1; + } + } + const found_open = open orelse return null; + depth = 0; + i = found_open; + while (i < bytes.len) : (i += 1) { + if (bytes[i] == chars.open) depth += 1 else if (bytes[i] == chars.close) { + depth -= 1; + if (depth == 0) return .{ .open = found_open, .close = i }; + } + } + return null; +} + +fn findQuotePair(bytes: []const u8, cursor: usize, quote: u8) ?PairRange { + var open: ?usize = null; + var i: usize = 0; + while (i < bytes.len) : (i += 1) { + if (bytes[i] != quote or (i > 0 and bytes[i - 1] == '\\')) continue; + if (open == null) open = i else { + const start = open.?; + if (cursor >= start and cursor <= i) return .{ .open = start, .close = i }; + open = null; + } + } + return null; +} + +fn findPairAtOrNear(bytes: []const u8, cursor: usize) ?PairRange { + const selectors = "({[\"'`"; + if (cursor < bytes.len and isPairSelector(bytes[cursor])) { + if (findPairForSelector(bytes, cursor + 1, bytes[cursor])) |pair| return pair; + } + if (cursor > 0 and isPairSelector(bytes[cursor - 1])) { + if (findPairForSelector(bytes, cursor, bytes[cursor - 1])) |pair| return pair; + } + for (selectors) |selector| { + if (findPairForSelector(bytes, cursor, selector)) |pair| return pair; + } + return null; +} + +test "regular: match rail jumps and selects delimiter pairs" { + var client = try Client.init(std.testing.allocator, .{ .width = 60, .height = 10 }); + defer client.deinit(); + try client.handleTraceLine("open fn main() { call(one, two); }"); + try client.handleInput("w"); + try client.handleInput("w"); + try client.handleInput("w"); + try client.handleInput("m"); + try client.handleInput("("); + var snap = try client.session.snapshot(); + try std.testing.expect(snap.cursor_byte > 7); + + try client.handleInput("m"); + try client.handleInput("s"); + snap = try client.session.snapshot(); + try std.testing.expect(snap.selection != null); + try std.testing.expectEqualStrings("one, two", snap.bytes[snap.selection.?.anchor..snap.selection.?.cursor]); + + try client.handleInput("m"); + try client.handleInput("a"); + snap = try client.session.snapshot(); + try std.testing.expectEqualStrings("(one, two)", snap.bytes[snap.selection.?.anchor..snap.selection.?.cursor]); +} + +test "regular: select mode object grammar selects word line indent parameter and form" { + var client = try Client.init(std.testing.allocator, .{ .width = 80, .height = 12 }); + defer client.deinit(); + try client.handleTraceLine("open fn main() {\n alpha(beta, gamma);\n next();\n}\n"); + try client.handleInput("j"); + try client.handleInput("w"); + try client.handleInput("s"); + try client.handleInput("w"); + var snap = try client.session.snapshot(); + try std.testing.expectEqualStrings("alpha", snap.bytes[snap.selection.?.anchor..snap.selection.?.cursor]); + + try client.handleInput("n"); + try client.handleInput("s"); + try client.handleInput("l"); + snap = try client.session.snapshot(); + try std.testing.expectEqualStrings(" alpha(beta, gamma);\n", snap.bytes[snap.selection.?.anchor..snap.selection.?.cursor]); + + try client.handleInput("n"); + try client.handleInput("s"); + try client.handleInput("i"); + snap = try client.session.snapshot(); + try std.testing.expectEqualStrings(" alpha(beta, gamma);\n next();", snap.bytes[snap.selection.?.anchor..snap.selection.?.cursor]); + + var param_client = try Client.init(std.testing.allocator, .{ .width = 80, .height = 12 }); + defer param_client.deinit(); + try param_client.handleTraceLine("open call(beta, gamma)"); + try param_client.handleTraceLine("right"); + try param_client.handleTraceLine("right"); + try param_client.handleTraceLine("right"); + try param_client.handleTraceLine("right"); + try param_client.handleTraceLine("right"); + try param_client.handleTraceLine("right"); + try param_client.handleInput("s"); + try param_client.handleInput("p"); + var param_snap = try param_client.session.snapshot(); + try std.testing.expectEqualStrings("beta", param_snap.bytes[param_snap.selection.?.anchor..param_snap.selection.?.cursor]); + + var form_client = try Client.init(std.testing.allocator, .{ .width = 80, .height = 12 }); + defer form_client.deinit(); + try form_client.handleTraceLine("open { alpha(); }"); + try form_client.handleTraceLine("right"); + try form_client.handleTraceLine("right"); + try form_client.handleInput("s"); + try form_client.handleInput("f"); + var form_snap = try form_client.session.snapshot(); + try std.testing.expect(std.mem.startsWith(u8, form_snap.bytes[form_snap.selection.?.anchor..form_snap.selection.?.cursor], "{")); +} + +test "regular: delete change yank compose with shared object ranges" { + var client = try Client.init(std.testing.allocator, .{ .width = 80, .height = 10 }); + defer client.deinit(); + try client.handleTraceLine("open alpha beta\n one\n two\nend"); + + try client.handleInput("d"); + try client.handleInput("w"); + const after_delete_word = try client.snapshotBytesAlloc(std.testing.allocator); + defer std.testing.allocator.free(after_delete_word); + try std.testing.expectEqualStrings(" beta\n one\n two\nend", after_delete_word); + + try client.handleInput("p"); + const pasted = try client.snapshotBytesAlloc(std.testing.allocator); + defer std.testing.allocator.free(pasted); + try std.testing.expectEqualStrings("alpha beta\n one\n two\nend", pasted); + + try client.handleInput("j"); + try client.handleInput("c"); + try client.handleInput("i"); + try client.handleInput("X"); + try client.handleInput(" "); + try client.handleInput("n"); + const changed = try client.snapshotBytesAlloc(std.testing.allocator); + defer std.testing.allocator.free(changed); + try std.testing.expectEqualStrings("alpha beta\nX\nend", changed); +} + +test "adversarial: unmatched delimiter reports without corrupting buffer" { + var client = try Client.init(std.testing.allocator, .{ .width = 60, .height = 8 }); + defer client.deinit(); + try client.handleTraceLine("open call(one"); + try client.handleInput("m"); + try client.handleInput("("); + const bytes = try client.snapshotBytesAlloc(std.testing.allocator); + defer std.testing.allocator.free(bytes); + try std.testing.expectEqualStrings("call(one", bytes); + const frame = try client.render(std.testing.allocator); + defer std.testing.allocator.free(frame); + try std.testing.expect(std.mem.indexOf(u8, frame, "unknown normal key") != null or std.mem.indexOf(u8, frame, "NoMatch") != null); +} + +test "regular: quote matching ignores escaped quote" { + var client = try Client.init(std.testing.allocator, .{ .width = 80, .height = 8 }); + defer client.deinit(); + try client.handleTraceLine("open say(\"a\\\"b\")"); + try client.handleTraceLine("right"); + try client.handleTraceLine("right"); + try client.handleTraceLine("right"); + try client.handleTraceLine("right"); + try client.handleTraceLine("right"); + try client.handleInput("m"); + try client.handleInput("\""); + const snap = try client.session.snapshot(); + try std.testing.expectEqual(@as(usize, 4), snap.cursor_byte); +}