diff --git a/crates/ruff_server/src/session/index/ruff_settings.rs b/crates/ruff_server/src/session/index/ruff_settings.rs index 64e7fbfe9107b..88ef63fc4bfe3 100644 --- a/crates/ruff_server/src/session/index/ruff_settings.rs +++ b/crates/ruff_server/src/session/index/ruff_settings.rs @@ -295,10 +295,14 @@ impl RuffSettingsIndex { return WalkState::Continue; } + let depth = entry.depth(); let directory = entry.into_path(); - // If the directory is excluded from the workspace, skip it. - if let Some(file_name) = directory.file_name() { + // An explicitly opened workspace root must be indexed even if an ancestor + // configuration excludes it. Excluded descendants can still be skipped. + if depth > 0 + && let Some(file_name) = directory.file_name() + { let settings = index .read() .unwrap() diff --git a/crates/ruff_server/tests/e2e/workspace.rs b/crates/ruff_server/tests/e2e/workspace.rs index 4e862e97c75de..242901fd6bdfd 100644 --- a/crates/ruff_server/tests/e2e/workspace.rs +++ b/crates/ruff_server/tests/e2e/workspace.rs @@ -1,7 +1,9 @@ -use anyhow::Result; -use insta::assert_json_snapshot; +use anyhow::{Context, Result}; +use insta::{assert_json_snapshot, assert_snapshot}; -use crate::TestServerBuilder; +use crate::{TestServer, TestServerBuilder}; + +const SOURCE: &str = "value= \"hello\"\n"; #[test] fn selects_the_correct_workspace_settings_for_multi_root_workspaces() -> Result<()> { @@ -104,6 +106,181 @@ ignore = ["F401"] Ok(()) } +#[test] +fn nested_workspace_root_is_not_excluded_by_an_ancestor() -> Result<()> { + let mut server = nested_workspace_server(&["sub"], WorkspaceExclusion::Exclude)?; + + assert_snapshot!( + open_and_format(&mut server, "sub/test.py", SOURCE) + .context("nested workspace should be formatted")?, + @"value = 'hello'" + ); + // Explicitly opening `sub` does not override its own exclusion of `foo`. + assert!(open_and_format(&mut server, "sub/foo/test.py", SOURCE).is_none()); + + Ok(()) +} + +#[test] +fn nested_workspace_root_is_not_excluded_by_an_ancestor_in_a_multi_root_workspace() -> Result<()> { + const ISSUE_SOURCE: &str = r#"print("This line is long enough to wrap.") +"#; + + let mut server = TestServerBuilder::new()? + .with_workspace(".")? + .with_workspace("sub")? + .with_file( + ".ruff.toml", + r#"target-version = "py312" +line-length = 40 + +extend-exclude = [ + "sub", +] +"#, + )? + .with_file( + "sub/.ruff.toml", + r#"target-version = "py312" +line-length = 40 + +extend-exclude = [ + "foo", +] +"#, + )? + .with_file("test.py", ISSUE_SOURCE)? + .with_file("sub/test.py", ISSUE_SOURCE)? + .with_file("sub/foo/test.py", ISSUE_SOURCE)? + .build(); + + assert_snapshot!( + open_and_format(&mut server, "test.py", ISSUE_SOURCE) + .context("parent workspace should be formatted")?, + @r#" + print( + "This line is long enough to wrap." + ) + "# + ); + assert_snapshot!( + open_and_format(&mut server, "sub/test.py", ISSUE_SOURCE) + .context("nested workspace should be formatted")?, + @r#" + print( + "This line is long enough to wrap." + ) + "# + ); + assert!(open_and_format(&mut server, "sub/foo/test.py", ISSUE_SOURCE).is_none()); + + Ok(()) +} + +#[test] +fn nested_workspace_remains_excluded_without_explicit_registration() -> Result<()> { + let mut server = nested_workspace_server(&["."], WorkspaceExclusion::ExtendExclude)?; + + assert!(open_and_format(&mut server, "sub/test.py", SOURCE).is_none()); + assert!(open_and_format(&mut server, "sub/foo/test.py", SOURCE).is_none()); + + Ok(()) +} + +#[test] +fn unrelated_file_outside_workspace_uses_fallback_configuration() -> Result<()> { + let mut server = nested_workspace_server(&["sub"], WorkspaceExclusion::ExtendExclude)?; + + assert_snapshot!( + open_and_format(&mut server, "unrelated/test.py", SOURCE) + .context("unrelated file should use fallback formatting")?, + @r#"value = "hello""# + ); + + Ok(()) +} + +#[test] +fn single_file_mode_does_not_index_nested_configuration() -> Result<()> { + let mut server = TestServerBuilder::new()? + .with_file("nested/.ruff.toml", "[format]\nquote-style = \"single\"\n")? + .with_file("nested/test.py", SOURCE)? + .with_file("unrelated/test.py", SOURCE)? + .build(); + + assert_snapshot!( + open_and_format(&mut server, "nested/test.py", SOURCE) + .context("nested file should use fallback formatting")?, + @r#"value = "hello""# + ); + assert_snapshot!( + open_and_format(&mut server, "unrelated/test.py", SOURCE) + .context("unrelated file should use fallback formatting")?, + @r#"value = "hello""# + ); + + Ok(()) +} + +#[derive(Clone, Copy)] +enum WorkspaceExclusion { + Exclude, + ExtendExclude, +} + +/// Creates a test server for the following temporary workspace: +/// +/// ```text +/// / +/// ├── .ruff.toml # exclude or extend-exclude = ["sub"] +/// ├── test.py +/// ├── sub/ +/// │ ├── .ruff.toml # extend-exclude = ["foo"] +/// │ │ # format.quote-style = "single" +/// │ ├── test.py +/// │ └── foo/ +/// │ └── test.py +/// └── unrelated/ +/// └── test.py +/// ``` +fn nested_workspace_server( + workspaces: &[&str], + exclusion: WorkspaceExclusion, +) -> Result { + let mut builder = TestServerBuilder::new()?; + for workspace in workspaces { + builder = builder.with_workspace(workspace)?; + } + + let server = builder + .with_file( + ".ruff.toml", + match exclusion { + WorkspaceExclusion::Exclude => "exclude = [\"sub\"]\n", + WorkspaceExclusion::ExtendExclude => "extend-exclude = [\"sub\"]\n", + }, + )? + .with_file( + "sub/.ruff.toml", + "extend-exclude = [\"foo\"]\n[format]\nquote-style = \"single\"\n", + )? + .with_file("test.py", SOURCE)? + .with_file("sub/test.py", SOURCE)? + .with_file("sub/foo/test.py", SOURCE)? + .with_file("unrelated/test.py", SOURCE)? + .build(); + + Ok(server) +} + +fn open_and_format(server: &mut TestServer, path: &str, source: &str) -> Option { + server.open_text_document(path, source, 1); + server + .format_request(path) + .and_then(|edits| edits.into_iter().next()) + .map(|edit| edit.new_text) +} + #[test] fn unavailable_document_diagnostic_returns_empty_response() -> Result<()> { let mut server = TestServerBuilder::new()?.with_workspace(".")?.build();