diff --git a/crates/oxc_linter/Cargo.toml b/crates/oxc_linter/Cargo.toml index a649dd871..d451b5b43 100644 --- a/crates/oxc_linter/Cargo.toml +++ b/crates/oxc_linter/Cargo.toml @@ -34,7 +34,7 @@ oxc_resolver = { version = "1.3.0" } rayon = { workspace = true } lazy_static = { workspace = true } # used in oxc_macros serde_json = { workspace = true } -serde = { workspace = true } +serde = { workspace = true, features = ["derive"] } regex = { workspace = true } rustc-hash = { workspace = true } phf = { workspace = true, features = ["macros"] } diff --git a/crates/oxc_linter/src/config/env.rs b/crates/oxc_linter/src/config/env.rs index 159a228fe..00ceee3a5 100644 --- a/crates/oxc_linter/src/config/env.rs +++ b/crates/oxc_linter/src/config/env.rs @@ -1,27 +1,58 @@ -use std::{self, ops::Deref}; +use rustc_hash::FxHashMap; +use serde::Deserialize; /// Environment /// https://eslint.org/docs/latest/use/configure/language-options#using-configuration-files -#[derive(Debug, Clone)] -pub struct ESLintEnv(Vec); +/// +/// TS type is `Record` +/// https://github.com/eslint/eslint/blob/ce838adc3b673e52a151f36da0eedf5876977514/lib/shared/types.js#L40 +#[derive(Debug, Clone, Deserialize)] +pub struct ESLintEnv(FxHashMap); impl ESLintEnv { - pub fn new(env: Vec) -> Self { - Self(env) + pub fn from_vec(env: Vec) -> Self { + let map = env.into_iter().map(|key| (key, true)).collect(); + + Self(map) + } + + pub fn iter(&self) -> impl Iterator + '_ { + // Filter out false values + self.0.iter().filter(|(_, v)| **v).map(|(k, _)| k.as_str()) } } -/// The `env` field from ESLint config impl Default for ESLintEnv { fn default() -> Self { - Self(vec!["builtin".to_string()]) + let mut map = FxHashMap::default(); + map.insert("builtin".to_string(), true); + + Self(map) } } -impl Deref for ESLintEnv { - type Target = Vec; +#[cfg(test)] +mod test { + use super::ESLintEnv; + use itertools::Itertools; + use serde::Deserialize; - fn deref(&self) -> &Self::Target { - &self.0 + #[test] + fn test_parse_env() { + let env = ESLintEnv::deserialize(&serde_json::json!({ + "browser": true, "node": true, "es6": false + })) + .unwrap(); + assert_eq!(env.iter().count(), 2); + assert!(env.iter().contains(&"browser")); + assert!(env.iter().contains(&"node")); + assert!(!env.iter().contains(&"es6")); + assert!(!env.iter().contains(&"builtin")); + } + #[test] + fn test_parse_env_default() { + let env = ESLintEnv::default(); + assert_eq!(env.iter().count(), 1); + assert!(env.iter().contains(&"builtin")); } } diff --git a/crates/oxc_linter/src/config/errors.rs b/crates/oxc_linter/src/config/errors.rs index 248bc6b93..cae49049d 100644 --- a/crates/oxc_linter/src/config/errors.rs +++ b/crates/oxc_linter/src/config/errors.rs @@ -16,9 +16,9 @@ pub struct FailedToParseConfigJsonError(pub PathBuf, pub String); pub struct FailedToParseConfigError(#[related] pub Vec); #[derive(Debug, Error, Diagnostic)] -#[error("Failed to parse config at {0:?} with error {1:?}")] +#[error("Failed to parse config with error {0:?}")] #[diagnostic()] -pub struct FailedToParseConfigPropertyError(pub &'static str, pub &'static str); +pub struct FailedToParseConfigPropertyError(pub String); #[derive(Debug, Error, Diagnostic)] #[error("Failed to rule value {0:?} with error {1:?}")] diff --git a/crates/oxc_linter/src/config/mod.rs b/crates/oxc_linter/src/config/mod.rs index 33287aa2b..bcd2fb00d 100644 --- a/crates/oxc_linter/src/config/mod.rs +++ b/crates/oxc_linter/src/config/mod.rs @@ -1,44 +1,34 @@ mod env; pub mod errors; +mod rules; mod settings; use std::path::Path; use oxc_diagnostics::{Error, FailedToOpenFileError, Report}; -use rustc_hash::{FxHashMap, FxHashSet}; -use serde_json::{Map, Value}; +use rustc_hash::FxHashSet; +use serde::Deserialize; use crate::{rules::RuleEnum, AllowWarnDeny}; -pub use self::{ - env::ESLintEnv, - settings::{ESLintSettings, JsxA11y, Nextjs}, -}; -use self::{ - errors::{ - FailedToParseConfigError, FailedToParseConfigJsonError, FailedToParseJsonc, - FailedToParseRuleValueError, - }, - settings::CustomComponents, +use self::errors::{ + FailedToParseConfigError, FailedToParseConfigJsonError, FailedToParseConfigPropertyError, + FailedToParseJsonc, }; +pub use self::{env::ESLintEnv, rules::ESLintRules, settings::ESLintSettings}; /// ESLint Config /// -#[derive(Debug)] +#[derive(Debug, Deserialize)] pub struct ESLintConfig { - rules: Vec, + #[serde(default)] + rules: ESLintRules, + #[serde(default)] settings: ESLintSettings, + #[serde(default)] env: ESLintEnv, } -#[derive(Debug)] -pub struct ESLintRuleConfig { - plugin_name: String, - rule_name: String, - severity: AllowWarnDeny, - config: Option, -} - impl ESLintConfig { pub fn from_file(path: &Path) -> Result { let mut string = std::fs::read_to_string(path).map_err(|e| { @@ -67,18 +57,15 @@ impl ESLintConfig { ))]) })?; - let config = Self::from_value(&json)?; + let config = Self::deserialize(&json).map_err(|err| { + FailedToParseConfigError(vec![Error::new(FailedToParseConfigPropertyError( + err.to_string(), + ))]) + })?; + Ok(config) } - pub fn from_value(value: &Value) -> Result { - let rules = parse_rules(value)?; - let settings = parse_settings_from_root(value); - let env = parse_env_from_root(value); - - Ok(Self { rules, settings, env }) - } - pub fn properties(self) -> (ESLintSettings, ESLintEnv) { (self.settings, self.env) } @@ -150,284 +137,37 @@ impl ESLintConfig { } } -fn parse_rules(root_json: &Value) -> Result, Error> { - let Value::Object(rules_object) = root_json else { return Ok(Vec::default()) }; - - let Some(Value::Object(rules_object)) = rules_object.get("rules") else { - return Ok(Vec::default()); - }; - - rules_object - .into_iter() - .map(|(key, value)| { - let (plugin_name, rule_name) = parse_rule_name(key); - let (severity, config) = resolve_rule_value(value)?; - Ok(ESLintRuleConfig { - plugin_name: plugin_name.to_string(), - rule_name: rule_name.to_string(), - severity, - config, - }) - }) - .collect::, Error>>() -} - -fn parse_settings_from_root(root_json: &Value) -> ESLintSettings { - let Value::Object(root_object) = root_json else { return ESLintSettings::default() }; - - let Some(settings_value) = root_object.get("settings") else { - return ESLintSettings::default(); - }; - - parse_settings(settings_value) -} - -pub fn parse_settings(setting_value: &Value) -> ESLintSettings { - if let Value::Object(settings_object) = setting_value { - let mut jsx_a11y_setting = JsxA11y::new(None, FxHashMap::default()); - let mut nextjs_setting = Nextjs::new(vec![]); - if let Some(Value::Object(jsx_a11y)) = settings_object.get("jsx-a11y") { - if let Some(Value::Object(components)) = jsx_a11y.get("components") { - let components_map: FxHashMap = components - .iter() - .map(|(key, value)| (String::from(key), String::from(value.as_str().unwrap()))) - .collect(); - - jsx_a11y_setting.set_components(components_map); - } - - if let Some(Value::String(polymorphic_prop_name)) = jsx_a11y.get("polymorphicPropName") - { - jsx_a11y_setting - .set_polymorphic_prop_name(Some(String::from(polymorphic_prop_name))); - } - } - - if let Some(Value::Object(nextjs)) = settings_object.get("next") { - if let Some(Value::String(root_dir)) = nextjs.get("rootDir") { - nextjs_setting.set_root_dir(vec![String::from(root_dir)]); - } - if let Some(Value::Array(root_dir)) = nextjs.get("rootDir") { - nextjs_setting.set_root_dir( - root_dir.iter().map(|v| v.as_str().unwrap().to_string()).collect(), - ); - } - } - - let link_components_setting = - parse_custom_components(settings_object, &CustomComponentEnum::LinkComponents); - let form_components_setting = - parse_custom_components(settings_object, &CustomComponentEnum::FormComponents); - - return ESLintSettings::new( - jsx_a11y_setting, - nextjs_setting, - // TODO: These should be inside of react_setting - link_components_setting, - form_components_setting, - ); - } - - ESLintSettings::default() -} - -enum CustomComponentEnum { - LinkComponents, - FormComponents, -} - -fn parse_custom_components( - settings_object: &Map, - components_type: &CustomComponentEnum, -) -> CustomComponents { - fn parse_obj(obj: &Map, attribute_name: &str, setting: &mut CustomComponents) { - if let Some(Value::String(name)) = obj.get("name") { - let mut arr: Vec = vec![]; - if let Some(Value::String(attribute)) = obj.get(attribute_name) { - arr.push(attribute.to_string()); - } else if let Some(Value::Array(attributes)) = obj.get(attribute_name) { - for attribute in attributes { - if let Value::String(attribute) = attribute { - arr.push(attribute.to_string()); - } - } - } - setting.insert(name.to_string(), arr); - } - } - - fn parse_component( - settings_object: &Map, - component_name: &str, - attribute_name: &str, - setting: &mut CustomComponents, - ) { - match settings_object.get(component_name) { - Some(Value::Array(component)) => { - for component in component { - if let Value::String(name) = component { - setting.insert(name.to_string(), [].to_vec()); - continue; - } - if let Value::Object(obj) = component { - parse_obj(obj, attribute_name, setting); - } - } - } - Some(Value::Object(obj)) => { - parse_obj(obj, attribute_name, setting); - } - _ => {} - }; - } - - let mut setting: CustomComponents = FxHashMap::default(); - - match components_type { - CustomComponentEnum::FormComponents => { - parse_component(settings_object, "formComponents", "formAttribute", &mut setting); - } - CustomComponentEnum::LinkComponents => { - parse_component(settings_object, "linkComponents", "linkAttribute", &mut setting); - } - } - setting -} - -fn parse_env_from_root(root_json: &Value) -> ESLintEnv { - let Value::Object(root_object) = root_json else { return ESLintEnv::default() }; - - let Some(env_value) = root_object.get("env") else { return ESLintEnv::default() }; - - let env_object = match env_value { - Value::Object(env_object) => env_object, - _ => return ESLintEnv::default(), - }; - - let mut result = vec![]; - for (k, v) in env_object { - if let Value::Bool(v) = v { - if *v { - result.push(String::from(k)); - } - } - } - - ESLintEnv::new(result) -} - -fn parse_rule_name(name: &str) -> (&str, &str) { - if let Some((category, name)) = name.split_once('/') { - let category = category.trim_start_matches('@'); - - let category = match category { - // if it matches typescript-eslint, map it to typescript - "typescript-eslint" => "typescript", - // plugin name in RuleEnum is in snake_case - "jsx-a11y" => "jsx_a11y", - "next" => "nextjs", - _ => category, - }; - - // since next.js eslint rule starts with @next/next/ - let name = name.trim_start_matches("next/"); - - (category, name) - } else { - ("eslint", name) - } -} - -/// Resolves the level of a rule and its config -/// -/// Three cases here -/// ```json -/// { -/// "rule": "off", -/// "rule": ["off", "config"], -/// "rule": ["off", "config1", "config2"], -/// } -/// ``` -fn resolve_rule_value(value: &serde_json::Value) -> Result<(AllowWarnDeny, Option), Error> { - if let Some(v) = value.as_str() { - return Ok((AllowWarnDeny::try_from(v)?, None)); - } - - if let Some(v) = value.as_array() { - let mut config = Vec::new(); - for item in v.iter().skip(1).take(2) { - config.push(item.clone()); - } - let config = if config.is_empty() { None } else { Some(Value::Array(config)) }; - if let Some(v_idx_0) = v.first() { - return Ok((AllowWarnDeny::try_from(v_idx_0)?, config)); - } - } - - Err(FailedToParseRuleValueError(value.to_string(), "Invalid rule value").into()) -} - #[cfg(test)] mod test { use super::ESLintConfig; + use serde::Deserialize; use std::env; #[test] - fn test_parse_from_file() { + fn test_from_file() { let fixture_path = env::current_dir().unwrap().join("fixtures/eslint_config.json"); let config = ESLintConfig::from_file(&fixture_path).unwrap(); assert!(!config.rules.is_empty()); } #[test] - fn test_parse_from_value() { - let config = ESLintConfig::from_value(&serde_json::json!({ - "rules": { "no-console": "off" } - })) - .unwrap(); - assert!(!config.rules.is_empty()); - } - - #[test] - fn test_parse_rules() { - // TODO: Should support `"xxx": 0` form(only `"xxx": [0]` is supported) - let config = ESLintConfig::from_value(&serde_json::json!({ + fn test_deserialize() { + let config = ESLintConfig::deserialize(&serde_json::json!({ "rules": { "no-console": "off", - "foo/no-unused-vars": [1], - "dummy": ["error", "arg1", "args2"], - } - })) - .unwrap(); - let mut rules = config.rules.iter(); - - let r1 = rules.next().unwrap(); - assert_eq!(r1.rule_name, "no-console"); - assert_eq!(r1.plugin_name, "eslint"); - assert!(r1.severity.is_allow()); - assert!(r1.config.is_none()); - - let r2 = rules.next().unwrap(); - assert_eq!(r2.rule_name, "no-unused-vars"); - assert_eq!(r2.plugin_name, "foo"); - assert!(r2.severity.is_warn_deny()); - assert!(r2.config.is_none()); - - let r3 = rules.next().unwrap(); - assert_eq!(r3.rule_name, "dummy"); - assert_eq!(r3.plugin_name, "eslint"); - assert!(r3.severity.is_warn_deny()); - assert_eq!(r3.config, Some(serde_json::json!(["arg1", "args2"]))); - } - #[test] - fn test_parse_rules_default() { - let config = ESLintConfig::from_value(&serde_json::json!({})).unwrap(); - assert!(config.rules.is_empty()); - } - - #[test] - fn test_parse_settings() { - let config = ESLintConfig::from_value(&serde_json::json!({ + "no-debugger": 2, + "no-bitwise": [ + "error", + { "allow": ["~"] } + ], + "eqeqeq": [ + "error", + "always", { "null": "ignore" }, "foo" + ], + "@typescript-eslint/ban-types": "error", + "jsx-a11y/alt-text": "warn", + "@next/next/noop": [1] + }, "settings": { "jsx-a11y": { "polymorphicPropName": "role", @@ -436,61 +176,14 @@ mod test { "Link2": "Anchor2" } }, - "next": { - "rootDir": "app" - }, - "formComponents": [ - "CustomForm", - {"name": "SimpleForm", "formAttribute": "endpoint"}, - {"name": "Form", "formAttribute": ["registerEndpoint", "loginEndpoint"]}, - ], - "linkComponents": [ - "Hyperlink", - {"name": "MyLink", "linkAttribute": "to"}, - {"name": "Link", "linkAttribute": ["to", "href"]}, - ] - } - })) - .unwrap(); - assert_eq!(config.settings.jsx_a11y.polymorphic_prop_name, Some("role".to_string())); - assert_eq!(config.settings.jsx_a11y.components.get("Link"), Some(&"Anchor".to_string())); - assert!(config.settings.nextjs.root_dir.contains(&"app".to_string())); - assert_eq!(config.settings.form_components.get("CustomForm"), Some(&vec![])); - assert_eq!( - config.settings.form_components.get("SimpleForm"), - Some(&vec!["endpoint".to_string()]) - ); - assert_eq!( - config.settings.form_components.get("Form"), - Some(&vec!["registerEndpoint".to_string(), "loginEndpoint".to_string()]) - ); - assert_eq!(config.settings.link_components.len(), 3); - } - #[test] - fn test_parse_settings_default() { - let config = ESLintConfig::from_value(&serde_json::json!({})).unwrap(); - assert!(config.settings.jsx_a11y.polymorphic_prop_name.is_none()); - assert!(config.settings.jsx_a11y.components.is_empty()); - assert!(config.settings.nextjs.root_dir.is_empty()); - assert!(config.settings.form_components.is_empty()); - assert!(config.settings.link_components.is_empty()); - } + }, + "env": { "browser": true, } + })); + assert!(config.is_ok()); - #[test] - fn test_parse_env() { - let config = ESLintConfig::from_value(&serde_json::json!({ - "env": { "browser": true, "node": true, "es6": false } - })) - .unwrap(); - assert_eq!(config.env.len(), 2); - assert!(config.env.contains(&"browser".to_string())); - assert!(config.env.contains(&"node".to_string())); - assert!(!config.env.contains(&"es6".to_string())); - } - #[test] - fn test_parse_env_default() { - let config = ESLintConfig::from_value(&serde_json::json!({})).unwrap(); - assert_eq!(config.env.len(), 1); - assert_eq!(config.env.first(), Some(&"builtin".to_string())); + let ESLintConfig { rules, settings, env } = config.unwrap(); + assert!(!rules.is_empty()); + assert_eq!(settings.jsx_a11y.polymorphic_prop_name, Some("role".to_string())); + assert_eq!(env.iter().count(), 1); } } diff --git a/crates/oxc_linter/src/config/rules.rs b/crates/oxc_linter/src/config/rules.rs new file mode 100644 index 000000000..a85018092 --- /dev/null +++ b/crates/oxc_linter/src/config/rules.rs @@ -0,0 +1,173 @@ +use super::errors::FailedToParseRuleValueError; +use crate::AllowWarnDeny; +use oxc_diagnostics::Error; +use serde::de::{self, Deserializer, Visitor}; +use serde::Deserialize; +use std::fmt; +use std::ops::Deref; + +/// The `rules` field from ESLint config +/// +/// TS type is `Record` +/// - type SeverityConf = 0 | 1 | 2 | "off" | "warn" | "error"; +/// - type RuleConf = SeverityConf | [SeverityConf, ...any[]]; +/// https://github.com/eslint/eslint/blob/ce838adc3b673e52a151f36da0eedf5876977514/lib/shared/types.js#L12 +#[derive(Debug, Clone, Default)] +pub struct ESLintRules(Vec); + +#[derive(Debug, Clone)] +pub struct ESLintRule { + pub plugin_name: String, + pub rule_name: String, + pub severity: AllowWarnDeny, + pub config: Option, +} + +// Manually implement Deserialize because the type is a bit complex... +// - Handle single value form and array form +// - SeverityConf into AllowWarnDeny +// - Align plugin names +impl<'de> Deserialize<'de> for ESLintRules { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct ESLintRulesVisitor; + + impl<'de> Visitor<'de> for ESLintRulesVisitor { + type Value = ESLintRules; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("Record") + } + + fn visit_map(self, mut map: M) -> Result + where + M: de::MapAccess<'de>, + { + let mut rules = vec![]; + while let Some((key, value)) = map.next_entry::()? { + let (plugin_name, rule_name) = parse_rule_key(&key); + let (severity, config) = parse_rule_value(&value).map_err(de::Error::custom)?; + rules.push(ESLintRule { plugin_name, rule_name, severity, config }); + } + + Ok(ESLintRules(rules)) + } + } + + deserializer.deserialize_any(ESLintRulesVisitor) + } +} + +fn parse_rule_key(name: &str) -> (String, String) { + let Some((plugin_name, rule_name)) = name.split_once('/') else { + return ("eslint".to_string(), name.to_string()); + }; + + let (oxlint_plugin_name, rule_name) = match plugin_name { + "@typescript-eslint" => ("typescript", rule_name), + "jsx-a11y" => ("jsx_a11y", rule_name), + "react-perf" => ("react_perf", rule_name), + // e.g. "@next/next/google-font-display" + "@next" => ("nextjs", rule_name.trim_start_matches("next/")), + _ => (plugin_name, rule_name), + }; + + (oxlint_plugin_name.to_string(), rule_name.to_string()) +} + +fn parse_rule_value( + value: &serde_json::Value, +) -> Result<(AllowWarnDeny, Option), Error> { + match value { + serde_json::Value::String(_) | serde_json::Value::Number(_) => { + let severity = AllowWarnDeny::try_from(value)?; + Ok((severity, None)) + } + + serde_json::Value::Array(v) => { + if v.is_empty() { + return Err(FailedToParseRuleValueError( + value.to_string(), + "Type should be `[SeverityConf, ...any[]`", + ) + .into()); + } + + // The first item should be SeverityConf + let severity = AllowWarnDeny::try_from(v.first().unwrap())?; + // e.g. ["warn"], [0] + let config = if v.len() == 1 { + None + // e.g. ["error", "args", { type: "whatever" }, ["len", "also"]] + } else { + Some(serde_json::Value::Array(v.iter().skip(1).cloned().collect::>())) + }; + + Ok((severity, config)) + } + + _ => Err(FailedToParseRuleValueError( + value.to_string(), + "Type should be `SeverityConf | [SeverityConf, ...any[]]`", + ) + .into()), + } +} + +impl Deref for ESLintRules { + type Target = Vec; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[cfg(test)] +mod test { + use super::ESLintRules; + use serde::Deserialize; + + #[test] + fn test_parse_rules() { + let rules = ESLintRules::deserialize(&serde_json::json!({ + "no-console": "off", + "foo/no-unused-vars": [1], + "dummy": ["error", "arg1", "args2"], + "@next/next/noop": 2, + })) + .unwrap(); + let mut rules = rules.iter(); + + let r1 = rules.next().unwrap(); + assert_eq!(r1.rule_name, "no-console"); + assert_eq!(r1.plugin_name, "eslint"); + assert!(r1.severity.is_allow()); + assert!(r1.config.is_none()); + + let r2 = rules.next().unwrap(); + assert_eq!(r2.rule_name, "no-unused-vars"); + assert_eq!(r2.plugin_name, "foo"); + assert!(r2.severity.is_warn_deny()); + assert!(r2.config.is_none()); + + let r3 = rules.next().unwrap(); + assert_eq!(r3.rule_name, "dummy"); + assert_eq!(r3.plugin_name, "eslint"); + assert!(r3.severity.is_warn_deny()); + assert_eq!(r3.config, Some(serde_json::json!(["arg1", "args2"]))); + + let r4 = rules.next().unwrap(); + assert_eq!(r4.rule_name, "noop"); + assert_eq!(r4.plugin_name, "nextjs"); + assert!(r4.severity.is_warn_deny()); + assert!(r4.config.is_none()); + } + + #[test] + fn test_parse_rules_default() { + let rules = ESLintRules::default(); + assert!(rules.is_empty()); + } +} diff --git a/crates/oxc_linter/src/config/settings.rs b/crates/oxc_linter/src/config/settings.rs deleted file mode 100644 index 7ca2f64f7..000000000 --- a/crates/oxc_linter/src/config/settings.rs +++ /dev/null @@ -1,74 +0,0 @@ -use rustc_hash::FxHashMap; - -/// The `settings` field from ESLint config -/// -/// An object containing name-value pairs of information that should be available to all rules -#[derive(Debug, Clone)] -pub struct ESLintSettings { - pub jsx_a11y: JsxA11y, - pub nextjs: Nextjs, - pub link_components: CustomComponents, - pub form_components: CustomComponents, -} - -impl Default for ESLintSettings { - fn default() -> Self { - Self { - jsx_a11y: JsxA11y { polymorphic_prop_name: None, components: FxHashMap::default() }, - nextjs: Nextjs { root_dir: vec![] }, - link_components: FxHashMap::default(), - form_components: FxHashMap::default(), - } - } -} - -impl ESLintSettings { - pub fn new( - jsx_a11y: JsxA11y, - nextjs: Nextjs, - link_components: CustomComponents, - form_components: CustomComponents, - ) -> Self { - Self { jsx_a11y, nextjs, link_components, form_components } - } -} - -#[derive(Debug, Clone)] -pub struct JsxA11y { - pub polymorphic_prop_name: Option, - pub components: FxHashMap, -} - -impl JsxA11y { - pub fn new( - polymorphic_prop_name: Option, - components: FxHashMap, - ) -> Self { - Self { polymorphic_prop_name, components } - } - - pub fn set_components(&mut self, components: FxHashMap) { - self.components = components; - } - - pub fn set_polymorphic_prop_name(&mut self, name: Option) { - self.polymorphic_prop_name = name; - } -} - -#[derive(Debug, Clone)] -pub struct Nextjs { - pub root_dir: Vec, -} - -impl Nextjs { - pub fn new(root_dir: Vec) -> Self { - Self { root_dir } - } - - pub fn set_root_dir(&mut self, root_dir: Vec) { - self.root_dir = root_dir; - } -} - -pub type CustomComponents = FxHashMap>; diff --git a/crates/oxc_linter/src/config/settings/jsx_a11y.rs b/crates/oxc_linter/src/config/settings/jsx_a11y.rs new file mode 100644 index 000000000..0a5b334c7 --- /dev/null +++ b/crates/oxc_linter/src/config/settings/jsx_a11y.rs @@ -0,0 +1,11 @@ +use rustc_hash::FxHashMap; +use serde::Deserialize; + +/// https://github.com/jsx-eslint/eslint-plugin-jsx-a11y#configurations +#[derive(Debug, Deserialize, Default)] +pub struct ESLintSettingsJSXA11y { + #[serde(rename = "polymorphicPropName")] + pub polymorphic_prop_name: Option, + #[serde(default)] + pub components: FxHashMap, +} diff --git a/crates/oxc_linter/src/config/settings/mod.rs b/crates/oxc_linter/src/config/settings/mod.rs new file mode 100644 index 000000000..63324227c --- /dev/null +++ b/crates/oxc_linter/src/config/settings/mod.rs @@ -0,0 +1,83 @@ +use self::{jsx_a11y::ESLintSettingsJSXA11y, next::ESLintSettingsNext, react::ESLintSettingsReact}; +use serde::Deserialize; + +mod jsx_a11y; +mod next; +mod react; + +/// The `settings` field from ESLint config +/// An object containing name-value pairs of information that should be available to all rules +/// +/// TS type is `Object` +/// https://github.com/eslint/eslint/blob/ce838adc3b673e52a151f36da0eedf5876977514/lib/shared/types.js#L53 +/// But each plugin extends this with their own properties. +#[derive(Debug, Deserialize, Default)] +pub struct ESLintSettings { + #[serde(default)] + #[serde(rename = "jsx-a11y")] + pub jsx_a11y: ESLintSettingsJSXA11y, + #[serde(default)] + pub next: ESLintSettingsNext, + #[serde(default)] + pub react: ESLintSettingsReact, +} + +#[cfg(test)] +mod test { + use super::ESLintSettings; + use serde::Deserialize; + + #[test] + fn test_parse_settings() { + let settings = ESLintSettings::deserialize(&serde_json::json!({ + "jsx-a11y": { + "polymorphicPropName": "role", + "components": { + "Link": "Anchor", + "Link2": "Anchor2" + } + }, + "next": { + "rootDir": "app" + }, + "react": { + "formComponents": [ + "CustomForm", + {"name": "SimpleForm", "formAttribute": "endpoint"}, + {"name": "Form", "formAttribute": ["registerEndpoint", "loginEndpoint"]}, + ], + "linkComponents": [ + "Hyperlink", + {"name": "MyLink", "linkAttribute": "to"}, + {"name": "Link", "linkAttribute": ["to", "href"]}, + ] + } + })) + .unwrap(); + + assert_eq!(settings.jsx_a11y.polymorphic_prop_name, Some("role".to_string())); + assert_eq!(settings.jsx_a11y.components.get("Link"), Some(&"Anchor".to_string())); + assert!(settings.next.get_root_dirs().contains(&"app".to_string())); + assert_eq!(settings.react.get_form_component_attrs("CustomForm"), Some(vec![])); + assert_eq!( + settings.react.get_form_component_attrs("SimpleForm"), + Some(vec!["endpoint".to_string()]) + ); + assert_eq!( + settings.react.get_form_component_attrs("Form"), + Some(vec!["registerEndpoint".to_string(), "loginEndpoint".to_string()]) + ); + assert_eq!( + settings.react.get_link_component_attrs("Link"), + Some(vec!["to".to_string(), "href".to_string()]) + ); + assert_eq!(settings.react.get_link_component_attrs("Noop"), None); + } + + #[test] + fn test_parse_settings_default() { + let settings = ESLintSettings::default(); + assert!(settings.jsx_a11y.polymorphic_prop_name.is_none()); + assert!(settings.jsx_a11y.components.is_empty()); + } +} diff --git a/crates/oxc_linter/src/config/settings/next.rs b/crates/oxc_linter/src/config/settings/next.rs new file mode 100644 index 000000000..e3b7656d2 --- /dev/null +++ b/crates/oxc_linter/src/config/settings/next.rs @@ -0,0 +1,32 @@ +use serde::Deserialize; + +/// https://nextjs.org/docs/pages/building-your-application/configuring/eslint#eslint-plugin +#[derive(Debug, Deserialize, Default)] +pub struct ESLintSettingsNext { + #[serde(default)] + #[serde(rename = "rootDir")] + root_dir: OneOrMany, +} + +impl ESLintSettingsNext { + pub fn get_root_dirs(&self) -> Vec { + match &self.root_dir { + OneOrMany::One(val) => vec![val.clone()], + OneOrMany::Many(vec) => vec.clone(), + } + } +} + +// Deserialize helper types + +#[derive(Clone, Debug, Deserialize, PartialEq)] +#[serde(untagged)] +enum OneOrMany { + One(T), + Many(Vec), +} +impl Default for OneOrMany { + fn default() -> Self { + OneOrMany::Many(Vec::new()) + } +} diff --git a/crates/oxc_linter/src/config/settings/react.rs b/crates/oxc_linter/src/config/settings/react.rs new file mode 100644 index 000000000..2c574d80c --- /dev/null +++ b/crates/oxc_linter/src/config/settings/react.rs @@ -0,0 +1,62 @@ +use serde::Deserialize; + +/// https://github.com/jsx-eslint/eslint-plugin-react#configuration-legacy-eslintrc- +#[derive(Debug, Deserialize, Default)] +pub struct ESLintSettingsReact { + #[serde(default)] + #[serde(rename = "formComponents")] + form_components: Vec, + #[serde(default)] + #[serde(rename = "linkComponents")] + link_components: Vec, + // TODO: More properties should be added +} + +impl ESLintSettingsReact { + pub fn get_form_component_attrs(&self, name: &str) -> Option> { + get_component_attrs_by_name(&self.form_components, name) + } + + pub fn get_link_component_attrs(&self, name: &str) -> Option> { + get_component_attrs_by_name(&self.link_components, name) + } +} + +// Deserialize helper types + +#[derive(Clone, Debug, Deserialize)] +#[serde(untagged)] +enum CustomComponent { + NameOnly(String), + ObjectWithOneAttr { + name: String, + #[serde(alias = "formAttribute", alias = "linkAttribute")] + attribute: String, + }, + ObjectWithManyAttrs { + name: String, + #[serde(alias = "formAttribute", alias = "linkAttribute")] + attributes: Vec, + }, +} + +fn get_component_attrs_by_name( + components: &Vec, + name: &str, +) -> Option> { + for item in components { + let comp = match item { + CustomComponent::NameOnly(name) => (name, vec![]), + CustomComponent::ObjectWithOneAttr { name, attribute } => { + (name, vec![attribute.to_string()]) + } + CustomComponent::ObjectWithManyAttrs { name, attributes } => (name, attributes.clone()), + }; + + if comp.0 == name { + return Some(comp.1); + } + } + + None +} diff --git a/crates/oxc_linter/src/lib.rs b/crates/oxc_linter/src/lib.rs index e6cb6cbf7..32d90bdbc 100644 --- a/crates/oxc_linter/src/lib.rs +++ b/crates/oxc_linter/src/lib.rs @@ -24,7 +24,7 @@ use std::{io::Write, rc::Rc, sync::Arc}; use oxc_diagnostics::Report; use crate::{ - config::{ESLintEnv, ESLintSettings, JsxA11y}, + config::{ESLintEnv, ESLintSettings}, fixer::Fix, fixer::{Fixer, Message}, rule::RuleCategory, diff --git a/crates/oxc_linter/src/options.rs b/crates/oxc_linter/src/options.rs index 90ee1b316..3db83150a 100644 --- a/crates/oxc_linter/src/options.rs +++ b/crates/oxc_linter/src/options.rs @@ -107,7 +107,7 @@ impl LintOptions { #[must_use] pub fn with_env(mut self, env: Vec) -> Self { - self.env = ESLintEnv::new(env); + self.env = ESLintEnv::from_vec(env); self } } diff --git a/crates/oxc_linter/src/rules/react/jsx_no_target_blank.rs b/crates/oxc_linter/src/rules/react/jsx_no_target_blank.rs index e2521d730..6fb8173b7 100644 --- a/crates/oxc_linter/src/rules/react/jsx_no_target_blank.rs +++ b/crates/oxc_linter/src/rules/react/jsx_no_target_blank.rs @@ -72,7 +72,7 @@ impl JsxNoTargetBlank { if tag_name == "a" { return true; } - return ctx.settings().link_components.get(tag_name).is_some(); + return ctx.settings().react.get_link_component_attrs(tag_name).is_some(); } fn check_is_forms(&self, tag_name: &str, ctx: &LintContext) -> bool { if !self.forms { @@ -81,7 +81,7 @@ impl JsxNoTargetBlank { if tag_name == "form" { return true; } - return ctx.settings().form_components.get(tag_name).is_some(); + return ctx.settings().react.get_form_component_attrs(tag_name).is_some(); } } @@ -142,18 +142,20 @@ impl Rule for JsxNoTargetBlank { } } else if attribute_name == "href" || attribute_name == "action" - || ctx.settings().link_components.get(tag_name).map_or( - false, - |link_attribute| { + || ctx + .settings() + .react + .get_link_component_attrs(tag_name) + .map_or(false, |link_attribute| { link_attribute.contains(&attribute_name.to_string()) - }, - ) - || ctx.settings().form_components.get(tag_name).map_or( - false, - |link_attribute| { - link_attribute.contains(&attribute_name.to_string()) - }, - ) + }) + || ctx + .settings() + .react + .get_form_component_attrs(tag_name) + .map_or(false, |form_attribute| { + form_attribute.contains(&attribute_name.to_string()) + }) { if let Some(val) = attribute.value.as_ref() { has_href_value = true; @@ -487,20 +489,20 @@ fn test() { ( r#""#, Some(serde_json::json!([{ "enforceDynamicLinks": "never" }])), - Some(serde_json::json!({ "linkComponents": ["Link"] })), + Some(serde_json::json!({ "react": { "linkComponents": ["Link"] } })), ), ( r#""#, Some(serde_json::json!([{ "enforceDynamicLinks": "never" }])), Some( - serde_json::json!({ "linkComponents": { "name": "Link", "linkAttribute": "to" } }), + serde_json::json!({"react": { "linkComponents": [{ "name": "Link", "linkAttribute": "to" }] }}), ), ), ( r#""#, Some(serde_json::json!([{ "enforceDynamicLinks": "never" }])), Some( - serde_json::json!({ "linkComponents": { "name": "Link", "linkAttribute": ["to"] } }), + serde_json::json!({ "react": { "linkComponents": [{ "name": "Link", "linkAttribute": ["to"] }] }}), ), ), ( @@ -681,13 +683,13 @@ fn test() { ( r#""#, Some(serde_json::json!([{ "enforceDynamicLinks": "always"}])), - Some(serde_json::json!({ "linkComponents": ["Link"] })), + Some(serde_json::json!({ "react": { "linkComponents": ["Link"] } })), ), ( r#""#, Some(serde_json::json!([{ "enforceDynamicLinks": "always" }])), Some( - serde_json::json!({ "linkComponents": { "name": "Link", "linkAttribute": "to" } }), + serde_json::json!({ "react": { "linkComponents": [{ "name": "Link", "linkAttribute": "to" }] } }), ), ), ( diff --git a/crates/oxc_linter/src/tester.rs b/crates/oxc_linter/src/tester.rs index e277eab13..8f90a8c78 100644 --- a/crates/oxc_linter/src/tester.rs +++ b/crates/oxc_linter/src/tester.rs @@ -6,12 +6,10 @@ use std::{ use oxc_allocator::Allocator; use oxc_diagnostics::miette::NamedSource; use oxc_diagnostics::{DiagnosticService, GraphicalReportHandler, GraphicalTheme}; +use serde::Deserialize; use serde_json::Value; -use crate::{ - config::parse_settings, rules::RULES, ESLintSettings, Fixer, LintOptions, LintService, Linter, - RuleEnum, -}; +use crate::{rules::RULES, ESLintSettings, Fixer, LintOptions, LintService, Linter, RuleEnum}; #[derive(Eq, PartialEq)] enum TestResult { @@ -188,8 +186,9 @@ impl Tester { ) -> TestResult { let allocator = Allocator::default(); let rule = self.find_rule().read_json(config); - let lint_settings: ESLintSettings = - settings.as_ref().map_or_else(ESLintSettings::default, parse_settings); + let lint_settings: ESLintSettings = settings + .as_ref() + .map_or_else(ESLintSettings::default, |v| ESLintSettings::deserialize(v).unwrap()); let options = LintOptions::default() .with_fix(is_fix) .with_import_plugin(self.import_plugin) diff --git a/crates/oxc_linter/src/utils/react.rs b/crates/oxc_linter/src/utils/react.rs index e9576ffc5..259a54854 100644 --- a/crates/oxc_linter/src/utils/react.rs +++ b/crates/oxc_linter/src/utils/react.rs @@ -8,7 +8,7 @@ use oxc_ast::{ }; use oxc_semantic::{AstNode, SymbolFlags}; -use crate::{ESLintSettings, JsxA11y, LintContext}; +use crate::{ESLintSettings, LintContext}; pub fn is_create_element_call(call_expr: &CallExpression) -> bool { if let Some(member_expr) = call_expr.callee.get_member_expr() { @@ -225,9 +225,8 @@ pub fn get_element_type(context: &LintContext, element: &JSXOpeningElement) -> O }; let ESLintSettings { jsx_a11y, .. } = context.settings(); - let JsxA11y { polymorphic_prop_name, components } = jsx_a11y; - if let Some(polymorphic_prop_name_value) = polymorphic_prop_name { + if let Some(polymorphic_prop_name_value) = &jsx_a11y.polymorphic_prop_name { if let Some(as_tag) = has_jsx_prop_lowercase(element, polymorphic_prop_name_value) { if let Some(JSXAttributeValue::StringLiteral(str)) = get_prop_value(as_tag) { return Some(String::from(str.value.as_str())); @@ -236,7 +235,7 @@ pub fn get_element_type(context: &LintContext, element: &JSXOpeningElement) -> O } let element_type = ident.name.as_str(); - if let Some(val) = components.get(element_type) { + if let Some(val) = jsx_a11y.components.get(element_type) { return Some(String::from(val)); } Some(String::from(element_type))