Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/weak-boxes-look.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 10 additions & 3 deletions crates/biome_lsp/src/handlers/text_document.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions crates/biome_lsp/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
200 changes: 200 additions & 0 deletions crates/biome_lsp/src/server.tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://github.com/biomejs/biome/issues/9217>
#[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<Vec<TextEdit>> = 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 <https://github.com/biomejs/biome/issues/9217>
#[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 `<folder>/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<Vec<TextEdit>> = 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
Expand Down
73 changes: 72 additions & 1 deletion crates/biome_lsp/src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Utf8PathBuf> {
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<Utf8PathBuf> = 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<Self>, 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);

Expand Down