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