hjkl_buffer/motion.rs
1//! Motion vocabulary helpers.
2//!
3//! Patch C (0.0.30) relocated the 24 inherent vim motion helpers
4//! that lived here onto [`hjkl_engine::motions`] free functions
5//! over `&mut hjkl_buffer::View`. Motions don't belong on `View`
6//! — they're computed over the buffer, not delegated to it; the
7//! relocation is a step toward 0.1.0's full motion-as-trait-bound
8//! generic-ification.
9//!
10//! What stays in this module: [`is_keyword_char`] — the
11//! `iskeyword`-spec parser. Keyword classification is data over the
12//! `iskeyword` string and a single `char`; it has no buffer
13//! dependency, so the engine motions module re-exports it from here.
14
15/// One parsed `iskeyword` token. The classification precedence is
16/// exactly that of the original per-token decision tree so both
17/// [`is_keyword_char`] and [`KeywordSpec`] agree byte-for-byte.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19enum Token {
20 /// `@` — any alphabetic char.
21 Alpha,
22 /// `N-M` — decimal char-code range, inclusive.
23 Range(u32, u32),
24 /// bare integer `N` — single char code.
25 Code(u32),
26 /// single char — literal match.
27 Literal(char),
28}
29
30impl Token {
31 /// Classify one already-trimmed, non-empty token, or `None` for
32 /// unrecognized tokens (which the matcher ignores). Precedence
33 /// mirrors the original inline parser: `@`, then `N-M` range,
34 /// then bare code, then single-char literal.
35 fn parse(token: &str) -> Option<Self> {
36 if token == "@" {
37 return Some(Self::Alpha);
38 }
39 if let Some((lo, hi)) = token.split_once('-')
40 && let (Ok(lo), Ok(hi)) = (lo.parse::<u32>(), hi.parse::<u32>())
41 {
42 return Some(Self::Range(lo, hi));
43 }
44 if let Ok(n) = token.parse::<u32>() {
45 return Some(Self::Code(n));
46 }
47 let mut chars = token.chars();
48 if let (Some(only), None) = (chars.next(), chars.next()) {
49 return Some(Self::Literal(only));
50 }
51 None
52 }
53
54 #[inline]
55 fn matches(self, c: char) -> bool {
56 match self {
57 Self::Alpha => c.is_alphabetic(),
58 Self::Range(lo, hi) => (lo..=hi).contains(&(c as u32)),
59 Self::Code(n) => c as u32 == n,
60 Self::Literal(only) => c == only,
61 }
62 }
63}
64
65/// A vim-style `iskeyword` spec parsed once into its tokens.
66///
67/// The spec (e.g. `"@,48-57,_,192-255"`) changes only when the
68/// option is set, but word motions classify every stepped-over char.
69/// Pre-parsing with [`KeywordSpec::parse`] and reusing it via
70/// [`KeywordSpec::matches`] avoids re-splitting/re-parsing the spec
71/// string on every character. The boolean result is identical to
72/// calling [`is_keyword_char`] with the same spec string.
73#[derive(Debug, Clone, Default)]
74pub struct KeywordSpec {
75 tokens: Vec<Token>,
76}
77
78impl KeywordSpec {
79 /// Parse a comma-separated `iskeyword` spec into a reusable
80 /// matcher. Understood forms: `@` (any alphabetic), `_` (literal
81 /// underscore), `N-M` (decimal char-code range, inclusive), bare
82 /// integer `N` (single char code), single char (literal).
83 /// Unknown tokens are dropped.
84 pub fn parse(spec: &str) -> Self {
85 let tokens = spec
86 .split(',')
87 .filter_map(|raw| {
88 let token = raw.trim();
89 if token.is_empty() {
90 None
91 } else {
92 Token::parse(token)
93 }
94 })
95 .collect();
96 Self { tokens }
97 }
98
99 /// True if `c` matches any token in the pre-parsed spec.
100 #[inline]
101 pub fn matches(&self, c: char) -> bool {
102 self.tokens.iter().any(|token| token.matches(c))
103 }
104}
105
106/// Match `c` against a vim-style `iskeyword` spec. Tokens are
107/// comma-separated; understood forms: `@` (any alphabetic),
108/// `_` (literal underscore), `N-M` (decimal char-code range, inclusive),
109/// bare integer `N` (single char code), single ASCII punctuation char
110/// (literal). Unknown tokens are ignored.
111///
112/// This is a zero-allocation convenience for one-off checks; hot
113/// per-character loops should pre-parse once with [`KeywordSpec`].
114pub fn is_keyword_char(c: char, spec: &str) -> bool {
115 spec.split(',').any(|raw| {
116 let token = raw.trim();
117 !token.is_empty() && Token::parse(token).is_some_and(|t| t.matches(c))
118 })
119}
120
121#[cfg(test)]
122mod tests {
123 use super::*;
124
125 #[test]
126 fn iskeyword_alphabetic_via_at() {
127 assert!(is_keyword_char('a', "@"));
128 assert!(is_keyword_char('Z', "@"));
129 assert!(!is_keyword_char('1', "@"));
130 }
131
132 #[test]
133 fn iskeyword_numeric_range() {
134 assert!(is_keyword_char('0', "48-57"));
135 assert!(is_keyword_char('9', "48-57"));
136 assert!(!is_keyword_char('a', "48-57"));
137 }
138
139 #[test]
140 fn iskeyword_literal_punctuation() {
141 assert!(is_keyword_char('_', "_"));
142 assert!(!is_keyword_char('.', "_"));
143 }
144
145 #[test]
146 fn iskeyword_default_spec() {
147 // Matches vim default `@,48-57,_,192-255` and engine's
148 // `Settings::default()`.
149 let spec = "@,48-57,_,192-255";
150 assert!(is_keyword_char('a', spec));
151 assert!(is_keyword_char('5', spec));
152 assert!(is_keyword_char('_', spec));
153 assert!(!is_keyword_char(' ', spec));
154 assert!(!is_keyword_char('.', spec));
155 }
156}