refactor(linter): Add LinterBuilder (#5714)

This will replace `OxlintOptions` in an upstream PR. This also adds `plugins` to
`Oxlintrc`. This field gets respected by the builder, but not by
`OxlintOptions`.
This commit is contained in:
DonIsaac 2024-09-20 11:58:37 +00:00
parent 8ade793dfe
commit ba7b01fbdf
10 changed files with 397 additions and 49 deletions

View file

@ -0,0 +1,294 @@
use std::{
cell::{Ref, RefCell},
fmt,
};
use rustc_hash::FxHashSet;
use crate::{
options::LintPlugins, rules::RULES, AllowWarnDeny, FixKind, FrameworkFlags, LintConfig,
LintFilter, LintFilterKind, LintOptions, Linter, Oxlintrc, RuleCategory, RuleEnum,
RuleWithSeverity,
};
#[must_use = "You dropped your builder without building a Linter! Did you mean to call .build()?"]
pub struct LinterBuilder {
rules: FxHashSet<RuleWithSeverity>,
options: LintOptions,
config: LintConfig,
cache: RulesCache,
}
impl Default for LinterBuilder {
fn default() -> Self {
Self { rules: Self::warn_correctness(LintPlugins::default()), ..Self::empty() }
}
}
impl LinterBuilder {
/// Create a [`LinterBuilder`] with default plugins enabled and no
/// configured rules.
///
/// You can think of this as `oxlint -A all`.
pub fn empty() -> Self {
let options = LintOptions::default();
let cache = RulesCache::new(options.plugins);
Self { rules: FxHashSet::default(), options, config: LintConfig::default(), cache }
}
/// Warn on all rules in all plugins and categories, including those in `nursery`.
/// This is the kitchen sink.
///
/// You can think of this as `oxlint -W all -W nursery`.
pub fn all() -> Self {
let options = LintOptions { plugins: LintPlugins::all(), ..LintOptions::default() };
let cache = RulesCache::new(options.plugins);
Self {
rules: RULES
.iter()
.map(|rule| RuleWithSeverity { rule: rule.clone(), severity: AllowWarnDeny::Warn })
.collect(),
options,
config: LintConfig::default(),
cache,
}
}
/// Create a [`LinterBuilder`] from a loaded or manually built [`Oxlintrc`].
/// `start_empty` will configure the builder to contain only the
/// configuration settings from the config. When this is `false`, the config
/// will be applied on top of a default [`Oxlintrc`].
///
/// # Example
/// Here's how to create a [`Linter`] from a `.oxlintrc.json` file.
/// ```
/// use oxc_linter::{LinterBuilder, Oxlintrc};
/// let oxlintrc = Oxlintrc::from_file("path/to/.oxlintrc.json").unwrap();
/// let linter = LinterBuilder::from_oxlintrc(true, oxlintrc).build();
/// // you can use `From` as a shorthand for `from_oxlintrc(false, oxlintrc)`
/// let linter = LinterBuilder::from(oxlintrc).build();
/// ```
pub fn from_oxlintrc(start_empty: bool, oxlintrc: Oxlintrc) -> Self {
// TODO: monorepo config merging, plugin-based extends, etc.
let Oxlintrc { plugins, settings, env, globals, rules: oxlintrc_rules } = oxlintrc;
let config = LintConfig { settings, env, globals };
let options = LintOptions { plugins, ..Default::default() };
let rules =
if start_empty { FxHashSet::default() } else { Self::warn_correctness(plugins) };
let cache = RulesCache::new(options.plugins);
let mut builder = Self { rules, options, config, cache };
{
let all_rules = builder.cache.borrow();
oxlintrc_rules.override_rules(&mut builder.rules, all_rules.as_slice());
}
builder
}
#[inline]
pub fn with_framework_hints(mut self, flags: FrameworkFlags) -> Self {
self.options.framework_hints = flags;
self
}
#[inline]
pub fn and_framework_hints(mut self, flags: FrameworkFlags) -> Self {
self.options.framework_hints |= flags;
self
}
#[inline]
pub fn with_fix(mut self, fix: FixKind) -> Self {
self.options.fix = fix;
self
}
#[inline]
pub fn with_plugins(mut self, plugins: LintPlugins) -> Self {
self.options.plugins = plugins;
self.cache.set_plugins(plugins);
self
}
#[inline]
pub fn and_plugins(mut self, plugins: LintPlugins, enabled: bool) -> Self {
self.options.plugins.set(plugins, enabled);
self.cache.set_plugins(self.options.plugins);
self
}
#[cfg(test)]
pub(crate) fn with_rule(mut self, rule: RuleWithSeverity) -> Self {
self.rules.insert(rule);
self
}
pub fn with_filters<I: IntoIterator<Item = LintFilter>>(mut self, filters: I) -> Self {
for filter in filters {
self = self.with_filter(filter);
}
self
}
pub fn with_filter(mut self, filter: LintFilter) -> Self {
let (severity, filter) = filter.into();
let all_rules = self.cache.borrow();
match severity {
AllowWarnDeny::Deny | AllowWarnDeny::Warn => match filter {
LintFilterKind::Category(category) => {
self.rules.extend(
all_rules
.iter()
.filter(|rule| rule.category() == category)
.map(|rule| RuleWithSeverity::new(rule.clone(), severity)),
);
}
LintFilterKind::Rule(_, name) => {
self.rules.extend(
all_rules
.iter()
.filter(|rule| rule.name() == name)
.map(|rule| RuleWithSeverity::new(rule.clone(), severity)),
);
}
LintFilterKind::Generic(name_or_category) => {
if name_or_category == "all" {
self.rules.extend(
all_rules
.iter()
.filter(|rule| rule.category() != RuleCategory::Nursery)
.map(|rule| RuleWithSeverity::new(rule.clone(), severity)),
);
} else {
self.rules.extend(
all_rules
.iter()
.filter(|rule| rule.name() == name_or_category)
.map(|rule| RuleWithSeverity::new(rule.clone(), severity)),
);
}
}
},
AllowWarnDeny::Allow => match filter {
LintFilterKind::Category(category) => {
self.rules.retain(|rule| rule.category() != category);
}
LintFilterKind::Rule(_, name) => {
self.rules.retain(|rule| rule.name() != name);
}
LintFilterKind::Generic(name_or_category) => {
if name_or_category == "all" {
self.rules.clear();
} else {
self.rules.retain(|rule| rule.name() != name_or_category);
}
}
},
}
drop(all_rules);
self
}
#[must_use]
pub fn build(self) -> Linter {
let mut rules = self.rules.into_iter().collect::<Vec<_>>();
rules.sort_unstable_by_key(|r| r.id());
Linter::new(rules, self.options, self.config)
}
/// Warn for all correctness rules in the given set of plugins.
fn warn_correctness(plugins: LintPlugins) -> FxHashSet<RuleWithSeverity> {
RULES
.iter()
.filter(|rule| {
// NOTE: this logic means there's no way to disable ESLint
// correctness rules. I think that's fine for now.
rule.category() == RuleCategory::Correctness
&& plugins.contains(LintPlugins::from(rule.plugin_name()))
})
.map(|rule| RuleWithSeverity { rule: rule.clone(), severity: AllowWarnDeny::Warn })
.collect()
}
}
impl From<Oxlintrc> for LinterBuilder {
#[inline]
fn from(oxlintrc: Oxlintrc) -> Self {
Self::from_oxlintrc(false, oxlintrc)
}
}
impl fmt::Debug for LinterBuilder {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("LinterBuilder")
.field("rules", &self.rules)
.field("options", &self.options)
.field("config", &self.config)
.finish_non_exhaustive()
}
}
struct RulesCache(RefCell<Option<Vec<RuleEnum>>>, LintPlugins);
impl RulesCache {
#[inline]
#[must_use]
pub fn new(plugins: LintPlugins) -> Self {
Self(RefCell::new(None), plugins)
}
pub fn set_plugins(&mut self, plugins: LintPlugins) {
self.1 = plugins;
self.clear();
}
#[must_use]
fn borrow(&self) -> Ref<'_, Vec<RuleEnum>> {
let cached = self.0.borrow();
if cached.is_some() {
Ref::map(cached, |cached| cached.as_ref().unwrap())
} else {
drop(cached);
self.initialize();
Ref::map(self.0.borrow(), |cached| cached.as_ref().unwrap())
}
}
/// # Panics
/// If the cache cell is currently borrowed.
fn clear(&self) {
*self.0.borrow_mut() = None;
}
/// Forcefully initialize this cache with all rules in all plugins currently
/// enabled.
///
/// This will clobber whatever value is currently stored. It should only be
/// called when the cache is not populated, either because it has not been
/// initialized yet or it was cleared with [`Self::clear`].
///
/// # Panics
/// If the cache cell is currently borrowed.
fn initialize(&self) {
debug_assert!(
self.0.borrow().is_none(),
"Cannot re-initialize a populated rules cache. It must be cleared first."
);
let mut all_rules: Vec<_> = if self.1.is_all() {
RULES.clone()
} else {
RULES
.iter()
.filter(|rule| self.1.contains(LintPlugins::from(rule.plugin_name())))
.cloned()
.collect()
};
all_rules.sort_unstable(); // TODO: do we need to sort? is is already sorted?
*self.0.borrow_mut() = Some(all_rules);
}
}

View file

@ -76,7 +76,7 @@ mod test {
}));
assert!(config.is_ok());
let Oxlintrc { rules, settings, env, globals } = config.unwrap();
let Oxlintrc { rules, settings, env, globals, .. } = config.unwrap();
assert!(!rules.is_empty());
assert_eq!(
settings.jsx_a11y.polymorphic_prop_name.as_ref().map(CompactStr::as_str),

View file

@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize};
use super::{env::OxlintEnv, globals::OxlintGlobals, rules::OxlintRules, settings::OxlintSettings};
use crate::utils::read_to_string;
use crate::{options::LintPlugins, utils::read_to_string};
/// Oxlint Configuration File
///
@ -42,7 +42,9 @@ use crate::utils::read_to_string;
/// ```
#[derive(Debug, Default, Deserialize, Serialize, JsonSchema)]
#[serde(default)]
#[non_exhaustive]
pub struct Oxlintrc {
pub plugins: LintPlugins,
/// See [Oxlint Rules](https://oxc.rs/docs/guide/usage/linter/rules.html).
pub rules: OxlintRules,
pub settings: OxlintSettings,

View file

@ -4,6 +4,7 @@
mod tester;
mod ast_util;
mod builder;
mod config;
mod context;
mod disable_directives;
@ -29,11 +30,12 @@ use oxc_diagnostics::Error;
use oxc_semantic::{AstNode, Semantic};
pub use crate::{
builder::LinterBuilder,
config::Oxlintrc,
context::LintContext,
fixer::FixKind,
frameworks::FrameworkFlags,
options::{AllowWarnDeny, InvalidFilterKind, LintFilter, OxlintOptions},
options::{AllowWarnDeny, InvalidFilterKind, LintFilter, LintFilterKind, OxlintOptions},
rule::{RuleCategory, RuleFixMeta, RuleMeta, RuleWithSeverity},
service::{LintService, LintServiceOptions},
};
@ -67,6 +69,14 @@ impl Default for Linter {
}
impl Linter {
pub(crate) fn new(
rules: Vec<RuleWithSeverity>,
options: LintOptions,
config: LintConfig,
) -> Self {
Self { rules, options, config: Arc::new(config) }
}
/// # Errors
///
/// Returns `Err` if there are any errors parsing the configuration file.
@ -82,14 +92,6 @@ impl Linter {
self
}
/// Used for testing
#[cfg(test)]
#[must_use]
pub(crate) fn with_eslint_config(mut self, config: LintConfig) -> Self {
self.config = Arc::new(config);
self
}
/// Set the kind of auto fixes to apply.
///
/// # Example

View file

@ -4,12 +4,11 @@ mod plugins;
use std::{convert::From, path::PathBuf};
use filter::LintFilterKind;
use oxc_diagnostics::Error;
use rustc_hash::FxHashSet;
pub use allow_warn_deny::AllowWarnDeny;
pub use filter::{InvalidFilterKind, LintFilter};
pub use filter::{InvalidFilterKind, LintFilter, LintFilterKind};
pub use plugins::{LintPluginOptions, LintPlugins};
use crate::{

View file

@ -6,6 +6,8 @@ bitflags! {
// NOTE: may be increased to a u32 if needed
#[derive(Debug, Clone, Copy, PartialEq, Hash)]
pub struct LintPlugins: u16 {
/// Not really a plugin. Included for completeness.
const ESLINT = 0;
/// `eslint-plugin-react`, plus `eslint-plugin-react-hooks`
const REACT = 1 << 0;
/// `eslint-plugin-unicorn`
@ -68,6 +70,12 @@ impl LintPlugins {
self.contains(LintPlugins::VITEST)
}
/// Returns `true` if the Jest plugin is enabled.
#[inline]
pub fn has_jest(self) -> bool {
self.contains(LintPlugins::JEST)
}
/// Returns `true` if Jest or Vitest plugins are enabled.
#[inline]
pub fn has_test(self) -> bool {

View file

@ -28,6 +28,19 @@ expression: json
}
]
},
"plugins": {
"default": [
"react",
"unicorn",
"typescript",
"oxc"
],
"allOf": [
{
"$ref": "#/definitions/LintPlugins"
}
]
},
"rules": {
"description": "See [Oxlint Rules](https://oxc.rs/docs/guide/usage/linter/rules.html).",
"default": {},
@ -222,6 +235,12 @@ expression: json
}
}
},
"LintPlugins": {
"type": "array",
"items": {
"type": "string"
}
},
"NextPluginSettings": {
"type": "object",
"properties": {

View file

@ -10,8 +10,8 @@ use serde::Deserialize;
use serde_json::Value;
use crate::{
fixer::FixKind, options::LintPluginOptions, rules::RULES, AllowWarnDeny, Fixer, LintService,
LintServiceOptions, Linter, OxlintOptions, Oxlintrc, RuleEnum, RuleWithSeverity,
fixer::FixKind, options::LintPlugins, rules::RULES, AllowWarnDeny, Fixer, LintService,
LintServiceOptions, LinterBuilder, Oxlintrc, RuleEnum, RuleWithSeverity,
};
#[derive(Eq, PartialEq)]
@ -171,13 +171,7 @@ pub struct Tester {
/// See: [insta::Settings::set_snapshot_suffix]
snapshot_suffix: Option<&'static str>,
current_working_directory: Box<Path>,
// import_plugin: bool,
// jest_plugin: bool,
// vitest_plugin: bool,
// jsx_a11y_plugin: bool,
// nextjs_plugin: bool,
// react_perf_plugin: bool,
plugins: LintPluginOptions,
plugins: LintPlugins,
}
impl Tester {
@ -201,7 +195,7 @@ impl Tester {
snapshot: String::new(),
snapshot_suffix: None,
current_working_directory,
plugins: LintPluginOptions::none(),
plugins: LintPlugins::default(),
}
}
@ -223,37 +217,37 @@ impl Tester {
}
pub fn with_import_plugin(mut self, yes: bool) -> Self {
self.plugins.import = yes;
self.plugins.set(LintPlugins::IMPORT, yes);
self
}
pub fn with_jest_plugin(mut self, yes: bool) -> Self {
self.plugins.jest = yes;
self.plugins.set(LintPlugins::JEST, yes);
self
}
pub fn with_vitest_plugin(mut self, yes: bool) -> Self {
self.plugins.vitest = yes;
self.plugins.set(LintPlugins::VITEST, yes);
self
}
pub fn with_jsx_a11y_plugin(mut self, yes: bool) -> Self {
self.plugins.jsx_a11y = yes;
self.plugins.set(LintPlugins::JSX_A11Y, yes);
self
}
pub fn with_nextjs_plugin(mut self, yes: bool) -> Self {
self.plugins.nextjs = yes;
self.plugins.set(LintPlugins::NEXTJS, yes);
self
}
pub fn with_react_perf_plugin(mut self, yes: bool) -> Self {
self.plugins.react_perf = yes;
self.plugins.set(LintPlugins::REACT_PERF, yes);
self
}
pub fn with_node_plugin(mut self, yes: bool) -> Self {
self.plugins.node = yes;
self.plugins.set(LintPlugins::NODE, yes);
self
}
@ -351,28 +345,22 @@ impl Tester {
) -> TestResult {
let allocator = Allocator::default();
let rule = self.find_rule().read_json(rule_config.unwrap_or_default());
let options = OxlintOptions::default()
.with_fix(fix.into())
.with_import_plugin(self.plugins.import)
.with_jest_plugin(self.plugins.jest)
.with_vitest_plugin(self.plugins.vitest)
.with_jsx_a11y_plugin(self.plugins.jsx_a11y)
.with_nextjs_plugin(self.plugins.nextjs)
.with_react_perf_plugin(self.plugins.react_perf)
.with_node_plugin(self.plugins.node);
let eslint_config = eslint_config
let linter = eslint_config
.as_ref()
.map_or_else(Oxlintrc::default, |v| Oxlintrc::deserialize(v).unwrap());
let linter = Linter::from_options(options)
.unwrap()
.with_rules(vec![RuleWithSeverity::new(rule, AllowWarnDeny::Warn)])
.with_eslint_config(eslint_config.into());
let path_to_lint = if self.plugins.import {
.map_or_else(LinterBuilder::empty, |v| {
LinterBuilder::from_oxlintrc(true, Oxlintrc::deserialize(v).unwrap())
})
.with_fix(fix.into())
.with_plugins(self.plugins)
.with_rule(RuleWithSeverity::new(rule, AllowWarnDeny::Warn))
.build();
let path_to_lint = if self.plugins.has_import() {
assert!(path.is_none(), "import plugin does not support path");
self.current_working_directory.join(&self.rule_path)
} else if let Some(path) = path {
self.current_working_directory.join(path)
} else if self.plugins.jest {
} else if self.plugins.has_jest() {
self.rule_path.with_extension("test.tsx")
} else {
self.rule_path.clone()
@ -380,7 +368,8 @@ impl Tester {
let cwd = self.current_working_directory.clone();
let paths = vec![path_to_lint.into_boxed_path()];
let options = LintServiceOptions::new(cwd, paths).with_cross_module(self.plugins.import);
let options =
LintServiceOptions::new(cwd, paths).with_cross_module(self.plugins.has_import());
let lint_service = LintService::from_linter(linter, options);
let diagnostic_service = DiagnosticService::default();
let tx_error = diagnostic_service.sender();
@ -395,7 +384,7 @@ impl Tester {
return TestResult::Fixed(fix_result.fixed_code.to_string());
}
let diagnostic_path = if self.plugins.import {
let diagnostic_path = if self.plugins.has_import() {
self.rule_path.strip_prefix(&self.current_working_directory).unwrap()
} else {
&self.rule_path

View file

@ -24,6 +24,19 @@
}
]
},
"plugins": {
"default": [
"react",
"unicorn",
"typescript",
"oxc"
],
"allOf": [
{
"$ref": "#/definitions/LintPlugins"
}
]
},
"rules": {
"description": "See [Oxlint Rules](https://oxc.rs/docs/guide/usage/linter/rules.html).",
"default": {},
@ -218,6 +231,12 @@
}
}
},
"LintPlugins": {
"type": "array",
"items": {
"type": "string"
}
},
"NextPluginSettings": {
"type": "object",
"properties": {

View file

@ -70,6 +70,22 @@ You may also use `"readable"` or `false` to represent `"readonly"`, and `"writea
## plugins
type: `array`
### plugins[n]
type: `string`
## rules
type: `object`