modeline.rs

  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}