summaryrefslogtreecommitdiff
path: root/src/modal.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/modal.zig')
-rw-r--r--src/modal.zig1594
1 files changed, 820 insertions, 774 deletions
diff --git a/src/modal.zig b/src/modal.zig
index 11eae743..3827d11f 100644
--- a/src/modal.zig
+++ b/src/modal.zig
@@ -1,14 +1,654 @@
const std = @import("std");
const uucode = @import("uucode");
-// Modal-editing text math, kept free of vaxis/ghostty so it can be unit-tested
-// in isolation (see the `unit-test` build step). main.zig wires this onto the
-// pane's cursor + (for file panes) its content.
-//
-// The cursor sits ON a grapheme: col is its UTF-8 byte offset in
-// [0, line.len]; col == line.len means "on the line terminator / after the
-// last grapheme". Motions never leave a cursor in the middle of UTF-8 or an
-// extended grapheme cluster.
+pub const Normal = struct {
+ pub const Role = enum {
+ escape,
+
+ prefix_goto,
+ prefix_view,
+ prefix_match,
+ prefix_find_fwd,
+ prefix_find_back,
+ prefix_till_fwd,
+ prefix_till_back,
+ prefix_replace,
+ prefix_next,
+ prefix_prev,
+
+ goto_file_start,
+ goto_last_line,
+ goto_line_start,
+ goto_line_end,
+ goto_first_nonws,
+ goto_line_down,
+ goto_line_up,
+ goto_column,
+ goto_view_top,
+ goto_view_center,
+ goto_view_bottom,
+ goto_definition,
+ goto_declaration,
+ goto_type_definition,
+ goto_implementation,
+ goto_references,
+
+ view_top,
+ view_center,
+ view_bottom,
+ view_scroll_down,
+ view_scroll_up,
+
+ match_inside,
+ match_around,
+ surround_add,
+ surround_replace,
+ surround_delete,
+
+ goto_paragraph,
+ add_newline,
+ goto_diagnostic,
+ goto_diagnostic_end,
+
+ move_left,
+ move_right,
+ move_down,
+ move_up,
+ next_word_start,
+ prev_word_start,
+ next_word_end,
+ next_long_word_start,
+ prev_long_word_start,
+ next_long_word_end,
+ repeat_find,
+ line_start,
+ line_end,
+ line_first_nonws,
+ goto_line,
+ half_page_down,
+ half_page_up,
+ page_down,
+ page_up,
+
+ insert,
+ append,
+ insert_line_start,
+ insert_line_end,
+ open_below,
+ open_above,
+
+ select_mode,
+ select_line,
+ select_line_bounds,
+ shrink_to_line_bounds,
+ collapse_selection,
+ flip_selection,
+ select_all,
+ copy_sel_below,
+ copy_sel_above,
+ keep_primary_sel,
+ remove_primary_sel,
+ rotate_sel_fwd,
+ rotate_sel_back,
+ split_sel_newline,
+ merge_sels,
+ merge_consecutive_sels,
+ trim_sels,
+ select_regex,
+ split_regex,
+
+ delete,
+ delete_noyank,
+ change,
+ yank,
+ replace_with_yank,
+ paste_after,
+ paste_before,
+ switch_case,
+ to_lowercase,
+ to_uppercase,
+ join_lines,
+ indent,
+ unindent,
+ format,
+ increment,
+ decrement,
+ comment_toggle,
+ undo,
+ redo,
+
+ leader,
+ command_line,
+ pipe_selection,
+ pipe_selection_to,
+ insert_output,
+ append_output,
+ search,
+ search_next,
+ search_prev,
+ };
+
+ pub const Input = struct {
+ roles: std.EnumSet(Role) = .initEmpty(),
+ cp: u21,
+ ctrl: bool = false,
+ alt: bool = false,
+
+ pub fn has(value: Input, role: Role) bool {
+ return value.roles.contains(role);
+ }
+
+ fn literal(value: Input) ?u21 {
+ if (value.ctrl or value.alt or value.cp >= 0xF0000) return null;
+ return value.cp;
+ }
+ };
+
+ pub const Prefix = enum(u8) {
+ none,
+ goto,
+ view,
+ match,
+ find_fwd,
+ find_back,
+ till_fwd,
+ till_back,
+ replace,
+ next,
+ prev,
+ };
+
+ pub const MatchSub = enum(u8) {
+ none,
+ inside,
+ around,
+ surround_add,
+ surround_replace,
+ surround_delete,
+ };
+
+ pub const State = struct {
+ count: u32 = 0,
+ prefix: Prefix = .none,
+ match_sub: MatchSub = .none,
+ held_char: u21 = 0,
+
+ pub fn clear(state: *State) void {
+ state.* = .{};
+ }
+ };
+
+ pub const PipeBehavior = enum {
+ /// `|` — stdin is the selection, and the output REPLACES it.
+ replace,
+ /// `A-|` — stdin is the selection, and the output is discarded. The text
+ /// is not touched at all; the point is the command's side effect.
+ ignore,
+ /// `!` — no stdin, and the output is inserted BEFORE each selection.
+ insert,
+ /// `A-!` — no stdin, and the output is appended AFTER each selection.
+ append,
+
+ /// Do the selections become stdin? helix's `pipe` flag.
+ pub fn pipes(b: PipeBehavior) bool {
+ return b == .replace or b == .ignore;
+ }
+ };
+
+ pub const Scope = enum { once, per_selection };
+ pub const Direction = enum { backward, forward };
+ pub const Motion = enum {
+ left,
+ right,
+ down,
+ up,
+ next_word_start,
+ prev_word_start,
+ next_word_end,
+ next_long_word_start,
+ prev_long_word_start,
+ next_long_word_end,
+ };
+ pub const Goto = enum {
+ file_start,
+ last_line,
+ line_start,
+ line_end,
+ first_nonws,
+ line_down,
+ line_up,
+ column,
+ view_top,
+ view_center,
+ view_bottom,
+ };
+ pub const View = enum { top, center, bottom, scroll_down, scroll_up };
+ pub const Find = enum { forward, backward, till_forward, till_backward };
+ pub const Line = enum { start, end, first_nonws };
+ pub const Page = enum { half_down, half_up, down, up };
+ pub const Insert = enum { at, append, line_start, line_end, open_below, open_above };
+ pub const Select = enum {
+ mode,
+ line,
+ line_bounds,
+ shrink_to_line_bounds,
+ collapse,
+ flip,
+ all,
+ };
+ pub const Multi = enum {
+ copy_below,
+ copy_above,
+ keep_primary,
+ remove_primary,
+ rotate_forward,
+ rotate_backward,
+ split_newline,
+ merge,
+ merge_consecutive,
+ trim,
+ };
+ pub const Edit = enum {
+ delete,
+ delete_noyank,
+ change,
+ yank,
+ replace_with_yank,
+ paste_after,
+ paste_before,
+ switch_case,
+ lowercase,
+ uppercase,
+ join_lines,
+ indent,
+ unindent,
+ comment_toggle,
+ undo,
+ redo,
+ };
+ pub const Lsp = enum { definition, declaration, type_definition, implementation, references, format };
+
+ pub const Counted = struct {
+ count: u32,
+ explicit: bool,
+ };
+
+ pub const Action = union(enum) {
+ escape,
+ goto: struct { target: Goto, count: u32, explicit_count: bool },
+ view: View,
+ find: struct { kind: Find, char: u21, count: u32 },
+ replace_char: u21,
+ match_bracket,
+ textobject: struct { char: u21, around: bool },
+ surround_add: u21,
+ surround_delete: u21,
+ surround_replace: struct { from: u21, to: u21 },
+ paragraph: struct { direction: Direction, count: u32 },
+ add_newline: struct { direction: Direction, count: u32 },
+ diagnostic: struct { direction: Direction, endpoint: bool },
+ move: struct { motion: Motion, count: u32 },
+ repeat_find: u32,
+ line: Line,
+ goto_line: Counted,
+ page: struct { kind: Page, count: u32 },
+ insert: struct { kind: Insert, count: u32 },
+ select: struct { kind: Select, count: u32 },
+ multi: struct { kind: Multi, count: u32 },
+ select_regex: bool, // false = select, true = split
+ edit: struct { kind: Edit, count: u32 },
+ lsp: Lsp,
+ adjust_number: i64,
+ leader,
+ command_line,
+ pipe_selection: PipeBehavior,
+ search,
+ search_step: Direction,
+
+ pub fn scope(value: Action) Scope {
+ return switch (value) {
+ .escape,
+ .multi,
+ .select_regex,
+ .leader,
+ .command_line,
+ .pipe_selection,
+ .search,
+ .search_step,
+ => .once,
+ .edit => |edit| switch (edit.kind) {
+ .comment_toggle, .undo, .redo => .once,
+ else => .per_selection,
+ },
+ else => .per_selection,
+ };
+ }
+ };
+
+ pub const Result = union(enum) {
+ pending,
+ ignored,
+ unbound,
+ action: Action,
+ };
+
+ fn resultAction(value: Action) Result {
+ return .{ .action = value };
+ }
+
+ fn consumeCount(state: *State) Counted {
+ const count = state.count;
+ state.count = 0;
+ return .{ .count = @max(1, count), .explicit = count != 0 };
+ }
+
+ fn armPrefix(state: *State, prefix: Prefix, saved_count: u32) Result {
+ state.prefix = prefix;
+ state.count = saved_count;
+ if (prefix == .match) {
+ state.match_sub = .none;
+ state.held_char = 0;
+ }
+ return .pending;
+ }
+
+ pub fn parse(state: *State, key: Input) Result {
+ if (key.has(.escape)) {
+ state.clear();
+ return resultAction(.escape);
+ }
+
+ // A digit is a count only before a command/prefix. A leading zero keeps
+ // its configured line-start role; after another digit it extends count.
+ if (state.prefix == .none and !key.ctrl and !key.alt and
+ key.cp >= '0' and key.cp <= '9' and
+ !(key.cp == '0' and state.count == 0))
+ {
+ if (state.count < 0xffff)
+ state.count = state.count * 10 + key.cp - '0';
+ return .pending;
+ }
+
+ const counted = consumeCount(state);
+ const count = counted.count;
+
+ switch (state.prefix) {
+ .goto => {
+ state.prefix = .none;
+ if (key.has(.goto_file_start)) return resultAction(.{ .goto = .{ .target = .file_start, .count = count, .explicit_count = counted.explicit } });
+ if (key.has(.goto_last_line)) return resultAction(.{ .goto = .{ .target = .last_line, .count = count, .explicit_count = counted.explicit } });
+ if (key.has(.goto_line_start)) return resultAction(.{ .goto = .{ .target = .line_start, .count = count, .explicit_count = counted.explicit } });
+ if (key.has(.goto_line_end)) return resultAction(.{ .goto = .{ .target = .line_end, .count = count, .explicit_count = counted.explicit } });
+ if (key.has(.goto_first_nonws)) return resultAction(.{ .goto = .{ .target = .first_nonws, .count = count, .explicit_count = counted.explicit } });
+ if (key.has(.goto_line_down)) return resultAction(.{ .goto = .{ .target = .line_down, .count = count, .explicit_count = counted.explicit } });
+ if (key.has(.goto_line_up)) return resultAction(.{ .goto = .{ .target = .line_up, .count = count, .explicit_count = counted.explicit } });
+ if (key.has(.goto_column)) return resultAction(.{ .goto = .{ .target = .column, .count = count, .explicit_count = counted.explicit } });
+ if (key.has(.goto_view_top)) return resultAction(.{ .goto = .{ .target = .view_top, .count = count, .explicit_count = counted.explicit } });
+ if (key.has(.goto_view_center)) return resultAction(.{ .goto = .{ .target = .view_center, .count = count, .explicit_count = counted.explicit } });
+ if (key.has(.goto_view_bottom)) return resultAction(.{ .goto = .{ .target = .view_bottom, .count = count, .explicit_count = counted.explicit } });
+ if (key.has(.goto_definition)) return resultAction(.{ .lsp = .definition });
+ if (key.has(.goto_declaration)) return resultAction(.{ .lsp = .declaration });
+ if (key.has(.goto_type_definition)) return resultAction(.{ .lsp = .type_definition });
+ if (key.has(.goto_implementation)) return resultAction(.{ .lsp = .implementation });
+ if (key.has(.goto_references)) return resultAction(.{ .lsp = .references });
+ return .ignored;
+ },
+ .view => {
+ state.prefix = .none;
+ if (key.has(.view_top)) return resultAction(.{ .view = .top });
+ if (key.has(.view_center)) return resultAction(.{ .view = .center });
+ if (key.has(.view_bottom)) return resultAction(.{ .view = .bottom });
+ if (key.has(.view_scroll_down)) return resultAction(.{ .view = .scroll_down });
+ if (key.has(.view_scroll_up)) return resultAction(.{ .view = .scroll_up });
+ if (key.has(.half_page_down)) return resultAction(.{ .page = .{ .kind = .half_down, .count = count } });
+ if (key.has(.half_page_up)) return resultAction(.{ .page = .{ .kind = .half_up, .count = count } });
+ if (key.has(.page_down)) return resultAction(.{ .page = .{ .kind = .down, .count = count } });
+ if (key.has(.page_up)) return resultAction(.{ .page = .{ .kind = .up, .count = count } });
+ return .ignored;
+ },
+ .find_fwd, .find_back, .till_fwd, .till_back => |prefix| {
+ state.prefix = .none;
+ const char = key.literal() orelse return .ignored;
+ const kind: Find = switch (prefix) {
+ .find_fwd => .forward,
+ .find_back => .backward,
+ .till_fwd => .till_forward,
+ .till_back => .till_backward,
+ else => unreachable,
+ };
+ return resultAction(.{ .find = .{ .kind = kind, .char = char, .count = count } });
+ },
+ .replace => {
+ state.prefix = .none;
+ const char = key.literal() orelse return .ignored;
+ return resultAction(.{ .replace_char = char });
+ },
+ .match => {
+ if (state.match_sub == .none) {
+ if (key.has(.prefix_match)) {
+ state.prefix = .none;
+ return resultAction(.match_bracket);
+ }
+ const sub: MatchSub = if (key.has(.match_inside))
+ .inside
+ else if (key.has(.match_around))
+ .around
+ else if (key.has(.surround_add))
+ .surround_add
+ else if (key.has(.surround_replace))
+ .surround_replace
+ else if (key.has(.surround_delete))
+ .surround_delete
+ else {
+ state.prefix = .none;
+ return .ignored;
+ };
+ state.match_sub = sub;
+ return .pending;
+ }
+ const char = key.literal() orelse {
+ state.clear();
+ return .ignored;
+ };
+ if (state.match_sub == .surround_replace and state.held_char == 0) {
+ state.held_char = char;
+ return .pending;
+ }
+ const sub = state.match_sub;
+ const from = state.held_char;
+ state.clear();
+ return switch (sub) {
+ .inside => resultAction(.{ .textobject = .{ .char = char, .around = false } }),
+ .around => resultAction(.{ .textobject = .{ .char = char, .around = true } }),
+ .surround_add => resultAction(.{ .surround_add = char }),
+ .surround_delete => resultAction(.{ .surround_delete = char }),
+ .surround_replace => resultAction(.{ .surround_replace = .{ .from = from, .to = char } }),
+ .none => unreachable,
+ };
+ },
+ .next, .prev => |prefix| {
+ state.prefix = .none;
+ const direction: Direction = if (prefix == .next) .forward else .backward;
+ if (key.has(.goto_paragraph)) return resultAction(.{ .paragraph = .{ .direction = direction, .count = count } });
+ if (key.has(.add_newline)) return resultAction(.{ .add_newline = .{ .direction = direction, .count = count } });
+ if (key.has(.goto_diagnostic)) return resultAction(.{ .diagnostic = .{ .direction = direction, .endpoint = false } });
+ if (key.has(.goto_diagnostic_end)) return resultAction(.{ .diagnostic = .{ .direction = direction, .endpoint = true } });
+ return .ignored;
+ },
+ .none => {},
+ }
+
+ // Prefix setters retain the count for their continuation.
+ if (key.has(.prefix_goto)) return armPrefix(state, .goto, if (counted.explicit) count else 0);
+ if (key.has(.prefix_view)) return armPrefix(state, .view, if (counted.explicit) count else 0);
+ if (key.has(.prefix_find_fwd)) return armPrefix(state, .find_fwd, if (counted.explicit) count else 0);
+ if (key.has(.prefix_find_back)) return armPrefix(state, .find_back, if (counted.explicit) count else 0);
+ if (key.has(.prefix_till_fwd)) return armPrefix(state, .till_fwd, if (counted.explicit) count else 0);
+ if (key.has(.prefix_till_back)) return armPrefix(state, .till_back, if (counted.explicit) count else 0);
+ if (key.has(.prefix_replace)) return armPrefix(state, .replace, if (counted.explicit) count else 0);
+ if (key.has(.prefix_next)) return armPrefix(state, .next, if (counted.explicit) count else 0);
+ if (key.has(.prefix_prev)) return armPrefix(state, .prev, if (counted.explicit) count else 0);
+ if (key.has(.prefix_match)) return armPrefix(state, .match, 0);
+
+ if (key.has(.move_left)) return resultAction(.{ .move = .{ .motion = .left, .count = count } });
+ if (key.has(.move_right)) return resultAction(.{ .move = .{ .motion = .right, .count = count } });
+ if (key.has(.move_down)) return resultAction(.{ .move = .{ .motion = .down, .count = count } });
+ if (key.has(.move_up)) return resultAction(.{ .move = .{ .motion = .up, .count = count } });
+ if (key.has(.next_word_start)) return resultAction(.{ .move = .{ .motion = .next_word_start, .count = count } });
+ if (key.has(.prev_word_start)) return resultAction(.{ .move = .{ .motion = .prev_word_start, .count = count } });
+ if (key.has(.next_word_end)) return resultAction(.{ .move = .{ .motion = .next_word_end, .count = count } });
+ if (key.has(.next_long_word_start)) return resultAction(.{ .move = .{ .motion = .next_long_word_start, .count = count } });
+ if (key.has(.prev_long_word_start)) return resultAction(.{ .move = .{ .motion = .prev_long_word_start, .count = count } });
+ if (key.has(.next_long_word_end)) return resultAction(.{ .move = .{ .motion = .next_long_word_end, .count = count } });
+ if (key.has(.repeat_find)) return resultAction(.{ .repeat_find = count });
+ if (key.has(.line_start)) return resultAction(.{ .line = .start });
+ if (key.has(.line_end)) return resultAction(.{ .line = .end });
+ if (key.has(.line_first_nonws)) return resultAction(.{ .line = .first_nonws });
+ if (key.has(.goto_line)) return resultAction(.{ .goto_line = counted });
+ if (key.has(.half_page_down)) return resultAction(.{ .page = .{ .kind = .half_down, .count = count } });
+ if (key.has(.half_page_up)) return resultAction(.{ .page = .{ .kind = .half_up, .count = count } });
+ if (key.has(.page_down)) return resultAction(.{ .page = .{ .kind = .down, .count = count } });
+ if (key.has(.page_up)) return resultAction(.{ .page = .{ .kind = .up, .count = count } });
+
+ if (key.has(.insert)) return resultAction(.{ .insert = .{ .kind = .at, .count = count } });
+ if (key.has(.append)) return resultAction(.{ .insert = .{ .kind = .append, .count = count } });
+ if (key.has(.insert_line_start)) return resultAction(.{ .insert = .{ .kind = .line_start, .count = count } });
+ if (key.has(.insert_line_end)) return resultAction(.{ .insert = .{ .kind = .line_end, .count = count } });
+ if (key.has(.open_below)) return resultAction(.{ .insert = .{ .kind = .open_below, .count = count } });
+ if (key.has(.open_above)) return resultAction(.{ .insert = .{ .kind = .open_above, .count = count } });
+
+ if (key.has(.select_mode)) return resultAction(.{ .select = .{ .kind = .mode, .count = count } });
+ if (key.has(.select_line)) return resultAction(.{ .select = .{ .kind = .line, .count = count } });
+ if (key.has(.select_line_bounds)) return resultAction(.{ .select = .{ .kind = .line_bounds, .count = count } });
+ if (key.has(.shrink_to_line_bounds)) return resultAction(.{ .select = .{ .kind = .shrink_to_line_bounds, .count = count } });
+ if (key.has(.collapse_selection)) return resultAction(.{ .select = .{ .kind = .collapse, .count = count } });
+ if (key.has(.flip_selection)) return resultAction(.{ .select = .{ .kind = .flip, .count = count } });
+ if (key.has(.select_all)) return resultAction(.{ .select = .{ .kind = .all, .count = count } });
+
+ if (key.has(.copy_sel_below)) return resultAction(.{ .multi = .{ .kind = .copy_below, .count = count } });
+ if (key.has(.copy_sel_above)) return resultAction(.{ .multi = .{ .kind = .copy_above, .count = count } });
+ if (key.has(.keep_primary_sel)) return resultAction(.{ .multi = .{ .kind = .keep_primary, .count = count } });
+ if (key.has(.remove_primary_sel)) return resultAction(.{ .multi = .{ .kind = .remove_primary, .count = count } });
+ if (key.has(.rotate_sel_fwd)) return resultAction(.{ .multi = .{ .kind = .rotate_forward, .count = count } });
+ if (key.has(.rotate_sel_back)) return resultAction(.{ .multi = .{ .kind = .rotate_backward, .count = count } });
+ if (key.has(.split_sel_newline)) return resultAction(.{ .multi = .{ .kind = .split_newline, .count = count } });
+ if (key.has(.merge_sels)) return resultAction(.{ .multi = .{ .kind = .merge, .count = count } });
+ if (key.has(.merge_consecutive_sels)) return resultAction(.{ .multi = .{ .kind = .merge_consecutive, .count = count } });
+ if (key.has(.trim_sels)) return resultAction(.{ .multi = .{ .kind = .trim, .count = count } });
+ if (key.has(.select_regex)) return resultAction(.{ .select_regex = false });
+ if (key.has(.split_regex)) return resultAction(.{ .select_regex = true });
+
+ if (key.has(.delete)) return resultAction(.{ .edit = .{ .kind = .delete, .count = count } });
+ if (key.has(.delete_noyank)) return resultAction(.{ .edit = .{ .kind = .delete_noyank, .count = count } });
+ if (key.has(.change)) return resultAction(.{ .edit = .{ .kind = .change, .count = count } });
+ if (key.has(.yank)) return resultAction(.{ .edit = .{ .kind = .yank, .count = count } });
+ if (key.has(.replace_with_yank)) return resultAction(.{ .edit = .{ .kind = .replace_with_yank, .count = count } });
+ if (key.has(.paste_after)) return resultAction(.{ .edit = .{ .kind = .paste_after, .count = count } });
+ if (key.has(.paste_before)) return resultAction(.{ .edit = .{ .kind = .paste_before, .count = count } });
+ if (key.has(.switch_case)) return resultAction(.{ .edit = .{ .kind = .switch_case, .count = count } });
+ if (key.has(.to_lowercase)) return resultAction(.{ .edit = .{ .kind = .lowercase, .count = count } });
+ if (key.has(.to_uppercase)) return resultAction(.{ .edit = .{ .kind = .uppercase, .count = count } });
+ if (key.has(.join_lines)) return resultAction(.{ .edit = .{ .kind = .join_lines, .count = count } });
+ if (key.has(.indent)) return resultAction(.{ .edit = .{ .kind = .indent, .count = count } });
+ if (key.has(.unindent)) return resultAction(.{ .edit = .{ .kind = .unindent, .count = count } });
+ if (key.has(.format)) return resultAction(.{ .lsp = .format });
+ if (key.has(.increment)) return resultAction(.{ .adjust_number = @intCast(count) });
+ if (key.has(.decrement)) return resultAction(.{ .adjust_number = -@as(i64, @intCast(count)) });
+ if (key.has(.comment_toggle)) return resultAction(.{ .edit = .{ .kind = .comment_toggle, .count = count } });
+ if (key.has(.undo)) return resultAction(.{ .edit = .{ .kind = .undo, .count = count } });
+ if (key.has(.redo)) return resultAction(.{ .edit = .{ .kind = .redo, .count = count } });
+
+ if (key.has(.leader)) return resultAction(.leader);
+ if (key.has(.command_line)) return resultAction(.command_line);
+ if (key.has(.pipe_selection)) return resultAction(.{ .pipe_selection = .replace });
+ if (key.has(.pipe_selection_to)) return resultAction(.{ .pipe_selection = .ignore });
+ if (key.has(.insert_output)) return resultAction(.{ .pipe_selection = .insert });
+ if (key.has(.append_output)) return resultAction(.{ .pipe_selection = .append });
+ if (key.has(.search)) return resultAction(.search);
+ if (key.has(.search_next)) return resultAction(.{ .search_step = .forward });
+ if (key.has(.search_prev)) return resultAction(.{ .search_step = .backward });
+ return .unbound;
+ }
+
+ fn input(cp: u21, roles: []const Role) Input {
+ return .{ .cp = cp, .roles = .initMany(roles) };
+ }
+
+ test "counts survive prefixes and identical parser actions can feed both adapters" {
+ var text: State = .{};
+ var pdf: State = .{};
+ const sequence = [_]Input{
+ input('1', &.{}),
+ input('2', &.{}),
+ input('g', &.{ .prefix_goto, .goto_file_start }),
+ input('j', &.{ .move_down, .goto_line_down, .view_scroll_down }),
+ };
+ for (sequence[0 .. sequence.len - 1]) |key| {
+ try std.testing.expectEqualDeep(parse(&text, key), parse(&pdf, key));
+ }
+ const ta = parse(&text, sequence[sequence.len - 1]);
+ const pa = parse(&pdf, sequence[sequence.len - 1]);
+ try std.testing.expectEqualDeep(ta, pa);
+ try std.testing.expectEqualDeep(Result{ .action = .{ .goto = .{
+ .target = .line_down,
+ .count = 12,
+ .explicit_count = true,
+ } } }, ta);
+ try std.testing.expectEqual(State{}, text);
+ try std.testing.expectEqual(State{}, pdf);
+ }
+
+ test "invalid continuations are ignored and clear prefix plus count" {
+ var state: State = .{};
+ try std.testing.expectEqual(Result.pending, parse(&state, input('4', &.{})));
+ try std.testing.expectEqual(Result.pending, parse(&state, input('g', &.{.prefix_goto})));
+ try std.testing.expectEqual(Result.ignored, parse(&state, input('?', &.{})));
+ try std.testing.expectEqual(State{}, state);
+ try std.testing.expectEqualDeep(Result{ .action = .{ .move = .{ .motion = .down, .count = 1 } } }, parse(&state, input('j', &.{.move_down})));
+ }
+
+ test "literal arguments retain conflicting command characters" {
+ var state: State = .{};
+ try std.testing.expectEqual(Result.pending, parse(&state, input('f', &.{.prefix_find_fwd})));
+ try std.testing.expectEqualDeep(Result{ .action = .{ .find = .{ .kind = .forward, .char = 'p', .count = 1 } } }, parse(&state, input('p', &.{.paste_after})));
+
+ try std.testing.expectEqual(Result.pending, parse(&state, input('m', &.{.prefix_match})));
+ try std.testing.expectEqual(Result.pending, parse(&state, input('r', &.{.surround_replace})));
+ try std.testing.expectEqual(Result.pending, parse(&state, input('[', &.{.prefix_prev})));
+ try std.testing.expectEqualDeep(Result{ .action = .{ .surround_replace = .{ .from = '[', .to = ']' } } }, parse(&state, input(']', &.{.prefix_next})));
+ try std.testing.expectEqual(State{}, state);
+ }
+
+ test "modified and special keys cannot satisfy literal continuations" {
+ var state: State = .{};
+ _ = parse(&state, input('r', &.{.prefix_replace}));
+ try std.testing.expectEqual(Result.ignored, parse(&state, .{ .cp = 'x', .ctrl = true }));
+ try std.testing.expectEqual(State{}, state);
+ _ = parse(&state, input('f', &.{.prefix_find_fwd}));
+ try std.testing.expectEqual(Result.ignored, parse(&state, .{ .cp = 0xF0001 }));
+ try std.testing.expectEqual(State{}, state);
+ }
+
+ test "replace accepts a Unicode literal" {
+ var state: State = .{};
+ try std.testing.expectEqual(Result.pending, parse(&state, input('r', &.{.prefix_replace})));
+ try std.testing.expectEqualDeep(Result{ .action = .{ .replace_char = '界' } }, parse(&state, input('界', &.{})));
+ try std.testing.expectEqual(State{}, state);
+ }
+
+ test "once versus per-selection is semantic action metadata" {
+ try std.testing.expectEqual(Scope.once, (@as(Action, .search)).scope());
+ try std.testing.expectEqual(Scope.once, (Action{ .edit = .{ .kind = .undo, .count = 1 } }).scope());
+ try std.testing.expectEqual(Scope.per_selection, (Action{ .edit = .{ .kind = .delete, .count = 1 } }).scope());
+ try std.testing.expectEqual(Scope.per_selection, (Action{ .move = .{ .motion = .down, .count = 3 } }).scope());
+ }
+};
+
+test {
+ _ = Normal;
+}
+
+// Cursor columns are UTF-8 byte offsets at grapheme boundaries; line.len is the terminator.
pub const Cursor = struct {
row: usize = 0,
@@ -19,8 +659,7 @@ pub const Cursor = struct {
}
};
-// word char classes (matches ad/vim/kakoune: word = alnum + _, punct = other
-// non-ws, ws = space/tab/newline).
+// Unicode word characters, punctuation, and whitespace.
pub const Kind = enum { word, punct, ws };
fn codepointAt(text: []const u8, off: usize) u21 {
@@ -59,23 +698,6 @@ fn kindOfCodepoint(cp: u21) Kind {
};
}
-pub fn kindOf(c: u8) Kind {
- return kindOfCodepoint(c);
-}
-
-// "long word" (W/B/E): only whitespace separates; punct is part of a word.
-fn kindOfLong(cp: u21) Kind {
- return if (isUnicodeWhitespace(cp)) .ws else .word;
-}
-
-fn kindAt(lines: []const []const u8, c: Cursor, long: bool) Kind {
- if (c.row >= lines.len) return .ws;
- const line = lines[c.row];
- if (c.col >= line.len) return .ws; // line terminator / EOF = whitespace
- const cp = codepointAt(line, graphemeStart(line, c.col));
- return if (long) kindOfLong(cp) else kindOfCodepoint(cp);
-}
-
fn lineLenOf(lines: []const []const u8, row: usize) usize {
if (row >= lines.len) return 0;
return lines[row].len;
@@ -110,30 +732,12 @@ fn stepBwd(lines: []const []const u8, c: *Cursor) bool {
return true;
}
-// at EOF? (past the last line's last char)
-fn atEof(lines: []const []const u8, c: Cursor) bool {
- if (c.row >= lines.len) return true;
- if (c.row + 1 < lines.len) return false;
- return c.col >= lines[c.row].len;
-}
-
pub fn firstNonWs(line: []const u8) usize {
var i: usize = 0;
while (i < line.len and isUnicodeWhitespace(codepointAt(line, i))) i = nextGrapheme(line, i);
return i;
}
-// ---- per-line motions ----
-
-pub fn lineStart(c: Cursor) Cursor {
- return .{ .row = c.row, .col = 0 };
-}
-
-pub fn lineEnd(lines: []const []const u8, c: Cursor) Cursor {
- const llen = lineLenOf(lines, c.row);
- return .{ .row = c.row, .col = if (llen == 0) 0 else prevGrapheme(lines[c.row], llen) };
-}
-
pub fn firstNonWsOf(lines: []const []const u8, c: Cursor) Cursor {
// the row can sit past the content (mouse click below a short pane's
// last line) — out of range reads as an empty line, like lineLenOf
@@ -141,8 +745,6 @@ pub fn firstNonWsOf(lines: []const []const u8, c: Cursor) Cursor {
return .{ .row = c.row, .col = firstNonWs(lines[c.row]) };
}
-// ---- char/line motions ----
-
pub fn charLeft(lines: []const []const u8, c: Cursor) Cursor {
if (c.row >= lines.len) return .{ .row = c.row, .col = 0 };
return .{ .row = c.row, .col = prevGrapheme(lines[c.row], @min(c.col, lines[c.row].len)) };
@@ -160,91 +762,6 @@ fn clampLineCol(line: []const u8, col: usize) usize {
return graphemeStart(line, @min(col, last));
}
-pub fn lineDown(lines: []const []const u8, c: Cursor) Cursor {
- const nr = if (c.row + 1 < lines.len) c.row + 1 else c.row;
- const llen = lineLenOf(lines, nr);
- return .{ .row = nr, .col = if (llen == 0) 0 else clampLineCol(lines[nr], c.col) };
-}
-
-pub fn lineUp(lines: []const []const u8, c: Cursor) Cursor {
- const nr = if (c.row > 0) c.row - 1 else c.row;
- const llen = lineLenOf(lines, nr);
- return .{ .row = nr, .col = if (llen == 0) 0 else clampLineCol(lines[nr], c.col) };
-}
-
-// ---- word motions ----
-
-// `w`/`W`: to the start of the next word.
-pub fn nextWordStart(lines: []const []const u8, c: Cursor, long: bool) Cursor {
- var p = c;
- const start_kind = kindAt(lines, p, long);
- if (start_kind != .ws) {
- // skip the rest of the current word-class run
- while (!atEof(lines, p) and kindAt(lines, p, long) == start_kind) {
- if (!stepFwd(lines, &p)) break;
- }
- }
- // skip whitespace (incl. newlines) to the next word start
- while (!atEof(lines, p) and kindAt(lines, p, long) == .ws) {
- if (!stepFwd(lines, &p)) break;
- }
- // p now sits on the next word's first char (or EOF -> last valid pos)
- return clampToChar(lines, p);
-}
-
-// `b`/`B`: to the start of the previous word.
-pub fn prevWordStart(lines: []const []const u8, c: Cursor, long: bool) Cursor {
- var p = c;
- if (!stepBwd(lines, &p)) return c; // at buffer start
- // skip whitespace backward
- while (kindAt(lines, p, long) == .ws) {
- if (!stepBwd(lines, &p)) return .{ .row = 0, .col = 0 };
- }
- // now on the end of the previous word; walk back to its start
- const k = kindAt(lines, p, long);
- while (true) {
- var q = p;
- if (!stepBwd(lines, &q)) {
- p.col = 0;
- break;
- }
- if (kindAt(lines, q, long) != k) break; // crossed into prior class
- p = q;
- }
- return clampToChar(lines, p);
-}
-
-// `e`/`E`: to the end of the current/next word.
-pub fn nextWordEnd(lines: []const []const u8, c: Cursor, long: bool) Cursor {
- var p = c;
- if (!stepFwd(lines, &p)) return clampToChar(lines, c);
- // skip whitespace forward
- while (!atEof(lines, p) and kindAt(lines, p, long) == .ws) {
- if (!stepFwd(lines, &p)) break;
- }
- if (atEof(lines, p)) return clampToChar(lines, p);
- // now on a word's first char; advance to the last char of this run
- const k = kindAt(lines, p, long);
- while (!atEof(lines, p)) {
- var q = p;
- if (!stepFwd(lines, &q)) break;
- if (kindAt(lines, q, long) != k) break;
- p = q;
- }
- return clampToChar(lines, p);
-}
-
-// ---- goto ----
-
-pub fn gotoFirst() Cursor {
- return .{ .row = 0, .col = 0 };
-}
-
-pub fn gotoLast(lines: []const []const u8) Cursor {
- const r = if (lines.len == 0) 0 else lines.len - 1;
- return .{ .row = r, .col = 0 };
-}
-
// the character the cursor sits on; line terminators / EOF read as '\n'.
fn codepointAtCursor(lines: []const []const u8, c: Cursor) u21 {
if (c.row >= lines.len) return '\n';
@@ -258,26 +775,6 @@ fn charAt(lines: []const []const u8, c: Cursor) u8 {
return if (cp <= 0x7f) @intCast(cp) else 0;
}
-// `f`/`F`/`t`/`T`: the nth occurrence of `ch` after/before the cursor, across
-// line boundaries (helix: not confined to the line). `till` stops one position
-// short of the hit. Returns null (no move) when there aren't n occurrences.
-pub fn findChar(lines: []const []const u8, c: Cursor, ch: u21, fwd: bool, till: bool, n: usize) ?Cursor {
- var p = clampToChar(lines, c);
- var left = if (n == 0) 1 else n;
- while (left > 0) {
- if (fwd) {
- if (!stepFwd(lines, &p)) return null;
- } else {
- if (!stepBwd(lines, &p)) return null;
- }
- if (codepointAtCursor(lines, p) == ch) left -= 1;
- }
- if (till) {
- if (fwd) _ = stepBwd(lines, &p) else _ = stepFwd(lines, &p);
- }
- return clampToChar(lines, p);
-}
-
// `mm`: the bracket matching the one under the cursor (dumb text scan with
// nesting; no tree-sitter). Null when the cursor is not on a bracket.
pub fn matchBracket(lines: []const []const u8, c: Cursor) ?Cursor {
@@ -314,31 +811,6 @@ pub fn matchBracket(lines: []const []const u8, c: Cursor) ?Cursor {
return null;
}
-fn isBlank(line: []const u8) bool {
- return firstNonWs(line) == line.len;
-}
-
-// `]p`: the start of the next blank-line-delimited block (or the last line).
-pub fn paragraphFwd(lines: []const []const u8, c: Cursor) Cursor {
- var r = c.row;
- while (r < lines.len and !isBlank(lines[r])) r += 1;
- while (r < lines.len and isBlank(lines[r])) r += 1;
- if (r >= lines.len) return gotoLast(lines);
- return .{ .row = r, .col = 0 };
-}
-
-// `[p`: the start of the current block, or of the previous one when already
-// on a block start / a blank line.
-pub fn paragraphBwd(lines: []const []const u8, c: Cursor) Cursor {
- if (c.row == 0 or lines.len == 0) return .{ .row = 0, .col = 0 };
- var r = @min(c.row, lines.len) - 1;
- while (r > 0 and isBlank(lines[r])) r -= 1;
- while (r > 0 and !isBlank(lines[r - 1])) r -= 1;
- return .{ .row = r, .col = 0 };
-}
-
-// ---- textobject / surround range math (mi/ma/ms/mr/md) ----
-
// an inclusive char range [a, b] in document order
pub const Range = struct { a: Cursor, b: Cursor };
@@ -380,94 +852,6 @@ pub fn enclosingQuote(lines: []const []const u8, c0: Cursor, q: u8) ?Range {
return null;
}
-// mi/ma over a bracket pair: `around` keeps the brackets, inside shrinks them
-// off (null when nothing is left between them).
-pub fn pairRange(lines: []const []const u8, c: Cursor, open: u8, close: u8, around: bool) ?Range {
- const r = enclosingPair(lines, c, open, close) orelse return null;
- if (around) return r;
- return shrinkOffDelims(lines, r);
-}
-
-pub fn quoteRange(lines: []const []const u8, c: Cursor, q: u8, around: bool) ?Range {
- const r = enclosingQuote(lines, c, q) orelse return null;
- if (around) return r;
- return shrinkOffDelims(lines, r);
-}
-
-fn shrinkOffDelims(lines: []const []const u8, r: Range) ?Range {
- var a = r.a;
- var b = r.b;
- if (!stepFwd(lines, &a)) return null;
- if (!stepBwd(lines, &b)) return null;
- if (b.row < a.row or (b.row == a.row and b.col < a.col)) return null; // empty inside
- return .{ .a = a, .b = b };
-}
-
-// miw/maw (and W): the word run under the cursor; `around` adds the trailing
-// whitespace on the line (or the leading run when there is none).
-pub fn wordRange(lines: []const []const u8, c0: Cursor, long: bool, around: bool) ?Range {
- const c = clampToChar(lines, c0);
- if (c.row >= lines.len) return null;
- const line = lines[c.row];
- if (line.len == 0 or c.col >= line.len) return null;
- const k = kindAt(lines, c, long);
- if (k == .ws) return null;
- var lo = c.col;
- while (lo > 0) {
- const prev = prevGrapheme(line, lo);
- if (kindAt(lines, .{ .row = c.row, .col = prev }, long) != k) break;
- lo = prev;
- }
- var hi = c.col;
- while (true) {
- const next = nextGrapheme(line, hi);
- if (next >= line.len or kindAt(lines, .{ .row = c.row, .col = next }, long) != k) break;
- hi = next;
- }
- if (around) {
- var h2 = hi;
- while (true) {
- const next = nextGrapheme(line, h2);
- if (next >= line.len or kindAt(lines, .{ .row = c.row, .col = next }, long) != .ws) break;
- h2 = next;
- }
- if (h2 != hi) {
- hi = h2;
- } else {
- while (lo > 0) {
- const prev = prevGrapheme(line, lo);
- if (kindAt(lines, .{ .row = c.row, .col = prev }, long) != .ws) break;
- lo = prev;
- }
- }
- }
- return .{ .a = .{ .row = c.row, .col = lo }, .b = .{ .row = c.row, .col = hi } };
-}
-
-// mip/map: the blank-line-delimited block around the cursor; `around` adds the
-// trailing blank lines (or the leading ones when there are none).
-pub fn paragraphRange(lines: []const []const u8, c0: Cursor, around: bool) ?Range {
- const c = clampToChar(lines, c0);
- if (c.row >= lines.len or isBlank(lines[c.row])) return null;
- var r0 = c.row;
- while (r0 > 0 and !isBlank(lines[r0 - 1])) r0 -= 1;
- var r1 = c.row;
- while (r1 + 1 < lines.len and !isBlank(lines[r1 + 1])) r1 += 1;
- if (around) {
- var r2 = r1;
- while (r2 + 1 < lines.len and isBlank(lines[r2 + 1])) r2 += 1;
- if (r2 != r1) {
- r1 = r2;
- } else {
- while (r0 > 0 and isBlank(lines[r0 - 1])) r0 -= 1;
- }
- }
- const llen = lineLenOf(lines, r1);
- return .{ .a = .{ .row = r0, .col = 0 }, .b = .{ .row = r1, .col = if (llen == 0) 0 else prevGrapheme(lines[r1], llen) } };
-}
-
-// ---- helpers used by motions + main.zig ----
-
// clamp a (possibly terminator/EOF) position onto a real character.
pub fn clampToChar(lines: []const []const u8, c: Cursor) Cursor {
if (c.row >= lines.len) {
@@ -505,21 +889,11 @@ pub fn lineSlice(content: []const u8, row: usize) []const u8 {
return content[start..nl];
}
-/// The byte span of line `row`, in ONE scan that stops at that row.
-///
-/// This exists because the obvious spelling costs a scan of the WHOLE document per call and the
-/// obvious USE of it costs several. `insertAt` below read `lineCount` twice merely to clamp a row,
-/// and `lineCount` is `std.mem.count` over every byte; on a 19 MB fixture that was two full passes
-/// before a single character could be inserted. Measured with `zig build perf`: `edit-char` on the
-/// 300 000-line fixture cost 15.0 ms, against 1.5 ms to render the frame that shows it.
-///
-/// Returns null when the row does not exist, so a caller that must clamp pays for the count only on
-/// that path - which is the rare one, since a cursor is normally inside its document.
+/// A bounded scan of one line; null if the row does not exist.
pub const LineSpan = struct { start: usize, end: usize };
pub fn lineSpan(content: []const u8, row: usize) ?LineSpan {
- // An empty document has no lines at all, which is what `lineCount` says about it - not one
- // empty line. Agreeing with that here is what lets `insertAt` fall through to offset 0.
+ // Match lineCount: empty content has no lines; a trailing newline adds one.
if (content.len == 0) return null;
var start: usize = 0;
var r: usize = 0;
@@ -527,15 +901,12 @@ pub fn lineSpan(content: []const u8, row: usize) ?LineSpan {
const nl = std.mem.indexOfScalarPos(u8, content, start, '\n') orelse return null;
start = nl + 1;
}
- // Row `row` exists if it begins inside the content, OR it is the empty last line after a
- // trailing newline - which `lineCount` also counts, so the two agree.
if (start > content.len) return null;
if (start == content.len and !(row == 0 or content.len == 0 or content[content.len - 1] == '\n')) return null;
const end = std.mem.indexOfScalarPos(u8, content, start, '\n') orelse content.len;
return .{ .start = start, .end = end };
}
-// ---- file content mutations. caller frees the returned slice + the old one. ----
fn spliceAlloc(alloc: std.mem.Allocator, content: []const u8, start: usize, end: usize, replacement: []const u8) ![]u8 {
const out = try alloc.alloc(u8, content.len - (end - start) + replacement.len);
@memcpy(out[0..start], content[0..start]);
@@ -546,8 +917,7 @@ fn spliceAlloc(alloc: std.mem.Allocator, content: []const u8, start: usize, end:
/// insert `text` at (row, col). col is clamped to the line length.
pub fn insertAt(alloc: std.mem.Allocator, content: []const u8, c: Cursor, text: []const u8) ![]u8 {
- // One bounded scan on the common path. The fallback keeps the old clamping exactly - a row past
- // the end lands on the last line - and only it pays for a full count.
+ // Only an out-of-range row requires counting the whole document.
const span = lineSpan(content, c.row) orelse blk: {
const last = lineCount(content) -| 1;
break :blk lineSpan(content, last) orelse LineSpan{ .start = content.len, .end = content.len };
@@ -728,106 +1098,10 @@ pub fn changeCase(alloc: std.mem.Allocator, content: []const u8, a: Cursor, b: C
return out;
}
-// `J`: join line `row` with the next — the newline and the next line's leading
-// whitespace become one space (helix join). `col` is the space's column.
-// Null when `row` is the last line.
-pub fn joinLine(alloc: std.mem.Allocator, content: []const u8, row: usize) !?struct { content: []u8, col: usize } {
- if (row + 1 >= lineCount(content)) return null;
- const a = lineSlice(content, row);
- const next = lineSlice(content, row + 1);
- const b = std.mem.trimStart(u8, next, " \t");
- const start = lineStartOffset(content, row);
- const rest = lineStartOffset(content, row + 1) + (next.len - b.len);
- const prefix_end = start + a.len;
- const out = try alloc.alloc(u8, prefix_end + 1 + content.len - rest);
- @memcpy(out[0..prefix_end], content[0..prefix_end]);
- out[prefix_end] = ' ';
- @memcpy(out[prefix_end + 1 ..], content[rest..]);
- return .{ .content = out, .col = a.len };
-}
-
// `>` / `<`: indent/unindent lines [r0, r1]. Fixed width — pardes has no
// per-language indent config; 4 spaces, one tab counts as one level out.
pub const INDENT_W = 4;
-pub fn indentLines(alloc: std.mem.Allocator, content: []const u8, r0: usize, r1: usize, add: bool) ![]u8 {
- const lo = @min(r0, r1);
- const hi = @max(r0, r1);
- var out_len = content.len;
- var it = std.mem.splitScalar(u8, content, '\n');
- var row: usize = 0;
- while (it.next()) |line| : (row += 1) {
- if (row < lo or row > hi) continue;
- if (add) {
- if (line.len != 0) out_len += INDENT_W;
- } else {
- var cut: usize = 0;
- if (line.len > 0 and line[0] == '\t') {
- cut = 1;
- } else while (cut < line.len and cut < INDENT_W and line[cut] == ' ') cut += 1;
- out_len -= cut;
- }
- }
-
- const out = try alloc.alloc(u8, out_len);
- it = std.mem.splitScalar(u8, content, '\n');
- row = 0;
- var write: usize = 0;
- while (it.next()) |line| : (row += 1) {
- if (row > 0) {
- out[write] = '\n';
- write += 1;
- }
- var selected = line;
- if (row >= lo and row <= hi) {
- if (add) {
- if (line.len != 0) {
- @memset(out[write..][0..INDENT_W], ' ');
- write += INDENT_W;
- }
- } else if (line.len > 0 and line[0] == '\t') {
- selected = line[1..];
- } else {
- var cut: usize = 0;
- while (cut < line.len and cut < INDENT_W and line[cut] == ' ') cut += 1;
- selected = line[cut..];
- }
- }
- @memcpy(out[write..][0..selected.len], selected);
- write += selected.len;
- }
- return out;
-}
-
-// `Ctrl-a`/`Ctrl-x`: add `delta` to the decimal integer under the cursor
-// (helix: under the cursor only, no forward scan). Null when the cursor is
-// not on a number. The new cursor sits on the number's last digit.
-pub fn adjustNumber(alloc: std.mem.Allocator, content: []const u8, c: Cursor, delta: i64) !?struct { content: []u8, cur: Cursor } {
- const line = lineSlice(content, c.row);
- if (c.col >= line.len) return null;
- var s = c.col;
- var e = c.col;
- if (!std.ascii.isDigit(line[s])) {
- // sitting on the '-' of a negative number counts
- if (!(line[s] == '-' and s + 1 < line.len and std.ascii.isDigit(line[s + 1]))) return null;
- e = s + 1;
- }
- while (s > 0 and std.ascii.isDigit(line[s - 1])) s -= 1;
- if (s > 0 and line[s - 1] == '-') s -= 1;
- while (e < line.len and std.ascii.isDigit(line[e])) e += 1;
- const val = std.fmt.parseInt(i64, line[s..e], 10) catch return null;
- const nv = val +| delta;
- var buf: [24]u8 = undefined;
- // "{d}" prints '+' for positive signed ints — format the magnitude unsigned
- const numstr = if (nv < 0)
- std.fmt.bufPrint(&buf, "-{d}", .{@abs(nv)}) catch return null
- else
- std.fmt.bufPrint(&buf, "{d}", .{@abs(nv)}) catch return null;
- const off = lineStartOffset(content, c.row);
- const start = off + s;
- return .{ .content = try spliceAlloc(alloc, content, start, off + e, numstr), .cur = .{ .row = c.row, .col = s + numstr.len - 1 } };
-}
-
// delete the EXCLUSIVE span [a, b) — insert-mode kills. col may equal the
// line length (the newline); a kill crossing it passes b = (row+1, 0).
pub fn deleteSpan(alloc: std.mem.Allocator, content: []const u8, a: Cursor, b: Cursor) ![]u8 {
@@ -837,35 +1111,14 @@ pub fn deleteSpan(alloc: std.mem.Allocator, content: []const u8, a: Cursor, b: C
return spliceAlloc(alloc, content, s, e, "");
}
-// ---- helix range engine (phase 5) ----
-//
-// Gap-offset ranges over the FLAT buffer, ported faithfully from
-// helix-core/src/movement.rs + selection.rs @ 278b24389 (the genizah
-// checkout). Positions are UTF-8 gap offsets 0..=text.len. Stored columns
-// remain byte offsets, but every range boundary is an extended-grapheme
-// boundary. A range with
-// head > anchor selects [anchor, head) with the block cursor ON head-1;
-// head < anchor selects [head, anchor) with the cursor ON head. The
-// differential suite (test/hxcases, `zig build hxdiff`) pins every behavior
-// here key-for-key against a real helix.
+// Ported from Helix movement.rs and selection.rs at 278b24389.
+// Half-open selections use UTF-8 byte gaps at grapheme boundaries.
+// A forward selection's cursor is the grapheme before head; a backward one's is at head.
-pub const HxRange = struct { anchor: usize, head: usize };
+pub const Selection = struct { anchor: usize, head: usize };
-/// The first byte of the grapheme cluster containing `off`.
-///
-/// The general answer needs UAX #29, which is why the slow path below iterates from the start of
-/// `text` with the full break state machine - and that made this the single hottest function in a
-/// keystroke: 21.5% of a profiled edit at the ESP32-P4's 40x12 geometry, because the render path
-/// calls it once per visible row with a column offset, so the cost follows the cursor's distance
-/// along its line. That is exactly the shape measured on the die, where inserting at column 320 of
-/// a fixed line cost 7.8 ms more than inserting at column 0 of the same line.
-///
-/// The fast path is sound rather than approximate. In UAX #29 every ASCII scalar is its own
-/// grapheme cluster with ONE exception, GB3: CR is joined to a following LF. Every other rule that
-/// could extend a cluster across `off` - Extend, ZWJ, SpacingMark, Prepend, Regional_Indicator -
-/// is spelled with non-ASCII scalars. So if the byte at `off` and the byte before it are both
-/// ASCII and are not that CR-LF pair, `off` already IS a cluster boundary and there is nothing to
-/// search for. Text that is not all ASCII still takes the slow path, byte for byte as before.
+/// The first byte of the grapheme containing off. Adjacent ASCII bytes are
+/// boundaries except CR-LF (UAX #29 GB3); other cases need full segmentation.
pub fn graphemeStart(text: []const u8, off: usize) usize {
const bounded = @min(off, text.len);
if (bounded == text.len) return text.len;
@@ -884,15 +1137,7 @@ pub fn graphemeStart(text: []const u8, off: usize) usize {
/// one extended grapheme forward, clamped at text.len
pub fn nextGrapheme(text: []const u8, off: usize) usize {
if (off >= text.len) return text.len;
- // The editor's own offsets are already boundaries. Keep the overwhelmingly
- // common ASCII path O(1); only repair a continuation-byte input here.
- //
- // GB3 is the one UAX #29 rule that joins two ASCII scalars: CR takes a
- // following LF into the same cluster. `graphemeStart` spells that exclusion
- // out (:875) and this did not, so the two disagreed about a CRLF file by
- // exactly one byte — a head stepped onto the offset between CR and LF and
- // `graphemeStart` then repaired it back onto the CR. Excluded here for the
- // same reason and in the same words; everything else ASCII is still O(1).
+ // Inputs are grapheme boundaries; repair continuation bytes. CR-LF stays one cluster.
if (text[off] < 0x80 and (off + 1 == text.len or text[off + 1] < 0x80) and
!(text[off] == '\r' and off + 1 < text.len and text[off + 1] == '\n')) return off + 1;
var start = off;
@@ -911,13 +1156,10 @@ pub fn prevGrapheme(text: []const u8, off: usize) usize {
bounded = repaired;
}
if (bounded == 0) return 0;
- // ...and the same GB3 exclusion, from the other side: a CR before this LF
- // means the cluster starts one byte earlier than the fast path would say.
+ // GB3 also excludes stepping backward into CR-LF.
if (text[bounded - 1] < 0x80 and (bounded == 1 or text[bounded - 2] < 0x80) and
!(bounded >= 2 and text[bounded - 2] == '\r' and text[bounded - 1] == '\n')) return bounded - 1;
- // Graphemes cannot cross a line break. Restrict the forward segmentation
- // needed for a reverse step to the current line instead of rescanning the
- // complete buffer.
+ // No grapheme crosses a line break; reverse segmentation only needs this line.
const line_start = if (std.mem.lastIndexOfScalar(u8, text[0 .. bounded - 1], '\n')) |nl| nl + 1 else 0;
if (line_start == bounded) return bounded - 1; // the newline is its own editor cell
var it = uucode.grapheme.utf8Iterator(text[line_start..bounded]);
@@ -935,14 +1177,12 @@ pub fn graphemeAtColumn(text: []const u8, column: usize) usize {
}
/// the block cursor cell of a range (helix Range::cursor)
-pub fn hxCursor(text: []const u8, r: HxRange) usize {
+pub fn selectionCursor(text: []const u8, r: Selection) usize {
return if (r.head > r.anchor) prevGrapheme(text, r.head) else r.head;
}
-/// helix Range::put_cursor: park the block cursor at cell `idx`, optionally
-/// extending — the anchor shifts one grapheme when the head crosses it so the
-/// anchor CELL stays fixed.
-pub fn hxPutCursor(text: []const u8, r: HxRange, idx: usize, extend: bool) HxRange {
+/// Crossing the anchor moves its byte gap one grapheme, preserving the anchor cell.
+pub fn moveSelectionCursor(text: []const u8, r: Selection, idx: usize, extend: bool) Selection {
if (!extend) return .{ .anchor = idx, .head = idx };
var anchor = r.anchor;
if (r.head >= r.anchor and idx < r.anchor) {
@@ -956,39 +1196,35 @@ pub fn hxPutCursor(text: []const u8, r: HxRange, idx: usize, extend: bool) HxRan
// ropey-style line math: len_lines = count('\n') + 1 — the slot after a
// trailing '\n' is a real, empty last line and the cursor can sit there.
-pub fn hxLineCount(text: []const u8) usize {
+pub fn cursorLineCount(text: []const u8) usize {
return std.mem.count(u8, text, "\n") + 1;
}
-pub fn hxLineOf(text: []const u8, off: usize) usize {
+pub fn lineAtOffset(text: []const u8, off: usize) usize {
return std.mem.count(u8, text[0..@min(off, text.len)], "\n");
}
/// offset of line's terminator ('\n'), or text.len on the last line
-pub fn hxLineEndIdx(text: []const u8, line: usize) usize {
+pub fn lineEndOffset(text: []const u8, line: usize) usize {
const s = lineStartOffset(text, line);
return if (std.mem.indexOfScalarPos(u8, text, s, '\n')) |nl| nl else text.len;
}
/// gap offset -> (row, col) cell
-pub fn hxPos(text: []const u8, off: usize) Cursor {
+pub fn positionAt(text: []const u8, off: usize) Cursor {
const bounded = @min(off, text.len);
- // The line start is the byte after the last '\n' BEFORE off, which is the
- // same number lineStartOffset(text, row) walks the whole prefix to reach —
- // one backward scan of a single line instead of a second pass over
- // everything above the cursor. On a multi-MB buffer that second pass was
- // most of what a keystroke cost.
+ // Find the line start backward without a second scan of the document prefix.
const s = if (std.mem.lastIndexOfScalar(u8, text[0..bounded], '\n')) |nl| nl + 1 else 0;
const e = std.mem.indexOfScalarPos(u8, text, s, '\n') orelse text.len;
const col = graphemeStart(text[s..e], @min(bounded - s, e - s));
- return .{ .row = hxLineOf(text, s + col), .col = col };
+ return .{ .row = lineAtOffset(text, s + col), .col = col };
}
/// (row, col) -> clamped gap offset; col == line length lands ON the '\n'
-pub fn hxOff(text: []const u8, c: Cursor) usize {
- const row = @min(c.row, hxLineCount(text) - 1);
+pub fn offsetAt(text: []const u8, c: Cursor) usize {
+ const row = @min(c.row, cursorLineCount(text) - 1);
const s = lineStartOffset(text, row);
- // hxLineEndIdx(text, row) inlined: it starts by walking to `row` again,
+ // lineEndOffset(text, row) inlined: it starts by walking to `row` again,
// and we are already standing there
const e = std.mem.indexOfScalarPos(u8, text, s, '\n') orelse text.len;
const raw = @min(c.col, e - s);
@@ -1008,9 +1244,9 @@ pub const WordTarget = enum {
// helix categorize_char: Eol is its OWN category, distinct from Whitespace —
// that distinction is load-bearing in reached_target.
-const HxCat = enum { word, punct, ws, eol };
+const WordClass = enum { word, punct, ws, eol };
-fn hxCatAt(text: []const u8, off: usize) HxCat {
+fn wordClassAt(text: []const u8, off: usize) WordClass {
if (off >= text.len) return .eol;
const cp = codepointAt(text, off);
if (cp == '\n' or cp == '\r') return .eol;
@@ -1021,25 +1257,25 @@ fn hxCatAt(text: []const u8, off: usize) HxCat {
};
}
-fn hxIsWs(c: HxCat) bool { // Rust char::is_whitespace (includes line endings)
+fn isWordWhitespace(c: WordClass) bool { // Rust char::is_whitespace (includes line endings)
return c == .ws or c == .eol;
}
-fn hxIsWordBoundary(a: HxCat, b: HxCat) bool {
+fn isWordBoundary(a: WordClass, b: WordClass) bool {
return a != b;
}
-fn hxIsLongBoundary(a: HxCat, b: HxCat) bool {
+fn isLongWordBoundary(a: WordClass, b: WordClass) bool {
if ((a == .word and b == .punct) or (a == .punct and b == .word)) return false;
return a != b;
}
-fn hxReached(target: WordTarget, prev: HxCat, next: HxCat) bool {
+fn reachedWordTarget(target: WordTarget, prev: WordClass, next: WordClass) bool {
return switch (target) {
- .next_word_start, .prev_word_end => hxIsWordBoundary(prev, next) and (next == .eol or !hxIsWs(next)),
- .next_word_end, .prev_word_start => hxIsWordBoundary(prev, next) and (!hxIsWs(prev) or next == .eol),
- .next_long_word_start, .prev_long_word_end => hxIsLongBoundary(prev, next) and (next == .eol or !hxIsWs(next)),
- .next_long_word_end, .prev_long_word_start => hxIsLongBoundary(prev, next) and (!hxIsWs(prev) or next == .eol),
+ .next_word_start, .prev_word_end => isWordBoundary(prev, next) and (next == .eol or !isWordWhitespace(next)),
+ .next_word_end, .prev_word_start => isWordBoundary(prev, next) and (!isWordWhitespace(prev) or next == .eol),
+ .next_long_word_start, .prev_long_word_end => isLongWordBoundary(prev, next) and (next == .eol or !isWordWhitespace(next)),
+ .next_long_word_end, .prev_long_word_start => isLongWordBoundary(prev, next) and (!isWordWhitespace(prev) or next == .eol),
};
}
@@ -1051,12 +1287,12 @@ fn wmIsPrev(t: WordTarget) bool {
}
/// w/b/e/W/B/E: helix word_move — each step selects the traversed span.
-pub fn hxWordMove(text: []const u8, r0: HxRange, count: usize, target: WordTarget) HxRange {
+pub fn moveWord(text: []const u8, r0: Selection, count: usize, target: WordTarget) Selection {
const is_prev = wmIsPrev(target);
if ((is_prev and r0.head == 0) or (!is_prev and r0.head == text.len)) return r0;
// block-cursor prep: collapse to the 1-wide cell at the head, pointing
// in the motion direction (the anchor of the input is irrelevant)
- var r: HxRange = if (is_prev)
+ var r: Selection = if (is_prev)
(if (r0.anchor < r0.head)
.{ .anchor = r0.head, .head = prevGrapheme(text, r0.head) }
else
@@ -1067,7 +1303,7 @@ pub fn hxWordMove(text: []const u8, r0: HxRange, count: usize, target: WordTarge
else
.{ .anchor = r0.head, .head = nextGrapheme(text, r0.head) });
for (0..@max(1, count)) |_| {
- const next = hxRangeToTarget(text, target, r, is_prev);
+ const next = wordRangeToTarget(text, target, r, is_prev);
if (next.anchor == r.anchor and next.head == r.head) break;
r = next;
}
@@ -1076,20 +1312,20 @@ pub fn hxWordMove(text: []const u8, r0: HxRange, count: usize, target: WordTarge
// port of CharHelpers::range_to_target — a char iterator walking away from
// origin.head; when reversed, "next" reads the byte just behind the position.
-fn hxRangeToTarget(text: []const u8, target: WordTarget, origin: HxRange, is_prev: bool) HxRange {
+fn wordRangeToTarget(text: []const u8, target: WordTarget, origin: Selection, is_prev: bool) Selection {
var anchor = origin.anchor;
var head = origin.head;
var it = origin.head;
- var prev_cat: ?HxCat = if (is_prev)
- (if (it < text.len) hxCatAt(text, it) else null)
+ var prev_cat: ?WordClass = if (is_prev)
+ (if (it < text.len) wordClassAt(text, it) else null)
else
- (if (it > 0) hxCatAt(text, prevGrapheme(text, it)) else null);
+ (if (it > 0) wordClassAt(text, prevGrapheme(text, it)) else null);
// skip any initial newline characters
while (true) {
if ((is_prev and it == 0) or (!is_prev and it >= text.len)) break;
const cell = if (is_prev) prevGrapheme(text, it) else it;
- const cat = hxCatAt(text, cell);
+ const cat = wordClassAt(text, cell);
if (cat != .eol) break;
it = if (is_prev) cell else nextGrapheme(text, cell);
prev_cat = cat;
@@ -1102,8 +1338,8 @@ fn hxRangeToTarget(text: []const u8, target: WordTarget, origin: HxRange, is_pre
while (true) {
if ((is_prev and it == 0) or (!is_prev and it >= text.len)) break;
const cell = if (is_prev) prevGrapheme(text, it) else it;
- const next_cat = hxCatAt(text, cell);
- if (prev_cat == null or hxReached(target, prev_cat.?, next_cat)) {
+ const next_cat = wordClassAt(text, cell);
+ if (prev_cat == null or reachedWordTarget(target, prev_cat.?, next_cat)) {
if (head == head_start) anchor = head else break;
}
prev_cat = next_cat;
@@ -1114,34 +1350,34 @@ fn hxRangeToTarget(text: []const u8, target: WordTarget, origin: HxRange, is_pre
}
/// a ropey "line is a line ending" — the line has no content of its own
-fn hxLineIsEmpty(text: []const u8, line: usize) bool {
- return lineStartOffset(text, line) == hxLineEndIdx(text, line);
+fn lineIsEmpty(text: []const u8, line: usize) bool {
+ return lineStartOffset(text, line) == lineEndOffset(text, line);
}
/// ]p / [p: helix move_next_paragraph / move_prev_paragraph
-pub fn hxParaMove(text: []const u8, r: HxRange, count: usize, fwd: bool, extend: bool) HxRange {
- const nlines = hxLineCount(text);
- const cursor = hxCursor(text, r);
- var line = hxLineOf(text, cursor);
+pub fn moveParagraph(text: []const u8, r: Selection, count: usize, fwd: bool, extend: bool) Selection {
+ const nlines = cursorLineCount(text);
+ const cursor = selectionCursor(text, r);
+ var line = lineAtOffset(text, cursor);
if (fwd) {
const nxt_start = if (line + 1 >= nlines) text.len else lineStartOffset(text, line + 1);
const last_char = prevGrapheme(text, nxt_start) == cursor;
- const curr_empty = hxLineIsEmpty(text, line);
- const next_empty = hxLineIsEmpty(text, @min(nlines - 1, line + 1));
+ const curr_empty = lineIsEmpty(text, line);
+ const next_empty = lineIsEmpty(text, @min(nlines - 1, line + 1));
const curr_empty_to_line = curr_empty and !next_empty;
// skip the character after the paragraph boundary
if (curr_empty_to_line and last_char) line += 1;
var l = line;
var last_line = l;
for (0..@max(1, count)) |_| {
- while (l < nlines and !hxLineIsEmpty(text, l)) l += 1;
- while (l < nlines and hxLineIsEmpty(text, l)) l += 1;
+ while (l < nlines and !lineIsEmpty(text, l)) l += 1;
+ while (l < nlines and lineIsEmpty(text, l)) l += 1;
if (l == last_line) break;
last_line = l;
}
const head = if (l >= nlines) text.len else lineStartOffset(text, l);
const anchor = if (extend)
- hxPutCursor(text, r, head, true).anchor
+ moveSelectionCursor(text, r, head, true).anchor
else if (curr_empty_to_line and last_char)
r.head
else
@@ -1149,22 +1385,22 @@ pub fn hxParaMove(text: []const u8, r: HxRange, count: usize, fwd: bool, extend:
return .{ .anchor = anchor, .head = head };
}
const first_char = lineStartOffset(text, line) == cursor;
- const prev_empty = hxLineIsEmpty(text, line -| 1);
- const curr_empty = hxLineIsEmpty(text, line);
+ const prev_empty = lineIsEmpty(text, line -| 1);
+ const curr_empty = lineIsEmpty(text, line);
const prev_empty_to_line = prev_empty and !curr_empty;
// skip the character before the paragraph boundary
if (prev_empty_to_line and !first_char) line += 1;
var l = line;
var last_line = l;
for (0..@max(1, count)) |_| {
- while (l > 0 and hxLineIsEmpty(text, l - 1)) l -= 1;
- while (l > 0 and !hxLineIsEmpty(text, l - 1)) l -= 1;
+ while (l > 0 and lineIsEmpty(text, l - 1)) l -= 1;
+ while (l > 0 and !lineIsEmpty(text, l - 1)) l -= 1;
if (l == last_line) break;
last_line = l;
}
const head = lineStartOffset(text, l);
const anchor = if (extend)
- hxPutCursor(text, r, head, true).anchor
+ moveSelectionCursor(text, r, head, true).anchor
else if (prev_empty_to_line and first_char)
cursor
else
@@ -1174,19 +1410,19 @@ pub fn hxParaMove(text: []const u8, r: HxRange, count: usize, fwd: bool, extend:
/// j/k target: helix move_vertically — goal_col clamps to the line's content
/// length, i.e. the cursor may land ON the '\n' of a shorter line.
-pub fn hxVertTarget(text: []const u8, pos: usize, down: bool, count: usize, goal_col: usize) usize {
- const nlines = hxLineCount(text);
- const line = hxLineOf(text, pos);
+pub fn verticalTarget(text: []const u8, pos: usize, down: bool, count: usize, goal_col: usize) usize {
+ const nlines = cursorLineCount(text);
+ const line = lineAtOffset(text, pos);
const nline = if (down) @min(line + @max(1, count), nlines - 1) else line -| @max(1, count);
const s = lineStartOffset(text, nline);
- // hxLineEndIdx(text, nline) without its second walk to nline (see hxOff)
+ // lineEndOffset(text, nline) without its second walk to nline (see offsetAt)
const e = std.mem.indexOfScalarPos(u8, text, s, '\n') orelse text.len;
return s + graphemeStart(text[s..e], @min(goal_col, e - s));
}
/// f/F/t/T target cell. helix find_char: the exclusive (till) search starts
/// one further out so repeats make progress; not-found = null (no move).
-pub fn hxFindTarget(text: []const u8, cursor: usize, ch: u21, fwd: bool, till: bool, count: usize) ?usize {
+pub fn findTarget(text: []const u8, cursor: usize, ch: u21, fwd: bool, till: bool, count: usize) ?usize {
var left = @max(1, count);
if (fwd) {
const head = nextGrapheme(text, cursor);
@@ -1212,17 +1448,17 @@ pub fn hxFindTarget(text: []const u8, cursor: usize, ch: u21, fwd: bool, till: b
}
// helix textobject.rs find_word_boundary
-fn hxFindWordBoundary(text: []const u8, pos0: usize, fwd: bool, long: bool) usize {
- var prev: HxCat = if (fwd)
- (if (pos0 == 0) .ws else hxCatAt(text, prevGrapheme(text, pos0)))
+fn findWordBoundary(text: []const u8, pos0: usize, fwd: bool, long: bool) usize {
+ var prev: WordClass = if (fwd)
+ (if (pos0 == 0) .ws else wordClassAt(text, prevGrapheme(text, pos0)))
else
- (if (pos0 >= text.len) .ws else hxCatAt(text, pos0));
+ (if (pos0 >= text.len) .ws else wordClassAt(text, pos0));
var pos = pos0;
var it = pos0;
while (true) {
if ((fwd and it >= text.len) or (!fwd and it == 0)) break;
const cell = if (fwd) it else prevGrapheme(text, it);
- const cat = hxCatAt(text, cell);
+ const cat = wordClassAt(text, cell);
if (cat == .eol or cat == .ws) return pos;
if (!long and cat != prev and pos != 0 and pos != text.len) return pos;
it = if (fwd) nextGrapheme(text, cell) else cell;
@@ -1234,34 +1470,34 @@ fn hxFindWordBoundary(text: []const u8, pos0: usize, fwd: bool, long: bool) usiz
/// miw/maw (and W): helix textobject_word — on whitespace it selects the
/// whitespace run's boundary (a 1-wide cursor there)
-pub fn hxTextobjectWord(text: []const u8, r: HxRange, around: bool, long: bool) HxRange {
- const pos = hxCursor(text, r);
- const word_start = hxFindWordBoundary(text, pos, false, long);
- const cat: HxCat = if (pos < text.len) hxCatAt(text, pos) else .ws;
- const word_end = if (cat == .ws or cat == .eol) pos else hxFindWordBoundary(text, nextGrapheme(text, pos), true, long);
+pub fn selectWord(text: []const u8, r: Selection, around: bool, long: bool) Selection {
+ const pos = selectionCursor(text, r);
+ const word_start = findWordBoundary(text, pos, false, long);
+ const cat: WordClass = if (pos < text.len) wordClassAt(text, pos) else .ws;
+ const word_end = if (cat == .ws or cat == .eol) pos else findWordBoundary(text, nextGrapheme(text, pos), true, long);
if (word_start == word_end or !around) return .{ .anchor = word_start, .head = word_end };
var end = word_end;
- while (end < text.len and hxIsWs(hxCatAt(text, end)) and hxCatAt(text, end) != .eol)
+ while (end < text.len and isWordWhitespace(wordClassAt(text, end)) and wordClassAt(text, end) != .eol)
end = nextGrapheme(text, end);
if (end > word_end) return .{ .anchor = word_start, .head = end };
var start = word_start;
while (start > 0) {
const before = prevGrapheme(text, start);
- const before_cat = hxCatAt(text, before);
- if (!hxIsWs(before_cat) or before_cat == .eol) break;
+ const before_cat = wordClassAt(text, before);
+ if (!isWordWhitespace(before_cat) or before_cat == .eol) break;
start = before;
}
return .{ .anchor = start, .head = word_end };
}
/// mip/map: helix textobject_paragraph
-pub fn hxTextobjectParagraph(text: []const u8, r: HxRange, around: bool, count: usize) HxRange {
- const nlines = hxLineCount(text);
- const cursor = hxCursor(text, r);
- var line = hxLineOf(text, cursor);
- const prev_empty = hxLineIsEmpty(text, line -| 1);
- const curr_empty = hxLineIsEmpty(text, line);
- const next_empty = line + 1 >= nlines or hxLineIsEmpty(text, line + 1);
+pub fn selectParagraph(text: []const u8, r: Selection, around: bool, count: usize) Selection {
+ const nlines = cursorLineCount(text);
+ const cursor = selectionCursor(text, r);
+ var line = lineAtOffset(text, cursor);
+ const prev_empty = lineIsEmpty(text, line -| 1);
+ const curr_empty = lineIsEmpty(text, line);
+ const next_empty = line + 1 >= nlines or lineIsEmpty(text, line + 1);
const nxt_start = if (line + 1 >= nlines) text.len else lineStartOffset(text, line + 1);
const last_char = prevGrapheme(text, nxt_start) == cursor;
const prev_empty_to_line = prev_empty and !curr_empty;
@@ -1271,29 +1507,29 @@ pub fn hxTextobjectParagraph(text: []const u8, r: HxRange, around: bool, count:
if (prev_empty_to_line or curr_empty_to_line) line_back += 1;
// do not include the current paragraph on a paragraph end (include next)
if (!(curr_empty_to_line and last_char)) {
- while (line_back > 0 and hxLineIsEmpty(text, line_back - 1)) line_back -= 1;
- while (line_back > 0 and !hxLineIsEmpty(text, line_back - 1)) line_back -= 1;
+ while (line_back > 0 and lineIsEmpty(text, line_back - 1)) line_back -= 1;
+ while (line_back > 0 and !lineIsEmpty(text, line_back - 1)) line_back -= 1;
}
if (curr_empty_to_line and last_char) line += 1;
const n = @max(1, count);
var count_done: usize = 0;
for (0..n) |_| {
var done = false;
- while (line < nlines and !hxLineIsEmpty(text, line)) {
+ while (line < nlines and !lineIsEmpty(text, line)) {
line += 1;
done = true;
}
- while (line < nlines and hxLineIsEmpty(text, line)) line += 1;
+ while (line < nlines and lineIsEmpty(text, line)) line += 1;
if (done) count_done += 1;
}
// search one paragraph backwards when we ran off the end
if (count_done != n and line >= nlines) {
- while (line_back > 0 and hxLineIsEmpty(text, line_back - 1)) line_back -= 1;
- while (line_back > 0 and !hxLineIsEmpty(text, line_back - 1)) line_back -= 1;
+ while (line_back > 0 and lineIsEmpty(text, line_back - 1)) line_back -= 1;
+ while (line_back > 0 and !lineIsEmpty(text, line_back - 1)) line_back -= 1;
}
if (!around) {
// inside: drop the trailing whitespace paragraph
- while (line > 0 and hxLineIsEmpty(text, line - 1)) line -= 1;
+ while (line > 0 and lineIsEmpty(text, line - 1)) line -= 1;
}
return .{
.anchor = lineStartOffset(text, line_back),
@@ -1301,25 +1537,25 @@ pub fn hxTextobjectParagraph(text: []const u8, r: HxRange, around: bool, count:
};
}
-test "hx textobject word and paragraph" {
+test "selection textobjects distinguish words and paragraph boundaries" {
const t = "alpha beta gamma\n";
// miw mid-word
- var r = hxTextobjectWord(t, .{ .anchor = 8, .head = 9 }, false, false);
+ var r = selectWord(t, .{ .anchor = 8, .head = 9 }, false, false);
try std.testing.expectEqual(@as(usize, 6), r.anchor);
try std.testing.expectEqual(@as(usize, 10), r.head);
// maw on the space after "beta": collapses to the boundary
- r = hxTextobjectWord(t, .{ .anchor = 10, .head = 11 }, true, false);
+ r = selectWord(t, .{ .anchor = 10, .head = 11 }, true, false);
try std.testing.expectEqual(@as(usize, 10), r.anchor);
try std.testing.expectEqual(@as(usize, 10), r.head);
const t2 = "aa\n\ncc\n";
// mip from the blank line selects the NEXT paragraph
- r = hxTextobjectParagraph(t2, .{ .anchor = 3, .head = 4 }, false, 1);
+ r = selectParagraph(t2, .{ .anchor = 3, .head = 4 }, false, 1);
try std.testing.expectEqual(@as(usize, 4), r.anchor);
try std.testing.expectEqual(@as(usize, 7), r.head);
}
/// leading-whitespace visual width (tab -> next multiple of INDENT_W)
-pub fn hxIndentWidth(line: []const u8) usize {
+pub fn indentWidth(line: []const u8) usize {
var w: usize = 0;
for (line) |ch| {
if (ch == ' ') w += 1 else if (ch == '\t') w = (w / INDENT_W + 1) * INDENT_W else break;
@@ -1329,37 +1565,32 @@ pub fn hxIndentWidth(line: []const u8) usize {
/// full indent LEVELS of a line as spaces (helix indent_level_for_line:
/// partial levels round down) — what o/O/insert-newline copy.
-pub fn hxIndentString(line: []const u8) []const u8 {
- const level = hxIndentWidth(line) / INDENT_W;
+pub fn indentText(line: []const u8) []const u8 {
+ const level = indentWidth(line) / INDENT_W;
const max = " "; // 8 levels is plenty (ponytail)
return max[0..@min(level * INDENT_W, max.len)];
}
-/// Indent width for an inserted newline. Keep the current full indent levels,
-/// then add one logical tab after a simple delimiter-shaped line ending.
-/// `)` intentionally includes both ordinary calls and the requested `})`
-/// continuation shape; this is syntax-agnostic and does not try to parse.
-pub fn hxNewlineIndentWidth(line: []const u8, col: usize) usize {
+/// Copy full indent levels and add one after (, [, {, or ), without parsing.
+pub fn newlineIndentWidth(line: []const u8, col: usize) usize {
const prefix = std.mem.trimEnd(u8, line[0..@min(col, line.len)], " \t");
const extra = if (prefix.len == 0) false else switch (prefix[prefix.len - 1]) {
'(', '[', '{', ')' => true,
else => false,
};
- return hxIndentString(line).len + @as(usize, if (extra) INDENT_W else 0);
+ return indentText(line).len + @as(usize, if (extra) INDENT_W else 0);
}
test "newline indent keeps levels and adds one after delimiters" {
- try std.testing.expectEqual(@as(usize, 4), hxNewlineIndentWidth(" value", 9));
- try std.testing.expectEqual(@as(usize, 8), hxNewlineIndentWidth(" call()", 10));
- try std.testing.expectEqual(@as(usize, 8), hxNewlineIndentWidth(" callback({}) ", 16));
- try std.testing.expectEqual(@as(usize, 4), hxNewlineIndentWidth("work(", 5));
- try std.testing.expectEqual(@as(usize, 4), hxNewlineIndentWidth("list[tail", 5));
+ try std.testing.expectEqual(@as(usize, 4), newlineIndentWidth(" value", 9));
+ try std.testing.expectEqual(@as(usize, 8), newlineIndentWidth(" call()", 10));
+ try std.testing.expectEqual(@as(usize, 8), newlineIndentWidth(" callback({}) ", 16));
+ try std.testing.expectEqual(@as(usize, 4), newlineIndentWidth("work(", 5));
+ try std.testing.expectEqual(@as(usize, 4), newlineIndentWidth("list[tail", 5));
}
-/// helix Ctrl-a / Ctrl-x: increment the SELECTED text as a decimal integer.
-/// Zero-padding is preserved (width follows sign flips, helix-style).
-/// Ponytail: no 0x/0o/0b bases, no '_' separators — decimal only.
-pub fn hxIncrement(alloc: std.mem.Allocator, frag: []const u8, amount: i64) !?[]u8 {
+/// Increment a selected decimal integer, preserving zero-padding across sign changes.
+pub fn incrementDecimal(alloc: std.mem.Allocator, frag: []const u8, amount: i64) !?[]u8 {
if (frag.len == 0) return null;
const neg = frag[0] == '-';
const digits = if (neg) frag[1..] else frag;
@@ -1391,80 +1622,80 @@ pub fn hxIncrement(alloc: std.mem.Allocator, frag: []const u8, amount: i64) !?[]
return out;
}
-test "hx word moves match helix" {
+test "word selections match Helix motions" {
const t = "alpha beta\n";
// w from a fresh 1-wide cursor selects "alpha " (cursor on the space)
- var r = hxWordMove(t, .{ .anchor = 0, .head = 1 }, 1, .next_word_start);
+ var r = moveWord(t, .{ .anchor = 0, .head = 1 }, 1, .next_word_start);
try std.testing.expectEqual(@as(usize, 0), r.anchor);
try std.testing.expectEqual(@as(usize, 6), r.head);
// e from the same start ends on 'a' of alpha
- r = hxWordMove(t, .{ .anchor = 0, .head = 1 }, 1, .next_word_end);
+ r = moveWord(t, .{ .anchor = 0, .head = 1 }, 1, .next_word_end);
try std.testing.expectEqual(@as(usize, 5), r.head);
try std.testing.expectEqual(@as(usize, 0), r.anchor);
// b from the w result selects "alpha" backward
- r = hxWordMove(t, .{ .anchor = 6, .head = 10 }, 1, .prev_word_start);
+ r = moveWord(t, .{ .anchor = 6, .head = 10 }, 1, .prev_word_start);
try std.testing.expectEqual(@as(usize, 10), r.anchor);
try std.testing.expectEqual(@as(usize, 6), r.head);
// 2w on "one two three": anchor comes from the last hop only
const t2 = "one two three\n";
- r = hxWordMove(t2, .{ .anchor = 0, .head = 1 }, 2, .next_word_start);
+ r = moveWord(t2, .{ .anchor = 0, .head = 1 }, 2, .next_word_start);
try std.testing.expectEqual(@as(usize, 4), r.anchor);
try std.testing.expectEqual(@as(usize, 8), r.head);
// w at EOF collapses to a zero-width range at len
const t3 = "alpha\n";
- r = hxWordMove(t3, .{ .anchor = 0, .head = 5 }, 1, .next_word_start);
+ r = moveWord(t3, .{ .anchor = 0, .head = 5 }, 1, .next_word_start);
try std.testing.expectEqual(@as(usize, 6), r.head);
try std.testing.expectEqual(@as(usize, 6), r.anchor);
// W treats punct runs as word chars
const t4 = "foo.bar baz\n";
- r = hxWordMove(t4, .{ .anchor = 0, .head = 1 }, 1, .next_long_word_start);
+ r = moveWord(t4, .{ .anchor = 0, .head = 1 }, 1, .next_long_word_start);
try std.testing.expectEqual(@as(usize, 0), r.anchor);
try std.testing.expectEqual(@as(usize, 8), r.head);
}
-test "hx put cursor keeps the anchor cell across crossings" {
+test "selection cursor keeps the anchor cell across crossings" {
const t = "abcdef\n";
// forward range [2,3) extended left of the anchor: anchor cell stays 2
- var r = hxPutCursor(t, .{ .anchor = 2, .head = 3 }, 0, true);
+ var r = moveSelectionCursor(t, .{ .anchor = 2, .head = 3 }, 0, true);
try std.testing.expectEqual(@as(usize, 3), r.anchor);
try std.testing.expectEqual(@as(usize, 0), r.head);
- try std.testing.expectEqual(@as(usize, 0), hxCursor(t, r));
+ try std.testing.expectEqual(@as(usize, 0), selectionCursor(t, r));
// and back: cursor to 4 -> forward again, anchor gap back to 2
- r = hxPutCursor(t, r, 4, true);
+ r = moveSelectionCursor(t, r, 4, true);
try std.testing.expectEqual(@as(usize, 2), r.anchor);
try std.testing.expectEqual(@as(usize, 5), r.head);
}
-test "hx paragraph moves" {
+test "paragraph selections cross blank lines" {
const t = "aa\nbb\n\ncc\ndd\n\nee\n";
// ]p from the top selects through the blank line to the next block
- var r = hxParaMove(t, .{ .anchor = 0, .head = 1 }, 1, true, false);
+ var r = moveParagraph(t, .{ .anchor = 0, .head = 1 }, 1, true, false);
try std.testing.expectEqual(@as(usize, 0), r.anchor);
try std.testing.expectEqual(@as(usize, 7), r.head);
// [p from "ee" (line 6, offset 14) goes back to "cc" block start
- r = hxParaMove(t, .{ .anchor = 14, .head = 15 }, 1, false, false);
+ r = moveParagraph(t, .{ .anchor = 14, .head = 15 }, 1, false, false);
try std.testing.expectEqual(@as(usize, 14), r.anchor);
try std.testing.expectEqual(@as(usize, 7), r.head);
}
-test "hx vertical: goal col clamps onto the newline cell" {
+test "vertical target clamps the goal column onto the newline cell" {
const t = "abcdef\nab\nabcdef\n";
// from (0,5) down: line "ab" clamps to its '\n' at offset 9
- try std.testing.expectEqual(@as(usize, 9), hxVertTarget(t, 5, true, 1, 5));
+ try std.testing.expectEqual(@as(usize, 9), verticalTarget(t, 5, true, 1, 5));
// two down with the same goal restores col 5
- try std.testing.expectEqual(@as(usize, 15), hxVertTarget(t, 9, true, 1, 5));
+ try std.testing.expectEqual(@as(usize, 15), verticalTarget(t, 9, true, 1, 5));
}
-test "hx find targets" {
+test "find targets count matches and skip adjacent till targets" {
const t = "abcabc\n";
- try std.testing.expectEqual(@as(usize, 2), hxFindTarget(t, 0, 'c', true, false, 1).?);
- try std.testing.expectEqual(@as(usize, 5), hxFindTarget(t, 0, 'c', true, false, 2).?);
- try std.testing.expectEqual(@as(usize, 1), hxFindTarget(t, 0, 'c', true, true, 1).?);
+ try std.testing.expectEqual(@as(usize, 2), findTarget(t, 0, 'c', true, false, 1).?);
+ try std.testing.expectEqual(@as(usize, 5), findTarget(t, 0, 'c', true, false, 2).?);
+ try std.testing.expectEqual(@as(usize, 1), findTarget(t, 0, 'c', true, true, 1).?);
// till repeat skips the adjacent target: from cell 1, next tc reaches 4
- try std.testing.expectEqual(@as(usize, 4), hxFindTarget(t, 1, 'c', true, true, 1).?);
- try std.testing.expectEqual(@as(usize, 3), hxFindTarget(t, 5, 'a', false, false, 1).?);
- try std.testing.expectEqual(@as(usize, 4), hxFindTarget(t, 5, 'a', false, true, 1).?);
- try std.testing.expectEqual(@as(?usize, null), hxFindTarget(t, 0, 'z', true, false, 1));
+ try std.testing.expectEqual(@as(usize, 4), findTarget(t, 1, 'c', true, true, 1).?);
+ try std.testing.expectEqual(@as(usize, 3), findTarget(t, 5, 'a', false, false, 1).?);
+ try std.testing.expectEqual(@as(usize, 4), findTarget(t, 5, 'a', false, true, 1).?);
+ try std.testing.expectEqual(@as(?usize, null), findTarget(t, 0, 'z', true, false, 1));
}
test "extended grapheme boundaries cover combining emoji flag and CJK text" {
@@ -1482,10 +1713,7 @@ test "extended grapheme boundaries cover combining emoji flag and CJK text" {
}
test "the ASCII arms of graphemeStart and nextGrapheme agree with the UAX #29 walk" {
- // Both functions answer ASCII from arithmetic and hand everything else to the segmenter. The
- // guard is a claim about UAX #29 (an ASCII scalar is its own cluster unless the next scalar
- // extends it, and every extender is non-ASCII), so pin it against the walk it skips rather
- // than against transcribed offsets: same text, both routes, every offset including past the end.
+ // Compare every offset against segmentation with the ASCII fast paths removed.
const H = struct {
// `graphemeStart` with the ASCII arm deleted — nothing else changed.
fn start(text: []const u8, off: usize) usize {
@@ -1522,19 +1750,14 @@ test "the ASCII arms of graphemeStart and nextGrapheme agree with the UAX #29 wa
}
};
- // Scalars that extend a preceding ASCII base into ONE cluster, which is the whole reason the
- // fast path inspects its neighbour: a combining mark, a ZWJ sequence, a spacing mark
- // (Devanagari visarga), a variation selector. Plus wide glyphs, a regional-indicator pair,
- // and three shapes of invalid UTF-8 the segmenter must still be trusted with: a bad start
- // byte, a truncated tail, a bad continuation.
+ // Combining marks, ZWJ, spacing marks, selectors, wide glyphs, flags, and invalid UTF-8.
const neighbours = [_][]const u8{
"", "a", "\u{301}", "\u{200d}\u{1f680}",
"\u{903}", "\u{fe0f}", "\u{20e3}", "\u{4e16}\u{754c}",
"\u{1f642}", "\u{1f1e6}\u{1f1e7}", "\xff", "\xe4\xb8",
"\xe4\x28\xb8",
};
- // Every byte the range test can see, ASCII and not: 0x20..0x7e take the fast path, and \t, \r,
- // the rest of the C0 controls and DEL are excluded by it and must still reach the same answer.
+ // Every ASCII byte paired with each Unicode or invalid neighbor, in both orders.
var buf: [16]u8 = undefined;
var b: u8 = 0;
while (b < 0x80) : (b += 1) {
@@ -1549,26 +1772,13 @@ test "the ASCII arms of graphemeStart and nextGrapheme agree with the UAX #29 wa
}
}
- // Text that has no CR-LF pair in it: GB3 is the one ASCII-only rule that joins two clusters,
- // and it gets its own test below because it is the single exclusion every fast path has to
- // carry by hand.
for ([_][]const u8{ "a\r", "\ra", "\n\r", "a\rb\nc" }) |text| try H.check(text);
// Mixed text long enough that a fast-path run starts, ends and restarts inside one string.
try H.check("plain ascii then \u{4e16}\u{754c} then e\u{301} then more ascii");
}
-// GB3 is the one UAX #29 rule that joins two ASCII scalars: CR takes a following LF into the same
-// cluster. Each of the three steppers carries that exclusion separately - `graphemeStart` at :875,
-// `nextGrapheme`'s ASCII arm at :896, `prevGrapheme`'s at :916 - so nothing but a test keeps them
-// agreeing. The invariant is that all three answer the same CRLF boundary: for every cluster the
-// segmenter reports, `graphemeStart` maps its start to itself, `nextGrapheme` maps that start to
-// its end, and `prevGrapheme` maps its end back to the start.
-//
-// This was a live bug: `nextGrapheme` and `prevGrapheme` stepped exactly one byte whenever the
-// byte at the offset and its neighbour were ASCII, so on a CRLF file the flat-buffer range engine
-// could step a head to offset 1 and `graphemeStart` would repair that same offset back to 0. Both
-// arms now spell the exclusion out, and this test is what holds them there.
+// CR-LF must have identical boundaries in forward, backward, and containing-cluster queries.
test "GB3 keeps CR-LF one cluster for every grapheme step" {
const text = "a\r\nb";
// The reference: the same segmentation the slow arms of these functions run.
@@ -1588,112 +1798,69 @@ test "GB3 keeps CR-LF one cluster for every grapheme step" {
}
test "Unicode find and word motion stay on grapheme boundaries" {
- const lines = [_][]const u8{"\u{e9}x\u{e9}"};
- try std.testing.expectEqual(Cursor{ .row = 0, .col = 3 }, findChar(&lines, .{ .row = 0, .col = 0 }, 'é', true, false, 1).?);
+ try std.testing.expectEqual(@as(usize, 3), findTarget("\u{e9}x\u{e9}", 0, 'é', true, false, 1).?);
const text = "café 世界 ok\n";
- const first = hxWordMove(text, .{ .anchor = 0, .head = 1 }, 1, .next_word_start);
+ const first = moveWord(text, .{ .anchor = 0, .head = 1 }, 1, .next_word_start);
try std.testing.expectEqual(@as(usize, 0), first.anchor);
try std.testing.expectEqual(@as(usize, 6), first.head);
- const second = hxWordMove(text, .{ .anchor = 0, .head = 1 }, 2, .next_word_start);
+ const second = moveWord(text, .{ .anchor = 0, .head = 1 }, 2, .next_word_start);
try std.testing.expectEqual(@as(usize, 6), second.anchor);
try std.testing.expectEqual(@as(usize, 13), second.head);
// Long-word motions split on Unicode whitespace, not only ASCII spaces.
const nbsp = "alpha\u{a0}beta\n";
- const long = hxWordMove(nbsp, .{ .anchor = 0, .head = 1 }, 1, .next_long_word_start);
+ const long = moveWord(nbsp, .{ .anchor = 0, .head = 1 }, 1, .next_long_word_start);
try std.testing.expectEqual(@as(usize, 7), long.head);
}
-test "hx increment" {
+test "decimal increment preserves padding and handles sign changes" {
const a = std.testing.allocator;
{
- const r = (try hxIncrement(a, "15", 1)).?;
+ const r = (try incrementDecimal(a, "15", 1)).?;
defer a.free(r);
try std.testing.expectEqualStrings("16", r);
}
{
- const r = (try hxIncrement(a, "007", 1)).?;
+ const r = (try incrementDecimal(a, "007", 1)).?;
defer a.free(r);
try std.testing.expectEqualStrings("008", r);
}
{
- const r = (try hxIncrement(a, "-3", 1)).?;
+ const r = (try incrementDecimal(a, "-3", 1)).?;
defer a.free(r);
try std.testing.expectEqualStrings("-2", r);
}
{
- const r = (try hxIncrement(a, "9", -10)).?;
+ const r = (try incrementDecimal(a, "9", -10)).?;
defer a.free(r);
try std.testing.expectEqualStrings("-1", r);
}
- try std.testing.expectEqual(@as(?[]u8, null), try hxIncrement(a, "a 1", 1));
- try std.testing.expectEqual(@as(?[]u8, null), try hxIncrement(a, "", 1));
+ try std.testing.expectEqual(@as(?[]u8, null), try incrementDecimal(a, "a 1", 1));
+ try std.testing.expectEqual(@as(?[]u8, null), try incrementDecimal(a, "", 1));
}
-// ---- tests ----
-
-test "kindOf" {
- try std.testing.expectEqual(Kind.word, kindOf('a'));
- try std.testing.expectEqual(Kind.word, kindOf('_'));
- try std.testing.expectEqual(Kind.word, kindOf('9'));
- try std.testing.expectEqual(Kind.punct, kindOf('.'));
- try std.testing.expectEqual(Kind.punct, kindOf('('));
- try std.testing.expectEqual(Kind.ws, kindOf(' '));
- try std.testing.expectEqual(Kind.ws, kindOf('\n'));
+test "codepoint classes distinguish words punctuation and whitespace" {
+ try std.testing.expectEqual(Kind.word, kindOfCodepoint('a'));
+ try std.testing.expectEqual(Kind.word, kindOfCodepoint('_'));
+ try std.testing.expectEqual(Kind.word, kindOfCodepoint('9'));
+ try std.testing.expectEqual(Kind.punct, kindOfCodepoint('.'));
+ try std.testing.expectEqual(Kind.punct, kindOfCodepoint('('));
+ try std.testing.expectEqual(Kind.ws, kindOfCodepoint(' '));
+ try std.testing.expectEqual(Kind.ws, kindOfCodepoint('\n'));
}
-test "char/line motions" {
+test "insert cursor steps characters and skips indentation" {
const lines = [_][]const u8{ "alpha beta", " two words", "x" };
const c = Cursor{ .row = 0, .col = 5 };
try std.testing.expectEqual(Cursor{ .row = 0, .col = 4 }, charLeft(&.{"hello"}, c));
try std.testing.expectEqual(Cursor{ .row = 0, .col = 6 }, charRight(&lines, c));
- try std.testing.expectEqual(Cursor{ .row = 1, .col = 5 }, lineDown(&lines, c));
- try std.testing.expectEqual(Cursor{ .row = 0, .col = 5 }, lineUp(&lines, Cursor{ .row = 1, .col = 5 }));
- // line ends
- try std.testing.expectEqual(Cursor{ .row = 0, .col = 9 }, lineEnd(&lines, c));
- try std.testing.expectEqual(Cursor{ .row = 2, .col = 0 }, lineEnd(&lines, Cursor{ .row = 2, .col = 0 }));
- // first non-ws
try std.testing.expectEqual(Cursor{ .row = 1, .col = 2 }, firstNonWsOf(&lines, Cursor{ .row = 1, .col = 0 }));
// cursor row past the content (mouse click below a short pane): no panic
try std.testing.expectEqual(Cursor{ .row = 24, .col = 0 }, firstNonWsOf(&lines, Cursor{ .row = 24, .col = 3 }));
- try std.testing.expectEqual(Cursor{ .row = 0, .col = 0 }, paragraphBwd(&lines, Cursor{ .row = 24, .col = 0 }));
- try std.testing.expectEqual(Cursor{ .row = 0, .col = 0 }, paragraphBwd(&[_][]const u8{}, Cursor{ .row = 5, .col = 0 }));
-}
-
-test "word motions w/b/e" {
- const lines = [_][]const u8{"this is a test"};
- const w = &lines;
- // "this is a test", indices 0..13
- try std.testing.expectEqual(Cursor{ .row = 0, .col = 5 }, nextWordStart(w, Cursor{ .row = 0, .col = 0 }, false)); // t->next word "is"
- try std.testing.expectEqual(Cursor{ .row = 0, .col = 8 }, nextWordStart(w, Cursor{ .row = 0, .col = 5 }, false)); // -> "a"
- try std.testing.expectEqual(Cursor{ .row = 0, .col = 10 }, nextWordStart(w, Cursor{ .row = 0, .col = 8 }, false)); // -> "test"
- try std.testing.expectEqual(Cursor{ .row = 0, .col = 10 }, nextWordStart(w, Cursor{ .row = 0, .col = 9 }, false)); // from ws
- // b
- try std.testing.expectEqual(Cursor{ .row = 0, .col = 8 }, prevWordStart(w, Cursor{ .row = 0, .col = 10 }, false)); // test -> "a"
- try std.testing.expectEqual(Cursor{ .row = 0, .col = 5 }, prevWordStart(w, Cursor{ .row = 0, .col = 8 }, false)); // -> "is"
- try std.testing.expectEqual(Cursor{ .row = 0, .col = 0 }, prevWordStart(w, Cursor{ .row = 0, .col = 5 }, false)); // -> "this"
- // e
- try std.testing.expectEqual(Cursor{ .row = 0, .col = 3 }, nextWordEnd(w, Cursor{ .row = 0, .col = 0 }, false)); // this[3]
- try std.testing.expectEqual(Cursor{ .row = 0, .col = 6 }, nextWordEnd(w, Cursor{ .row = 0, .col = 3 }, false)); // -> "is"[6]
- try std.testing.expectEqual(Cursor{ .row = 0, .col = 13 }, nextWordEnd(w, Cursor{ .row = 0, .col = 10 }, false)); // -> "test"[13]
-}
-
-test "word motions cross line" {
- const lines = [_][]const u8{ "foo bar", "", "baz" };
- const w = &lines;
- // from end of "foo bar" (row0 col6) w crosses the blank line to "baz"
- try std.testing.expectEqual(Cursor{ .row = 2, .col = 0 }, nextWordStart(w, Cursor{ .row = 0, .col = 6 }, false));
- // b from "baz" crosses back to "bar"
- try std.testing.expectEqual(Cursor{ .row = 0, .col = 4 }, prevWordStart(w, Cursor{ .row = 2, .col = 0 }, false));
- // e from row0 col0 -> "foo" end (col2)
- try std.testing.expectEqual(Cursor{ .row = 0, .col = 2 }, nextWordEnd(w, Cursor{ .row = 0, .col = 0 }, false));
}
test "lineSpan agrees with the whole-document scans it replaces" {
- // The bounded scan is only worth having if it is indistinguishable from the pair it replaced,
- // including at the edges that make line counting awkward: an empty document, a trailing
- // newline (which is its own empty last line), and a row past the end.
for ([_][]const u8{ "", "a", "a\n", "a\nbb\n", "a\nbb\nccc", "\n", "\n\n" }) |content| {
const n = lineCount(content);
var row: usize = 0;
@@ -1717,27 +1884,11 @@ test "insertAt still clamps a row past the end onto the last line" {
defer gpa.free(out);
try std.testing.expectEqualStrings("a\nbb\ncccX", out);
- // And an in-range insert lands where the old spelling put it.
const mid = try insertAt(gpa, content, .{ .row = 1, .col = 1 }, "X");
defer gpa.free(mid);
try std.testing.expectEqualStrings("a\nbXb\nccc", mid);
}
-test "long word W treats punct as word" {
- // "foo.bar baz" : W from 0 -> "baz" at 8 (foo.bar is one long word)
- const lines = [_][]const u8{"foo.bar baz"};
- const w = &lines;
- try std.testing.expectEqual(Cursor{ .row = 0, .col = 8 }, nextWordStart(w, Cursor{ .row = 0, .col = 0 }, true));
- // w (non-long) from 0 -> '.' at 3 (punct is its own word, like vim/helix)
- try std.testing.expectEqual(Cursor{ .row = 0, .col = 3 }, nextWordStart(w, Cursor{ .row = 0, .col = 0 }, false));
-}
-
-test "goto" {
- const lines = [_][]const u8{ "a", "b", "c" };
- try std.testing.expectEqual(Cursor{ .row = 0, .col = 0 }, gotoFirst());
- try std.testing.expectEqual(Cursor{ .row = 2, .col = 0 }, gotoLast(&lines));
-}
-
test "lineStartOffset + lineSlice" {
const content = "alpha\nbeta\n\ngamma";
try std.testing.expectEqual(@as(usize, 0), lineStartOffset(content, 0));
@@ -1858,23 +2009,6 @@ test "clearLine" {
try std.testing.expectEqualStrings("keep\n\nkeep2", r);
}
-test "findChar f/F/t/T across lines and counts" {
- const lines = [_][]const u8{ "abcabc", "xa" };
- const w = &lines;
- // f: next occurrence, on it
- try std.testing.expectEqual(Cursor{ .row = 0, .col = 3 }, findChar(w, .{ .row = 0, .col = 0 }, 'a', true, false, 1).?);
- // count: 2fa crosses into the next line
- try std.testing.expectEqual(Cursor{ .row = 1, .col = 1 }, findChar(w, .{ .row = 0, .col = 0 }, 'a', true, false, 2).?);
- // t stops one short
- try std.testing.expectEqual(Cursor{ .row = 0, .col = 2 }, findChar(w, .{ .row = 0, .col = 0 }, 'a', true, true, 1).?);
- // F backward, on it
- try std.testing.expectEqual(Cursor{ .row = 0, .col = 0 }, findChar(w, .{ .row = 0, .col = 3 }, 'a', false, false, 1).?);
- // T backward stops one after
- try std.testing.expectEqual(Cursor{ .row = 0, .col = 1 }, findChar(w, .{ .row = 0, .col = 3 }, 'a', false, true, 1).?);
- // not found: null, no move
- try std.testing.expectEqual(@as(?Cursor, null), findChar(w, .{ .row = 0, .col = 0 }, 'z', true, false, 1));
-}
-
test "matchBracket nesting both directions" {
const lines = [_][]const u8{"a (b (c) d) e"};
const w = &lines;
@@ -1891,78 +2025,30 @@ test "matchBracket across lines" {
try std.testing.expectEqual(Cursor{ .row = 0, .col = 7 }, matchBracket(w, .{ .row = 2, .col = 0 }).?);
}
-test "paragraph motions" {
- const lines = [_][]const u8{ "one", "two", "", "", "three", "four", "", "five" };
- const w = &lines;
- try std.testing.expectEqual(Cursor{ .row = 4, .col = 0 }, paragraphFwd(w, .{ .row = 0, .col = 1 }));
- try std.testing.expectEqual(Cursor{ .row = 7, .col = 0 }, paragraphFwd(w, .{ .row = 4, .col = 0 }));
- // no next block: the last line
- try std.testing.expectEqual(Cursor{ .row = 7, .col = 0 }, paragraphFwd(w, .{ .row = 7, .col = 0 }));
- // from mid-block up to its start; from a start up to the previous block's
- try std.testing.expectEqual(Cursor{ .row = 4, .col = 0 }, paragraphBwd(w, .{ .row = 5, .col = 1 }));
- try std.testing.expectEqual(Cursor{ .row = 0, .col = 0 }, paragraphBwd(w, .{ .row = 4, .col = 0 }));
- try std.testing.expectEqual(Cursor{ .row = 0, .col = 0 }, paragraphBwd(w, .{ .row = 0, .col = 0 }));
-}
-
-test "pairRange inside/around, cursor on and between brackets" {
+test "enclosing pair includes delimiters and chooses the nearest nested pair" {
const lines = [_][]const u8{"f(a, (b))"};
const w = &lines;
- const around = pairRange(w, .{ .row = 0, .col = 3 }, '(', ')', true).?;
+ const around = enclosingPair(w, .{ .row = 0, .col = 3 }, '(', ')').?;
try std.testing.expectEqual(Cursor{ .row = 0, .col = 1 }, around.a);
try std.testing.expectEqual(Cursor{ .row = 0, .col = 8 }, around.b);
- const inside = pairRange(w, .{ .row = 0, .col = 3 }, '(', ')', false).?;
- try std.testing.expectEqual(Cursor{ .row = 0, .col = 2 }, inside.a);
- try std.testing.expectEqual(Cursor{ .row = 0, .col = 7 }, inside.b);
- // cursor on the nested open picks the nested pair
- const nested = pairRange(w, .{ .row = 0, .col = 5 }, '(', ')', false).?;
- try std.testing.expectEqual(Cursor{ .row = 0, .col = 6 }, nested.a);
- try std.testing.expectEqual(Cursor{ .row = 0, .col = 6 }, nested.b);
- // empty pair: no inside
+ const nested = enclosingPair(w, .{ .row = 0, .col = 5 }, '(', ')').?;
+ try std.testing.expectEqual(Cursor{ .row = 0, .col = 5 }, nested.a);
+ try std.testing.expectEqual(Cursor{ .row = 0, .col = 7 }, nested.b);
const empty = [_][]const u8{"()"};
- try std.testing.expectEqual(@as(?Range, null), pairRange(&empty, .{ .row = 0, .col = 0 }, '(', ')', false));
- // not enclosed
- try std.testing.expectEqual(@as(?Range, null), pairRange(&empty, .{ .row = 0, .col = 1 }, '[', ']', false));
+ const pair = enclosingPair(&empty, .{ .row = 0, .col = 0 }, '(', ')').?;
+ try std.testing.expectEqual(Cursor{ .row = 0, .col = 0 }, pair.a);
+ try std.testing.expectEqual(Cursor{ .row = 0, .col = 1 }, pair.b);
+ try std.testing.expectEqual(@as(?Range, null), enclosingPair(&empty, .{ .row = 0, .col = 1 }, '[', ']'));
}
-test "quoteRange line-scoped" {
+test "enclosing quote stays on its line" {
const lines = [_][]const u8{"say 'hi there' end"};
const w = &lines;
- const r = quoteRange(w, .{ .row = 0, .col = 7 }, '\'', true).?;
+ const r = enclosingQuote(w, .{ .row = 0, .col = 7 }, '\'').?;
try std.testing.expectEqual(Cursor{ .row = 0, .col = 4 }, r.a);
try std.testing.expectEqual(Cursor{ .row = 0, .col = 13 }, r.b);
- const ri = quoteRange(w, .{ .row = 0, .col = 7 }, '\'', false).?;
- try std.testing.expectEqual(Cursor{ .row = 0, .col = 5 }, ri.a);
- try std.testing.expectEqual(Cursor{ .row = 0, .col = 12 }, ri.b);
- // cursor after the pair: not enclosed
- try std.testing.expectEqual(@as(?Range, null), quoteRange(w, .{ .row = 0, .col = 16 }, '\'', true));
-}
-
-test "wordRange inside/around" {
- const lines = [_][]const u8{"one two.three"};
- const w = &lines;
- const r = wordRange(w, .{ .row = 0, .col = 1 }, false, false).?;
- try std.testing.expectEqual(Cursor{ .row = 0, .col = 0 }, r.a);
- try std.testing.expectEqual(Cursor{ .row = 0, .col = 2 }, r.b);
- // around eats the trailing spaces
- const ra = wordRange(w, .{ .row = 0, .col = 1 }, false, true).?;
- try std.testing.expectEqual(Cursor{ .row = 0, .col = 4 }, ra.b);
- // long word spans the dot
- const rl = wordRange(w, .{ .row = 0, .col = 6 }, true, false).?;
- try std.testing.expectEqual(Cursor{ .row = 0, .col = 5 }, rl.a);
- try std.testing.expectEqual(Cursor{ .row = 0, .col = 13 }, rl.b);
- // on whitespace: none
- try std.testing.expectEqual(@as(?Range, null), wordRange(w, .{ .row = 0, .col = 3 }, false, false));
-}
-
-test "paragraphRange inside/around" {
- const lines = [_][]const u8{ "a", "b", "", "c" };
- const w = &lines;
- const r = paragraphRange(w, .{ .row = 1, .col = 0 }, false).?;
- try std.testing.expectEqual(Cursor{ .row = 0, .col = 0 }, r.a);
- try std.testing.expectEqual(Cursor{ .row = 1, .col = 0 }, r.b);
- const ra = paragraphRange(w, .{ .row = 1, .col = 0 }, true).?;
- try std.testing.expectEqual(Cursor{ .row = 2, .col = 0 }, ra.b);
- try std.testing.expectEqual(@as(?Range, null), paragraphRange(w, .{ .row = 2, .col = 0 }, false));
+ try std.testing.expectEqual(@as(?Range, null), enclosingQuote(w, .{ .row = 0, .col = 16 }, '\''));
+ try std.testing.expectEqual(@as(?Range, null), enclosingQuote(&.{ "'open", "close'" }, .{}, '\''));
}
test "advanceBy" {
@@ -1997,46 +2083,6 @@ test "changeCase" {
try std.testing.expectEqualStrings("ab CD", up);
}
-test "joinLine" {
- const a = std.testing.allocator;
- const r = (try joinLine(a, "one\n two\nthree", 0)).?;
- defer a.free(r.content);
- try std.testing.expectEqualStrings("one two\nthree", r.content);
- try std.testing.expectEqual(@as(usize, 3), r.col);
- // last line: nothing to join
- try std.testing.expectEqual(@as(?@TypeOf(r), null), try joinLine(a, "one", 0));
-}
-
-test "indentLines add and remove" {
- const a = std.testing.allocator;
- const r = try indentLines(a, "one\n\ntwo", 0, 2, true);
- defer a.free(r);
- try std.testing.expectEqualStrings(" one\n\n two", r);
- const u = try indentLines(a, " one\n\ttwo\n three\nx", 0, 2, false);
- defer a.free(u);
- try std.testing.expectEqualStrings("one\ntwo\nthree\nx", u);
-}
-
-test "adjustNumber" {
- const a = std.testing.allocator;
- const r = (try adjustNumber(a, "x 41 y", .{ .row = 0, .col = 3 }, 1)).?;
- defer a.free(r.content);
- try std.testing.expectEqualStrings("x 42 y", r.content);
- try std.testing.expectEqual(Cursor{ .row = 0, .col = 3 }, r.cur);
- // negative, cursor on the '-'
- const n = (try adjustNumber(a, "v=-1;", .{ .row = 0, .col = 2 }, -1)).?;
- defer a.free(n.content);
- try std.testing.expectEqualStrings("v=-2;", n.content);
- try std.testing.expectEqual(Cursor{ .row = 0, .col = 3 }, n.cur);
- // width change moves the last-digit column
- const g = (try adjustNumber(a, "9", .{ .row = 0, .col = 0 }, 1)).?;
- defer a.free(g.content);
- try std.testing.expectEqualStrings("10", g.content);
- try std.testing.expectEqual(Cursor{ .row = 0, .col = 1 }, g.cur);
- // not on a number
- try std.testing.expectEqual(@as(?@TypeOf(r), null), try adjustNumber(a, "abc", .{ .row = 0, .col = 0 }, 1));
-}
-
test "deleteSpan including the newline" {
const a = std.testing.allocator;
const r = try deleteSpan(a, "hello world", .{ .row = 0, .col = 2 }, .{ .row = 0, .col = 5 });