Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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/batch-plugin-visitor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@biomejs/biome": patch
---

Improved plugin performance by batching all plugins into a single syntax visitor with a kind-to-plugin lookup map, reducing per-node dispatch overhead from O(N) to O(1) where N is the number of plugins.
5 changes: 4 additions & 1 deletion .github/workflows/benchmark_js.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: Benchmarks JS
name: Benchmarks JS/GritQL

on:
workflow_dispatch:
Expand All @@ -17,6 +17,7 @@ on:
- 'crates/biome_formatter/**/*.rs'
- 'crates/biome_rowan/**/*.rs'
- 'crates/biome_parser/**/*.rs'
- 'crates/biome_grit_patterns/**/*.rs'
push:
branches:
- main
Expand All @@ -30,6 +31,7 @@ on:
- 'crates/biome_formatter/**/*.rs'
- 'crates/biome_rowan/**/*.rs'
- 'crates/biome_parser/**/*.rs'
- 'crates/biome_grit_patterns/**/*.rs'

env:
RUST_LOG: info
Expand All @@ -47,6 +49,7 @@ jobs:
- biome_js_parser
- biome_js_formatter
- biome_js_analyze
- biome_grit_patterns

steps:

Expand Down
3 changes: 3 additions & 0 deletions Cargo.lock

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

156 changes: 154 additions & 2 deletions crates/biome_analyze/src/analyzer_plugin.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use camino::Utf8PathBuf;
use rustc_hash::FxHashSet;
use camino::{Utf8Path, Utf8PathBuf};
use rustc_hash::{FxHashMap, FxHashSet};
use std::hash::Hash;
use std::{fmt::Debug, sync::Arc};

Expand All @@ -23,6 +23,14 @@ 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.
///
/// 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
}
}

#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)]
Expand All @@ -37,6 +45,10 @@ pub enum PluginTargetLanguage {
pub struct PluginVisitor<L: Language> {
query: FxHashSet<L::Kind>,
plugin: Arc<Box<dyn AnalyzerPlugin>>,

/// When set, all nodes in this subtree are skipped until we leave it.
/// Used to skip subtrees that fall entirely outside the analysis range
/// (see the `ctx.range` check in `visit`).
skip_subtree: Option<SyntaxNode<L>>,
}

Expand Down Expand Up @@ -102,6 +114,10 @@ where
return;
}

if !self.plugin.applies_to_file(&ctx.options.file_path) {
return;
}

let rule_timer = profiling::start_plugin_rule("plugin");
let diagnostics = self
.plugin
Expand All @@ -126,3 +142,139 @@ where
ctx.signal_queue.extend(signals);
}
}

/// A batched syntax visitor that evaluates multiple plugins in a single visitor.
///
/// Instead of registering N separate `PluginVisitor` instances (one per plugin),
/// this holds all plugins together and dispatches using a kind-to-plugin lookup
/// map. This reduces visitor-dispatch overhead and enables O(1) kind matching
/// per node instead of iterating all plugins.
pub struct BatchPluginVisitor<L: Language> {
plugins: Vec<Arc<Box<dyn AnalyzerPlugin>>>,

/// Maps each syntax kind to the indices of plugins that query for it.
kind_to_plugins: FxHashMap<L::Kind, Vec<usize>>,

/// When set, all nodes in this subtree are skipped until we leave it.
/// Used to skip subtrees that fall entirely outside the analysis range
/// (see the `ctx.range` check in `visit`).
skip_subtree: Option<SyntaxNode<L>>,

/// Cached per-plugin results of `applies_to_file`. Populated lazily on
/// first `WalkEvent::Enter` — the file path is constant for the entire walk.
applicable: Option<Vec<bool>>,
}

impl<L> BatchPluginVisitor<L>
where
L: Language + 'static,
L::Kind: Eq + Hash,
{
/// Creates a batched plugin visitor from a slice of plugins.
///
/// # Safety
/// Caller must ensure all plugins target language `L`. The `RawSyntaxKind`
/// values returned by each plugin's `query()` are converted to `L::Kind`
/// via `from_raw` without validation.
pub unsafe fn new_unchecked(plugins: AnalyzerPluginSlice) -> Self {
let mut all_plugins = Vec::with_capacity(plugins.len());
let mut kind_to_plugins: FxHashMap<L::Kind, Vec<usize>> = FxHashMap::default();

for (idx, plugin) in plugins.iter().enumerate() {
all_plugins.push(Arc::clone(plugin));
let mut seen_kinds = FxHashSet::default();
for raw_kind in plugin.query() {
let kind = L::Kind::from_raw(raw_kind);
if seen_kinds.insert(kind) {
kind_to_plugins.entry(kind).or_default().push(idx);
}
}
}

Self {
plugins: all_plugins,
kind_to_plugins,
skip_subtree: None,
applicable: None,
}
}
}

impl<L> Visitor for BatchPluginVisitor<L>
where
L: Language + 'static,
L::Kind: Eq + Hash,
{
type Language = L;

fn visit(
&mut self,
event: &WalkEvent<SyntaxNode<Self::Language>>,
ctx: VisitorContext<Self::Language>,
) {
let node = match event {
WalkEvent::Enter(node) => node,
WalkEvent::Leave(node) => {
if let Some(skip_subtree) = &self.skip_subtree
&& skip_subtree == node
{
self.skip_subtree = None;
}

return;
}
};

if self.skip_subtree.is_some() {
return;
}

if let Some(range) = ctx.range
&& node.text_range_with_trivia().ordering(range).is_ne()
{
self.skip_subtree = Some(node.clone());
return;
}

let kind = node.kind();

let Some(plugin_indices) = self.kind_to_plugins.get(&kind) else {
return;
};

let applicable = self.applicable.get_or_insert_with(|| {
self.plugins
.iter()
.map(|p| p.applies_to_file(&ctx.options.file_path))
.collect()
});

for &idx in plugin_indices {
if !applicable[idx] {
continue;
}

let plugin = &self.plugins[idx];
let rule_timer = profiling::start_plugin_rule("plugin");
let diagnostics = plugin.evaluate(node.clone().into(), ctx.options.file_path.clone());
rule_timer.stop();

let signals = diagnostics.into_iter().map(|diagnostic| {
let name = diagnostic
.subcategory
.clone()
.unwrap_or_else(|| "anonymous".into());

SignalEntry {
text_range: diagnostic.span().unwrap_or_default(),
signal: Box::new(PluginSignal::<L>::new(diagnostic)),
rule: SignalRuleKey::Plugin(name.into()),
category: RuleCategory::Lint,
instances: Default::default(),
}
});

ctx.signal_queue.extend(signals);
}
}
}
3 changes: 2 additions & 1 deletion crates/biome_analyze/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ mod visitor;
pub use biome_diagnostics::category_concat;

pub use crate::analyzer_plugin::{
AnalyzerPlugin, AnalyzerPluginSlice, AnalyzerPluginVec, PluginTargetLanguage, PluginVisitor,
AnalyzerPlugin, AnalyzerPluginSlice, AnalyzerPluginVec, BatchPluginVisitor,
PluginTargetLanguage, PluginVisitor,
};
pub use crate::categories::{
ActionCategory, OtherActionCategory, RefactorKind, RuleCategories, RuleCategoriesBuilder,
Expand Down
24 changes: 14 additions & 10 deletions crates/biome_css_analyze/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ pub use crate::registry::visit_registry;
use crate::suppression_action::CssSuppressionAction;
use biome_analyze::{
AnalysisFilter, AnalyzerOptions, AnalyzerPluginSlice, AnalyzerSignal, AnalyzerSuppression,
ControlFlow, LanguageRoot, MatchQueryParams, MetadataRegistry, Phases, PluginTargetLanguage,
PluginVisitor, RuleAction, RuleRegistry, to_analyzer_suppressions,
BatchPluginVisitor, ControlFlow, LanguageRoot, MatchQueryParams, MetadataRegistry, Phases,
PluginTargetLanguage, RuleAction, RuleRegistry, to_analyzer_suppressions,
};
use biome_css_syntax::{CssFileSource, CssLanguage, TextRange};
use biome_diagnostics::Error;
Expand Down Expand Up @@ -151,15 +151,19 @@ where
analyzer.add_visitor(phase, visitor);
}

for plugin in plugins {
// SAFETY: The plugin target language is correctly checked here.
let css_plugins: Vec<_> = plugins
.iter()
.filter(|p| p.language() == PluginTargetLanguage::Css)
.cloned()
.collect();

if !css_plugins.is_empty() {
// SAFETY: All plugins have been verified to target CSS above.
unsafe {
if plugin.language() == PluginTargetLanguage::Css {
analyzer.add_visitor(
Phases::Syntax,
Box::new(PluginVisitor::new_unchecked(plugin.clone())),
)
}
analyzer.add_visitor(
Phases::Syntax,
Box::new(BatchPluginVisitor::new_unchecked(&css_plugins)),
);
}
}

Expand Down
11 changes: 11 additions & 0 deletions crates/biome_grit_patterns/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ keywords.workspace = true
categories.workspace = true
publish = false

[[bench]]
harness = false
name = "grit_query"

[dependencies]
biome_analyze = { workspace = true }
biome_console = { workspace = true }
Expand Down Expand Up @@ -39,9 +43,16 @@ serde_json = { workspace = true, optional = true }

[dev-dependencies]
biome_test_utils = { path = "../biome_test_utils" }
criterion = { package = "codspeed-criterion-compat", version = "*" }
insta = { workspace = true }
tests_macros = { path = "../tests_macros" }

[target.'cfg(all(target_family="unix", not(all(target_arch = "aarch64", target_env = "musl"))))'.dev-dependencies]
tikv-jemallocator = { workspace = true }

[target.'cfg(target_os = "windows")'.dev-dependencies]
mimalloc = { workspace = true }

[features]
schema = ["biome_js_parser/schema", "dep:schemars", "serde"]
serde = ["dep:serde", "dep:serde_json"]
Expand Down
Loading