diff --git a/Cargo.lock b/Cargo.lock index 2b51a987642276..f4668261a4ab29 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3649,6 +3649,12 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d6ef0072f8a535281e4876be788938b528e9a1d43900b82c2569af7da799125" +[[package]] +name = "ec4rs" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acf65d056c7da9c971c2847ce250fd1f0f9659d5718845c3ec0ad95f5668352c" + [[package]] name = "ecdsa" version = "0.14.8" @@ -6210,6 +6216,7 @@ dependencies = [ "clock", "collections", "ctor", + "ec4rs", "env_logger", "futures 0.3.30", "fuzzy", @@ -10301,6 +10308,7 @@ version = "0.1.0" dependencies = [ "anyhow", "collections", + "ec4rs", "fs", "futures 0.3.30", "gpui", diff --git a/Cargo.toml b/Cargo.toml index e6d71a291aee9f..9ffe48b4a1aaa2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -347,6 +347,7 @@ ctor = "0.2.6" dashmap = "6.0" derive_more = "0.99.17" dirs = "4.0" +ec4rs = "1.1" emojis = "0.6.1" env_logger = "0.11" exec = "0.3.1" diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 05d7726069959f..865453555f1bff 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -2237,7 +2237,7 @@ fn join_project_internal( worktree_id: worktree.id, path: settings_file.path, content: Some(settings_file.content), - kind: Some(proto::update_user_settings::Kind::Settings.into()), + kind: Some(settings_file.kind.to_proto() as i32), }, )?; } diff --git a/crates/collab/src/tests/editor_tests.rs b/crates/collab/src/tests/editor_tests.rs index 16deef70d58aa8..2a3c643f6deeb5 100644 --- a/crates/collab/src/tests/editor_tests.rs +++ b/crates/collab/src/tests/editor_tests.rs @@ -12,6 +12,7 @@ use editor::{ test::editor_test_context::{AssertionContextManager, EditorTestContext}, Editor, }; +use fs::Fs; use futures::StreamExt; use gpui::{TestAppContext, UpdateGlobal, VisualContext, VisualTestContext}; use indoc::indoc; @@ -30,7 +31,7 @@ use serde_json::json; use settings::SettingsStore; use std::{ ops::Range, - path::Path, + path::{Path, PathBuf}, sync::{ atomic::{self, AtomicBool, AtomicUsize}, Arc, @@ -60,7 +61,7 @@ async fn test_host_disconnect( .fs() .insert_tree( "/a", - serde_json::json!({ + json!({ "a.txt": "a-contents", "b.txt": "b-contents", }), @@ -2152,6 +2153,295 @@ async fn test_git_blame_is_forwarded(cx_a: &mut TestAppContext, cx_b: &mut TestA }); } +#[gpui::test(iterations = 30)] +async fn test_collaborating_with_editorconfig( + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + let mut server = TestServer::start(cx_a.executor()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + server + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) + .await; + let active_call_a = cx_a.read(ActiveCall::global); + + cx_b.update(editor::init); + + // Set up a fake language server. + client_a.language_registry().add(rust_lang()); + client_a + .fs() + .insert_tree( + "/a", + json!({ + "src": { + "main.rs": "mod other;\nfn main() { let foo = other::foo(); }", + "other_mod": { + "other.rs": "pub fn foo() -> usize {\n 4\n}", + ".editorconfig": "", + }, + }, + ".editorconfig": "[*]\ntab_width = 2\n", + }), + ) + .await; + let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await; + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + let main_buffer_a = project_a + .update(cx_a, |p, cx| { + p.open_buffer((worktree_id, "src/main.rs"), cx) + }) + .await + .unwrap(); + let other_buffer_a = project_a + .update(cx_a, |p, cx| { + p.open_buffer((worktree_id, "src/other_mod/other.rs"), cx) + }) + .await + .unwrap(); + let cx_a = cx_a.add_empty_window(); + let main_editor_a = + cx_a.new_view(|cx| Editor::for_buffer(main_buffer_a, Some(project_a.clone()), cx)); + let other_editor_a = + cx_a.new_view(|cx| Editor::for_buffer(other_buffer_a, Some(project_a), cx)); + let mut main_editor_cx_a = EditorTestContext { + cx: cx_a.clone(), + window: cx_a.handle(), + editor: main_editor_a, + assertion_cx: AssertionContextManager::new(), + }; + let mut other_editor_cx_a = EditorTestContext { + cx: cx_a.clone(), + window: cx_a.handle(), + editor: other_editor_a, + assertion_cx: AssertionContextManager::new(), + }; + + // Join the project as client B. + let project_b = client_b.join_remote_project(project_id, cx_b).await; + let main_buffer_b = project_b + .update(cx_b, |p, cx| { + p.open_buffer((worktree_id, "src/main.rs"), cx) + }) + .await + .unwrap(); + let other_buffer_b = project_b + .update(cx_b, |p, cx| { + p.open_buffer((worktree_id, "src/other_mod/other.rs"), cx) + }) + .await + .unwrap(); + let cx_b = cx_b.add_empty_window(); + let main_editor_b = + cx_b.new_view(|cx| Editor::for_buffer(main_buffer_b, Some(project_b.clone()), cx)); + let other_editor_b = + cx_b.new_view(|cx| Editor::for_buffer(other_buffer_b, Some(project_b.clone()), cx)); + let mut main_editor_cx_b = EditorTestContext { + cx: cx_b.clone(), + window: cx_b.handle(), + editor: main_editor_b, + assertion_cx: AssertionContextManager::new(), + }; + let mut other_editor_cx_b = EditorTestContext { + cx: cx_b.clone(), + window: cx_b.handle(), + editor: other_editor_b, + assertion_cx: AssertionContextManager::new(), + }; + + let initial_main = indoc! {" +ˇmod other; +fn main() { let foo = other::foo(); }"}; + let initial_other = indoc! {" +ˇpub fn foo() -> usize { + 4 +}"}; + + let first_tabbed_main = indoc! {" + ˇmod other; +fn main() { let foo = other::foo(); }"}; + tab_undo_assert( + &mut main_editor_cx_a, + &mut main_editor_cx_b, + initial_main, + first_tabbed_main, + true, + ); + tab_undo_assert( + &mut main_editor_cx_a, + &mut main_editor_cx_b, + initial_main, + first_tabbed_main, + false, + ); + + let first_tabbed_other = indoc! {" + ˇpub fn foo() -> usize { + 4 +}"}; + tab_undo_assert( + &mut other_editor_cx_a, + &mut other_editor_cx_b, + initial_other, + first_tabbed_other, + true, + ); + tab_undo_assert( + &mut other_editor_cx_a, + &mut other_editor_cx_b, + initial_other, + first_tabbed_other, + false, + ); + + client_a + .fs() + .atomic_write( + PathBuf::from("/a/src/.editorconfig"), + "[*]\ntab_width = 3\n".to_owned(), + ) + .await + .unwrap(); + cx_a.run_until_parked(); + cx_b.run_until_parked(); + + let second_tabbed_main = indoc! {" + ˇmod other; +fn main() { let foo = other::foo(); }"}; + tab_undo_assert( + &mut main_editor_cx_a, + &mut main_editor_cx_b, + initial_main, + second_tabbed_main, + true, + ); + tab_undo_assert( + &mut main_editor_cx_a, + &mut main_editor_cx_b, + initial_main, + second_tabbed_main, + false, + ); + + let second_tabbed_other = indoc! {" + ˇpub fn foo() -> usize { + 4 +}"}; + tab_undo_assert( + &mut other_editor_cx_a, + &mut other_editor_cx_b, + initial_other, + second_tabbed_other, + true, + ); + tab_undo_assert( + &mut other_editor_cx_a, + &mut other_editor_cx_b, + initial_other, + second_tabbed_other, + false, + ); + + let editorconfig_buffer_b = project_b + .update(cx_b, |p, cx| { + p.open_buffer((worktree_id, "src/other_mod/.editorconfig"), cx) + }) + .await + .unwrap(); + editorconfig_buffer_b.update(cx_b, |buffer, cx| { + buffer.set_text("[*.rs]\ntab_width = 6\n", cx); + }); + project_b + .update(cx_b, |project, cx| { + project.save_buffer(editorconfig_buffer_b.clone(), cx) + }) + .await + .unwrap(); + cx_a.run_until_parked(); + cx_b.run_until_parked(); + + tab_undo_assert( + &mut main_editor_cx_a, + &mut main_editor_cx_b, + initial_main, + second_tabbed_main, + true, + ); + tab_undo_assert( + &mut main_editor_cx_a, + &mut main_editor_cx_b, + initial_main, + second_tabbed_main, + false, + ); + + let third_tabbed_other = indoc! {" + ˇpub fn foo() -> usize { + 4 +}"}; + tab_undo_assert( + &mut other_editor_cx_a, + &mut other_editor_cx_b, + initial_other, + third_tabbed_other, + true, + ); + + tab_undo_assert( + &mut other_editor_cx_a, + &mut other_editor_cx_b, + initial_other, + third_tabbed_other, + false, + ); +} + +#[track_caller] +fn tab_undo_assert( + cx_a: &mut EditorTestContext, + cx_b: &mut EditorTestContext, + expected_initial: &str, + expected_tabbed: &str, + a_tabs: bool, +) { + cx_a.assert_editor_state(expected_initial); + cx_b.assert_editor_state(expected_initial); + + if a_tabs { + cx_a.update_editor(|editor, cx| { + editor.tab(&editor::actions::Tab, cx); + }); + } else { + cx_b.update_editor(|editor, cx| { + editor.tab(&editor::actions::Tab, cx); + }); + } + + cx_a.run_until_parked(); + cx_b.run_until_parked(); + + cx_a.assert_editor_state(expected_tabbed); + cx_b.assert_editor_state(expected_tabbed); + + if a_tabs { + cx_a.update_editor(|editor, cx| { + editor.undo(&editor::actions::Undo, cx); + }); + } else { + cx_b.update_editor(|editor, cx| { + editor.undo(&editor::actions::Undo, cx); + }); + } + cx_a.run_until_parked(); + cx_b.run_until_parked(); + cx_a.assert_editor_state(expected_initial); + cx_b.assert_editor_state(expected_initial); +} + fn extract_hint_labels(editor: &Editor) -> Vec { let mut labels = Vec::new(); for hint in editor.inlay_hint_cache().hints() { diff --git a/crates/collab/src/tests/integration_tests.rs b/crates/collab/src/tests/integration_tests.rs index e124fd6a7e3ffc..80cc2500f5f4ca 100644 --- a/crates/collab/src/tests/integration_tests.rs +++ b/crates/collab/src/tests/integration_tests.rs @@ -34,7 +34,7 @@ use project::{ }; use rand::prelude::*; use serde_json::json; -use settings::{LocalSettingsKind, SettingsStore}; +use settings::SettingsStore; use std::{ cell::{Cell, RefCell}, env, future, mem, @@ -3328,16 +3328,8 @@ async fn test_local_settings( .local_settings(worktree_b.read(cx).id()) .collect::>(), &[ - ( - Path::new("").into(), - LocalSettingsKind::Settings, - r#"{"tab_size":2}"#.to_string() - ), - ( - Path::new("a").into(), - LocalSettingsKind::Settings, - r#"{"tab_size":8}"#.to_string() - ), + (Path::new("").into(), r#"{"tab_size":2}"#.to_string()), + (Path::new("a").into(), r#"{"tab_size":8}"#.to_string()), ] ) }); @@ -3355,16 +3347,8 @@ async fn test_local_settings( .local_settings(worktree_b.read(cx).id()) .collect::>(), &[ - ( - Path::new("").into(), - LocalSettingsKind::Settings, - r#"{}"#.to_string() - ), - ( - Path::new("a").into(), - LocalSettingsKind::Settings, - r#"{"tab_size":8}"#.to_string() - ), + (Path::new("").into(), r#"{}"#.to_string()), + (Path::new("a").into(), r#"{"tab_size":8}"#.to_string()), ] ) }); @@ -3392,16 +3376,8 @@ async fn test_local_settings( .local_settings(worktree_b.read(cx).id()) .collect::>(), &[ - ( - Path::new("a").into(), - LocalSettingsKind::Settings, - r#"{"tab_size":8}"#.to_string() - ), - ( - Path::new("b").into(), - LocalSettingsKind::Settings, - r#"{"tab_size":4}"#.to_string() - ), + (Path::new("a").into(), r#"{"tab_size":8}"#.to_string()), + (Path::new("b").into(), r#"{"tab_size":4}"#.to_string()), ] ) }); @@ -3431,11 +3407,7 @@ async fn test_local_settings( store .local_settings(worktree_b.read(cx).id()) .collect::>(), - &[( - Path::new("a").into(), - LocalSettingsKind::Settings, - r#"{"hard_tabs":true}"#.to_string() - ),] + &[(Path::new("a").into(), r#"{"hard_tabs":true}"#.to_string()),] ) }); } diff --git a/crates/collab/src/tests/remote_editing_collaboration_tests.rs b/crates/collab/src/tests/remote_editing_collaboration_tests.rs index dae33457555ec4..f4e96e1dd05714 100644 --- a/crates/collab/src/tests/remote_editing_collaboration_tests.rs +++ b/crates/collab/src/tests/remote_editing_collaboration_tests.rs @@ -3,7 +3,7 @@ use call::ActiveCall; use fs::{FakeFs, Fs as _}; use gpui::{Context as _, TestAppContext}; use http_client::BlockedHttpClient; -use language::{language_settings::all_language_settings, LanguageRegistry}; +use language::{language_settings::language_settings, LanguageRegistry}; use node_runtime::NodeRuntime; use project::ProjectPath; use remote::SshRemoteClient; @@ -134,9 +134,7 @@ async fn test_sharing_an_ssh_remote_project( cx_b.read(|cx| { let file = buffer_b.read(cx).file(); assert_eq!( - all_language_settings(file, cx) - .language(Some(&("Rust".into()))) - .language_servers, + language_settings(Some("Rust".into()), file, cx).language_servers, ["override-rust-analyzer".to_string()] ) }); diff --git a/crates/copilot/src/copilot.rs b/crates/copilot/src/copilot.rs index a1fd7a9bb96683..3773f5cdb2e3b9 100644 --- a/crates/copilot/src/copilot.rs +++ b/crates/copilot/src/copilot.rs @@ -864,7 +864,11 @@ impl Copilot { let buffer = buffer.read(cx); let uri = registered_buffer.uri.clone(); let position = position.to_point_utf16(buffer); - let settings = language_settings(buffer.language_at(position).as_ref(), buffer.file(), cx); + let settings = language_settings( + buffer.language_at(position).map(|l| l.name()), + buffer.file(), + cx, + ); let tab_size = settings.tab_size; let hard_tabs = settings.hard_tabs; let relative_path = buffer diff --git a/crates/copilot/src/copilot_completion_provider.rs b/crates/copilot/src/copilot_completion_provider.rs index 3a3361cda1996d..059d3a4236adc3 100644 --- a/crates/copilot/src/copilot_completion_provider.rs +++ b/crates/copilot/src/copilot_completion_provider.rs @@ -77,7 +77,7 @@ impl InlineCompletionProvider for CopilotCompletionProvider { let file = buffer.file(); let language = buffer.language_at(cursor_position); let settings = all_language_settings(file, cx); - settings.inline_completions_enabled(language.as_ref(), file.map(|f| f.path().as_ref())) + settings.inline_completions_enabled(language.as_ref(), file.map(|f| f.path().as_ref()), cx) } fn refresh( @@ -209,7 +209,7 @@ impl InlineCompletionProvider for CopilotCompletionProvider { ) { let settings = AllLanguageSettings::get_global(cx); - let copilot_enabled = settings.inline_completions_enabled(None, None); + let copilot_enabled = settings.inline_completions_enabled(None, None, cx); if !copilot_enabled { return; diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index 790a0a6a1eba78..699258f9a58495 100644 --- a/crates/editor/src/display_map.rs +++ b/crates/editor/src/display_map.rs @@ -423,11 +423,12 @@ impl DisplayMap { } fn tab_size(buffer: &Model, cx: &mut ModelContext) -> NonZeroU32 { + let buffer = buffer.read(cx).as_singleton().map(|buffer| buffer.read(cx)); let language = buffer - .read(cx) - .as_singleton() - .and_then(|buffer| buffer.read(cx).language()); - language_settings(language, None, cx).tab_size + .and_then(|buffer| buffer.language()) + .map(|l| l.name()); + let file = buffer.and_then(|buffer| buffer.file()); + language_settings(language, file, cx).tab_size } #[cfg(test)] diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index ba3841b4e2202e..c014a8f679ec5c 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -90,7 +90,7 @@ pub use inline_completion_provider::*; pub use items::MAX_TAB_TITLE_LEN; use itertools::Itertools; use language::{ - language_settings::{self, all_language_settings, InlayHintSettings}, + language_settings::{self, all_language_settings, language_settings, InlayHintSettings}, markdown, point_from_lsp, AutoindentMode, BracketPair, Buffer, Capability, CharKind, CodeLabel, CursorShape, Diagnostic, Documentation, IndentKind, IndentSize, Language, OffsetRangeExt, Point, Selection, SelectionGoal, TransactionId, @@ -428,8 +428,7 @@ impl Default for EditorStyle { } pub fn make_inlay_hints_style(cx: &WindowContext) -> HighlightStyle { - let show_background = all_language_settings(None, cx) - .language(None) + let show_background = language_settings::language_settings(None, None, cx) .inlay_hints .show_background; @@ -4248,7 +4247,10 @@ impl Editor { .text_anchor_for_position(position, cx)?; let settings = language_settings::language_settings( - buffer.read(cx).language_at(buffer_position).as_ref(), + buffer + .read(cx) + .language_at(buffer_position) + .map(|l| l.name()), buffer.read(cx).file(), cx, ); @@ -13376,11 +13378,8 @@ fn inlay_hint_settings( cx: &mut ViewContext<'_, Editor>, ) -> InlayHintSettings { let file = snapshot.file_at(location); - let language = snapshot.language_at(location); - let settings = all_language_settings(file, cx); - settings - .language(language.map(|l| l.name()).as_ref()) - .inlay_hints + let language = snapshot.language_at(location).map(|l| l.name()); + language_settings(language, file, cx).inlay_hints } fn consume_contiguous_rows( diff --git a/crates/editor/src/indent_guides.rs b/crates/editor/src/indent_guides.rs index 520ca508b7648d..815825b606bf12 100644 --- a/crates/editor/src/indent_guides.rs +++ b/crates/editor/src/indent_guides.rs @@ -39,9 +39,13 @@ impl Editor { ) -> Option> { let show_indent_guides = self.should_show_indent_guides().unwrap_or_else(|| { if let Some(buffer) = self.buffer().read(cx).as_singleton() { - language_settings(buffer.read(cx).language(), buffer.read(cx).file(), cx) - .indent_guides - .enabled + language_settings( + buffer.read(cx).language().map(|l| l.name()), + buffer.read(cx).file(), + cx, + ) + .indent_guides + .enabled } else { true } diff --git a/crates/extension/src/wasm_host/wit/since_v0_1_0.rs b/crates/extension/src/wasm_host/wit/since_v0_1_0.rs index 862e2e7c7f7894..57b2edd301fe7b 100644 --- a/crates/extension/src/wasm_host/wit/since_v0_1_0.rs +++ b/crates/extension/src/wasm_host/wit/since_v0_1_0.rs @@ -356,8 +356,11 @@ impl ExtensionImports for WasmState { cx.update(|cx| match category.as_str() { "language" => { let key = key.map(|k| LanguageName::new(&k)); - let settings = - AllLanguageSettings::get(location, cx).language(key.as_ref()); + let settings = AllLanguageSettings::get(location, cx).language( + location, + key.as_ref(), + cx, + ); Ok(serde_json::to_string(&settings::LanguageSettings { tab_size: settings.tab_size, })?) diff --git a/crates/extension/src/wasm_host/wit/since_v0_2_0.rs b/crates/extension/src/wasm_host/wit/since_v0_2_0.rs index e7f5432e1d32cc..da5632f3aecb86 100644 --- a/crates/extension/src/wasm_host/wit/since_v0_2_0.rs +++ b/crates/extension/src/wasm_host/wit/since_v0_2_0.rs @@ -402,8 +402,11 @@ impl ExtensionImports for WasmState { cx.update(|cx| match category.as_str() { "language" => { let key = key.map(|k| LanguageName::new(&k)); - let settings = - AllLanguageSettings::get(location, cx).language(key.as_ref()); + let settings = AllLanguageSettings::get(location, cx).language( + location, + key.as_ref(), + cx, + ); Ok(serde_json::to_string(&settings::LanguageSettings { tab_size: settings.tab_size, })?) diff --git a/crates/inline_completion_button/src/inline_completion_button.rs b/crates/inline_completion_button/src/inline_completion_button.rs index abf8266320247e..8f727fd2fe5c80 100644 --- a/crates/inline_completion_button/src/inline_completion_button.rs +++ b/crates/inline_completion_button/src/inline_completion_button.rs @@ -62,7 +62,7 @@ impl Render for InlineCompletionButton { let status = copilot.read(cx).status(); let enabled = self.editor_enabled.unwrap_or_else(|| { - all_language_settings.inline_completions_enabled(None, None) + all_language_settings.inline_completions_enabled(None, None, cx) }); let icon = match status { @@ -248,8 +248,9 @@ impl InlineCompletionButton { if let Some(language) = self.language.clone() { let fs = fs.clone(); - let language_enabled = language_settings::language_settings(Some(&language), None, cx) - .show_inline_completions; + let language_enabled = + language_settings::language_settings(Some(language.name()), None, cx) + .show_inline_completions; menu = menu.entry( format!( @@ -292,7 +293,7 @@ impl InlineCompletionButton { ); } - let globally_enabled = settings.inline_completions_enabled(None, None); + let globally_enabled = settings.inline_completions_enabled(None, None, cx); menu.entry( if globally_enabled { "Hide Inline Completions for All Files" @@ -340,6 +341,7 @@ impl InlineCompletionButton { && all_language_settings(file, cx).inline_completions_enabled( language, file.map(|file| file.path().as_ref()), + cx, ), ) }; @@ -442,7 +444,7 @@ async fn configure_disabled_globs( fn toggle_inline_completions_globally(fs: Arc, cx: &mut AppContext) { let show_inline_completions = - all_language_settings(None, cx).inline_completions_enabled(None, None); + all_language_settings(None, cx).inline_completions_enabled(None, None, cx); update_settings_file::(fs, cx, move |file, _| { file.defaults.show_inline_completions = Some(!show_inline_completions) }); @@ -466,7 +468,7 @@ fn toggle_inline_completions_for_language( cx: &mut AppContext, ) { let show_inline_completions = - all_language_settings(None, cx).inline_completions_enabled(Some(&language), None); + all_language_settings(None, cx).inline_completions_enabled(Some(&language), None, cx); update_settings_file::(fs, cx, move |file, _| { file.languages .entry(language.name()) diff --git a/crates/language/Cargo.toml b/crates/language/Cargo.toml index 69c7dcce0dbef4..b117b9682bb116 100644 --- a/crates/language/Cargo.toml +++ b/crates/language/Cargo.toml @@ -30,6 +30,7 @@ async-trait.workspace = true async-watch.workspace = true clock.workspace = true collections.workspace = true +ec4rs.workspace = true futures.workspace = true fuzzy.workspace = true git.workspace = true diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index 92cd84202a250d..132ace668304c6 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -37,6 +37,7 @@ use smallvec::SmallVec; use smol::future::yield_now; use std::{ any::Any, + borrow::Cow, cell::Cell, cmp::{self, Ordering, Reverse}, collections::BTreeMap, @@ -2490,7 +2491,11 @@ impl BufferSnapshot { /// Returns [`IndentSize`] for a given position that respects user settings /// and language preferences. pub fn language_indent_size_at(&self, position: T, cx: &AppContext) -> IndentSize { - let settings = language_settings(self.language_at(position), self.file(), cx); + let settings = language_settings( + self.language_at(position).map(|l| l.name()), + self.file(), + cx, + ); if settings.hard_tabs { IndentSize::tab() } else { @@ -2823,11 +2828,15 @@ impl BufferSnapshot { /// Returns the settings for the language at the given location. pub fn settings_at<'a, D: ToOffset>( - &self, + &'a self, position: D, cx: &'a AppContext, - ) -> &'a LanguageSettings { - language_settings(self.language_at(position), self.file.as_ref(), cx) + ) -> Cow<'a, LanguageSettings> { + language_settings( + self.language_at(position).map(|l| l.name()), + self.file.as_ref(), + cx, + ) } pub fn char_classifier_at(&self, point: T) -> CharClassifier { @@ -3529,7 +3538,8 @@ impl BufferSnapshot { ignore_disabled_for_language: bool, cx: &AppContext, ) -> Vec { - let language_settings = language_settings(self.language(), self.file.as_ref(), cx); + let language_settings = + language_settings(self.language().map(|l| l.name()), self.file.as_ref(), cx); let settings = language_settings.indent_guides; if !ignore_disabled_for_language && !settings.enabled { return Vec::new(); diff --git a/crates/language/src/language_settings.rs b/crates/language/src/language_settings.rs index de37e52290bf46..f4083b22445133 100644 --- a/crates/language/src/language_settings.rs +++ b/crates/language/src/language_settings.rs @@ -4,6 +4,10 @@ use crate::{File, Language, LanguageName, LanguageServerName}; use anyhow::Result; use collections::{HashMap, HashSet}; use core::slice; +use ec4rs::{ + property::{FinalNewline, IndentSize, IndentStyle, MaxLineLen, TabWidth, TrimTrailingWs}, + Properties as EditorconfigProperties, +}; use globset::{Glob, GlobMatcher, GlobSet, GlobSetBuilder}; use gpui::AppContext; use itertools::{Either, Itertools}; @@ -16,8 +20,10 @@ use serde::{ Deserialize, Deserializer, Serialize, }; use serde_json::Value; -use settings::{add_references_to_properties, Settings, SettingsLocation, SettingsSources}; -use std::{num::NonZeroU32, path::Path, sync::Arc}; +use settings::{ + add_references_to_properties, Settings, SettingsLocation, SettingsSources, SettingsStore, +}; +use std::{borrow::Cow, num::NonZeroU32, path::Path, sync::Arc}; use util::serde::default_true; /// Initializes the language settings. @@ -27,17 +33,20 @@ pub fn init(cx: &mut AppContext) { /// Returns the settings for the specified language from the provided file. pub fn language_settings<'a>( - language: Option<&Arc>, - file: Option<&Arc>, + language: Option, + file: Option<&'a Arc>, cx: &'a AppContext, -) -> &'a LanguageSettings { - let language_name = language.map(|l| l.name()); - all_language_settings(file, cx).language(language_name.as_ref()) +) -> Cow<'a, LanguageSettings> { + let location = file.map(|f| SettingsLocation { + worktree_id: f.worktree_id(cx), + path: f.path().as_ref(), + }); + AllLanguageSettings::get(location, cx).language(location, language.as_ref(), cx) } /// Returns the settings for all languages from the provided file. pub fn all_language_settings<'a>( - file: Option<&Arc>, + file: Option<&'a Arc>, cx: &'a AppContext, ) -> &'a AllLanguageSettings { let location = file.map(|f| SettingsLocation { @@ -810,13 +819,27 @@ impl InlayHintSettings { impl AllLanguageSettings { /// Returns the [`LanguageSettings`] for the language with the specified name. - pub fn language<'a>(&'a self, language_name: Option<&LanguageName>) -> &'a LanguageSettings { - if let Some(name) = language_name { - if let Some(overrides) = self.languages.get(name) { - return overrides; - } + pub fn language<'a>( + &'a self, + location: Option>, + language_name: Option<&LanguageName>, + cx: &'a AppContext, + ) -> Cow<'a, LanguageSettings> { + let settings = language_name + .and_then(|name| self.languages.get(name)) + .unwrap_or(&self.defaults); + + let editorconfig_properties = location.and_then(|location| { + cx.global::() + .editorconfg_properties(location.worktree_id, location.path) + }); + 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) } - &self.defaults } /// Returns whether inline completions are enabled for the given path. @@ -833,6 +856,7 @@ impl AllLanguageSettings { &self, language: Option<&Arc>, path: Option<&Path>, + cx: &AppContext, ) -> bool { if let Some(path) = path { if !self.inline_completions_enabled_for_path(path) { @@ -840,11 +864,64 @@ impl AllLanguageSettings { } } - self.language(language.map(|l| l.name()).as_ref()) + self.language(None, language.map(|l| l.name()).as_ref(), cx) .show_inline_completions } } +fn merge_with_editorconfig(settings: &mut LanguageSettings, cfg: &EditorconfigProperties) { + let max_line_length = cfg.get::().ok().and_then(|v| match v { + MaxLineLen::Value(u) => Some(u as u32), + MaxLineLen::Off => None, + }); + let tab_size = cfg.get::().ok().and_then(|v| match v { + IndentSize::Value(u) => NonZeroU32::new(u as u32), + IndentSize::UseTabWidth => cfg.get::().ok().and_then(|w| match w { + TabWidth::Value(u) => NonZeroU32::new(u as u32), + }), + }); + let hard_tabs = cfg + .get::() + .map(|v| v.eq(&IndentStyle::Tabs)) + .ok(); + let ensure_final_newline_on_save = cfg + .get::() + .map(|v| match v { + FinalNewline::Value(b) => b, + }) + .ok(); + let remove_trailing_whitespace_on_save = cfg + .get::() + .map(|v| match v { + TrimTrailingWs::Value(b) => b, + }) + .ok(); + let preferred_line_length = max_line_length; + let soft_wrap = if max_line_length.is_some() { + Some(SoftWrap::PreferredLineLength) + } else { + None + }; + + fn merge(target: &mut T, value: Option) { + if let Some(value) = value { + *target = value; + } + } + merge(&mut settings.tab_size, tab_size); + merge(&mut settings.hard_tabs, hard_tabs); + merge( + &mut settings.remove_trailing_whitespace_on_save, + remove_trailing_whitespace_on_save, + ); + merge( + &mut settings.ensure_final_newline_on_save, + ensure_final_newline_on_save, + ); + merge(&mut settings.preferred_line_length, preferred_line_length); + merge(&mut settings.soft_wrap, soft_wrap); +} + /// The kind of an inlay hint. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum InlayHintKind { diff --git a/crates/languages/src/rust.rs b/crates/languages/src/rust.rs index 0d644e1bfef247..1f040eb6476e59 100644 --- a/crates/languages/src/rust.rs +++ b/crates/languages/src/rust.rs @@ -6,7 +6,6 @@ use futures::{io::BufReader, StreamExt}; use gpui::{AppContext, AsyncAppContext}; use http_client::github::{latest_github_release, GitHubLspBinaryVersion}; pub use language::*; -use language_settings::all_language_settings; use lsp::LanguageServerBinary; use regex::Regex; use smol::fs::{self, File}; @@ -21,6 +20,8 @@ use std::{ use task::{TaskTemplate, TaskTemplates, TaskVariables, VariableName}; use util::{fs::remove_matching, maybe, ResultExt}; +use crate::language_settings::language_settings; + pub struct RustLspAdapter; impl RustLspAdapter { @@ -424,13 +425,13 @@ impl ContextProvider for RustContextProvider { cx: &AppContext, ) -> Option { const DEFAULT_RUN_NAME_STR: &str = "RUST_DEFAULT_PACKAGE_RUN"; - let package_to_run = all_language_settings(file.as_ref(), cx) - .language(Some(&"Rust".into())) + let package_to_run = language_settings(Some("Rust".into()), file.as_ref(), cx) .tasks .variables - .get(DEFAULT_RUN_NAME_STR); + .get(DEFAULT_RUN_NAME_STR) + .cloned(); let run_task_args = if let Some(package_to_run) = package_to_run { - vec!["run".into(), "-p".into(), package_to_run.clone()] + vec!["run".into(), "-p".into(), package_to_run] } else { vec!["run".into()] }; diff --git a/crates/languages/src/yaml.rs b/crates/languages/src/yaml.rs index 642d6c030ac915..9f1c468b876b4a 100644 --- a/crates/languages/src/yaml.rs +++ b/crates/languages/src/yaml.rs @@ -101,7 +101,7 @@ impl LspAdapter for YamlLspAdapter { let tab_size = cx.update(|cx| { AllLanguageSettings::get(Some(location), cx) - .language(Some(&"YAML".into())) + .language(Some(location), Some(&"YAML".into()), cx) .tab_size })?; let mut options = serde_json::json!({"[yaml]": {"editor.tabSize": tab_size}}); diff --git a/crates/multi_buffer/src/multi_buffer.rs b/crates/multi_buffer/src/multi_buffer.rs index f091c86ed92abd..d19ec4501fefc9 100644 --- a/crates/multi_buffer/src/multi_buffer.rs +++ b/crates/multi_buffer/src/multi_buffer.rs @@ -1778,7 +1778,7 @@ impl MultiBuffer { &self, point: T, cx: &'a AppContext, - ) -> &'a LanguageSettings { + ) -> Cow<'a, LanguageSettings> { let mut language = None; let mut file = None; if let Some((buffer, offset, _)) = self.point_to_buffer_offset(point, cx) { @@ -1786,7 +1786,7 @@ impl MultiBuffer { language = buffer.language_at(offset); file = buffer.file(); } - language_settings(language.as_ref(), file, cx) + language_settings(language.map(|l| l.name()), file, cx) } pub fn for_each_buffer(&self, mut f: impl FnMut(&Model)) { @@ -3580,14 +3580,14 @@ impl MultiBufferSnapshot { &'a self, point: T, cx: &'a AppContext, - ) -> &'a LanguageSettings { + ) -> Cow<'a, LanguageSettings> { let mut language = None; let mut file = None; if let Some((buffer, offset)) = self.point_to_buffer_offset(point) { language = buffer.language_at(offset); file = buffer.file(); } - language_settings(language, file, cx) + language_settings(language.map(|l| l.name()), file, cx) } pub fn language_scope_at(&self, point: T) -> Option { diff --git a/crates/paths/src/paths.rs b/crates/paths/src/paths.rs index 679bab32d2bb0a..e29a94b6a03938 100644 --- a/crates/paths/src/paths.rs +++ b/crates/paths/src/paths.rs @@ -293,3 +293,6 @@ pub fn local_tasks_file_relative_path() -> &'static Path { pub fn local_vscode_tasks_file_relative_path() -> &'static Path { Path::new(".vscode/tasks.json") } + +/// A default editorconfig file name to use when resolving project settings. +pub const EDITORCONFIG_NAME: &str = ".editorconfig"; diff --git a/crates/prettier/src/prettier.rs b/crates/prettier/src/prettier.rs index 012beb3fd7ab28..d2d56696a696e3 100644 --- a/crates/prettier/src/prettier.rs +++ b/crates/prettier/src/prettier.rs @@ -205,7 +205,7 @@ impl Prettier { let params = buffer .update(cx, |buffer, cx| { let buffer_language = buffer.language(); - let language_settings = language_settings(buffer_language, buffer.file(), cx); + let language_settings = language_settings(buffer_language.map(|l| l.name()), buffer.file(), cx); let prettier_settings = &language_settings.prettier; anyhow::ensure!( prettier_settings.allowed, diff --git a/crates/project/src/lsp_command.rs b/crates/project/src/lsp_command.rs index 4a80180d7cd888..57f8cea348dac3 100644 --- a/crates/project/src/lsp_command.rs +++ b/crates/project/src/lsp_command.rs @@ -2303,7 +2303,9 @@ impl LspCommand for OnTypeFormatting { .await?; let options = buffer.update(&mut cx, |buffer, cx| { - lsp_formatting_options(language_settings(buffer.language(), buffer.file(), cx)) + lsp_formatting_options( + language_settings(buffer.language().map(|l| l.name()), buffer.file(), cx).as_ref(), + ) })?; Ok(Self { diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index fe0a6443bc8118..534bd71d7f8835 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -30,8 +30,7 @@ use gpui::{ use http_client::HttpClient; use language::{ language_settings::{ - all_language_settings, language_settings, AllLanguageSettings, FormatOnSave, Formatter, - LanguageSettings, SelectedFormatter, + language_settings, FormatOnSave, Formatter, LanguageSettings, SelectedFormatter, }, markdown, point_to_lsp, prepare_completion_documentation, proto::{deserialize_anchor, deserialize_version, serialize_anchor, serialize_version}, @@ -223,7 +222,8 @@ impl LocalLspStore { })?; let settings = buffer.handle.update(&mut cx, |buffer, cx| { - language_settings(buffer.language(), buffer.file(), cx).clone() + language_settings(buffer.language().map(|l| l.name()), buffer.file(), cx) + .into_owned() })?; let remove_trailing_whitespace = settings.remove_trailing_whitespace_on_save; @@ -280,7 +280,7 @@ impl LocalLspStore { .zip(buffer.abs_path.as_ref()); let prettier_settings = buffer.handle.read_with(&cx, |buffer, cx| { - language_settings(buffer.language(), buffer.file(), cx) + language_settings(buffer.language().map(|l| l.name()), buffer.file(), cx) .prettier .clone() })?; @@ -1225,7 +1225,8 @@ impl LspStore { }); let buffer_file = buffer.read(cx).file().cloned(); - let settings = language_settings(Some(&new_language), buffer_file.as_ref(), cx).clone(); + let settings = + language_settings(Some(new_language.name()), 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 { @@ -1400,15 +1401,17 @@ 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, buffer.file(), cx); + let settings = language_settings(buffer_language.map(|l| l.name()), buffer.file(), cx); if let Some(language) = buffer_language { if settings.enable_language_server { if let Some(file) = buffer_file { language_servers_to_start.push((file.worktree.clone(), language.name())); } } - language_formatters_to_check - .push((buffer_file.map(|f| f.worktree_id(cx)), settings.clone())); + language_formatters_to_check.push(( + buffer_file.map(|f| f.worktree_id(cx)), + settings.into_owned(), + )); } } @@ -1433,10 +1436,13 @@ impl LspStore { }); if let Some((language, adapter)) = language { let worktree = self.worktree_for_id(worktree_id, cx).ok(); - let file = worktree.as_ref().and_then(|tree| { - tree.update(cx, |tree, cx| tree.root_file(cx).map(|f| f as _)) + let root_file = worktree.as_ref().and_then(|worktree| { + worktree + .update(cx, |tree, cx| tree.root_file(cx)) + .map(|f| f as _) }); - if !language_settings(Some(language), file.as_ref(), cx).enable_language_server { + let settings = language_settings(Some(language.name()), root_file.as_ref(), cx); + if !settings.enable_language_server { language_servers_to_stop.push((worktree_id, started_lsp_name.clone())); } else if let Some(worktree) = worktree { let server_name = &adapter.name; @@ -1753,10 +1759,9 @@ impl LspStore { }) .filter(|_| { maybe!({ - let language_name = buffer.read(cx).language_at(position)?.name(); + let language = buffer.read(cx).language_at(position)?; Some( - AllLanguageSettings::get_global(cx) - .language(Some(&language_name)) + language_settings(Some(language.name()), buffer.read(cx).file(), cx) .linked_edits, ) }) == Some(true) @@ -1850,11 +1855,14 @@ impl LspStore { cx: &mut ModelContext, ) -> Task>> { let options = buffer.update(cx, |buffer, cx| { - lsp_command::lsp_formatting_options(language_settings( - buffer.language_at(position).as_ref(), - buffer.file(), - cx, - )) + lsp_command::lsp_formatting_options( + language_settings( + buffer.language_at(position).map(|l| l.name()), + buffer.file(), + cx, + ) + .as_ref(), + ) }); self.request_lsp( buffer.clone(), @@ -5288,23 +5296,16 @@ impl LspStore { }) } - fn language_settings<'a>( - &'a self, - worktree: &'a Model, - language: &LanguageName, - cx: &'a mut ModelContext, - ) -> &'a LanguageSettings { - let root_file = worktree.update(cx, |tree, cx| tree.root_file(cx)); - all_language_settings(root_file.map(|f| f as _).as_ref(), cx).language(Some(language)) - } - pub fn start_language_servers( &mut self, worktree: &Model, language: LanguageName, cx: &mut ModelContext, ) { - let settings = self.language_settings(worktree, &language, cx); + let root_file = worktree + .update(cx, |tree, cx| tree.root_file(cx)) + .map(|f| f as _); + let settings = language_settings(Some(language.clone()), root_file.as_ref(), cx); if !settings.enable_language_server || self.mode.is_remote() { return; } diff --git a/crates/project/src/project_settings.rs b/crates/project/src/project_settings.rs index 3ecf0e300878ca..cab420d12cd9ff 100644 --- a/crates/project/src/project_settings.rs +++ b/crates/project/src/project_settings.rs @@ -5,7 +5,7 @@ use gpui::{AppContext, AsyncAppContext, BorrowAppContext, EventEmitter, Model, M use language::LanguageServerName; use paths::{ local_settings_file_relative_path, local_tasks_file_relative_path, - local_vscode_tasks_file_relative_path, + local_vscode_tasks_file_relative_path, EDITORCONFIG_NAME, }; use rpc::{proto, AnyProtoClient, TypedEnvelope}; use schemars::JsonSchema; @@ -287,14 +287,29 @@ impl SettingsObserver { let store = cx.global::(); for worktree in self.worktree_store.read(cx).worktrees() { let worktree_id = worktree.read(cx).id().to_proto(); - for (path, kind, content) in store.local_settings(worktree.read(cx).id()) { + for (path, content) in store.local_settings(worktree.read(cx).id()) { downstream_client .send(proto::UpdateWorktreeSettings { project_id, worktree_id, path: path.to_string_lossy().into(), content: Some(content), - kind: Some(local_settings_kind_to_proto(kind).into()), + kind: Some( + local_settings_kind_to_proto(LocalSettingsKind::Settings).into(), + ), + }) + .log_err(); + } + for (path, content, _) in store.local_editorconfig_settings(worktree.read(cx).id()) { + downstream_client + .send(proto::UpdateWorktreeSettings { + project_id, + worktree_id, + path: path.to_string_lossy().into(), + content: Some(content), + kind: Some( + local_settings_kind_to_proto(LocalSettingsKind::Editorconfig).into(), + ), }) .log_err(); } @@ -453,6 +468,11 @@ impl SettingsObserver { .unwrap(), ); (settings_dir, LocalSettingsKind::Tasks) + } else if path.ends_with(EDITORCONFIG_NAME) { + let Some(settings_dir) = path.parent().map(Arc::from) else { + continue; + }; + (settings_dir, LocalSettingsKind::Editorconfig) } else { continue; }; diff --git a/crates/project/src/project_tests.rs b/crates/project/src/project_tests.rs index 8795af4cb44bd5..1a0536d067378b 100644 --- a/crates/project/src/project_tests.rs +++ b/crates/project/src/project_tests.rs @@ -4,7 +4,9 @@ use futures::{future, StreamExt}; use gpui::{AppContext, SemanticVersion, UpdateGlobal}; use http_client::Url; use language::{ - language_settings::{language_settings, AllLanguageSettings, LanguageSettingsContent}, + language_settings::{ + language_settings, AllLanguageSettings, LanguageSettingsContent, SoftWrap, + }, tree_sitter_rust, tree_sitter_typescript, Diagnostic, DiagnosticSet, FakeLspAdapter, LanguageConfig, LanguageMatcher, LanguageName, LineEnding, OffsetRangeExt, Point, ToPoint, }; @@ -15,7 +17,7 @@ use serde_json::json; #[cfg(not(windows))] use std::os; -use std::{mem, ops::Range, task::Poll}; +use std::{mem, num::NonZeroU32, ops::Range, task::Poll}; use task::{ResolvedTask, TaskContext}; use unindent::Unindent as _; use util::{assert_set_eq, paths::PathMatcher, test::temp_tree, TryFutureExt as _}; @@ -91,6 +93,107 @@ async fn test_symlinks(cx: &mut gpui::TestAppContext) { }); } +#[gpui::test] +async fn test_editorconfig_support(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let dir = temp_tree(json!({ + ".editorconfig": r#" + root = true + [*.rs] + indent_style = tab + indent_size = 3 + end_of_line = lf + insert_final_newline = true + trim_trailing_whitespace = true + max_line_length = 80 + [*.js] + tab_width = 10 + "#, + ".zed": { + "settings.json": r#"{ + "tab_size": 8, + "hard_tabs": false, + "ensure_final_newline_on_save": false, + "remove_trailing_whitespace_on_save": false, + "preferred_line_length": 64, + "soft_wrap": "editor_width" + }"#, + }, + "a.rs": "fn a() {\n A\n}", + "b": { + ".editorconfig": r#" + [*.rs] + indent_size = 2 + max_line_length = off + "#, + "b.rs": "fn b() {\n B\n}", + }, + "c.js": "def c\n C\nend", + "README.json": "tabs are better\n", + })); + + let path = dir.path(); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree_from_real_fs(path, path).await; + let project = Project::test(fs, [path], cx).await; + + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(js_lang()); + language_registry.add(json_lang()); + language_registry.add(rust_lang()); + + let worktree = project.update(cx, |project, cx| project.worktrees(cx).next().unwrap()); + + cx.executor().run_until_parked(); + + cx.update(|cx| { + let tree = worktree.read(cx); + let settings_for = |path: &str| { + let file_entry = tree.entry_for_path(path).unwrap().clone(); + let file = File::for_entry(file_entry, worktree.clone()); + let file_language = project + .read(cx) + .languages() + .language_for_file_path(file.path.as_ref()); + let file_language = cx + .background_executor() + .block(file_language) + .expect("Failed to get file language"); + let file = file as _; + language_settings(Some(file_language.name()), Some(&file), cx).into_owned() + }; + + let settings_a = settings_for("a.rs"); + let settings_b = settings_for("b/b.rs"); + let settings_c = settings_for("c.js"); + let settings_readme = settings_for("README.json"); + + // .editorconfig overrides .zed/settings + assert_eq!(Some(settings_a.tab_size), NonZeroU32::new(3)); + assert_eq!(settings_a.hard_tabs, true); + assert_eq!(settings_a.ensure_final_newline_on_save, true); + assert_eq!(settings_a.remove_trailing_whitespace_on_save, true); + assert_eq!(settings_a.preferred_line_length, 80); + + // "max_line_length" also sets "soft_wrap" + assert_eq!(settings_a.soft_wrap, SoftWrap::PreferredLineLength); + + // .editorconfig in b/ overrides .editorconfig in root + assert_eq!(Some(settings_b.tab_size), NonZeroU32::new(2)); + + // "indent_size" is not set, so "tab_width" is used + assert_eq!(Some(settings_c.tab_size), NonZeroU32::new(10)); + + // When max_line_length is "off", default to .zed/settings.json + assert_eq!(settings_b.preferred_line_length, 64); + assert_eq!(settings_b.soft_wrap, SoftWrap::EditorWidth); + + // README.md should not be affected by .editorconfig's globe "*.rs" + assert_eq!(Some(settings_readme.tab_size), NonZeroU32::new(8)); + }); +} + #[gpui::test] async fn test_managing_project_specific_settings(cx: &mut gpui::TestAppContext) { init_test(cx); @@ -146,26 +249,16 @@ async fn test_managing_project_specific_settings(cx: &mut gpui::TestAppContext) .update(|cx| { let tree = worktree.read(cx); - let settings_a = language_settings( - None, - Some( - &(File::for_entry( - tree.entry_for_path("a/a.rs").unwrap().clone(), - worktree.clone(), - ) as _), - ), - cx, - ); - let settings_b = language_settings( - None, - Some( - &(File::for_entry( - tree.entry_for_path("b/b.rs").unwrap().clone(), - worktree.clone(), - ) as _), - ), - cx, - ); + let file_a = File::for_entry( + tree.entry_for_path("a/a.rs").unwrap().clone(), + worktree.clone(), + ) as _; + let settings_a = language_settings(None, Some(&file_a), cx); + let file_b = File::for_entry( + tree.entry_for_path("b/b.rs").unwrap().clone(), + worktree.clone(), + ) as _; + let settings_b = language_settings(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/remote_server/src/remote_editing_tests.rs b/crates/remote_server/src/remote_editing_tests.rs index 41065ad5508310..3cab99c58306a4 100644 --- a/crates/remote_server/src/remote_editing_tests.rs +++ b/crates/remote_server/src/remote_editing_tests.rs @@ -5,7 +5,7 @@ use fs::{FakeFs, Fs}; use gpui::{Context, Model, TestAppContext}; use http_client::{BlockedHttpClient, FakeHttpClient}; use language::{ - language_settings::{all_language_settings, AllLanguageSettings}, + language_settings::{language_settings, AllLanguageSettings}, Buffer, FakeLspAdapter, LanguageConfig, LanguageMatcher, LanguageRegistry, LanguageServerName, LineEnding, }; @@ -208,7 +208,7 @@ async fn test_remote_settings(cx: &mut TestAppContext, server_cx: &mut TestAppCo server_cx.read(|cx| { assert_eq!( AllLanguageSettings::get_global(cx) - .language(Some(&"Rust".into())) + .language(None, Some(&"Rust".into()), cx) .language_servers, ["from-local-settings".to_string()] ) @@ -228,7 +228,7 @@ async fn test_remote_settings(cx: &mut TestAppContext, server_cx: &mut TestAppCo server_cx.read(|cx| { assert_eq!( AllLanguageSettings::get_global(cx) - .language(Some(&"Rust".into())) + .language(None, Some(&"Rust".into()), cx) .language_servers, ["from-server-settings".to_string()] ) @@ -287,7 +287,7 @@ async fn test_remote_settings(cx: &mut TestAppContext, server_cx: &mut TestAppCo }), cx ) - .language(Some(&"Rust".into())) + .language(None, Some(&"Rust".into()), cx) .language_servers, ["override-rust-analyzer".to_string()] ) @@ -296,9 +296,7 @@ async fn test_remote_settings(cx: &mut TestAppContext, server_cx: &mut TestAppCo cx.read(|cx| { let file = buffer.read(cx).file(); assert_eq!( - all_language_settings(file, cx) - .language(Some(&"Rust".into())) - .language_servers, + language_settings(Some("Rust".into()), file, cx).language_servers, ["override-rust-analyzer".to_string()] ) }); @@ -379,9 +377,7 @@ async fn test_remote_lsp(cx: &mut TestAppContext, server_cx: &mut TestAppContext cx.read(|cx| { let file = buffer.read(cx).file(); assert_eq!( - all_language_settings(file, cx) - .language(Some(&"Rust".into())) - .language_servers, + language_settings(Some("Rust".into()), file, cx).language_servers, ["rust-analyzer".to_string()] ) }); diff --git a/crates/settings/Cargo.toml b/crates/settings/Cargo.toml index e9f6f6e4899cc7..cad4b2b0cf7d36 100644 --- a/crates/settings/Cargo.toml +++ b/crates/settings/Cargo.toml @@ -18,6 +18,7 @@ test-support = ["gpui/test-support", "fs/test-support"] [dependencies] anyhow.workspace = true collections.workspace = true +ec4rs.workspace = true fs.workspace = true futures.workspace = true gpui.workspace = true diff --git a/crates/settings/src/settings_store.rs b/crates/settings/src/settings_store.rs index 60494a6aee2e9d..a25eae7e2c6cb1 100644 --- a/crates/settings/src/settings_store.rs +++ b/crates/settings/src/settings_store.rs @@ -1,9 +1,10 @@ use anyhow::{anyhow, Context, Result}; use collections::{btree_map, hash_map, BTreeMap, HashMap}; +use ec4rs::{ConfigParser, PropertiesSource, Section}; use fs::Fs; use futures::{channel::mpsc, future::LocalBoxFuture, FutureExt, StreamExt}; use gpui::{AppContext, AsyncAppContext, BorrowAppContext, Global, Task, UpdateGlobal}; -use paths::local_settings_file_relative_path; +use paths::{local_settings_file_relative_path, EDITORCONFIG_NAME}; use schemars::{gen::SchemaGenerator, schema::RootSchema, JsonSchema}; use serde::{de::DeserializeOwned, Deserialize as _, Serialize}; use smallvec::SmallVec; @@ -12,12 +13,14 @@ use std::{ fmt::Debug, ops::Range, path::{Path, PathBuf}, - str, + str::{self, FromStr}, sync::{Arc, LazyLock}, }; use tree_sitter::Query; use util::{merge_non_null_json_value_into, RangeExt, ResultExt as _}; +pub type EditorconfigProperties = ec4rs::Properties; + use crate::{SettingsJsonSchemaParams, WorktreeId}; /// A value that can be defined as a user setting. @@ -167,8 +170,8 @@ pub struct SettingsStore { raw_user_settings: serde_json::Value, raw_server_settings: Option, raw_extension_settings: serde_json::Value, - raw_local_settings: - BTreeMap<(WorktreeId, Arc), HashMap>, + raw_local_settings: BTreeMap<(WorktreeId, Arc), serde_json::Value>, + raw_editorconfig_settings: BTreeMap<(WorktreeId, Arc), (String, Option)>, tab_size_callback: Option<( TypeId, Box Option + Send + Sync + 'static>, @@ -179,6 +182,26 @@ pub struct SettingsStore { >, } +#[derive(Clone)] +pub struct Editorconfig { + pub is_root: bool, + pub sections: SmallVec<[Section; 5]>, +} + +impl FromStr for Editorconfig { + type Err = anyhow::Error; + + fn from_str(contents: &str) -> Result { + let parser = ConfigParser::new_buffered(contents.as_bytes()) + .context("creating editorconfig parser")?; + let is_root = parser.is_root; + let sections = parser + .collect::, _>>() + .context("parsing editorconfig sections")?; + Ok(Self { is_root, sections }) + } +} + #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] pub enum LocalSettingsKind { Settings, @@ -226,6 +249,7 @@ impl SettingsStore { raw_server_settings: None, raw_extension_settings: serde_json::json!({}), raw_local_settings: Default::default(), + raw_editorconfig_settings: BTreeMap::default(), tab_size_callback: Default::default(), setting_file_updates_tx, _setting_file_updates: cx.spawn(|cx| async move { @@ -567,33 +591,91 @@ impl SettingsStore { settings_content: Option<&str>, cx: &mut AppContext, ) -> std::result::Result<(), InvalidSettingsError> { - debug_assert!( - kind != LocalSettingsKind::Tasks, - "Attempted to submit tasks into the settings store" - ); - - let raw_local_settings = self - .raw_local_settings - .entry((root_id, directory_path.clone())) - .or_default(); - let changed = if settings_content.is_some_and(|content| !content.is_empty()) { - let new_contents = - parse_json_with_comments(settings_content.unwrap()).map_err(|e| { - InvalidSettingsError::LocalSettings { + let mut zed_settings_changed = false; + match ( + kind, + settings_content + .map(|content| content.trim()) + .filter(|content| !content.is_empty()), + ) { + (LocalSettingsKind::Tasks, _) => { + return Err(InvalidSettingsError::Tasks { + message: "Attempted to submit tasks into the settings store".to_string(), + }) + } + (LocalSettingsKind::Settings, None) => { + zed_settings_changed = self + .raw_local_settings + .remove(&(root_id, directory_path.clone())) + .is_some() + } + (LocalSettingsKind::Editorconfig, None) => { + self.raw_editorconfig_settings + .remove(&(root_id, directory_path.clone())); + } + (LocalSettingsKind::Settings, Some(settings_contents)) => { + let new_settings = parse_json_with_comments::(settings_contents) + .map_err(|e| InvalidSettingsError::LocalSettings { path: directory_path.join(local_settings_file_relative_path()), message: e.to_string(), + })?; + match self + .raw_local_settings + .entry((root_id, directory_path.clone())) + { + btree_map::Entry::Vacant(v) => { + v.insert(new_settings); + zed_settings_changed = true; } - })?; - if Some(&new_contents) == raw_local_settings.get(&kind) { - false - } else { - raw_local_settings.insert(kind, new_contents); - true + btree_map::Entry::Occupied(mut o) => { + if o.get() != &new_settings { + o.insert(new_settings); + zed_settings_changed = true; + } + } + } + } + (LocalSettingsKind::Editorconfig, Some(editorconfig_contents)) => { + match self + .raw_editorconfig_settings + .entry((root_id, directory_path.clone())) + { + btree_map::Entry::Vacant(v) => match editorconfig_contents.parse() { + Ok(new_contents) => { + v.insert((editorconfig_contents.to_owned(), Some(new_contents))); + } + Err(e) => { + v.insert((editorconfig_contents.to_owned(), None)); + return Err(InvalidSettingsError::Editorconfig { + message: e.to_string(), + path: directory_path.join(EDITORCONFIG_NAME), + }); + } + }, + btree_map::Entry::Occupied(mut o) => { + if o.get().0 != editorconfig_contents { + match editorconfig_contents.parse() { + Ok(new_contents) => { + o.insert(( + editorconfig_contents.to_owned(), + Some(new_contents), + )); + } + Err(e) => { + o.insert((editorconfig_contents.to_owned(), None)); + return Err(InvalidSettingsError::Editorconfig { + message: e.to_string(), + path: directory_path.join(EDITORCONFIG_NAME), + }); + } + } + } + } + } } - } else { - raw_local_settings.remove(&kind).is_some() }; - if changed { + + if zed_settings_changed { self.recompute_values(Some((root_id, &directory_path)), cx)?; } Ok(()) @@ -605,13 +687,10 @@ impl SettingsStore { cx: &mut AppContext, ) -> Result<()> { let settings: serde_json::Value = serde_json::to_value(content)?; - if settings.is_object() { - self.raw_extension_settings = settings; - self.recompute_values(None, cx)?; - Ok(()) - } else { - Err(anyhow!("settings must be an object")) - } + anyhow::ensure!(settings.is_object(), "settings must be an object"); + self.raw_extension_settings = settings; + self.recompute_values(None, cx)?; + Ok(()) } /// Add or remove a set of local settings via a JSON string. @@ -625,7 +704,7 @@ impl SettingsStore { pub fn local_settings( &self, root_id: WorktreeId, - ) -> impl '_ + Iterator, LocalSettingsKind, String)> { + ) -> impl '_ + Iterator, String)> { self.raw_local_settings .range( (root_id, Path::new("").into()) @@ -634,11 +713,23 @@ impl SettingsStore { Path::new("").into(), ), ) - .flat_map(|((_, path), content)| { - content.iter().filter_map(|(&kind, raw_content)| { - let parsed_content = serde_json::to_string(raw_content).log_err()?; - Some((path.clone(), kind, parsed_content)) - }) + .map(|((_, path), content)| (path.clone(), serde_json::to_string(content).unwrap())) + } + + pub fn local_editorconfig_settings( + &self, + root_id: WorktreeId, + ) -> impl '_ + Iterator, String, Option)> { + self.raw_editorconfig_settings + .range( + (root_id, Path::new("").into()) + ..( + WorktreeId::from_usize(root_id.to_usize() + 1), + Path::new("").into(), + ), + ) + .map(|((_, path), (content, parsed_content))| { + (path.clone(), content.clone(), parsed_content.clone()) }) } @@ -753,7 +844,7 @@ impl SettingsStore { &mut self, changed_local_path: Option<(WorktreeId, &Path)>, cx: &mut AppContext, - ) -> Result<(), InvalidSettingsError> { + ) -> std::result::Result<(), InvalidSettingsError> { // Reload the global and local values for every setting. let mut project_settings_stack = Vec::::new(); let mut paths_stack = Vec::>::new(); @@ -819,69 +910,90 @@ impl SettingsStore { paths_stack.clear(); project_settings_stack.clear(); for ((root_id, directory_path), local_settings) in &self.raw_local_settings { - if let Some(local_settings) = local_settings.get(&LocalSettingsKind::Settings) { - // Build a stack of all of the local values for that setting. - while let Some(prev_entry) = paths_stack.last() { - if let Some((prev_root_id, prev_path)) = prev_entry { - if root_id != prev_root_id || !directory_path.starts_with(prev_path) { - paths_stack.pop(); - project_settings_stack.pop(); - continue; - } + // Build a stack of all of the local values for that setting. + while let Some(prev_entry) = paths_stack.last() { + if let Some((prev_root_id, prev_path)) = prev_entry { + if root_id != prev_root_id || !directory_path.starts_with(prev_path) { + paths_stack.pop(); + project_settings_stack.pop(); + continue; } - break; } + break; + } - match setting_value.deserialize_setting(local_settings) { - Ok(local_settings) => { - paths_stack.push(Some((*root_id, directory_path.as_ref()))); - project_settings_stack.push(local_settings); - - // If a local settings file changed, then avoid recomputing local - // settings for any path outside of that directory. - if changed_local_path.map_or( - false, - |(changed_root_id, changed_local_path)| { - *root_id != changed_root_id - || !directory_path.starts_with(changed_local_path) - }, - ) { - continue; - } - - if let Some(value) = setting_value - .load_setting( - SettingsSources { - default: &default_settings, - extensions: extension_settings.as_ref(), - user: user_settings.as_ref(), - release_channel: release_channel_settings.as_ref(), - server: server_settings.as_ref(), - project: &project_settings_stack.iter().collect::>(), - }, - cx, - ) - .log_err() - { - setting_value.set_local_value( - *root_id, - directory_path.clone(), - value, - ); - } + match setting_value.deserialize_setting(local_settings) { + Ok(local_settings) => { + paths_stack.push(Some((*root_id, directory_path.as_ref()))); + project_settings_stack.push(local_settings); + + // If a local settings file changed, then avoid recomputing local + // settings for any path outside of that directory. + if changed_local_path.map_or( + false, + |(changed_root_id, changed_local_path)| { + *root_id != changed_root_id + || !directory_path.starts_with(changed_local_path) + }, + ) { + continue; } - Err(error) => { - return Err(InvalidSettingsError::LocalSettings { - path: directory_path.join(local_settings_file_relative_path()), - message: error.to_string(), - }); + + if let Some(value) = setting_value + .load_setting( + SettingsSources { + default: &default_settings, + extensions: extension_settings.as_ref(), + user: user_settings.as_ref(), + release_channel: release_channel_settings.as_ref(), + server: server_settings.as_ref(), + project: &project_settings_stack.iter().collect::>(), + }, + cx, + ) + .log_err() + { + setting_value.set_local_value(*root_id, directory_path.clone(), value); } } + Err(error) => { + return Err(InvalidSettingsError::LocalSettings { + path: directory_path.join(local_settings_file_relative_path()), + message: error.to_string(), + }); + } } } } Ok(()) } + + pub fn editorconfg_properties( + &self, + for_worktree: WorktreeId, + for_path: &Path, + ) -> Option { + let mut properties = EditorconfigProperties::new(); + + for (directory_with_config, _, parsed_editorconfig) in + self.local_editorconfig_settings(for_worktree) + { + if !for_path.starts_with(&directory_with_config) { + properties.use_fallbacks(); + return Some(properties); + } + let parsed_editorconfig = parsed_editorconfig?; + if parsed_editorconfig.is_root { + properties = EditorconfigProperties::new(); + } + for section in parsed_editorconfig.sections { + section.apply_to(&mut properties, for_path).log_err()?; + } + } + + properties.use_fallbacks(); + Some(properties) + } } #[derive(Debug, Clone, PartialEq)] @@ -890,6 +1002,8 @@ pub enum InvalidSettingsError { UserSettings { message: String }, ServerSettings { message: String }, DefaultSettings { message: String }, + Editorconfig { path: PathBuf, message: String }, + Tasks { message: String }, } impl std::fmt::Display for InvalidSettingsError { @@ -898,8 +1012,10 @@ impl std::fmt::Display for InvalidSettingsError { InvalidSettingsError::LocalSettings { message, .. } | InvalidSettingsError::UserSettings { message } | InvalidSettingsError::ServerSettings { message } - | InvalidSettingsError::DefaultSettings { message } => { - write!(f, "{}", message) + | InvalidSettingsError::DefaultSettings { message } + | InvalidSettingsError::Tasks { message } + | InvalidSettingsError::Editorconfig { message, .. } => { + write!(f, "{message}") } } } diff --git a/crates/supermaven/src/supermaven_completion_provider.rs b/crates/supermaven/src/supermaven_completion_provider.rs index 2a7fc31c0db8fe..b9185c97627477 100644 --- a/crates/supermaven/src/supermaven_completion_provider.rs +++ b/crates/supermaven/src/supermaven_completion_provider.rs @@ -121,7 +121,7 @@ impl InlineCompletionProvider for SupermavenCompletionProvider { let file = buffer.file(); let language = buffer.language_at(cursor_position); let settings = all_language_settings(file, cx); - settings.inline_completions_enabled(language.as_ref(), file.map(|f| f.path().as_ref())) + settings.inline_completions_enabled(language.as_ref(), file.map(|f| f.path().as_ref()), cx) } fn refresh(