diff --git a/Cargo.lock b/Cargo.lock index a0da98b2c8d887..26b5ec1e4b434d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -795,6 +795,7 @@ dependencies = [ "log", "regex-automata", "regex-syntax", + "serde", ] [[package]] @@ -835,6 +836,15 @@ dependencies = [ "thiserror", ] +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -860,6 +870,16 @@ dependencies = [ "hashbrown 0.14.5", ] +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] + [[package]] name = "hermit-abi" version = "0.3.9" @@ -1669,11 +1689,13 @@ name = "oxc_linter" version = "0.10.3" dependencies = [ "aho-corasick", + "assert-unchecked", "bitflags 2.6.0", "convert_case", "cow-utils", "dashmap 6.1.0", "globset", + "heapless", "insta", "itertools", "json-strip-comments", @@ -1682,6 +1704,7 @@ dependencies = [ "markdown", "memchr", "mime_guess", + "nonmax", "once_cell", "oxc_allocator", "oxc_ast", diff --git a/Cargo.toml b/Cargo.toml index 6897c685d13bee..a9fd4ff9ebf85f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -146,6 +146,7 @@ futures = "0.3.31" glob = "0.3.1" globset = "0.4.15" handlebars = "6.1.0" +heapless = "0.8.0" humansize = "2.1.3" ignore = "0.4.23" indexmap = "2.6.0" diff --git a/crates/oxc_diagnostics/src/lib.rs b/crates/oxc_diagnostics/src/lib.rs index 8925274c3fe597..584c15be167a07 100644 --- a/crates/oxc_diagnostics/src/lib.rs +++ b/crates/oxc_diagnostics/src/lib.rs @@ -97,6 +97,7 @@ pub struct OxcCode { pub scope: Option>, pub number: Option>, } + impl OxcCode { pub fn is_some(&self) -> bool { self.scope.is_some() || self.number.is_some() diff --git a/crates/oxc_linter/Cargo.toml b/crates/oxc_linter/Cargo.toml index 20d6e9b1be656e..c3f6b442a97d70 100644 --- a/crates/oxc_linter/Cargo.toml +++ b/crates/oxc_linter/Cargo.toml @@ -26,27 +26,30 @@ oxc_cfg = { workspace = true } oxc_codegen = { workspace = true } oxc_diagnostics = { workspace = true } oxc_ecmascript = { workspace = true } -oxc_index = { workspace = true } +oxc_index = { workspace = true, features = ["serialize"] } oxc_macros = { workspace = true } oxc_parser = { workspace = true } oxc_regular_expression = { workspace = true } oxc_resolver = { workspace = true } oxc_semantic = { workspace = true } oxc_span = { workspace = true, features = ["schemars", "serialize"] } -oxc_syntax = { workspace = true } +oxc_syntax = { workspace = true, features = ["serialize"] } aho-corasick = { workspace = true } +assert-unchecked = { workspace = true } bitflags = { workspace = true } convert_case = { workspace = true } cow-utils = { workspace = true } dashmap = { workspace = true } -globset = { workspace = true } +globset = { workspace = true, features = ["serde1"] } +heapless = { workspace = true } itertools = { workspace = true } json-strip-comments = { workspace = true } language-tags = { workspace = true } lazy_static = { workspace = true } memchr = { workspace = true } mime_guess = { workspace = true } +nonmax = { workspace = true } once_cell = { workspace = true } phf = { workspace = true, features = ["macros"] } rayon = { workspace = true } diff --git a/crates/oxc_linter/src/builder.rs b/crates/oxc_linter/src/builder.rs index 0ca05e142b763b..b870470eaacf74 100644 --- a/crates/oxc_linter/src/builder.rs +++ b/crates/oxc_linter/src/builder.rs @@ -7,7 +7,7 @@ use oxc_span::CompactStr; use rustc_hash::FxHashSet; use crate::{ - config::{ESLintRule, LintPlugins, OxlintRules}, + config::{ConfigStore, ESLintRule, LintPlugins, OxlintOverrides, OxlintRules}, rules::RULES, AllowWarnDeny, FixKind, FrameworkFlags, LintConfig, LintFilter, LintFilterKind, LintOptions, Linter, Oxlintrc, RuleCategory, RuleEnum, RuleWithSeverity, @@ -18,6 +18,7 @@ pub struct LinterBuilder { pub(super) rules: FxHashSet, options: LintOptions, config: LintConfig, + overrides: OxlintOverrides, cache: RulesCache, } @@ -36,9 +37,10 @@ impl LinterBuilder { let options = LintOptions::default(); let config = LintConfig::default(); let rules = FxHashSet::default(); + let overrides = OxlintOverrides::default(); let cache = RulesCache::new(config.plugins); - Self { rules, options, config, cache } + Self { rules, options, config, overrides, cache } } /// Warn on all rules in all plugins and categories, including those in `nursery`. @@ -48,6 +50,7 @@ impl LinterBuilder { pub fn all() -> Self { let options = LintOptions::default(); let config = LintConfig { plugins: LintPlugins::all(), ..LintConfig::default() }; + let overrides = OxlintOverrides::default(); let cache = RulesCache::new(config.plugins); Self { rules: RULES @@ -56,6 +59,7 @@ impl LinterBuilder { .collect(), options, config, + overrides, cache, } } @@ -76,15 +80,22 @@ impl LinterBuilder { /// ``` pub fn from_oxlintrc(start_empty: bool, oxlintrc: Oxlintrc) -> Self { // TODO: monorepo config merging, plugin-based extends, etc. - let Oxlintrc { plugins, settings, env, globals, categories, rules: oxlintrc_rules } = - oxlintrc; + let Oxlintrc { + plugins, + settings, + env, + globals, + categories, + rules: oxlintrc_rules, + overrides, + } = oxlintrc; let config = LintConfig { plugins, settings, env, globals }; let options = LintOptions::default(); let rules = if start_empty { FxHashSet::default() } else { Self::warn_correctness(plugins) }; let cache = RulesCache::new(config.plugins); - let mut builder = Self { rules, options, config, cache }; + let mut builder = Self { rules, options, config, overrides, cache }; if !categories.is_empty() { builder = builder.with_filters(categories.filters()); @@ -222,6 +233,8 @@ impl LinterBuilder { } } + /// # Panics + /// If more than 128 overrides are present within the oxlint config. #[must_use] pub fn build(self) -> Linter { // When a plugin gets disabled before build(), rules for that plugin aren't removed until @@ -234,7 +247,8 @@ impl LinterBuilder { self.rules.into_iter().collect::>() }; rules.sort_unstable_by_key(|r| r.id()); - Linter::new(rules, self.options, self.config) + let config = ConfigStore::new(rules, self.config, self.overrides).unwrap(); + Linter::new(self.options, config) } /// Warn for all correctness rules in the given set of plugins. @@ -532,7 +546,7 @@ mod test { desired_plugins.set(LintPlugins::TYPESCRIPT, false); let linter = LinterBuilder::default().with_plugins(desired_plugins).build(); - for rule in linter.rules() { + for rule in linter.rules().iter() { let name = rule.name(); let plugin = rule.plugin_name(); assert_ne!( diff --git a/crates/oxc_linter/src/config/flat.rs b/crates/oxc_linter/src/config/flat.rs new file mode 100644 index 00000000000000..9d91322c981371 --- /dev/null +++ b/crates/oxc_linter/src/config/flat.rs @@ -0,0 +1,332 @@ +use crate::LintPlugins; +use crate::{rules::RULES, RuleWithSeverity}; +use assert_unchecked::assert_unchecked; +use oxc_diagnostics::OxcDiagnostic; +use rustc_hash::FxHashSet; +use std::{ + hash::{BuildHasher, Hash, Hasher}, + path::Path, + sync::Arc, +}; + +use super::{ + overrides::{OverrideId, OxlintOverrides}, + LintConfig, +}; +use dashmap::DashMap; +use heapless::Vec as StackVec; +use rustc_hash::FxBuildHasher; + +type AppliedOverrideHash = u64; + +// bigger = more overrides in oxlintrc files, but more stack space taken when resolving configs. We +// should tune this value based on real-world usage, but we don't collect telemetry yet. +pub const MAX_OVERRIDE_COUNT: usize = 128; +const _: () = { + assert!(MAX_OVERRIDE_COUNT.is_power_of_two()); +}; + +// TODO: support `categories` et. al. in overrides. +#[derive(Debug)] +pub(crate) struct ResolvedLinterState { + // TODO: Arc + Vec -> SyncVec? It would save a pointer dereference. + pub rules: Arc<[RuleWithSeverity]>, + pub config: Arc, +} + +impl Clone for ResolvedLinterState { + fn clone(&self) -> Self { + Self { rules: Arc::clone(&self.rules), config: Arc::clone(&self.config) } + } +} + +/// Keeps track of a list of config deltas, lazily applying them to a base config as requested by +/// [`ConfigStore::resolve`]. This struct is [`Sync`] + [`Send`] since the linter runs on each file +/// in parallel. +#[derive(Debug)] +pub struct ConfigStore { + // TODO: flatten base config + overrides into a single "flat" config. Similar idea to ESLint's + // flat configs, but we would still support v8 configs. Doing this could open the door to + // supporting flat configs (e.g. eslint.config.js). Still need to figure out how this plays + // with nested configs. + /// Resolved override cache. The key is a hash of each override's ID that matched the list of + /// file globs in order to avoid re-allocating the same set of rules multiple times. + cache: DashMap, + /// "root" level configuration. In the future this may just be the first entry in `overrides`. + base: ResolvedLinterState, + /// Config deltas applied to `base`. + overrides: OxlintOverrides, +} + +impl ConfigStore { + pub fn new( + base_rules: Vec, + base_config: LintConfig, + overrides: OxlintOverrides, + ) -> Result { + if overrides.len() > MAX_OVERRIDE_COUNT { + return Err(OxcDiagnostic::error(format!( + "Oxlint only supports up to {} overrides, but {} were provided", + overrides.len(), + MAX_OVERRIDE_COUNT + ))); + } + let base = ResolvedLinterState { + rules: Arc::from(base_rules.into_boxed_slice()), + config: Arc::new(base_config), + }; + // best-best case: no overrides are provided & config is initialized with 0 capacity best + // case: each file matches only a single override, so we only need `overrides.len()` + // capacity worst case: files match more than one override. In the most ridiculous case, we + // could end up needing (overrides.len() ** 2) capacity. I don't really want to + // pre-allocate that much space unconditionally. Better to re-alloc if we end up needing + // it. + let cache = DashMap::with_capacity_and_hasher(overrides.len(), FxBuildHasher); + + Ok(Self { cache, base, overrides }) + } + + /// Set the base rules, replacing all existing rules. + #[cfg(test)] + #[inline] + pub fn set_rules(&mut self, new_rules: Vec) { + self.base.rules = Arc::from(new_rules.into_boxed_slice()); + } + + pub fn number_of_rules(&self) -> usize { + self.base.rules.len() + } + + pub fn rules(&self) -> &Arc<[RuleWithSeverity]> { + &self.base.rules + } + + pub(crate) fn resolve(&self, path: &Path) -> ResolvedLinterState { + if self.overrides.is_empty() { + return self.base.clone(); + } + + // SAFETY: number of overrides is checked in constructor, and overrides cannot be added + // after ConfigStore is created. + unsafe { assert_unchecked!(self.overrides.len() <= MAX_OVERRIDE_COUNT) }; + // Resolution gets run in a relatively tight loop. This vec allocates on the stack, kind + // of like `int buf[MAX_OVERRIDE_COUNT]` in C (but we also keep track of a len). This + // prevents lots of malloc/free calls, reducing heap fragmentation and system call + // overhead. + let mut overrides_to_apply: StackVec = StackVec::new(); + let mut hasher = FxBuildHasher.build_hasher(); + + for (id, override_config) in self.overrides.iter_enumerated() { + // SAFETY: we know that overrides_to_apply's length will always be less than or equal + // to the maximum override count, which is what the capacity is set to. This assertion + // helps rustc optimize away bounds checks in the loop's `.push()` calls. Rustc is + // notoriously bad at optimizing away loop bounds checks, so we do it for it. + unsafe { assert_unchecked!(overrides_to_apply.len() < overrides_to_apply.capacity()) }; + if override_config.files.is_match(path) { + overrides_to_apply.push(id).unwrap(); + id.hash(&mut hasher); + } + } + + if overrides_to_apply.is_empty() { + return self.base.clone(); + } + + let key = hasher.finish(); + self.cache + .entry(key) + .or_insert_with(|| self.apply_overrides(&overrides_to_apply)) + .value() + .clone() + } + + /// NOTE: this function must not borrow any entries from `self.cache` or DashMap will deadlock. + fn apply_overrides(&self, override_ids: &[OverrideId]) -> ResolvedLinterState { + let plugins = self + .overrides + .iter() + .rev() + .find_map(|cfg| cfg.plugins) + .unwrap_or(self.base.config.plugins); + + let all_rules = RULES + .iter() + .filter(|rule| plugins.contains(LintPlugins::from(rule.plugin_name()))) + .cloned() + .collect::>(); + let mut rules = self + .base + .rules + .iter() + .filter(|rule| plugins.contains(LintPlugins::from(rule.plugin_name()))) + .cloned() + .collect::>(); + + let overrides = override_ids.iter().map(|id| &self.overrides[*id]); + for override_config in overrides { + if override_config.rules.is_empty() { + continue; + } + override_config.rules.override_rules(&mut rules, &all_rules); + } + + let rules = rules.into_iter().collect::>(); + let config = if plugins == self.base.config.plugins { + Arc::clone(&self.base.config) + } else { + let mut config = (*self.base.config.as_ref()).clone(); + + config.plugins = plugins; + Arc::new(config) + }; + + ResolvedLinterState { rules: Arc::from(rules.into_boxed_slice()), config } + } +} + +#[cfg(test)] +mod test { + use super::{ConfigStore, OxlintOverrides}; + use crate::{config::LintConfig, AllowWarnDeny, LintPlugins, RuleEnum, RuleWithSeverity}; + + macro_rules! from_json { + ($json:tt) => { + serde_json::from_value(serde_json::json!($json)).unwrap() + }; + } + + #[allow(clippy::default_trait_access)] + fn no_explicit_any() -> RuleWithSeverity { + RuleWithSeverity::new(RuleEnum::NoExplicitAny(Default::default()), AllowWarnDeny::Warn) + } + + #[allow(clippy::default_trait_access)] + fn no_cycle() -> RuleWithSeverity { + RuleWithSeverity::new(RuleEnum::NoCycle(Default::default()), AllowWarnDeny::Warn) + } + + /// an empty ruleset is a no-op + #[test] + fn test_no_rules() { + let base_rules = vec![no_explicit_any()]; + let overrides: OxlintOverrides = from_json!([{ + "files": ["*.test.{ts,tsx}"], + "rules": {} + }]); + let store = ConfigStore::new(base_rules, LintConfig::default(), overrides).unwrap(); + + let rules_for_source_file = store.resolve("App.tsx".as_ref()); + let rules_for_test_file = store.resolve("App.test.tsx".as_ref()); + + assert_eq!(rules_for_source_file.rules.len(), 1); + assert_eq!(rules_for_test_file.rules.len(), 1); + assert_eq!( + rules_for_test_file.rules[0].rule.id(), + rules_for_source_file.rules[0].rule.id() + ); + } + + /// adding plugins but no rules is a no-op + #[test] + fn test_no_rules_and_new_plugins() { + let base_rules = vec![no_explicit_any()]; + let overrides: OxlintOverrides = from_json!([{ + "files": ["*.test.{ts,tsx}"], + "plugins": ["react", "typescript", "unicorn", "oxc", "jsx-a11y"], + "rules": {} + }]); + let store = ConfigStore::new(base_rules, LintConfig::default(), overrides).unwrap(); + + let rules_for_source_file = store.resolve("App.tsx".as_ref()); + let rules_for_test_file = store.resolve("App.test.tsx".as_ref()); + + assert_eq!(rules_for_source_file.rules.len(), 1); + assert_eq!(rules_for_test_file.rules.len(), 1); + assert_eq!( + rules_for_test_file.rules[0].rule.id(), + rules_for_source_file.rules[0].rule.id() + ); + } + + /// removing plugins strips rules from those plugins, even if no rules are + /// added/removed explicitly + #[test] + fn test_no_rules_and_remove_plugins() { + let base_rules = vec![no_cycle()]; + let overrides = from_json!([{ + "files": ["*.test.{ts,tsx}"], + "plugins": ["jest"], + "rules": {} + }]); + let config = LintConfig { + plugins: LintPlugins::default() | LintPlugins::IMPORT, + ..LintConfig::default() + }; + let store = ConfigStore::new(base_rules, config, overrides).unwrap(); + + assert_eq!(store.resolve("App.tsx".as_ref()).rules.len(), 1); + assert_eq!(store.resolve("App.test.tsx".as_ref()).rules.len(), 0); + } + + #[test] + fn test_remove_rule() { + let base_rules = vec![no_explicit_any()]; + let overrides: OxlintOverrides = from_json!([{ + "files": ["*.test.{ts,tsx}"], + "rules": { + "@typescript-eslint/no-explicit-any": "off" + } + }]); + + let store = ConfigStore::new(base_rules, LintConfig::default(), overrides).unwrap(); + assert_eq!(store.number_of_rules(), 1); + + let rules_for_source_file = store.resolve("App.tsx".as_ref()); + assert_eq!(rules_for_source_file.rules.len(), 1); + + assert!(store.resolve("App.test.tsx".as_ref()).rules.is_empty()); + assert!(store.resolve("App.test.ts".as_ref()).rules.is_empty()); + } + + #[test] + fn test_add_rule() { + let base_rules = vec![no_explicit_any()]; + let overrides = from_json!([{ + "files": ["src/**/*.{ts,tsx}"], + "rules": { + "no-unused-vars": "warn" + } + }]); + + let store = ConfigStore::new(base_rules, LintConfig::default(), overrides).unwrap(); + assert_eq!(store.number_of_rules(), 1); + + assert_eq!(store.resolve("App.tsx".as_ref()).rules.len(), 1); + assert_eq!(store.resolve("src/App.tsx".as_ref()).rules.len(), 2); + assert_eq!(store.resolve("src/App.ts".as_ref()).rules.len(), 2); + assert_eq!(store.resolve("src/foo/bar/baz/App.tsx".as_ref()).rules.len(), 2); + assert_eq!(store.resolve("src/foo/bar/baz/App.spec.tsx".as_ref()).rules.len(), 2); + } + + #[test] + fn test_change_rule_severity() { + let base_rules = vec![no_explicit_any()]; + let overrides = from_json!([{ + "files": ["src/**/*.{ts,tsx}"], + "rules": { + "no-explicit-any": "error" + } + }]); + + let store = ConfigStore::new(base_rules, LintConfig::default(), overrides).unwrap(); + assert_eq!(store.number_of_rules(), 1); + + let app = store.resolve("App.tsx".as_ref()).rules; + assert_eq!(app.len(), 1); + assert_eq!(app[0].severity, AllowWarnDeny::Warn); + + let src_app = store.resolve("src/App.tsx".as_ref()).rules; + assert_eq!(src_app.len(), 1); + assert_eq!(src_app[0].severity, AllowWarnDeny::Deny); + } +} diff --git a/crates/oxc_linter/src/config/mod.rs b/crates/oxc_linter/src/config/mod.rs index f0c87860e11b5a..cad1a00c72df1e 100644 --- a/crates/oxc_linter/src/config/mod.rs +++ b/crates/oxc_linter/src/config/mod.rs @@ -1,14 +1,19 @@ mod categories; mod env; +mod flat; mod globals; +mod overrides; mod oxlintrc; mod plugins; mod rules; mod settings; +pub(crate) use self::flat::ResolvedLinterState; pub use self::{ env::OxlintEnv, + flat::ConfigStore, globals::OxlintGlobals, + overrides::OxlintOverrides, oxlintrc::Oxlintrc, plugins::LintPlugins, rules::ESLintRule, @@ -16,7 +21,7 @@ pub use self::{ settings::{jsdoc::JSDocPluginSettings, OxlintSettings}, }; -#[derive(Debug, Default)] +#[derive(Debug, Default, Clone)] pub(crate) struct LintConfig { pub(crate) plugins: LintPlugins, pub(crate) settings: OxlintSettings, diff --git a/crates/oxc_linter/src/config/overrides.rs b/crates/oxc_linter/src/config/overrides.rs new file mode 100644 index 00000000000000..576466cd30faed --- /dev/null +++ b/crates/oxc_linter/src/config/overrides.rs @@ -0,0 +1,153 @@ +use std::{borrow::Cow, ops::Deref, path::Path}; + +use nonmax::NonMaxU32; +use schemars::{gen, schema::Schema, JsonSchema}; +use serde::{de, ser, Deserialize, Serialize}; + +use oxc_index::{Idx, IndexVec}; + +use crate::{config::OxlintRules, LintPlugins}; + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct OverrideId(NonMaxU32); + +impl Idx for OverrideId { + #[allow(clippy::cast_possible_truncation)] + fn from_usize(idx: usize) -> Self { + assert!(idx < u32::MAX as usize); + // SAFETY: We just checked `idx` is a legal value for `NonMaxU32` + Self(unsafe { NonMaxU32::new_unchecked(idx as u32) }) + } + + fn index(self) -> usize { + self.0.get() as usize + } +} + +// nominal wrapper required to add JsonSchema impl +#[derive(Debug, Default, Clone, Deserialize, Serialize)] +pub struct OxlintOverrides(IndexVec); + +impl Deref for OxlintOverrides { + type Target = IndexVec; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl OxlintOverrides { + #[inline] + pub fn empty() -> Self { + Self(IndexVec::new()) + } + + // must be explicitly defined to make serde happy + /// Returns `true` if the overrides list has no elements. + #[inline] + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + +impl JsonSchema for OxlintOverrides { + fn schema_name() -> String { + "OxlintOverrides".to_owned() + } + + fn schema_id() -> Cow<'static, str> { + Cow::Borrowed("OxlintOverrides") + } + + fn json_schema(gen: &mut gen::SchemaGenerator) -> Schema { + gen.subschema_for::>() + } +} + +#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)] +#[non_exhaustive] +pub struct OxlintOverride { + /// A list of glob patterns to override. + /// + /// ## Example + /// `[ "*.test.ts", "*.spec.ts" ]` + pub files: GlobSet, + + /// Optionally change what plugins are enabled for this override. When + /// omitted, the base config's plugins are used. + #[serde(default)] + pub plugins: Option, + + #[serde(default)] + pub rules: OxlintRules, +} + +/// A glob pattern. +/// +/// Thin wrapper around [`globset::GlobSet`] because that struct doesn't implement Serialize or schemars +/// traits. +#[derive(Clone, Debug, Default)] +pub struct GlobSet { + /// Raw patterns from the config. Inefficient, but required for [serialization](Serialize), + /// which in turn is required for `--print-config`. + raw: Vec, + globs: globset::GlobSet, +} + +impl GlobSet { + pub fn new, I: IntoIterator>( + patterns: I, + ) -> Result { + let patterns = patterns.into_iter(); + let size_hint = patterns.size_hint(); + + let mut builder = globset::GlobSetBuilder::new(); + let mut raw = Vec::with_capacity(size_hint.1.unwrap_or(size_hint.0)); + + for pattern in patterns { + let pattern = pattern.as_ref(); + let glob = globset::Glob::new(pattern)?; + builder.add(glob); + raw.push(pattern.to_string()); + } + + let globs = builder.build()?; + Ok(Self { raw, globs }) + } + + pub fn is_match>(&self, path: P) -> bool { + self.globs.is_match(path) + } +} + +impl ser::Serialize for GlobSet { + fn serialize(&self, serializer: S) -> Result + where + S: ser::Serializer, + { + self.raw.serialize(serializer) + } +} + +impl<'de> de::Deserialize<'de> for GlobSet { + fn deserialize(deserializer: D) -> Result + where + D: de::Deserializer<'de>, + { + let globs = Vec::::deserialize(deserializer)?; + Self::new(globs).map_err(de::Error::custom) + } +} + +impl JsonSchema for GlobSet { + fn schema_name() -> String { + Self::schema_id().into() + } + + fn schema_id() -> Cow<'static, str> { + Cow::Borrowed("GlobSet") + } + + fn json_schema(gen: &mut gen::SchemaGenerator) -> Schema { + gen.subschema_for::>() + } +} diff --git a/crates/oxc_linter/src/config/oxlintrc.rs b/crates/oxc_linter/src/config/oxlintrc.rs index 21121a50fc238a..a621eddf25af03 100644 --- a/crates/oxc_linter/src/config/oxlintrc.rs +++ b/crates/oxc_linter/src/config/oxlintrc.rs @@ -5,8 +5,8 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use super::{ - categories::OxlintCategories, env::OxlintEnv, globals::OxlintGlobals, plugins::LintPlugins, - rules::OxlintRules, settings::OxlintSettings, + categories::OxlintCategories, env::OxlintEnv, globals::OxlintGlobals, + overrides::OxlintOverrides, plugins::LintPlugins, rules::OxlintRules, settings::OxlintSettings, }; use crate::utils::read_to_string; @@ -30,7 +30,7 @@ use crate::utils::read_to_string; /// ```json /// { /// "$schema": "./node_modules/oxlint/configuration_schema.json", -/// "plugins": ["import", "unicorn"], +/// "plugins": ["import", "typescript", "unicorn"], /// "env": { /// "browser": true /// }, @@ -42,7 +42,15 @@ use crate::utils::read_to_string; /// "rules": { /// "eqeqeq": "warn", /// "import/no-cycle": "error" -/// } +/// }, +/// "overrides": [ +/// { +/// "files": ["*.test.ts", "*.spec.ts"], +/// "rules": { +/// "@typescript-eslint/no-explicit-any": "off" +/// } +/// } +/// ] /// } /// ``` #[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)] @@ -74,6 +82,9 @@ pub struct Oxlintrc { pub env: OxlintEnv, /// Enabled or disabled specific global variables. pub globals: OxlintGlobals, + /// Add, remove, or otherwise reconfigure rules for specific files or groups of files. + #[serde(skip_serializing_if = "OxlintOverrides::is_empty")] + pub overrides: OxlintOverrides, } impl Oxlintrc { diff --git a/crates/oxc_linter/src/config/plugins.rs b/crates/oxc_linter/src/config/plugins.rs index f06d9e4e86a3f4..9099a1e55da32f 100644 --- a/crates/oxc_linter/src/config/plugins.rs +++ b/crates/oxc_linter/src/config/plugins.rs @@ -179,9 +179,26 @@ impl<'de> Deserialize<'de> for LintPlugins { A: de::SeqAccess<'de>, { let mut plugins = LintPlugins::default(); - while let Some(plugin) = seq.next_element::<&str>()? { - plugins |= plugin.into(); + loop { + // serde_json::from_str will provide an &str, while + // serde_json::from_value provides a String. The former is + // used in almost all cases, but the latter is more + // convenient for test cases. + match seq.next_element::<&str>() { + Ok(Some(next)) => { + plugins |= next.into(); + } + Ok(None) => break, + Err(_) => { + if let Some(next) = seq.next_element::()? { + plugins |= next.as_str().into(); + } else { + break; + } + } + }; } + Ok(plugins) } } diff --git a/crates/oxc_linter/src/context/host.rs b/crates/oxc_linter/src/context/host.rs index c164de28bb6113..3fd3be0f0f16a4 100644 --- a/crates/oxc_linter/src/context/host.rs +++ b/crates/oxc_linter/src/context/host.rs @@ -135,6 +135,11 @@ impl<'a> ContextHost<'a> { self.semantic.source_type() } + #[inline] + pub fn plugins(&self) -> LintPlugins { + self.plugins + } + /// Add a diagnostic message to the end of the list of diagnostics. Can be used /// by any rule to report issues. #[inline] diff --git a/crates/oxc_linter/src/lib.rs b/crates/oxc_linter/src/lib.rs index 03cf7542f6b7f3..306806903b5958 100644 --- a/crates/oxc_linter/src/lib.rs +++ b/crates/oxc_linter/src/lib.rs @@ -21,9 +21,10 @@ mod utils; pub mod loader; pub mod table; +use crate::config::ResolvedLinterState; use std::{io::Write, path::Path, rc::Rc, sync::Arc}; -use config::LintConfig; +use config::{ConfigStore, LintConfig}; use context::ContextHost; use options::LintOptions; use oxc_semantic::{AstNode, Semantic}; @@ -57,9 +58,10 @@ fn size_asserts() { #[derive(Debug)] pub struct Linter { - rules: Vec, + // rules: Vec, options: LintOptions, - config: Arc, + // config: Arc, + config: ConfigStore, } impl Default for Linter { @@ -69,18 +71,14 @@ impl Default for Linter { } impl Linter { - pub(crate) fn new( - rules: Vec, - options: LintOptions, - config: LintConfig, - ) -> Self { - Self { rules, options, config: Arc::new(config) } + pub(crate) fn new(options: LintOptions, config: ConfigStore) -> Self { + Self { options, config } } #[cfg(test)] #[must_use] pub fn with_rules(mut self, rules: Vec) -> Self { - self.rules = rules; + self.config.set_rules(rules); self } @@ -105,20 +103,19 @@ impl Linter { } pub fn number_of_rules(&self) -> usize { - self.rules.len() + self.config.number_of_rules() } - #[cfg(test)] - pub(crate) fn rules(&self) -> &Vec { - &self.rules + pub(crate) fn rules(&self) -> &Arc<[RuleWithSeverity]> { + self.config.rules() } pub fn run<'a>(&self, path: &Path, semantic: Rc>) -> Vec> { - let ctx_host = - Rc::new(ContextHost::new(path, semantic, self.options, Arc::clone(&self.config))); + // Get config + rules for this file. Takes base rules and applies glob-based overrides. + let ResolvedLinterState { rules, config } = self.config.resolve(path); + let ctx_host = Rc::new(ContextHost::new(path, semantic, self.options, config)); - let rules = self - .rules + let rules = rules .iter() .filter(|rule| rule.should_run(&ctx_host)) .map(|rule| (rule, Rc::clone(&ctx_host).spawn(rule))); @@ -126,7 +123,7 @@ impl Linter { let semantic = ctx_host.semantic(); let should_run_on_jest_node = - self.config.plugins.has_test() && ctx_host.frameworks().is_test(); + ctx_host.plugins().has_test() && ctx_host.frameworks().is_test(); // IMPORTANT: We have two branches here for performance reasons: // diff --git a/crates/oxc_linter/src/snapshots/schema_json.snap b/crates/oxc_linter/src/snapshots/schema_json.snap index ebb339ce0f3c42..b5a33c4db5ca5d 100644 --- a/crates/oxc_linter/src/snapshots/schema_json.snap +++ b/crates/oxc_linter/src/snapshots/schema_json.snap @@ -5,7 +5,7 @@ expression: json { "$schema": "http://json-schema.org/draft-07/schema#", "title": "Oxlintrc", - "description": "Oxlint Configuration File\n\nThis configuration is aligned with ESLint v8's configuration schema (`eslintrc.json`).\n\nUsage: `oxlint -c oxlintrc.json --import-plugin`\n\n::: danger NOTE\n\nOnly the `.json` format is supported. You can use comments in configuration files.\n\n:::\n\nExample\n\n`.oxlintrc.json`\n\n```json { \"$schema\": \"./node_modules/oxlint/configuration_schema.json\", \"plugins\": [\"import\", \"unicorn\"], \"env\": { \"browser\": true }, \"globals\": { \"foo\": \"readonly\" }, \"settings\": { }, \"rules\": { \"eqeqeq\": \"warn\", \"import/no-cycle\": \"error\" } } ```", + "description": "Oxlint Configuration File\n\nThis configuration is aligned with ESLint v8's configuration schema (`eslintrc.json`).\n\nUsage: `oxlint -c oxlintrc.json --import-plugin`\n\n::: danger NOTE\n\nOnly the `.json` format is supported. You can use comments in configuration files.\n\n:::\n\nExample\n\n`.oxlintrc.json`\n\n```json { \"$schema\": \"./node_modules/oxlint/configuration_schema.json\", \"plugins\": [\"import\", \"typescript\", \"unicorn\"], \"env\": { \"browser\": true }, \"globals\": { \"foo\": \"readonly\" }, \"settings\": { }, \"rules\": { \"eqeqeq\": \"warn\", \"import/no-cycle\": \"error\" }, \"overrides\": [ { \"files\": [\"*.test.ts\", \"*.spec.ts\"], \"rules\": { \"@typescript-eslint/no-explicit-any\": \"off\" } } ] } ```", "type": "object", "properties": { "categories": { @@ -36,6 +36,14 @@ expression: json } ] }, + "overrides": { + "description": "Add, remove, or otherwise reconfigure rules for specific files or groups of files.", + "allOf": [ + { + "$ref": "#/definitions/OxlintOverrides" + } + ] + }, "plugins": { "default": [ "react", @@ -170,6 +178,12 @@ expression: json "$ref": "#/definitions/DummyRule" } }, + "GlobSet": { + "type": "array", + "items": { + "type": "string" + } + }, "GlobalValue": { "type": "string", "enum": [ @@ -322,6 +336,48 @@ expression: json "$ref": "#/definitions/GlobalValue" } }, + "OxlintOverride": { + "type": "object", + "required": [ + "files" + ], + "properties": { + "files": { + "description": "A list of glob patterns to override.\n\n## Example `[ \"*.test.ts\", \"*.spec.ts\" ]`", + "allOf": [ + { + "$ref": "#/definitions/GlobSet" + } + ] + }, + "plugins": { + "description": "Optionally change what plugins are enabled for this override. When omitted, the base config's plugins are used.", + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/LintPlugins" + }, + { + "type": "null" + } + ] + }, + "rules": { + "default": {}, + "allOf": [ + { + "$ref": "#/definitions/OxlintRules" + } + ] + } + } + }, + "OxlintOverrides": { + "type": "array", + "items": { + "$ref": "#/definitions/OxlintOverride" + } + }, "OxlintRules": { "$ref": "#/definitions/DummyRuleMap" }, diff --git a/crates/oxc_linter/src/table.rs b/crates/oxc_linter/src/table.rs index 7fe893f733276a..fa5f55da2c586c 100644 --- a/crates/oxc_linter/src/table.rs +++ b/crates/oxc_linter/src/table.rs @@ -34,11 +34,8 @@ impl Default for RuleTable { impl RuleTable { pub fn new() -> Self { - let default_rules = Linter::default() - .rules - .into_iter() - .map(|rule| rule.name()) - .collect::>(); + let default_rules = + Linter::default().rules().iter().map(|rule| rule.name()).collect::>(); let mut rows = RULES .iter() diff --git a/npm/oxlint/configuration_schema.json b/npm/oxlint/configuration_schema.json index 44b7b00ec91a12..ab64f15e56b3be 100644 --- a/npm/oxlint/configuration_schema.json +++ b/npm/oxlint/configuration_schema.json @@ -1,7 +1,7 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "title": "Oxlintrc", - "description": "Oxlint Configuration File\n\nThis configuration is aligned with ESLint v8's configuration schema (`eslintrc.json`).\n\nUsage: `oxlint -c oxlintrc.json --import-plugin`\n\n::: danger NOTE\n\nOnly the `.json` format is supported. You can use comments in configuration files.\n\n:::\n\nExample\n\n`.oxlintrc.json`\n\n```json { \"$schema\": \"./node_modules/oxlint/configuration_schema.json\", \"plugins\": [\"import\", \"unicorn\"], \"env\": { \"browser\": true }, \"globals\": { \"foo\": \"readonly\" }, \"settings\": { }, \"rules\": { \"eqeqeq\": \"warn\", \"import/no-cycle\": \"error\" } } ```", + "description": "Oxlint Configuration File\n\nThis configuration is aligned with ESLint v8's configuration schema (`eslintrc.json`).\n\nUsage: `oxlint -c oxlintrc.json --import-plugin`\n\n::: danger NOTE\n\nOnly the `.json` format is supported. You can use comments in configuration files.\n\n:::\n\nExample\n\n`.oxlintrc.json`\n\n```json { \"$schema\": \"./node_modules/oxlint/configuration_schema.json\", \"plugins\": [\"import\", \"typescript\", \"unicorn\"], \"env\": { \"browser\": true }, \"globals\": { \"foo\": \"readonly\" }, \"settings\": { }, \"rules\": { \"eqeqeq\": \"warn\", \"import/no-cycle\": \"error\" }, \"overrides\": [ { \"files\": [\"*.test.ts\", \"*.spec.ts\"], \"rules\": { \"@typescript-eslint/no-explicit-any\": \"off\" } } ] } ```", "type": "object", "properties": { "categories": { @@ -32,6 +32,14 @@ } ] }, + "overrides": { + "description": "Add, remove, or otherwise reconfigure rules for specific files or groups of files.", + "allOf": [ + { + "$ref": "#/definitions/OxlintOverrides" + } + ] + }, "plugins": { "default": [ "react", @@ -166,6 +174,12 @@ "$ref": "#/definitions/DummyRule" } }, + "GlobSet": { + "type": "array", + "items": { + "type": "string" + } + }, "GlobalValue": { "type": "string", "enum": [ @@ -318,6 +332,48 @@ "$ref": "#/definitions/GlobalValue" } }, + "OxlintOverride": { + "type": "object", + "required": [ + "files" + ], + "properties": { + "files": { + "description": "A list of glob patterns to override.\n\n## Example `[ \"*.test.ts\", \"*.spec.ts\" ]`", + "allOf": [ + { + "$ref": "#/definitions/GlobSet" + } + ] + }, + "plugins": { + "description": "Optionally change what plugins are enabled for this override. When omitted, the base config's plugins are used.", + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/LintPlugins" + }, + { + "type": "null" + } + ] + }, + "rules": { + "default": {}, + "allOf": [ + { + "$ref": "#/definitions/OxlintRules" + } + ] + } + } + }, + "OxlintOverrides": { + "type": "array", + "items": { + "$ref": "#/definitions/OxlintOverride" + } + }, "OxlintRules": { "$ref": "#/definitions/DummyRuleMap" }, diff --git a/tasks/website/src/linter/snapshots/schema_markdown.snap b/tasks/website/src/linter/snapshots/schema_markdown.snap index 9dba7f7d282602..0e832d15d2757a 100644 --- a/tasks/website/src/linter/snapshots/schema_markdown.snap +++ b/tasks/website/src/linter/snapshots/schema_markdown.snap @@ -23,6 +23,7 @@ Example "$schema": "./node_modules/oxlint/configuration_schema.json", "plugins": [ "import", + "typescript", "unicorn" ], "env": { @@ -35,7 +36,18 @@ Example "rules": { "eqeqeq": "warn", "import/no-cycle": "error" - } + }, + "overrides": [ + { + "files": [ + "*.test.ts", + "*.spec.ts" + ], + "rules": { + "@typescript-eslint/no-explicit-any": "off" + } + } + ] } ``` @@ -148,6 +160,38 @@ Globals can be disabled by setting their value to `"off"`. For example, in an en You may also use `"readable"` or `false` to represent `"readonly"`, and `"writeable"` or `true` to represent `"writable"`. +## overrides + +type: `array` + + + + + +### overrides[n] + +type: `object` + + + + + +#### overrides[n].files + +type: `string[]` + + + + + +#### overrides[n].rules + +type: `object` + + +See [Oxlint Rules](https://oxc.rs/docs/guide/usage/linter/rules.html) + + ## plugins type: `string[]`