Support .editorconfig (#19455)

Kirill Bulatov and Ulysse Buonomo created

Closes https://github.com/zed-industries/zed/issues/8534
Supersedes https://github.com/zed-industries/zed/pull/16349

Potential concerns:
* we do not follow up to the `/` when looking for `.editorconfig`, only
up to the worktree root.
Seems fine for most of the cases, and the rest should be solved
generically later, as the same issue exists for settings.json
* `fn language` in `AllLanguageSettings` is very hot, called very
frequently during rendering. We accumulate and parse all `.editorconfig`
file contents beforehand, but have to go over globs and match these
against the path given + merge the properties still.
This does not seem to be very bad, but needs more testing and
potentially some extra caching.


Release Notes:

- Added .editorconfig support

---------

Co-authored-by: Ulysse Buonomo <buonomo.ulysse@gmail.com>

Change summary

Cargo.lock                                                      |   8 
Cargo.toml                                                      |   1 
crates/collab/src/rpc.rs                                        |   2 
crates/collab/src/tests/editor_tests.rs                         | 294 ++
crates/collab/src/tests/integration_tests.rs                    |  44 
crates/collab/src/tests/remote_editing_collaboration_tests.rs   |   6 
crates/copilot/src/copilot.rs                                   |   6 
crates/copilot/src/copilot_completion_provider.rs               |   4 
crates/editor/src/display_map.rs                                |   9 
crates/editor/src/editor.rs                                     |  17 
crates/editor/src/indent_guides.rs                              |  10 
crates/extension/src/wasm_host/wit/since_v0_1_0.rs              |   7 
crates/extension/src/wasm_host/wit/since_v0_2_0.rs              |   7 
crates/inline_completion_button/src/inline_completion_button.rs |  14 
crates/language/Cargo.toml                                      |   1 
crates/language/src/buffer.rs                                   |  20 
crates/language/src/language_settings.rs                        | 107 
crates/languages/src/rust.rs                                    |  11 
crates/languages/src/yaml.rs                                    |   2 
crates/multi_buffer/src/multi_buffer.rs                         |   8 
crates/paths/src/paths.rs                                       |   3 
crates/prettier/src/prettier.rs                                 |   2 
crates/project/src/lsp_command.rs                               |   4 
crates/project/src/lsp_store.rs                                 |  61 
crates/project/src/project_settings.rs                          |  26 
crates/project/src/project_tests.rs                             | 137 +
crates/remote_server/src/remote_editing_tests.rs                |  16 
crates/settings/Cargo.toml                                      |   1 
crates/settings/src/settings_store.rs                           | 304 ++
crates/supermaven/src/supermaven_completion_provider.rs         |   2 
30 files changed, 870 insertions(+), 264 deletions(-)

Detailed changes

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",
@@ -10302,6 +10309,7 @@ version = "0.1.0"
 dependencies = [
  "anyhow",
  "collections",
+ "ec4rs",
  "fs",
  "futures 0.3.30",
  "gpui",

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"

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),
                 },
             )?;
         }

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<String> {
     let mut labels = Vec::new();
     for hint in editor.inlay_hint_cache().hints() {

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::<Vec<_>>(),
             &[
-                (
-                    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::<Vec<_>>(),
             &[
-                (
-                    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::<Vec<_>>(),
             &[
-                (
-                    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::<Vec<_>>(),
-            &[(
-                Path::new("a").into(),
-                LocalSettingsKind::Settings,
-                r#"{"hard_tabs":true}"#.to_string()
-            ),]
+            &[(Path::new("a").into(), r#"{"hard_tabs":true}"#.to_string()),]
         )
     });
 }

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;
@@ -135,9 +135,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()]
         )
     });

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

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;

crates/editor/src/display_map.rs 🔗

@@ -423,11 +423,12 @@ impl DisplayMap {
     }
 
     fn tab_size(buffer: &Model<MultiBuffer>, cx: &mut ModelContext<Self>) -> 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)]

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,
         );
@@ -13374,11 +13376,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(

crates/editor/src/indent_guides.rs 🔗

@@ -39,9 +39,13 @@ impl Editor {
     ) -> Option<Vec<MultiBufferIndentGuide>> {
         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
             }

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,
                         })?)

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,
                         })?)

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<dyn Fs>, 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::<AllLanguageSettings>(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::<AllLanguageSettings>(fs, cx, move |file, _| {
         file.languages
             .entry(language.name())

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

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<T: ToOffset>(&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<T: ToOffset>(&self, point: T) -> CharClassifier {
@@ -3529,7 +3538,8 @@ impl BufferSnapshot {
         ignore_disabled_for_language: bool,
         cx: &AppContext,
     ) -> Vec<IndentGuide> {
-        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();

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<Language>>,
-    file: Option<&Arc<dyn File>>,
+    language: Option<LanguageName>,
+    file: Option<&'a Arc<dyn File>>,
     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<dyn File>>,
+    file: Option<&'a Arc<dyn File>>,
     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<SettingsLocation<'a>>,
+        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::<SettingsStore>()
+                .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<Language>>,
         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::<MaxLineLen>().ok().and_then(|v| match v {
+        MaxLineLen::Value(u) => Some(u as u32),
+        MaxLineLen::Off => None,
+    });
+    let tab_size = cfg.get::<IndentSize>().ok().and_then(|v| match v {
+        IndentSize::Value(u) => NonZeroU32::new(u as u32),
+        IndentSize::UseTabWidth => cfg.get::<TabWidth>().ok().and_then(|w| match w {
+            TabWidth::Value(u) => NonZeroU32::new(u as u32),
+        }),
+    });
+    let hard_tabs = cfg
+        .get::<IndentStyle>()
+        .map(|v| v.eq(&IndentStyle::Tabs))
+        .ok();
+    let ensure_final_newline_on_save = cfg
+        .get::<FinalNewline>()
+        .map(|v| match v {
+            FinalNewline::Value(b) => b,
+        })
+        .ok();
+    let remove_trailing_whitespace_on_save = cfg
+        .get::<TrimTrailingWs>()
+        .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<T>(target: &mut T, value: Option<T>) {
+        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 {

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<TaskTemplates> {
         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()]
         };

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}});

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<Buffer>)) {
@@ -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<T: ToOffset>(&self, point: T) -> Option<LanguageScope> {

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";

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,

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 {

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<Self>,
     ) -> Task<Result<Option<Transaction>>> {
         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<Worktree>,
-        language: &LanguageName,
-        cx: &'a mut ModelContext<Self>,
-    ) -> &'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<Worktree>,
         language: LanguageName,
         cx: &mut ModelContext<Self>,
     ) {
-        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;
         }

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::<SettingsStore>();
         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;
             };

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);

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()]
         )
     });

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

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<serde_json::Value>,
     raw_extension_settings: serde_json::Value,
-    raw_local_settings:
-        BTreeMap<(WorktreeId, Arc<Path>), HashMap<LocalSettingsKind, serde_json::Value>>,
+    raw_local_settings: BTreeMap<(WorktreeId, Arc<Path>), serde_json::Value>,
+    raw_editorconfig_settings: BTreeMap<(WorktreeId, Arc<Path>), (String, Option<Editorconfig>)>,
     tab_size_callback: Option<(
         TypeId,
         Box<dyn Fn(&dyn Any) -> Option<usize> + 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<Self, Self::Err> {
+        let parser = ConfigParser::new_buffered(contents.as_bytes())
+            .context("creating editorconfig parser")?;
+        let is_root = parser.is_root;
+        let sections = parser
+            .collect::<Result<SmallVec<_>, _>>()
+            .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::<serde_json::Value>(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<Item = (Arc<Path>, LocalSettingsKind, String)> {
+    ) -> impl '_ + Iterator<Item = (Arc<Path>, 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<Item = (Arc<Path>, String, Option<Editorconfig>)> {
+        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::<DeserializedSetting>::new();
         let mut paths_stack = Vec::<Option<(WorktreeId, &Path)>>::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::<Vec<_>>(),
-                                    },
-                                    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::<Vec<_>>(),
+                                },
+                                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<EditorconfigProperties> {
+        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}")
             }
         }
     }

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(