Skip to content
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
2960a3b
feat(linter): add includes option for plugin file scoping
chocky335 Feb 21, 2026
d374fd6
test(plugin-loader): add deserialization and applies_to_file tests
chocky335 Feb 21, 2026
c6fa7ee
perf(analyze): cache applies_to_file result per visitor
chocky335 Feb 21, 2026
4b83531
fix(plugin-loader): add includes support to JS plugins and address re…
chocky335 Feb 22, 2026
c3ba15f
test(plugin-loader): add includes coverage for JS plugins and e2e int…
chocky335 Feb 22, 2026
555fc38
refactor(plugin-loader): extract shared applies_to_file helper and ad…
chocky335 Feb 22, 2026
1525b85
refactor(plugin-loader): use idiomatic Option<&[T]> and add negated-g…
chocky335 Feb 22, 2026
c0965b3
test(plugin-loader): add empty includes edge case test
chocky335 Feb 22, 2026
c4f6dcc
docs(plugin-loader): document empty includes edge case
chocky335 Feb 22, 2026
e8db041
refactor(analyze): replace Option<bool> with FileApplicability enum
chocky335 Feb 23, 2026
5a1c180
refactor(plugin-loader): require path field in PluginWithOptions
chocky335 Feb 23, 2026
ce0d07e
Merge branch 'next' into perf/plugin-anchor-dispatch
arendjr Feb 25, 2026
883e3df
[autofix.ci] apply automated fixes
autofix-ci[bot] Feb 25, 2026
c16ef18
Merge remote-tracking branch 'upstream/next' into perf/plugin-anchor-…
chocky335 Feb 27, 2026
34d114e
refactor(analyze): separate applicability check into two if-blocks
chocky335 Feb 27, 2026
570979d
Merge branch 'perf/plugin-anchor-dispatch' of github.com:chocky335/bi…
chocky335 Feb 27, 2026
9b381d8
Merge branch 'next' into perf/plugin-anchor-dispatch
arendjr Mar 4, 2026
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
14 changes: 14 additions & 0 deletions .changeset/plugin-file-scoping.md
Original file line number Diff line number Diff line change
@@ -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"] }
]
}
```
2 changes: 2 additions & 0 deletions Cargo.lock

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

34 changes: 33 additions & 1 deletion crates/biome_analyze/src/analyzer_plugin.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use camino::Utf8PathBuf;
use camino::{Utf8Path, Utf8PathBuf};
use rustc_hash::FxHashSet;
use std::hash::Hash;
use std::{fmt::Debug, sync::Arc};
Expand All @@ -23,6 +23,11 @@ pub trait AnalyzerPlugin: Debug + Send + Sync {
fn query(&self) -> Vec<RawSyntaxKind>;

fn evaluate(&self, node: AnySyntaxNode, path: Arc<Utf8PathBuf>) -> Vec<RuleDiagnostic>;

/// Returns true if this plugin should run on the given file path.
fn applies_to_file(&self, _path: &Utf8Path) -> bool {
true
}
}

#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)]
Expand All @@ -32,12 +37,25 @@ pub enum PluginTargetLanguage {
Json,
}

/// Cached result of checking whether a plugin applies to the current file.
#[derive(Copy, Clone, Debug)]
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<L: Language> {
query: FxHashSet<L::Kind>,
plugin: Arc<Box<dyn AnalyzerPlugin>>,
skip_subtree: Option<SyntaxNode<L>>,
/// Cached result of `applies_to_file` for the current file path.
applies_to_file: FileApplicability,
}

impl<L> PluginVisitor<L>
Expand All @@ -56,6 +74,7 @@ where
query,
plugin,
skip_subtree: None,
applies_to_file: FileApplicability::Unknown,
}
}
}
Expand Down Expand Up @@ -102,6 +121,19 @@ where
return;
}

match self.applies_to_file {
FileApplicability::NotApplicable => return,
FileApplicability::Unknown => {
if self.plugin.applies_to_file(&ctx.options.file_path) {
self.applies_to_file = FileApplicability::Applicable;
} else {
self.applies_to_file = FileApplicability::NotApplicable;
return;
}
}
FileApplicability::Applicable => {}
}

let rule_timer = profiling::start_plugin_rule("plugin");
let diagnostics = self
.plugin
Expand Down
1 change: 1 addition & 0 deletions crates/biome_css_analyze/tests/spec_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,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:?}"),
Expand Down
1 change: 1 addition & 0 deletions crates/biome_js_analyze/tests/spec_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,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:?}"),
Expand Down
2 changes: 2 additions & 0 deletions crates/biome_plugin_loader/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -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 }
Expand Down
90 changes: 86 additions & 4 deletions crates/biome_plugin_loader/src/analyzer_grit_plugin.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<Box<[NormalizedGlob]>>,
}

impl AnalyzerGritPlugin {
pub fn load(fs: &dyn FileSystem, path: &Utf8Path) -> Result<Self, PluginDiagnostic> {
pub fn load(
fs: &dyn FileSystem,
path: &Utf8Path,
includes: Option<&[NormalizedGlob]>,
) -> Result<Self, PluginDiagnostic> {
let source = fs.read_file_from_path(path)?;
let options = CompilePatternOptions::default()
.with_extra_built_ins(vec![
Expand All @@ -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),
})
}
}

Expand Down Expand Up @@ -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<Utf8PathBuf>) -> Vec<RuleDiagnostic> {
let name: &str = self.grit_query.name.as_deref().unwrap_or("anonymous");

Expand Down Expand Up @@ -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<NormalizedGlob> = 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<NormalizedGlob> = 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<NormalizedGlob> = 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<NormalizedGlob> = 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<NormalizedGlob> = 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")));
}
}
51 changes: 50 additions & 1 deletion crates/biome_plugin_loader/src/analyzer_js_plugin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ 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;
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.
Expand Down Expand Up @@ -46,6 +48,11 @@ pub struct AnalyzerJsPlugin {
fs: Arc<dyn FsWithResolverProxy>,
path: Utf8PathBuf,
loaded: ThreadLocalCell<LoadedPlugin>,

/// 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<Box<[NormalizedGlob]>>,
}

impl Debug for AnalyzerJsPlugin {
Expand All @@ -60,6 +67,7 @@ impl AnalyzerJsPlugin {
pub fn load(
fs: Arc<dyn FsWithResolverProxy>,
path: &Utf8Path,
includes: Option<&[NormalizedGlob]>,
) -> Result<Self, PluginDiagnostic> {
// Load the plugin in the main thread here to catch errors while loading.
load_plugin(fs.clone(), path)?;
Expand All @@ -68,6 +76,7 @@ impl AnalyzerJsPlugin {
fs,
path: path.to_owned(),
loaded: ThreadLocalCell::new(),
includes: includes.map(Into::into),
})
}
}
Expand All @@ -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<RawSyntaxKind> {
// TODO: Support granular query defined in the JS plugin.
AnyJsRoot::KIND_SET
Expand Down Expand Up @@ -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<dyn FsWithResolverProxy>;
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<NormalizedGlob> = 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<NormalizedGlob> = 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();
Expand All @@ -160,7 +209,7 @@ mod tests {
);

let fs = Arc::new(fs) as Arc<dyn FsWithResolverProxy>;
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();
Expand Down
Loading
Loading