diff --git a/.changeset/plugin-enabled-option.md b/.changeset/plugin-enabled-option.md new file mode 100644 index 000000000000..b7c3f49d1713 --- /dev/null +++ b/.changeset/plugin-enabled-option.md @@ -0,0 +1,31 @@ +--- +"@biomejs/biome": minor +--- + +Add `severity` option to plugin configuration. Plugins can now be configured using an object form with `path` and `severity` fields. + +```json +{ + "plugins": [ + "./my-plugin.grit", + { "path": "./other-plugin.grit", "severity": "off" } + ] +} +``` + +Supported severity values: +- `"off"`: Disable the plugin entirely (no diagnostics emitted). +- `"warn"`: Override plugin diagnostics to warning severity. +- `"error"`: Override plugin diagnostics to error severity (default). + +This allows configuring plugin behavior per-path via overrides: + +```json +{ + "plugins": ["./my-plugin.grit"], + "overrides": [{ + "includes": ["scripts/**"], + "plugins": [{ "path": "./my-plugin.grit", "severity": "off" }] + }] +} +``` diff --git a/crates/biome_analyze/src/analyzer_plugin.rs b/crates/biome_analyze/src/analyzer_plugin.rs index 05b46c943e22..5b2e48f0032f 100644 --- a/crates/biome_analyze/src/analyzer_plugin.rs +++ b/crates/biome_analyze/src/analyzer_plugin.rs @@ -103,19 +103,38 @@ where .plugin .evaluate(node.clone().into(), ctx.options.file_path.clone()) .into_iter() - .map(|diagnostic| { + .filter_map(|diagnostic| { let name = diagnostic .subcategory .clone() .unwrap_or_else(|| "anonymous".into()); - SignalEntry { + // Check if there's a severity override for this plugin + if let Some(severity_override) = ctx.options.plugin_severity(&name) { + match severity_override { + // Plugin is disabled - skip this diagnostic + None => return None, + // Override the severity + Some(severity) => { + let diagnostic = diagnostic.with_severity(severity); + return Some(SignalEntry { + text_range: diagnostic.span().unwrap_or_default(), + signal: Box::new(DiagnosticSignal::new(move || diagnostic.clone())), + rule: SignalRuleKey::Plugin(name.into()), + category: RuleCategory::Lint, + instances: Default::default(), + }); + } + } + } + + Some(SignalEntry { text_range: diagnostic.span().unwrap_or_default(), signal: Box::new(DiagnosticSignal::new(move || diagnostic.clone())), rule: SignalRuleKey::Plugin(name.into()), category: RuleCategory::Lint, instances: Default::default(), - } + }) }); ctx.signal_queue.extend(signals); diff --git a/crates/biome_analyze/src/lib.rs b/crates/biome_analyze/src/lib.rs index 69e468f1c355..38598fabc2cf 100644 --- a/crates/biome_analyze/src/lib.rs +++ b/crates/biome_analyze/src/lib.rs @@ -38,7 +38,9 @@ pub use crate::categories::{ pub use crate::diagnostics::{AnalyzerDiagnostic, AnalyzerSuppressionDiagnostic, RuleError}; use crate::matcher::SignalRuleKey; pub use crate::matcher::{InspectMatcher, MatchQueryParams, QueryMatcher, RuleKey, SignalEntry}; -pub use crate::options::{AnalyzerConfiguration, AnalyzerOptions, AnalyzerRules}; +pub use crate::options::{ + AnalyzerConfiguration, AnalyzerOptions, AnalyzerRules, PluginSeverityMap, +}; pub use crate::query::{AddVisitor, QueryKey, QueryMatch, Queryable}; pub use crate::registry::{ LanguageRoot, MetadataRegistry, Phase, Phases, RegistryRuleMetadata, RegistryVisitor, diff --git a/crates/biome_analyze/src/options.rs b/crates/biome_analyze/src/options.rs index 5a07ad99722b..051eb563188d 100644 --- a/crates/biome_analyze/src/options.rs +++ b/crates/biome_analyze/src/options.rs @@ -1,3 +1,4 @@ +use biome_diagnostics::Severity; use camino::Utf8PathBuf; use rustc_hash::FxHashMap; @@ -6,6 +7,10 @@ use std::any::{Any, TypeId}; use std::borrow::Cow; use std::sync::Arc; +/// Map from plugin name to severity configuration. +/// None means "off" (skip the diagnostic), Some(severity) means override to that severity. +pub type PluginSeverityMap = FxHashMap, Option>; + /// A convenient new type data structure to store the options that belong to a rule #[derive(Debug)] pub struct RuleOptions(TypeId, Box, Option); @@ -77,6 +82,11 @@ pub struct AnalyzerConfiguration { /// Whether the CSS files contain CSS Modules css_modules: bool, + + /// Severity configuration for plugins. + /// Keys are plugin names (derived from file stems), values are severity overrides. + /// None means "off" (skip diagnostics), Some(severity) means override to that level. + pub plugin_severities: PluginSeverityMap, } impl AnalyzerConfiguration { @@ -117,6 +127,11 @@ impl AnalyzerConfiguration { self.css_modules = css_modules; self } + + pub fn with_plugin_severities(mut self, plugin_severities: PluginSeverityMap) -> Self { + self.plugin_severities = plugin_severities; + self + } } /// A set of information useful to the analyzer infrastructure @@ -194,6 +209,17 @@ impl AnalyzerOptions { pub fn css_modules(&self) -> bool { self.configuration.css_modules } + + /// Get the configured severity for a plugin. + /// Returns None if no configuration exists for this plugin. + /// Returns Some(None) if the plugin is configured as "off". + /// Returns Some(Some(severity)) if a severity override is configured. + pub fn plugin_severity(&self, plugin_name: &str) -> Option> { + self.configuration + .plugin_severities + .get(plugin_name) + .copied() + } } #[derive(Clone, Copy, Debug, Default)] diff --git a/crates/biome_plugin_loader/src/configuration.rs b/crates/biome_plugin_loader/src/configuration.rs index 486998735d53..ba9378941919 100644 --- a/crates/biome_plugin_loader/src/configuration.rs +++ b/crates/biome_plugin_loader/src/configuration.rs @@ -4,6 +4,7 @@ use biome_deserialize::{ use biome_deserialize_macros::{Deserializable, Merge}; use biome_fs::normalize_path; use camino::Utf8Path; +use rustc_hash::FxHashMap; use serde::{Deserialize, Serialize}; use std::{ ops::{Deref, DerefMut}, @@ -15,28 +16,40 @@ use std::{ #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct Plugins(pub Vec); +/// Map from plugin name to severity configuration +pub type PluginSeverityMap = FxHashMap, PluginSeverity>; + impl Plugins { pub fn iter(&self) -> impl Iterator { self.deref().iter() } + /// Builds a map of plugin name -> severity from the configuration. + /// This is used to pass severity overrides to the analyzer. + pub fn to_severity_map(&self) -> PluginSeverityMap { + self.iter() + .map(|config| (config.name().into(), config.severity())) + .collect() + } + /// Normalizes plugin paths in-place. /// /// For each relative path, this joins it with `base_dir` and normalizes /// `.` / `..` segments (without resolving symlinks). pub fn normalize_relative_paths(&mut self, base_dir: &Utf8Path) { for plugin_config in self.0.iter_mut() { - match plugin_config { - PluginConfiguration::Path(plugin_path) => { - let plugin_path_buf = Utf8Path::new(plugin_path.as_str()); - if plugin_path_buf.is_absolute() { - continue; - } - - let normalized = normalize_path(&base_dir.join(plugin_path_buf)); - *plugin_path = normalized.to_string(); - } + let plugin_path = match plugin_config { + PluginConfiguration::Path(path) => path, + PluginConfiguration::WithOptions(opts) => &mut opts.path, + }; + + let plugin_path_buf = Utf8Path::new(plugin_path.as_str()); + if plugin_path_buf.is_absolute() { + continue; } + + let normalized = normalize_path(&base_dir.join(plugin_path_buf)); + *plugin_path = normalized.to_string(); } } } @@ -67,8 +80,88 @@ impl DerefMut for Plugins { #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[serde(rename_all = "camelCase", deny_unknown_fields, untagged)] pub enum PluginConfiguration { + /// A simple path to a plugin file Path(String), - // TODO: PathWithOptions(PluginPathWithOptions), + /// A plugin with additional options + WithOptions(PluginWithOptions), +} + +impl PluginConfiguration { + /// Returns the path to the plugin file + pub fn path(&self) -> &str { + match self { + Self::Path(path) => path, + Self::WithOptions(opts) => &opts.path, + } + } + + /// Returns the plugin name (derived from the file stem of the path) + pub fn name(&self) -> &str { + let path = self.path(); + Utf8Path::new(path).file_stem().unwrap_or(path) + } + + /// Returns the severity level for this plugin (default: Error) + pub fn severity(&self) -> PluginSeverity { + match self { + Self::Path(_) => PluginSeverity::Error, + Self::WithOptions(opts) => opts.severity.unwrap_or_default(), + } + } + + /// Returns whether the plugin has an explicit severity config. + /// This is used to determine whether to override the plugin's inline severity. + pub fn has_explicit_severity(&self) -> bool { + match self { + Self::Path(_) => false, + Self::WithOptions(opts) => opts.severity.is_some(), + } + } + + /// Returns whether the plugin is enabled (severity is not "off") + pub fn is_enabled(&self) -> bool { + self.severity() != PluginSeverity::Off + } +} + +/// Severity level for plugin diagnostics +#[derive(Clone, Copy, Debug, Default, Deserialize, Deserializable, Eq, PartialEq, Serialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub enum PluginSeverity { + /// Plugin is disabled + Off, + /// Plugin emits warnings + Warn, + /// Plugin emits errors (default) + #[default] + Error, +} + +impl PluginSeverity { + /// Converts to a diagnostic severity. + /// Returns `None` for `Off` (meaning the diagnostic should be skipped). + pub fn to_diagnostic_severity(self) -> Option { + match self { + Self::Off => None, + Self::Warn => Some(biome_diagnostics::Severity::Warning), + Self::Error => Some(biome_diagnostics::Severity::Error), + } + } +} + +/// Plugin configuration with additional options +#[derive(Clone, Debug, Default, Deserialize, Deserializable, Eq, PartialEq, Serialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PluginWithOptions { + /// Path to the plugin file + #[serde(default)] + pub path: String, + /// Severity level for the plugin's diagnostics. + /// Use "off" to disable, "warn" for warnings, "error" for errors (default). + #[serde(skip_serializing_if = "Option::is_none")] + pub severity: Option, } impl Deserializable for PluginConfiguration { @@ -77,17 +170,14 @@ impl Deserializable for PluginConfiguration { value: &impl DeserializableValue, rule_name: &str, ) -> Option { - if value.visitable_type()? == DeserializableType::Str { - Deserializable::deserialize(ctx, value, rule_name).map(Self::Path) - } else { - // TODO: Fix this to allow plugins to receive options. - // We probably need to pass them as `AnyJsonValue` or - // `biome_json_value::JsonValue`, since plugin options are - // untyped. - // Also, we don't have a way to configure Grit plugins yet. - /*Deserializable::deserialize(value, rule_name, diagnostics) - .map(|plugin| Self::PathWithOptions(plugin))*/ - None + match value.visitable_type()? { + DeserializableType::Str => { + Deserializable::deserialize(ctx, value, rule_name).map(Self::Path) + } + DeserializableType::Map => { + Deserializable::deserialize(ctx, value, rule_name).map(Self::WithOptions) + } + _ => None, } } } @@ -106,12 +196,12 @@ mod tests { plugins.normalize_relative_paths(base_dir); - let PluginConfiguration::Path(first) = &plugins.0[0]; + let first = plugins.0[0].path(); assert!(Utf8Path::new(first).starts_with(base_dir)); let expected_suffix = Utf8Path::new("biome").join("my-plugin.grit"); assert!(Utf8Path::new(first).ends_with(expected_suffix.as_path())); - let PluginConfiguration::Path(second) = &plugins.0[1]; + let second = plugins.0[1].path(); assert!(Utf8Path::new(second).starts_with(base_dir)); assert!(Utf8Path::new(second).ends_with("other.grit")); } @@ -125,7 +215,77 @@ mod tests { plugins.normalize_relative_paths(base_dir); - let PluginConfiguration::Path(result) = &plugins.0[0]; - assert_eq!(result, &absolute); + assert_eq!(plugins.0[0].path(), &absolute); + } + + #[test] + fn normalize_relative_paths_works_with_options() { + let base_dir = Utf8Path::new("base"); + let mut plugins = Plugins(vec![PluginConfiguration::WithOptions(PluginWithOptions { + path: "./my-plugin.grit".into(), + severity: Some(PluginSeverity::Off), + })]); + + plugins.normalize_relative_paths(base_dir); + + let first = plugins.0[0].path(); + assert!(Utf8Path::new(first).starts_with(base_dir)); + assert!(Utf8Path::new(first).ends_with("my-plugin.grit")); + assert!(!plugins.0[0].is_enabled()); + } + + #[test] + fn plugin_name_from_path() { + let plugin = PluginConfiguration::Path("./my-plugin.grit".into()); + assert_eq!(plugin.name(), "my-plugin"); + + let plugin = PluginConfiguration::Path("/absolute/path/to/plugin.grit".into()); + assert_eq!(plugin.name(), "plugin"); + + let plugin = PluginConfiguration::WithOptions(PluginWithOptions { + path: "./foo/bar/baz.grit".into(), + severity: None, + }); + assert_eq!(plugin.name(), "baz"); + } + + #[test] + fn plugin_severity_and_enabled() { + // Path-only defaults to Error severity and enabled + let path_only = PluginConfiguration::Path("./plugin.grit".into()); + assert!(path_only.is_enabled()); + assert_eq!(path_only.severity(), PluginSeverity::Error); + + // WithOptions with no severity defaults to Error and enabled + let with_none = PluginConfiguration::WithOptions(PluginWithOptions { + path: "./plugin.grit".into(), + severity: None, + }); + assert!(with_none.is_enabled()); + assert_eq!(with_none.severity(), PluginSeverity::Error); + + // Explicit Error severity + let explicit_error = PluginConfiguration::WithOptions(PluginWithOptions { + path: "./plugin.grit".into(), + severity: Some(PluginSeverity::Error), + }); + assert!(explicit_error.is_enabled()); + assert_eq!(explicit_error.severity(), PluginSeverity::Error); + + // Warn severity is enabled but with warning level + let warn = PluginConfiguration::WithOptions(PluginWithOptions { + path: "./plugin.grit".into(), + severity: Some(PluginSeverity::Warn), + }); + assert!(warn.is_enabled()); + assert_eq!(warn.severity(), PluginSeverity::Warn); + + // Off severity disables the plugin + let off = PluginConfiguration::WithOptions(PluginWithOptions { + path: "./plugin.grit".into(), + severity: Some(PluginSeverity::Off), + }); + assert!(!off.is_enabled()); + assert_eq!(off.severity(), PluginSeverity::Off); } } diff --git a/crates/biome_plugin_loader/src/plugin_cache.rs b/crates/biome_plugin_loader/src/plugin_cache.rs index 836cbf47ef3c..7b2d595662bd 100644 --- a/crates/biome_plugin_loader/src/plugin_cache.rs +++ b/crates/biome_plugin_loader/src/plugin_cache.rs @@ -3,7 +3,7 @@ use camino::Utf8PathBuf; use papaya::HashMap; use rustc_hash::{FxBuildHasher, FxHashSet}; -use crate::configuration::{PluginConfiguration, Plugins}; +use crate::configuration::Plugins; use crate::{BiomePlugin, PluginDiagnostic}; /// Cache for storing loaded plugins in memory. @@ -19,7 +19,8 @@ impl PluginCache { self.0.pin().insert(path, plugin); } - /// Returns the loaded and matched analyzer plugins, deduped + /// Returns the loaded and matched analyzer plugins, deduped. + /// Plugins with `enabled: false` are skipped. pub fn get_analyzer_plugins( &self, plugin_configs: &Plugins, @@ -30,21 +31,23 @@ impl PluginCache { let map = self.0.pin(); for plugin_config in plugin_configs.iter() { - match plugin_config { - PluginConfiguration::Path(plugin_path) => { - if seen.insert(plugin_path) { - let path_buf = Utf8PathBuf::from(plugin_path); - match map - .iter() - .find(|(path, _)| path.ends_with(path_buf.as_path())) - { - Some((_, plugin)) => { - result.extend_from_slice(&plugin.analyzer_plugins); - } - None => { - diagnostics.push(PluginDiagnostic::not_loaded(path_buf)); - } - } + // Skip disabled plugins + if !plugin_config.is_enabled() { + continue; + } + + let plugin_path = plugin_config.path(); + if seen.insert(plugin_path) { + let path_buf = Utf8PathBuf::from(plugin_path); + match map + .iter() + .find(|(path, _)| path.ends_with(path_buf.as_path())) + { + Some((_, plugin)) => { + result.extend_from_slice(&plugin.analyzer_plugins); + } + None => { + diagnostics.push(PluginDiagnostic::not_loaded(path_buf)); } } } diff --git a/crates/biome_service/src/file_handlers/css.rs b/crates/biome_service/src/file_handlers/css.rs index 27ed65783561..72cebbdd891f 100644 --- a/crates/biome_service/src/file_handlers/css.rs +++ b/crates/biome_service/src/file_handlers/css.rs @@ -244,7 +244,8 @@ impl ServiceLanguage for CssLanguage { .parser .css_modules_enabled .is_some_and(|css_modules_enabled| css_modules_enabled.into()), - ); + ) + .with_plugin_severities(global.get_plugin_severities_for_path(file_path.as_path())); AnalyzerOptions::default() .with_file_path(file_path.as_path()) diff --git a/crates/biome_service/src/file_handlers/graphql.rs b/crates/biome_service/src/file_handlers/graphql.rs index ed0507ab4bbd..d3034e00f0c3 100644 --- a/crates/biome_service/src/file_handlers/graphql.rs +++ b/crates/biome_service/src/file_handlers/graphql.rs @@ -171,8 +171,9 @@ impl ServiceLanguage for GraphqlLanguage { _file_source: &DocumentFileSource, suppression_reason: Option<&str>, ) -> AnalyzerOptions { - let configuration = - AnalyzerConfiguration::default().with_rules(to_analyzer_rules(global, path.as_path())); + let configuration = AnalyzerConfiguration::default() + .with_rules(to_analyzer_rules(global, path.as_path())) + .with_plugin_severities(global.get_plugin_severities_for_path(path.as_path())); AnalyzerOptions::default() .with_file_path(path.as_path()) .with_configuration(configuration) diff --git a/crates/biome_service/src/file_handlers/html.rs b/crates/biome_service/src/file_handlers/html.rs index 741797e459ad..8b79ebd52314 100644 --- a/crates/biome_service/src/file_handlers/html.rs +++ b/crates/biome_service/src/file_handlers/html.rs @@ -200,8 +200,9 @@ impl ServiceLanguage for HtmlLanguage { _file_source: &super::DocumentFileSource, suppression_reason: Option<&str>, ) -> AnalyzerOptions { - let configuration = - AnalyzerConfiguration::default().with_rules(to_analyzer_rules(global, path.as_path())); + let configuration = AnalyzerConfiguration::default() + .with_rules(to_analyzer_rules(global, path.as_path())) + .with_plugin_severities(global.get_plugin_severities_for_path(path.as_path())); AnalyzerOptions::default() .with_file_path(path.as_path()) diff --git a/crates/biome_service/src/file_handlers/javascript.rs b/crates/biome_service/src/file_handlers/javascript.rs index b9986c522d32..69afc99c8ee5 100644 --- a/crates/biome_service/src/file_handlers/javascript.rs +++ b/crates/biome_service/src/file_handlers/javascript.rs @@ -382,7 +382,8 @@ impl ServiceLanguage for JsLanguage { .with_globals(globals) .with_preferred_quote(preferred_quote) .with_preferred_jsx_quote(preferred_jsx_quote) - .with_preferred_indentation(preferred_indentation); + .with_preferred_indentation(preferred_indentation) + .with_plugin_severities(global.get_plugin_severities_for_path(path.as_path())); AnalyzerOptions::default() .with_file_path(path.as_path()) diff --git a/crates/biome_service/src/file_handlers/json.rs b/crates/biome_service/src/file_handlers/json.rs index ac4e8aa56743..a2095bb9b2b4 100644 --- a/crates/biome_service/src/file_handlers/json.rs +++ b/crates/biome_service/src/file_handlers/json.rs @@ -231,7 +231,8 @@ impl ServiceLanguage for JsonLanguage { ) -> AnalyzerOptions { let configuration = AnalyzerConfiguration::default() .with_rules(to_analyzer_rules(global, path.as_path())) - .with_preferred_quote(PreferredQuote::Double); + .with_preferred_quote(PreferredQuote::Double) + .with_plugin_severities(global.get_plugin_severities_for_path(path.as_path())); AnalyzerOptions::default() .with_file_path(path.as_path()) .with_configuration(configuration) diff --git a/crates/biome_service/src/settings.rs b/crates/biome_service/src/settings.rs index 932930a8d7fc..dad60264de34 100644 --- a/crates/biome_service/src/settings.rs +++ b/crates/biome_service/src/settings.rs @@ -40,7 +40,7 @@ use biome_js_syntax::JsLanguage; use biome_json_formatter::context::JsonFormatOptions; use biome_json_parser::JsonParserOptions; use biome_json_syntax::JsonLanguage; -use biome_plugin_loader::Plugins; +use biome_plugin_loader::{PluginConfiguration, Plugins}; use camino::{Utf8Path, Utf8PathBuf}; use ignore::gitignore::{Gitignore, GitignoreBuilder}; use std::borrow::Cow; @@ -280,17 +280,61 @@ impl Settings { result } + /// Returns the plugin severity map for the analyzer, taking overrides into account. + /// Keys are plugin names (derived from file stems), values are severity overrides. + /// None means "off" (skip diagnostics), Some(severity) means override to that level. + /// Only plugins with explicit severity config are included; plugins without explicit + /// severity use their own inline severity from the Grit pattern. + pub fn get_plugin_severities_for_path( + &self, + path: &Utf8Path, + ) -> biome_analyze::PluginSeverityMap { + let plugins = self.get_plugins_for_path(path); + plugins + .iter() + .filter(|config| config.has_explicit_severity()) + .map(|config| { + let name = config.name().into(); + let severity = config.severity().to_diagnostic_severity(); + (name, severity) + }) + .collect() + } + /// Returns the plugins that should be enabled for the given `path`, taking overrides into account. + /// When the same plugin path appears in both base config and overrides, the override takes precedence. pub fn get_plugins_for_path(&self, path: &Utf8Path) -> Cow<'_, Plugins> { - let mut result = Cow::Borrowed(&self.plugins); + let mut has_overrides = false; + + for pattern in &self.override_settings.patterns { + if pattern.is_file_included(path) && !pattern.plugins.is_empty() { + has_overrides = true; + break; + } + } + + if !has_overrides { + return Cow::Borrowed(&self.plugins); + } + + // Merge plugins: override entries replace base entries with the same path + let mut merged: Vec = self.plugins.0.clone(); for pattern in &self.override_settings.patterns { if pattern.is_file_included(path) { - result.to_mut().extend_from_slice(&pattern.plugins); + for override_plugin in pattern.plugins.iter() { + let override_path = override_plugin.path(); + // Replace existing plugin with same path, or add if not found + if let Some(pos) = merged.iter().position(|p| p.path() == override_path) { + merged[pos] = override_plugin.clone(); + } else { + merged.push(override_plugin.clone()); + } + } } } - result + Cow::Owned(Plugins(merged)) } /// Return all plugins configured in setting diff --git a/crates/biome_service/src/workspace.tests.rs b/crates/biome_service/src/workspace.tests.rs index b4844d278321..f24098ad1f60 100644 --- a/crates/biome_service/src/workspace.tests.rs +++ b/crates/biome_service/src/workspace.tests.rs @@ -11,7 +11,7 @@ use biome_configuration::{ use biome_diagnostics::Diagnostic; use biome_fs::{BiomePath, MemoryFileSystem}; use biome_js_syntax::{JsFileSource, TextSize}; -use biome_plugin_loader::{PluginConfiguration, Plugins}; +use biome_plugin_loader::{PluginConfiguration, PluginSeverity, PluginWithOptions, Plugins}; use camino::Utf8PathBuf; use insta::{assert_debug_snapshot, assert_snapshot}; @@ -675,6 +675,336 @@ fn plugins_may_use_invalid_span() { assert_eq!(result.errors, 0); } +#[test] +fn plugin_can_be_disabled_via_options() { + const PLUGIN_CONTENT: &[u8] = br#" +`Object.assign($args)` where { + register_diagnostic( + span = $args, + message = "Prefer object spread instead of `Object.assign()`" + ) +} +"#; + + const FILE_CONTENT: &[u8] = b"const a = Object.assign({ foo: 'bar' });"; + + let fs = MemoryFileSystem::default(); + fs.insert(Utf8PathBuf::from("/project/plugin.grit"), PLUGIN_CONTENT); + fs.insert(Utf8PathBuf::from("/project/a.ts"), FILE_CONTENT); + + let workspace = server(Arc::new(fs), None); + let OpenProjectResult { project_key } = workspace + .open_project(OpenProjectParams { + path: Utf8PathBuf::from("/project").into(), + open_uninitialized: true, + }) + .unwrap(); + + // Configure plugin with enabled: false + workspace + .update_settings(UpdateSettingsParams { + project_key, + configuration: Configuration { + plugins: Some(Plugins(vec![PluginConfiguration::WithOptions( + PluginWithOptions { + path: "./plugin.grit".to_string(), + severity: Some(PluginSeverity::Off), + }, + )])), + ..Default::default() + }, + workspace_directory: Some(BiomePath::new("/project")), + extended_configurations: Default::default(), + }) + .unwrap(); + + workspace + .open_file(OpenFileParams { + project_key, + path: BiomePath::new("/project/a.ts"), + content: FileContent::FromServer, + document_file_source: None, + persist_node_cache: false, + }) + .unwrap(); + + let result = workspace + .pull_diagnostics(PullDiagnosticsParams { + project_key, + path: BiomePath::new("/project/a.ts"), + categories: RuleCategories::default(), + only: Vec::new(), + skip: Vec::new(), + enabled_rules: Vec::new(), + pull_code_actions: true, + }) + .unwrap(); + + // Plugin is disabled, so no diagnostics + let plugin_diagnostics: Vec<_> = result + .diagnostics + .iter() + .filter(|diag| diag.category().is_some_and(|cat| cat.name() == "plugin")) + .collect(); + assert!( + plugin_diagnostics.is_empty(), + "Expected no plugin diagnostics because the plugin is disabled" + ); +} + +#[test] +fn plugin_can_be_disabled_in_override_via_options() { + const PLUGIN_CONTENT: &[u8] = br#" +`Object.assign($args)` where { + register_diagnostic( + span = $args, + message = "Prefer object spread instead of `Object.assign()`" + ) +} +"#; + + const FILE_CONTENT: &[u8] = b"const a = Object.assign({ foo: 'bar' });"; + + let fs = MemoryFileSystem::default(); + fs.insert(Utf8PathBuf::from("/project/plugin.grit"), PLUGIN_CONTENT); + fs.insert(Utf8PathBuf::from("/project/a.ts"), FILE_CONTENT); + fs.insert(Utf8PathBuf::from("/project/scripts/b.ts"), FILE_CONTENT); + + let workspace = server(Arc::new(fs), None); + let OpenProjectResult { project_key } = workspace + .open_project(OpenProjectParams { + path: Utf8PathBuf::from("/project").into(), + open_uninitialized: true, + }) + .unwrap(); + + // Enable plugin globally, but disable for scripts/** + workspace + .update_settings(UpdateSettingsParams { + project_key, + configuration: Configuration { + plugins: Some(Plugins(vec![PluginConfiguration::Path( + "./plugin.grit".to_string(), + )])), + overrides: Some(Overrides(vec![OverridePattern { + includes: Some(OverrideGlobs::Globs(Box::new([ + biome_glob::NormalizedGlob::from_str("./scripts/**").unwrap(), + ]))), + plugins: Some(Plugins(vec![PluginConfiguration::WithOptions( + PluginWithOptions { + path: "./plugin.grit".to_string(), + severity: Some(PluginSeverity::Off), + }, + )])), + ..OverridePattern::default() + }])), + ..Default::default() + }, + workspace_directory: Some(BiomePath::new("/project")), + extended_configurations: Default::default(), + }) + .unwrap(); + + // Open both files + workspace + .open_file(OpenFileParams { + project_key, + path: BiomePath::new("/project/a.ts"), + content: FileContent::FromServer, + document_file_source: None, + persist_node_cache: false, + }) + .unwrap(); + + workspace + .open_file(OpenFileParams { + project_key, + path: BiomePath::new("/project/scripts/b.ts"), + content: FileContent::FromServer, + document_file_source: None, + persist_node_cache: false, + }) + .unwrap(); + + // a.ts should have diagnostics (plugin enabled) + let result_a = workspace + .pull_diagnostics(PullDiagnosticsParams { + project_key, + path: BiomePath::new("/project/a.ts"), + categories: RuleCategories::default(), + only: Vec::new(), + skip: Vec::new(), + enabled_rules: Vec::new(), + pull_code_actions: true, + }) + .unwrap(); + + let plugin_diagnostics_a: Vec<_> = result_a + .diagnostics + .iter() + .filter(|diag| diag.category().is_some_and(|cat| cat.name() == "plugin")) + .collect(); + assert_eq!( + plugin_diagnostics_a.len(), + 1, + "Expected plugin diagnostic for a.ts (plugin enabled)" + ); + + // scripts/b.ts should have no diagnostics (plugin disabled via override) + let result_b = workspace + .pull_diagnostics(PullDiagnosticsParams { + project_key, + path: BiomePath::new("/project/scripts/b.ts"), + categories: RuleCategories::default(), + only: Vec::new(), + skip: Vec::new(), + enabled_rules: Vec::new(), + pull_code_actions: true, + }) + .unwrap(); + + let plugin_diagnostics_b: Vec<_> = result_b + .diagnostics + .iter() + .filter(|diag| diag.category().is_some_and(|cat| cat.name() == "plugin")) + .collect(); + assert!( + plugin_diagnostics_b.is_empty(), + "Expected no plugin diagnostics for scripts/b.ts (disabled via override)" + ); +} + +#[test] +fn plugin_severity_can_be_overridden_to_warn() { + // Plugin normally emits Error severity, but we override to Warn via config + const PLUGIN_CONTENT: &[u8] = br#" +`Object.assign($args)` where { + register_diagnostic( + span = $args, + message = "Prefer object spread instead of `Object.assign()`" + ) +} +"#; + + const FILE_CONTENT: &[u8] = b"const a = Object.assign({ foo: 'bar' });"; + + let fs = MemoryFileSystem::default(); + fs.insert(Utf8PathBuf::from("/project/plugin.grit"), PLUGIN_CONTENT); + fs.insert(Utf8PathBuf::from("/project/a.ts"), FILE_CONTENT); + + let workspace = server(Arc::new(fs), None); + let OpenProjectResult { project_key } = workspace + .open_project(OpenProjectParams { + path: Utf8PathBuf::from("/project").into(), + open_uninitialized: true, + }) + .unwrap(); + + // Configure plugin with severity: warn + workspace + .update_settings(UpdateSettingsParams { + project_key, + configuration: Configuration { + plugins: Some(Plugins(vec![PluginConfiguration::WithOptions( + PluginWithOptions { + path: "./plugin.grit".to_string(), + severity: Some(PluginSeverity::Warn), + }, + )])), + ..Default::default() + }, + workspace_directory: Some(BiomePath::new("/project")), + extended_configurations: Default::default(), + }) + .unwrap(); + + workspace + .open_file(OpenFileParams { + project_key, + path: BiomePath::new("/project/a.ts"), + content: FileContent::FromServer, + document_file_source: None, + persist_node_cache: false, + }) + .unwrap(); + + let result = workspace + .pull_diagnostics(PullDiagnosticsParams { + project_key, + path: BiomePath::new("/project/a.ts"), + categories: RuleCategories::default(), + only: Vec::new(), + skip: Vec::new(), + enabled_rules: Vec::new(), + pull_code_actions: true, + }) + .unwrap(); + + // Plugin should emit a diagnostic with Warning severity (not Error) + let plugin_diagnostics: Vec<_> = result + .diagnostics + .iter() + .filter(|diag| diag.category().is_some_and(|cat| cat.name() == "plugin")) + .collect(); + + assert_eq!( + plugin_diagnostics.len(), + 1, + "Expected exactly one plugin diagnostic" + ); + + // Check the severity is Warning + let severity = plugin_diagnostics[0].severity(); + assert_eq!( + severity, + biome_diagnostics::Severity::Warning, + "Expected plugin diagnostic to have Warning severity (was {:?})", + severity + ); +} + +#[test] +fn disabled_plugin_with_missing_file_does_not_error() { + // If a plugin is disabled, we shouldn't try to load it at all. + // This means a missing plugin file shouldn't cause an error if severity is "off". + let fs = MemoryFileSystem::default(); + // Note: We intentionally do NOT create the plugin file + fs.insert( + Utf8PathBuf::from("/project/a.ts"), + b"const a = Object.assign({ foo: 'bar' });", + ); + + let workspace = server(Arc::new(fs), None); + let OpenProjectResult { project_key } = workspace + .open_project(OpenProjectParams { + path: Utf8PathBuf::from("/project").into(), + open_uninitialized: true, + }) + .unwrap(); + + // Configure a non-existent plugin with severity: off + let result = workspace.update_settings(UpdateSettingsParams { + project_key, + configuration: Configuration { + plugins: Some(Plugins(vec![PluginConfiguration::WithOptions( + PluginWithOptions { + path: "./non-existent-plugin.grit".to_string(), + severity: Some(PluginSeverity::Off), + }, + )])), + ..Default::default() + }, + workspace_directory: Some(BiomePath::new("/project")), + extended_configurations: Default::default(), + }); + + // Should succeed without errors since disabled plugins are not loaded + assert!( + result.is_ok(), + "update_settings should succeed for disabled plugin with missing file" + ); +} + #[test] fn correctly_apply_plugins_in_override() { let files: &[(&str, &[u8])] = &[ diff --git a/crates/biome_service/src/workspace/server.rs b/crates/biome_service/src/workspace/server.rs index 41fcd6975562..2a13ea53b8ad 100644 --- a/crates/biome_service/src/workspace/server.rs +++ b/crates/biome_service/src/workspace/server.rs @@ -37,8 +37,7 @@ use biome_json_syntax::JsonFileSource; use biome_module_graph::{ModuleDependencies, ModuleDiagnostic, ModuleGraph}; use biome_package::PackageType; use biome_parser::AnyParse; -use biome_plugin_loader::{BiomePlugin, PluginCache, PluginDiagnostic}; -use biome_plugin_loader::{PluginConfiguration, Plugins}; +use biome_plugin_loader::{BiomePlugin, PluginCache, PluginDiagnostic, Plugins}; use biome_project_layout::ProjectLayout; use biome_resolver::FsWithResolverProxy; use biome_rowan::{NodeCache, SendNode}; @@ -680,15 +679,17 @@ impl WorkspaceServer { let plugin_cache = PluginCache::default(); for plugin_config in plugins.iter() { - match plugin_config { - PluginConfiguration::Path(plugin_path) => { - match BiomePlugin::load(self.fs.clone(), plugin_path, base_path) { - Ok((plugin, _)) => { - plugin_cache.insert_plugin(plugin_path.clone().into(), plugin); - } - Err(diagnostic) => diagnostics.push(diagnostic), - } + // Skip loading disabled plugins entirely + if !plugin_config.is_enabled() { + continue; + } + + let plugin_path = plugin_config.path(); + match BiomePlugin::load(self.fs.clone(), plugin_path, base_path) { + Ok((plugin, _)) => { + plugin_cache.insert_plugin(plugin_path.to_string().into(), plugin); } + Err(diagnostic) => diagnostics.push(diagnostic), } } diff --git a/packages/@biomejs/backend-jsonrpc/src/workspace.ts b/packages/@biomejs/backend-jsonrpc/src/workspace.ts index 945ed8ec8395..34e8923f1f11 100644 --- a/packages/@biomejs/backend-jsonrpc/src/workspace.ts +++ b/packages/@biomejs/backend-jsonrpc/src/workspace.ts @@ -892,7 +892,7 @@ match these patterns. */ plugins?: Plugins; } -export type PluginConfiguration = string; +export type PluginConfiguration = string | PluginWithOptions; export type VcsClientKind = "git"; /** * A list of rules that belong to this group @@ -1074,6 +1074,20 @@ export interface OverrideLinterConfiguration { */ rules?: Rules; } +/** + * Plugin configuration with additional options + */ +export interface PluginWithOptions { + /** + * Path to the plugin file + */ + path?: string; + /** + * Severity level for the plugin's diagnostics. +Use "off" to disable, "warn" for warnings, "error" for errors (default). + */ + severity?: PluginSeverity; +} export type OrganizeImportsConfiguration = | RuleAssistPlainConfiguration | RuleAssistWithOrganizeImportsOptions; @@ -3260,6 +3274,10 @@ See useStrictMode?: UseStrictModeConfiguration; } export type Glob = string; +/** + * Severity level for plugin diagnostics + */ +export type PluginSeverity = "off" | "warn" | "error"; export type RuleAssistPlainConfiguration = "off" | "on"; export interface RuleAssistWithOrganizeImportsOptions { level: RuleAssistPlainConfiguration; diff --git a/packages/@biomejs/biome/configuration_schema.json b/packages/@biomejs/biome/configuration_schema.json index 2870efd82f04..d62480c311af 100644 --- a/packages/@biomejs/biome/configuration_schema.json +++ b/packages/@biomejs/biome/configuration_schema.json @@ -6292,7 +6292,51 @@ }, "additionalProperties": false }, - "PluginConfiguration": { "anyOf": [{ "type": "string" }] }, + "PluginConfiguration": { + "anyOf": [ + { "description": "A simple path to a plugin file", "type": "string" }, + { + "description": "A plugin with additional options", + "$ref": "#/$defs/PluginWithOptions" + } + ] + }, + "PluginSeverity": { + "description": "Severity level for plugin diagnostics", + "oneOf": [ + { + "description": "Plugin is disabled", + "type": "string", + "const": "off" + }, + { + "description": "Plugin emits warnings", + "type": "string", + "const": "warn" + }, + { + "description": "Plugin emits errors (default)", + "type": "string", + "const": "error" + } + ] + }, + "PluginWithOptions": { + "description": "Plugin configuration with additional options", + "type": "object", + "properties": { + "path": { + "description": "Path to the plugin file", + "type": "string", + "default": "" + }, + "severity": { + "description": "Severity level for the plugin's diagnostics.\nUse \"off\" to disable, \"warn\" for warnings, \"error\" for errors (default).", + "anyOf": [{ "$ref": "#/$defs/PluginSeverity" }, { "type": "null" }] + } + }, + "additionalProperties": false + }, "Plugins": { "type": "array", "items": { "$ref": "#/$defs/PluginConfiguration" }