Skip to main content

squeeze/
modeline.rs

1//! Vim modeline finder.
2//!
3//! Extracts vim modelines such as `vim: set ts=4 sw=4 et:`, `vi: ts=4 sw=4`,
4//! or version-gated forms like `vim700:` / `vim<702:` / `vim=703:` /
5//! `vim>702:`. Supports the `vim`, `vi`, and `ex` prefixes and both the bare
6//! (first) and `set` (second) modeline forms. The bare form must contain at
7//! least one `option=value` assignment so prose such as "I prefer vim: it is
8//! great" is not misread as a modeline.
9
10use super::{Finder, Memo, RunClass, RunRule};
11use crate::word::boundary_before;
12use std::ops::Range;
13
14#[derive(Default)]
15pub struct Modeline {}
16
17// Forms:
18//   vim: set ts=4 sw=4 et:   (second form: `set` + terminating colon)
19//   vim: ts=4 sw=4           (first form: options to end of line)
20//   vim:ts=4:sw=4
21//   vim700: / vim<702: / vim=703: / vim>702: (version-gated prefixes)
22//
23// Constraints:
24//   - no whitespace between the vi/vim/ex token and the `:` (vim
25//     itself rejects `vim : ...`);
26//   - the first form requires at least one `=` so plain prose after
27//     `vim:` does not match; the second form is discriminating enough
28//     through its `set ...:` structure;
29//   - `[ \t]` instead of `\s` everywhere: finders are single-line by
30//     contract, the match must never cross a newline.
31//
32// This is a hand-written equivalent of the former regex
33// `(?i)\b(?:vim(?:[<=>]?\d+)?|vi|ex):[ \t]*(?:set[ \t]+[^:\r\n]+:|[A-Za-z][A-Za-z0-9_:.,/\t -]*=[A-Za-z0-9_=:.,/\t -]*)`.
34impl Modeline {
35    fn is_option_byte(b: u8) -> bool {
36        b.is_ascii_alphanumeric()
37            || matches!(b, b'_' | b':' | b'.' | b',' | b'/' | b'\t' | b' ' | b'-')
38    }
39
40    fn eq_ci(input: &[u8], pos: usize, word: &[u8]) -> bool {
41        input.len() >= pos + word.len() && input[pos..pos + word.len()].eq_ignore_ascii_case(word)
42    }
43
44    /// Position after the `:` that ends the `vim`/`vi`/`ex` token at `pos`.
45    fn keyword_end(input: &[u8], pos: usize) -> Option<usize> {
46        if Self::eq_ci(input, pos, b"vim") {
47            let p = pos + 3;
48            // Optional version: `[<=>]?\d+`, then the colon.
49            let mut q = p;
50            if matches!(input.get(q), Some(b'<' | b'=' | b'>')) {
51                q += 1;
52            }
53            let digits_start = q;
54            while input.get(q).is_some_and(u8::is_ascii_digit) {
55                q += 1;
56            }
57            if q > digits_start && input.get(q) == Some(&b':') {
58                return Some(q + 1);
59            }
60            return (input.get(p) == Some(&b':')).then_some(p + 1);
61        }
62        if Self::eq_ci(input, pos, b"vi") || Self::eq_ci(input, pos, b"ex") {
63            return (input.get(pos + 2) == Some(&b':')).then_some(pos + 3);
64        }
65        None
66    }
67
68    /// End of the options after the colon: `set ...:` first, then
69    /// `name=value...`.
70    ///
71    /// The `name=value` form scans the run of option bytes for its `=`. A
72    /// run without one fails for every candidate inside it, so `memo`
73    /// remembers the failed run and later candidates in it fail at once,
74    /// which keeps a line with many `ex:` tokens linear.
75    fn options_end(input: &[u8], after_colon: usize, memo: &mut Memo) -> Option<usize> {
76        let mut q = after_colon;
77        while matches!(input.get(q), Some(b' ' | b'\t')) {
78            q += 1;
79        }
80        if Self::eq_ci(input, q, b"set") {
81            let p = q + 3;
82            let mut r = p;
83            while matches!(input.get(r), Some(b' ' | b'\t')) {
84                r += 1;
85            }
86            let spaces = r - p;
87            if spaces >= 1 {
88                while input
89                    .get(r)
90                    .is_some_and(|&b| !matches!(b, b':' | b'\r' | b'\n'))
91                {
92                    r += 1;
93                }
94                // `[ \t]+[^:\r\n]+`: at least one space and one more byte.
95                if input.get(r) == Some(&b':') && r > p + 1 {
96                    return Some(r + 1);
97                }
98            }
99        }
100        if input.get(q).is_some_and(u8::is_ascii_alphabetic) {
101            if memo.covers(q) {
102                // Inside a run already known to end without `=`.
103                return None;
104            }
105            let mut r = q + 1;
106            while input.get(r).is_some_and(|&b| Self::is_option_byte(b)) {
107                r += 1;
108            }
109            if input.get(r) == Some(&b'=') {
110                r += 1;
111                while input
112                    .get(r)
113                    .is_some_and(|&b| Self::is_option_byte(b) || b == b'=')
114                {
115                    r += 1;
116                }
117                return Some(r);
118            }
119            *memo = Memo {
120                start: q,
121                end: r,
122                aux: 0,
123            };
124        }
125        None
126    }
127
128    fn match_at(input: &[u8], pos: usize, memo: &mut Memo) -> Option<Range<usize>> {
129        if !boundary_before(input, pos) {
130            return None;
131        }
132        let after_colon = Self::keyword_end(input, pos)?;
133        let end = Self::options_end(input, after_colon, memo)?;
134        Self::trim_range(input, pos, end)
135    }
136
137    fn trim_range(input: &[u8], start: usize, mut end: usize) -> Option<Range<usize>> {
138        // Trim trailing whitespace.
139        while end > start && matches!(input[end - 1], b' ' | b'\t') {
140            end -= 1;
141        }
142        Some(start..end)
143    }
144}
145
146impl Finder for Modeline {
147    fn id(&self) -> &'static str {
148        "modeline"
149    }
150
151    fn dispatchable(&self) -> bool {
152        true
153    }
154
155    fn could_start_at(&self, byte: u8) -> bool {
156        matches!(byte, b'v' | b'V' | b'e' | b'E')
157    }
158
159    fn could_start_after(&self, prev: u8, _cur: u8) -> bool {
160        // `\b`: a non-ASCII previous byte is decoded by `try_at`.
161        prev >= 0x80 || !(prev.is_ascii_alphanumeric() || prev == b'_')
162    }
163
164    fn could_continue_with(&self, cur: u8, next: u8) -> bool {
165        match cur {
166            b'v' | b'V' => matches!(next, b'i' | b'I'),
167            _ => matches!(next, b'x' | b'X'),
168        }
169    }
170
171    fn run_rules(&self) -> Vec<RunRule> {
172        // `vi:`, `ex:`, `vim:`, `vim700:` or `vim<702:`: a word run of at
173        // least two bytes followed by `:` or a version comparison.
174        vec![RunRule::new(RunClass::Word, 2, crate::RUN_CAP).followed_by(b":<=>")]
175    }
176
177    fn try_at(&self, input: &[u8], pos: usize) -> Option<Range<usize>> {
178        Self::match_at(input, pos, &mut Memo::default())
179    }
180
181    fn try_at_memo(&self, input: &[u8], pos: usize, memo: &mut Memo) -> Option<Range<usize>> {
182        Self::match_at(input, pos, memo)
183    }
184
185    fn find(&self, s: &str) -> Option<Range<usize>> {
186        let input = s.as_bytes();
187        let mut memo = Memo::default();
188        for pos in 0..input.len() {
189            if self.could_start_at(input[pos])
190                && let Some(range) = Self::match_at(input, pos, &mut memo)
191            {
192                return Some(range);
193            }
194        }
195        None
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    #[test]
204    fn id_should_return_modeline() {
205        assert_eq!("modeline", Modeline::default().id());
206    }
207
208    #[test]
209    fn find_vim_set_form() {
210        let f = Modeline::default();
211        let input = "// vim: set ts=4 sw=4 et:";
212        let r = f.find(input).unwrap();
213        assert_eq!("vim: set ts=4 sw=4 et:", &input[r]);
214    }
215
216    #[test]
217    fn find_vim_bare_form() {
218        let f = Modeline::default();
219        let input = "// vim: ts=4 sw=4";
220        let r = f.find(input).unwrap();
221        assert_eq!("vim: ts=4 sw=4", &input[r]);
222    }
223
224    #[test]
225    fn find_vi_prefix() {
226        let f = Modeline::default();
227        let input = "// vi: ts=2";
228        let r = f.find(input).unwrap();
229        assert_eq!("vi: ts=2", &input[r]);
230    }
231
232    #[test]
233    fn find_ex_prefix() {
234        let f = Modeline::default();
235        let input = "/* ex: ts=8 */";
236        let r = f.find(input).unwrap();
237        assert!(input[r].starts_with("ex: ts=8"));
238    }
239
240    #[test]
241    fn find_returns_none_for_plain_text() {
242        let f = Modeline::default();
243        assert!(f.find("hello world").is_none());
244    }
245
246    #[test]
247    fn find_returns_none_for_only_prefix() {
248        let f = Modeline::default();
249        assert!(f.find("vim").is_none());
250    }
251
252    // --- Format variants ---
253
254    #[test]
255    fn find_vim_colon_separated_options() {
256        let f = Modeline::default();
257        let input = "/* vim:ts=4:sw=4:et: */";
258        let r = f.find(input).unwrap();
259        assert_eq!("vim:ts=4:sw=4:et:", &input[r]);
260    }
261
262    #[test]
263    fn find_vim_no_space_after_colon() {
264        let f = Modeline::default();
265        let input = "// vim:ts=4 sw=4";
266        let r = f.find(input).unwrap();
267        assert_eq!("vim:ts=4 sw=4", &input[r]);
268    }
269
270    #[test]
271    fn find_vim_uppercase_prefix() {
272        let f = Modeline::default();
273        let input = "// VIM: ts=4";
274        let r = f.find(input).unwrap();
275        assert_eq!("VIM: ts=4", &input[r]);
276    }
277
278    #[test]
279    fn find_vim_with_filetype() {
280        let f = Modeline::default();
281        let input = "// vim: set ft=rust ts=4:";
282        let r = f.find(input).unwrap();
283        assert_eq!("vim: set ft=rust ts=4:", &input[r]);
284    }
285
286    // --- Boundaries ---
287
288    #[test]
289    fn find_at_start_of_input() {
290        let f = Modeline::default();
291        let input = "vim: ts=4";
292        let r = f.find(input).unwrap();
293        assert_eq!("vim: ts=4", &input[r]);
294    }
295
296    #[test]
297    fn find_trailing_whitespace_trimmed() {
298        let f = Modeline::default();
299        let input = "vim: ts=4   ";
300        let r = f.find(input).unwrap();
301        assert_eq!("vim: ts=4", &input[r]);
302    }
303
304    // --- Negative cases ---
305
306    #[test]
307    fn find_rejects_random_colon_word() {
308        let f = Modeline::default();
309        assert!(f.find("https://example.com").is_none());
310    }
311
312    #[test]
313    fn find_rejects_prefix_glued_to_alpha() {
314        let f = Modeline::default();
315        // 'devim:' — the `vim:` is preceded by 'e', so \b makes it not match
316        assert!(f.find("devim:ts=4").is_none());
317    }
318
319    #[test]
320    fn find_rejects_url_with_scheme_like_prefix() {
321        let f = Modeline::default();
322        assert!(f.find("see http: //example.com").is_none());
323    }
324
325    #[test]
326    fn find_empty_input() {
327        let f = Modeline::default();
328        assert!(f.find("").is_none());
329    }
330
331    // --- In source comments ---
332
333    #[test]
334    fn find_inside_c_block_comment() {
335        let f = Modeline::default();
336        let input = "/* vim: set ts=4 et: */";
337        let r = f.find(input).unwrap();
338        assert!(input[r].starts_with("vim: set ts=4"));
339    }
340
341    #[test]
342    fn find_inside_hash_comment() {
343        let f = Modeline::default();
344        let input = "# vim: ts=4 sw=4";
345        let r = f.find(input).unwrap();
346        assert_eq!("vim: ts=4 sw=4", &input[r]);
347    }
348
349    #[test]
350    fn find_inside_html_comment() {
351        let f = Modeline::default();
352        let input = "<!-- vim: ts=2 -->";
353        let r = f.find(input).unwrap();
354        assert!(input[r].starts_with("vim: ts=2"));
355    }
356
357    // --- Multiple modelines (per-line basis) ---
358
359    #[test]
360    fn find_returns_first_modeline_when_multiple_in_same_input() {
361        let f = Modeline::default();
362        let input = "vim: ts=4 something vim: ts=2";
363        let r = f.find(input).unwrap();
364        // Both forms are matched; first wins.
365        assert!(input[r].starts_with("vim: ts=4"));
366    }
367
368    // --- vi/ex variants ---
369
370    #[test]
371    fn find_vi_with_set_form() {
372        let f = Modeline::default();
373        let input = "// vi: set noet ts=4:";
374        let r = f.find(input).unwrap();
375        assert_eq!("vi: set noet ts=4:", &input[r]);
376    }
377}