Skip to content

Commit

Permalink
refactor(linter): add LintFilter
Browse files Browse the repository at this point in the history
Re-creation of #5329
  • Loading branch information
DonIsaac committed Sep 11, 2024
1 parent b9bf544 commit 5213b84
Show file tree
Hide file tree
Showing 8 changed files with 453 additions and 81 deletions.
46 changes: 45 additions & 1 deletion apps/oxlint/src/lint/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ use std::{env, io::BufWriter, time::Instant};
use ignore::gitignore::Gitignore;
use oxc_diagnostics::{DiagnosticService, GraphicalReportHandler};
use oxc_linter::{
partial_loader::LINT_PARTIAL_LOADER_EXT, LintService, LintServiceOptions, Linter, OxlintOptions,
partial_loader::LINT_PARTIAL_LOADER_EXT, AllowWarnDeny, InvalidFilterKind, LintFilter,
LintService, LintServiceOptions, Linter, OxlintOptions,
};
use oxc_span::VALID_EXTENSIONS;

Expand Down Expand Up @@ -80,6 +81,11 @@ impl Runner for LintRunner {
}
}

let filter = match Self::get_filters(filter) {
Ok(filter) => filter,
Err(e) => return e,
};

let extensions = VALID_EXTENSIONS
.iter()
.chain(LINT_PARTIAL_LOADER_EXT.iter())
Expand Down Expand Up @@ -184,6 +190,44 @@ impl LintRunner {
}
diagnostic_service
}

// moved into a separate function for readability, but it's only ever used
// in one place.
#[inline]
fn get_filters(
filters_arg: Vec<(AllowWarnDeny, String)>,
) -> Result<Vec<LintFilter>, CliRunResult> {
let mut filters = Vec::with_capacity(filters_arg.len());

for (severity, filter_arg) in filters_arg {
match LintFilter::new(severity, filter_arg) {
Ok(filter) => {
filters.push(filter);
}
Err(InvalidFilterKind::Empty) => {
return Err(CliRunResult::InvalidOptions {
message: format!("Cannot {severity} an empty filter."),
});
}
Err(InvalidFilterKind::PluginMissing(filter)) => {
return Err(CliRunResult::InvalidOptions {
message: format!(
"Failed to {severity} filter {filter}: Plugin name is missing. Expected <plugin>/<rule>"
),
});
}
Err(InvalidFilterKind::RuleMissing(filter)) => {
return Err(CliRunResult::InvalidOptions {
message: format!(
"Failed to {severity} filter {filter}: Rule name is missing. Expected <plugin>/<rule>"
),
});
}
}
}

Ok(filters)
}
}

#[cfg(all(test, not(target_os = "windows")))]
Expand Down
2 changes: 1 addition & 1 deletion crates/oxc_linter/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ pub use crate::{
context::LintContext,
fixer::FixKind,
frameworks::FrameworkFlags,
options::{AllowWarnDeny, OxlintOptions},
options::{AllowWarnDeny, InvalidFilterKind, LintFilter, OxlintOptions},
rule::{RuleCategory, RuleFixMeta, RuleMeta, RuleWithSeverity},
service::{LintService, LintServiceOptions},
};
Expand Down
9 changes: 8 additions & 1 deletion crates/oxc_linter/src/options/allow_warn_deny.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use std::convert::From;
use std::{convert::From, fmt};

use oxc_diagnostics::{OxcDiagnostic, Severity};
use schemars::{schema::SchemaObject, JsonSchema};
Expand Down Expand Up @@ -29,6 +29,13 @@ impl AllowWarnDeny {
}
}

impl fmt::Display for AllowWarnDeny {
#[inline]
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.as_str().fmt(f)
}
}

impl TryFrom<&str> for AllowWarnDeny {
type Error = OxcDiagnostic;

Expand Down
280 changes: 280 additions & 0 deletions crates/oxc_linter/src/options/filter.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,280 @@
use crate::RuleCategory;

use super::{plugins::LintPlugins, AllowWarnDeny};
use std::{borrow::Cow, fmt};

/// Enables, disables, and sets the severity of lint rules.
///
/// Filters come in 3 forms:
/// 1. Filter by rule name and/or plugin: `no-const-assign`, `eslint/no-const-assign`
/// 2. Filter an entire category: `correctness`
/// 3. Some unknow filter. This is a fallback used when parsing a filter string,
/// and is interpreted uniquely by the linter.
#[derive(Debug, Clone)]
#[cfg_attr(test, derive(PartialEq))]
pub struct LintFilter {
severity: AllowWarnDeny,
kind: LintFilterKind,
}

impl LintFilter {
/// # Errors
///
/// If `kind` is an empty string, or is a `<plugin>/<rule>` filter but is missing either the
/// plugin or the rule.
pub fn new<F: TryInto<LintFilterKind>>(
severity: AllowWarnDeny,
kind: F,
) -> Result<Self, <F as TryInto<LintFilterKind>>::Error> {
Ok(Self { severity, kind: kind.try_into()? })
}

#[must_use]
pub fn allow<F: Into<LintFilterKind>>(kind: F) -> Self {
Self { severity: AllowWarnDeny::Allow, kind: kind.into() }
}

#[must_use]
pub fn warn<F: Into<LintFilterKind>>(kind: F) -> Self {
Self { severity: AllowWarnDeny::Warn, kind: kind.into() }
}

#[must_use]
pub fn deny<F: Into<LintFilterKind>>(kind: F) -> Self {
Self { severity: AllowWarnDeny::Deny, kind: kind.into() }
}

#[inline]
pub fn severity(&self) -> AllowWarnDeny {
self.severity
}

#[inline]
pub fn kind(&self) -> &LintFilterKind {
&self.kind
}
}

impl Default for LintFilter {
fn default() -> Self {
Self {
severity: AllowWarnDeny::Warn,
kind: LintFilterKind::Category(RuleCategory::Correctness),
}
}
}

impl From<LintFilter> for (AllowWarnDeny, LintFilterKind) {
fn from(val: LintFilter) -> Self {
(val.severity, val.kind)
}
}

impl<'a> From<&'a LintFilter> for (AllowWarnDeny, &'a LintFilterKind) {
fn from(val: &'a LintFilter) -> Self {
(val.severity, &val.kind)
}
}

#[derive(Debug, Clone)]
#[cfg_attr(test, derive(PartialEq))]
pub enum LintFilterKind {
Generic(Cow<'static, str>),
/// e.g. `no-const-assign` or `eslint/no-const-assign`
Rule(LintPlugins, Cow<'static, str>),
/// e.g. `correctness`
Category(RuleCategory),
// TODO: plugin + category? e.g `-A react:correctness`
}

impl LintFilterKind {
/// # Errors
///
/// If `filter` is an empty string, or is a `<plugin>/<rule>` filter but is missing either the
/// plugin or the rule.
pub fn parse(filter: Cow<'static, str>) -> Result<Self, InvalidFilterKind> {
if filter.is_empty() {
return Err(InvalidFilterKind::Empty);
}

if filter.contains('/') {
// this is an unfortunate amount of code duplication, but it needs to be done for
// `filter` to live long enough to avoid a String allocation for &'static str
let (plugin, rule) = match filter {
Cow::Borrowed(filter) => {
let mut parts = filter.splitn(2, '/');

let plugin = parts
.next()
.ok_or(InvalidFilterKind::PluginMissing(Cow::Borrowed(filter)))?;
if plugin.is_empty() {
return Err(InvalidFilterKind::PluginMissing(Cow::Borrowed(filter)));
}

let rule = parts
.next()
.ok_or(InvalidFilterKind::RuleMissing(Cow::Borrowed(filter)))?;
if rule.is_empty() {
return Err(InvalidFilterKind::RuleMissing(Cow::Borrowed(filter)));
}

(LintPlugins::from(plugin), Cow::Borrowed(rule))
}
Cow::Owned(filter) => {
let mut parts = filter.splitn(2, '/');

let plugin = parts
.next()
.ok_or_else(|| InvalidFilterKind::PluginMissing(filter.clone().into()))?;
if plugin.is_empty() {
return Err(InvalidFilterKind::PluginMissing(filter.into()));
}

let rule = parts
.next()
.ok_or_else(|| InvalidFilterKind::RuleMissing(filter.clone().into()))?;
if rule.is_empty() {
return Err(InvalidFilterKind::RuleMissing(filter.into()));
}

(LintPlugins::from(plugin), Cow::Owned(rule.to_string()))
}
};
Ok(LintFilterKind::Rule(plugin, rule))
} else {
match RuleCategory::try_from(filter.as_ref()) {
Ok(category) => Ok(LintFilterKind::Category(category)),
Err(()) => Ok(LintFilterKind::Generic(filter)),
}
}
}
}

impl TryFrom<String> for LintFilterKind {
type Error = InvalidFilterKind;

#[inline]
fn try_from(filter: String) -> Result<Self, Self::Error> {
Self::parse(Cow::Owned(filter))
}
}

impl TryFrom<&'static str> for LintFilterKind {
type Error = InvalidFilterKind;

#[inline]
fn try_from(filter: &'static str) -> Result<Self, Self::Error> {
Self::parse(Cow::Borrowed(filter))
}
}

impl TryFrom<Cow<'static, str>> for LintFilterKind {
type Error = InvalidFilterKind;

#[inline]
fn try_from(filter: Cow<'static, str>) -> Result<Self, Self::Error> {
Self::parse(filter)
}
}

impl From<RuleCategory> for LintFilterKind {
#[inline]
fn from(category: RuleCategory) -> Self {
LintFilterKind::Category(category)
}
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InvalidFilterKind {
Empty,
PluginMissing(Cow<'static, str>),
RuleMissing(Cow<'static, str>),
}

impl fmt::Display for InvalidFilterKind {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
Self::Empty => "Filter cannot be empty.".fmt(f),
Self::PluginMissing(filter) => {
write!(
f,
"Filter '{filter}' must match <plugin>/<rule> but is missing a plugin name."
)
}
Self::RuleMissing(filter) => {
write!(
f,
"Filter '{filter}' must match <plugin>/<rule> but is missing a rule name."
)
}
}
}
}

impl std::error::Error for InvalidFilterKind {}

#[cfg(test)]
mod test {
use super::*;

#[test]
fn test_from_category() {
let correctness: LintFilter = LintFilter::new(AllowWarnDeny::Warn, "correctness").unwrap();
assert_eq!(correctness.severity(), AllowWarnDeny::Warn);
assert!(
matches!(correctness.kind(), LintFilterKind::Category(RuleCategory::Correctness)),
"{:?}",
correctness.kind()
);
}

#[test]
fn test_eslint_deny() {
let filter = LintFilter::deny(LintFilterKind::try_from("no-const-assign").unwrap());
assert_eq!(filter.severity(), AllowWarnDeny::Deny);
assert_eq!(filter.kind(), &LintFilterKind::Generic("no-const-assign".into()));

let filter = LintFilter::deny(LintFilterKind::try_from("eslint/no-const-assign").unwrap());
assert_eq!(filter.severity(), AllowWarnDeny::Deny);
assert_eq!(
filter.kind(),
&LintFilterKind::Rule(LintPlugins::from("eslint"), "no-const-assign".into())
);
assert!(matches!(filter.kind(), LintFilterKind::Rule(_, _)));
}

#[test]
fn test_parse() {
let test_cases: Vec<(&'static str, LintFilterKind)> = vec![
("import/namespace", LintFilterKind::Rule(LintPlugins::IMPORT, "namespace".into())),
(
"react-hooks/exhaustive-deps",
LintFilterKind::Rule(LintPlugins::REACT, "exhaustive-deps".into()),
),
// categories
("nursery", LintFilterKind::Category("nursery".try_into().unwrap())),
("perf", LintFilterKind::Category("perf".try_into().unwrap())),
// misc
("not-a-valid-filter", LintFilterKind::Generic("not-a-valid-filter".into())),
];

for (input, expected) in test_cases {
let actual = LintFilterKind::try_from(input).unwrap();
assert_eq!(actual, expected, "input: {input}");
}
}

#[test]
fn test_parse_invalid() {
let test_cases = vec!["/rules-of-hooks", "import/", "", "/", "//"];

for input in test_cases {
let actual = LintFilterKind::parse(Cow::Borrowed(input));
assert!(
actual.is_err(),
"input '{input}' produced filter '{:?}' but it should have errored",
actual.unwrap()
);
}
}
}
Loading

0 comments on commit 5213b84

Please sign in to comment.