diff --git a/.changeset/plugin-file-scoping.md b/.changeset/plugin-file-scoping.md new file mode 100644 index 000000000000..046ee96372b2 --- /dev/null +++ b/.changeset/plugin-file-scoping.md @@ -0,0 +1,14 @@ +--- +"@biomejs/biome": minor +--- + +Added `includes` option for plugin file scoping. Plugins can now be configured with glob patterns to restrict which files they run on. Use negated globs for exclusions. + +```json +{ + "plugins": [ + "global-plugin.grit", + { "path": "scoped-plugin.grit", "includes": ["src/**/*.ts", "!**/*.test.ts"] } + ] +} +``` diff --git a/Cargo.lock b/Cargo.lock index 524a3eb7fb6c..80b18cdfa3b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1505,6 +1505,7 @@ dependencies = [ "biome_deserialize_macros", "biome_diagnostics", "biome_fs", + "biome_glob", "biome_grit_patterns", "biome_js_parser", "biome_js_runtime", @@ -1525,6 +1526,7 @@ dependencies = [ "rustc-hash 2.1.1", "schemars", "serde", + "serde_json", "windows 0.62.2", ] diff --git a/crates/biome_analyze/src/analyzer_plugin.rs b/crates/biome_analyze/src/analyzer_plugin.rs index 870db3c9589a..c795495b0e46 100644 --- a/crates/biome_analyze/src/analyzer_plugin.rs +++ b/crates/biome_analyze/src/analyzer_plugin.rs @@ -25,9 +25,6 @@ pub trait AnalyzerPlugin: Debug + Send + Sync { fn evaluate(&self, node: AnySyntaxNode, path: Arc) -> Vec; /// Returns true if this plugin should run on the given file path. - /// - /// Stub that always returns `true` — file-scoping will be implemented - /// in a companion PR (#9171) via the `includes` plugin option. fn applies_to_file(&self, _path: &Utf8Path) -> bool { true } @@ -40,6 +37,17 @@ pub enum PluginTargetLanguage { Json, } +/// Cached result of checking whether a plugin applies to the current file. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +enum FileApplicability { + /// Not yet checked for this file. + Unknown, + /// Plugin applies to the current file. + Applicable, + /// Plugin does not apply to the current file. + NotApplicable, +} + /// A syntax visitor that queries nodes and evaluates in a plugin. /// Based on [`crate::SyntaxVisitor`]. pub struct PluginVisitor { @@ -50,6 +58,8 @@ pub struct PluginVisitor { /// Used to skip subtrees that fall entirely outside the analysis range /// (see the `ctx.range` check in `visit`). skip_subtree: Option>, + /// Cached result of `applies_to_file` for the current file path. + applies_to_file: FileApplicability, } impl PluginVisitor @@ -68,6 +78,7 @@ where query, plugin, skip_subtree: None, + applies_to_file: FileApplicability::Unknown, } } } @@ -114,7 +125,15 @@ where return; } - if !self.plugin.applies_to_file(&ctx.options.file_path) { + if self.applies_to_file == FileApplicability::Unknown { + self.applies_to_file = if self.plugin.applies_to_file(&ctx.options.file_path) { + FileApplicability::Applicable + } else { + FileApplicability::NotApplicable + }; + } + + if self.applies_to_file == FileApplicability::NotApplicable { return; } diff --git a/crates/biome_css_analyze/tests/spec_tests.rs b/crates/biome_css_analyze/tests/spec_tests.rs index f980f2285795..35803c21e645 100644 --- a/crates/biome_css_analyze/tests/spec_tests.rs +++ b/crates/biome_css_analyze/tests/spec_tests.rs @@ -336,6 +336,7 @@ fn run_plugin_test(input: &'static str, _: &str, _: &str, _: &str) { let plugin = match AnalyzerGritPlugin::load( &OsFileSystem::new(plugin_path.to_owned()), Utf8Path::new(plugin_path), + None, ) { Ok(plugin) => plugin, Err(err) => panic!("Cannot load plugin: {err:?}"), diff --git a/crates/biome_js_analyze/tests/spec_tests.rs b/crates/biome_js_analyze/tests/spec_tests.rs index 497c4a595cb4..db629bed6ee0 100644 --- a/crates/biome_js_analyze/tests/spec_tests.rs +++ b/crates/biome_js_analyze/tests/spec_tests.rs @@ -492,6 +492,7 @@ fn run_plugin_test(input: &'static str, _: &str, _: &str, _: &str) { let plugin = match AnalyzerGritPlugin::load( &OsFileSystem::new(plugin_path.to_owned()), Utf8Path::new(plugin_path), + None, ) { Ok(plugin) => plugin, Err(err) => panic!("Cannot load plugin: {err:?}"), diff --git a/crates/biome_plugin_loader/Cargo.toml b/crates/biome_plugin_loader/Cargo.toml index 4f14221ed983..f1fff3806f09 100644 --- a/crates/biome_plugin_loader/Cargo.toml +++ b/crates/biome_plugin_loader/Cargo.toml @@ -19,6 +19,7 @@ biome_deserialize = { workspace = true, features = ["serde"] } biome_deserialize_macros = { workspace = true } biome_diagnostics = { workspace = true } biome_fs = { workspace = true } +biome_glob = { workspace = true, features = ["biome_deserialize", "serde"] } biome_grit_patterns = { workspace = true } biome_js_runtime = { workspace = true, optional = true } biome_js_syntax = { workspace = true } @@ -40,6 +41,7 @@ serde = { workspace = true } [dev-dependencies] biome_js_parser = { workspace = true } insta = { workspace = true } +serde_json = { workspace = true } [target.'cfg(unix)'.dependencies] libc = { workspace = true, optional = true } diff --git a/crates/biome_plugin_loader/src/analyzer_grit_plugin.rs b/crates/biome_plugin_loader/src/analyzer_grit_plugin.rs index 5ec8d837aae1..5c711b6e317c 100644 --- a/crates/biome_plugin_loader/src/analyzer_grit_plugin.rs +++ b/crates/biome_plugin_loader/src/analyzer_grit_plugin.rs @@ -1,9 +1,10 @@ -use crate::{AnalyzerPlugin, PluginDiagnostic}; +use crate::{AnalyzerPlugin, PluginDiagnostic, file_matches_includes}; use biome_analyze::{PluginTargetLanguage, RuleDiagnostic}; use biome_console::markup; use biome_css_syntax::{CssRoot, CssSyntaxNode}; use biome_diagnostics::{Severity, category}; use biome_fs::FileSystem; +use biome_glob::NormalizedGlob; use biome_grit_patterns::{ BuiltInFunction, CompilePatternOptions, GritBinding, GritExecContext, GritPattern, GritQuery, GritQueryContext, GritQueryState, GritResolvedPattern, GritTargetFile, GritTargetLanguage, @@ -18,14 +19,23 @@ use grit_pattern_matcher::{binding::Binding, pattern::ResolvedPattern}; use grit_util::{AnalysisLogs, error::GritPatternError}; use std::{borrow::Cow, fmt::Debug, str::FromStr, sync::Arc}; -/// Definition of an analyzer plugin. +/// Definition of an analyzer plugin backed by a GritQL query. #[derive(Debug)] pub struct AnalyzerGritPlugin { grit_query: GritQuery, + + /// Glob patterns that restrict which files this plugin runs on. + /// `None` means the plugin runs on all files. + /// `Some(&[])` (an empty list) means the plugin never runs on any file. + includes: Option>, } impl AnalyzerGritPlugin { - pub fn load(fs: &dyn FileSystem, path: &Utf8Path) -> Result { + pub fn load( + fs: &dyn FileSystem, + path: &Utf8Path, + includes: Option<&[NormalizedGlob]>, + ) -> Result { let source = fs.read_file_from_path(path)?; let options = CompilePatternOptions::default() .with_extra_built_ins(vec![ @@ -39,7 +49,10 @@ impl AnalyzerGritPlugin { .with_path(path); let grit_query = compile_pattern_with_options(&source, options)?; - Ok(Self { grit_query }) + Ok(Self { + grit_query, + includes: includes.map(Into::into), + }) } } @@ -68,6 +81,10 @@ impl AnalyzerPlugin for AnalyzerGritPlugin { } } + fn applies_to_file(&self, path: &Utf8Path) -> bool { + file_matches_includes(self.includes.as_deref(), path) + } + fn evaluate(&self, node: AnySyntaxNode, path: Arc) -> Vec { let name: &str = self.grit_query.name.as_deref().unwrap_or("anonymous"); @@ -186,3 +203,68 @@ fn register_diagnostic<'a>( Ok(span_node.clone()) } + +#[cfg(test)] +mod tests { + use super::*; + use biome_fs::MemoryFileSystem; + + fn load_test_plugin(includes: Option<&[NormalizedGlob]>) -> AnalyzerGritPlugin { + let fs = MemoryFileSystem::default(); + fs.insert("/test.grit".into(), r#"`hello`"#); + AnalyzerGritPlugin::load(&fs, Utf8Path::new("/test.grit"), includes).unwrap() + } + + #[test] + fn applies_to_all_files_without_includes() { + let plugin = load_test_plugin(None); + assert!(plugin.applies_to_file(Utf8Path::new("src/main.ts"))); + assert!(plugin.applies_to_file(Utf8Path::new("test/foo.js"))); + } + + #[test] + fn applies_to_matching_files_with_includes() { + let globs: Vec = vec!["src/**/*.ts".parse().unwrap()]; + let plugin = load_test_plugin(Some(&globs)); + assert!(plugin.applies_to_file(Utf8Path::new("src/main.ts"))); + assert!(plugin.applies_to_file(Utf8Path::new("src/nested/file.ts"))); + } + + #[test] + fn rejects_non_matching_files_with_includes() { + let globs: Vec = vec!["src/**/*.ts".parse().unwrap()]; + let plugin = load_test_plugin(Some(&globs)); + assert!(!plugin.applies_to_file(Utf8Path::new("test/foo.ts"))); + assert!(!plugin.applies_to_file(Utf8Path::new("src/main.js"))); + } + + #[test] + fn applies_with_negated_glob_exclusion() { + let globs: Vec = vec![ + "src/**/*.ts".parse().unwrap(), + "!**/*.test.ts".parse().unwrap(), + ]; + let plugin = load_test_plugin(Some(&globs)); + assert!(plugin.applies_to_file(Utf8Path::new("src/main.ts"))); + assert!(!plugin.applies_to_file(Utf8Path::new("src/foo.test.ts"))); + } + + #[test] + fn glob_does_not_match_absolute_paths_without_prefix() { + let globs: Vec = vec!["src/**/*.ts".parse().unwrap()]; + let plugin = load_test_plugin(Some(&globs)); + // Relative paths match as expected + assert!(plugin.applies_to_file(Utf8Path::new("src/main.ts"))); + // Absolute paths do NOT match a relative glob — this is expected behavior. + // Users should use `**/src/**/*.ts` for absolute path matching. + assert!(!plugin.applies_to_file(Utf8Path::new("/project/src/main.ts"))); + } + + #[test] + fn empty_includes_matches_nothing() { + let globs: Vec = vec![]; + let plugin = load_test_plugin(Some(&globs)); + assert!(!plugin.applies_to_file(Utf8Path::new("src/main.ts"))); + assert!(!plugin.applies_to_file(Utf8Path::new("any/file.js"))); + } +} diff --git a/crates/biome_plugin_loader/src/analyzer_js_plugin.rs b/crates/biome_plugin_loader/src/analyzer_js_plugin.rs index 4013f672f8b6..2e1664bfab9a 100644 --- a/crates/biome_plugin_loader/src/analyzer_js_plugin.rs +++ b/crates/biome_plugin_loader/src/analyzer_js_plugin.rs @@ -9,6 +9,7 @@ use camino::{Utf8Path, Utf8PathBuf}; use biome_analyze::{AnalyzerPlugin, PluginTargetLanguage, RuleDiagnostic}; use biome_console::markup; use biome_diagnostics::category; +use biome_glob::NormalizedGlob; use biome_js_runtime::JsExecContext; use biome_js_syntax::AnyJsRoot; use biome_resolver::FsWithResolverProxy; @@ -16,6 +17,7 @@ use biome_rowan::{AnySyntaxNode, AstNode, RawSyntaxKind, SyntaxKind}; use biome_text_size::TextRange; use crate::PluginDiagnostic; +use crate::file_matches_includes; use crate::thread_local::ThreadLocalCell; /// Already loaded plugin in a thread. @@ -46,6 +48,11 @@ pub struct AnalyzerJsPlugin { fs: Arc, path: Utf8PathBuf, loaded: ThreadLocalCell, + + /// Glob patterns that restrict which files this plugin runs on. + /// `None` means the plugin runs on all files. + /// `Some(&[])` (an empty list) means the plugin never runs on any file. + includes: Option>, } impl Debug for AnalyzerJsPlugin { @@ -60,6 +67,7 @@ impl AnalyzerJsPlugin { pub fn load( fs: Arc, path: &Utf8Path, + includes: Option<&[NormalizedGlob]>, ) -> Result { // Load the plugin in the main thread here to catch errors while loading. load_plugin(fs.clone(), path)?; @@ -68,6 +76,7 @@ impl AnalyzerJsPlugin { fs, path: path.to_owned(), loaded: ThreadLocalCell::new(), + includes: includes.map(Into::into), }) } } @@ -77,6 +86,10 @@ impl AnalyzerPlugin for AnalyzerJsPlugin { PluginTargetLanguage::JavaScript } + fn applies_to_file(&self, path: &Utf8Path) -> bool { + file_matches_includes(self.includes.as_deref(), path) + } + fn query(&self) -> Vec { // TODO: Support granular query defined in the JS plugin. AnyJsRoot::KIND_SET @@ -146,6 +159,42 @@ mod tests { }); } + fn load_test_plugin(includes: Option<&[NormalizedGlob]>) -> AnalyzerJsPlugin { + let fs = MemoryFileSystem::default(); + fs.insert( + "/plugin.js".into(), + r#"import { registerDiagnostic } from "@biomejs/plugin-api"; + export default function useMyPlugin() { + registerDiagnostic("information", "Hello, world!"); + }"#, + ); + let fs = Arc::new(fs) as Arc; + AnalyzerJsPlugin::load(fs, "/plugin.js".into(), includes).unwrap() + } + + #[test] + fn applies_to_all_files_without_includes() { + let plugin = load_test_plugin(None); + assert!(plugin.applies_to_file(Utf8Path::new("src/main.ts"))); + assert!(plugin.applies_to_file(Utf8Path::new("test/foo.js"))); + } + + #[test] + fn applies_to_matching_files_with_includes() { + let globs: Vec = vec!["src/**/*.ts".parse().unwrap()]; + let plugin = load_test_plugin(Some(&globs)); + assert!(plugin.applies_to_file(Utf8Path::new("src/main.ts"))); + assert!(plugin.applies_to_file(Utf8Path::new("src/nested/file.ts"))); + } + + #[test] + fn rejects_non_matching_files_with_includes() { + let globs: Vec = vec!["src/**/*.ts".parse().unwrap()]; + let plugin = load_test_plugin(Some(&globs)); + assert!(!plugin.applies_to_file(Utf8Path::new("test/foo.ts"))); + assert!(!plugin.applies_to_file(Utf8Path::new("src/main.js"))); + } + #[test] fn evaluate_in_worker_threads() { let fs = MemoryFileSystem::default(); @@ -160,7 +209,8 @@ mod tests { ); let fs = Arc::new(fs) as Arc; - let plugin = Arc::new(AnalyzerJsPlugin::load(fs.clone(), "/plugin.js".into()).unwrap()); + let plugin = + Arc::new(AnalyzerJsPlugin::load(fs.clone(), "/plugin.js".into(), None).unwrap()); let worker1 = { let plugin = plugin.clone(); diff --git a/crates/biome_plugin_loader/src/configuration.rs b/crates/biome_plugin_loader/src/configuration.rs index 486998735d53..1ce116f268be 100644 --- a/crates/biome_plugin_loader/src/configuration.rs +++ b/crates/biome_plugin_loader/src/configuration.rs @@ -3,6 +3,7 @@ use biome_deserialize::{ }; use biome_deserialize_macros::{Deserializable, Merge}; use biome_fs::normalize_path; +use biome_glob::NormalizedGlob; use camino::Utf8Path; use serde::{Deserialize, Serialize}; use std::{ @@ -26,17 +27,16 @@ impl Plugins { /// `.` / `..` 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::PathWithOptions(opts) => &mut opts.path, + }; + let path_buf = Utf8Path::new(plugin_path.as_str()); + if path_buf.is_absolute() { + continue; } + let normalized = normalize_path(&base_dir.join(path_buf)); + *plugin_path = normalized.to_string(); } } } @@ -63,12 +63,45 @@ impl DerefMut for Plugins { } } +/// Configuration for a single plugin entry. +/// +/// Can be either a plain path string or an object with path and options: +/// +/// ```json +/// { +/// "plugins": [ +/// "simple-plugin.grit", +/// { "path": "scoped-plugin.grit", "includes": ["src/**/*.ts"] } +/// ] +/// } +/// ``` #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[serde(rename_all = "camelCase", deny_unknown_fields, untagged)] pub enum PluginConfiguration { + /// A plain path to the plugin. Path(String), - // TODO: PathWithOptions(PluginPathWithOptions), + + /// A path with additional options. + PathWithOptions(PluginWithOptions), +} + +impl PluginConfiguration { + /// Returns the plugin path. + pub fn path(&self) -> &str { + match self { + Self::Path(path) => path, + Self::PathWithOptions(opts) => &opts.path, + } + } + + /// Returns the includes patterns, if any. + pub fn includes(&self) -> Option<&[NormalizedGlob]> { + match self { + Self::Path(_) => None, + Self::PathWithOptions(opts) => opts.includes.as_deref(), + } + } } impl Deserializable for PluginConfiguration { @@ -80,21 +113,31 @@ impl Deserializable for PluginConfiguration { 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 + Deserializable::deserialize(ctx, value, rule_name).map(Self::PathWithOptions) } } } +/// Plugin path 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 { + /// The path to the plugin. + #[deserializable(required)] + pub path: String, + + /// A list of glob patterns. The plugin will only run on files matching + /// these patterns. Use negated globs (e.g., `!**/*.test.ts`) for exclusions. + #[serde(skip_serializing_if = "Option::is_none")] + pub includes: Option>, +} + #[cfg(test)] mod tests { use super::*; + use biome_deserialize::json::deserialize_from_json_str; + use biome_json_parser::JsonParserOptions; #[test] fn normalize_relative_paths_makes_paths_base_dir_relative_and_normalized() { @@ -106,12 +149,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 +168,75 @@ 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_with_options() { + let base_dir = Utf8Path::new("base"); + let mut plugins = Plugins(vec![PluginConfiguration::PathWithOptions( + PluginWithOptions { + path: "./my-plugin.grit".into(), + includes: Some(vec!["src/**/*.ts".parse().unwrap()]), + }, + )]); + + plugins.normalize_relative_paths(base_dir); + + let path = plugins.0[0].path(); + assert!(Utf8Path::new(path).starts_with(base_dir)); + assert!(Utf8Path::new(path).ends_with("my-plugin.grit")); + + // includes should be unchanged + assert!(plugins.0[0].includes().is_some()); + } + + #[test] + fn deserialize_plain_string() { + let config: PluginConfiguration = serde_json::from_str(r#""my-plugin.grit""#).unwrap(); + assert_eq!(config.path(), "my-plugin.grit"); + assert!(config.includes().is_none()); + } + + #[test] + fn deserialize_object_with_includes() { + let config: PluginConfiguration = + serde_json::from_str(r#"{ "path": "my-plugin.grit", "includes": ["src/**/*.ts"] }"#) + .unwrap(); + assert_eq!(config.path(), "my-plugin.grit"); + assert_eq!(config.includes().unwrap().len(), 1); + } + + #[test] + fn deserialize_object_without_includes() { + let config: PluginConfiguration = + serde_json::from_str(r#"{ "path": "my-plugin.grit" }"#).unwrap(); + assert_eq!(config.path(), "my-plugin.grit"); + assert!(config.includes().is_none()); + } + + #[test] + fn deserialize_object_missing_path_emits_error() { + let source = r#"{ "includes": ["src/**"] }"#; + let result = deserialize_from_json_str::( + source, + JsonParserOptions::default(), + "", + ); + assert!(result.has_errors()); + } + + #[test] + fn deserialize_plugins_list_mixed() { + let plugins: Plugins = serde_json::from_str( + r#"["simple.grit", { "path": "scoped.grit", "includes": ["src/**"] }]"#, + ) + .unwrap(); + assert_eq!(plugins.0.len(), 2); + assert!(matches!(plugins.0[0], PluginConfiguration::Path(_))); + assert!(matches!( + plugins.0[1], + PluginConfiguration::PathWithOptions(_) + )); } } diff --git a/crates/biome_plugin_loader/src/lib.rs b/crates/biome_plugin_loader/src/lib.rs index 8d7e31838e9f..3feac8109125 100644 --- a/crates/biome_plugin_loader/src/lib.rs +++ b/crates/biome_plugin_loader/src/lib.rs @@ -26,6 +26,7 @@ use biome_analyze::{AnalyzerPlugin, AnalyzerPluginVec}; use biome_console::markup; use biome_deserialize::json::deserialize_from_json_str; use biome_fs::normalize_path; +use biome_glob::{CandidatePath, NormalizedGlob}; use biome_json_parser::JsonParserOptions; use biome_resolver::FsWithResolverProxy; use camino::{Utf8Path, Utf8PathBuf}; @@ -40,10 +41,13 @@ impl BiomePlugin { /// Loads a plugin from the given `plugin_path`. /// /// The base path is used to resolve relative paths. + /// The optional `includes` patterns restrict which files the plugin runs on. + /// Note: `Some(&[])` (empty includes) means the plugin never matches any file. pub fn load( fs: Arc, plugin_path: &str, base_path: &Utf8Path, + includes: Option<&[NormalizedGlob]>, ) -> Result<(Self, Utf8PathBuf), PluginDiagnostic> { let plugin_path = normalize_path(&base_path.join(plugin_path)); @@ -53,7 +57,7 @@ impl BiomePlugin { .extension() .is_some_and(|extension| extension == "grit") { - let plugin = AnalyzerGritPlugin::load(fs.as_ref(), &plugin_path)?; + let plugin = AnalyzerGritPlugin::load(fs.as_ref(), &plugin_path, includes)?; return Ok(( Self { analyzer_plugins: vec![Arc::new(Box::new(plugin) as Box)], @@ -68,7 +72,7 @@ impl BiomePlugin { .extension() .is_some_and(|extension| extension == "js" || extension == "mjs") { - let plugin = AnalyzerJsPlugin::load(fs.clone(), &plugin_path)?; + let plugin = AnalyzerJsPlugin::load(fs.clone(), &plugin_path, includes)?; return Ok(( Self { analyzer_plugins: vec![Arc::new(Box::new(plugin) as Box)], @@ -104,8 +108,11 @@ impl BiomePlugin { .map(|rule| Utf8PathBuf::from_path_buf(rule).unwrap()) .map(|rule| { if rule.as_os_str().as_encoded_bytes().ends_with(b".grit") { - let plugin = - AnalyzerGritPlugin::load(fs.as_ref(), &plugin_path.join(rule))?; + let plugin = AnalyzerGritPlugin::load( + fs.as_ref(), + &plugin_path.join(rule), + includes, + )?; Ok(Arc::new(Box::new(plugin) as Box)) } else { Err(PluginDiagnostic::unsupported_rule_format(markup!( @@ -121,6 +128,17 @@ impl BiomePlugin { } } +/// Checks whether a file path matches the plugin's `includes` globs. +/// +/// Returns `true` if `includes` is `None` (no restriction). +/// When `includes` is `Some`, delegates to `CandidatePath::matches_with_exceptions`. +pub(crate) fn file_matches_includes(includes: Option<&[NormalizedGlob]>, path: &Utf8Path) -> bool { + let Some(includes) = includes else { + return true; + }; + CandidatePath::new(path).matches_with_exceptions(includes) +} + #[cfg(test)] mod test { use super::*; @@ -154,8 +172,8 @@ mod test { fs.insert("/my-plugin/rules/1.grit".into(), r#"`hello`"#); let fs = Arc::new(fs) as Arc; - let (plugin, _) = - BiomePlugin::load(fs, "./my-plugin", Utf8Path::new("/")).expect("Couldn't load plugin"); + let (plugin, _) = BiomePlugin::load(fs, "./my-plugin", Utf8Path::new("/"), None) + .expect("Couldn't load plugin"); assert_eq!(plugin.analyzer_plugins.len(), 1); } @@ -165,7 +183,7 @@ mod test { fs.insert("/my-plugin/rules/1.grit".into(), r#"`hello`"#); let fs = Arc::new(fs) as Arc; - let error = BiomePlugin::load(fs, "./my-plugin", Utf8Path::new("/")) + let error = BiomePlugin::load(fs, "./my-plugin", Utf8Path::new("/"), None) .expect_err("Plugin loading should've failed"); snap_diagnostic("load_plugin_without_manifest", error.into()); } @@ -182,7 +200,7 @@ mod test { ); let fs = Arc::new(fs) as Arc; - let error = BiomePlugin::load(fs, "./my-plugin", Utf8Path::new("/")) + let error = BiomePlugin::load(fs, "./my-plugin", Utf8Path::new("/"), None) .expect_err("Plugin loading should've failed"); snap_diagnostic("load_plugin_with_wrong_version", error.into()); } @@ -199,7 +217,7 @@ mod test { ); let fs = Arc::new(fs) as Arc; - let error = BiomePlugin::load(fs, "./my-plugin", Utf8Path::new("/")) + let error = BiomePlugin::load(fs, "./my-plugin", Utf8Path::new("/"), None) .expect_err("Plugin loading should've failed"); snap_diagnostic("load_plugin_with_wrong_rule_extension", error.into()); } @@ -210,7 +228,7 @@ mod test { fs.insert("/my-plugin.grit".into(), r#"`hello`"#); let fs = Arc::new(fs) as Arc; - let (plugin, _) = BiomePlugin::load(fs, "./my-plugin.grit", Utf8Path::new("/")) + let (plugin, _) = BiomePlugin::load(fs, "./my-plugin.grit", Utf8Path::new("/"), None) .expect("Couldn't load plugin"); assert_eq!(plugin.analyzer_plugins.len(), 1); } @@ -225,7 +243,7 @@ mod test { ); let fs = Arc::new(fs) as Arc; - let (plugin, _) = BiomePlugin::load(fs, "./my-plugin.js", Utf8Path::new("/")) + let (plugin, _) = BiomePlugin::load(fs, "./my-plugin.js", Utf8Path::new("/"), None) .expect("Couldn't load plugin"); assert_eq!(plugin.analyzer_plugins.len(), 1); diff --git a/crates/biome_plugin_loader/src/plugin_cache.rs b/crates/biome_plugin_loader/src/plugin_cache.rs index 836cbf47ef3c..a44689672bd9 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. @@ -30,21 +30,18 @@ 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)); - } - } + 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/snapshots/biome_service__workspace__tests__scoped_plugin_diagnostics___project__src__foo.ts.snap b/crates/biome_service/src/snapshots/biome_service__workspace__tests__scoped_plugin_diagnostics___project__src__foo.ts.snap new file mode 100644 index 000000000000..b9419bb0b0a7 --- /dev/null +++ b/crates/biome_service/src/snapshots/biome_service__workspace__tests__scoped_plugin_diagnostics___project__src__foo.ts.snap @@ -0,0 +1,40 @@ +--- +source: crates/biome_service/src/workspace.tests.rs +expression: plugin_diagnostics +--- +[ + Diagnostic { + category: Some( + Category { + name: "plugin", + link: None, + }, + ), + severity: Error, + description: "Prefer object spread instead of `Object.assign()`", + message: "Prefer object spread instead of `Object.assign()`", + advices: Advices { + advices: [], + }, + verbose_advices: Advices { + advices: [], + }, + location: Location { + path: Some( + File( + "/project/src/foo.ts", + ), + ), + span: Some( + 24..38, + ), + source_code: None, + }, + tags: DiagnosticTags( + BitFlags { + bits: 0b0, + }, + ), + source: None, + }, +] diff --git a/crates/biome_service/src/workspace.tests.rs b/crates/biome_service/src/workspace.tests.rs index ed8a18d5d509..9bad68fdd2a1 100644 --- a/crates/biome_service/src/workspace.tests.rs +++ b/crates/biome_service/src/workspace.tests.rs @@ -841,6 +841,128 @@ const hasOwn = Object.hasOwn({ foo: 'bar' }, 'foo');"#, } } +#[test] +fn correctly_scope_plugin_with_includes() { + let files: &[(&str, &[u8])] = &[ + ( + "/project/plugin_a.grit", + br#"`Object.assign($args)` where { + register_diagnostic( + span = $args, + message = "Prefer object spread instead of `Object.assign()`" + ) +}"#, + ), + ( + "/project/src/foo.ts", + b"const a = Object.assign({ foo: 'bar' });", + ), + ( + "/project/lib/bar.ts", + b"const a = Object.assign({ foo: 'bar' });", + ), + ( + "/project/src/foo.test.ts", + b"const a = Object.assign({ foo: 'bar' });", + ), + ]; + + let fs = MemoryFileSystem::default(); + for (path, content) in files { + fs.insert(Utf8PathBuf::from(*path), *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(); + + workspace + .update_settings(UpdateSettingsParams { + project_key, + configuration: Configuration { + plugins: Some(Plugins(vec![PluginConfiguration::PathWithOptions( + biome_plugin_loader::PluginWithOptions { + path: "./plugin_a.grit".to_string(), + includes: Some(vec![ + biome_glob::NormalizedGlob::from_str("**/src/**/*.ts").unwrap(), + biome_glob::NormalizedGlob::from_str("!**/*.test.ts").unwrap(), + ]), + }, + )])), + ..Default::default() + }, + workspace_directory: Some(BiomePath::new("/project")), + extended_configurations: Default::default(), + module_graph_resolution_kind: ModuleGraphResolutionKind::None, + }) + .unwrap(); + + workspace + .scan_project(ScanProjectParams { + project_key, + watch: false, + force: false, + scan_kind: ScanKind::Project, + verbose: false, + }) + .unwrap(); + + // src/foo.ts should trigger the plugin (matches includes) + // lib/bar.ts should NOT trigger the plugin (doesn't match includes) + // src/foo.test.ts should NOT trigger the plugin (excluded by negated glob) + for (path, expect_diagnosis_count) in [ + ("/project/src/foo.ts", 1), + ("/project/lib/bar.ts", 0), + ("/project/src/foo.test.ts", 0), + ] { + workspace + .open_file(OpenFileParams { + project_key, + path: BiomePath::new(path), + content: FileContent::FromServer, + document_file_source: None, + persist_node_cache: false, + inline_config: None, + }) + .unwrap(); + + let result = workspace + .pull_diagnostics(PullDiagnosticsParams { + project_key, + path: BiomePath::new(path), + categories: RuleCategories::default(), + only: Vec::new(), + skip: Vec::new(), + enabled_rules: Vec::new(), + pull_code_actions: true, + inline_config: None, + }) + .unwrap(); + + let plugin_diagnostics: Vec<_> = result + .diagnostics + .iter() + .filter(|diag| diag.category().is_some_and(|cat| cat.name() == "plugin")) + .collect(); + + assert_eq!( + plugin_diagnostics.len(), + expect_diagnosis_count, + "Expected {expect_diagnosis_count} plugin diagnostics for {path}, got {}", + plugin_diagnostics.len() + ); + + if expect_diagnosis_count > 0 { + let snapshot_name = format!("scoped_plugin_diagnostics_{path}"); + assert_debug_snapshot!(snapshot_name, plugin_diagnostics); + } + } +} + #[test] fn test_order() { for items in FileFeaturesResult::PROTECTED_FILES.windows(2) { diff --git a/crates/biome_service/src/workspace/server.rs b/crates/biome_service/src/workspace/server.rs index ab2df4bce660..ad7e7de59103 100644 --- a/crates/biome_service/src/workspace/server.rs +++ b/crates/biome_service/src/workspace/server.rs @@ -55,8 +55,8 @@ use biome_json_syntax::JsonFileSource; use biome_module_graph::{HtmlEmbeddedContent, ModuleDependencies, ModuleDiagnostic, ModuleGraph}; use biome_package::PackageType; use biome_parser::AnyParse; +use biome_plugin_loader::Plugins; use biome_plugin_loader::{BiomePlugin, PluginCache, PluginDiagnostic}; -use biome_plugin_loader::{PluginConfiguration, Plugins}; use biome_project_layout::ProjectLayout; use biome_resolver::FsWithResolverProxy; use biome_rowan::{NodeCache, SendNode}; @@ -794,15 +794,13 @@ 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), - } + let plugin_path = plugin_config.path(); + let includes = plugin_config.includes(); + match BiomePlugin::load(self.fs.clone(), plugin_path, base_path, includes) { + Ok((plugin, _)) => { + plugin_cache.insert_plugin(plugin_path.to_owned().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 0c447d9b6dca..f67c4554d983 100644 --- a/packages/@biomejs/backend-jsonrpc/src/workspace.ts +++ b/packages/@biomejs/backend-jsonrpc/src/workspace.ts @@ -978,7 +978,21 @@ match these patterns. */ plugins?: Plugins; } -export type PluginConfiguration = string; +/** + * Configuration for a single plugin entry. + +Can be either a plain path string or an object with path and options: + +```json +{ + "plugins": [ + "simple-plugin.grit", + { "path": "scoped-plugin.grit", "includes": ["src/**/*.ts"] } + ] +} +``` + */ +export type PluginConfiguration = string | PluginWithOptions; export type VcsClientKind = "git"; /** * A list of rules that belong to this group @@ -1190,6 +1204,20 @@ export interface OverrideLinterConfiguration { */ rules?: Rules; } +/** + * Plugin path with additional options. + */ +export interface PluginWithOptions { + /** + * A list of glob patterns. The plugin will only run on files matching +these patterns. Use negated globs (e.g., `!**/*.test.ts`) for exclusions. + */ + includes?: NormalizedGlob[]; + /** + * The path to the plugin. + */ + path: string; +} export type NoDuplicateClassesConfiguration = | RuleAssistPlainConfiguration | RuleAssistWithNoDuplicateClassesOptions; diff --git a/packages/@biomejs/biome/configuration_schema.json b/packages/@biomejs/biome/configuration_schema.json index cdfa53f0a078..7c3f5cfc67ff 100644 --- a/packages/@biomejs/biome/configuration_schema.json +++ b/packages/@biomejs/biome/configuration_schema.json @@ -6945,7 +6945,30 @@ }, "additionalProperties": false }, - "PluginConfiguration": { "anyOf": [{ "type": "string" }] }, + "PluginConfiguration": { + "description": "Configuration for a single plugin entry.\n\nCan be either a plain path string or an object with path and options:\n\n```json\n{\n \"plugins\": [\n \"simple-plugin.grit\",\n { \"path\": \"scoped-plugin.grit\", \"includes\": [\"src/**/*.ts\"] }\n ]\n}\n```", + "anyOf": [ + { "description": "A plain path to the plugin.", "type": "string" }, + { + "description": "A path with additional options.", + "$ref": "#/$defs/PluginWithOptions" + } + ] + }, + "PluginWithOptions": { + "description": "Plugin path with additional options.", + "type": "object", + "properties": { + "includes": { + "description": "A list of glob patterns. The plugin will only run on files matching\nthese patterns. Use negated globs (e.g., `!**/*.test.ts`) for exclusions.", + "type": ["array", "null"], + "items": { "$ref": "#/$defs/NormalizedGlob" } + }, + "path": { "description": "The path to the plugin.", "type": "string" } + }, + "additionalProperties": false, + "required": ["path"] + }, "Plugins": { "type": "array", "items": { "$ref": "#/$defs/PluginConfiguration" }