diff options
Diffstat (limited to 'src/modal.zig')
| -rw-r--r-- | src/modal.zig | 1594 |
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 }); |
