Skip to main content

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<Token> {
36        if token == "@" {
37            return Some(Token::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(Token::Range(lo, hi));
43        }
44        if let Ok(n) = token.parse::<u32>() {
45            return Some(Token::Code(n));
46        }
47        let mut chars = token.chars();
48        if let (Some(only), None) = (chars.next(), chars.next()) {
49            return Some(Token::Literal(only));
50        }
51        None
52    }
53
54    #[inline]
55    fn matches(self, c: char) -> bool {
56        match self {
57            Token::Alpha => c.is_alphabetic(),
58            Token::Range(lo, hi) => (lo..=hi).contains(&(c as u32)),
59            Token::Code(n) => c as u32 == n,
60            Token::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}