diff --git a/assets/settings/default.json b/assets/settings/default.json index f687778d7bd7fc..6f7ef0cc5ddeb4 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -1216,6 +1216,12 @@ "hard_tabs": false, // How many columns a tab should occupy. "tab_size": 4, + // Number of lines to search for modelines at the beginning and end of files. + // Modelines contain editor directives (e.g., vim/emacs settings) that configure + // the editor behavior for specific files. + // + // A value of 0 disables modelines support. + "modeline_lines": 5, // What debuggers are preferred by default for all languages. "debuggers": [], // Whether to enable word diff highlighting in the editor. diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 9c7590ccd6c587..658bb523a7b401 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -2145,6 +2145,7 @@ impl AcpThread { let settings = language::language_settings::language_settings( buffer.language().map(|l| l.name()), + buffer.modeline().map(Arc::as_ref), buffer.file(), cx, ); diff --git a/crates/acp_thread/src/diff.rs b/crates/acp_thread/src/diff.rs index f17e9d0fce4044..4074b05c01e904 100644 --- a/crates/acp_thread/src/diff.rs +++ b/crates/acp_thread/src/diff.rs @@ -383,6 +383,7 @@ async fn build_buffer_diff( old_text_rope, buffer.language().cloned(), language_registry, + buffer.modeline().cloned(), cx, ) })? diff --git a/crates/agent/src/tools/edit_file_tool.rs b/crates/agent/src/tools/edit_file_tool.rs index cbe96a6b20d6e3..f10d7c99b2871b 100644 --- a/crates/agent/src/tools/edit_file_tool.rs +++ b/crates/agent/src/tools/edit_file_tool.rs @@ -417,6 +417,7 @@ impl AgentTool for EditFileTool { .read_with(cx, |buffer, cx| { let settings = language_settings::language_settings( buffer.language().map(|l| l.name()), + buffer.modeline().map(Arc::as_ref), buffer.file(), cx, ); diff --git a/crates/buffer_diff/src/buffer_diff.rs b/crates/buffer_diff/src/buffer_diff.rs index 55de3f968bc1cc..f12e2e8dc7cb1e 100644 --- a/crates/buffer_diff/src/buffer_diff.rs +++ b/crates/buffer_diff/src/buffer_diff.rs @@ -237,8 +237,13 @@ impl BufferDiffSnapshot { if let Some(text) = &base_text { let base_text_rope = Rope::from(text.as_str()); base_text_pair = Some((text.clone(), base_text_rope.clone())); - let snapshot = - language::Buffer::build_snapshot(base_text_rope, language, language_registry, cx); + let snapshot = language::Buffer::build_snapshot( + base_text_rope, + language, + language_registry, + None, + cx, + ); base_text_snapshot = cx.background_spawn(snapshot); base_text_exists = true; } else { @@ -855,7 +860,7 @@ fn build_diff_options( } } - language_settings(language, file, cx) + language_settings(language, None, file, cx) .word_diff_enabled .then_some(DiffOptions { language_scope, diff --git a/crates/collab/src/tests/remote_editing_collaboration_tests.rs b/crates/collab/src/tests/remote_editing_collaboration_tests.rs index 04403de9fa0883..ab8983d80f8f69 100644 --- a/crates/collab/src/tests/remote_editing_collaboration_tests.rs +++ b/crates/collab/src/tests/remote_editing_collaboration_tests.rs @@ -161,8 +161,10 @@ async fn test_sharing_an_ssh_remote_project( cx_b.read(|cx| { let file = buffer_b.read(cx).file(); + let modeline = buffer_b.read(cx).modeline(); assert_eq!( - language_settings(Some("Rust".into()), file, cx).language_servers, + language_settings(Some("Rust".into()), modeline.map(Arc::as_ref), file, cx) + .language_servers, ["override-rust-analyzer".to_string()] ) }); diff --git a/crates/copilot/src/copilot.rs b/crates/copilot/src/copilot.rs index 6fbdeff807b65d..ea53eaef25f44f 100644 --- a/crates/copilot/src/copilot.rs +++ b/crates/copilot/src/copilot.rs @@ -968,6 +968,7 @@ impl Copilot { let position = position.to_point_utf16(buffer); let settings = language_settings( buffer.language_at(position).map(|l| l.name()), + buffer.modeline().map(Arc::as_ref), buffer.file(), cx, ); diff --git a/crates/debugger_ui/src/new_process_modal.rs b/crates/debugger_ui/src/new_process_modal.rs index 40187cef9cc55c..f07f66aa15acd6 100644 --- a/crates/debugger_ui/src/new_process_modal.rs +++ b/crates/debugger_ui/src/new_process_modal.rs @@ -1277,22 +1277,28 @@ impl PickerDelegate for DebugDelegate { }; let file = location.buffer.read(cx).file(); let language = location.buffer.read(cx).language(); + let modeline = location.buffer.read(cx).modeline(); let language_name = language.as_ref().map(|l| l.name()); let Some(adapter): Option = - language::language_settings::language_settings(language_name, file, cx) - .debuggers - .first() - .map(SharedString::from) - .map(Into::into) - .or_else(|| { - language.and_then(|l| { - l.config() - .debuggers - .first() - .map(SharedString::from) - .map(Into::into) - }) + language::language_settings::language_settings( + language_name, + modeline.map(Arc::as_ref), + file, + cx, + ) + .debuggers + .first() + .map(SharedString::from) + .map(Into::into) + .or_else(|| { + language.and_then(|l| { + l.config() + .debuggers + .first() + .map(SharedString::from) + .map(Into::into) }) + }) else { return; }; diff --git a/crates/edit_prediction_context/src/edit_prediction_context_tests.rs b/crates/edit_prediction_context/src/edit_prediction_context_tests.rs index f62df37e551db1..1d6d8de6fc70cd 100644 --- a/crates/edit_prediction_context/src/edit_prediction_context_tests.rs +++ b/crates/edit_prediction_context/src/edit_prediction_context_tests.rs @@ -517,6 +517,7 @@ pub(crate) fn rust_lang() -> Arc { matcher: LanguageMatcher { path_suffixes: vec!["rs".to_string()], first_line_pattern: None, + ..LanguageMatcher::default() }, ..Default::default() }, diff --git a/crates/edit_prediction_ui/src/edit_prediction_button.rs b/crates/edit_prediction_ui/src/edit_prediction_button.rs index dd3ebab42029f5..e2c276c1ce16b6 100644 --- a/crates/edit_prediction_ui/src/edit_prediction_button.rs +++ b/crates/edit_prediction_ui/src/edit_prediction_button.rs @@ -694,7 +694,7 @@ impl EditPredictionButton { let language_state = self.language.as_ref().map(|language| { ( language.clone(), - language_settings::language_settings(Some(language.name()), None, cx) + language_settings::language_settings(Some(language.name()), None, None, cx) .show_edit_predictions, ) }); diff --git a/crates/editor/src/bracket_colorization.rs b/crates/editor/src/bracket_colorization.rs index e4933b3ad5d8a2..29fc118bb34566 100644 --- a/crates/editor/src/bracket_colorization.rs +++ b/crates/editor/src/bracket_colorization.rs @@ -3,6 +3,7 @@ //! and theme accents to colorize those. use std::ops::Range; +use std::sync::Arc; use crate::Editor; use collections::HashMap; @@ -49,6 +50,7 @@ impl Editor { let buffer_snapshot = buffer.read(cx).snapshot(); if language_settings::language_settings( buffer_snapshot.language().map(|language| language.name()), + buffer_snapshot.modeline().map(Arc::as_ref), buffer_snapshot.file(), cx, ) diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index 081e8ec2e5c3fe..c67c9f7e5e02e9 100644 --- a/crates/editor/src/display_map.rs +++ b/crates/editor/src/display_map.rs @@ -632,7 +632,8 @@ impl DisplayMap { .and_then(|buffer| buffer.language()) .map(|l| l.name()); let file = buffer.and_then(|buffer| buffer.file()); - language_settings(language, file, cx).tab_size + let modeline = buffer.and_then(|buffer| buffer.modeline()); + language_settings(language, modeline.map(Arc::as_ref), file, cx).tab_size } #[cfg(test)] diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 6651cce3740018..f3ffd67c3e1112 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -576,7 +576,7 @@ impl Default for EditorStyle { } pub fn make_inlay_hints_style(cx: &mut App) -> HighlightStyle { - let show_background = language_settings::language_settings(None, None, cx) + let show_background = language_settings::language_settings(None, None, None, cx) .inlay_hints .show_background; @@ -5406,11 +5406,13 @@ impl Editor { .read(cx) .text_anchor_for_position(position, cx)?; + let modeline = buffer.read(cx).modeline(); let settings = language_settings::language_settings( buffer .read(cx) .language_at(buffer_position) .map(|l| l.name()), + modeline.map(Arc::as_ref), buffer.read(cx).file(), cx, ); @@ -5530,7 +5532,12 @@ impl Editor { .language_at(buffer_position.text_anchor) .map(|language| language.name()); - let language_settings = language_settings(language.clone(), buffer_snapshot.file(), cx); + let language_settings = language_settings( + language.clone(), + buffer_snapshot.modeline().map(Arc::as_ref), + buffer_snapshot.file(), + cx, + ); let completion_settings = language_settings.completions.clone(); if !menu_is_open && trigger.is_some() && !language_settings.show_completions_on_input { @@ -6408,11 +6415,13 @@ impl Editor { let buffer = buffer.read(cx); let language = buffer.language()?; let file = buffer.file(); - let debug_adapter = language_settings(language.name().into(), file, cx) - .debuggers - .first() - .map(SharedString::from) - .or_else(|| language.config().debuggers.first().map(SharedString::from))?; + let modeline = buffer.modeline(); + let debug_adapter = + language_settings(language.name().into(), modeline.map(Arc::as_ref), file, cx) + .debuggers + .first() + .map(SharedString::from) + .or_else(|| language.config().debuggers.first().map(SharedString::from))?; dap_store.update(cx, |dap_store, cx| { for (_, task) in &resolved_tasks.templates { @@ -7380,8 +7389,16 @@ impl Editor { let buffer = buffer.read(cx); let file = buffer.file(); + let modeline = buffer.modeline(); - if !language_settings(buffer.language().map(|l| l.name()), file, cx).show_edit_predictions { + if !language_settings( + buffer.language().map(|l| l.name()), + modeline.map(Arc::as_ref), + file, + cx, + ) + .show_edit_predictions + { return EditPredictionSettings::Disabled; }; @@ -21782,7 +21799,11 @@ impl Editor { let language = buffer.language().map(|language| language.name()); if let hash_map::Entry::Vacant(v) = acc.entry(language.clone()) { let file = buffer.file(); - v.insert(language_settings(language, file, cx).into_owned()); + let modeline = buffer.modeline(); + v.insert( + language_settings(language, modeline.map(Arc::as_ref), file, cx) + .into_owned(), + ); } acc }, @@ -22907,10 +22928,14 @@ fn process_completion_for_edit( CompletionIntent::CompleteWithInsert => false, CompletionIntent::CompleteWithReplace => true, CompletionIntent::Complete | CompletionIntent::Compose => { - let insert_mode = - language_settings(buffer.language().map(|l| l.name()), buffer.file(), cx) - .completions - .lsp_insert_mode; + let insert_mode = language_settings( + buffer.language().map(|l| l.name()), + buffer.modeline().map(Arc::as_ref), + buffer.file(), + cx, + ) + .completions + .lsp_insert_mode; match insert_mode { LspInsertMode::Insert => false, LspInsertMode::Replace => true, diff --git a/crates/editor/src/indent_guides.rs b/crates/editor/src/indent_guides.rs index f186f9da77aca5..68257cd79689a4 100644 --- a/crates/editor/src/indent_guides.rs +++ b/crates/editor/src/indent_guides.rs @@ -1,4 +1,4 @@ -use std::{cmp::Ordering, ops::Range, time::Duration}; +use std::{cmp::Ordering, ops::Range, sync::Arc, time::Duration}; use collections::HashSet; use gpui::{App, AppContext as _, Context, Task, Window}; @@ -39,6 +39,7 @@ impl Editor { if let Some(buffer) = self.buffer().read(cx).as_singleton() { language_settings( buffer.read(cx).language().map(|l| l.name()), + buffer.read(cx).modeline().map(Arc::as_ref), buffer.read(cx).file(), cx, ) diff --git a/crates/editor/src/inlays/inlay_hints.rs b/crates/editor/src/inlays/inlay_hints.rs index 18bbc56005a8ca..5aea723fa840a7 100644 --- a/crates/editor/src/inlays/inlay_hints.rs +++ b/crates/editor/src/inlays/inlay_hints.rs @@ -1,6 +1,7 @@ use std::{ collections::hash_map, ops::{ControlFlow, Range}, + sync::Arc, time::Duration, }; @@ -38,7 +39,8 @@ pub fn inlay_hint_settings( ) -> InlayHintSettings { let file = snapshot.file_at(location); let language = snapshot.language_at(location).map(|l| l.name()); - language_settings(language, file, cx).inlay_hints + let modeline = snapshot.modeline_at(location); + language_settings(language, modeline.map(Arc::as_ref), file, cx).inlay_hints } #[derive(Debug)] diff --git a/crates/editor/src/jsx_tag_auto_close.rs b/crates/editor/src/jsx_tag_auto_close.rs index e22fde313df4b9..8c30899923b07f 100644 --- a/crates/editor/src/jsx_tag_auto_close.rs +++ b/crates/editor/src/jsx_tag_auto_close.rs @@ -3,6 +3,7 @@ use collections::HashMap; use gpui::{Context, Entity, Window}; use multi_buffer::{BufferOffset, MultiBuffer, ToOffset}; use std::ops::Range; +use std::sync::Arc; use util::ResultExt as _; use language::{BufferSnapshot, JsxTagAutoCloseConfig, Node}; @@ -318,6 +319,7 @@ pub(crate) fn refresh_enabled_in_any_buffer( let buffer = buffer.read(cx); let snapshot = buffer.snapshot(); + let modeline = buffer.modeline(); for syntax_layer in snapshot.syntax_layers() { let language = syntax_layer.language; if language.config().jsx_tag_auto_close.is_none() { @@ -325,6 +327,7 @@ pub(crate) fn refresh_enabled_in_any_buffer( } let language_settings = language::language_settings::language_settings( Some(language.name()), + modeline.map(Arc::as_ref), snapshot.file(), cx, ); diff --git a/crates/extension_host/src/extension_store_test.rs b/crates/extension_host/src/extension_store_test.rs index 6d3aadeb5ac498..0f8c0a133c4ee0 100644 --- a/crates/extension_host/src/extension_store_test.rs +++ b/crates/extension_host/src/extension_store_test.rs @@ -214,6 +214,7 @@ async fn test_extension_store(cx: &mut TestAppContext) { matcher: LanguageMatcher { path_suffixes: vec!["erb".into()], first_line_pattern: None, + ..LanguageMatcher::default() }, }, ), @@ -227,6 +228,7 @@ async fn test_extension_store(cx: &mut TestAppContext) { matcher: LanguageMatcher { path_suffixes: vec!["rb".into()], first_line_pattern: None, + ..LanguageMatcher::default() }, }, ), diff --git a/crates/extension_host/src/wasm_host/wit/since_v0_1_0.rs b/crates/extension_host/src/wasm_host/wit/since_v0_1_0.rs index a7a20f6dc7f1db..e73bd10548d3c5 100644 --- a/crates/extension_host/src/wasm_host/wit/since_v0_1_0.rs +++ b/crates/extension_host/src/wasm_host/wit/since_v0_1_0.rs @@ -440,6 +440,7 @@ impl ExtensionImports for WasmState { let settings = AllLanguageSettings::get(location, cx).language( location, key.as_ref(), + None, cx, ); Ok(serde_json::to_string(&settings::LanguageSettings { diff --git a/crates/extension_host/src/wasm_host/wit/since_v0_8_0.rs b/crates/extension_host/src/wasm_host/wit/since_v0_8_0.rs index a2776f9f3b5b05..8c0d3fc5eb419b 100644 --- a/crates/extension_host/src/wasm_host/wit/since_v0_8_0.rs +++ b/crates/extension_host/src/wasm_host/wit/since_v0_8_0.rs @@ -933,6 +933,7 @@ impl ExtensionImports for WasmState { let settings = AllLanguageSettings::get(location, cx).language( location, key.as_ref(), + None, cx, ); Ok(serde_json::to_string(&settings::LanguageSettings { diff --git a/crates/git_ui/src/commit_view.rs b/crates/git_ui/src/commit_view.rs index 7d191c1ae461ac..96e771baa70352 100644 --- a/crates/git_ui/src/commit_view.rs +++ b/crates/git_ui/src/commit_view.rs @@ -824,6 +824,7 @@ async fn build_buffer_diff( old_text.as_deref().unwrap_or("").into(), buffer.language().cloned(), Some(language_registry.clone()), + buffer.modeline().cloned(), cx, ) })? diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index 7166a01ef64bff..1270029693991e 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -1,8 +1,8 @@ pub mod row_chunk; use crate::{ - DebuggerTextObject, LanguageScope, Outline, OutlineConfig, RunnableCapture, RunnableTag, - TextObject, TreeSitterOptions, + DebuggerTextObject, LanguageScope, ModelineSettings, Outline, OutlineConfig, RunnableCapture, + RunnableTag, TextObject, TreeSitterOptions, diagnostic_set::{DiagnosticEntry, DiagnosticEntryRef, DiagnosticGroup}, language_settings::{LanguageSettings, language_settings}, outline::OutlineItem, @@ -129,6 +129,7 @@ pub struct Buffer { /// The contents of a cell are (self.version, has_changes) at the time of a last call. has_unsaved_edits: Cell<(clock::Global, bool)>, change_bits: Vec>>, + modeline: Option>, _subscriptions: Vec, tree_sitter_data: Arc>, } @@ -177,6 +178,7 @@ pub struct BufferSnapshot { language: Option>, non_text_state_update_count: usize, tree_sitter_data: Arc>, + modeline: Option>, } /// The kind and amount of indentation in a particular line. For now, @@ -1090,6 +1092,7 @@ impl Buffer { deferred_ops: OperationQueue::new(), has_conflict: false, change_bits: Default::default(), + modeline: None, _subscriptions: Vec::new(), } } @@ -1098,6 +1101,7 @@ impl Buffer { text: Rope, language: Option>, language_registry: Option>, + modeline: Option>, cx: &mut App, ) -> impl Future + use<> { let entity_id = cx.reserve_entity::().entity_id(); @@ -1121,6 +1125,7 @@ impl Buffer { tree_sitter_data: Arc::new(Mutex::new(tree_sitter_data)), language, non_text_state_update_count: 0, + modeline, } } } @@ -1146,6 +1151,7 @@ impl Buffer { remote_selections: Default::default(), language: None, non_text_state_update_count: 0, + modeline: None, } } @@ -1175,6 +1181,7 @@ impl Buffer { remote_selections: Default::default(), language, non_text_state_update_count: 0, + modeline: None, } } @@ -1195,6 +1202,7 @@ impl Buffer { diagnostics: self.diagnostics.clone(), language: self.language.clone(), non_text_state_update_count: self.non_text_state_update_count, + modeline: self.modeline.clone(), } } @@ -1419,6 +1427,16 @@ impl Buffer { ); } + /// Assign the buffer [`ModelineSettings`]. + pub fn set_modeline(&mut self, modeline: Option) { + self.modeline = modeline.map(Arc::new); + } + + /// Returns the [`ModelineSettings`]. + pub fn modeline(&self) -> Option<&Arc> { + self.modeline.as_ref() + } + /// Assign the buffer a new [`Capability`]. pub fn set_capability(&mut self, capability: Capability, cx: &mut Context) { if self.capability != capability { @@ -2536,8 +2554,13 @@ impl Buffer { } else { // The auto-indent setting is not present in editorconfigs, hence // we can avoid passing the file here. - let auto_indent = - language_settings(language.map(|l| l.name()), None, cx).auto_indent; + let auto_indent = language_settings( + language.map(|l| l.name()), + self.modeline().map(Arc::as_ref), + None, + cx, + ) + .auto_indent; previous_setting = Some((language_id, auto_indent)); auto_indent } @@ -3130,6 +3153,7 @@ impl BufferSnapshot { pub fn language_indent_size_at(&self, position: T, cx: &App) -> IndentSize { let settings = language_settings( self.language_at(position).map(|l| l.name()), + self.modeline().map(Arc::as_ref), self.file(), cx, ); @@ -3564,6 +3588,11 @@ impl BufferSnapshot { }) } + /// Returns the [`ModelineSettings`]. + pub fn modeline(&self) -> Option<&Arc> { + self.modeline.as_ref() + } + /// Returns the main [`Language`]. pub fn language(&self) -> Option<&Arc> { self.language.as_ref() @@ -3584,6 +3613,7 @@ impl BufferSnapshot { ) -> Cow<'a, LanguageSettings> { language_settings( self.language_at(position).map(|l| l.name()), + self.modeline().map(Arc::as_ref), self.file.as_ref(), cx, ) @@ -5093,6 +5123,7 @@ impl Clone for BufferSnapshot { language: self.language.clone(), tree_sitter_data: self.tree_sitter_data.clone(), non_text_state_update_count: self.non_text_state_update_count, + modeline: self.modeline.clone(), } } } diff --git a/crates/language/src/buffer_tests.rs b/crates/language/src/buffer_tests.rs index e95bc544a56ecf..752463e27ca329 100644 --- a/crates/language/src/buffer_tests.rs +++ b/crates/language/src/buffer_tests.rs @@ -246,6 +246,7 @@ async fn test_first_line_pattern(cx: &mut TestAppContext) { matcher: LanguageMatcher { path_suffixes: vec!["js".into()], first_line_pattern: Some(Regex::new(r"\bnode\b").unwrap()), + ..LanguageMatcher::default() }, ..Default::default() }); diff --git a/crates/language/src/language.rs b/crates/language/src/language.rs index 0451be3ee164aa..7b7d222e1494ff 100644 --- a/crates/language/src/language.rs +++ b/crates/language/src/language.rs @@ -12,6 +12,7 @@ mod highlight_map; mod language_registry; pub mod language_settings; mod manifest; +pub mod modeline; mod outline; pub mod proto; mod syntax_map; @@ -40,6 +41,7 @@ use lsp::{ CodeActionKind, InitializeParams, LanguageServerBinary, LanguageServerBinaryOptions, Uri, }; pub use manifest::{ManifestDelegate, ManifestName, ManifestProvider, ManifestQuery}; +pub use modeline::{ModelineSettings, parse_modeline}; use parking_lot::Mutex; use regex::Regex; use schemars::{JsonSchema, SchemaGenerator, json_schema}; @@ -135,6 +137,7 @@ pub static PLAIN_TEXT: LazyLock> = LazyLock::new(|| { matcher: LanguageMatcher { path_suffixes: vec!["txt".to_owned()], first_line_pattern: None, + modeline_aliases: vec!["text".to_owned(), "txt".to_owned()], }, ..Default::default() }, @@ -839,6 +842,11 @@ pub struct LanguageMatcher { )] #[schemars(schema_with = "regex_json_schema")] pub first_line_pattern: Option, + /// Alternative names for this language used in vim/emacs modelines. + /// These are matched case-insensitively against the `mode` (emacs) or + /// `filetype`/`ft` (vim) specified in the modeline. + #[serde(default)] + pub modeline_aliases: Vec, } /// The configuration for JSX tag auto-closing. diff --git a/crates/language/src/language_registry.rs b/crates/language/src/language_registry.rs index a0b04efd1b1366..8e99c487532447 100644 --- a/crates/language/src/language_registry.rs +++ b/crates/language/src/language_registry.rs @@ -706,6 +706,44 @@ impl LanguageRegistry { .cloned() } + /// Look up a language by its modeline name (vim filetype or emacs mode). + /// + /// This performs a case-insensitive match against: + /// 1. Explicit modeline aliases defined in the language config + /// 2. The language's grammar name + /// 3. The language name itself + pub fn available_language_for_modeline_name( + self: &Arc, + modeline_name: &str, + ) -> Option { + let modeline_name_lower = modeline_name.to_lowercase(); + let state = self.state.read(); + + state + .available_languages + .iter() + .find(|lang| { + lang.matcher + .modeline_aliases + .iter() + .any(|alias| alias.to_lowercase() == modeline_name_lower) + }) + .or_else(|| { + state.available_languages.iter().find(|lang| { + lang.grammar + .as_ref() + .is_some_and(|g| g.to_lowercase() == modeline_name_lower) + }) + }) + .or_else(|| { + state + .available_languages + .iter() + .find(|lang| lang.name.0.to_lowercase() == modeline_name_lower) + }) + .cloned() + } + pub fn language_for_file( self: &Arc, file: &Arc, diff --git a/crates/language/src/language_settings.rs b/crates/language/src/language_settings.rs index 068f8e1aa39ca3..918127fd5ca0e0 100644 --- a/crates/language/src/language_settings.rs +++ b/crates/language/src/language_settings.rs @@ -1,6 +1,6 @@ //! Provides `language`-related settings. -use crate::{File, Language, LanguageName, LanguageServerName}; +use crate::{File, Language, LanguageName, LanguageServerName, ModelineSettings}; use collections::{FxHashMap, HashMap, HashSet}; use ec4rs::{ Properties as EditorconfigProperties, @@ -22,6 +22,7 @@ use std::{borrow::Cow, num::NonZeroU32, path::Path, sync::Arc}; /// Returns the settings for the specified language from the provided file. pub fn language_settings<'a>( language: Option, + modeline: Option<&ModelineSettings>, file: Option<&'a Arc>, cx: &'a App, ) -> Cow<'a, LanguageSettings> { @@ -29,7 +30,7 @@ pub fn language_settings<'a>( worktree_id: f.worktree_id(cx), path: f.path().as_ref(), }); - AllLanguageSettings::get(location, cx).language(location, language.as_ref(), cx) + AllLanguageSettings::get(location, cx).language(location, language.as_ref(), modeline, cx) } /// Returns the settings for all languages from the provided file. @@ -436,6 +437,7 @@ impl AllLanguageSettings { &'a self, location: Option>, language_name: Option<&LanguageName>, + modeline: Option<&ModelineSettings>, cx: &'a App, ) -> Cow<'a, LanguageSettings> { let settings = language_name @@ -446,13 +448,17 @@ impl AllLanguageSettings { cx.global::() .editorconfig_properties(location.worktree_id, location.path) }); - if let Some(editorconfig_properties) = editorconfig_properties { + let mut lang_settings = if let Some(editorconfig_properties) = editorconfig_properties { let mut settings = settings.clone(); merge_with_editorconfig(&mut settings, &editorconfig_properties); Cow::Owned(settings) } else { Cow::Borrowed(settings) + }; + if let Some(modeline) = modeline { + merge_with_modeline(lang_settings.to_mut(), modeline); } + lang_settings } /// Returns whether edit predictions are enabled for the given path. @@ -462,7 +468,7 @@ impl AllLanguageSettings { /// Returns whether edit predictions are enabled for the given language and path. pub fn show_edit_predictions(&self, language: Option<&Arc>, cx: &App) -> bool { - self.language(None, language.map(|l| l.name()).as_ref(), cx) + self.language(None, language.map(|l| l.name()).as_ref(), None, cx) .show_edit_predictions } @@ -472,6 +478,33 @@ impl AllLanguageSettings { } } +fn merge_with_modeline(settings: &mut LanguageSettings, modeline: &ModelineSettings) { + let show_whitespaces = if modeline.show_trailing_whitespace.unwrap_or(false) { + Some(ShowWhitespaceSetting::Trailing) + } else { + None + }; + + fn merge(target: &mut T, value: Option) { + if let Some(value) = value { + *target = value; + } + } + + merge(&mut settings.tab_size, modeline.tab_size); + merge(&mut settings.hard_tabs, modeline.hard_tabs); + merge( + &mut settings.preferred_line_length, + modeline.preferred_line_length.map(|v| u32::from(v)), + ); + merge(&mut settings.auto_indent, modeline.auto_indent); + merge(&mut settings.show_whitespaces, show_whitespaces); + merge( + &mut settings.ensure_final_newline_on_save, + modeline.ensure_final_newline, + ); +} + fn merge_with_editorconfig(settings: &mut LanguageSettings, cfg: &EditorconfigProperties) { let preferred_line_length = cfg.get::().ok().and_then(|v| match v { MaxLineLen::Value(u) => Some(u as u32), diff --git a/crates/language/src/modeline.rs b/crates/language/src/modeline.rs new file mode 100644 index 00000000000000..8b7e6044492fc6 --- /dev/null +++ b/crates/language/src/modeline.rs @@ -0,0 +1,763 @@ +use regex::Regex; +use std::{num::NonZeroU32, sync::LazyLock}; + +/// The settings extracted from an emacs/vim modelines. +/// +/// The parsing tries to best match the modeline directives and +/// variables to Zed, matching LanguageSettings fields. +/// The mode mapping is done later thanks to the LanguageRegistry. +/// +/// It is not exhaustive, but covers the most common settings. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct ModelineSettings { + /// The emacs mode or vim filetype. + pub mode: Option, + /// How many columns a tab should occupy. + pub tab_size: Option, + /// Whether to indent lines using tab characters, as opposed to multiple + /// spaces. + pub hard_tabs: Option, + /// The number of bytes that comprise the indentation. + pub indent_size: Option, + /// Whether to auto-indent lines. + pub auto_indent: Option, + /// The column at which to soft-wrap lines. + pub preferred_line_length: Option, + /// Whether to ensure a final newline at the end of the file. + pub ensure_final_newline: Option, + /// Whether to show trailing whitespace on the editor. + pub show_trailing_whitespace: Option, + + /// Emacs modeline variables that were parsed but not mapped to Zed settings. + /// Stored as (variable-name, value) pairs. + pub emacs_extra_variables: Vec<(String, String)>, + /// Vim modeline options that were parsed but not mapped to Zed settings. + /// Stored as (option-name, value) pairs. + pub vim_extra_variables: Vec<(String, Option)>, +} + +impl ModelineSettings { + fn has_settings(&self) -> bool { + self != &Self::default() + } +} + +/// Parse modelines from file content. +/// +/// Supports: +/// - Emacs modelines: -*- mode: rust; tab-width: 4; indent-tabs-mode: nil; -*- and "Local Variables" +/// - Vim modelines: vim: set ft=rust ts=4 sw=4 et: +pub fn parse_modeline(first_lines: &[&str], last_lines: &[&str]) -> Option { + let mut settings = ModelineSettings::default(); + + parse_modelines(first_lines, &mut settings); + + // Parse Emacs Local Variables in last lines + parse_emacs_local_variables(last_lines, &mut settings); + + // Also check for vim modelines in last lines if we don't have settings yet + if !settings.has_settings() { + parse_vim_modelines(last_lines, &mut settings); + } + + Some(settings).filter(|s| s.has_settings()) +} + +fn parse_modelines(modelines: &[&str], settings: &mut ModelineSettings) { + for line in modelines { + parse_emacs_modeline(line, settings); + // if emacs is set, do not check for vim modelines + if settings.has_settings() { + return; + } + } + + parse_vim_modelines(modelines, settings); +} + +static EMACS_MODELINE_RE: LazyLock = + LazyLock::new(|| Regex::new(r"-\*-\s*(.+?)\s*-\*-").expect("valid regex")); + +/// Parse Emacs-style modelines +/// Format: -*- mode: rust; tab-width: 4; indent-tabs-mode: nil; -*- +/// See Emacs (set-auto-mode) +fn parse_emacs_modeline(line: &str, settings: &mut ModelineSettings) { + let Some(captures) = EMACS_MODELINE_RE.captures(line) else { + return; + }; + let Some(modeline_content) = captures.get(1).map(|m| m.as_str()) else { + return; + }; + for part in modeline_content.split(';') { + parse_emacs_key_value(part, settings, true); + } +} + +/// Parse Emacs-style Local Variables block +/// +/// Emacs supports a "Local Variables" block at the end of files: +/// ```text +/// /* Local Variables: */ +/// /* mode: c */ +/// /* tab-width: 4 */ +/// /* End: */ +/// ``` +/// +/// Emacs related code is hack-local-variables--find-variables in +/// https://cgit.git.savannah.gnu.org/cgit/emacs.git/tree/lisp/files.el#n4346 +fn parse_emacs_local_variables(lines: &[&str], settings: &mut ModelineSettings) { + const LOCAL_VARIABLES: &str = "Local Variables:"; + + let Some((start_idx, prefix, suffix)) = lines.iter().enumerate().find_map(|(i, line)| { + let prefix_len = line.find(LOCAL_VARIABLES)?; + let suffix_start = prefix_len + LOCAL_VARIABLES.len(); + Some((i, line.get(..prefix_len)?, line.get(suffix_start..)?)) + }) else { + return; + }; + + let mut continuation = String::new(); + + for line in &lines[start_idx + 1..] { + let Some(content) = line + .strip_prefix(prefix) + .and_then(|l| l.strip_suffix(suffix)) + .map(str::trim) + else { + return; + }; + + if let Some(continued) = content.strip_suffix('\\') { + continuation.push_str(continued); + continue; + } + + let to_parse = if continuation.is_empty() { + content + } else { + continuation.push_str(content); + &continuation + }; + + if to_parse == "End:" { + return; + } + + parse_emacs_key_value(to_parse, settings, false); + continuation.clear(); + } +} + +fn parse_emacs_key_value(part: &str, settings: &mut ModelineSettings, bare: bool) { + let part = part.trim(); + if part.is_empty() { + return; + } + + if let Some((key, value)) = part.split_once(':') { + let key = key.trim(); + let value = value.trim(); + + match key.to_lowercase().as_str() { + "mode" => { + settings.mode = Some(value.to_string()); + } + "c-basic-offset" | "python-indent-offset" => { + if let Ok(size) = value.parse::() { + settings.indent_size = Some(size); + } + } + "fill-column" => { + if let Ok(size) = value.parse::() { + settings.preferred_line_length = Some(size); + } + } + "tab-width" => { + if let Ok(size) = value.parse::() { + settings.tab_size = Some(size); + } + } + "indent-tabs-mode" => { + settings.hard_tabs = Some(value != "nil"); + } + "electric-indent-mode" => { + settings.auto_indent = Some(value != "nil"); + } + "require-final-newline" => { + settings.ensure_final_newline = Some(value != "nil"); + } + "show-trailing-whitespace" => { + settings.show_trailing_whitespace = Some(value != "nil"); + } + key => settings + .emacs_extra_variables + .push((key.to_string(), value.to_string())), + } + } else if bare { + // Handle bare mode specification (e.g., -*- rust -*-) + settings.mode = Some(part.to_string()); + } +} + +fn parse_vim_modelines(modelines: &[&str], settings: &mut ModelineSettings) { + for line in modelines { + parse_vim_modeline(line, settings); + } +} + +static VIM_MODELINE_PATTERNS: LazyLock> = LazyLock::new(|| { + [ + // Second form: [text{white}]{vi:vim:Vim:}[white]se[t] {options}:[text] + // Allow escaped colons in options: match non-colon chars or backslash followed by any char + r"(?:^|\s)(vi|vim|Vim):(?:\s*)se(?:t)?\s+((?:[^\\:]|\\.)*):", + // First form: [text{white}]{vi:vim:}[white]{options} + r"(?:^|\s+)(vi|vim):(?:\s*(.+))", + ] + .iter() + .map(|pattern| Regex::new(pattern).expect("valid regex")) + .collect() +}); + +/// Parse Vim-style modelines +/// Supports both forms: +/// 1. First form: vi:noai:sw=3 ts=6 +/// 2. Second form: vim: set ft=rust ts=4 sw=4 et: +fn parse_vim_modeline(line: &str, settings: &mut ModelineSettings) { + for re in VIM_MODELINE_PATTERNS.iter() { + if let Some(captures) = re.captures(line) { + if let Some(options) = captures.get(2) { + parse_vim_settings(options.as_str().trim(), settings); + break; + } + } + } +} + +fn parse_vim_settings(content: &str, settings: &mut ModelineSettings) { + fn split_colon_unescape(input: &str) -> Vec { + let mut split = Vec::new(); + let mut str = String::new(); + let mut chars = input.chars().peekable(); + while let Some(c) = chars.next() { + if c == '\\' { + match chars.next() { + Some(escaped_char) => str.push(escaped_char), + None => str.push('\\'), + } + } else if c == ':' { + split.push(std::mem::take(&mut str)); + } else { + str.push(c); + } + } + split.push(str); + split + } + + let parts = split_colon_unescape(content); + for colon_part in parts { + let colon_part = colon_part.trim(); + if colon_part.is_empty() { + continue; + } + + // Each colon part might contain space-separated options + for part in colon_part.split_whitespace() { + if let Some((key, value)) = part.split_once('=') { + match key { + "ft" | "filetype" => { + settings.mode = Some(value.to_string()); + } + "ts" | "tabstop" => { + if let Ok(size) = value.parse::() { + settings.tab_size = Some(size); + } + } + "sw" | "shiftwidth" => { + if let Ok(size) = value.parse::() { + settings.indent_size = Some(size); + } + } + "tw" | "textwidth" => { + if let Ok(size) = value.parse::() { + settings.preferred_line_length = Some(size); + } + } + _ => { + settings + .vim_extra_variables + .push((key.to_string(), Some(value.to_string()))); + } + } + } else { + match part { + "ai" | "autoindent" => { + settings.auto_indent = Some(true); + } + "noai" | "noautoindent" => { + settings.auto_indent = Some(false); + } + "et" | "expandtab" => { + settings.hard_tabs = Some(false); + } + "noet" | "noexpandtab" => { + settings.hard_tabs = Some(true); + } + "eol" | "endofline" => { + settings.ensure_final_newline = Some(true); + } + "noeol" | "noendofline" => { + settings.ensure_final_newline = Some(false); + } + "set" => { + // Ignore the "set" keyword itself + } + _ => { + settings.vim_extra_variables.push((part.to_string(), None)); + } + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use indoc::indoc; + use pretty_assertions::assert_eq; + + #[test] + fn test_no_modeline() { + let content = "This is just regular content\nwith no modeline"; + assert!(parse_modeline(&[content], &[content]).is_none()); + } + + #[test] + fn test_emacs_bare_mode() { + let content = "/* -*- rust -*- */"; + let settings = parse_modeline(&[content], &[]).unwrap(); + assert_eq!( + settings, + ModelineSettings { + mode: Some("rust".to_string()), + ..Default::default() + } + ); + } + + #[test] + fn test_emacs_modeline_parsing() { + let content = "/* -*- mode: rust; tab-width: 4; indent-tabs-mode: nil; -*- */"; + let settings = parse_modeline(&[content], &[]).unwrap(); + assert_eq!( + settings, + ModelineSettings { + mode: Some("rust".to_string()), + tab_size: Some(NonZeroU32::new(4).unwrap()), + hard_tabs: Some(false), + ..Default::default() + } + ); + } + + #[test] + fn test_emacs_last_line_parsing() { + let content = indoc! {r#" + # Local Variables: + # compile-command: "cc foo.c -Dfoo=bar -Dhack=whatever \ + # -Dmumble=blaah" + # End: + "#} + .lines() + .collect::>(); + let settings = parse_modeline(&[], &content).unwrap(); + assert_eq!( + settings, + ModelineSettings { + emacs_extra_variables: vec![( + "compile-command".to_string(), + "\"cc foo.c -Dfoo=bar -Dhack=whatever -Dmumble=blaah\"".to_string() + ),], + ..Default::default() + } + ); + + let content = indoc! {" + foo + /* Local Variables: */ + /* eval: (font-lock-mode -1) */ + /* mode: old-c */ + /* mode: c */ + /* End: */ + /* mode: ignored */ + "} + .lines() + .collect::>(); + let settings = parse_modeline(&[], &content).unwrap(); + assert_eq!( + settings, + ModelineSettings { + mode: Some("c".to_string()), + emacs_extra_variables: vec![( + "eval".to_string(), + "(font-lock-mode -1)".to_string() + ),], + ..Default::default() + } + ); + } + + #[test] + fn test_vim_modeline_parsing() { + // Test second form (set format) + let content = "// vim: set ft=rust ts=4 sw=4 et:"; + let settings = parse_modeline(&[content], &[]).unwrap(); + assert_eq!( + settings, + ModelineSettings { + mode: Some("rust".to_string()), + tab_size: Some(NonZeroU32::new(4).unwrap()), + hard_tabs: Some(false), + indent_size: Some(NonZeroU32::new(4).unwrap()), + ..Default::default() + } + ); + + // Test first form (colon-separated) + let content = "vi:noai:sw=3:ts=6"; + let settings = parse_modeline(&[content], &[]).unwrap(); + assert_eq!( + settings, + ModelineSettings { + tab_size: Some(NonZeroU32::new(6).unwrap()), + auto_indent: Some(false), + indent_size: Some(NonZeroU32::new(3).unwrap()), + ..Default::default() + } + ); + } + + #[test] + fn test_vim_modeline_first_form() { + // Examples from vim specification: vi:noai:sw=3 ts=6 + let content = " vi:noai:sw=3 ts=6 "; + let settings = parse_modeline(&[content], &[]).unwrap(); + assert_eq!( + settings, + ModelineSettings { + tab_size: Some(NonZeroU32::new(6).unwrap()), + auto_indent: Some(false), + indent_size: Some(NonZeroU32::new(3).unwrap()), + ..Default::default() + } + ); + + // Test with filetype + let content = "vim:ft=python:ts=8:noet"; + let settings = parse_modeline(&[content], &[]).unwrap(); + assert_eq!( + settings, + ModelineSettings { + mode: Some("python".to_string()), + tab_size: Some(NonZeroU32::new(8).unwrap()), + hard_tabs: Some(true), + ..Default::default() + } + ); + } + + #[test] + fn test_vim_modeline_second_form() { + // Examples from vim specification: /* vim: set ai tw=75: */ + let content = "/* vim: set ai tw=75: */"; + let settings = parse_modeline(&[content], &[]).unwrap(); + assert_eq!( + settings, + ModelineSettings { + auto_indent: Some(true), + preferred_line_length: Some(NonZeroU32::new(75).unwrap()), + ..Default::default() + } + ); + + // Test with 'Vim:' (capital V) + let content = "/* Vim: set ai tw=75: */"; + let settings = parse_modeline(&[content], &[]).unwrap(); + assert_eq!( + settings, + ModelineSettings { + auto_indent: Some(true), + preferred_line_length: Some(NonZeroU32::new(75).unwrap()), + ..Default::default() + } + ); + + // Test 'se' shorthand + let content = "// vi: se ft=c ts=4:"; + let settings = parse_modeline(&[content], &[]).unwrap(); + assert_eq!( + settings, + ModelineSettings { + mode: Some("c".to_string()), + tab_size: Some(NonZeroU32::new(4).unwrap()), + ..Default::default() + } + ); + + // Test complex modeline with encoding + let content = "# vim: set ft=python ts=4 sw=4 et encoding=utf-8:"; + let settings = parse_modeline(&[content], &[]).unwrap(); + assert_eq!( + settings, + ModelineSettings { + mode: Some("python".to_string()), + tab_size: Some(NonZeroU32::new(4).unwrap()), + hard_tabs: Some(false), + indent_size: Some(NonZeroU32::new(4).unwrap()), + vim_extra_variables: vec![("encoding".to_string(), Some("utf-8".to_string()))], + ..Default::default() + } + ); + } + + #[test] + fn test_vim_modeline_edge_cases() { + // Test modeline at start of line (compatibility with version 3.0) + let content = "vi:ts=2:et"; + let settings = parse_modeline(&[content], &[]).unwrap(); + assert_eq!( + settings, + ModelineSettings { + tab_size: Some(NonZeroU32::new(2).unwrap()), + hard_tabs: Some(false), + ..Default::default() + } + ); + + // Test vim at start of line + let content = "vim:ft=rust:noet"; + let settings = parse_modeline(&[content], &[]).unwrap(); + assert_eq!( + settings, + ModelineSettings { + mode: Some("rust".to_string()), + hard_tabs: Some(true), + ..Default::default() + } + ); + + // Test mixed boolean flags + let content = "vim: set wrap noet ts=8:"; + let settings = parse_modeline(&[content], &[]).unwrap(); + assert_eq!( + settings, + ModelineSettings { + tab_size: Some(NonZeroU32::new(8).unwrap()), + hard_tabs: Some(true), + vim_extra_variables: vec![("wrap".to_string(), None)], + ..Default::default() + } + ); + } + + #[test] + fn test_vim_modeline_invalid_cases() { + // Test malformed options are ignored gracefully + let content = "vim: set ts=invalid ft=rust:"; + let settings = parse_modeline(&[content], &[]).unwrap(); + assert_eq!( + settings, + ModelineSettings { + mode: Some("rust".to_string()), + ..Default::default() + } + ); + + // Test empty modeline content - this should still work as there might be options + let content = "vim: set :"; + // This should return None because there are no actual options + let result = parse_modeline(&[content], &[]); + assert!(result.is_none(), "Expected None but got: {:?}", result); + + // Test modeline without proper format + let content = "not a modeline"; + assert!(parse_modeline(&[content], &[]).is_none()); + + // Test word that looks like modeline but isn't + let content = "example: this could be confused with ex:"; + assert!(parse_modeline(&[content], &[]).is_none()); + } + + #[test] + fn test_vim_language_mapping() { + // Test vim-specific language mappings + let content = "vim: set ft=sh:"; + let settings = parse_modeline(&[content], &[]).unwrap(); + assert_eq!(settings.mode, Some("sh".to_string())); + + let content = "vim: set ft=golang:"; + let settings = parse_modeline(&[content], &[]).unwrap(); + assert_eq!(settings.mode, Some("golang".to_string())); + + let content = "vim: set filetype=js:"; + let settings = parse_modeline(&[content], &[]).unwrap(); + assert_eq!(settings.mode, Some("js".to_string())); + } + + #[test] + fn test_vim_extra_variables() { + // Test that unknown vim options are stored as extra variables + let content = "vim: set foldmethod=marker conceallevel=2 custom=value:"; + let settings = parse_modeline(&[content], &[]).unwrap(); + + assert!( + settings + .vim_extra_variables + .contains(&("foldmethod".to_string(), Some("marker".to_string()))) + ); + assert!( + settings + .vim_extra_variables + .contains(&("conceallevel".to_string(), Some("2".to_string()))) + ); + assert!( + settings + .vim_extra_variables + .contains(&("custom".to_string(), Some("value".to_string()))) + ); + } + + #[test] + fn test_modeline_position() { + // Test modeline in first lines + let first_lines = ["#!/bin/bash", "# vim: set ft=bash ts=4:"]; + let settings = parse_modeline(&first_lines, &[]).unwrap(); + assert_eq!(settings.mode, Some("bash".to_string())); + + // Test modeline in last lines + let last_lines = ["", "/* vim: set ft=c: */"]; + let settings = parse_modeline(&[], &last_lines).unwrap(); + assert_eq!(settings.mode, Some("c".to_string())); + + // Test no modeline found + let content = ["regular content", "no modeline here"]; + assert!(parse_modeline(&content, &content).is_none()); + } + + #[test] + fn test_vim_modeline_version_checks() { + // Note: Current implementation doesn't support version checks yet + // These are tests for future implementation based on vim spec + + // Test version-specific modelines (currently ignored in our implementation) + let content = "/* vim700: set foldmethod=marker */"; + // Should be ignored for now since we don't support version checks + assert!(parse_modeline(&[content], &[]).is_none()); + + let content = "/* vim>702: set cole=2: */"; + // Should be ignored for now since we don't support version checks + assert!(parse_modeline(&[content], &[]).is_none()); + } + + #[test] + fn test_vim_modeline_colon_escaping() { + // Test colon escaping as mentioned in vim spec + + // According to vim spec: "if you want to include a ':' in a set command precede it with a '\'" + let content = r#"/* vim: set fdm=expr fde=getline(v\:lnum)=~'{'?'>1'\:'1': */"#; + + let result = parse_modeline(&[content], &[]).unwrap(); + + // The modeline should parse fdm=expr and fde=getline(v:lnum)=~'{'?'>1':'1' + // as extra variables since they're not recognized settings + assert_eq!(result.vim_extra_variables.len(), 2); + assert_eq!( + result.vim_extra_variables[0], + ("fdm".to_string(), Some("expr".to_string())) + ); + assert_eq!( + result.vim_extra_variables[1], + ( + "fde".to_string(), + Some("getline(v:lnum)=~'{'?'>1':'1'".to_string()) + ) + ); + } + + #[test] + fn test_vim_modeline_whitespace_requirements() { + // Test whitespace requirements from vim spec + + // Valid: whitespace before vi/vim + let content = " vim: set ft=rust:"; + assert!(parse_modeline(&[content], &[]).is_some()); + + // Valid: tab before vi/vim + let content = "\tvim: set ft=rust:"; + assert!(parse_modeline(&[content], &[]).is_some()); + + // Valid: vi/vim at start of line (compatibility) + let content = "vim: set ft=rust:"; + assert!(parse_modeline(&[content], &[]).is_some()); + } + + #[test] + fn test_vim_modeline_comprehensive_examples() { + // Real-world examples from vim documentation and common usage + + // Python example + let content = "# vim: set expandtab tabstop=4 shiftwidth=4 softtabstop=4:"; + let settings = parse_modeline(&[content], &[]).unwrap(); + assert_eq!(settings.hard_tabs, Some(false)); + assert_eq!(settings.tab_size, Some(NonZeroU32::new(4).unwrap())); + + // C example with multiple options + let content = "/* vim: set ts=8 sw=8 noet ai cindent: */"; + let settings = parse_modeline(&[content], &[]).unwrap(); + assert_eq!(settings.tab_size, Some(NonZeroU32::new(8).unwrap())); + assert_eq!(settings.hard_tabs, Some(true)); + assert!( + settings + .vim_extra_variables + .contains(&("cindent".to_string(), None)) + ); + + // Shell script example + let content = "# vi: set ft=sh ts=2 sw=2 et:"; + let settings = parse_modeline(&[content], &[]).unwrap(); + assert_eq!(settings.mode, Some("sh".to_string())); + assert_eq!(settings.tab_size, Some(NonZeroU32::new(2).unwrap())); + assert_eq!(settings.hard_tabs, Some(false)); + + // First form colon-separated + let content = "vim:ft=xml:ts=2:sw=2:et"; + let settings = parse_modeline(&[content], &[]).unwrap(); + assert_eq!(settings.mode, Some("xml".to_string())); + assert_eq!(settings.tab_size, Some(NonZeroU32::new(2).unwrap())); + assert_eq!(settings.hard_tabs, Some(false)); + } + + #[test] + fn test_combined_emacs_vim_detection() { + // Test that both emacs and vim modelines can be detected in the same file + + let first_lines = [ + "#!/usr/bin/env python3", + "# -*- require-final-newline: t; -*-", + "# vim: set ft=python ts=4 sw=4 et:", + ]; + + // Should find the emacs modeline first (with coding) + let settings = parse_modeline(&first_lines, &[]).unwrap(); + assert_eq!(settings.ensure_final_newline, Some(true)); + assert_eq!(settings.tab_size, None); + + // Test vim-only content + let vim_only = ["# vim: set ft=python ts=4 sw=4 et:"]; + let settings = parse_modeline(&vim_only, &[]).unwrap(); + assert_eq!(settings.mode, Some("python".to_string())); + assert_eq!(settings.tab_size, Some(NonZeroU32::new(4).unwrap())); + assert_eq!(settings.hard_tabs, Some(false)); + } +} diff --git a/crates/languages/src/bash/config.toml b/crates/languages/src/bash/config.toml index 8ff4802aee5124..06574629f18680 100644 --- a/crates/languages/src/bash/config.toml +++ b/crates/languages/src/bash/config.toml @@ -2,6 +2,7 @@ name = "Shell Script" code_fence_block_name = "bash" grammar = "bash" path_suffixes = ["sh", "bash", "bashrc", "bash_profile", "bash_aliases", "bash_logout", "bats", "profile", "zsh", "zshrc", "zshenv", "zsh_profile", "zsh_aliases", "zsh_histfile", "zlogin", "zprofile", ".env", "PKGBUILD", "APKBUILD"] +modeline_aliases = ["sh", "shell", "zsh", "fish"] line_comments = ["# "] first_line_pattern = '^#!.*\b(?:ash|bash|bats|dash|sh|zsh)\b' autoclose_before = "}])" diff --git a/crates/languages/src/cpp/config.toml b/crates/languages/src/cpp/config.toml index 8d85b4f2416cad..a6a641929da504 100644 --- a/crates/languages/src/cpp/config.toml +++ b/crates/languages/src/cpp/config.toml @@ -1,6 +1,7 @@ name = "C++" grammar = "cpp" path_suffixes = ["cc", "hh", "cpp", "h", "hpp", "cxx", "hxx", "c++", "h++", "ipp", "inl", "ino", "ixx", "cu", "cuh", "C", "H"] +modeline_aliases = ["c++", "cpp", "cxx"] line_comments = ["// ", "/// ", "//! "] decrease_indent_patterns = [ { pattern = "^\\s*\\{.*\\}?\\s*$", valid_after = ["if", "for", "while", "do", "switch", "else"] }, diff --git a/crates/languages/src/go/config.toml b/crates/languages/src/go/config.toml index 0a5122c038e1e3..655012944e62ff 100644 --- a/crates/languages/src/go/config.toml +++ b/crates/languages/src/go/config.toml @@ -1,6 +1,7 @@ name = "Go" grammar = "go" path_suffixes = ["go"] +modeline_aliases = ["golang"] line_comments = ["// "] autoclose_before = ";:.,=}])>" brackets = [ diff --git a/crates/languages/src/javascript/config.toml b/crates/languages/src/javascript/config.toml index 265f362ce4b655..2850fd6bc47fe7 100644 --- a/crates/languages/src/javascript/config.toml +++ b/crates/languages/src/javascript/config.toml @@ -1,6 +1,7 @@ name = "JavaScript" grammar = "tsx" path_suffixes = ["js", "jsx", "mjs", "cjs"] +modeline_aliases = ["js", "js2"] # [/ ] is so we match "env node" or "/node" but not "ts-node" first_line_pattern = '^#!.*\b(?:[/ ]node|deno run.*--ext[= ]js)\b' line_comments = ["// "] diff --git a/crates/languages/src/lib.rs b/crates/languages/src/lib.rs index 9df14fb162e2ed..1cf21bcf61cece 100644 --- a/crates/languages/src/lib.rs +++ b/crates/languages/src/lib.rs @@ -50,6 +50,7 @@ pub static LANGUAGE_GIT_COMMIT: std::sync::LazyLock> = matcher: LanguageMatcher { path_suffixes: vec!["COMMIT_EDITMSG".to_owned()], first_line_pattern: None, + ..LanguageMatcher::default() }, line_comments: vec![Arc::from("#")], ..LanguageConfig::default() diff --git a/crates/languages/src/markdown/config.toml b/crates/languages/src/markdown/config.toml index 2bbda0ef43e9a4..bb6e86dc2e2317 100644 --- a/crates/languages/src/markdown/config.toml +++ b/crates/languages/src/markdown/config.toml @@ -1,6 +1,7 @@ name = "Markdown" grammar = "markdown" path_suffixes = ["md", "mdx", "mdwn", "markdown", "MD"] +modeline_aliases = ["md"] completion_query_characters = ["-"] block_comment = { start = "", tab_size = 0 } autoclose_before = ";:.,=}])>" diff --git a/crates/languages/src/python.rs b/crates/languages/src/python.rs index fc2f91121e96e0..b1910d25644191 100644 --- a/crates/languages/src/python.rs +++ b/crates/languages/src/python.rs @@ -903,7 +903,7 @@ impl ContextProvider for PythonContextProvider { fn selected_test_runner(location: Option<&Arc>, cx: &App) -> TestRunner { const TEST_RUNNER_VARIABLE: &str = "TEST_RUNNER"; - language_settings(Some(LanguageName::new("Python")), location, cx) + language_settings(Some(LanguageName::new("Python")), None, location, cx) .tasks .variables .get(TEST_RUNNER_VARIABLE) diff --git a/crates/languages/src/python/config.toml b/crates/languages/src/python/config.toml index c58a54fc1cae78..6a5781e4080c26 100644 --- a/crates/languages/src/python/config.toml +++ b/crates/languages/src/python/config.toml @@ -1,6 +1,7 @@ name = "Python" grammar = "python" path_suffixes = ["py", "pyi", "mpy"] +modeline_aliases = ["py"] first_line_pattern = '^#!.*\bpython[0-9.]*\b' line_comments = ["# "] autoclose_before = ";:.,=}])>" diff --git a/crates/languages/src/rust.rs b/crates/languages/src/rust.rs index 31d7448285969f..6d9d0ca234d850 100644 --- a/crates/languages/src/rust.rs +++ b/crates/languages/src/rust.rs @@ -768,7 +768,7 @@ impl ContextProvider for RustContextProvider { const DEFAULT_RUN_NAME_STR: &str = "RUST_DEFAULT_PACKAGE_RUN"; const CUSTOM_TARGET_DIR: &str = "RUST_TARGET_DIR"; - let language_sets = language_settings(Some("Rust".into()), file.as_ref(), cx); + let language_sets = language_settings(Some("Rust".into()), None, file.as_ref(), cx); let package_to_run = language_sets .tasks .variables diff --git a/crates/languages/src/rust/config.toml b/crates/languages/src/rust/config.toml index 826a219e9868a3..203a44853f8bd2 100644 --- a/crates/languages/src/rust/config.toml +++ b/crates/languages/src/rust/config.toml @@ -1,6 +1,7 @@ name = "Rust" grammar = "rust" path_suffixes = ["rs"] +modeline_aliases = ["rs", "rustic"] line_comments = ["// ", "/// ", "//! "] autoclose_before = ";:.,=}])>" brackets = [ diff --git a/crates/languages/src/tsx/config.toml b/crates/languages/src/tsx/config.toml index d0a4eb6532db62..42438fdf890a98 100644 --- a/crates/languages/src/tsx/config.toml +++ b/crates/languages/src/tsx/config.toml @@ -1,6 +1,7 @@ name = "TSX" grammar = "tsx" path_suffixes = ["tsx"] +modeline_aliases = ["typescript-txs"] line_comments = ["// "] block_comment = { start = "/*", prefix = "* ", end = "*/", tab_size = 1 } documentation_comment = { start = "/**", prefix = "* ", end = "*/", tab_size = 1 } diff --git a/crates/languages/src/typescript/config.toml b/crates/languages/src/typescript/config.toml index 67656e6a538da6..c0e8a8899a99b0 100644 --- a/crates/languages/src/typescript/config.toml +++ b/crates/languages/src/typescript/config.toml @@ -1,6 +1,7 @@ name = "TypeScript" grammar = "typescript" path_suffixes = ["ts", "cts", "mts"] +modeline_aliases = ["ts"] first_line_pattern = '^#!.*\b(?:deno run|ts-node|bun|tsx|[/ ]node)\b' line_comments = ["// "] block_comment = { start = "/*", prefix = "* ", end = "*/", tab_size = 1 } diff --git a/crates/languages/src/yaml.rs b/crates/languages/src/yaml.rs index 57f254a68f126a..63da5de41943e4 100644 --- a/crates/languages/src/yaml.rs +++ b/crates/languages/src/yaml.rs @@ -144,7 +144,7 @@ impl LspAdapter for YamlLspAdapter { let tab_size = cx.update(|cx| { AllLanguageSettings::get(Some(location), cx) - .language(Some(location), Some(&"YAML".into()), cx) + .language(Some(location), Some(&"YAML".into()), None, cx) .tab_size })?; diff --git a/crates/languages/src/yaml/config.toml b/crates/languages/src/yaml/config.toml index 51e8e1224a4090..3e95f0d5b18507 100644 --- a/crates/languages/src/yaml/config.toml +++ b/crates/languages/src/yaml/config.toml @@ -1,6 +1,7 @@ name = "YAML" grammar = "yaml" path_suffixes = ["yml", "yaml", "pixi.lock", "clang-format", "clangd"] +modeline_aliases = ["yml"] line_comments = ["# "] autoclose_before = ",]}" brackets = [ diff --git a/crates/multi_buffer/src/multi_buffer.rs b/crates/multi_buffer/src/multi_buffer.rs index af36aaadf02b53..4cfebc15a68f31 100644 --- a/crates/multi_buffer/src/multi_buffer.rs +++ b/crates/multi_buffer/src/multi_buffer.rs @@ -20,9 +20,9 @@ use itertools::Itertools; use language::{ AutoindentMode, BracketMatch, Buffer, BufferChunks, BufferRow, BufferSnapshot, Capability, CharClassifier, CharKind, CharScopeContext, Chunk, CursorShape, DiagnosticEntryRef, DiskState, - File, IndentGuideSettings, IndentSize, Language, LanguageScope, OffsetRangeExt, OffsetUtf16, - Outline, OutlineItem, Point, PointUtf16, Selection, TextDimension, TextObject, ToOffset as _, - ToPoint as _, TransactionId, TreeSitterOptions, Unclipped, + File, IndentGuideSettings, IndentSize, Language, LanguageScope, ModelineSettings, + OffsetRangeExt, OffsetUtf16, Outline, OutlineItem, Point, PointUtf16, Selection, TextDimension, + TextObject, ToOffset as _, ToPoint as _, TransactionId, TreeSitterOptions, Unclipped, language_settings::{LanguageSettings, language_settings}, }; @@ -2418,7 +2418,12 @@ impl MultiBuffer { .and_then(|buffer_id| self.buffer(buffer_id)) .map(|buffer| { let buffer = buffer.read(cx); - language_settings(buffer.language().map(|l| l.name()), buffer.file(), cx) + language_settings( + buffer.language().map(|l| l.name()), + buffer.modeline().map(Arc::as_ref), + buffer.file(), + cx, + ) }) .unwrap_or_else(move || self.language_settings_at(MultiBufferOffset::default(), cx)) } @@ -2430,12 +2435,19 @@ impl MultiBuffer { ) -> Cow<'a, LanguageSettings> { let mut language = None; let mut file = None; + let mut modeline = None; if let Some((buffer, offset)) = self.point_to_buffer_offset(point, cx) { let buffer = buffer.read(cx); language = buffer.language_at(offset); file = buffer.file(); + modeline = buffer.modeline(); } - language_settings(language.map(|l| l.name()), file, cx) + language_settings( + language.map(|l| l.name()), + modeline.map(Arc::as_ref), + file, + cx, + ) } pub fn for_each_buffer(&self, mut f: impl FnMut(&Entity)) { @@ -6004,8 +6016,12 @@ impl MultiBufferSnapshot { let end_row = MultiBufferRow(range.end.row); let mut row_indents = self.line_indents(start_row, |buffer| { - let settings = - language_settings(buffer.language().map(|l| l.name()), buffer.file(), cx); + let settings = language_settings( + buffer.language().map(|l| l.name()), + buffer.modeline().map(Arc::as_ref), + buffer.file(), + cx, + ); settings.indent_guides.enabled || ignore_disabled_for_language }); @@ -6029,7 +6045,12 @@ impl MultiBufferSnapshot { .get_or_insert_with(|| { ( buffer.remote_id(), - language_settings(buffer.language().map(|l| l.name()), buffer.file(), cx), + language_settings( + buffer.language().map(|l| l.name()), + buffer.modeline().map(Arc::as_ref), + buffer.file(), + cx, + ), ) }) .1; @@ -6121,6 +6142,11 @@ impl MultiBufferSnapshot { .and_then(|(buffer, offset)| buffer.language_at(offset)) } + pub fn modeline_at(&self, offset: T) -> Option<&Arc> { + self.point_to_buffer_offset(offset) + .and_then(|(buffer, _)| buffer.modeline()) + } + fn language_settings<'a>(&'a self, cx: &'a App) -> Cow<'a, LanguageSettings> { self.excerpts .first() @@ -6128,6 +6154,7 @@ impl MultiBufferSnapshot { .map(|buffer| { language_settings( buffer.language().map(|language| language.name()), + buffer.modeline().map(Arc::as_ref), buffer.file(), cx, ) @@ -6142,11 +6169,18 @@ impl MultiBufferSnapshot { ) -> Cow<'a, LanguageSettings> { let mut language = None; let mut file = None; + let mut modeline = None; if let Some((buffer, offset)) = self.point_to_buffer_offset(point) { language = buffer.language_at(offset); file = buffer.file(); + modeline = buffer.modeline(); } - language_settings(language.map(|l| l.name()), file, cx) + language_settings( + language.map(|l| l.name()), + modeline.map(Arc::as_ref), + file, + cx, + ) } pub fn language_scope_at(&self, point: T) -> Option { diff --git a/crates/prettier/src/prettier.rs b/crates/prettier/src/prettier.rs index bc4ce609a1fd39..eb2db5c5be4303 100644 --- a/crates/prettier/src/prettier.rs +++ b/crates/prettier/src/prettier.rs @@ -351,7 +351,7 @@ impl Prettier { let params = buffer .update(cx, |buffer, cx| { let buffer_language = buffer.language().map(|language| language.as_ref()); - let language_settings = language_settings(buffer_language.map(|l| l.name()), buffer.file(), cx); + let language_settings = language_settings(buffer_language.map(|l| l.name()), buffer.modeline().map(Arc::as_ref), buffer.file(), cx); let prettier_settings = &language_settings.prettier; anyhow::ensure!( prettier_settings.allowed, @@ -502,6 +502,7 @@ impl Prettier { buffer.language().map(|language| language.as_ref()); let language_settings = language_settings( buffer_language.map(|l| l.name()), + buffer.modeline().map(Arc::as_ref), buffer.file(), cx, ); diff --git a/crates/project/src/lsp_command.rs b/crates/project/src/lsp_command.rs index 05ee70bf66fe9e..784c43eebb197e 100644 --- a/crates/project/src/lsp_command.rs +++ b/crates/project/src/lsp_command.rs @@ -2879,7 +2879,13 @@ impl LspCommand for OnTypeFormatting { let options = buffer.update(&mut cx, |buffer, cx| { lsp_formatting_options( - language_settings(buffer.language().map(|l| l.name()), buffer.file(), cx).as_ref(), + language_settings( + buffer.language().map(|l| l.name()), + buffer.modeline().map(Arc::as_ref), + buffer.file(), + cx, + ) + .as_ref(), ) })?; diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index 59b7a6932d4733..b63cb3f30c5e90 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -63,10 +63,10 @@ use language::{ Bias, BinaryStatus, Buffer, BufferRow, BufferSnapshot, CachedLspAdapter, CodeLabel, Diagnostic, DiagnosticEntry, DiagnosticSet, DiagnosticSourceKind, Diff, File as _, Language, LanguageName, LanguageRegistry, LocalFile, LspAdapter, LspAdapterDelegate, LspInstaller, ManifestDelegate, - ManifestName, Patch, PointUtf16, TextBufferSnapshot, ToOffset, ToPointUtf16, Toolchain, - Transaction, Unclipped, + ManifestName, ModelineSettings, Patch, PointUtf16, TextBufferSnapshot, ToOffset, ToPointUtf16, + Toolchain, Transaction, Unclipped, language_settings::{FormatOnSave, Formatter, LanguageSettings, language_settings}, - point_to_lsp, + modeline, point_to_lsp, proto::{ deserialize_anchor, deserialize_lsp_edit, deserialize_version, serialize_anchor, serialize_lsp_edit, serialize_version, @@ -1412,9 +1412,13 @@ impl LocalLspStore { .language_servers_for_buffer(buffer, cx) .map(|(adapter, lsp)| (adapter.clone(), lsp.clone())) .collect::>(); - let settings = - language_settings(buffer.language().map(|l| l.name()), buffer.file(), cx) - .into_owned(); + let settings = language_settings( + buffer.language().map(|l| l.name()), + buffer.modeline().map(Arc::as_ref), + buffer.file(), + cx, + ) + .into_owned(); (adapters_and_servers, settings) }) })?; @@ -4433,7 +4437,56 @@ impl LspStore { let file = buffer.file()?; let content = buffer.as_rope(); - let available_language = self.languages.language_for_file(file, Some(content), cx); + + let modeline_settings = { + let settings_store = cx.global::(); + let modeline_lines = settings_store + .raw_user_settings() + .and_then(|s| s.content.modeline_lines) + .or(settings_store.raw_default_settings().modeline_lines) + .unwrap_or(5); + + const MAX_MODELINE_BYTES: usize = 1024; + + let first_bytes = content.len().min(MAX_MODELINE_BYTES); + let mut first_lines = Vec::new(); + let mut lines = content.chunks_in_range(0..first_bytes).lines(); + for _ in 0..modeline_lines { + if let Some(line) = lines.next() { + first_lines.push(line.to_string()); + } else { + break; + } + } + let first_lines_ref: Vec<_> = first_lines.iter().map(|line| line.as_str()).collect(); + + let last_start = content.len().saturating_sub(MAX_MODELINE_BYTES); + let mut last_lines = Vec::new(); + let mut lines = content + .reversed_chunks_in_range(last_start..content.len()) + .lines(); + for _ in 0..modeline_lines { + if let Some(line) = lines.next() { + last_lines.push(line.to_string()); + } else { + break; + } + } + let last_lines_ref: Vec<_> = + last_lines.iter().rev().map(|line| line.as_str()).collect(); + modeline::parse_modeline(&first_lines_ref, &last_lines_ref) + }; + + let available_language = if let Some(ModelineSettings { + mode: Some(ref mode_name), + .. + }) = modeline_settings + { + self.languages + .available_language_for_modeline_name(mode_name) + } else { + self.languages.language_for_file(file, Some(content), cx) + }; if let Some(available_language) = &available_language { if let Some(Ok(Ok(new_language))) = self .languages @@ -4449,6 +4502,10 @@ impl LspStore { }); } + buffer_handle.update(cx, |buffer, _cx| { + buffer.set_modeline(modeline_settings); + }); + available_language } @@ -4461,6 +4518,7 @@ impl LspStore { let buffer = buffer_entity.read(cx); let buffer_file = buffer.file().cloned(); let buffer_id = buffer.remote_id(); + let buffer_modeline = buffer.modeline().cloned(); if let Some(local_store) = self.as_local_mut() && local_store.registered_buffers.contains_key(&buffer_id) && let Some(abs_path) = @@ -4478,8 +4536,13 @@ impl LspStore { } }); - let settings = - language_settings(Some(new_language.name()), buffer_file.as_ref(), cx).into_owned(); + let settings = language_settings( + Some(new_language.name()), + buffer_modeline.as_deref(), + buffer_file.as_ref(), + cx, + ) + .into_owned(); let buffer_file = File::from_dyn(buffer_file.as_ref()); let worktree_id = if let Some(file) = buffer_file { @@ -4780,7 +4843,12 @@ impl LspStore { let buffer = buffer.read(cx); let buffer_file = File::from_dyn(buffer.file()); let buffer_language = buffer.language(); - let settings = language_settings(buffer_language.map(|l| l.name()), buffer.file(), cx); + let settings = language_settings( + buffer_language.map(|l| l.name()), + buffer.modeline().map(Arc::as_ref), + buffer.file(), + cx, + ); if buffer_language.is_some() { language_formatters_to_check.push(( buffer_file.map(|f| f.worktree_id(cx)), @@ -5356,9 +5424,15 @@ impl LspStore { .filter(|_| { maybe!({ let language = buffer.read(cx).language_at(position)?; + let modeline = buffer.read(cx).modeline(); Some( - language_settings(Some(language.name()), buffer.read(cx).file(), cx) - .linked_edits, + language_settings( + Some(language.name()), + modeline.map(Arc::as_ref), + buffer.read(cx).file(), + cx, + ) + .linked_edits, ) }) == Some(true) }) @@ -5463,6 +5537,7 @@ impl LspStore { lsp_command::lsp_formatting_options( language_settings( buffer.language_at(position).map(|l| l.name()), + buffer.modeline().map(Arc::as_ref), buffer.file(), cx, ) @@ -6150,11 +6225,13 @@ impl LspStore { }) } else if let Some(local) = self.as_local() { let snapshot = buffer.read(cx).snapshot(); + let modeline = buffer.read(cx).modeline(); let offset = position.to_offset(&snapshot); let scope = snapshot.language_scope_at(offset); let language = snapshot.language().cloned(); let completion_settings = language_settings( language.as_ref().map(|language| language.name()), + modeline.map(Arc::as_ref), buffer.read(cx).file(), cx, ) diff --git a/crates/project/src/manifest_tree/server_tree.rs b/crates/project/src/manifest_tree/server_tree.rs index b6828fdb281d51..b932dc203f979d 100644 --- a/crates/project/src/manifest_tree/server_tree.rs +++ b/crates/project/src/manifest_tree/server_tree.rs @@ -245,6 +245,7 @@ impl LanguageServerTree { let settings = AllLanguageSettings::get(Some(settings_location), cx).language( Some(settings_location), Some(language_name), + None, cx, ); if !settings.enable_language_server { diff --git a/crates/project/src/project_tests.rs b/crates/project/src/project_tests.rs index 8adba2dea16391..55108a25cdb253 100644 --- a/crates/project/src/project_tests.rs +++ b/crates/project/src/project_tests.rs @@ -215,7 +215,7 @@ async fn test_editorconfig_support(cx: &mut gpui::TestAppContext) { .block(file_language) .expect("Failed to get file language"); let file = file as _; - language_settings(Some(file_language.name()), Some(&file), cx).into_owned() + language_settings(Some(file_language.name()), None, Some(&file), cx).into_owned() }; let settings_a = settings_for("a.rs"); @@ -373,12 +373,12 @@ async fn test_managing_project_specific_settings(cx: &mut gpui::TestAppContext) tree.entry_for_path(rel_path("a/a.rs")).unwrap().clone(), worktree.clone(), ) as _; - let settings_a = language_settings(None, Some(&file_a), cx); + let settings_a = language_settings(None, None, Some(&file_a), cx); let file_b = File::for_entry( tree.entry_for_path(rel_path("b/b.rs")).unwrap().clone(), worktree.clone(), ) as _; - let settings_b = language_settings(None, Some(&file_b), cx); + let settings_b = language_settings(None, None, Some(&file_b), cx); assert_eq!(settings_a.tab_size.get(), 8); assert_eq!(settings_b.tab_size.get(), 2); diff --git a/crates/project/src/task_inventory.rs b/crates/project/src/task_inventory.rs index 4f4939491f19eb..e600197f34a74d 100644 --- a/crates/project/src/task_inventory.rs +++ b/crates/project/src/task_inventory.rs @@ -302,12 +302,12 @@ impl Inventory { let last_scheduled_scenarios = self.last_scheduled_scenarios.iter().cloned().collect(); let adapter = task_contexts.location().and_then(|location| { - let (file, language) = { + let (file, language, modeline) = { let buffer = location.buffer.read(cx); - (buffer.file(), buffer.language()) + (buffer.file(), buffer.language(), buffer.modeline()) }; let language_name = language.as_ref().map(|l| l.name()); - let adapter = language_settings(language_name, file, cx) + let adapter = language_settings(language_name, modeline.map(Arc::as_ref), file, cx) .debuggers .first() .map(SharedString::from) @@ -394,7 +394,7 @@ impl Inventory { }); let language_tasks = language .filter(|language| { - language_settings(Some(language.name()), file.as_ref(), cx) + language_settings(Some(language.name()), None, file.as_ref(), cx) .tasks .enabled }) @@ -478,7 +478,7 @@ impl Inventory { let global_tasks = self.global_templates_from_settings().collect::>(); let associated_tasks = language .filter(|language| { - language_settings(Some(language.name()), file.as_ref(), cx) + language_settings(Some(language.name()), None, file.as_ref(), cx) .tasks .enabled }) diff --git a/crates/remote_server/src/remote_editing_tests.rs b/crates/remote_server/src/remote_editing_tests.rs index a91d1d055d582e..2affbfbf92939c 100644 --- a/crates/remote_server/src/remote_editing_tests.rs +++ b/crates/remote_server/src/remote_editing_tests.rs @@ -278,7 +278,7 @@ async fn test_remote_settings(cx: &mut TestAppContext, server_cx: &mut TestAppCo server_cx.read(|cx| { assert_eq!( AllLanguageSettings::get_global(cx) - .language(None, Some(&"Rust".into()), cx) + .language(None, Some(&"Rust".into()), None, cx) .language_servers, ["from-local-settings"], "User language settings should be synchronized with the server settings" @@ -299,7 +299,7 @@ async fn test_remote_settings(cx: &mut TestAppContext, server_cx: &mut TestAppCo server_cx.read(|cx| { assert_eq!( AllLanguageSettings::get_global(cx) - .language(None, Some(&"Rust".into()), cx) + .language(None, Some(&"Rust".into()), None, cx) .language_servers, ["from-server-settings".to_string()], "Server language settings should take precedence over the user settings" @@ -359,7 +359,7 @@ async fn test_remote_settings(cx: &mut TestAppContext, server_cx: &mut TestAppCo }), cx ) - .language(None, Some(&"Rust".into()), cx) + .language(None, Some(&"Rust".into()), None, cx) .language_servers, ["override-rust-analyzer".to_string()] ) @@ -367,8 +367,10 @@ async fn test_remote_settings(cx: &mut TestAppContext, server_cx: &mut TestAppCo cx.read(|cx| { let file = buffer.read(cx).file(); + let modeline = buffer.read(cx).modeline(); assert_eq!( - language_settings(Some("Rust".into()), file, cx).language_servers, + language_settings(Some("Rust".into()), modeline.map(Arc::as_ref), file, cx) + .language_servers, ["override-rust-analyzer".to_string()] ) }); @@ -514,8 +516,10 @@ async fn test_remote_lsp(cx: &mut TestAppContext, server_cx: &mut TestAppContext cx.read(|cx| { let file = buffer.read(cx).file(); + let modeline = buffer.read(cx).modeline(); assert_eq!( - language_settings(Some("Rust".into()), file, cx).language_servers, + language_settings(Some("Rust".into()), modeline.map(Arc::as_ref), file, cx) + .language_servers, ["rust-analyzer".to_string(), "fake-analyzer".to_string()] ) }); diff --git a/crates/settings/src/settings_content.rs b/crates/settings/src/settings_content.rs index 230e1ffd48b9cc..45ec65818c545e 100644 --- a/crates/settings/src/settings_content.rs +++ b/crates/settings/src/settings_content.rs @@ -160,6 +160,13 @@ pub struct SettingsContent { /// Settings related to Vim mode in Zed. pub vim: Option, + + /// Number of lines to search for modelines at the beginning and end of files. + /// Modelines contain editor directives (e.g., vim/emacs settings) that configure + /// the editor behavior for specific files. + /// + /// Default: 5 + pub modeline_lines: Option, } impl SettingsContent { diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index 587850303f1364..f7c5fc482ad44d 100644 --- a/crates/settings/src/vscode_import.rs +++ b/crates/settings/src/vscode_import.rs @@ -215,6 +215,7 @@ impl VsCodeSettings { vim: None, vim_mode: None, workspace: self.workspace_settings_content(), + modeline_lines: None, } } diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index 0c383970c990c3..71eb5779a90766 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -7211,6 +7211,22 @@ fn language_settings_data() -> Vec { metadata: None, files: USER, }), + SettingsPageItem::SettingItem(SettingItem { + title: "Vim/Emacs Modeline Support", + description: "Number of lines to search for modelines (set to 0 to disable).", + field: Box::new(SettingField { + json_path: Some("modeline_lines"), + pick: |settings_content| { + settings_content.modeline_lines.as_ref() + }, + write: |settings_content, value| { + settings_content.modeline_lines = value; + + }, + }), + metadata: None, + files: USER | PROJECT, + }), ]); } items diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 0d0cd35f43610d..29d1bb68801738 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -13,6 +13,7 @@ # Configuration - [Configuring Zed](./configuring-zed.md) + - [Modelines](./modelines.md) - [Configuring Languages](./configuring-languages.md) - [Toolchains](./toolchains.md) - [Key bindings](./key-bindings.md) diff --git a/docs/src/configuring-zed.md b/docs/src/configuring-zed.md index 477885a4537580..4a1285310d1000 100644 --- a/docs/src/configuring-zed.md +++ b/docs/src/configuring-zed.md @@ -43,6 +43,10 @@ For example you can set `tab_size`, `formatter` etc. but not `theme`, `vim_mode` The syntax for configuration files is a super-set of JSON that allows `//` comments. +## Per-file Settings + +Zed has some compatibility support for Emacs and Vim [modelines](./modelines.md), so you can set some settings per-file. + ## Per-release Channel Overrides Zed reads the same `settings.json` across all release channels (Stable, Preview or Nightly). @@ -2710,6 +2714,16 @@ Positive `integer` values or `null` for unlimited tabs `boolean` values +## Modeline Lines + +- Description: Number of lines to search for modelines at the beginning and end of files. Modelines contain editor directives (e.g., vim/emacs settings) that configure the editor behavior for specific files. See [Modelines](./modelines.md) for more details on supported modeline variables. +- Setting: `modeline_lines` +- Default: `5` + +**Options** + +Positive `integer` values. Set to `0` to disable modeline parsing. + ## Multi Cursor Modifier - Description: Determines the modifier to be used to add multiple cursors with the mouse. The open hover link mouse gestures will adapt such that it do not conflict with the multicursor modifier. diff --git a/docs/src/extensions/languages.md b/docs/src/extensions/languages.md index f3ffcd71ba8122..ba56ee65452b02 100644 --- a/docs/src/extensions/languages.md +++ b/docs/src/extensions/languages.md @@ -27,6 +27,7 @@ line_comments = ["# "] - `tab_size` defines the indentation/tab size used for this language (default is `4`). - `hard_tabs` whether to indent with tabs (`true`) or spaces (`false`, the default). - `first_line_pattern` is a regular expression, that in addition to `path_suffixes` (above) or `file_types` in settings can be used to match files which should use this language. For example Zed uses this to identify Shell Scripts by matching the [shebangs lines](https://github.com/zed-industries/zed/blob/main/crates/languages/src/bash/config.toml) in the first line of a script. +- `modeline_aliases` is an array of additional Emacs modes or Vim filetypes to map modeline settings to Zed language. - `debuggers` is an array of strings that are used to identify debuggers in the language. When launching a debugger's `New Process Modal`, Zed will order available debuggers by the order of entries in this array.