1use regex::Regex;
2use settings::AutoIndentMode;
3use std::{num::NonZeroU32, sync::LazyLock};
4
5/// The settings extracted from an emacs/vim modelines.
6///
7/// The parsing tries to best match the modeline directives and
8/// variables to Zed, matching LanguageSettings fields.
9/// The mode mapping is done later thanks to the LanguageRegistry.
10///
11/// It is not exhaustive, but covers the most common settings.
12#[derive(Debug, Clone, Default, PartialEq)]
13pub struct ModelineSettings {
14 /// The emacs mode or vim filetype.
15 pub mode: Option<String>,
16 /// How many columns a tab should occupy.
17 pub tab_size: Option<NonZeroU32>,
18 /// Whether to indent lines using tab characters, as opposed to multiple
19 /// spaces.
20 pub hard_tabs: Option<bool>,
21 /// The number of bytes that comprise the indentation.
22 pub indent_size: Option<NonZeroU32>,
23 /// Whether to auto-indent lines.
24 pub auto_indent: Option<AutoIndentMode>,
25 /// The column at which to soft-wrap lines.
26 pub preferred_line_length: Option<NonZeroU32>,
27 /// Whether to ensure a final newline at the end of the file.
28 pub ensure_final_newline: Option<bool>,
29 /// Whether to show trailing whitespace on the editor.
30 pub show_trailing_whitespace: Option<bool>,
31
32 /// Emacs modeline variables that were parsed but not mapped to Zed settings.
33 /// Stored as (variable-name, value) pairs.
34 pub emacs_extra_variables: Vec<(String, String)>,
35 /// Vim modeline options that were parsed but not mapped to Zed settings.
36 /// Stored as (option-name, value) pairs.
37 pub vim_extra_variables: Vec<(String, Option<String>)>,
38}
39
40impl ModelineSettings {
41 fn has_settings(&self) -> bool {
42 self != &Self::default()
43 }
44}
45
46/// Parse modelines from file content.
47///
48/// Supports:
49/// - Emacs modelines: -*- mode: rust; tab-width: 4; indent-tabs-mode: nil; -*- and "Local Variables"
50/// - Vim modelines: vim: set ft=rust ts=4 sw=4 et:
51pub fn parse_modeline(first_lines: &[&str], last_lines: &[&str]) -> Option<ModelineSettings> {
52 let mut settings = ModelineSettings::default();
53
54 parse_modelines(first_lines, &mut settings);
55
56 // Parse Emacs Local Variables in last lines
57 parse_emacs_local_variables(last_lines, &mut settings);
58
59 // Also check for vim modelines in last lines if we don't have settings yet
60 if !settings.has_settings() {
61 parse_vim_modelines(last_lines, &mut settings);
62 }
63
64 Some(settings).filter(|s| s.has_settings())
65}
66
67fn parse_modelines(modelines: &[&str], settings: &mut ModelineSettings) {
68 for line in modelines {
69 parse_emacs_modeline(line, settings);
70 // if emacs is set, do not check for vim modelines
71 if settings.has_settings() {
72 return;
73 }
74 }
75
76 parse_vim_modelines(modelines, settings);
77}
78
79static EMACS_MODELINE_RE: LazyLock<Regex> =
80 LazyLock::new(|| Regex::new(r"-\*-\s*(.+?)\s*-\*-").expect("valid regex"));
81
82/// Parse Emacs-style modelines
83/// Format: -*- mode: rust; tab-width: 4; indent-tabs-mode: nil; -*-
84/// See Emacs (set-auto-mode)
85fn parse_emacs_modeline(line: &str, settings: &mut ModelineSettings) {
86 let Some(captures) = EMACS_MODELINE_RE.captures(line) else {
87 return;
88 };
89 let Some(modeline_content) = captures.get(1).map(|m| m.as_str()) else {
90 return;
91 };
92 for part in modeline_content.split(';') {
93 parse_emacs_key_value(part, settings, true);
94 }
95}
96
97/// Parse Emacs-style Local Variables block
98///
99/// Emacs supports a "Local Variables" block at the end of files:
100/// ```text
101/// /* Local Variables: */
102/// /* mode: c */
103/// /* tab-width: 4 */
104/// /* End: */
105/// ```
106///
107/// Emacs related code is hack-local-variables--find-variables in
108/// https://cgit.git.savannah.gnu.org/cgit/emacs.git/tree/lisp/files.el#n4346
109fn parse_emacs_local_variables(lines: &[&str], settings: &mut ModelineSettings) {
110 const LOCAL_VARIABLES: &str = "Local Variables:";
111
112 let Some((start_idx, prefix, suffix)) = lines.iter().enumerate().find_map(|(i, line)| {
113 let prefix_len = line.find(LOCAL_VARIABLES)?;
114 let suffix_start = prefix_len + LOCAL_VARIABLES.len();
115 Some((i, line.get(..prefix_len)?, line.get(suffix_start..)?))
116 }) else {
117 return;
118 };
119
120 let mut continuation = String::new();
121
122 for line in &lines[start_idx + 1..] {
123 let Some(content) = line
124 .strip_prefix(prefix)
125 .and_then(|l| l.strip_suffix(suffix))
126 .map(str::trim)
127 else {
128 return;
129 };
130
131 if let Some(continued) = content.strip_suffix('\\') {
132 continuation.push_str(continued);
133 continue;
134 }
135
136 let to_parse = if continuation.is_empty() {
137 content
138 } else {
139 continuation.push_str(content);
140 &continuation
141 };
142
143 if to_parse == "End:" {
144 return;
145 }
146
147 parse_emacs_key_value(to_parse, settings, false);
148 continuation.clear();
149 }
150}
151
152fn parse_emacs_key_value(part: &str, settings: &mut ModelineSettings, bare: bool) {
153 let part = part.trim();
154 if part.is_empty() {
155 return;
156 }
157
158 if let Some((key, value)) = part.split_once(':') {
159 let key = key.trim();
160 let value = value.trim();
161
162 match key.to_lowercase().as_str() {
163 "mode" => {
164 settings.mode = Some(value.to_string());
165 }
166 "c-basic-offset" | "python-indent-offset" => {
167 if let Ok(size) = value.parse::<NonZeroU32>() {
168 settings.indent_size = Some(size);
169 }
170 }
171 "fill-column" => {
172 if let Ok(size) = value.parse::<NonZeroU32>() {
173 settings.preferred_line_length = Some(size);
174 }
175 }
176 "tab-width" => {
177 if let Ok(size) = value.parse::<NonZeroU32>() {
178 settings.tab_size = Some(size);
179 }
180 }
181 "indent-tabs-mode" => {
182 settings.hard_tabs = Some(value != "nil");
183 }
184 "electric-indent-mode" => {
185 settings.auto_indent = Some(if value != "nil" {
186 AutoIndentMode::SyntaxAware
187 } else {
188 AutoIndentMode::None
189 });
190 }
191 "require-final-newline" => {
192 settings.ensure_final_newline = Some(value != "nil");
193 }
194 "show-trailing-whitespace" => {
195 settings.show_trailing_whitespace = Some(value != "nil");
196 }
197 key => settings
198 .emacs_extra_variables
199 .push((key.to_string(), value.to_string())),
200 }
201 } else if bare {
202 // Handle bare mode specification (e.g., -*- rust -*-)
203 settings.mode = Some(part.to_string());
204 }
205}
206
207fn parse_vim_modelines(modelines: &[&str], settings: &mut ModelineSettings) {
208 for line in modelines {
209 parse_vim_modeline(line, settings);
210 }
211}
212
213static VIM_MODELINE_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
214 [
215 // Second form: [text{white}]{vi:vim:Vim:}[white]se[t] {options}:[text]
216 // Allow escaped colons in options: match non-colon chars or backslash followed by any char
217 r"(?:^|\s)(vi|vim|Vim):(?:\s*)se(?:t)?\s+((?:[^\\:]|\\.)*):",
218 // First form: [text{white}]{vi:vim:}[white]{options}
219 r"(?:^|\s+)(vi|vim):(?:\s*(.+))",
220 ]
221 .iter()
222 .map(|pattern| Regex::new(pattern).expect("valid regex"))
223 .collect()
224});
225
226/// Parse Vim-style modelines
227/// Supports both forms:
228/// 1. First form: vi:noai:sw=3 ts=6
229/// 2. Second form: vim: set ft=rust ts=4 sw=4 et:
230fn parse_vim_modeline(line: &str, settings: &mut ModelineSettings) {
231 for re in VIM_MODELINE_PATTERNS.iter() {
232 if let Some(captures) = re.captures(line) {
233 if let Some(options) = captures.get(2) {
234 parse_vim_settings(options.as_str().trim(), settings);
235 break;
236 }
237 }
238 }
239}
240
241fn parse_vim_settings(content: &str, settings: &mut ModelineSettings) {
242 fn split_colon_unescape(input: &str) -> Vec<String> {
243 let mut split = Vec::new();
244 let mut str = String::new();
245 let mut chars = input.chars().peekable();
246 while let Some(c) = chars.next() {
247 if c == '\\' {
248 match chars.next() {
249 Some(escaped_char) => str.push(escaped_char),
250 None => str.push('\\'),
251 }
252 } else if c == ':' {
253 split.push(std::mem::take(&mut str));
254 } else {
255 str.push(c);
256 }
257 }
258 split.push(str);
259 split
260 }
261
262 let parts = split_colon_unescape(content);
263 for colon_part in parts {
264 let colon_part = colon_part.trim();
265 if colon_part.is_empty() {
266 continue;
267 }
268
269 // Each colon part might contain space-separated options
270 for part in colon_part.split_whitespace() {
271 if let Some((key, value)) = part.split_once('=') {
272 match key {
273 "ft" | "filetype" => {
274 settings.mode = Some(value.to_string());
275 }
276 "ts" | "tabstop" => {
277 if let Ok(size) = value.parse::<NonZeroU32>() {
278 settings.tab_size = Some(size);
279 }
280 }
281 "sw" | "shiftwidth" => {
282 if let Ok(size) = value.parse::<NonZeroU32>() {
283 settings.indent_size = Some(size);
284 }
285 }
286 "tw" | "textwidth" => {
287 if let Ok(size) = value.parse::<NonZeroU32>() {
288 settings.preferred_line_length = Some(size);
289 }
290 }
291 _ => {
292 settings
293 .vim_extra_variables
294 .push((key.to_string(), Some(value.to_string())));
295 }
296 }
297 } else {
298 match part {
299 "ai" | "autoindent" => {
300 settings.auto_indent = Some(AutoIndentMode::SyntaxAware);
301 }
302 "noai" | "noautoindent" => {
303 settings.auto_indent = Some(AutoIndentMode::None);
304 }
305 "et" | "expandtab" => {
306 settings.hard_tabs = Some(false);
307 }
308 "noet" | "noexpandtab" => {
309 settings.hard_tabs = Some(true);
310 }
311 "eol" | "endofline" => {
312 settings.ensure_final_newline = Some(true);
313 }
314 "noeol" | "noendofline" => {
315 settings.ensure_final_newline = Some(false);
316 }
317 "set" => {
318 // Ignore the "set" keyword itself
319 }
320 _ => {
321 settings.vim_extra_variables.push((part.to_string(), None));
322 }
323 }
324 }
325 }
326 }
327}
328
329#[cfg(test)]
330mod tests {
331 use super::*;
332 use indoc::indoc;
333 use pretty_assertions::assert_eq;
334
335 #[test]
336 fn test_no_modeline() {
337 let content = "This is just regular content\nwith no modeline";
338 assert!(parse_modeline(&[content], &[content]).is_none());
339 }
340
341 #[test]
342 fn test_emacs_bare_mode() {
343 let content = "/* -*- rust -*- */";
344 let settings = parse_modeline(&[content], &[]).unwrap();
345 assert_eq!(
346 settings,
347 ModelineSettings {
348 mode: Some("rust".to_string()),
349 ..Default::default()
350 }
351 );
352 }
353
354 #[test]
355 fn test_emacs_modeline_parsing() {
356 let content = "/* -*- mode: rust; tab-width: 4; indent-tabs-mode: nil; -*- */";
357 let settings = parse_modeline(&[content], &[]).unwrap();
358 assert_eq!(
359 settings,
360 ModelineSettings {
361 mode: Some("rust".to_string()),
362 tab_size: Some(NonZeroU32::new(4).unwrap()),
363 hard_tabs: Some(false),
364 ..Default::default()
365 }
366 );
367 }
368
369 #[test]
370 fn test_emacs_last_line_parsing() {
371 let content = indoc! {r#"
372 # Local Variables:
373 # compile-command: "cc foo.c -Dfoo=bar -Dhack=whatever \
374 # -Dmumble=blaah"
375 # End:
376 "#}
377 .lines()
378 .collect::<Vec<_>>();
379 let settings = parse_modeline(&[], &content).unwrap();
380 assert_eq!(
381 settings,
382 ModelineSettings {
383 emacs_extra_variables: vec![(
384 "compile-command".to_string(),
385 "\"cc foo.c -Dfoo=bar -Dhack=whatever -Dmumble=blaah\"".to_string()
386 ),],
387 ..Default::default()
388 }
389 );
390
391 let content = indoc! {"
392 foo
393 /* Local Variables: */
394 /* eval: (font-lock-mode -1) */
395 /* mode: old-c */
396 /* mode: c */
397 /* End: */
398 /* mode: ignored */
399 "}
400 .lines()
401 .collect::<Vec<_>>();
402 let settings = parse_modeline(&[], &content).unwrap();
403 assert_eq!(
404 settings,
405 ModelineSettings {
406 mode: Some("c".to_string()),
407 emacs_extra_variables: vec![(
408 "eval".to_string(),
409 "(font-lock-mode -1)".to_string()
410 ),],
411 ..Default::default()
412 }
413 );
414 }
415
416 #[test]
417 fn test_vim_modeline_parsing() {
418 // Test second form (set format)
419 let content = "// vim: set ft=rust ts=4 sw=4 et:";
420 let settings = parse_modeline(&[content], &[]).unwrap();
421 assert_eq!(
422 settings,
423 ModelineSettings {
424 mode: Some("rust".to_string()),
425 tab_size: Some(NonZeroU32::new(4).unwrap()),
426 hard_tabs: Some(false),
427 indent_size: Some(NonZeroU32::new(4).unwrap()),
428 ..Default::default()
429 }
430 );
431
432 // Test first form (colon-separated)
433 let content = "vi:noai:sw=3:ts=6";
434 let settings = parse_modeline(&[content], &[]).unwrap();
435 assert_eq!(
436 settings,
437 ModelineSettings {
438 tab_size: Some(NonZeroU32::new(6).unwrap()),
439 auto_indent: Some(AutoIndentMode::None),
440 indent_size: Some(NonZeroU32::new(3).unwrap()),
441 ..Default::default()
442 }
443 );
444 }
445
446 #[test]
447 fn test_vim_modeline_first_form() {
448 // Examples from vim specification: vi:noai:sw=3 ts=6
449 let content = " vi:noai:sw=3 ts=6 ";
450 let settings = parse_modeline(&[content], &[]).unwrap();
451 assert_eq!(
452 settings,
453 ModelineSettings {
454 tab_size: Some(NonZeroU32::new(6).unwrap()),
455 auto_indent: Some(AutoIndentMode::None),
456 indent_size: Some(NonZeroU32::new(3).unwrap()),
457 ..Default::default()
458 }
459 );
460
461 // Test with filetype
462 let content = "vim:ft=python:ts=8:noet";
463 let settings = parse_modeline(&[content], &[]).unwrap();
464 assert_eq!(
465 settings,
466 ModelineSettings {
467 mode: Some("python".to_string()),
468 tab_size: Some(NonZeroU32::new(8).unwrap()),
469 hard_tabs: Some(true),
470 ..Default::default()
471 }
472 );
473 }
474
475 #[test]
476 fn test_vim_modeline_second_form() {
477 // Examples from vim specification: /* vim: set ai tw=75: */
478 let content = "/* vim: set ai tw=75: */";
479 let settings = parse_modeline(&[content], &[]).unwrap();
480 assert_eq!(
481 settings,
482 ModelineSettings {
483 auto_indent: Some(AutoIndentMode::SyntaxAware),
484 preferred_line_length: Some(NonZeroU32::new(75).unwrap()),
485 ..Default::default()
486 }
487 );
488
489 // Test with 'Vim:' (capital V)
490 let content = "/* Vim: set ai tw=75: */";
491 let settings = parse_modeline(&[content], &[]).unwrap();
492 assert_eq!(
493 settings,
494 ModelineSettings {
495 auto_indent: Some(AutoIndentMode::SyntaxAware),
496 preferred_line_length: Some(NonZeroU32::new(75).unwrap()),
497 ..Default::default()
498 }
499 );
500
501 // Test 'se' shorthand
502 let content = "// vi: se ft=c ts=4:";
503 let settings = parse_modeline(&[content], &[]).unwrap();
504 assert_eq!(
505 settings,
506 ModelineSettings {
507 mode: Some("c".to_string()),
508 tab_size: Some(NonZeroU32::new(4).unwrap()),
509 ..Default::default()
510 }
511 );
512
513 // Test complex modeline with encoding
514 let content = "# vim: set ft=python ts=4 sw=4 et encoding=utf-8:";
515 let settings = parse_modeline(&[content], &[]).unwrap();
516 assert_eq!(
517 settings,
518 ModelineSettings {
519 mode: Some("python".to_string()),
520 tab_size: Some(NonZeroU32::new(4).unwrap()),
521 hard_tabs: Some(false),
522 indent_size: Some(NonZeroU32::new(4).unwrap()),
523 vim_extra_variables: vec![("encoding".to_string(), Some("utf-8".to_string()))],
524 ..Default::default()
525 }
526 );
527 }
528
529 #[test]
530 fn test_vim_modeline_edge_cases() {
531 // Test modeline at start of line (compatibility with version 3.0)
532 let content = "vi:ts=2:et";
533 let settings = parse_modeline(&[content], &[]).unwrap();
534 assert_eq!(
535 settings,
536 ModelineSettings {
537 tab_size: Some(NonZeroU32::new(2).unwrap()),
538 hard_tabs: Some(false),
539 ..Default::default()
540 }
541 );
542
543 // Test vim at start of line
544 let content = "vim:ft=rust:noet";
545 let settings = parse_modeline(&[content], &[]).unwrap();
546 assert_eq!(
547 settings,
548 ModelineSettings {
549 mode: Some("rust".to_string()),
550 hard_tabs: Some(true),
551 ..Default::default()
552 }
553 );
554
555 // Test mixed boolean flags
556 let content = "vim: set wrap noet ts=8:";
557 let settings = parse_modeline(&[content], &[]).unwrap();
558 assert_eq!(
559 settings,
560 ModelineSettings {
561 tab_size: Some(NonZeroU32::new(8).unwrap()),
562 hard_tabs: Some(true),
563 vim_extra_variables: vec![("wrap".to_string(), None)],
564 ..Default::default()
565 }
566 );
567 }
568
569 #[test]
570 fn test_vim_modeline_invalid_cases() {
571 // Test malformed options are ignored gracefully
572 let content = "vim: set ts=invalid ft=rust:";
573 let settings = parse_modeline(&[content], &[]).unwrap();
574 assert_eq!(
575 settings,
576 ModelineSettings {
577 mode: Some("rust".to_string()),
578 ..Default::default()
579 }
580 );
581
582 // Test empty modeline content - this should still work as there might be options
583 let content = "vim: set :";
584 // This should return None because there are no actual options
585 let result = parse_modeline(&[content], &[]);
586 assert!(result.is_none(), "Expected None but got: {:?}", result);
587
588 // Test modeline without proper format
589 let content = "not a modeline";
590 assert!(parse_modeline(&[content], &[]).is_none());
591
592 // Test word that looks like modeline but isn't
593 let content = "example: this could be confused with ex:";
594 assert!(parse_modeline(&[content], &[]).is_none());
595 }
596
597 #[test]
598 fn test_vim_language_mapping() {
599 // Test vim-specific language mappings
600 let content = "vim: set ft=sh:";
601 let settings = parse_modeline(&[content], &[]).unwrap();
602 assert_eq!(settings.mode, Some("sh".to_string()));
603
604 let content = "vim: set ft=golang:";
605 let settings = parse_modeline(&[content], &[]).unwrap();
606 assert_eq!(settings.mode, Some("golang".to_string()));
607
608 let content = "vim: set filetype=js:";
609 let settings = parse_modeline(&[content], &[]).unwrap();
610 assert_eq!(settings.mode, Some("js".to_string()));
611 }
612
613 #[test]
614 fn test_vim_extra_variables() {
615 // Test that unknown vim options are stored as extra variables
616 let content = "vim: set foldmethod=marker conceallevel=2 custom=value:";
617 let settings = parse_modeline(&[content], &[]).unwrap();
618
619 assert!(
620 settings
621 .vim_extra_variables
622 .contains(&("foldmethod".to_string(), Some("marker".to_string())))
623 );
624 assert!(
625 settings
626 .vim_extra_variables
627 .contains(&("conceallevel".to_string(), Some("2".to_string())))
628 );
629 assert!(
630 settings
631 .vim_extra_variables
632 .contains(&("custom".to_string(), Some("value".to_string())))
633 );
634 }
635
636 #[test]
637 fn test_modeline_position() {
638 // Test modeline in first lines
639 let first_lines = ["#!/bin/bash", "# vim: set ft=bash ts=4:"];
640 let settings = parse_modeline(&first_lines, &[]).unwrap();
641 assert_eq!(settings.mode, Some("bash".to_string()));
642
643 // Test modeline in last lines
644 let last_lines = ["", "/* vim: set ft=c: */"];
645 let settings = parse_modeline(&[], &last_lines).unwrap();
646 assert_eq!(settings.mode, Some("c".to_string()));
647
648 // Test no modeline found
649 let content = ["regular content", "no modeline here"];
650 assert!(parse_modeline(&content, &content).is_none());
651 }
652
653 #[test]
654 fn test_vim_modeline_version_checks() {
655 // Note: Current implementation doesn't support version checks yet
656 // These are tests for future implementation based on vim spec
657
658 // Test version-specific modelines (currently ignored in our implementation)
659 let content = "/* vim700: set foldmethod=marker */";
660 // Should be ignored for now since we don't support version checks
661 assert!(parse_modeline(&[content], &[]).is_none());
662
663 let content = "/* vim>702: set cole=2: */";
664 // Should be ignored for now since we don't support version checks
665 assert!(parse_modeline(&[content], &[]).is_none());
666 }
667
668 #[test]
669 fn test_vim_modeline_colon_escaping() {
670 // Test colon escaping as mentioned in vim spec
671
672 // According to vim spec: "if you want to include a ':' in a set command precede it with a '\'"
673 let content = r#"/* vim: set fdm=expr fde=getline(v\:lnum)=~'{'?'>1'\:'1': */"#;
674
675 let result = parse_modeline(&[content], &[]).unwrap();
676
677 // The modeline should parse fdm=expr and fde=getline(v:lnum)=~'{'?'>1':'1'
678 // as extra variables since they're not recognized settings
679 assert_eq!(result.vim_extra_variables.len(), 2);
680 assert_eq!(
681 result.vim_extra_variables[0],
682 ("fdm".to_string(), Some("expr".to_string()))
683 );
684 assert_eq!(
685 result.vim_extra_variables[1],
686 (
687 "fde".to_string(),
688 Some("getline(v:lnum)=~'{'?'>1':'1'".to_string())
689 )
690 );
691 }
692
693 #[test]
694 fn test_vim_modeline_whitespace_requirements() {
695 // Test whitespace requirements from vim spec
696
697 // Valid: whitespace before vi/vim
698 let content = " vim: set ft=rust:";
699 assert!(parse_modeline(&[content], &[]).is_some());
700
701 // Valid: tab before vi/vim
702 let content = "\tvim: set ft=rust:";
703 assert!(parse_modeline(&[content], &[]).is_some());
704
705 // Valid: vi/vim at start of line (compatibility)
706 let content = "vim: set ft=rust:";
707 assert!(parse_modeline(&[content], &[]).is_some());
708 }
709
710 #[test]
711 fn test_vim_modeline_comprehensive_examples() {
712 // Real-world examples from vim documentation and common usage
713
714 // Python example
715 let content = "# vim: set expandtab tabstop=4 shiftwidth=4 softtabstop=4:";
716 let settings = parse_modeline(&[content], &[]).unwrap();
717 assert_eq!(settings.hard_tabs, Some(false));
718 assert_eq!(settings.tab_size, Some(NonZeroU32::new(4).unwrap()));
719
720 // C example with multiple options
721 let content = "/* vim: set ts=8 sw=8 noet ai cindent: */";
722 let settings = parse_modeline(&[content], &[]).unwrap();
723 assert_eq!(settings.tab_size, Some(NonZeroU32::new(8).unwrap()));
724 assert_eq!(settings.hard_tabs, Some(true));
725 assert!(
726 settings
727 .vim_extra_variables
728 .contains(&("cindent".to_string(), None))
729 );
730
731 // Shell script example
732 let content = "# vi: set ft=sh ts=2 sw=2 et:";
733 let settings = parse_modeline(&[content], &[]).unwrap();
734 assert_eq!(settings.mode, Some("sh".to_string()));
735 assert_eq!(settings.tab_size, Some(NonZeroU32::new(2).unwrap()));
736 assert_eq!(settings.hard_tabs, Some(false));
737
738 // First form colon-separated
739 let content = "vim:ft=xml:ts=2:sw=2:et";
740 let settings = parse_modeline(&[content], &[]).unwrap();
741 assert_eq!(settings.mode, Some("xml".to_string()));
742 assert_eq!(settings.tab_size, Some(NonZeroU32::new(2).unwrap()));
743 assert_eq!(settings.hard_tabs, Some(false));
744 }
745
746 #[test]
747 fn test_combined_emacs_vim_detection() {
748 // Test that both emacs and vim modelines can be detected in the same file
749
750 let first_lines = [
751 "#!/usr/bin/env python3",
752 "# -*- require-final-newline: t; -*-",
753 "# vim: set ft=python ts=4 sw=4 et:",
754 ];
755
756 // Should find the emacs modeline first (with coding)
757 let settings = parse_modeline(&first_lines, &[]).unwrap();
758 assert_eq!(settings.ensure_final_newline, Some(true));
759 assert_eq!(settings.tab_size, None);
760
761 // Test vim-only content
762 let vim_only = ["# vim: set ft=python ts=4 sw=4 et:"];
763 let settings = parse_modeline(&vim_only, &[]).unwrap();
764 assert_eq!(settings.mode, Some("python".to_string()));
765 assert_eq!(settings.tab_size, Some(NonZeroU32::new(4).unwrap()));
766 assert_eq!(settings.hard_tabs, Some(false));
767 }
768}