1use super::{Finder, Memo, RunClass, RunRule};
11use crate::word::boundary_before;
12use std::ops::Range;
13
14#[derive(Default)]
15pub struct Modeline {}
16
17impl 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 fn keyword_end(input: &[u8], pos: usize) -> Option<usize> {
46 if Self::eq_ci(input, pos, b"vim") {
47 let p = pos + 3;
48 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 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 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 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 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 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 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 #[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 #[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 #[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 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 #[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 #[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 assert!(input[r].starts_with("vim: ts=4"));
366 }
367
368 #[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}