Skip to content

Commit d5ee469

Browse files
ematipicoarendjr
andauthored
fix(lsp): correctly resolve configurationPath (#9323)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: arendjr <[email protected]>
1 parent 6294aa2 commit d5ee469

5 files changed

Lines changed: 291 additions & 4 deletions

File tree

.changeset/weak-boxes-look.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@biomejs/biome": patch
3+
---
4+
5+
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.

crates/biome_lsp/src/handlers/text_document.rs

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,17 @@ pub(crate) async fn did_open(
4242
session.load_extension_settings(None).await;
4343
}
4444

45-
let status = if let Some(path) = session.get_settings_configuration_path() {
46-
info!("Loading user configuration from text_document {}", &path);
45+
let status = if let Some(config_path) = session.resolve_configuration_path(Some(&path))
46+
{
47+
info!(
48+
"Loading user configuration from text_document {}",
49+
&config_path
50+
);
4751
session
48-
.load_biome_configuration_file(ConfigurationPathHint::FromUser(path), false)
52+
.load_biome_configuration_file(
53+
ConfigurationPathHint::FromUser(config_path),
54+
false,
55+
)
4956
.await
5057
} else {
5158
let project_path = path

crates/biome_lsp/src/server.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,10 @@ impl LanguageServer for LSPServer {
358358
async fn did_change_configuration(&self, params: DidChangeConfigurationParams) {
359359
let settings = params.settings;
360360
self.session.load_extension_settings(Some(settings)).await;
361+
// Reload the workspace configuration so that settings such as
362+
// `configurationPath` take effect immediately without requiring the
363+
// user to restart the server or open a new file.
364+
self.session.load_workspace_settings(true).await;
361365
self.setup_capabilities().await;
362366
self.session.update_all_diagnostics().await;
363367
}

crates/biome_lsp/src/server.tests.rs

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4662,6 +4662,206 @@ const foo = 'bad'
46624662
Ok(())
46634663
}
46644664

4665+
// #region CONFIGURATION PATH RESOLUTION
4666+
4667+
/// Verifies that a relative `configurationPath` in the extension settings is
4668+
/// resolved against the workspace root URI.
4669+
///
4670+
/// Regression test for <https://github.com/biomejs/biome/issues/9217>
4671+
#[tokio::test]
4672+
async fn relative_configuration_path_resolves_against_root_uri() -> Result<()> {
4673+
let fs = MemoryFileSystem::default();
4674+
4675+
// Place the config in a sub-directory so the path must be relative.
4676+
let config = r#"{
4677+
"formatter": {
4678+
"enabled": false
4679+
}
4680+
}"#;
4681+
fs.insert(to_utf8_file_path_buf(uri!("configs/biome.json")), config);
4682+
4683+
let factory = ServerFactory::new_with_fs(Arc::new(fs));
4684+
let (service, client) = factory.create().into_inner();
4685+
let (stream, sink) = client.split();
4686+
let mut server = Server::new(service);
4687+
4688+
let (sender, _) = channel(CHANNEL_BUFFER_SIZE);
4689+
let reader = tokio::spawn(client_handler(stream, sink, sender));
4690+
4691+
server.initialize().await?;
4692+
server.initialized().await?;
4693+
4694+
// Set configurationPath to a relative path (the bug: this used to be
4695+
// resolved against the daemon's cwd instead of the workspace root).
4696+
server
4697+
.load_configuration_with_settings(WorkspaceSettings {
4698+
configuration_path: Some("configs/biome.json".to_string()),
4699+
..Default::default()
4700+
})
4701+
.await?;
4702+
4703+
server.open_document(r#"statement( );"#).await?;
4704+
4705+
// The config disables the formatter, so formatting should return no edits.
4706+
let res: Option<Vec<TextEdit>> = server
4707+
.request(
4708+
"textDocument/formatting",
4709+
"formatting",
4710+
DocumentFormattingParams {
4711+
text_document: TextDocumentIdentifier {
4712+
uri: uri!("document.js"),
4713+
},
4714+
options: FormattingOptions {
4715+
tab_size: 4,
4716+
insert_spaces: false,
4717+
properties: HashMap::default(),
4718+
trim_trailing_whitespace: None,
4719+
insert_final_newline: None,
4720+
trim_final_newlines: None,
4721+
},
4722+
work_done_progress_params: WorkDoneProgressParams {
4723+
work_done_token: None,
4724+
},
4725+
},
4726+
)
4727+
.await?
4728+
.context("formatting returned None")?;
4729+
4730+
assert!(
4731+
res.is_none(),
4732+
"Expected no formatting edits because the config at configs/biome.json disables the formatter. \
4733+
If this fails, the relative configurationPath was not resolved against the workspace root."
4734+
);
4735+
4736+
server.close_document().await?;
4737+
server.shutdown().await?;
4738+
reader.abort();
4739+
4740+
Ok(())
4741+
}
4742+
4743+
/// Verifies that a relative `configurationPath` is resolved against the
4744+
/// workspace folder that **contains the file being opened**, not always the
4745+
/// first workspace folder.
4746+
///
4747+
/// Regression test for <https://github.com/biomejs/biome/issues/9217>
4748+
#[tokio::test]
4749+
#[expect(deprecated)]
4750+
async fn relative_configuration_path_resolves_against_correct_workspace_folder() -> Result<()> {
4751+
let fs = MemoryFileSystem::default();
4752+
4753+
// test_one has formatting enabled (default), test_two disables it.
4754+
// Both configs live at `<folder>/configs/biome.json`.
4755+
let config_one = r#"{}"#;
4756+
let config_two = r#"{
4757+
"formatter": {
4758+
"enabled": false
4759+
}
4760+
}"#;
4761+
4762+
fs.insert(
4763+
to_utf8_file_path_buf(uri!("test_one/configs/biome.json")),
4764+
config_one,
4765+
);
4766+
fs.insert(
4767+
to_utf8_file_path_buf(uri!("test_two/configs/biome.json")),
4768+
config_two,
4769+
);
4770+
4771+
let factory = ServerFactory::new_with_fs(Arc::new(fs));
4772+
let (service, client) = factory.create().into_inner();
4773+
let (stream, sink) = client.split();
4774+
let mut server = Server::new(service);
4775+
4776+
let (sender, _) = channel(CHANNEL_BUFFER_SIZE);
4777+
let reader = tokio::spawn(client_handler(stream, sink, sender));
4778+
4779+
// Initialize with two workspace folders (test_one, test_two).
4780+
let _res: InitializeResult = server
4781+
.request(
4782+
"initialize",
4783+
"_init",
4784+
InitializeParams {
4785+
process_id: None,
4786+
root_path: None,
4787+
root_uri: Some(uri!("/")),
4788+
initialization_options: None,
4789+
capabilities: ClientCapabilities::default(),
4790+
trace: None,
4791+
workspace_folders: Some(vec![
4792+
WorkspaceFolder {
4793+
name: "test_one".to_string(),
4794+
uri: uri!("test_one"),
4795+
},
4796+
WorkspaceFolder {
4797+
name: "test_two".to_string(),
4798+
uri: uri!("test_two"),
4799+
},
4800+
]),
4801+
client_info: None,
4802+
locale: None,
4803+
work_done_progress_params: Default::default(),
4804+
},
4805+
)
4806+
.await?
4807+
.context("initialize returned None")?;
4808+
4809+
server.initialized().await?;
4810+
4811+
// Set a relative configurationPath. Each workspace folder has its own
4812+
// `configs/biome.json`; the correct one must be picked per file.
4813+
server
4814+
.load_configuration_with_settings(WorkspaceSettings {
4815+
configuration_path: Some("configs/biome.json".to_string()),
4816+
..Default::default()
4817+
})
4818+
.await?;
4819+
4820+
// Open a file in test_two — its config disables the formatter.
4821+
server
4822+
.open_named_document(
4823+
r#"statement( );"#,
4824+
uri!("test_two/document.js"),
4825+
"javascript",
4826+
)
4827+
.await?;
4828+
4829+
let res: Option<Vec<TextEdit>> = server
4830+
.request(
4831+
"textDocument/formatting",
4832+
"formatting",
4833+
DocumentFormattingParams {
4834+
text_document: TextDocumentIdentifier {
4835+
uri: uri!("test_two/document.js"),
4836+
},
4837+
options: FormattingOptions {
4838+
tab_size: 4,
4839+
insert_spaces: false,
4840+
properties: HashMap::default(),
4841+
trim_trailing_whitespace: None,
4842+
insert_final_newline: None,
4843+
trim_final_newlines: None,
4844+
},
4845+
work_done_progress_params: WorkDoneProgressParams {
4846+
work_done_token: None,
4847+
},
4848+
},
4849+
)
4850+
.await?
4851+
.context("formatting returned None")?;
4852+
4853+
assert!(
4854+
res.is_none(),
4855+
"Expected no formatting edits because test_two/configs/biome.json disables the formatter. \
4856+
If this fails, the relative configurationPath was resolved against the wrong workspace folder."
4857+
);
4858+
4859+
server.shutdown().await?;
4860+
reader.abort();
4861+
4862+
Ok(())
4863+
}
4864+
46654865
// #endregion
46664866

46674867
// #region TEST UTILS

crates/biome_lsp/src/session.rs

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -681,11 +681,82 @@ impl Session {
681681
.and_then(|s| s.configuration_path())
682682
}
683683

684+
/// Resolves the user-provided `configurationPath` setting to an absolute path.
685+
///
686+
/// If the path is already absolute, it is returned as-is. If it is relative,
687+
/// it is resolved against the appropriate workspace root:
688+
///
689+
/// - When `file_path` is provided (e.g. from `did_open`), the workspace folder
690+
/// that contains the file is used as the base for resolution. This ensures
691+
/// that in a multi-root workspace, the relative path is resolved against the
692+
/// correct root rather than an arbitrary one.
693+
/// - When `file_path` is `None` (e.g. from `load_workspace_settings`), each
694+
/// workspace folder is tried in order. The closest path to the `file_path` is used.
695+
/// - If no workspace folders are registered, the session's `base_path()`
696+
/// (derived from the deprecated `root_uri` initialization parameter) is used
697+
/// as a fallback to keep backwards compatibility.
698+
///
699+
/// Returns `None` if no `configurationPath` is set in the extension settings.
700+
pub(crate) fn resolve_configuration_path(
701+
&self,
702+
file_path: Option<&Utf8PathBuf>,
703+
) -> Option<Utf8PathBuf> {
704+
let config_path = self.get_settings_configuration_path()?;
705+
706+
if config_path.is_absolute() {
707+
return Some(config_path);
708+
}
709+
710+
// Collect workspace folder roots as absolute Utf8PathBufs.
711+
let workspace_roots: Vec<Utf8PathBuf> = self
712+
.get_workspace_folders()
713+
.unwrap_or_default()
714+
.into_iter()
715+
.filter_map(|folder| {
716+
folder
717+
.uri
718+
.to_file_path()
719+
.and_then(|p| Utf8PathBuf::from_path_buf(p.to_path_buf()).ok())
720+
})
721+
.collect();
722+
723+
if let Some(file_path) = file_path {
724+
// Find the workspace folder that contains this file and resolve
725+
// the relative config path against that folder.
726+
if let Some(root) = workspace_roots
727+
.iter()
728+
.filter(|root| file_path.starts_with(*root))
729+
.max_by_key(|root| root.as_str().len())
730+
{
731+
return Some(root.join(&config_path));
732+
}
733+
}
734+
735+
// No file context, or the file doesn't belong to any known workspace
736+
// folder. Without a file to anchor against, we cannot determine which
737+
// folder the relative path belongs to, so we fall back to the first
738+
// registered workspace folder. This is a best-effort guess; the
739+
// correct per-file resolution happens in `did_open` where the file
740+
// path is available.
741+
if let Some(root) = workspace_roots.first() {
742+
return Some(root.join(&config_path));
743+
}
744+
745+
// Fall back to the (deprecated) root_uri base path.
746+
if let Some(base) = self.base_path() {
747+
return Some(base.join(&config_path));
748+
}
749+
750+
// Nothing to resolve against; return the path unchanged and let the
751+
// downstream loader produce a meaningful error.
752+
Some(config_path)
753+
}
754+
684755
/// This function attempts to read the `biome.json` configuration file from
685756
/// the root URI and update the workspace settings accordingly
686757
#[tracing::instrument(level = "debug", skip(self))]
687758
pub(crate) async fn load_workspace_settings(self: &Arc<Self>, reload: bool) {
688-
if let Some(config_path) = self.get_settings_configuration_path() {
759+
if let Some(config_path) = self.resolve_configuration_path(None) {
689760
info!("Detected configuration path in the workspace settings.");
690761
self.set_configuration_status(ConfigurationStatus::Loading);
691762

0 commit comments

Comments
 (0)