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/fix-lsp-project-selection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@biomejs/biome": patch
---

Fixed [#1630](https://github.com/biomejs/biome/issues/1630): LSP project selection now prefers the most specific project root in nested workspaces.
191 changes: 110 additions & 81 deletions crates/biome_lsp/src/handlers/text_document.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ use crate::{documents::Document, session::Session};
use biome_configuration::ConfigurationPathHint;
use biome_service::workspace::{
ChangeFileParams, CloseFileParams, DocumentFileSource, FeaturesBuilder, FileContent,
GetFileContentParams, IgnoreKind, OpenFileParams, PathIsIgnoredParams,
GetFileContentParams, IgnoreKind, OpenFileParams, PathIsIgnoredParams, ProjectKey,
};
use camino::Utf8PathBuf;
use camino::{Utf8Path, Utf8PathBuf};
use std::sync::Arc;
use tower_lsp_server::ls_types as lsp;
use tracing::{debug, error, field, info, trace};
Expand All @@ -31,86 +31,13 @@ pub(crate) async fn did_open(
let language_hint = DocumentFileSource::from_language_id(&params.text_document.language_id);

let path = session.file_path(&url)?;
let file_path = path.to_path_buf();
let config_path = session.resolve_configuration_path(Some(&file_path));

let project_key = match session.project_for_path(&path) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was moved / split up into into helper functions (load_project_for_open, load_status_for_open, load_from_workspace_base, etc.). Because the new logic for choosing the most specific project root would have made did_open even more nested and harder to reason about. The refactor keeps behaviour clear and testable.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The refactor keeps behaviour clear and testable.

And yet no tests were added ;)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ha great point. I trusted AI with that summary of the change — apologies. I did get it to try and add tests but I think that code requires too much setup to be tested in more of a unit-test-style. Which means this refactor is mainly done for non-test reasons — just to reduce the nesting / indentation of that function as it was getting big.

Some(project_key) => project_key,
None => {
info!("No open project for path: {path:?}. Opening new project.");

session.set_configuration_status(ConfigurationStatus::Loading);
if !session.has_initialized() {
session.load_extension_settings(None).await;
}

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(config_path, false)
.await
} else {
let project_path = path
.parent()
.map(|parent| parent.to_path_buf())
.unwrap_or_default();
info!("Loading configuration from text_document {}", &project_path);
// First check if the current file belongs to any registered workspace folder.
// If so, return that folder; otherwise, use the folder computed by did_open.
let project_path = if let Some(workspace_folders) = session.get_workspace_folders()
{
if let Some(ws_root) = workspace_folders
.iter()
.filter_map(|folder| {
folder.uri.to_file_path().map(|p| {
Utf8PathBuf::from_path_buf(p.to_path_buf())
.expect("To have a valid UTF-8 path")
})
})
.find(|ws| project_path.starts_with(ws))
{
ws_root
} else {
project_path.clone()
}
} else if let Some(base_path) = session.base_path() {
if project_path.starts_with(&base_path) {
base_path
} else {
project_path.clone()
}
} else {
project_path
};

session
.load_biome_configuration_file(
ConfigurationPathHint::FromLsp(project_path),
false,
)
.await
};

session.set_configuration_status(status);

if status.is_loaded() {
match session.project_for_path(&path) {
Some(project_key) => project_key,

None => {
error!("Could not find project for {path}");

return Ok(());
}
}
} else {
error!("Configuration could not be loaded for {path}");

return Ok(());
}
}
let Some(project_key) =
ensure_project_for_opened_document(session, &path, config_path.as_ref()).await
else {
return Ok(());
};

let is_ignored = session
Expand Down Expand Up @@ -148,6 +75,108 @@ pub(crate) async fn did_open(
Ok(())
}

/// Ensure the file is associated with a project and the correct configuration
/// is loaded before opening the document.
async fn ensure_project_for_opened_document(
session: &Arc<Session>,
path: &Utf8Path,
config_path: Option<&ConfigurationPathHint>,
) -> Option<ProjectKey> {
let is_relative_config_path = session
.get_settings_configuration_path()
.is_some_and(|config_path| !config_path.is_absolute());

let mut load_status = None;

if let Some(project_key) = session.project_for_path(path) {
if is_relative_config_path {
// Absolute configurationPath is global; only relative needs per-file resolution.
// Use the per-file resolved configurationPath so each workspace folder
// uses its own config, even when a project is already open.
if let Some(resolved_path) = config_path {
session.set_configuration_status(ConfigurationStatus::Loading);
let status = session
.load_biome_configuration_file(resolved_path.clone(), false)
.await;
load_status = Some(status);
}
}
if load_status.is_none() {
return Some(project_key);
}
} else {
info!("No open project for path: {path:?}. Opening new project.");
}

if load_status.is_none() {
session.set_configuration_status(ConfigurationStatus::Loading);
if !session.has_initialized() {
session.load_extension_settings(None).await;
}
load_status = Some(load_from_workspace_root_for_path(session, path, config_path).await);
}
let status = load_status.expect("load_status should be set");

session.set_configuration_status(status);

if status.is_loaded() {
session.project_for_path(path).or_else(|| {
error!("Could not find project for {path}");
None
})
} else {
error!("Configuration could not be loaded for {path}");
None
}
}

/// Load configuration anchored to the workspace root that contains `path`.
async fn load_from_workspace_root_for_path(
session: &Arc<Session>,
path: &Utf8Path,
config_path: Option<&ConfigurationPathHint>,
) -> ConfigurationStatus {
if let Some(path) = config_path {
info!("Loading user configuration from text_document {:?}", &path);
return session
.load_biome_configuration_file(path.clone(), false)
.await;
}

let project_path = path
.parent()
.map(|parent| parent.to_path_buf())
.unwrap_or_default();
info!("Loading configuration from text_document {}", &project_path);
let project_path = resolve_workspace_base_path(session, &project_path);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We now call resolve_configuration_path(...) to anchor relative configurationPath to the file’s workspace folder.


session
.load_biome_configuration_file(ConfigurationPathHint::FromLsp(project_path), false)
.await
}

fn resolve_workspace_base_path(session: &Session, project_path: &Utf8Path) -> Utf8PathBuf {
let workspace_base = || {
let workspace_folders = session.get_workspace_folders()?;
workspace_folders
.iter()
.filter_map(|folder| {
folder.uri.to_file_path().map(|p| {
Utf8PathBuf::from_path_buf(p.to_path_buf()).expect("To have a valid UTF-8 path")
})
})
.filter(|ws| project_path.starts_with(ws))
.max_by_key(|ws| ws.as_str().len())
};
workspace_base()
.or_else(|| {
session
.base_path()
.and_then(|base_path| project_path.starts_with(&base_path).then_some(base_path))
})
.unwrap_or_else(|| project_path.to_path_buf())
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/// Handler for `textDocument/didChange` LSP notification
#[tracing::instrument(level = "debug", skip_all, fields(url = field::display(&params.text_document.uri.as_str()), version = params.text_document.version), err)]
pub(crate) async fn did_change(
Expand Down
3 changes: 3 additions & 0 deletions crates/biome_lsp/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -445,7 +445,10 @@ impl LanguageServer for LSPServer {

self.session
.update_workspace_folders(params.event.added, params.event.removed);
self.session.clear_configuration_cache().await;
self.session.load_workspace_settings(true).await;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
self.setup_capabilities().await;
self.session.update_all_diagnostics().await;
}

async fn code_action(&self, params: CodeActionParams) -> LspResult<Option<CodeActionResponse>> {
Expand Down
11 changes: 10 additions & 1 deletion crates/biome_lsp/src/server.tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4827,7 +4827,16 @@ async fn relative_configuration_path_resolves_against_correct_workspace_folder()
})
.await?;

// Open a file in test_two — its config disables the formatter.
// Open a file in test_one first, so a project is already open.
server
.open_named_document(
r#"statement( );"#,
uri!("test_one/document.js"),
"javascript",
)
.await?;

// Now open a file in test_two — its config disables the formatter.
server
.open_named_document(
r#"statement( );"#,
Expand Down
Loading