summaryrefslogtreecommitdiff
path: root/src/normal_input.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/normal_input.zig')
-rw-r--r--src/normal_input.zig623
1 files changed, 623 insertions, 0 deletions
diff --git a/src/normal_input.zig b/src/normal_input.zig
new file mode 100644
index 00000000..c6b2574f
--- /dev/null
+++ b/src/normal_input.zig
@@ -0,0 +1,623 @@
+//! Pure BODY-NORMAL input recognition.
+//!
+//! The platform/core boundary first normalizes a physical key into every
+//! configured `Role` it matches. This module then owns the state machine:
+//! counts, prefixes and literal character arguments. It deliberately knows
+//! nothing about panes or text, so the text and PDF adapters consume exactly
+//! the same semantic `Action` values.
+const std = @import("std");
+
+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,
+ 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 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: u8,
+ 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,
+ 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;
+}
+
+/// Consume one normalized physical key. Invalid continuations are
+/// distinguished from genuinely unbound top-level keys, and always clear the
+/// prefix that owned them.
+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;
+ if (char > 0x7f) return .ignored;
+ return resultAction(.{ .replace_char = @intCast(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);
+ 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 "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());
+}