Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(linter) Parse eslint configuration #1146

Merged
merged 1 commit into from
Dec 14, 2023
Merged
Show file tree
Hide file tree
Changes from all 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
6 changes: 6 additions & 0 deletions crates/oxc_cli/src/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,12 @@ pub struct LintOptions {
#[bpaf(external)]
pub codeowner_options: CodeownerOptions,

/// ESLint configuration file (experimental)
///
/// * only `.json` extension is supported
#[bpaf(long("config"), short('c'), argument("PATH"))]
pub config: Option<PathBuf>,

/// Single file, single path or list of paths
#[bpaf(positional("PATH"), many)]
pub paths: Vec<PathBuf>,
Expand Down
60 changes: 54 additions & 6 deletions crates/oxc_cli/src/lint/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use std::{env, io::BufWriter, path::Path, vec::Vec};

use oxc_diagnostics::DiagnosticService;
use oxc_diagnostics::{DiagnosticService, GraphicalReportHandler};
use oxc_linter::{LintOptions, LintService, Linter};

use crate::{
Expand All @@ -12,6 +12,38 @@ pub struct LintRunner {
options: CliLintOptions,
}

impl LintRunner {
fn check_options(&self) -> CliRunResult {
let CliLintOptions { filter, misc_options, enable_plugins, config, .. } = &self.options;

if misc_options.rules {
let mut stdout = BufWriter::new(std::io::stdout());
Linter::print_rules(&mut stdout);
return CliRunResult::None;
}

// disallow passing config path and filter at the same time
if config.is_some() && !filter.is_empty() {
return CliRunResult::InvalidOptions {
message: "`--config` and rule filters cannot currently be used together. \nPlease use `--config` to specify a config file, or filter to specify rules."
.to_string(),
};
}

if enable_plugins.import_plugin
|| enable_plugins.jest_plugin
|| enable_plugins.jsx_a11y_plugin
{
return CliRunResult::InvalidOptions {
message: "`--config` and plugin options cannot currently be used together. \nPlease use `--config` to specify a config file, or plugin options to enable plugins."
.to_string(),
};
}

CliRunResult::None
}
}

impl Runner for LintRunner {
type Options = CliLintOptions;

Expand All @@ -20,10 +52,10 @@ impl Runner for LintRunner {
}

fn run(self) -> CliRunResult {
if self.options.misc_options.rules {
let mut stdout = BufWriter::new(std::io::stdout());
Linter::print_rules(&mut stdout);
return CliRunResult::None;
let result = self.check_options();

if !matches!(result, CliRunResult::None) {
return result;
}

let CliLintOptions {
Expand All @@ -35,6 +67,7 @@ impl Runner for LintRunner {
misc_options,
codeowner_options,
enable_plugins,
config,
} = self.options;

let mut paths = paths;
Expand Down Expand Up @@ -63,12 +96,27 @@ impl Runner for LintRunner {
let cwd = std::env::current_dir().unwrap().into_boxed_path();
let lint_options = LintOptions::default()
.with_filter(filter)
.with_config_path(config)
.with_fix(fix_options.fix)
.with_timing(misc_options.timing)
.with_import_plugin(enable_plugins.import_plugin)
.with_jest_plugin(enable_plugins.jest_plugin)
.with_jsx_a11y_plugin(enable_plugins.jsx_a11y_plugin);
let lint_service = LintService::new(cwd, &paths, lint_options);

let linter = match Linter::from_options(lint_options) {
Ok(lint_service) => lint_service,
Err(diagnostic) => {
let handler = GraphicalReportHandler::new();
let mut err = String::new();
handler.render_report(&mut err, diagnostic.as_ref()).unwrap();
eprintln!("{err}");
return CliRunResult::InvalidOptions {
message: "Failed to parse configuration file.".to_string(),
};
}
};

let lint_service = LintService::new(cwd, &paths, linter);

let diagnostic_service = DiagnosticService::default()
.with_quiet(warning_options.quiet)
Expand Down
2 changes: 1 addition & 1 deletion crates/oxc_diagnostics/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,6 @@ use thiserror::Error;
pub struct MinifiedFileError(pub PathBuf);

#[derive(Debug, Error, Diagnostic)]
#[error("Failed to open file")]
#[error("Failed to open file {0:?} with error \"{1}\"")]
#[diagnostic(help("Failed to open file {0:?} with error \"{1}\""))]
pub struct FailedToOpenFileError(pub PathBuf, pub std::io::Error);
41 changes: 41 additions & 0 deletions crates/oxc_linter/src/config/errors.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
use oxc_diagnostics::{
miette::{self, Diagnostic},
thiserror::Error,
Report,
};
use std::path::PathBuf;

#[derive(Debug, Error, Diagnostic)]
#[error("Failed to parse config {0:?} with error {1:?}")]
#[diagnostic()]
pub struct FailedToParseConfigJsonError(pub PathBuf, pub String);

#[derive(Debug, Error, Diagnostic)]
#[error("Failed to parse eslint config")]
#[diagnostic()]
pub struct FailedToParseConfigError(#[related] pub Vec<Report>);

#[derive(Debug, Error, Diagnostic)]
#[error("Failed to parse config at {0:?} with error {1:?}")]
#[diagnostic()]
pub struct FailedToParseConfigPropertyError(pub &'static str, pub &'static str);

#[derive(Debug, Error, Diagnostic)]
#[error("Failed to rule value {0:?} with error {1:?}")]
#[diagnostic()]
pub struct FailedToParseRuleValueError(pub String, pub &'static str);

#[derive(Debug, Error, Diagnostic)]
#[error(r#"Failed to parse rule severity, expected one of "allow", "off", "deny", "error" or "warn", but got {0:?}"#)]
#[diagnostic()]
pub struct FailedToParseAllowWarnDenyFromStringError(pub String);

#[derive(Debug, Error, Diagnostic)]
#[error(r#"Failed to parse rule severity, expected one of `0`, `1` or `2`, but got {0:?}"#)]
#[diagnostic()]
pub struct FailedToParseAllowWarnDenyFromNumberError(pub String);

#[derive(Debug, Error, Diagnostic)]
#[error(r#"Failed to parse rule severity, expected a string or a number, but got {0:?}"#)]
#[diagnostic()]
pub struct FailedToParseAllowWarnDenyFromJsonValueError(pub String);
200 changes: 200 additions & 0 deletions crates/oxc_linter/src/config/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
use std::path::PathBuf;

pub mod errors;
use oxc_diagnostics::{Error, FailedToOpenFileError, Report};
use phf::{phf_map, Map};
use serde_json::Value;

use crate::{
rules::{RuleEnum, RULES},
AllowWarnDeny,
};

use self::errors::{
FailedToParseConfigError, FailedToParseConfigJsonError, FailedToParseConfigPropertyError,
FailedToParseRuleValueError,
};

pub struct ESLintConfig {
rules: std::vec::Vec<RuleEnum>,
}

impl ESLintConfig {
pub fn new(path: &PathBuf) -> Result<Self, Report> {
let file = match std::fs::read_to_string(path) {
Ok(file) => file,
Err(e) => {
return Err(FailedToParseConfigError(vec![Error::new(FailedToOpenFileError(
path.clone(),
e,
))])
.into());
}
};

let file = match serde_json::from_str::<serde_json::Value>(&file) {
Ok(file) => file,
Err(e) => {
return Err(FailedToParseConfigError(vec![Error::new(
FailedToParseConfigJsonError(path.clone(), e.to_string()),
)])
.into());
}
};

let extends_hm = match parse_extends(&file) {
Ok(Some(extends_hm)) => {
extends_hm.into_iter().collect::<std::collections::HashSet<_>>()
}
Ok(None) => std::collections::HashSet::new(),
Err(e) => {
return Err(FailedToParseConfigError(vec![Error::new(
FailedToParseConfigJsonError(path.clone(), e.to_string()),
)])
.into());
}
};
let roles_hm = match parse_rules(&file) {
Ok(roles_hm) => roles_hm
.into_iter()
.map(|(plugin_name, rule_name, allow_warn_deny, config)| {
((plugin_name, rule_name), (allow_warn_deny, config))
})
.collect::<std::collections::HashMap<_, _>>(),
Err(e) => {
return Err(e);
}
};

// `extends` provides the defaults
// `rules` provides the overrides
let rules = RULES.clone().into_iter().filter_map(|rule| {
// Check if the extends set is empty or contains the plugin name
let in_extends = extends_hm.contains(rule.plugin_name());

// Check if there's a custom rule that explicitly handles this rule
let (is_explicitly_handled, policy, config) =
if let Some((policy, config)) = roles_hm.get(&(rule.plugin_name(), rule.name())) {
// Return true for handling, and also whether it's enabled or not
(true, *policy, config)
} else {
// Not explicitly handled
(false, AllowWarnDeny::Allow, &None)
};

// The rule is included if it's in the extends set and not explicitly disabled,
// or if it's explicitly enabled
if (in_extends && !is_explicitly_handled) || policy.is_enabled() {
Some(rule.read_json(config.cloned()))
} else {
None
}
});

Ok(Self { rules: rules.collect::<Vec<_>>() })
}

pub fn into_rules(mut self) -> Vec<RuleEnum> {
self.rules.sort_unstable_by_key(RuleEnum::name);
self.rules
}
}

fn parse_extends(root_json: &Value) -> Result<Option<Vec<&'static str>>, Report> {
let Some(extends) = root_json.get("extends") else {
return Ok(None);
};

let extends_obj = match extends {
Value::Array(v) => v,
_ => {
return Err(FailedToParseConfigPropertyError("extends", "Expected an array.").into());
}
};

let extends_rule_groups = extends_obj
.iter()
.filter_map(|v| {
let v = match v {
Value::String(s) => s,
_ => return None,
};

if let Some(m) = EXTENDS_MAP.get(v.as_str()) {
return Some(*m);
}

None
})
.collect::<Vec<_>>();

Ok(Some(extends_rule_groups))
}

#[allow(clippy::type_complexity)]
fn parse_rules(
root_json: &Value,
) -> Result<Vec<(&str, &str, AllowWarnDeny, Option<&Value>)>, Error> {
let Value::Object(rules_object) = root_json else { return Ok(vec![]) };

let Some(Value::Object(rules_object)) = rules_object.get("rules") else { return Ok(vec![]) };

rules_object
.iter()
.map(|(key, value)| {
let (plugin_name, name) = parse_rule_name(key);

let (rule_severity, rule_config) = resolve_rule_value(value)?;

Ok((plugin_name, name, rule_severity, rule_config))
})
.collect::<Result<Vec<_>, Error>>()
}

pub const EXTENDS_MAP: Map<&'static str, &'static str> = phf_map! {
"eslint:recommended" => "eslint",
"plugin:react/recommended" => "react",
"plugin:@typescript-eslint/recommended" => "typescript",
"plugin:react-hooks/recommended" => "react",
"plugin:unicorn/recommended" => "unicorn",
"plugin:jest/recommended" => "jest",
};

fn parse_rule_name(name: &str) -> (&str, &str) {
if let Some((category, name)) = name.split_once('/') {
let category = category.trim_start_matches('@');

// if it matches typescript-eslint, map it to typescript
let category = match category {
"typescript-eslint" => "typescript",
_ => category,
};

(category, name)
} else {
("eslint", name)
}
}

/// Resolves the level of a rule and its config
///
/// Two cases here
/// ```json
/// {
/// "rule": "off",
/// "rule": ["off", "config"],
/// }
/// ```
fn resolve_rule_value(value: &serde_json::Value) -> Result<(AllowWarnDeny, Option<&Value>), Error> {
if let Some(v) = value.as_str() {
return Ok((AllowWarnDeny::try_from(v)?, None));
}

if let Some(v) = value.as_array() {
if let Some(v_idx_0) = v.get(0) {
return Ok((AllowWarnDeny::try_from(v_idx_0)?, v.get(1)));
}
}

Err(FailedToParseRuleValueError(value.to_string(), "Invalid rule value").into())
}
13 changes: 9 additions & 4 deletions crates/oxc_linter/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
#![deny(clippy::print_stdout)]
#![warn(clippy::print_stdout)]
#![allow(clippy::self_named_module_files)] // for rules.rs

#[cfg(test)]
mod tester;

mod ast_util;
mod config;
mod context;
mod disable_directives;
mod fixer;
Expand All @@ -18,6 +19,7 @@ mod utils;

use std::{self, fs, io::Write, rc::Rc, time::Duration};

use oxc_diagnostics::Report;
pub(crate) use oxc_semantic::AstNode;
use rustc_hash::FxHashMap;

Expand Down Expand Up @@ -53,9 +55,12 @@ impl Linter {
Self { rules, options: LintOptions::default() }
}

pub fn from_options(options: LintOptions) -> Self {
let rules = options.derive_rules();
Self { rules, options }
/// # Errors
///
/// Returns `Err` if there are any errors parsing the configuration file.
pub fn from_options(options: LintOptions) -> Result<Self, Report> {
let rules = options.derive_rules()?;
Ok(Self { rules, options })
}

#[must_use]
Expand Down
Loading
Loading