modeline.rs

  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}