Skip to content
Draft
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/brown-impalas-sniff.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@biomejs/biome": minor
---

Added a migrate quick fix for `biome.json` and `biome.jsonc` in the Biome LSP. Editors can now offer the same native configuration migrations as `biome migrate`, including config structure and rule migration updates, directly from published configuration diagnostics.
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

63 changes: 14 additions & 49 deletions crates/biome_cli/src/execute/migrate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use crate::commands::MigrateSubCommand;
use crate::diagnostics::MigrationDiagnostic;
use crate::runner::diagnostics::{ContentDiffAdvice, MigrateDiffDiagnostic};
use crate::{CliDiagnostic, CliSession};
use biome_analyze::{ActionFilter, AnalysisFilter};
use biome_analyze::AnalysisFilter;
use biome_configuration::Configuration;
use biome_console::fmt::{Display, Formatter};
use biome_console::{Console, ConsoleExt, markup};
Expand All @@ -13,16 +13,13 @@ use biome_diagnostics::{
};
use biome_fs::{BiomePath, ConfigName, OpenOptions};
use biome_json_parser::{JsonParserOptions, parse_json_with_cache};
use biome_json_syntax::{JsonFileSource, JsonRoot};
use biome_migrate::{ControlFlow, migrate_configuration};
use biome_rowan::{AstNode, NodeCache};
use biome_json_syntax::JsonFileSource;
use biome_migrate::migrate_configuration_tree;
use biome_rowan::NodeCache;
use biome_service::Workspace;
use biome_service::projects::ProjectKey;
use biome_service::workspace::{
ChangeFileParams, FileContent, FixAction, FormatFileParams, OpenFileParams,
};
use biome_service::workspace::{ChangeFileParams, FileContent, FormatFileParams, OpenFileParams};
use camino::Utf8PathBuf;
use std::borrow::Cow;
use std::collections::BTreeSet;
use std::fmt::Debug;

Expand Down Expand Up @@ -365,53 +362,21 @@ fn migrate_file(payload: MigrateFile) -> Result<MigrationFileResult, CliDiagnost
Ok(result)
}
None => {
let mut tree = parsed.tree();
let mut actions = Vec::new();
let is_root = workspace.fs().working_directory().is_some_and(|wd| {
configuration_file_path.strip_prefix(wd).is_ok_and(|path| {
path.starts_with(ConfigName::biome_json())
|| path.starts_with(ConfigName::biome_jsonc())
})
});
loop {
let (action, _) = migrate_configuration(
&tree,
AnalysisFilter::default(),
configuration_file_path.as_path(),
is_root,
|signal| {
if let Some(action) = signal.actions(ActionFilter::rule_fix()).next() {
return ControlFlow::Break(action);
}
ControlFlow::Continue(())
},
);
match action {
Some(action) => {
if let (root, Some((range, _))) =
action.mutation.commit_with_text_range_and_edit(true)
{
tree = match JsonRoot::cast(root) {
Some(tree) => tree,
None => {
return Err(CliDiagnostic::check_error(category!("migrate")));
}
};
actions.push(FixAction {
rule_name: action.rule_name.map(|(group, rule)| {
(Cow::Borrowed(group), Cow::Borrowed(rule))
}),
range,
});
}
}
None => {
break;
}
}
}

let new_configuration_content = tree.to_string();
let new_configuration_content = match migrate_configuration_tree(
&parsed.tree(),
AnalysisFilter::default(),
configuration_file_path.as_path(),
is_root,
) {
Some(tree) => tree.to_string(),
None => biome_config_content.clone(),
};
if biome_config_content != new_configuration_content {
if write {
let mut configuration_file = biome_config_file;
Expand Down
6 changes: 4 additions & 2 deletions crates/biome_fs/src/path.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,11 +135,11 @@ impl BiomePath {
}

/// The priority of the file.
/// - `biome.json` and `biome.jsonc` have the highest priority
/// - Biome configuration files have the highest priority
/// - `package.json`, `tsconfig.json`/`jsconfig.json`, `turbo.json`, and `pnpm-workspace.yaml` have the second-highest priority, and they are considered as manifest files
/// - Other files are considered as files to handle
fn priority(file_name: &str) -> FileKinds {
if file_name == ConfigName::biome_json() || file_name == ConfigName::biome_jsonc() {
if ConfigName::file_names().contains(&file_name) {
FileKinds::Config
Comment on lines 141 to 143
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n -C2 'ConfigName::biome_json\(\)|ConfigName::biome_jsonc\(\)' \
  crates/biome_service/src crates/biome_lsp/src crates/biome_fs/src

Repository: biomejs/biome

Length of output: 2718


🏁 Script executed:

# Find ConfigName definition and file_names() implementation
rg -n 'fn file_names|impl ConfigName' crates/biome_fs/src -A 5

Repository: biomejs/biome

Length of output: 763


🏁 Script executed:

# Check the exact implementation of BiomePath::priority in path.rs
sed -n '130,160p' crates/biome_fs/src/path.rs

Repository: biomejs/biome

Length of output: 1141


🏁 Script executed:

# Verify if there are hidden config variants mentioned anywhere
rg -n '\.biome\.json' crates/biome_service/src crates/biome_lsp/src crates/biome_fs/src

Repository: biomejs/biome

Length of output: 370


🏁 Script executed:

rg -n 'is_config\(\)' crates/biome_service/src crates/biome_lsp/src

Repository: biomejs/biome

Length of output: 595


🏁 Script executed:

sed -n '1440,1450p' crates/biome_service/src/workspace/server.rs

Repository: biomejs/biome

Length of output: 636


🏁 Script executed:

sed -n '255,265p' crates/biome_service/src/projects.rs

Repository: biomejs/biome

Length of output: 565


🏁 Script executed:

sed -n '564,580p' crates/biome_service/src/file_handlers/json.rs

Repository: biomejs/biome

Length of output: 690


Hidden config variants are classified but not validated.

The change promotes .biome.json and .biome.jsonc to FileKinds::Config via ConfigName::file_names(), and tests confirm this. However, crates/biome_service/src/file_handlers/json.rs:566–567 still checks only ConfigName::biome_json() and ConfigName::biome_jsonc() to decide whether to deserialise the config and emit diagnostics. Hidden variants will now be classified as config files but won't trigger config validation.

Either add hidden variants to the validation check in file_handlers/json.rs, or remove them from ConfigName::file_names() if they're not ready for full support.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/biome_fs/src/path.rs` around lines 141 - 143, The priority() logic now
classifies hidden names from ConfigName::file_names() as FileKinds::Config but
file_handlers/json.rs still only validates ConfigName::biome_json() and
ConfigName::biome_jsonc(), so either (A) extend the validation condition in
file_handlers/json.rs (the deserialisation/diagnostics branch that checks
ConfigName::biome_json() and ConfigName::biome_jsonc()) to include the hidden
variants returned by ConfigName::file_names(), or (B) revert the change by
removing the hidden variants from ConfigName::file_names() so priority() no
longer marks them as FileKinds::Config; choose one approach and update the
corresponding tests (or remove expectations) so classification and validation
behavior stay consistent.

} else if matches!(
file_name,
Expand Down Expand Up @@ -345,6 +345,8 @@ mod test {
);
assert_eq!(BiomePath::priority("biome.json"), FileKinds::Config);
assert_eq!(BiomePath::priority("biome.jsonc"), FileKinds::Config);
assert_eq!(BiomePath::priority(".biome.json"), FileKinds::Config);
assert_eq!(BiomePath::priority(".biome.jsonc"), FileKinds::Config);
assert_eq!(BiomePath::priority(".gitignore"), FileKinds::Ignore);
assert_eq!(BiomePath::priority(".ignore"), FileKinds::Ignore);
}
Expand Down
1 change: 1 addition & 0 deletions crates/biome_lsp/src/capabilities.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ use tower_lsp_server::ls_types::{

pub(crate) const DEFAULT_CODE_ACTION_CAPABILITIES: &[&str] = &[
"quickfix.biome",
"quickfix.biome.migrateConfiguration",
// quickfix.suppressRule
SUPPRESSION_TOP_LEVEL_ACTION_CATEGORY,
SUPPRESSION_INLINE_ACTION_CATEGORY,
Expand Down
76 changes: 61 additions & 15 deletions crates/biome_lsp/src/handlers/analysis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ use biome_service::file_handlers::vue::VueFileHandler;
use biome_service::workspace::{
CheckFileSizeParams, FeaturesBuilder, FileFeaturesResult, FixFileMode, FixFileParams,
GetFileContentParams, IgnoreKind, PathIsIgnoredParams, ProjectKey, PullActionsParams,
SupportsFeatureParams,
PullConfigurationActionsParams, SupportsFeatureParams,
};
use biome_service::{WorkspaceError, extension_error};
use serde_json::Value;
Expand All @@ -34,6 +34,8 @@ use tracing::{debug, info};
const FIX_ALL_CATEGORY: ActionCategory = ActionCategory::Source(SourceActionKind::FixAll);
const ORGANIZE_IMPORTS_CATEGORY: ActionCategory =
ActionCategory::Source(SourceActionKind::OrganizeImports);
const CONFIG_MIGRATE_QUICKFIX_CATEGORY: ActionCategory =
ActionCategory::QuickFix(Cow::Borrowed("migrateConfiguration"));

fn fix_all_kind() -> CodeActionKind {
match FIX_ALL_CATEGORY.to_str() {
Expand Down Expand Up @@ -91,10 +93,6 @@ pub(crate) fn code_actions(
.build(),
})?;

if !file_features.supports_lint() && !file_features.supports_assist() {
info!("Linter and assist are disabled.");
return Ok(Some(Vec::new()));
}
let mut categories = RuleCategoriesBuilder::default();
if file_features.supports_lint() {
categories = categories.with_lint();
Expand All @@ -115,6 +113,7 @@ pub(crate) fn code_actions(
let mut has_organize_imports = false;
let mut filters = Vec::new();
if let Some(filter) = &params.context.only {
filters.reserve(filter.len());
for kind in filter {
let kind = kind.as_str();
if FIX_ALL_CATEGORY.matches(kind) {
Expand All @@ -130,6 +129,20 @@ pub(crate) fn code_actions(
let position_encoding = session.position_encoding();

let diagnostics = params.context.diagnostics;
let migrate_configuration_action = config_migrate_code_action(
session,
&url,
&path,
&doc.line_index,
position_encoding,
&filters,
doc.project_key,
)?;
if !file_features.supports_lint() && !file_features.supports_assist() {
info!("Linter and assist are disabled.");
return Ok(migrate_configuration_action.map(|action| vec![action]));
}

let content = session.workspace.get_file_content(GetFileContentParams {
project_key: doc.project_key,
path: path.clone(),
Expand Down Expand Up @@ -325,6 +338,7 @@ pub(crate) fn code_actions(
Some(CodeActionOrCommand::CodeAction(lsp_action))
})
.chain(fix_all)
.chain(migrate_configuration_action)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

This quick fix still gets filtered out in mixed-action files.

After chaining migrate_configuration_action here, the later has_fixes retain pass keeps only source.fixAll.biome or actions with diagnostics. config_migrate_code_action() sets diagnostics: None, so the new quick fix vanishes as soon as any ordinary diagnostic-fixing action is present.

💡 Minimal fix
-                action.kind.as_ref() == Some(&fix_all_kind()) || action.diagnostics.is_some()
+                action.kind.as_ref() == Some(&fix_all_kind())
+                    || action.kind.as_ref() == Some(&config_migrate_kind())
+                    || action.diagnostics.is_some()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/biome_lsp/src/handlers/analysis.rs` at line 293, The
migrate_configuration_action is being dropped by the has_fixes filter because
config_migrate_code_action() sets diagnostics: None; change
config_migrate_code_action() to provide diagnostics: Some(vec![]) (an empty Vec)
or set its kind to include the source.fixAll biome kind (e.g.,
CodeActionKind::SOURCE_FIX_ALL / "source.fixAll.biome") so it survives the
has_fixes retain; update the function config_migrate_code_action() accordingly
so the chained .chain(migrate_configuration_action) produces a code action that
either has Some(diagnostics) or the source.fixAll biome kind.

.collect();

// If any actions is marked as fixing a diagnostic, hide other actions
Expand Down Expand Up @@ -556,16 +570,6 @@ fn fix_all(
})?;
let should_format = file_features.supports_format();

if session.workspace.is_path_ignored(PathIsIgnoredParams {
path: path.clone(),
is_dir: false,
project_key: doc.project_key,
features: analyzer_features,
ignore_kind: IgnoreKind::Ancestors,
})? {
return Ok(None);
}

let size_limit_result = session.workspace.check_file_size(CheckFileSizeParams {
project_key: doc.project_key,
path: path.clone(),
Expand Down Expand Up @@ -701,3 +705,45 @@ fn fix_all(
data: None,
})))
}

fn config_migrate_code_action(
session: &Session,
url: &Uri,
path: &BiomePath,
line_index: &LineIndex,
position_encoding: biome_lsp_converters::PositionEncoding,
filters: &[&str],
project_key: biome_service::projects::ProjectKey,
) -> Result<Option<CodeActionOrCommand>, Error> {
if !path.is_config() {
return Ok(None);
}

if !filters.is_empty()
&& !filters
.iter()
.any(|filter| CONFIG_MIGRATE_QUICKFIX_CATEGORY.matches(filter))
{
return Ok(None);
}

let Some(action) = session
.workspace
.pull_configuration_actions(PullConfigurationActionsParams {
project_key,
path: path.clone(),
})?
.actions
.into_iter()
.next()
else {
return Ok(None);
};

Ok(
utils::code_fix_to_lsp(url, line_index, position_encoding, &[], action)
.ok()
.flatten()
.map(CodeActionOrCommand::CodeAction),
)
}
36 changes: 35 additions & 1 deletion crates/biome_lsp/src/handlers/text_document.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,22 @@ use crate::session::ConfigurationStatus;
use crate::utils::apply_document_changes;
use crate::{documents::Document, session::Session};
use biome_configuration::ConfigurationPathHint;
use biome_fs::ConfigName;
use biome_service::workspace::{
ChangeFileParams, CloseFileParams, DocumentFileSource, FeaturesBuilder, FileContent,
GetFileContentParams, IgnoreKind, OpenFileParams, PathIsIgnoredParams, ProjectKey,
GetFileContentParams, IgnoreKind, OpenFileParams, OpenProjectParams, OpenProjectResult,
PathIsIgnoredParams, ProjectKey, ScanKind,
};
use camino::{Utf8Path, Utf8PathBuf};
use std::sync::Arc;
use tower_lsp_server::ls_types as lsp;
use tracing::{debug, error, field, info, trace};

fn is_biome_configuration_file(path: &Utf8PathBuf) -> bool {
path.file_name()
.is_some_and(|file_name| ConfigName::file_names().contains(&file_name))
}

/// Handler for `textDocument/didOpen` LSP notification
#[tracing::instrument(
level = "debug",
Expand Down Expand Up @@ -124,6 +131,33 @@ async fn ensure_project_for_opened_document(
error!("Could not find project for {path}");
None
})
} else if is_biome_configuration_file(&path.to_path_buf()) {
let project_path = path
.parent()
.map(|parent| parent.to_path_buf())
.unwrap_or_default();
let OpenProjectResult { project_key } =
match session.workspace.open_project(OpenProjectParams {
path: project_path.as_path().into(),
open_uninitialized: true,
}) {
Ok(result) => result,
Err(error) => {
error!("Could not open fallback project for {path}: {error}");
return None;
}
};

session
.insert_and_scan_project(
project_key,
project_path.into(),
ScanKind::KnownFiles,
false,
)
.await;

Some(project_key)
} else {
error!("Configuration could not be loaded for {path}");
None
Expand Down
Loading
Loading