const std = @import("std"); const uucode = @import("uucode"); 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, prefix_register, 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, join_select_space, rotate_contents_fwd, rotate_contents_back, align_selections, indent, unindent, format, increment, decrement, comment_toggle, undo, redo, leader, command_line, pipe_selection, pipe_selection_to, insert_output, append_output, keep_pipe, macro_record, macro_replay, repeat_insert, 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, register, }; 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, /// `"`: the register the next command names, 0 for none register: 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, /// `$` — stdin is the selection; the output is discarded, and only the /// selections the command exits 0 on are kept. keep, /// Do the selections become stdin? helix's `pipe` flag. pub fn pipes(b: PipeBehavior) bool { return b == .replace or b == .ignore or b == .keep; } }; 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, join_select_space, rotate_contents_forward, rotate_contents_backward, align_selections, 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, macro_record, macro_replay: u32, repeat_insert: u32, pub fn scope(value: Action) Scope { return switch (value) { .escape, .multi, .select_regex, .leader, .command_line, .pipe_selection, .search, .search_step, .macro_record, .macro_replay, .repeat_insert, => .once, .edit => |edit| switch (edit.kind) { .join_lines, .join_select_space, .rotate_contents_forward, .rotate_contents_backward, .align_selections, .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; }, .register => { state.prefix = .none; const char = key.literal() orelse return .ignored; state.register = char; // the count typed before `"` is still the command's state.count = if (counted.explicit) count else 0; return .pending; }, .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(.prefix_register)) return armPrefix(state, .register, if (counted.explicit) count else 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(.join_select_space)) return resultAction(.{ .edit = .{ .kind = .join_select_space, .count = count } }); if (key.has(.rotate_contents_fwd)) return resultAction(.{ .edit = .{ .kind = .rotate_contents_forward, .count = count } }); if (key.has(.rotate_contents_back)) return resultAction(.{ .edit = .{ .kind = .rotate_contents_backward, .count = count } }); if (key.has(.align_selections)) return resultAction(.{ .edit = .{ .kind = .align_selections, .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(.keep_pipe)) return resultAction(.{ .pipe_selection = .keep }); if (key.has(.macro_record)) return resultAction(.macro_record); if (key.has(.macro_replay)) return resultAction(.{ .macro_replay = count }); if (key.has(.repeat_insert)) return resultAction(.{ .repeat_insert = count }); 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, col: usize = 0, pub fn eql(a: Cursor, b: Cursor) bool { return a.row == b.row and a.col == b.col; } }; // Unicode word characters, punctuation, and whitespace. pub const Kind = enum { word, punct, ws }; fn codepointAt(text: []const u8, off: usize) u21 { if (off >= text.len) return 0xFFFD; const n = std.unicode.utf8ByteSequenceLength(text[off]) catch return 0xFFFD; if (off + n > text.len) return 0xFFFD; return std.unicode.utf8Decode(text[off .. off + n]) catch 0xFFFD; } fn isUnicodeWhitespace(cp: u21) bool { if (cp == ' ' or (cp >= '\t' and cp <= '\r') or cp == 0x85) return true; return switch (uucode.get(.general_category, cp)) { .separator_space, .separator_line, .separator_paragraph => true, else => false, }; } fn kindOfCodepoint(cp: u21) Kind { if (isUnicodeWhitespace(cp)) return .ws; if (cp == '_') return .word; return switch (uucode.get(.general_category, cp)) { .letter_uppercase, .letter_lowercase, .letter_titlecase, .letter_modifier, .letter_other, .mark_nonspacing, .mark_spacing_combining, .mark_enclosing, .number_decimal_digit, .number_letter, .number_other, .punctuation_connector, => .word, else => .punct, }; } fn lineLenOf(lines: []const []const u8, row: usize) usize { if (row >= lines.len) return 0; return lines[row].len; } // advance one position across line boundaries (line terminators are positions // too: col == line.len is the newline). Returns false at EOF. fn stepFwd(lines: []const []const u8, c: *Cursor) bool { if (c.row >= lines.len) return false; const llen = lineLenOf(lines, c.row); if (c.col < llen) { c.col = nextGrapheme(lines[c.row], c.col); return true; } // at the newline: move to next line start if (c.row + 1 < lines.len) { c.row += 1; c.col = 0; return true; } return false; // EOF } fn stepBwd(lines: []const []const u8, c: *Cursor) bool { if (c.col > 0) { c.col = prevGrapheme(lines[c.row], c.col); return true; } if (c.row == 0) return false; c.row -= 1; c.col = lineLenOf(lines, c.row); // the previous line's newline return true; } 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; } 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 if (c.row >= lines.len) return .{ .row = c.row, .col = 0 }; return .{ .row = c.row, .col = firstNonWs(lines[c.row]) }; } 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)) }; } pub fn charRight(lines: []const []const u8, c: Cursor) Cursor { const llen = lineLenOf(lines, c.row); const last = if (llen == 0) 0 else prevGrapheme(lines[c.row], llen); return .{ .row = c.row, .col = if (c.col < last) @min(nextGrapheme(lines[c.row], c.col), last) else last }; } fn clampLineCol(line: []const u8, col: usize) usize { if (line.len == 0) return 0; const last = prevGrapheme(line, line.len); return graphemeStart(line, @min(col, last)); } // 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'; const line = lines[c.row]; if (c.col >= line.len) return '\n'; return codepointAt(line, graphemeStart(line, c.col)); } fn charAt(lines: []const []const u8, c: Cursor) u8 { const cp = codepointAtCursor(lines, c); return if (cp <= 0x7f) @intCast(cp) else 0; } // `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 { const opens = "([{<"; const closes = ")]}>"; const start = clampToChar(lines, c); const ch = charAt(lines, start); if (std.mem.indexOfScalar(u8, opens, ch)) |i| { var depth: usize = 0; var p = start; while (true) { const cc = charAt(lines, p); if (cc == opens[i]) depth += 1; if (cc == closes[i]) { depth -= 1; if (depth == 0) return p; } if (!stepFwd(lines, &p)) return null; } } if (std.mem.indexOfScalar(u8, closes, ch)) |i| { var depth: usize = 0; var p = start; while (true) { const cc = charAt(lines, p); if (cc == closes[i]) depth += 1; if (cc == opens[i]) { depth -= 1; if (depth == 0) return p; } if (!stepBwd(lines, &p)) return null; } } return null; } // an inclusive char range [a, b] in document order pub const Range = struct { a: Cursor, b: Cursor }; // the nearest pair of brackets enclosing the cursor (nesting-aware; the // cursor sitting ON a bracket belongs to that pair). Positions of the // bracket chars themselves. pub fn enclosingPair(lines: []const []const u8, c: Cursor, open: u8, close: u8) ?Range { var a = clampToChar(lines, c); if (charAt(lines, a) != open) { var depth: usize = 0; while (true) { if (!stepBwd(lines, &a)) return null; const ch = charAt(lines, a); if (ch == close) depth += 1; if (ch == open) { if (depth == 0) break; depth -= 1; } } } const b = matchBracket(lines, a) orelse return null; return .{ .a = a, .b = b }; } // the quote pair around the cursor, scanned on the cursor's line only // (plain-text strings don't span lines). Positions of the quote chars. pub fn enclosingQuote(lines: []const []const u8, c0: Cursor, q: u8) ?Range { const c = clampToChar(lines, c0); if (c.row >= lines.len) return null; const line = lines[c.row]; var i: usize = 0; while (i < line.len) { const o = std.mem.indexOfScalarPos(u8, line, i, q) orelse return null; const e = std.mem.indexOfScalarPos(u8, line, o + 1, q) orelse return null; if (c.col < o) return null; // the cursor sits before any pair if (c.col <= e) return .{ .a = .{ .row = c.row, .col = o }, .b = .{ .row = c.row, .col = e } }; i = e + 1; } return null; } // 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) { return .{ .row = if (lines.len == 0) 0 else lines.len - 1, .col = 0 }; } const llen = lineLenOf(lines, c.row); if (llen == 0) return .{ .row = c.row, .col = 0 }; return .{ .row = c.row, .col = clampLineCol(lines[c.row], c.col) }; } pub fn lineCount(content: []const u8) usize { if (content.len == 0) return 0; return std.mem.count(u8, content, "\n") + 1; } // byte offset of the start of line `row` (0-based). row may == lineCount() // (== content.len, the end). pub fn lineStartOffset(content: []const u8, row: usize) usize { var off: usize = 0; var r: usize = 0; while (r < row) : (r += 1) { const nl = std.mem.indexOfScalarPos(u8, content, off, '\n') orelse return content.len; off = nl + 1; } return off; } // the text of line `row` (no terminator), a slice into `content`. pub fn lineSlice(content: []const u8, row: usize) []const u8 { const start = lineStartOffset(content, row); if (start >= content.len) return ""; // indexOfScalarPos, not indexOfPos with a one-byte needle: the latter runs the generic // substring search where a memchr will do, and this is called once per visible row per frame. const nl = std.mem.indexOfScalarPos(u8, content, start, '\n') orelse content.len; return content[start..nl]; } /// 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 { // 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; while (r < row) : (r += 1) { const nl = std.mem.indexOfScalarPos(u8, content, start, '\n') orelse return null; start = nl + 1; } 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 }; } 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]); @memcpy(out[start..][0..replacement.len], replacement); @memcpy(out[start + replacement.len ..], content[end..]); return out; } /// 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 { // 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 }; }; const off = span.start + @min(c.col, span.end - span.start); return spliceAlloc(alloc, content, off, off, text); } // delete the character at (row, col). no-op if col is past the line end. pub fn deleteChar(alloc: std.mem.Allocator, content: []const u8, c: Cursor) ![]u8 { const line = lineSlice(content, c.row); if (c.col >= line.len) return alloc.dupe(u8, content); const col = graphemeStart(line, c.col); const line_start = lineStartOffset(content, c.row); return spliceAlloc(alloc, content, line_start + col, line_start + nextGrapheme(line, col), ""); } // delete whole lines [r0, r1] inclusive (the line content + their terminators). // returns the new content; `deleted` is the joined removed text (no terminators). pub const Deleted = struct { content: []u8, deleted: []u8 }; pub fn deleteLines(alloc: std.mem.Allocator, content: []const u8, r0: usize, r1: usize) !Deleted { const n = lineCount(content); const lo = @min(r0, r1); const hi = @min(@max(r0, r1), if (n == 0) 0 else n - 1); if (n == 0 or hi < lo) { const unchanged = try alloc.dupe(u8, content); errdefer alloc.free(unchanged); return .{ .content = unchanged, .deleted = try alloc.dupe(u8, "") }; } const start = lineStartOffset(content, lo); // end = start of line (hi+1), or content.len if hi is the last line. const end = if (hi + 1 < n) lineStartOffset(content, hi + 1) else content.len; // if we're removing the last line and there's a preceding newline, also // drop that newline so we don't leave a trailing blank line. var cut_lo = start; const cut_hi = end; if (hi + 1 == n and start > 0) cut_lo -= 1; // remove the '\n' before the last line var deleted_len: usize = hi - lo; var r = lo; while (r <= hi) : (r += 1) deleted_len += lineSlice(content, r).len; const deleted = try alloc.alloc(u8, deleted_len); errdefer alloc.free(deleted); var write: usize = 0; r = lo; while (r <= hi) : (r += 1) { if (r > lo) { deleted[write] = '\n'; write += 1; } const line = lineSlice(content, r); @memcpy(deleted[write..][0..line.len], line); write += line.len; } return .{ .content = try spliceAlloc(alloc, content, cut_lo, cut_hi, ""), .deleted = deleted }; } // normalize two cursors into document order (lo <= hi), then byte offsets of the // INCLUSIVE range [lo .. hi] (the char under hi is included). e == s means empty. fn rangeBytes(content: []const u8, a: Cursor, b: Cursor) struct { s: usize, e: usize } { var lo = a; var hi = b; if (hi.row < lo.row or (hi.row == lo.row and hi.col < lo.col)) { lo = b; hi = a; } const lo_line = lineSlice(content, lo.row); const hi_line = lineSlice(content, hi.row); const lo_col = graphemeStart(lo_line, @min(lo.col, lo_line.len)); const hi_col = graphemeStart(hi_line, @min(hi.col, hi_line.len)); const s = lineStartOffset(content, lo.row) + lo_col; var e = lineStartOffset(content, hi.row) + hi_col; if (e < content.len) e = nextGrapheme(content, e); // include the grapheme under the head return .{ .s = s, .e = @max(s, e) }; } // the text of the inclusive char range [a, b] (cursors in either order). Caller frees. pub fn rangeText(alloc: std.mem.Allocator, content: []const u8, a: Cursor, b: Cursor) ![]u8 { const r = rangeBytes(content, a, b); return alloc.dupe(u8, content[r.s..r.e]); } // delete the inclusive char range [a, b]. returns new content + the removed text. pub fn deleteRange(alloc: std.mem.Allocator, content: []const u8, a: Cursor, b: Cursor) !Deleted { const r = rangeBytes(content, a, b); const deleted = try alloc.dupe(u8, content[r.s..r.e]); errdefer alloc.free(deleted); return .{ .content = try spliceAlloc(alloc, content, r.s, r.e, ""), .deleted = deleted }; } // replace line `row`'s text with "" (keep the line, empty it). For `c`hange line. pub fn clearLine(alloc: std.mem.Allocator, content: []const u8, row: usize) ![]u8 { const line = lineSlice(content, row); const start = lineStartOffset(content, row); return spliceAlloc(alloc, content, start, start + line.len, ""); } // paste `text` as a new line BELOW `row`. Multiline `text` becomes several lines. pub fn pasteLineBelow(alloc: std.mem.Allocator, content: []const u8, row: usize, text: []const u8) ![]u8 { const n = lineCount(content); const off = if (row + 1 < n) lineStartOffset(content, row + 1) else content.len; const suffix_newline = off < content.len or (content.len > 0 and content[content.len - 1] == '\n'); const prefix_newline = !suffix_newline and off > 0; const extra: usize = @intFromBool(suffix_newline or prefix_newline); const out = try alloc.alloc(u8, content.len + text.len + extra); var write: usize = 0; @memcpy(out[write..][0..off], content[0..off]); write += off; if (prefix_newline) { out[write] = '\n'; write += 1; } @memcpy(out[write..][0..text.len], text); write += text.len; if (suffix_newline) { out[write] = '\n'; write += 1; } @memcpy(out[write..], content[off..]); return out; } // the cursor position AFTER `text` inserted at `c` (one past its last char). pub fn advanceBy(c: Cursor, text: []const u8) Cursor { var r = c.row; var col = c.col; for (text) |ch| { if (ch == '\n') { r += 1; col = 0; } else col += 1; } return .{ .row = r, .col = col }; } // replace the inclusive char range [a, b] with `text` (R replace-with-yank). pub fn replaceRange(alloc: std.mem.Allocator, content: []const u8, a: Cursor, b: Cursor, text: []const u8) ![]u8 { const r = rangeBytes(content, a, b); return spliceAlloc(alloc, content, r.s, r.e, text); } // `r`: overwrite every char in the inclusive range [a, b] with `ch` — // NEWLINES TOO (helix replace maps every grapheme, so `xrz` joins lines). pub fn replaceChars(alloc: std.mem.Allocator, content: []const u8, a: Cursor, b: Cursor, ch: u21) ![]u8 { const r = rangeBytes(content, a, b); var encoded: [4]u8 = undefined; const encoded_len = try std.unicode.utf8Encode(ch, &encoded); var count: usize = 0; var at = r.s; while (at < r.e) : (count += 1) at = nextGrapheme(content, at); const replacement_len = count * encoded_len; const out = try alloc.alloc(u8, content.len - (r.e - r.s) + replacement_len); @memcpy(out[0..r.s], content[0..r.s]); var write = r.s; for (0..count) |_| { @memcpy(out[write..][0..encoded_len], encoded[0..encoded_len]); write += encoded_len; } @memcpy(out[write..], content[r.e..]); return out; } // `~` / `` ` `` / ``Alt-` ``: case-map the inclusive range [a, b]. pub const CaseOp = enum { toggle, lower, upper }; pub fn changeCase(alloc: std.mem.Allocator, content: []const u8, a: Cursor, b: Cursor, op: CaseOp) ![]u8 { const r = rangeBytes(content, a, b); const out = try alloc.dupe(u8, content); for (out[r.s..r.e]) |*p| { p.* = switch (op) { .toggle => if (std.ascii.isUpper(p.*)) std.ascii.toLower(p.*) else std.ascii.toUpper(p.*), .lower => std.ascii.toLower(p.*), .upper => std.ascii.toUpper(p.*), }; } return out; } // `>` / `<`: 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; // 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 { const s = lineStartOffset(content, a.row) + @min(a.col, lineSlice(content, a.row).len); const e = lineStartOffset(content, b.row) + @min(b.col, lineSlice(content, b.row).len); if (e <= s) return alloc.dupe(u8, content); return spliceAlloc(alloc, content, s, e, ""); } // 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 Selection = struct { anchor: usize, head: usize }; /// 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; if (text[bounded] < 0x80) { if (bounded == 0) return 0; const prev = text[bounded - 1]; if (prev < 0x80 and !(prev == '\r' and text[bounded] == '\n')) return bounded; } var it = uucode.grapheme.utf8Iterator(text); while (it.nextGrapheme()) |g| { if (bounded < g.end) return g.start; } return text.len; } /// one extended grapheme forward, clamped at text.len pub fn nextGrapheme(text: []const u8, off: usize) usize { if (off >= text.len) return text.len; // 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; while (start > 0 and (text[start] & 0xC0) == 0x80) start -= 1; if (start != off) start = graphemeStart(text, off); var it = uucode.grapheme.utf8Iterator(text[start..]); const g = it.nextGrapheme() orelse return @min(start + 1, text.len); return start + g.end; } pub fn prevGrapheme(text: []const u8, off: usize) usize { var bounded = @min(off, text.len); if (bounded < text.len) { const repaired = graphemeStart(text, bounded); if (repaired < bounded) return repaired; bounded = repaired; } if (bounded == 0) return 0; // 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; // 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]); var last = line_start; while (it.nextGrapheme()) |g| last = line_start + g.start; return last; } /// Byte offset of the zero-based grapheme column, clamped to the line end. pub fn graphemeAtColumn(text: []const u8, column: usize) usize { var at: usize = 0; var col: usize = 0; while (at < text.len and col < column) : (col += 1) at = nextGrapheme(text, at); return at; } /// the block cursor cell of a range (helix Range::cursor) pub fn selectionCursor(text: []const u8, r: Selection) usize { return if (r.head > r.anchor) prevGrapheme(text, r.head) else r.head; } /// 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) { anchor = nextGrapheme(text, r.anchor); } else if (r.head < r.anchor and idx >= r.anchor) { anchor = prevGrapheme(text, r.anchor); } if (anchor <= idx) return .{ .anchor = anchor, .head = nextGrapheme(text, idx) }; return .{ .anchor = anchor, .head = idx }; } // 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 cursorLineCount(text: []const u8) usize { return std.mem.count(u8, text, "\n") + 1; } 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 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 positionAt(text: []const u8, off: usize) Cursor { const bounded = @min(off, text.len); // 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 = lineAtOffset(text, s + col), .col = col }; } /// (row, col) -> clamped gap offset; col == line length lands ON the '\n' pub fn offsetAt(text: []const u8, c: Cursor) usize { const row = @min(c.row, cursorLineCount(text) - 1); const s = lineStartOffset(text, row); // 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); return s + graphemeStart(text[s..e], raw); } pub const WordTarget = enum { next_word_start, next_word_end, prev_word_start, prev_word_end, next_long_word_start, next_long_word_end, prev_long_word_start, prev_long_word_end, }; // helix categorize_char: Eol is its OWN category, distinct from Whitespace — // that distinction is load-bearing in reached_target. const WordClass = enum { word, punct, ws, eol }; 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; return switch (kindOfCodepoint(cp)) { .word => .word, .punct => .punct, .ws => .ws, }; } fn isWordWhitespace(c: WordClass) bool { // Rust char::is_whitespace (includes line endings) return c == .ws or c == .eol; } fn isWordBoundary(a: WordClass, b: WordClass) bool { return a != b; } 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 reachedWordTarget(target: WordTarget, prev: WordClass, next: WordClass) bool { return switch (target) { .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), }; } fn wmIsPrev(t: WordTarget) bool { return switch (t) { .prev_word_start, .prev_word_end, .prev_long_word_start, .prev_long_word_end => true, else => false, }; } /// w/b/e/W/B/E: helix word_move — each step selects the traversed span. 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: Selection = if (is_prev) (if (r0.anchor < r0.head) .{ .anchor = r0.head, .head = prevGrapheme(text, r0.head) } else .{ .anchor = nextGrapheme(text, r0.head), .head = r0.head }) else (if (r0.anchor < r0.head) .{ .anchor = prevGrapheme(text, r0.head), .head = r0.head } else .{ .anchor = r0.head, .head = nextGrapheme(text, r0.head) }); for (0..@max(1, count)) |_| { const next = wordRangeToTarget(text, target, r, is_prev); if (next.anchor == r.anchor and next.head == r.head) break; r = next; } return r; } // 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 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: ?WordClass = if (is_prev) (if (it < text.len) wordClassAt(text, it) else null) else (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 = wordClassAt(text, cell); if (cat != .eol) break; it = if (is_prev) cell else nextGrapheme(text, cell); prev_cat = cat; head = it; } if (prev_cat == .eol) anchor = head; // find the target position const head_start = head; 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 = 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; it = if (is_prev) cell else nextGrapheme(text, cell); head = it; } return .{ .anchor = anchor, .head = head }; } /// a ropey "line is a line ending" — the line has no content of its own 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 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 = 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 !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) moveSelectionCursor(text, r, head, true).anchor else if (curr_empty_to_line and last_char) r.head else cursor; return .{ .anchor = anchor, .head = head }; } const first_char = lineStartOffset(text, line) == cursor; 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 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) moveSelectionCursor(text, r, head, true).anchor else if (prev_empty_to_line and first_char) cursor else r.head; return .{ .anchor = anchor, .head = head }; } /// 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 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); // 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 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); var i = if (till) nextGrapheme(text, head) else head; if (i > text.len) return null; while (i < text.len) : (i = nextGrapheme(text, i)) { if (codepointAt(text, i) == ch) { left -= 1; if (left == 0) return if (till) prevGrapheme(text, i) else i; } } return null; } var i = if (till) prevGrapheme(text, cursor) else cursor; while (i > 0) { i = prevGrapheme(text, i); if (codepointAt(text, i) == ch) { left -= 1; if (left == 0) return if (till) nextGrapheme(text, i) else i; } } return null; } // helix textobject.rs find_word_boundary 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 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 = 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; pos = it; prev = cat; } return pos; } /// miw/maw (and W): helix textobject_word — on whitespace it selects the /// whitespace run's boundary (a 1-wide cursor there) 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 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 = 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 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; const curr_empty_to_line = curr_empty and !next_empty; var line_back = line; 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 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 !lineIsEmpty(text, line)) { line += 1; done = true; } 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 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 lineIsEmpty(text, line - 1)) line -= 1; } return .{ .anchor = lineStartOffset(text, line_back), .head = if (line >= nlines) text.len else lineStartOffset(text, line), }; } test "selection textobjects distinguish words and paragraph boundaries" { const t = "alpha beta gamma\n"; // miw mid-word 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 = 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 = 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 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; } return w; } /// 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 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)]; } /// 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 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), 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)); } /// 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; if (digits.len == 0) return null; for (digits) |ch| if (!std.ascii.isDigit(ch)) return null; const val = std.fmt.parseInt(i128, frag, 10) catch return null; const nv = val +| @as(i128, amount); const pad = digits[0] == '0'; const neg_after = nv < 0; // format_length includes the sign, adjusted when the sign flips var flen: usize = frag.len; if (neg and !neg_after) flen -= 1; if (!neg and neg_after) flen += 1; var buf: [48]u8 = undefined; // "{d}" prints '+' for positive signed ints — format the magnitude unsigned const mag = std.fmt.bufPrint(&buf, "{d}", .{@abs(nv)}) catch return null; const sign_len: usize = @intFromBool(neg_after); const want = if (pad) flen - sign_len else mag.len; const digits_len = @max(want, mag.len); const out = try alloc.alloc(u8, sign_len + digits_len); var write: usize = 0; if (neg_after) { out[0] = '-'; write = 1; } @memset(out[write..][0 .. digits_len - mag.len], '0'); write += digits_len - mag.len; @memcpy(out[write..], mag); return out; } 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 = 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 = 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 = 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 = 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 = 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 = 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 "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 = 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), selectionCursor(t, r)); // and back: cursor to 4 -> forward again, anchor gap back to 2 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 "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 = 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 = 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 "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), verticalTarget(t, 5, true, 1, 5)); // two down with the same goal restores col 5 try std.testing.expectEqual(@as(usize, 15), verticalTarget(t, 9, true, 1, 5)); } test "find targets count matches and skip adjacent till targets" { const t = "abcabc\n"; 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), 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" { const text = "a" ++ "e\u{301}" ++ "👩🏽\u{200d}🚀" ++ "🇧🇷" ++ "界"; const boundaries = [_]usize{ 0, 1, 4, 19, 27, 30 }; for (boundaries[0 .. boundaries.len - 1], boundaries[1..]) |start, end| { try std.testing.expectEqual(end, nextGrapheme(text, start)); try std.testing.expectEqual(start, prevGrapheme(text, end)); } // Stale byte offsets are repaired to a cluster boundary instead of being // allowed to leak continuation bytes into cursor state. try std.testing.expectEqual(@as(usize, 1), graphemeStart(text, 2)); try std.testing.expectEqual(@as(usize, 1), prevGrapheme(text, 3)); try std.testing.expectEqual(@as(usize, 19), graphemeAtColumn(text, 3)); } test "the ASCII arms of graphemeStart and nextGrapheme agree with the UAX #29 walk" { // 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 { const bounded = @min(off, text.len); if (bounded == text.len) return text.len; var it = uucode.grapheme.utf8Iterator(text); while (it.nextGrapheme()) |g| { if (bounded < g.end) return g.start; } return text.len; } // `nextGrapheme` with the ASCII arm deleted. fn next(text: []const u8, off: usize) usize { if (off >= text.len) return text.len; var s = off; while (s > 0 and (text[s] & 0xC0) == 0x80) s -= 1; if (s != off) s = start(text, off); var it = uucode.grapheme.utf8Iterator(text[s..]); const g = it.nextGrapheme() orelse return @min(s + 1, text.len); return s + g.end; } fn check(text: []const u8) !void { var off: usize = 0; while (off <= text.len + 2) : (off += 1) { std.testing.expectEqual(start(text, off), graphemeStart(text, off)) catch |e| { std.debug.print("graphemeStart({any}, {d})\n", .{ text, off }); return e; }; std.testing.expectEqual(next(text, off), nextGrapheme(text, off)) catch |e| { std.debug.print("nextGrapheme({any}, {d})\n", .{ text, off }); return e; }; } } }; // 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 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) { buf[0] = b; for (neighbours) |tail| { @memcpy(buf[1..][0..tail.len], tail); try H.check(buf[0 .. 1 + tail.len]); // ...and the same byte as a follower, so a boundary is probed from both sides. @memcpy(buf[0..tail.len], tail); buf[tail.len] = b; try H.check(buf[0 .. tail.len + 1]); } } 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"); } // 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. var it = uucode.grapheme.utf8Iterator(text); var starts: [8]usize = undefined; var ends: [8]usize = undefined; var n: usize = 0; while (it.nextGrapheme()) |g| : (n += 1) { starts[n] = g.start; ends[n] = g.end; } for (starts[0..n], ends[0..n]) |start, end| { try std.testing.expectEqual(start, graphemeStart(text, start)); try std.testing.expectEqual(end, nextGrapheme(text, start)); try std.testing.expectEqual(start, prevGrapheme(text, end)); } } test "Unicode find and word motion stay on grapheme boundaries" { try std.testing.expectEqual(@as(usize, 3), findTarget("\u{e9}x\u{e9}", 0, 'é', true, false, 1).?); const text = "café 世界 ok\n"; 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 = 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 = moveWord(nbsp, .{ .anchor = 0, .head = 1 }, 1, .next_long_word_start); try std.testing.expectEqual(@as(usize, 7), long.head); } test "decimal increment preserves padding and handles sign changes" { const a = std.testing.allocator; { const r = (try incrementDecimal(a, "15", 1)).?; defer a.free(r); try std.testing.expectEqualStrings("16", r); } { const r = (try incrementDecimal(a, "007", 1)).?; defer a.free(r); try std.testing.expectEqualStrings("008", r); } { const r = (try incrementDecimal(a, "-3", 1)).?; defer a.free(r); try std.testing.expectEqualStrings("-2", r); } { 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 incrementDecimal(a, "a 1", 1)); try std.testing.expectEqual(@as(?[]u8, null), try incrementDecimal(a, "", 1)); } 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 "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 = 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 })); } test "lineSpan agrees with the whole-document scans it replaces" { for ([_][]const u8{ "", "a", "a\n", "a\nbb\n", "a\nbb\nccc", "\n", "\n\n" }) |content| { const n = lineCount(content); var row: usize = 0; while (row < n) : (row += 1) { const span = lineSpan(content, row) orelse { std.debug.print("row {d} of {s} missing\n", .{ row, content }); return error.MissingRow; }; try std.testing.expectEqual(lineStartOffset(content, row), span.start); try std.testing.expectEqualStrings(lineSlice(content, row), content[span.start..span.end]); } // One past the last line must be absent, which is what lets insertAt clamp. try std.testing.expectEqual(@as(?LineSpan, null), lineSpan(content, n)); } } test "insertAt still clamps a row past the end onto the last line" { const gpa = std.testing.allocator; const content = "a\nbb\nccc"; const out = try insertAt(gpa, content, .{ .row = 99, .col = 99 }, "X"); defer gpa.free(out); try std.testing.expectEqualStrings("a\nbb\ncccX", out); const mid = try insertAt(gpa, content, .{ .row = 1, .col = 1 }, "X"); defer gpa.free(mid); try std.testing.expectEqualStrings("a\nbXb\nccc", mid); } test "lineStartOffset + lineSlice" { const content = "alpha\nbeta\n\ngamma"; try std.testing.expectEqual(@as(usize, 0), lineStartOffset(content, 0)); try std.testing.expectEqual(@as(usize, 6), lineStartOffset(content, 1)); try std.testing.expectEqual(@as(usize, 11), lineStartOffset(content, 2)); try std.testing.expectEqual(@as(usize, 12), lineStartOffset(content, 3)); try std.testing.expectEqual(@as(usize, 17), lineStartOffset(content, 4)); // past end try std.testing.expectEqualStrings("alpha", lineSlice(content, 0)); try std.testing.expectEqualStrings("beta", lineSlice(content, 1)); try std.testing.expectEqualStrings("", lineSlice(content, 2)); try std.testing.expectEqualStrings("gamma", lineSlice(content, 3)); try std.testing.expectEqual(@as(usize, 4), lineCount(content)); } test "insertAt mid-line and at end" { const content = "hello world"; const a = std.testing.allocator; const r1 = try insertAt(a, content, .{ .row = 0, .col = 5 }, "!"); defer a.free(r1); try std.testing.expectEqualStrings("hello! world", r1); const r2 = try insertAt(a, content, .{ .row = 0, .col = 99 }, "!"); defer a.free(r2); try std.testing.expectEqualStrings("hello world!", r2); } test "insertAt multiline creates lines" { const content = "a\nb"; const a = std.testing.allocator; const r = try insertAt(a, content, .{ .row = 0, .col = 1 }, "X\nY"); defer a.free(r); try std.testing.expectEqualStrings("aX\nY\nb", r); try std.testing.expectEqual(@as(usize, 3), lineCount(r)); } test "deleteChar" { const content = "abc"; const a = std.testing.allocator; const r = try deleteChar(a, content, .{ .row = 0, .col = 1 }); defer a.free(r); try std.testing.expectEqualStrings("ac", r); // past end: no-op const r2 = try deleteChar(a, content, .{ .row = 0, .col = 5 }); defer a.free(r2); try std.testing.expectEqualStrings("abc", r2); } test "Unicode edits replace and delete whole graphemes" { const a = std.testing.allocator; const content = "A" ++ "e\u{301}" ++ "👩🏽\u{200d}🚀" ++ "🇧🇷" ++ "界" ++ "Z"; const deleted = try deleteChar(a, content, .{ .row = 0, .col = 2 }); defer a.free(deleted); try std.testing.expectEqualStrings("A👩🏽\u{200d}🚀🇧🇷界Z", deleted); const replaced = try replaceChars(a, content, .{ .row = 0, .col = 1 }, .{ .row = 0, .col = 19 }, '界'); defer a.free(replaced); try std.testing.expectEqualStrings("A界界界界Z", replaced); } test "deleteLines middle" { const content = "one\ntwo\nthree\nfour"; const a = std.testing.allocator; const d = try deleteLines(a, content, 1, 2); defer a.free(d.content); defer a.free(d.deleted); try std.testing.expectEqualStrings("one\nfour", d.content); try std.testing.expectEqualStrings("two\nthree", d.deleted); } test "deleteLines last line drops preceding newline" { const content = "one\ntwo\nthree"; const a = std.testing.allocator; const d = try deleteLines(a, content, 2, 2); defer a.free(d.content); defer a.free(d.deleted); try std.testing.expectEqualStrings("one\ntwo", d.content); try std.testing.expectEqualStrings("three", d.deleted); } test "deleteLines only line" { const content = "only"; const a = std.testing.allocator; const d = try deleteLines(a, content, 0, 0); defer a.free(d.content); defer a.free(d.deleted); try std.testing.expectEqualStrings("", d.content); try std.testing.expectEqualStrings("only", d.deleted); } test "rangeText + deleteRange (char-wise select)" { const a = std.testing.allocator; const content = "hello\nworld\nfoo"; // same-line inclusive range: "hello"[1..3] -> "ell" const t1 = try rangeText(a, content, .{ .row = 0, .col = 1 }, .{ .row = 0, .col = 3 }); defer a.free(t1); try std.testing.expectEqualStrings("ell", t1); // reversed cursors give the same range const t2 = try rangeText(a, content, .{ .row = 0, .col = 3 }, .{ .row = 0, .col = 1 }); defer a.free(t2); try std.testing.expectEqualStrings("ell", t2); // cross-line range includes the newline: row0 col3 .. row1 col1 -> "lo\nwo" const t3 = try rangeText(a, content, .{ .row = 0, .col = 3 }, .{ .row = 1, .col = 1 }); defer a.free(t3); try std.testing.expectEqualStrings("lo\nwo", t3); // delete the same cross-line range const d = try deleteRange(a, content, .{ .row = 0, .col = 3 }, .{ .row = 1, .col = 1 }); defer a.free(d.content); defer a.free(d.deleted); try std.testing.expectEqualStrings("helrld\nfoo", d.content); try std.testing.expectEqualStrings("lo\nwo", d.deleted); } test "clearLine" { const content = "keep\nzap me\nkeep2"; const a = std.testing.allocator; const r = try clearLine(a, content, 1); defer a.free(r); try std.testing.expectEqualStrings("keep\n\nkeep2", r); } test "matchBracket nesting both directions" { const lines = [_][]const u8{"a (b (c) d) e"}; const w = &lines; try std.testing.expectEqual(Cursor{ .row = 0, .col = 10 }, matchBracket(w, .{ .row = 0, .col = 2 }).?); try std.testing.expectEqual(Cursor{ .row = 0, .col = 2 }, matchBracket(w, .{ .row = 0, .col = 10 }).?); try std.testing.expectEqual(Cursor{ .row = 0, .col = 7 }, matchBracket(w, .{ .row = 0, .col = 5 }).?); try std.testing.expectEqual(@as(?Cursor, null), matchBracket(w, .{ .row = 0, .col = 0 })); } test "matchBracket across lines" { const lines = [_][]const u8{ "if (x) {", " y", "}" }; const w = &lines; try std.testing.expectEqual(Cursor{ .row = 2, .col = 0 }, matchBracket(w, .{ .row = 0, .col = 7 }).?); try std.testing.expectEqual(Cursor{ .row = 0, .col = 7 }, matchBracket(w, .{ .row = 2, .col = 0 }).?); } test "enclosing pair includes delimiters and chooses the nearest nested pair" { const lines = [_][]const u8{"f(a, (b))"}; const w = &lines; 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 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{"()"}; 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 "enclosing quote stays on its line" { const lines = [_][]const u8{"say 'hi there' end"}; const w = &lines; 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); 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" { try std.testing.expectEqual(Cursor{ .row = 0, .col = 5 }, advanceBy(.{ .row = 0, .col = 2 }, "abc")); try std.testing.expectEqual(Cursor{ .row = 2, .col = 1 }, advanceBy(.{ .row = 0, .col = 2 }, "a\nbc\nd")); } test "replaceRange" { const a = std.testing.allocator; const r = try replaceRange(a, "hello world", .{ .row = 0, .col = 0 }, .{ .row = 0, .col = 4 }, "bye"); defer a.free(r); try std.testing.expectEqualStrings("bye world", r); } test "replaceChars overwrites newlines too" { const a = std.testing.allocator; const r = try replaceChars(a, "ab\ncd", .{ .row = 0, .col = 1 }, .{ .row = 1, .col = 0 }, 'x'); defer a.free(r); try std.testing.expectEqualStrings("axxxd", r); } test "changeCase" { const a = std.testing.allocator; const t = try changeCase(a, "aB cD", .{ .row = 0, .col = 0 }, .{ .row = 0, .col = 4 }, .toggle); defer a.free(t); try std.testing.expectEqualStrings("Ab Cd", t); const lo = try changeCase(a, "AB CD", .{ .row = 0, .col = 0 }, .{ .row = 0, .col = 1 }, .lower); defer a.free(lo); try std.testing.expectEqualStrings("ab CD", lo); const up = try changeCase(a, "ab cd", .{ .row = 0, .col = 3 }, .{ .row = 0, .col = 4 }, .upper); defer a.free(up); try std.testing.expectEqualStrings("ab CD", up); } 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 }); defer a.free(r); try std.testing.expectEqualStrings("he world", r); const j = try deleteSpan(a, "ab\ncd", .{ .row = 0, .col = 2 }, .{ .row = 1, .col = 0 }); defer a.free(j); try std.testing.expectEqualStrings("abcd", j); // empty span: copy const e = try deleteSpan(a, "ab", .{ .row = 0, .col = 1 }, .{ .row = 0, .col = 1 }); defer a.free(e); try std.testing.expectEqualStrings("ab", e); } test "pasteLineBelow" { const content = "one\ntwo"; const a = std.testing.allocator; const r = try pasteLineBelow(a, content, 0, "INSERTED"); defer a.free(r); try std.testing.expectEqualStrings("one\nINSERTED\ntwo", r); // paste below last line const r2 = try pasteLineBelow(a, content, 1, "END"); defer a.free(r2); try std.testing.expectEqualStrings("one\ntwo\nEND", r2); // multiline yanked text const r3 = try pasteLineBelow(a, content, 0, "a\nb"); defer a.free(r3); try std.testing.expectEqualStrings("one\na\nb\ntwo", r3); }