From dcfe81f8cc056785e59c2d1f39e91de19177c879 Mon Sep 17 00:00:00 2001 From: Smit Barmase Date: Mon, 19 Jan 2026 20:32:32 +0530 Subject: [PATCH] Support external `.editorconfig` (#46332) Closes #41832 Extends https://github.com/zed-industries/zed/pull/19455 When an internal `.editorconfig` is detected in the worktree, we traverse parent directories up to the filesystem root looking for additional `.editorconfig` files. All discovered external configs are loaded and cached (shared when multiple worktrees reference the same parent directories). When computing settings for a file, external configs are applied first (from furthest to closest), then internal configs. For local projects, file watchers are set up for each external config so changes are applied immediately. When a project is shared via collab, external configs are sent to guests through the existing `UpdateWorktreeSettings` proto message (with a new `outside_worktree` field). SSH remoting works similarly. Limitations: We don't currently take creation of new external editor config files into account since they are loaded once on worktree add. Release Notes: - Added support for `.editorconfig` files outside the project directory. Zed now traverses parent directories to find and apply EditorConfig settings. Use `root = true` in any `.editorconfig` to stop inheriting settings from parent directories. --- .../20221109000000_test_schema.sql | 1 + .../migrations/20251208000000_test_schema.sql | 3 +- crates/collab/src/db.rs | 1 + crates/collab/src/db/queries/projects.rs | 7 +- crates/collab/src/db/queries/rooms.rs | 1 + .../src/db/tables/worktree_settings_file.rs | 1 + crates/collab/src/rpc.rs | 2 + crates/collab/src/tests/editor_tests.rs | 107 +++- crates/language/src/language_settings.rs | 4 +- crates/project/src/project_settings.rs | 149 ++++- crates/project/src/project_tests.rs | 575 +++++++++++++++++- crates/proto/proto/worktree.proto | 1 + crates/settings/src/editorconfig_store.rs | 385 ++++++++++++ crates/settings/src/settings.rs | 10 +- crates/settings/src/settings_store.rs | 296 ++++----- 15 files changed, 1334 insertions(+), 209 deletions(-) create mode 100644 crates/settings/src/editorconfig_store.rs diff --git a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql index dae3f47c71bd317be55dd17801cb9ff37a5bdecf..da859425a2e9b4e06865e22cbce611ccd6a608f7 100644 --- a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql +++ b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql @@ -146,6 +146,7 @@ CREATE TABLE "worktree_settings_files" ( "path" VARCHAR NOT NULL, "content" TEXT, "kind" VARCHAR, + "outside_worktree" BOOL NOT NULL DEFAULT FALSE, PRIMARY KEY (project_id, worktree_id, path), FOREIGN KEY (project_id, worktree_id) REFERENCES worktrees (project_id, id) ON DELETE CASCADE ); diff --git a/crates/collab/migrations/20251208000000_test_schema.sql b/crates/collab/migrations/20251208000000_test_schema.sql index 6edfe1037ba375684ba064cbf4ddea778c3d0040..cff1876567cd087f8e305dfbad02241082fd1224 100644 --- a/crates/collab/migrations/20251208000000_test_schema.sql +++ b/crates/collab/migrations/20251208000000_test_schema.sql @@ -503,7 +503,8 @@ CREATE TABLE public.worktree_settings_files ( worktree_id bigint NOT NULL, path character varying NOT NULL, content text NOT NULL, - kind character varying + kind character varying, + outside_worktree boolean DEFAULT false NOT NULL ); CREATE TABLE public.worktrees ( diff --git a/crates/collab/src/db.rs b/crates/collab/src/db.rs index 3e5b6eb04dd42f2f5981694efec71d409883d571..a36e54b82d96657e9f5c41550555c98d6ca1692b 100644 --- a/crates/collab/src/db.rs +++ b/crates/collab/src/db.rs @@ -649,6 +649,7 @@ pub struct WorktreeSettingsFile { pub path: String, pub content: String, pub kind: LocalSettingsKind, + pub outside_worktree: bool, } pub struct NewExtensionVersion { diff --git a/crates/collab/src/db/queries/projects.rs b/crates/collab/src/db/queries/projects.rs index 6f1d8b884d15041eadaa9073a5bd99e5ed352502..ed6325c62173358c8deac2dcd6289ce0b8ae5e71 100644 --- a/crates/collab/src/db/queries/projects.rs +++ b/crates/collab/src/db/queries/projects.rs @@ -760,6 +760,7 @@ impl Database { path: ActiveValue::Set(update.path.clone()), content: ActiveValue::Set(content.clone()), kind: ActiveValue::Set(kind), + outside_worktree: ActiveValue::Set(update.outside_worktree.unwrap_or(false)), }) .on_conflict( OnConflict::columns([ @@ -767,7 +768,10 @@ impl Database { worktree_settings_file::Column::WorktreeId, worktree_settings_file::Column::Path, ]) - .update_column(worktree_settings_file::Column::Content) + .update_columns([ + worktree_settings_file::Column::Content, + worktree_settings_file::Column::OutsideWorktree, + ]) .to_owned(), ) .exec(&*tx) @@ -1050,6 +1054,7 @@ impl Database { path: db_settings_file.path, content: db_settings_file.content, kind: db_settings_file.kind, + outside_worktree: db_settings_file.outside_worktree, }); } } diff --git a/crates/collab/src/db/queries/rooms.rs b/crates/collab/src/db/queries/rooms.rs index eafb5cac44a510bf4ced0434a9b4adfadff0ebbc..d8fca0306f5b2ae5668a735db578061275192b58 100644 --- a/crates/collab/src/db/queries/rooms.rs +++ b/crates/collab/src/db/queries/rooms.rs @@ -834,6 +834,7 @@ impl Database { path: db_settings_file.path, content: db_settings_file.content, kind: db_settings_file.kind, + outside_worktree: db_settings_file.outside_worktree, }); } } diff --git a/crates/collab/src/db/tables/worktree_settings_file.rs b/crates/collab/src/db/tables/worktree_settings_file.rs index bed2de55efe7ffd7962875f2f05175c9d4f122ef..cfd792a808de3f9840e7177621f13acce19db0ee 100644 --- a/crates/collab/src/db/tables/worktree_settings_file.rs +++ b/crates/collab/src/db/tables/worktree_settings_file.rs @@ -12,6 +12,7 @@ pub struct Model { pub path: String, pub content: String, pub kind: LocalSettingsKind, + pub outside_worktree: bool, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index a74a36fc38738d7e15c2adfb7465165c3112b69e..3b665fdd23205c08a98f01e649708c00cc68175a 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -1555,6 +1555,7 @@ fn notify_rejoined_projects( path: settings_file.path, content: Some(settings_file.content), kind: Some(settings_file.kind.to_proto().into()), + outside_worktree: Some(settings_file.outside_worktree), }, )?; } @@ -1987,6 +1988,7 @@ async fn join_project( path: settings_file.path, content: Some(settings_file.content), kind: Some(settings_file.kind.to_proto() as i32), + outside_worktree: Some(settings_file.outside_worktree), }, )?; } diff --git a/crates/collab/src/tests/editor_tests.rs b/crates/collab/src/tests/editor_tests.rs index e56058d1281acb2e41da09adf6b9d6014f7f526e..ca444024820be6b5b3a4165999d78ea9b642cc5e 100644 --- a/crates/collab/src/tests/editor_tests.rs +++ b/crates/collab/src/tests/editor_tests.rs @@ -21,7 +21,7 @@ use gpui::{ App, Rgba, SharedString, TestAppContext, UpdateGlobal, VisualContext, VisualTestContext, }; use indoc::indoc; -use language::{FakeLspAdapter, rust_lang}; +use language::{FakeLspAdapter, language_settings::language_settings, rust_lang}; use lsp::LSP_REQUEST_TIMEOUT; use pretty_assertions::assert_eq; use project::{ @@ -35,6 +35,7 @@ use serde_json::json; use settings::{InlayHintSettingsContent, InlineBlameSettings, SettingsStore}; use std::{ collections::BTreeSet, + num::NonZeroU32, ops::{Deref as _, Range}, path::{Path, PathBuf}, sync::{ @@ -3978,6 +3979,110 @@ fn main() { let foo = other::foo(); }"}; ); } +#[gpui::test(iterations = 10)] +async fn test_collaborating_with_external_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); + + client_a.language_registry().add(rust_lang()); + client_b.language_registry().add(rust_lang()); + + // Set up external .editorconfig in parent directory + client_a + .fs() + .insert_tree( + path!("/parent"), + json!({ + ".editorconfig": "[*]\nindent_size = 5\n", + "worktree": { + ".editorconfig": "[*]\n", + "src": { + "main.rs": "fn main() {}", + }, + }, + }), + ) + .await; + + let (project_a, worktree_id) = client_a + .build_local_project(path!("/parent/worktree"), cx_a) + .await; + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + + // Open buffer on client A + let buffer_a = project_a + .update(cx_a, |p, cx| { + p.open_buffer((worktree_id, rel_path("src/main.rs")), cx) + }) + .await + .unwrap(); + + cx_a.run_until_parked(); + + // Verify client A sees external editorconfig settings + cx_a.read(|cx| { + let file = buffer_a.read(cx).file(); + let settings = language_settings(Some("Rust".into()), file, cx); + assert_eq!(Some(settings.tab_size), NonZeroU32::new(5)); + }); + + // Client B joins the project + let project_b = client_b.join_remote_project(project_id, cx_b).await; + let buffer_b = project_b + .update(cx_b, |p, cx| { + p.open_buffer((worktree_id, rel_path("src/main.rs")), cx) + }) + .await + .unwrap(); + + cx_b.run_until_parked(); + + // Verify client B also sees external editorconfig settings + cx_b.read(|cx| { + let file = buffer_b.read(cx).file(); + let settings = language_settings(Some("Rust".into()), file, cx); + assert_eq!(Some(settings.tab_size), NonZeroU32::new(5)); + }); + + // Client A modifies the external .editorconfig + client_a + .fs() + .atomic_write( + PathBuf::from(path!("/parent/.editorconfig")), + "[*]\nindent_size = 9\n".to_owned(), + ) + .await + .unwrap(); + + cx_a.run_until_parked(); + cx_b.run_until_parked(); + + // Verify client A sees updated settings + cx_a.read(|cx| { + let file = buffer_a.read(cx).file(); + let settings = language_settings(Some("Rust".into()), file, cx); + assert_eq!(Some(settings.tab_size), NonZeroU32::new(9)); + }); + + // Verify client B also sees updated settings + cx_b.read(|cx| { + let file = buffer_b.read(cx).file(); + let settings = language_settings(Some("Rust".into()), file, cx); + assert_eq!(Some(settings.tab_size), NonZeroU32::new(9)); + }); +} + #[gpui::test] async fn test_add_breakpoints(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { let executor = cx_a.executor(); diff --git a/crates/language/src/language_settings.rs b/crates/language/src/language_settings.rs index d796296c5ea9c04212eb9adfa9de28261faef701..909eeee59f4dc0f630166a2b8af529ce6fee2d71 100644 --- a/crates/language/src/language_settings.rs +++ b/crates/language/src/language_settings.rs @@ -451,7 +451,9 @@ impl AllLanguageSettings { let editorconfig_properties = location.and_then(|location| { cx.global::() - .editorconfig_properties(location.worktree_id, location.path) + .editorconfig_store + .read(cx) + .properties(location.worktree_id, location.path) }); if let Some(editorconfig_properties) = editorconfig_properties { let mut settings = settings.clone(); diff --git a/crates/project/src/project_settings.rs b/crates/project/src/project_settings.rs index 50fe994f20fda651fbeb4e3e3c14484bef9b511a..6c514bf56fb934b836070d073ef1b6371fffdb1b 100644 --- a/crates/project/src/project_settings.rs +++ b/crates/project/src/project_settings.rs @@ -20,8 +20,9 @@ use serde::{Deserialize, Serialize}; pub use settings::DirenvSettings; pub use settings::LspSettings; use settings::{ - DapSettingsContent, InvalidSettingsError, LocalSettingsKind, RegisterSetting, Settings, - SettingsLocation, SettingsStore, parse_json_with_comments, watch_config_file, + DapSettingsContent, EditorconfigEvent, InvalidSettingsError, LocalSettingsKind, + LocalSettingsPath, RegisterSetting, Settings, SettingsLocation, SettingsStore, + parse_json_with_comments, watch_config_file, }; use std::{cell::OnceCell, collections::BTreeMap, path::PathBuf, sync::Arc, time::Duration}; use task::{DebugTaskFile, TaskTemplates, VsCodeDebugTaskFile, VsCodeTaskFile}; @@ -670,6 +671,7 @@ pub struct SettingsObserver { HashMap), Option>>, _trusted_worktrees_watcher: Option, _user_settings_watcher: Option, + _editorconfig_watcher: Option, _global_task_config_watcher: Task<()>, _global_debug_config_watcher: Task<()>, } @@ -708,9 +710,11 @@ impl SettingsObserver { for ((worktree_id, directory_path), settings_contents) in pending_local_settings { + let path = + LocalSettingsPath::InWorktree(directory_path.clone()); apply_local_settings( worktree_id, - &directory_path, + path.clone(), LocalSettingsKind::Settings, &settings_contents, cx, @@ -722,7 +726,7 @@ impl SettingsObserver { .send(proto::UpdateWorktreeSettings { project_id: settings_observer.project_id, worktree_id: worktree_id.to_proto(), - path: directory_path.to_proto(), + path: path.to_proto(), content: settings_contents, kind: Some( local_settings_kind_to_proto( @@ -730,6 +734,7 @@ impl SettingsObserver { ) .into(), ), + outside_worktree: Some(false), }) .log_err(); } @@ -742,6 +747,36 @@ impl SettingsObserver { ) }); + let editorconfig_store = cx.global::().editorconfig_store.clone(); + let _editorconfig_watcher = cx.subscribe( + &editorconfig_store, + |this, _, event: &EditorconfigEvent, cx| { + let EditorconfigEvent::ExternalConfigChanged { + path, + content, + affected_worktree_ids, + } = event; + for worktree_id in affected_worktree_ids { + if let Some(worktree) = this + .worktree_store + .read(cx) + .worktree_for_id(*worktree_id, cx) + { + this.update_settings( + worktree, + [( + path.clone(), + LocalSettingsKind::Editorconfig, + content.clone(), + )], + false, + cx, + ); + } + } + }, + ); + Self { worktree_store, task_store, @@ -750,6 +785,7 @@ impl SettingsObserver { _trusted_worktrees_watcher, pending_local_settings: HashMap::default(), _user_settings_watcher: None, + _editorconfig_watcher: Some(_editorconfig_watcher), project_id: REMOTE_SERVER_PROJECT_ID, _global_task_config_watcher: Self::subscribe_to_global_task_file_changes( fs.clone(), @@ -805,6 +841,7 @@ impl SettingsObserver { _trusted_worktrees_watcher: None, pending_local_settings: HashMap::default(), _user_settings_watcher: user_settings_watcher, + _editorconfig_watcher: None, _global_task_config_watcher: Self::subscribe_to_global_task_file_changes( fs.clone(), paths::tasks_file().clone(), @@ -841,19 +878,25 @@ impl SettingsObserver { kind: Some( local_settings_kind_to_proto(LocalSettingsKind::Settings).into(), ), + outside_worktree: Some(false), }) .log_err(); } - for (path, content, _) in store.local_editorconfig_settings(worktree.read(cx).id()) { + for (path, content, _) in store + .editorconfig_store + .read(cx) + .local_editorconfig_settings(worktree.read(cx).id()) + { downstream_client .send(proto::UpdateWorktreeSettings { project_id, worktree_id, path: path.to_proto(), - content: Some(content), + content: Some(content.to_owned()), kind: Some( local_settings_kind_to_proto(LocalSettingsKind::Editorconfig).into(), ), + outside_worktree: Some(path.is_outside_worktree()), }) .log_err(); } @@ -874,7 +917,12 @@ impl SettingsObserver { .with_context(|| format!("unknown kind {kind}"))?, None => proto::LocalSettingsKind::Settings, }; - let path = RelPath::from_proto(&envelope.payload.path)?; + + let path = LocalSettingsPath::from_proto( + &envelope.payload.path, + envelope.payload.outside_worktree.unwrap_or(false), + )?; + this.update(&mut cx, |this, cx| { let is_via_collab = match &this.mode { SettingsObserverMode::Local(..) => false, @@ -1012,6 +1060,23 @@ impl SettingsObserver { let Some(settings_dir) = path.parent().map(Arc::from) else { continue; }; + if matches!(change, PathChange::Loaded) || matches!(change, PathChange::Added) { + let worktree_id = worktree.read(cx).id(); + let worktree_path = worktree.read(cx).abs_path(); + let fs = fs.clone(); + cx.update_global::(|store, cx| { + store + .editorconfig_store + .update(cx, |editorconfig_store, cx| { + editorconfig_store.discover_local_external_configs_chain( + worktree_id, + worktree_path, + fs, + cx, + ); + }); + }); + } (settings_dir, LocalSettingsKind::Editorconfig) } else { continue; @@ -1088,7 +1153,11 @@ impl SettingsObserver { this.update_settings( worktree, settings_contents.into_iter().map(|(path, kind, content)| { - (path, kind, content.and_then(|c| c.log_err())) + ( + LocalSettingsPath::InWorktree(path), + kind, + content.and_then(|c| c.log_err()), + ) }), false, cx, @@ -1102,7 +1171,9 @@ impl SettingsObserver { fn update_settings( &mut self, worktree: Entity, - settings_contents: impl IntoIterator, LocalSettingsKind, Option)>, + settings_contents: impl IntoIterator< + Item = (LocalSettingsPath, LocalSettingsKind, Option), + >, is_via_collab: bool, cx: &mut Context, ) { @@ -1114,10 +1185,10 @@ impl SettingsObserver { } else { OnceCell::new() }; - for (directory, kind, file_content) in settings_contents { + for (directory_path, kind, file_content) in settings_contents { let mut applied = true; - match kind { - LocalSettingsKind::Settings => { + match (&directory_path, kind) { + (LocalSettingsPath::InWorktree(directory), LocalSettingsKind::Settings) => { if *can_trust_worktree.get_or_init(|| { if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) { trusted_worktrees.update(cx, |trusted_worktrees, cx| { @@ -1127,7 +1198,13 @@ impl SettingsObserver { true } }) { - apply_local_settings(worktree_id, &directory, kind, &file_content, cx) + apply_local_settings( + worktree_id, + LocalSettingsPath::InWorktree(directory.clone()), + kind, + &file_content, + cx, + ) } else { applied = false; self.pending_local_settings @@ -1136,10 +1213,7 @@ impl SettingsObserver { .insert((worktree_id, directory.clone()), file_content.clone()); } } - LocalSettingsKind::Editorconfig => { - apply_local_settings(worktree_id, &directory, kind, &file_content, cx) - } - LocalSettingsKind::Tasks => { + (LocalSettingsPath::InWorktree(directory), LocalSettingsKind::Tasks) => { let result = task_store.update(cx, |task_store, cx| { task_store.update_user_tasks( TaskSettingsLocation::Worktree(SettingsLocation { @@ -1168,7 +1242,7 @@ impl SettingsObserver { } } } - LocalSettingsKind::Debug => { + (LocalSettingsPath::InWorktree(directory), LocalSettingsKind::Debug) => { let result = task_store.update(cx, |task_store, cx| { task_store.update_user_debug_scenarios( TaskSettingsLocation::Worktree(SettingsLocation { @@ -1199,6 +1273,17 @@ impl SettingsObserver { } } } + (directory, LocalSettingsKind::Editorconfig) => { + apply_local_settings(worktree_id, directory.clone(), kind, &file_content, cx); + } + (LocalSettingsPath::OutsideWorktree(path), kind) => { + log::error!( + "OutsideWorktree path {:?} with kind {:?} is only supported by editorconfig", + path, + kind + ); + continue; + } }; if applied { @@ -1207,9 +1292,10 @@ impl SettingsObserver { .send(proto::UpdateWorktreeSettings { project_id: self.project_id, worktree_id: remote_worktree_id.to_proto(), - path: directory.to_proto(), + path: directory_path.to_proto(), content: file_content.clone(), kind: Some(local_settings_kind_to_proto(kind).into()), + outside_worktree: Some(directory_path.is_outside_worktree()), }) .log_err(); } @@ -1323,19 +1409,14 @@ impl SettingsObserver { fn apply_local_settings( worktree_id: WorktreeId, - directory: &Arc, + path: LocalSettingsPath, kind: LocalSettingsKind, file_content: &Option, cx: &mut Context<'_, SettingsObserver>, ) { cx.update_global::(|store, cx| { - let result = store.set_local_settings( - worktree_id, - directory.clone(), - kind, - file_content.as_deref(), - cx, - ); + let result = + store.set_local_settings(worktree_id, path.clone(), kind, file_content.as_deref(), cx); match result { Err(InvalidSettingsError::LocalSettings { path, message }) => { @@ -1345,9 +1426,17 @@ fn apply_local_settings( ))); } Err(e) => log::error!("Failed to set local settings: {e}"), - Ok(()) => cx.emit(SettingsObserverEvent::LocalSettingsUpdated(Ok(directory - .as_std_path() - .join(local_settings_file_relative_path().as_std_path())))), + Ok(()) => { + let settings_path = match &path { + LocalSettingsPath::InWorktree(rel_path) => rel_path + .as_std_path() + .join(local_settings_file_relative_path().as_std_path()), + LocalSettingsPath::OutsideWorktree(abs_path) => abs_path.to_path_buf(), + }; + cx.emit(SettingsObserverEvent::LocalSettingsUpdated(Ok( + settings_path, + ))) + } } }) } diff --git a/crates/project/src/project_tests.rs b/crates/project/src/project_tests.rs index ac828549f0e4e40142d25365a218abb4b7fd0665..db6140f3d24a7a068cc0f657e3248a7b30ec99c9 100644 --- a/crates/project/src/project_tests.rs +++ b/crates/project/src/project_tests.rs @@ -27,7 +27,7 @@ use language::{ ManifestName, ManifestProvider, ManifestQuery, OffsetRangeExt, Point, ToPoint, ToolchainList, ToolchainLister, language_settings::{LanguageSettingsContent, language_settings}, - rust_lang, tree_sitter_typescript, + markdown_lang, rust_lang, tree_sitter_typescript, }; use lsp::{ DiagnosticSeverity, DocumentChanges, FileOperationFilter, NumberOrString, TextDocumentEdit, @@ -244,6 +244,579 @@ async fn test_editorconfig_support(cx: &mut gpui::TestAppContext) { }); } +#[gpui::test] +async fn test_external_editorconfig_support(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/grandparent"), + json!({ + ".editorconfig": "[*]\nindent_size = 4\n", + "parent": { + ".editorconfig": "[*.rs]\nindent_size = 2\n", + "worktree": { + ".editorconfig": "[*.md]\nindent_size = 3\n", + "main.rs": "fn main() {}", + "README.md": "# README", + "other.txt": "other content", + } + } + }), + ) + .await; + + let project = Project::test(fs, [path!("/grandparent/parent/worktree").as_ref()], cx).await; + + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + language_registry.add(markdown_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(rel_path(path)).unwrap().clone(); + let file = File::for_entry(file_entry, worktree.clone()); + let file_language = project + .read(cx) + .languages() + .load_language_for_file_path(file.path.as_std_path()); + let file_language = cx + .foreground_executor() + .block_on(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_rs = settings_for("main.rs"); + let settings_md = settings_for("README.md"); + let settings_txt = settings_for("other.txt"); + + // main.rs gets indent_size = 2 from parent's external .editorconfig + assert_eq!(Some(settings_rs.tab_size), NonZeroU32::new(2)); + + // README.md gets indent_size = 3 from internal worktree .editorconfig + assert_eq!(Some(settings_md.tab_size), NonZeroU32::new(3)); + + // other.txt gets indent_size = 4 from grandparent's external .editorconfig + assert_eq!(Some(settings_txt.tab_size), NonZeroU32::new(4)); + }); +} + +#[gpui::test] +async fn test_external_editorconfig_root_stops_traversal(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/parent"), + json!({ + ".editorconfig": "[*]\nindent_size = 99\n", + "worktree": { + ".editorconfig": "root = true\n[*]\nindent_size = 2\n", + "file.rs": "fn main() {}", + } + }), + ) + .await; + + let project = Project::test(fs, [path!("/parent/worktree").as_ref()], cx).await; + + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + 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 file_entry = tree.entry_for_path(rel_path("file.rs")).unwrap().clone(); + let file = File::for_entry(file_entry, worktree.clone()); + let file_language = project + .read(cx) + .languages() + .load_language_for_file_path(file.path.as_std_path()); + let file_language = cx + .foreground_executor() + .block_on(file_language) + .expect("Failed to get file language"); + let file = file as _; + let settings = language_settings(Some(file_language.name()), Some(&file), cx).into_owned(); + + // file.rs gets indent_size = 2 from worktree's root config, NOT 99 from parent + assert_eq!(Some(settings.tab_size), NonZeroU32::new(2)); + }); +} + +#[gpui::test] +async fn test_external_editorconfig_root_in_parent_stops_traversal(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/grandparent"), + json!({ + ".editorconfig": "[*]\nindent_size = 99\n", + "parent": { + ".editorconfig": "root = true\n[*]\nindent_size = 4\n", + "worktree": { + "file.rs": "fn main() {}", + } + } + }), + ) + .await; + + let project = Project::test(fs, [path!("/grandparent/parent/worktree").as_ref()], cx).await; + + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + 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 file_entry = tree.entry_for_path(rel_path("file.rs")).unwrap().clone(); + let file = File::for_entry(file_entry, worktree.clone()); + let file_language = project + .read(cx) + .languages() + .load_language_for_file_path(file.path.as_std_path()); + let file_language = cx + .foreground_executor() + .block_on(file_language) + .expect("Failed to get file language"); + let file = file as _; + let settings = language_settings(Some(file_language.name()), Some(&file), cx).into_owned(); + + // file.rs gets indent_size = 4 from parent's root config, NOT 99 from grandparent + assert_eq!(Some(settings.tab_size), NonZeroU32::new(4)); + }); +} + +#[gpui::test] +async fn test_external_editorconfig_shared_across_worktrees(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/parent"), + json!({ + ".editorconfig": "root = true\n[*]\nindent_size = 5\n", + "worktree_a": { + "file.rs": "fn a() {}", + ".editorconfig": "[*]\ninsert_final_newline = true\n", + }, + "worktree_b": { + "file.rs": "fn b() {}", + ".editorconfig": "[*]\ninsert_final_newline = false\n", + } + }), + ) + .await; + + let project = Project::test( + fs, + [ + path!("/parent/worktree_a").as_ref(), + path!("/parent/worktree_b").as_ref(), + ], + cx, + ) + .await; + + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + + cx.executor().run_until_parked(); + + cx.update(|cx| { + let worktrees: Vec<_> = project.read(cx).worktrees(cx).collect(); + assert_eq!(worktrees.len(), 2); + + for worktree in worktrees { + let tree = worktree.read(cx); + let file_entry = tree.entry_for_path(rel_path("file.rs")).unwrap().clone(); + let file = File::for_entry(file_entry, worktree.clone()); + let file_language = project + .read(cx) + .languages() + .load_language_for_file_path(file.path.as_std_path()); + let file_language = cx + .foreground_executor() + .block_on(file_language) + .expect("Failed to get file language"); + let file = file as _; + let settings = + language_settings(Some(file_language.name()), Some(&file), cx).into_owned(); + + // Both worktrees should get indent_size = 5 from shared parent .editorconfig + assert_eq!(Some(settings.tab_size), NonZeroU32::new(5)); + } + }); +} + +#[gpui::test] +async fn test_external_editorconfig_not_loaded_without_internal_config( + cx: &mut gpui::TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/parent"), + json!({ + ".editorconfig": "[*]\nindent_size = 99\n", + "worktree": { + "file.rs": "fn main() {}", + } + }), + ) + .await; + + let project = Project::test(fs, [path!("/parent/worktree").as_ref()], cx).await; + + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + 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 file_entry = tree.entry_for_path(rel_path("file.rs")).unwrap().clone(); + let file = File::for_entry(file_entry, worktree.clone()); + let file_language = project + .read(cx) + .languages() + .load_language_for_file_path(file.path.as_std_path()); + let file_language = cx + .foreground_executor() + .block_on(file_language) + .expect("Failed to get file language"); + let file = file as _; + let settings = language_settings(Some(file_language.name()), Some(&file), cx).into_owned(); + + // file.rs should have default tab_size = 4, NOT 99 from parent's external .editorconfig + // because without an internal .editorconfig, external configs are not loaded + assert_eq!(Some(settings.tab_size), NonZeroU32::new(4)); + }); +} + +#[gpui::test] +async fn test_external_editorconfig_modification_triggers_refresh(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/parent"), + json!({ + ".editorconfig": "[*]\nindent_size = 4\n", + "worktree": { + ".editorconfig": "[*]\n", + "file.rs": "fn main() {}", + } + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/parent/worktree").as_ref()], cx).await; + + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + 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 file_entry = tree.entry_for_path(rel_path("file.rs")).unwrap().clone(); + let file = File::for_entry(file_entry, worktree.clone()); + let file_language = project + .read(cx) + .languages() + .load_language_for_file_path(file.path.as_std_path()); + let file_language = cx + .foreground_executor() + .block_on(file_language) + .expect("Failed to get file language"); + let file = file as _; + let settings = language_settings(Some(file_language.name()), Some(&file), cx).into_owned(); + + // Test initial settings: tab_size = 4 from parent's external .editorconfig + assert_eq!(Some(settings.tab_size), NonZeroU32::new(4)); + }); + + fs.atomic_write( + PathBuf::from(path!("/parent/.editorconfig")), + "[*]\nindent_size = 8\n".to_owned(), + ) + .await + .unwrap(); + + cx.executor().run_until_parked(); + + cx.update(|cx| { + let tree = worktree.read(cx); + let file_entry = tree.entry_for_path(rel_path("file.rs")).unwrap().clone(); + let file = File::for_entry(file_entry, worktree.clone()); + let file_language = project + .read(cx) + .languages() + .load_language_for_file_path(file.path.as_std_path()); + let file_language = cx + .foreground_executor() + .block_on(file_language) + .expect("Failed to get file language"); + let file = file as _; + let settings = language_settings(Some(file_language.name()), Some(&file), cx).into_owned(); + + // Test settings updated: tab_size = 8 + assert_eq!(Some(settings.tab_size), NonZeroU32::new(8)); + }); +} + +#[gpui::test] +async fn test_adding_worktree_discovers_external_editorconfigs(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/parent"), + json!({ + ".editorconfig": "root = true\n[*]\nindent_size = 7\n", + "existing_worktree": { + ".editorconfig": "[*]\n", + "file.rs": "fn a() {}", + }, + "new_worktree": { + ".editorconfig": "[*]\n", + "file.rs": "fn b() {}", + } + }), + ) + .await; + + let project = Project::test(fs, [path!("/parent/existing_worktree").as_ref()], cx).await; + + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + + cx.executor().run_until_parked(); + + cx.update(|cx| { + let worktree = project.read(cx).worktrees(cx).next().unwrap(); + let tree = worktree.read(cx); + let file_entry = tree.entry_for_path(rel_path("file.rs")).unwrap().clone(); + let file = File::for_entry(file_entry, worktree.clone()); + let file_language = project + .read(cx) + .languages() + .load_language_for_file_path(file.path.as_std_path()); + let file_language = cx + .foreground_executor() + .block_on(file_language) + .expect("Failed to get file language"); + let file = file as _; + let settings = language_settings(Some(file_language.name()), Some(&file), cx).into_owned(); + + // Test existing worktree has tab_size = 7 + assert_eq!(Some(settings.tab_size), NonZeroU32::new(7)); + }); + + let (new_worktree, _) = project + .update(cx, |project, cx| { + project.find_or_create_worktree(path!("/parent/new_worktree"), true, cx) + }) + .await + .unwrap(); + + cx.executor().run_until_parked(); + + cx.update(|cx| { + let tree = new_worktree.read(cx); + let file_entry = tree.entry_for_path(rel_path("file.rs")).unwrap().clone(); + let file = File::for_entry(file_entry, new_worktree.clone()); + let file_language = project + .read(cx) + .languages() + .load_language_for_file_path(file.path.as_std_path()); + let file_language = cx + .foreground_executor() + .block_on(file_language) + .expect("Failed to get file language"); + let file = file as _; + let settings = language_settings(Some(file_language.name()), Some(&file), cx).into_owned(); + + // Verify new worktree also has tab_size = 7 from shared parent editorconfig + assert_eq!(Some(settings.tab_size), NonZeroU32::new(7)); + }); +} + +#[gpui::test] +async fn test_removing_worktree_cleans_up_external_editorconfig(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/parent"), + json!({ + ".editorconfig": "[*]\nindent_size = 6\n", + "worktree": { + ".editorconfig": "[*]\n", + "file.rs": "fn main() {}", + } + }), + ) + .await; + + let project = Project::test(fs, [path!("/parent/worktree").as_ref()], cx).await; + + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + + let worktree = project.update(cx, |project, cx| project.worktrees(cx).next().unwrap()); + let worktree_id = worktree.read_with(cx, |tree, _| tree.id()); + + cx.executor().run_until_parked(); + + cx.update(|cx| { + let store = cx.global::(); + let (worktree_ids, external_paths, watcher_paths) = + store.editorconfig_store.read(cx).test_state(); + + // Test external config is loaded + assert!(worktree_ids.contains(&worktree_id)); + assert!(!external_paths.is_empty()); + assert!(!watcher_paths.is_empty()); + }); + + project.update(cx, |project, cx| { + project.remove_worktree(worktree_id, cx); + }); + + cx.executor().run_until_parked(); + + cx.update(|cx| { + let store = cx.global::(); + let (worktree_ids, external_paths, watcher_paths) = + store.editorconfig_store.read(cx).test_state(); + + // Test worktree state, external configs, and watchers all removed + assert!(!worktree_ids.contains(&worktree_id)); + assert!(external_paths.is_empty()); + assert!(watcher_paths.is_empty()); + }); +} + +#[gpui::test] +async fn test_shared_external_editorconfig_cleanup_with_multiple_worktrees( + cx: &mut gpui::TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/parent"), + json!({ + ".editorconfig": "root = true\n[*]\nindent_size = 5\n", + "worktree_a": { + ".editorconfig": "[*]\n", + "file.rs": "fn a() {}", + }, + "worktree_b": { + ".editorconfig": "[*]\n", + "file.rs": "fn b() {}", + } + }), + ) + .await; + + let project = Project::test( + fs, + [ + path!("/parent/worktree_a").as_ref(), + path!("/parent/worktree_b").as_ref(), + ], + cx, + ) + .await; + + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + + cx.executor().run_until_parked(); + + let (worktree_a_id, worktree_b, worktree_b_id) = cx.update(|cx| { + let worktrees: Vec<_> = project.read(cx).worktrees(cx).collect(); + assert_eq!(worktrees.len(), 2); + + let worktree_a = &worktrees[0]; + let worktree_b = &worktrees[1]; + let worktree_a_id = worktree_a.read(cx).id(); + let worktree_b_id = worktree_b.read(cx).id(); + (worktree_a_id, worktree_b.clone(), worktree_b_id) + }); + + cx.update(|cx| { + let store = cx.global::(); + let (worktree_ids, external_paths, _) = store.editorconfig_store.read(cx).test_state(); + + // Test both worktrees have settings and share external config + assert!(worktree_ids.contains(&worktree_a_id)); + assert!(worktree_ids.contains(&worktree_b_id)); + assert_eq!(external_paths.len(), 1); // single shared external config + }); + + project.update(cx, |project, cx| { + project.remove_worktree(worktree_a_id, cx); + }); + + cx.executor().run_until_parked(); + + cx.update(|cx| { + let store = cx.global::(); + let (worktree_ids, external_paths, watcher_paths) = + store.editorconfig_store.read(cx).test_state(); + + // Test worktree_a is gone but external config remains for worktree_b + assert!(!worktree_ids.contains(&worktree_a_id)); + assert!(worktree_ids.contains(&worktree_b_id)); + // External config should still exist because worktree_b uses it + assert_eq!(external_paths.len(), 1); + assert_eq!(watcher_paths.len(), 1); + }); + + cx.update(|cx| { + let tree = worktree_b.read(cx); + let file_entry = tree.entry_for_path(rel_path("file.rs")).unwrap().clone(); + let file = File::for_entry(file_entry, worktree_b.clone()); + let file_language = project + .read(cx) + .languages() + .load_language_for_file_path(file.path.as_std_path()); + let file_language = cx + .foreground_executor() + .block_on(file_language) + .expect("Failed to get file language"); + let file = file as _; + let settings = language_settings(Some(file_language.name()), Some(&file), cx).into_owned(); + + // Test worktree_b still has correct settings + assert_eq!(Some(settings.tab_size), NonZeroU32::new(5)); + }); +} + #[gpui::test] async fn test_git_provider_project_setting(cx: &mut gpui::TestAppContext) { init_test(cx); diff --git a/crates/proto/proto/worktree.proto b/crates/proto/proto/worktree.proto index 7917962cf16b3f7e042e6584faf9ab7334e8ae3d..f4839dd476e4578d0afcdd29b9b134f4571751e2 100644 --- a/crates/proto/proto/worktree.proto +++ b/crates/proto/proto/worktree.proto @@ -146,6 +146,7 @@ message UpdateWorktreeSettings { string path = 3; optional string content = 4; optional LocalSettingsKind kind = 5; + optional bool outside_worktree = 6; } enum LocalSettingsKind { diff --git a/crates/settings/src/editorconfig_store.rs b/crates/settings/src/editorconfig_store.rs new file mode 100644 index 0000000000000000000000000000000000000000..d819993b047b14e3a1a0ef17c49a5c7c69ae13f7 --- /dev/null +++ b/crates/settings/src/editorconfig_store.rs @@ -0,0 +1,385 @@ +use anyhow::{Context as _, Result}; +use collections::{BTreeMap, BTreeSet, HashSet}; +use ec4rs::{ConfigParser, PropertiesSource, Section}; +use fs::Fs; +use futures::StreamExt; +use gpui::{Context, EventEmitter, Task}; +use paths::EDITORCONFIG_NAME; +use smallvec::SmallVec; +use std::{path::Path, str::FromStr, sync::Arc}; +use util::{ResultExt as _, rel_path::RelPath}; + +use crate::{InvalidSettingsError, LocalSettingsPath, WorktreeId, watch_config_file}; + +pub type EditorconfigProperties = ec4rs::Properties; + +#[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(Clone, Debug)] +pub enum EditorconfigEvent { + ExternalConfigChanged { + path: LocalSettingsPath, + content: Option, + affected_worktree_ids: Vec, + }, +} + +impl EventEmitter for EditorconfigStore {} + +#[derive(Default)] +pub struct EditorconfigStore { + external_configs: BTreeMap, (String, Option)>, + worktree_state: BTreeMap, + local_external_config_watchers: BTreeMap, Task<()>>, + local_external_config_discovery_tasks: BTreeMap>, +} + +#[derive(Default)] +struct EditorconfigWorktreeState { + internal_configs: BTreeMap, (String, Option)>, + external_config_paths: BTreeSet>, +} + +impl EditorconfigStore { + pub(crate) fn set_configs( + &mut self, + worktree_id: WorktreeId, + path: LocalSettingsPath, + content: Option<&str>, + ) -> std::result::Result<(), InvalidSettingsError> { + match (&path, content) { + (LocalSettingsPath::InWorktree(rel_path), None) => { + if let Some(state) = self.worktree_state.get_mut(&worktree_id) { + state.internal_configs.remove(rel_path); + } + } + (LocalSettingsPath::OutsideWorktree(abs_path), None) => { + if let Some(state) = self.worktree_state.get_mut(&worktree_id) { + state.external_config_paths.remove(abs_path); + } + let still_in_use = self + .worktree_state + .values() + .any(|state| state.external_config_paths.contains(abs_path)); + if !still_in_use { + self.external_configs.remove(abs_path); + self.local_external_config_watchers.remove(abs_path); + } + } + (LocalSettingsPath::InWorktree(rel_path), Some(content)) => { + let state = self.worktree_state.entry(worktree_id).or_default(); + let should_update = state + .internal_configs + .get(rel_path) + .map_or(true, |entry| entry.0 != content); + if should_update { + let parsed = match content.parse::() { + Ok(parsed) => Some(parsed), + Err(e) => { + state + .internal_configs + .insert(rel_path.clone(), (content.to_owned(), None)); + return Err(InvalidSettingsError::Editorconfig { + message: e.to_string(), + path: LocalSettingsPath::InWorktree( + rel_path.join(RelPath::unix(EDITORCONFIG_NAME).unwrap()), + ), + }); + } + }; + state + .internal_configs + .insert(rel_path.clone(), (content.to_owned(), parsed)); + } + } + (LocalSettingsPath::OutsideWorktree(abs_path), Some(content)) => { + let state = self.worktree_state.entry(worktree_id).or_default(); + state.external_config_paths.insert(abs_path.clone()); + let should_update = self + .external_configs + .get(abs_path) + .map_or(true, |entry| entry.0 != content); + if should_update { + let parsed = match content.parse::() { + Ok(parsed) => Some(parsed), + Err(e) => { + self.external_configs + .insert(abs_path.clone(), (content.to_owned(), None)); + return Err(InvalidSettingsError::Editorconfig { + message: e.to_string(), + path: LocalSettingsPath::OutsideWorktree( + abs_path.join(EDITORCONFIG_NAME).into(), + ), + }); + } + }; + self.external_configs + .insert(abs_path.clone(), (content.to_owned(), parsed)); + } + } + } + Ok(()) + } + + pub(crate) fn remove_for_worktree(&mut self, root_id: WorktreeId) { + self.local_external_config_discovery_tasks.remove(&root_id); + let Some(removed) = self.worktree_state.remove(&root_id) else { + return; + }; + let paths_in_use: HashSet<_> = self + .worktree_state + .values() + .flat_map(|w| w.external_config_paths.iter()) + .collect(); + for path in removed.external_config_paths.iter() { + if !paths_in_use.contains(path) { + self.external_configs.remove(path); + self.local_external_config_watchers.remove(path); + } + } + } + + fn internal_configs( + &self, + root_id: WorktreeId, + ) -> impl '_ + Iterator)> { + self.worktree_state + .get(&root_id) + .into_iter() + .flat_map(|state| { + state + .internal_configs + .iter() + .map(|(path, data)| (path.as_ref(), data.0.as_str(), data.1.as_ref())) + }) + } + + fn external_configs( + &self, + worktree_id: WorktreeId, + ) -> impl '_ + Iterator)> { + self.worktree_state + .get(&worktree_id) + .into_iter() + .flat_map(|state| { + state.external_config_paths.iter().filter_map(|path| { + self.external_configs + .get(path) + .map(|entry| (path.as_ref(), entry.0.as_str(), entry.1.as_ref())) + }) + }) + } + + pub fn local_editorconfig_settings( + &self, + worktree_id: WorktreeId, + ) -> impl '_ + Iterator)> { + let external = self + .external_configs(worktree_id) + .map(|(path, content, parsed)| { + ( + LocalSettingsPath::OutsideWorktree(path.into()), + content, + parsed, + ) + }); + let internal = self + .internal_configs(worktree_id) + .map(|(path, content, parsed)| { + (LocalSettingsPath::InWorktree(path.into()), content, parsed) + }); + external.chain(internal) + } + + pub fn discover_local_external_configs_chain( + &mut self, + worktree_id: WorktreeId, + worktree_path: Arc, + fs: Arc, + cx: &mut Context, + ) { + // We should only have one discovery task per worktree. + if self + .local_external_config_discovery_tasks + .contains_key(&worktree_id) + { + return; + } + + let task = cx.spawn({ + let fs = fs.clone(); + async move |this, cx| { + let discovered_paths = { + let mut paths = Vec::new(); + let mut current = worktree_path.parent().map(|p| p.to_path_buf()); + while let Some(dir) = current { + let dir_path: Arc = Arc::from(dir.as_path()); + let path = dir.join(EDITORCONFIG_NAME); + if fs.load(&path).await.is_ok() { + paths.push(dir_path); + } + current = dir.parent().map(|p| p.to_path_buf()); + } + paths + }; + + this.update(cx, |this, cx| { + for dir_path in discovered_paths { + // We insert it here so that watchers can send events to appropriate worktrees. + // external_config_paths gets populated again in set_configs. + this.worktree_state + .entry(worktree_id) + .or_default() + .external_config_paths + .insert(dir_path.clone()); + match this.local_external_config_watchers.entry(dir_path.clone()) { + std::collections::btree_map::Entry::Occupied(_) => { + if let Some(existing_config) = this.external_configs.get(&dir_path) + { + cx.emit(EditorconfigEvent::ExternalConfigChanged { + path: LocalSettingsPath::OutsideWorktree(dir_path), + content: Some(existing_config.0.clone()), + affected_worktree_ids: vec![worktree_id], + }); + } else { + log::error!("Watcher exists for {dir_path:?} but no config found in external_configs"); + } + } + std::collections::btree_map::Entry::Vacant(entry) => { + let watcher = + Self::watch_local_external_config(fs.clone(), dir_path, cx); + entry.insert(watcher); + } + } + } + }) + .ok(); + } + }); + + self.local_external_config_discovery_tasks + .insert(worktree_id, task); + } + + fn watch_local_external_config( + fs: Arc, + dir_path: Arc, + cx: &mut Context, + ) -> Task<()> { + let config_path = dir_path.join(EDITORCONFIG_NAME); + let mut config_rx = watch_config_file(cx.background_executor(), fs, config_path); + + cx.spawn(async move |this, cx| { + while let Some(content) = config_rx.next().await { + let content = Some(content).filter(|c| !c.is_empty()); + let dir_path = dir_path.clone(); + this.update(cx, |this, cx| { + let affected_worktree_ids: Vec = this + .worktree_state + .iter() + .filter_map(|(id, state)| { + state + .external_config_paths + .contains(&dir_path) + .then_some(*id) + }) + .collect(); + + cx.emit(EditorconfigEvent::ExternalConfigChanged { + path: LocalSettingsPath::OutsideWorktree(dir_path), + content, + affected_worktree_ids, + }); + }) + .ok(); + } + }) + } + + pub fn properties( + &self, + for_worktree: WorktreeId, + for_path: &RelPath, + ) -> Option { + let mut properties = EditorconfigProperties::new(); + let state = self.worktree_state.get(&for_worktree); + let empty_path: Arc = RelPath::empty().into(); + let internal_root_config_is_root = state + .and_then(|state| state.internal_configs.get(&empty_path)) + .and_then(|data| data.1.as_ref()) + .is_some_and(|ec| ec.is_root); + + if !internal_root_config_is_root { + for (_, _, parsed_editorconfig) in self.external_configs(for_worktree) { + if let Some(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.as_std_path()) + .log_err()?; + } + } + } + } + + for (directory_with_config, _, parsed_editorconfig) in self.internal_configs(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.as_std_path()) + .log_err()?; + } + } + + properties.use_fallbacks(); + Some(properties) + } +} + +#[cfg(any(test, feature = "test-support"))] +impl EditorconfigStore { + pub fn test_state(&self) -> (Vec, Vec>, Vec>) { + let worktree_ids: Vec<_> = self.worktree_state.keys().copied().collect(); + let external_paths: Vec<_> = self.external_configs.keys().cloned().collect(); + let watcher_paths: Vec<_> = self + .local_external_config_watchers + .keys() + .cloned() + .collect(); + (worktree_ids, external_paths, watcher_paths) + } + + pub fn external_config_paths_for_worktree(&self, worktree_id: WorktreeId) -> Vec> { + self.worktree_state + .get(&worktree_id) + .map(|state| state.external_config_paths.iter().cloned().collect()) + .unwrap_or_default() + } +} diff --git a/crates/settings/src/settings.rs b/crates/settings/src/settings.rs index 088d288a310c5cd0e7bd319d305a0d2b1655b627..af7b0c79ff154d18e1bc496ab38ee6b5d0154ea8 100644 --- a/crates/settings/src/settings.rs +++ b/crates/settings/src/settings.rs @@ -1,6 +1,7 @@ mod base_keymap_setting; mod content_into_gpui; mod editable_setting_control; +mod editorconfig_store; mod keymap_file; mod settings_file; mod settings_store; @@ -33,6 +34,9 @@ pub use ::settings_content::*; pub use base_keymap_setting::*; pub use content_into_gpui::IntoGpui; pub use editable_setting_control::*; +pub use editorconfig_store::{ + Editorconfig, EditorconfigEvent, EditorconfigProperties, EditorconfigStore, +}; pub use keymap_file::{ KeyBindingValidator, KeyBindingValidatorRegistration, KeybindSource, KeybindUpdateOperation, KeybindUpdateTarget, KeymapFile, KeymapFileLoadResult, @@ -40,9 +44,9 @@ pub use keymap_file::{ pub use settings_file::*; pub use settings_json::*; pub use settings_store::{ - InvalidSettingsError, LSP_SETTINGS_SCHEMA_URL_PREFIX, LocalSettingsKind, MigrationStatus, - Settings, SettingsFile, SettingsJsonSchemaParams, SettingsKey, SettingsLocation, - SettingsParseResult, SettingsStore, + InvalidSettingsError, LSP_SETTINGS_SCHEMA_URL_PREFIX, LocalSettingsKind, LocalSettingsPath, + MigrationStatus, Settings, SettingsFile, SettingsJsonSchemaParams, SettingsKey, + SettingsLocation, SettingsParseResult, SettingsStore, }; pub use vscode_import::{VsCodeSettings, VsCodeSettingsSource}; diff --git a/crates/settings/src/settings_store.rs b/crates/settings/src/settings_store.rs index 0fa032a0e77d00a84463078309ce36cc29c11d3c..6c715177862b921fd6f1d2bced83d09332128c62 100644 --- a/crates/settings/src/settings_store.rs +++ b/crates/settings/src/settings_store.rs @@ -1,25 +1,26 @@ use anyhow::{Context as _, Result}; use collections::{BTreeMap, HashMap, btree_map, hash_map}; -use ec4rs::{ConfigParser, PropertiesSource, Section}; use fs::Fs; use futures::{ FutureExt, StreamExt, channel::{mpsc, oneshot}, future::LocalBoxFuture, }; -use gpui::{App, AsyncApp, BorrowAppContext, Global, SharedString, Task, UpdateGlobal}; +use gpui::{ + App, AppContext, AsyncApp, BorrowAppContext, Entity, Global, SharedString, Task, UpdateGlobal, +}; -use paths::{EDITORCONFIG_NAME, local_settings_file_relative_path, task_file_name}; +use paths::{local_settings_file_relative_path, task_file_name}; use schemars::{JsonSchema, json_schema}; use serde_json::Value; -use smallvec::SmallVec; +use settings_content::ParseStatus; use std::{ any::{Any, TypeId, type_name}, fmt::Debug, ops::Range, - path::PathBuf, + path::{Path, PathBuf}, rc::Rc, - str::{self, FromStr}, + str, sync::Arc, }; use util::{ @@ -28,17 +29,17 @@ use util::{ schemars::{AllowTrailingCommas, DefaultDenyUnknownFields, replace_subschema}, }; -pub type EditorconfigProperties = ec4rs::Properties; +use crate::editorconfig_store::EditorconfigStore; -use crate::settings_content::{ - ExtensionsSettingsContent, FontFamilyName, IconThemeName, LanguageSettingsContent, - LanguageToSettingsMap, LspSettings, LspSettingsMap, ProjectSettingsContent, SettingsContent, - ThemeName, UserSettingsContent, -}; use crate::{ - ActiveSettingsProfileName, ParseStatus, UserSettingsContentExt, VsCodeSettings, WorktreeId, + ActiveSettingsProfileName, FontFamilyName, IconThemeName, LanguageSettingsContent, + LanguageToSettingsMap, LspSettings, LspSettingsMap, ThemeName, UserSettingsContentExt, + VsCodeSettings, WorktreeId, + settings_content::{ + ExtensionsSettingsContent, ProjectSettingsContent, RootUserSettings, SettingsContent, + UserSettingsContent, merge_from::MergeFrom, + }, }; -use settings_content::{RootUserSettings, merge_from::MergeFrom}; use settings_json::{infer_json_indent_size, update_value_in_json_text}; @@ -153,7 +154,7 @@ pub struct SettingsStore { merged_settings: Rc, local_settings: BTreeMap<(WorktreeId, Arc), SettingsContent>, - raw_editorconfig_settings: BTreeMap<(WorktreeId, Arc), (String, Option)>, + pub editorconfig_store: Entity, _setting_file_updates: Task<()>, setting_file_updates_tx: @@ -201,26 +202,6 @@ impl Ord for SettingsFile { } } -#[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, @@ -229,6 +210,33 @@ pub enum LocalSettingsKind { Debug, } +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum LocalSettingsPath { + InWorktree(Arc), + OutsideWorktree(Arc), +} + +impl LocalSettingsPath { + pub fn is_outside_worktree(&self) -> bool { + matches!(self, Self::OutsideWorktree(_)) + } + + pub fn to_proto(&self) -> String { + match self { + Self::InWorktree(path) => path.to_proto(), + Self::OutsideWorktree(path) => path.to_string_lossy().to_string(), + } + } + + pub fn from_proto(path: &str, is_outside_worktree: bool) -> anyhow::Result { + if is_outside_worktree { + Ok(Self::OutsideWorktree(PathBuf::from(path).into())) + } else { + Ok(Self::InWorktree(RelPath::from_proto(path)?)) + } + } +} + impl Global for SettingsStore {} #[doc(hidden)] @@ -263,7 +271,7 @@ pub struct SettingsJsonSchemaParams<'a> { } impl SettingsStore { - pub fn new(cx: &App, default_settings: &str) -> Self { + pub fn new(cx: &mut App, default_settings: &str) -> Self { let (setting_file_updates_tx, mut setting_file_updates_rx) = mpsc::unbounded(); let default_settings: Rc = SettingsContent::parse_json_with_comments(default_settings) @@ -279,7 +287,7 @@ impl SettingsStore { merged_settings: default_settings, local_settings: BTreeMap::default(), - raw_editorconfig_settings: BTreeMap::default(), + editorconfig_store: cx.new(|_| EditorconfigStore::default()), setting_file_updates_tx, _setting_file_updates: cx.spawn(async move |cx| { while let Some(setting_file_update) = setting_file_updates_rx.next().await { @@ -835,19 +843,17 @@ impl SettingsStore { pub fn set_local_settings( &mut self, root_id: WorktreeId, - directory_path: Arc, + path: LocalSettingsPath, kind: LocalSettingsKind, settings_content: Option<&str>, cx: &mut App, ) -> std::result::Result<(), InvalidSettingsError> { + let content = settings_content + .map(|content| content.trim()) + .filter(|content| !content.is_empty()); let mut zed_settings_changed = false; - match ( - kind, - settings_content - .map(|content| content.trim()) - .filter(|content| !content.is_empty()), - ) { - (LocalSettingsKind::Tasks, _) => { + match (path.clone(), kind, content) { + (LocalSettingsPath::InWorktree(directory_path), LocalSettingsKind::Tasks, _) => { return Err(InvalidSettingsError::Tasks { message: "Attempted to submit tasks into the settings store".to_string(), path: directory_path @@ -856,7 +862,7 @@ impl SettingsStore { .to_path_buf(), }); } - (LocalSettingsKind::Debug, _) => { + (LocalSettingsPath::InWorktree(directory_path), LocalSettingsKind::Debug, _) => { return Err(InvalidSettingsError::Debug { message: "Attempted to submit debugger config into the settings store" .to_string(), @@ -866,19 +872,19 @@ impl SettingsStore { .to_path_buf(), }); } - (LocalSettingsKind::Settings, None) => { + (LocalSettingsPath::InWorktree(directory_path), LocalSettingsKind::Settings, None) => { zed_settings_changed = self .local_settings .remove(&(root_id, directory_path.clone())) .is_some(); self.file_errors - .remove(&SettingsFile::Project((root_id, directory_path.clone()))); - } - (LocalSettingsKind::Editorconfig, None) => { - self.raw_editorconfig_settings - .remove(&(root_id, directory_path.clone())); + .remove(&SettingsFile::Project((root_id, directory_path))); } - (LocalSettingsKind::Settings, Some(settings_contents)) => { + ( + LocalSettingsPath::InWorktree(directory_path), + LocalSettingsKind::Settings, + Some(settings_contents), + ) => { let (new_settings, parse_result) = self .parse_and_migrate_zed_settings::( settings_contents, @@ -892,7 +898,7 @@ impl SettingsStore { }), }?; if let Some(new_settings) = new_settings { - match self.local_settings.entry((root_id, directory_path.clone())) { + match self.local_settings.entry((root_id, directory_path)) { btree_map::Entry::Vacant(v) => { v.insert(SettingsContent { project: new_settings, @@ -912,50 +918,24 @@ impl SettingsStore { } } } - (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(RelPath::unix(EDITORCONFIG_NAME).unwrap()), - }); - } - }, - 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(RelPath::unix(EDITORCONFIG_NAME).unwrap()), - }); - } - } - } - } - } + (directory_path, LocalSettingsKind::Editorconfig, editorconfig_contents) => { + self.editorconfig_store.update(cx, |store, _| { + store.set_configs(root_id, directory_path, editorconfig_contents) + })?; + } + (LocalSettingsPath::OutsideWorktree(path), kind, _) => { + log::error!( + "OutsideWorktree path {:?} with kind {:?} is only supported by editorconfig", + path, + kind + ); + return Ok(()); + } + } + if let LocalSettingsPath::InWorktree(directory_path) = &path { + if zed_settings_changed { + self.recompute_values(Some((root_id, &directory_path)), cx); } - }; - - if zed_settings_changed { - self.recompute_values(Some((root_id, &directory_path)), cx); } Ok(()) } @@ -980,8 +960,10 @@ impl SettingsStore { pub fn clear_local_settings(&mut self, root_id: WorktreeId, cx: &mut App) -> Result<()> { self.local_settings .retain(|(worktree_id, _), _| worktree_id != &root_id); - self.raw_editorconfig_settings - .retain(|(worktree_id, _), _| worktree_id != &root_id); + + self.editorconfig_store + .update(cx, |store, _cx| store.remove_for_worktree(root_id)); + for setting_value in self.setting_values.values_mut() { setting_value.clear_local_values(root_id); } @@ -1004,23 +986,6 @@ impl SettingsStore { .map(|((_, path), content)| (path.clone(), &content.project)) } - pub fn local_editorconfig_settings( - &self, - root_id: WorktreeId, - ) -> impl '_ + Iterator, String, Option)> { - self.raw_editorconfig_settings - .range( - (root_id, RelPath::empty().into()) - ..( - WorktreeId::from_usize(root_id.to_usize() + 1), - RelPath::empty().into(), - ), - ) - .map(|((_, path), (content, parsed_content))| { - (path.clone(), content.clone(), parsed_content.clone()) - }) - } - pub fn json_schema(&self, params: &SettingsJsonSchemaParams) -> Value { let mut generator = schemars::generate::SchemaSettings::draft2019_09() .with_transform(DefaultDenyUnknownFields) @@ -1181,35 +1146,6 @@ impl SettingsStore { } } } - - pub fn editorconfig_properties( - &self, - for_worktree: WorktreeId, - for_path: &RelPath, - ) -> 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.as_std_path()) - .log_err()?; - } - } - - properties.use_fallbacks(); - Some(properties) - } } /// The result of parsing settings, including any migration attempts @@ -1296,13 +1232,31 @@ impl SettingsParseResult { #[derive(Debug, Clone, PartialEq)] pub enum InvalidSettingsError { - LocalSettings { path: Arc, message: String }, - UserSettings { message: String }, - ServerSettings { message: String }, - DefaultSettings { message: String }, - Editorconfig { path: Arc, message: String }, - Tasks { path: PathBuf, message: String }, - Debug { path: PathBuf, message: String }, + LocalSettings { + path: Arc, + message: String, + }, + UserSettings { + message: String, + }, + ServerSettings { + message: String, + }, + DefaultSettings { + message: String, + }, + Editorconfig { + path: LocalSettingsPath, + message: String, + }, + Tasks { + path: PathBuf, + message: String, + }, + Debug { + path: PathBuf, + message: String, + }, } impl std::fmt::Display for InvalidSettingsError { @@ -1505,7 +1459,7 @@ mod tests { store .set_local_settings( WorktreeId::from_usize(1), - rel_path("root1").into(), + LocalSettingsPath::InWorktree(rel_path("root1").into()), LocalSettingsKind::Settings, Some(r#"{ "tab_size": 5 }"#), cx, @@ -1514,7 +1468,7 @@ mod tests { store .set_local_settings( WorktreeId::from_usize(1), - rel_path("root1/subdir").into(), + LocalSettingsPath::InWorktree(rel_path("root1/subdir").into()), LocalSettingsKind::Settings, Some(r#"{ "preferred_line_length": 50 }"#), cx, @@ -1524,7 +1478,7 @@ mod tests { store .set_local_settings( WorktreeId::from_usize(1), - rel_path("root2").into(), + LocalSettingsPath::InWorktree(rel_path("root2").into()), LocalSettingsKind::Settings, Some(r#"{ "tab_size": 9, "auto_update": true}"#), cx, @@ -1995,7 +1949,7 @@ mod tests { store .set_local_settings( local.0, - local.1.clone(), + LocalSettingsPath::InWorktree(local.1.clone()), LocalSettingsKind::Settings, Some(r#"{}"#), cx, @@ -2029,7 +1983,7 @@ mod tests { store .set_local_settings( local.0, - local.1.clone(), + LocalSettingsPath::InWorktree(local.1.clone()), LocalSettingsKind::Settings, Some(r#"{"preferred_line_length": 80}"#), cx, @@ -2086,7 +2040,7 @@ mod tests { store .set_local_settings( local_1.0, - local_1.1.clone(), + LocalSettingsPath::InWorktree(local_1.1.clone()), LocalSettingsKind::Settings, Some(r#"{"preferred_line_length": 1}"#), cx, @@ -2095,7 +2049,7 @@ mod tests { store .set_local_settings( local_1_child.0, - local_1_child.1.clone(), + LocalSettingsPath::InWorktree(local_1_child.1.clone()), LocalSettingsKind::Settings, Some(r#"{}"#), cx, @@ -2104,7 +2058,7 @@ mod tests { store .set_local_settings( local_2.0, - local_2.1.clone(), + LocalSettingsPath::InWorktree(local_2.1.clone()), LocalSettingsKind::Settings, Some(r#"{"preferred_line_length": 2}"#), cx, @@ -2113,7 +2067,7 @@ mod tests { store .set_local_settings( local_2_child.0, - local_2_child.1.clone(), + LocalSettingsPath::InWorktree(local_2_child.1.clone()), LocalSettingsKind::Settings, Some(r#"{}"#), cx, @@ -2135,7 +2089,7 @@ mod tests { store .set_local_settings( local_1_adjacent_child.0, - local_1_adjacent_child.1.clone(), + LocalSettingsPath::InWorktree(local_1_adjacent_child.1.clone()), LocalSettingsKind::Settings, Some(r#"{}"#), cx, @@ -2144,7 +2098,7 @@ mod tests { store .set_local_settings( local_1_child.0, - local_1_child.1.clone(), + LocalSettingsPath::InWorktree(local_1_child.1.clone()), LocalSettingsKind::Settings, Some(r#"{"preferred_line_length": 3}"#), cx, @@ -2158,7 +2112,7 @@ mod tests { store .set_local_settings( local_1_adjacent_child.0, - local_1_adjacent_child.1, + LocalSettingsPath::InWorktree(local_1_adjacent_child.1), LocalSettingsKind::Settings, Some(r#"{"preferred_line_length": 3}"#), cx, @@ -2167,7 +2121,7 @@ mod tests { store .set_local_settings( local_1_child.0, - local_1_child.1.clone(), + LocalSettingsPath::InWorktree(local_1_child.1.clone()), LocalSettingsKind::Settings, Some(r#"{}"#), cx, @@ -2202,7 +2156,7 @@ mod tests { store .set_local_settings( wt0_root.0, - wt0_root.1.clone(), + LocalSettingsPath::InWorktree(wt0_root.1.clone()), LocalSettingsKind::Settings, Some(r#"{"preferred_line_length": 80}"#), cx, @@ -2211,7 +2165,7 @@ mod tests { store .set_local_settings( wt0_child1.0, - wt0_child1.1.clone(), + LocalSettingsPath::InWorktree(wt0_child1.1.clone()), LocalSettingsKind::Settings, Some(r#"{"preferred_line_length": 120}"#), cx, @@ -2220,7 +2174,7 @@ mod tests { store .set_local_settings( wt0_child2.0, - wt0_child2.1.clone(), + LocalSettingsPath::InWorktree(wt0_child2.1.clone()), LocalSettingsKind::Settings, Some(r#"{}"#), cx, @@ -2230,7 +2184,7 @@ mod tests { store .set_local_settings( wt1_root.0, - wt1_root.1.clone(), + LocalSettingsPath::InWorktree(wt1_root.1.clone()), LocalSettingsKind::Settings, Some(r#"{"preferred_line_length": 90}"#), cx, @@ -2239,7 +2193,7 @@ mod tests { store .set_local_settings( wt1_subdir.0, - wt1_subdir.1.clone(), + LocalSettingsPath::InWorktree(wt1_subdir.1.clone()), LocalSettingsKind::Settings, Some(r#"{}"#), cx, @@ -2290,7 +2244,7 @@ mod tests { store .set_local_settings( wt0_deep_child.0, - wt0_deep_child.1.clone(), + LocalSettingsPath::InWorktree(wt0_deep_child.1.clone()), LocalSettingsKind::Settings, Some(r#"{"preferred_line_length": 140}"#), cx,