Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T01:50:53.919Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

modeline.rs

782 lines · 27.5 KB · rust
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
208// Vim requires whitespace before the `{vi:|vim:|ex:}` marker. As an exception,
209// `vi:` and `vim:` may also appear at the start of a line, but `ex:` may not as
210// Vim ignores a leading `ex:` since it could be short for `example:`.
211static VIM_MODELINE_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
212    [
213        // Second form: [text{white}]{vi:|vim:|Vim:|ex:}[white]se[t] {options}:[text]
214        // Allow escaped colons in options: match non-colon chars or backslash followed by any char
215        r"(?:(?:^|\s)(?:vi|vim|Vim)|\sex):\s*se(?:t)?\s+((?:[^\\:]|\\.)*):",
216        // First form: [text{white}]{vi:|vim:|ex:}[white]{options}
217        r"(?:(?:^|\s)(?:vi|vim)|\sex):\s*(.+)",
218    ]
219    .iter()
220    .map(|pattern| Regex::new(pattern).expect("valid regex"))
221    .collect()
222});
223
224/// Parse Vim-style modelines
225/// Supports both forms:
226/// 1. First form: vi:noai:sw=3 ts=6
227/// 2. Second form: vim: set ft=rust ts=4 sw=4 et:
228fn parse_vim_modeline(line: &str, settings: &mut ModelineSettings) {
229    for re in VIM_MODELINE_PATTERNS.iter() {
230        if let Some(captures) = re.captures(line) {
231            if let Some(options) = captures.get(1) {
232                parse_vim_settings(options.as_str().trim(), settings);
233                break;
234            }
235        }
236    }
237}
238
239fn parse_vim_settings(content: &str, settings: &mut ModelineSettings) {
240    fn split_colon_unescape(input: &str) -> Vec<String> {
241        let mut split = Vec::new();
242        let mut str = String::new();
243        let mut chars = input.chars().peekable();
244        while let Some(c) = chars.next() {
245            if c == '\\' {
246                match chars.next() {
247                    Some(escaped_char) => str.push(escaped_char),
248                    None => str.push('\\'),
249                }
250            } else if c == ':' {
251                split.push(std::mem::take(&mut str));
252            } else {
253                str.push(c);
254            }
255        }
256        split.push(str);
257        split
258    }
259
260    let parts = split_colon_unescape(content);
261    for colon_part in parts {
262        let colon_part = colon_part.trim();
263        if colon_part.is_empty() {
264            continue;
265        }
266
267        // Each colon part might contain space-separated options
268        for part in colon_part.split_whitespace() {
269            if let Some((key, value)) = part.split_once('=') {
270                match key {
271                    "ft" | "filetype" => {
272                        settings.mode = Some(value.to_string());
273                    }
274                    "ts" | "tabstop" => {
275                        if let Ok(size) = value.parse::<NonZeroU32>() {
276                            settings.tab_size = Some(size);
277                        }
278                    }
279                    "sw" | "shiftwidth" => {
280                        if let Ok(size) = value.parse::<NonZeroU32>() {
281                            settings.indent_size = Some(size);
282                        }
283                    }
284                    "tw" | "textwidth" => {
285                        if let Ok(size) = value.parse::<NonZeroU32>() {
286                            settings.preferred_line_length = Some(size);
287                        }
288                    }
289                    _ => {
290                        settings
291                            .vim_extra_variables
292                            .push((key.to_string(), Some(value.to_string())));
293                    }
294                }
295            } else {
296                match part {
297                    "ai" | "autoindent" => {
298                        settings.auto_indent = Some(true);
299                    }
300                    "noai" | "noautoindent" => {
301                        settings.auto_indent = Some(false);
302                    }
303                    "et" | "expandtab" => {
304                        settings.hard_tabs = Some(false);
305                    }
306                    "noet" | "noexpandtab" => {
307                        settings.hard_tabs = Some(true);
308                    }
309                    "eol" | "endofline" => {
310                        settings.ensure_final_newline = Some(true);
311                    }
312                    "noeol" | "noendofline" => {
313                        settings.ensure_final_newline = Some(false);
314                    }
315                    "set" => {
316                        // Ignore the "set" keyword itself
317                    }
318                    _ => {
319                        settings.vim_extra_variables.push((part.to_string(), None));
320                    }
321                }
322            }
323        }
324    }
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330    use indoc::indoc;
331    use pretty_assertions::assert_eq;
332
333    #[test]
334    fn test_no_modeline() {
335        let content = "This is just regular content\nwith no modeline";
336        assert!(parse_modeline(&[content], &[content]).is_none());
337    }
338
339    #[test]
340    fn test_emacs_bare_mode() {
341        let content = "/* -*- rust -*- */";
342        let settings = parse_modeline(&[content], &[]).unwrap();
343        assert_eq!(
344            settings,
345            ModelineSettings {
346                mode: Some("rust".to_string()),
347                ..Default::default()
348            }
349        );
350    }
351
352    #[test]
353    fn test_emacs_modeline_parsing() {
354        let content = "/* -*- mode: rust; tab-width: 4; indent-tabs-mode: nil; -*- */";
355        let settings = parse_modeline(&[content], &[]).unwrap();
356        assert_eq!(
357            settings,
358            ModelineSettings {
359                mode: Some("rust".to_string()),
360                tab_size: Some(NonZeroU32::new(4).unwrap()),
361                hard_tabs: Some(false),
362                ..Default::default()
363            }
364        );
365    }
366
367    #[test]
368    fn test_emacs_last_line_parsing() {
369        let content = indoc! {r#"
370        # Local Variables:
371        # compile-command: "cc foo.c -Dfoo=bar -Dhack=whatever \
372        #   -Dmumble=blaah"
373        # End:
374        "#}
375        .lines()
376        .collect::<Vec<_>>();
377        let settings = parse_modeline(&[], &content).unwrap();
378        assert_eq!(
379            settings,
380            ModelineSettings {
381                emacs_extra_variables: vec![(
382                    "compile-command".to_string(),
383                    "\"cc foo.c -Dfoo=bar -Dhack=whatever -Dmumble=blaah\"".to_string()
384                ),],
385                ..Default::default()
386            }
387        );
388
389        let content = indoc! {"
390            foo
391            /* Local Variables: */
392            /* eval: (font-lock-mode -1) */
393            /* mode: old-c */
394            /* mode: c */
395            /* End: */
396            /* mode: ignored */
397        "}
398        .lines()
399        .collect::<Vec<_>>();
400        let settings = parse_modeline(&[], &content).unwrap();
401        assert_eq!(
402            settings,
403            ModelineSettings {
404                mode: Some("c".to_string()),
405                emacs_extra_variables: vec![(
406                    "eval".to_string(),
407                    "(font-lock-mode -1)".to_string()
408                ),],
409                ..Default::default()
410            }
411        );
412    }
413
414    #[test]
415    fn test_vim_modeline_parsing() {
416        // Test second form (set format)
417        let content = "// vim: set ft=rust ts=4 sw=4 et:";
418        let settings = parse_modeline(&[content], &[]).unwrap();
419        assert_eq!(
420            settings,
421            ModelineSettings {
422                mode: Some("rust".to_string()),
423                tab_size: Some(NonZeroU32::new(4).unwrap()),
424                hard_tabs: Some(false),
425                indent_size: Some(NonZeroU32::new(4).unwrap()),
426                ..Default::default()
427            }
428        );
429
430        // Test first form (colon-separated)
431        let content = "vi:noai:sw=3:ts=6";
432        let settings = parse_modeline(&[content], &[]).unwrap();
433        assert_eq!(
434            settings,
435            ModelineSettings {
436                tab_size: Some(NonZeroU32::new(6).unwrap()),
437                auto_indent: Some(false),
438                indent_size: Some(NonZeroU32::new(3).unwrap()),
439                ..Default::default()
440            }
441        );
442    }
443
444    #[test]
445    fn test_vim_modeline_first_form() {
446        // Examples from vim specification: vi:noai:sw=3 ts=6
447        let content = "   vi:noai:sw=3 ts=6 ";
448        let settings = parse_modeline(&[content], &[]).unwrap();
449        assert_eq!(
450            settings,
451            ModelineSettings {
452                tab_size: Some(NonZeroU32::new(6).unwrap()),
453                auto_indent: Some(false),
454                indent_size: Some(NonZeroU32::new(3).unwrap()),
455                ..Default::default()
456            }
457        );
458
459        // Test with filetype
460        let content = "vim:ft=python:ts=8:noet";
461        let settings = parse_modeline(&[content], &[]).unwrap();
462        assert_eq!(
463            settings,
464            ModelineSettings {
465                mode: Some("python".to_string()),
466                tab_size: Some(NonZeroU32::new(8).unwrap()),
467                hard_tabs: Some(true),
468                ..Default::default()
469            }
470        );
471
472        // Using `ex:` should only be interpreted when whitespace separates it
473        // from the beginning of the line.
474        let content = "ex: filetype=shell";
475        assert_eq!(parse_modeline(&[content], &[]), None);
476
477        let content = "\tex: filetype=shell";
478        let settings = parse_modeline(&[content], &[]).unwrap();
479        assert_eq!(
480            settings,
481            ModelineSettings {
482                mode: Some("shell".to_string()),
483                ..Default::default()
484            }
485        );
486    }
487
488    #[test]
489    fn test_vim_modeline_second_form() {
490        // Examples from vim specification: /* vim: set ai tw=75: */
491        let content = "/* vim: set ai tw=75: */";
492        let settings = parse_modeline(&[content], &[]).unwrap();
493        assert_eq!(
494            settings,
495            ModelineSettings {
496                auto_indent: Some(true),
497                preferred_line_length: Some(NonZeroU32::new(75).unwrap()),
498                ..Default::default()
499            }
500        );
501
502        // Test with 'Vim:' (capital V)
503        let content = "/* Vim: set ai tw=75: */";
504        let settings = parse_modeline(&[content], &[]).unwrap();
505        assert_eq!(
506            settings,
507            ModelineSettings {
508                auto_indent: Some(true),
509                preferred_line_length: Some(NonZeroU32::new(75).unwrap()),
510                ..Default::default()
511            }
512        );
513
514        // Test 'se' shorthand
515        let content = "// vi: se ft=c ts=4:";
516        let settings = parse_modeline(&[content], &[]).unwrap();
517        assert_eq!(
518            settings,
519            ModelineSettings {
520                mode: Some("c".to_string()),
521                tab_size: Some(NonZeroU32::new(4).unwrap()),
522                ..Default::default()
523            }
524        );
525
526        // Test complex modeline with encoding
527        let content = "# vim: set ft=python ts=4 sw=4 et encoding=utf-8:";
528        let settings = parse_modeline(&[content], &[]).unwrap();
529        assert_eq!(
530            settings,
531            ModelineSettings {
532                mode: Some("python".to_string()),
533                tab_size: Some(NonZeroU32::new(4).unwrap()),
534                hard_tabs: Some(false),
535                indent_size: Some(NonZeroU32::new(4).unwrap()),
536                vim_extra_variables: vec![("encoding".to_string(), Some("utf-8".to_string()))],
537                ..Default::default()
538            }
539        );
540    }
541
542    #[test]
543    fn test_vim_modeline_edge_cases() {
544        // Test modeline at start of line (compatibility with version 3.0)
545        let content = "vi:ts=2:et";
546        let settings = parse_modeline(&[content], &[]).unwrap();
547        assert_eq!(
548            settings,
549            ModelineSettings {
550                tab_size: Some(NonZeroU32::new(2).unwrap()),
551                hard_tabs: Some(false),
552                ..Default::default()
553            }
554        );
555
556        // Test vim at start of line
557        let content = "vim:ft=rust:noet";
558        let settings = parse_modeline(&[content], &[]).unwrap();
559        assert_eq!(
560            settings,
561            ModelineSettings {
562                mode: Some("rust".to_string()),
563                hard_tabs: Some(true),
564                ..Default::default()
565            }
566        );
567
568        // Test mixed boolean flags
569        let content = "vim: set wrap noet ts=8:";
570        let settings = parse_modeline(&[content], &[]).unwrap();
571        assert_eq!(
572            settings,
573            ModelineSettings {
574                tab_size: Some(NonZeroU32::new(8).unwrap()),
575                hard_tabs: Some(true),
576                vim_extra_variables: vec![("wrap".to_string(), None)],
577                ..Default::default()
578            }
579        );
580    }
581
582    #[test]
583    fn test_vim_modeline_invalid_cases() {
584        // Test malformed options are ignored gracefully
585        let content = "vim: set ts=invalid ft=rust:";
586        let settings = parse_modeline(&[content], &[]).unwrap();
587        assert_eq!(
588            settings,
589            ModelineSettings {
590                mode: Some("rust".to_string()),
591                ..Default::default()
592            }
593        );
594
595        // Test empty modeline content - this should still work as there might be options
596        let content = "vim: set :";
597        // This should return None because there are no actual options
598        let result = parse_modeline(&[content], &[]);
599        assert!(result.is_none(), "Expected None but got: {:?}", result);
600
601        // Test modeline without proper format
602        let content = "not a modeline";
603        assert!(parse_modeline(&[content], &[]).is_none());
604
605        // Test word that looks like modeline but isn't
606        let content = "example: this could be confused with ex:";
607        assert!(parse_modeline(&[content], &[]).is_none());
608    }
609
610    #[test]
611    fn test_vim_language_mapping() {
612        // Test vim-specific language mappings
613        let content = "vim: set ft=sh:";
614        let settings = parse_modeline(&[content], &[]).unwrap();
615        assert_eq!(settings.mode, Some("sh".to_string()));
616
617        let content = "vim: set ft=golang:";
618        let settings = parse_modeline(&[content], &[]).unwrap();
619        assert_eq!(settings.mode, Some("golang".to_string()));
620
621        let content = "vim: set filetype=js:";
622        let settings = parse_modeline(&[content], &[]).unwrap();
623        assert_eq!(settings.mode, Some("js".to_string()));
624    }
625
626    #[test]
627    fn test_vim_extra_variables() {
628        // Test that unknown vim options are stored as extra variables
629        let content = "vim: set foldmethod=marker conceallevel=2 custom=value:";
630        let settings = parse_modeline(&[content], &[]).unwrap();
631
632        assert!(
633            settings
634                .vim_extra_variables
635                .contains(&("foldmethod".to_string(), Some("marker".to_string())))
636        );
637        assert!(
638            settings
639                .vim_extra_variables
640                .contains(&("conceallevel".to_string(), Some("2".to_string())))
641        );
642        assert!(
643            settings
644                .vim_extra_variables
645                .contains(&("custom".to_string(), Some("value".to_string())))
646        );
647    }
648
649    #[test]
650    fn test_modeline_position() {
651        // Test modeline in first lines
652        let first_lines = ["#!/bin/bash", "# vim: set ft=bash ts=4:"];
653        let settings = parse_modeline(&first_lines, &[]).unwrap();
654        assert_eq!(settings.mode, Some("bash".to_string()));
655
656        // Test modeline in last lines
657        let last_lines = ["", "/* vim: set ft=c: */"];
658        let settings = parse_modeline(&[], &last_lines).unwrap();
659        assert_eq!(settings.mode, Some("c".to_string()));
660
661        // Test no modeline found
662        let content = ["regular content", "no modeline here"];
663        assert!(parse_modeline(&content, &content).is_none());
664    }
665
666    #[test]
667    fn test_vim_modeline_version_checks() {
668        // Note: Current implementation doesn't support version checks yet
669        // These are tests for future implementation based on vim spec
670
671        // Test version-specific modelines (currently ignored in our implementation)
672        let content = "/* vim700: set foldmethod=marker */";
673        // Should be ignored for now since we don't support version checks
674        assert!(parse_modeline(&[content], &[]).is_none());
675
676        let content = "/* vim>702: set cole=2: */";
677        // Should be ignored for now since we don't support version checks
678        assert!(parse_modeline(&[content], &[]).is_none());
679    }
680
681    #[test]
682    fn test_vim_modeline_colon_escaping() {
683        // Test colon escaping as mentioned in vim spec
684
685        // According to vim spec: "if you want to include a ':' in a set command precede it with a '\'"
686        let content = r#"/* vim: set fdm=expr fde=getline(v\:lnum)=~'{'?'>1'\:'1': */"#;
687
688        let result = parse_modeline(&[content], &[]).unwrap();
689
690        // The modeline should parse fdm=expr and fde=getline(v:lnum)=~'{'?'>1':'1'
691        // as extra variables since they're not recognized settings
692        assert_eq!(result.vim_extra_variables.len(), 2);
693        assert_eq!(
694            result.vim_extra_variables[0],
695            ("fdm".to_string(), Some("expr".to_string()))
696        );
697        assert_eq!(
698            result.vim_extra_variables[1],
699            (
700                "fde".to_string(),
701                Some("getline(v:lnum)=~'{'?'>1':'1'".to_string())
702            )
703        );
704    }
705
706    #[test]
707    fn test_vim_modeline_whitespace_requirements() {
708        // Test whitespace requirements from vim spec
709
710        // Valid: whitespace before vi/vim
711        let content = "  vim: set ft=rust:";
712        assert!(parse_modeline(&[content], &[]).is_some());
713
714        // Valid: tab before vi/vim
715        let content = "\tvim: set ft=rust:";
716        assert!(parse_modeline(&[content], &[]).is_some());
717
718        // Valid: vi/vim at start of line (compatibility)
719        let content = "vim: set ft=rust:";
720        assert!(parse_modeline(&[content], &[]).is_some());
721    }
722
723    #[test]
724    fn test_vim_modeline_comprehensive_examples() {
725        // Real-world examples from vim documentation and common usage
726
727        // Python example
728        let content = "# vim: set expandtab tabstop=4 shiftwidth=4 softtabstop=4:";
729        let settings = parse_modeline(&[content], &[]).unwrap();
730        assert_eq!(settings.hard_tabs, Some(false));
731        assert_eq!(settings.tab_size, Some(NonZeroU32::new(4).unwrap()));
732
733        // C example with multiple options
734        let content = "/* vim: set ts=8 sw=8 noet ai cindent: */";
735        let settings = parse_modeline(&[content], &[]).unwrap();
736        assert_eq!(settings.tab_size, Some(NonZeroU32::new(8).unwrap()));
737        assert_eq!(settings.hard_tabs, Some(true));
738        assert!(
739            settings
740                .vim_extra_variables
741                .contains(&("cindent".to_string(), None))
742        );
743
744        // Shell script example
745        let content = "# vi: set ft=sh ts=2 sw=2 et:";
746        let settings = parse_modeline(&[content], &[]).unwrap();
747        assert_eq!(settings.mode, Some("sh".to_string()));
748        assert_eq!(settings.tab_size, Some(NonZeroU32::new(2).unwrap()));
749        assert_eq!(settings.hard_tabs, Some(false));
750
751        // First form colon-separated
752        let content = "vim:ft=xml:ts=2:sw=2:et";
753        let settings = parse_modeline(&[content], &[]).unwrap();
754        assert_eq!(settings.mode, Some("xml".to_string()));
755        assert_eq!(settings.tab_size, Some(NonZeroU32::new(2).unwrap()));
756        assert_eq!(settings.hard_tabs, Some(false));
757    }
758
759    #[test]
760    fn test_combined_emacs_vim_detection() {
761        // Test that both emacs and vim modelines can be detected in the same file
762
763        let first_lines = [
764            "#!/usr/bin/env python3",
765            "# -*- require-final-newline: t; -*-",
766            "# vim: set ft=python ts=4 sw=4 et:",
767        ];
768
769        // Should find the emacs modeline first (with coding)
770        let settings = parse_modeline(&first_lines, &[]).unwrap();
771        assert_eq!(settings.ensure_final_newline, Some(true));
772        assert_eq!(settings.tab_size, None);
773
774        // Test vim-only content
775        let vim_only = ["# vim: set ft=python ts=4 sw=4 et:"];
776        let settings = parse_modeline(&vim_only, &[]).unwrap();
777        assert_eq!(settings.mode, Some("python".to_string()));
778        assert_eq!(settings.tab_size, Some(NonZeroU32::new(4).unwrap()));
779        assert_eq!(settings.hard_tabs, Some(false));
780    }
781}
782
Served at tenant.openagents/omega Member data and write actions are omitted.