diff --git a/.changeset/weak-boxes-look.md b/.changeset/weak-boxes-look.md new file mode 100644 index 000000000000..0cc8f1b210b0 --- /dev/null +++ b/.changeset/weak-boxes-look.md @@ -0,0 +1,5 @@ +--- +"@biomejs/biome": patch +--- + +Fixed [#9217](https://github.com/biomejs/biome/issues/9217) and [biomejs/biome-vscode#959](https://github.com/biomejs/biome-vscode/issues/959), where the Biome language server didn't correctly resolve the editor setting `configurationPath` when the provided value is a relative path. diff --git a/crates/biome_lsp/src/handlers/text_document.rs b/crates/biome_lsp/src/handlers/text_document.rs index 77f1b2f848e7..67c57a372e73 100644 --- a/crates/biome_lsp/src/handlers/text_document.rs +++ b/crates/biome_lsp/src/handlers/text_document.rs @@ -42,10 +42,17 @@ pub(crate) async fn did_open( session.load_extension_settings(None).await; } - let status = if let Some(path) = session.get_settings_configuration_path() { - info!("Loading user configuration from text_document {}", &path); + let status = if let Some(config_path) = session.resolve_configuration_path(Some(&path)) + { + info!( + "Loading user configuration from text_document {}", + &config_path + ); session - .load_biome_configuration_file(ConfigurationPathHint::FromUser(path), false) + .load_biome_configuration_file( + ConfigurationPathHint::FromUser(config_path), + false, + ) .await } else { let project_path = path diff --git a/crates/biome_lsp/src/server.rs b/crates/biome_lsp/src/server.rs index 4e17f637ac6b..0774cd2e6c71 100644 --- a/crates/biome_lsp/src/server.rs +++ b/crates/biome_lsp/src/server.rs @@ -358,6 +358,10 @@ impl LanguageServer for LSPServer { async fn did_change_configuration(&self, params: DidChangeConfigurationParams) { let settings = params.settings; self.session.load_extension_settings(Some(settings)).await; + // Reload the workspace configuration so that settings such as + // `configurationPath` take effect immediately without requiring the + // user to restart the server or open a new file. + self.session.load_workspace_settings(true).await; self.setup_capabilities().await; self.session.update_all_diagnostics().await; } diff --git a/crates/biome_lsp/src/server.tests.rs b/crates/biome_lsp/src/server.tests.rs index fe27f249102c..40675ba98428 100644 --- a/crates/biome_lsp/src/server.tests.rs +++ b/crates/biome_lsp/src/server.tests.rs @@ -4662,6 +4662,206 @@ const foo = 'bad' Ok(()) } +// #region CONFIGURATION PATH RESOLUTION + +/// Verifies that a relative `configurationPath` in the extension settings is +/// resolved against the workspace root URI. +/// +/// Regression test for +#[tokio::test] +async fn relative_configuration_path_resolves_against_root_uri() -> Result<()> { + let fs = MemoryFileSystem::default(); + + // Place the config in a sub-directory so the path must be relative. + let config = r#"{ + "formatter": { + "enabled": false + } + }"#; + fs.insert(to_utf8_file_path_buf(uri!("configs/biome.json")), config); + + let factory = ServerFactory::new_with_fs(Arc::new(fs)); + let (service, client) = factory.create().into_inner(); + let (stream, sink) = client.split(); + let mut server = Server::new(service); + + let (sender, _) = channel(CHANNEL_BUFFER_SIZE); + let reader = tokio::spawn(client_handler(stream, sink, sender)); + + server.initialize().await?; + server.initialized().await?; + + // Set configurationPath to a relative path (the bug: this used to be + // resolved against the daemon's cwd instead of the workspace root). + server + .load_configuration_with_settings(WorkspaceSettings { + configuration_path: Some("configs/biome.json".to_string()), + ..Default::default() + }) + .await?; + + server.open_document(r#"statement( );"#).await?; + + // The config disables the formatter, so formatting should return no edits. + let res: Option> = server + .request( + "textDocument/formatting", + "formatting", + DocumentFormattingParams { + text_document: TextDocumentIdentifier { + uri: uri!("document.js"), + }, + options: FormattingOptions { + tab_size: 4, + insert_spaces: false, + properties: HashMap::default(), + trim_trailing_whitespace: None, + insert_final_newline: None, + trim_final_newlines: None, + }, + work_done_progress_params: WorkDoneProgressParams { + work_done_token: None, + }, + }, + ) + .await? + .context("formatting returned None")?; + + assert!( + res.is_none(), + "Expected no formatting edits because the config at configs/biome.json disables the formatter. \ + If this fails, the relative configurationPath was not resolved against the workspace root." + ); + + server.close_document().await?; + server.shutdown().await?; + reader.abort(); + + Ok(()) +} + +/// Verifies that a relative `configurationPath` is resolved against the +/// workspace folder that **contains the file being opened**, not always the +/// first workspace folder. +/// +/// Regression test for +#[tokio::test] +#[expect(deprecated)] +async fn relative_configuration_path_resolves_against_correct_workspace_folder() -> Result<()> { + let fs = MemoryFileSystem::default(); + + // test_one has formatting enabled (default), test_two disables it. + // Both configs live at `/configs/biome.json`. + let config_one = r#"{}"#; + let config_two = r#"{ + "formatter": { + "enabled": false + } + }"#; + + fs.insert( + to_utf8_file_path_buf(uri!("test_one/configs/biome.json")), + config_one, + ); + fs.insert( + to_utf8_file_path_buf(uri!("test_two/configs/biome.json")), + config_two, + ); + + let factory = ServerFactory::new_with_fs(Arc::new(fs)); + let (service, client) = factory.create().into_inner(); + let (stream, sink) = client.split(); + let mut server = Server::new(service); + + let (sender, _) = channel(CHANNEL_BUFFER_SIZE); + let reader = tokio::spawn(client_handler(stream, sink, sender)); + + // Initialize with two workspace folders (test_one, test_two). + let _res: InitializeResult = server + .request( + "initialize", + "_init", + InitializeParams { + process_id: None, + root_path: None, + root_uri: Some(uri!("/")), + initialization_options: None, + capabilities: ClientCapabilities::default(), + trace: None, + workspace_folders: Some(vec![ + WorkspaceFolder { + name: "test_one".to_string(), + uri: uri!("test_one"), + }, + WorkspaceFolder { + name: "test_two".to_string(), + uri: uri!("test_two"), + }, + ]), + client_info: None, + locale: None, + work_done_progress_params: Default::default(), + }, + ) + .await? + .context("initialize returned None")?; + + server.initialized().await?; + + // Set a relative configurationPath. Each workspace folder has its own + // `configs/biome.json`; the correct one must be picked per file. + server + .load_configuration_with_settings(WorkspaceSettings { + configuration_path: Some("configs/biome.json".to_string()), + ..Default::default() + }) + .await?; + + // Open a file in test_two — its config disables the formatter. + server + .open_named_document( + r#"statement( );"#, + uri!("test_two/document.js"), + "javascript", + ) + .await?; + + let res: Option> = server + .request( + "textDocument/formatting", + "formatting", + DocumentFormattingParams { + text_document: TextDocumentIdentifier { + uri: uri!("test_two/document.js"), + }, + options: FormattingOptions { + tab_size: 4, + insert_spaces: false, + properties: HashMap::default(), + trim_trailing_whitespace: None, + insert_final_newline: None, + trim_final_newlines: None, + }, + work_done_progress_params: WorkDoneProgressParams { + work_done_token: None, + }, + }, + ) + .await? + .context("formatting returned None")?; + + assert!( + res.is_none(), + "Expected no formatting edits because test_two/configs/biome.json disables the formatter. \ + If this fails, the relative configurationPath was resolved against the wrong workspace folder." + ); + + server.shutdown().await?; + reader.abort(); + + Ok(()) +} + // #endregion // #region TEST UTILS diff --git a/crates/biome_lsp/src/session.rs b/crates/biome_lsp/src/session.rs index 11a87db2a325..009e65f40f5c 100644 --- a/crates/biome_lsp/src/session.rs +++ b/crates/biome_lsp/src/session.rs @@ -681,11 +681,82 @@ impl Session { .and_then(|s| s.configuration_path()) } + /// Resolves the user-provided `configurationPath` setting to an absolute path. + /// + /// If the path is already absolute, it is returned as-is. If it is relative, + /// it is resolved against the appropriate workspace root: + /// + /// - When `file_path` is provided (e.g. from `did_open`), the workspace folder + /// that contains the file is used as the base for resolution. This ensures + /// that in a multi-root workspace, the relative path is resolved against the + /// correct root rather than an arbitrary one. + /// - When `file_path` is `None` (e.g. from `load_workspace_settings`), each + /// workspace folder is tried in order. The closest path to the `file_path` is used. + /// - If no workspace folders are registered, the session's `base_path()` + /// (derived from the deprecated `root_uri` initialization parameter) is used + /// as a fallback to keep backwards compatibility. + /// + /// Returns `None` if no `configurationPath` is set in the extension settings. + pub(crate) fn resolve_configuration_path( + &self, + file_path: Option<&Utf8PathBuf>, + ) -> Option { + let config_path = self.get_settings_configuration_path()?; + + if config_path.is_absolute() { + return Some(config_path); + } + + // Collect workspace folder roots as absolute Utf8PathBufs. + let workspace_roots: Vec = self + .get_workspace_folders() + .unwrap_or_default() + .into_iter() + .filter_map(|folder| { + folder + .uri + .to_file_path() + .and_then(|p| Utf8PathBuf::from_path_buf(p.to_path_buf()).ok()) + }) + .collect(); + + if let Some(file_path) = file_path { + // Find the workspace folder that contains this file and resolve + // the relative config path against that folder. + if let Some(root) = workspace_roots + .iter() + .filter(|root| file_path.starts_with(*root)) + .max_by_key(|root| root.as_str().len()) + { + return Some(root.join(&config_path)); + } + } + + // No file context, or the file doesn't belong to any known workspace + // folder. Without a file to anchor against, we cannot determine which + // folder the relative path belongs to, so we fall back to the first + // registered workspace folder. This is a best-effort guess; the + // correct per-file resolution happens in `did_open` where the file + // path is available. + if let Some(root) = workspace_roots.first() { + return Some(root.join(&config_path)); + } + + // Fall back to the (deprecated) root_uri base path. + if let Some(base) = self.base_path() { + return Some(base.join(&config_path)); + } + + // Nothing to resolve against; return the path unchanged and let the + // downstream loader produce a meaningful error. + Some(config_path) + } + /// This function attempts to read the `biome.json` configuration file from /// the root URI and update the workspace settings accordingly #[tracing::instrument(level = "debug", skip(self))] pub(crate) async fn load_workspace_settings(self: &Arc, reload: bool) { - if let Some(config_path) = self.get_settings_configuration_path() { + if let Some(config_path) = self.resolve_configuration_path(None) { info!("Detected configuration path in the workspace settings."); self.set_configuration_status(ConfigurationStatus::Loading);