refactor(linter/config): Use serde::Deserialize for config parsing (#2325)

Fixes #2258 

### Overview

- Re-implemented the config parser to use `serde::Deserialize`
- In order to benefit from it as much as possible, avoided implementing
custom deserializers and tried to use attributes as much as possible
  - This required some changes to the caller signatures...

 

- Fixed a bug that did not support for abbreviations like `"rule-name":
1`
- Fixed settings that should have been located in `settings.react` but
were not
This commit is contained in:
Yuji Sugiura 2024-02-08 17:48:38 +09:00 committed by GitHub
parent f3470163d9
commit 63b4741ff3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 479 additions and 468 deletions

View file

@ -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"] }

View file

@ -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<String>);
///
/// TS type is `Record<string, boolean>`
/// https://github.com/eslint/eslint/blob/ce838adc3b673e52a151f36da0eedf5876977514/lib/shared/types.js#L40
#[derive(Debug, Clone, Deserialize)]
pub struct ESLintEnv(FxHashMap<String, bool>);
impl ESLintEnv {
pub fn new(env: Vec<String>) -> Self {
Self(env)
pub fn from_vec(env: Vec<String>) -> Self {
let map = env.into_iter().map(|key| (key, true)).collect();
Self(map)
}
pub fn iter(&self) -> impl Iterator<Item = &str> + '_ {
// 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<String>;
#[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"));
}
}

View file

@ -16,9 +16,9 @@ pub struct FailedToParseConfigJsonError(pub PathBuf, pub String);
pub struct FailedToParseConfigError(#[related] pub Vec<Report>);
#[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:?}")]

View file

@ -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
/// <https://eslint.org/docs/latest/use/configure/configuration-files-new#configuration-objects>
#[derive(Debug)]
#[derive(Debug, Deserialize)]
pub struct ESLintConfig {
rules: Vec<ESLintRuleConfig>,
#[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<serde_json::Value>,
}
impl ESLintConfig {
pub fn from_file(path: &Path) -> Result<Self, Report> {
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<Self, Report> {
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<Vec<ESLintRuleConfig>, 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::<Result<Vec<_>, 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<String, String> = 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<String, Value>,
components_type: &CustomComponentEnum,
) -> CustomComponents {
fn parse_obj(obj: &Map<String, Value>, attribute_name: &str, setting: &mut CustomComponents) {
if let Some(Value::String(name)) = obj.get("name") {
let mut arr: Vec<String> = 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<String, Value>,
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<Value>), 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);
}
}

View file

@ -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<string, RuleConf>`
/// - 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<ESLintRule>);
#[derive(Debug, Clone)]
pub struct ESLintRule {
pub plugin_name: String,
pub rule_name: String,
pub severity: AllowWarnDeny,
pub config: Option<serde_json::Value>,
}
// 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<D>(deserializer: D) -> Result<Self, D::Error>
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<string, SeverityConf | [SeverityConf, ...any[]]>")
}
fn visit_map<M>(self, mut map: M) -> Result<Self::Value, M::Error>
where
M: de::MapAccess<'de>,
{
let mut rules = vec![];
while let Some((key, value)) = map.next_entry::<String, serde_json::Value>()? {
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<serde_json::Value>), 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::<Vec<_>>()))
};
Ok((severity, config))
}
_ => Err(FailedToParseRuleValueError(
value.to_string(),
"Type should be `SeverityConf | [SeverityConf, ...any[]]`",
)
.into()),
}
}
impl Deref for ESLintRules {
type Target = Vec<ESLintRule>;
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());
}
}

View file

@ -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<String>,
pub components: FxHashMap<String, String>,
}
impl JsxA11y {
pub fn new(
polymorphic_prop_name: Option<String>,
components: FxHashMap<String, String>,
) -> Self {
Self { polymorphic_prop_name, components }
}
pub fn set_components(&mut self, components: FxHashMap<String, String>) {
self.components = components;
}
pub fn set_polymorphic_prop_name(&mut self, name: Option<String>) {
self.polymorphic_prop_name = name;
}
}
#[derive(Debug, Clone)]
pub struct Nextjs {
pub root_dir: Vec<String>,
}
impl Nextjs {
pub fn new(root_dir: Vec<String>) -> Self {
Self { root_dir }
}
pub fn set_root_dir(&mut self, root_dir: Vec<String>) {
self.root_dir = root_dir;
}
}
pub type CustomComponents = FxHashMap<String, Vec<String>>;

View file

@ -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<String>,
#[serde(default)]
pub components: FxHashMap<String, String>,
}

View file

@ -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());
}
}

View file

@ -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<String>,
}
impl ESLintSettingsNext {
pub fn get_root_dirs(&self) -> Vec<String> {
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<T> {
One(T),
Many(Vec<T>),
}
impl<T> Default for OneOrMany<T> {
fn default() -> Self {
OneOrMany::Many(Vec::new())
}
}

View file

@ -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<CustomComponent>,
#[serde(default)]
#[serde(rename = "linkComponents")]
link_components: Vec<CustomComponent>,
// TODO: More properties should be added
}
impl ESLintSettingsReact {
pub fn get_form_component_attrs(&self, name: &str) -> Option<Vec<String>> {
get_component_attrs_by_name(&self.form_components, name)
}
pub fn get_link_component_attrs(&self, name: &str) -> Option<Vec<String>> {
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<String>,
},
}
fn get_component_attrs_by_name(
components: &Vec<CustomComponent>,
name: &str,
) -> Option<Vec<String>> {
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
}

View file

@ -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,

View file

@ -107,7 +107,7 @@ impl LintOptions {
#[must_use]
pub fn with_env(mut self, env: Vec<String>) -> Self {
self.env = ESLintEnv::new(env);
self.env = ESLintEnv::from_vec(env);
self
}
}

View file

@ -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#"<Link target="_blank" href={ dynamicLink }></Link>"#,
Some(serde_json::json!([{ "enforceDynamicLinks": "never" }])),
Some(serde_json::json!({ "linkComponents": ["Link"] })),
Some(serde_json::json!({ "react": { "linkComponents": ["Link"] } })),
),
(
r#"<Link target="_blank" to={ dynamicLink }></Link>"#,
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#"<Link target="_blank" to={ dynamicLink }></Link>"#,
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#"<Link target="_blank" href={ dynamicLink }></Link>"#,
Some(serde_json::json!([{ "enforceDynamicLinks": "always"}])),
Some(serde_json::json!({ "linkComponents": ["Link"] })),
Some(serde_json::json!({ "react": { "linkComponents": ["Link"] } })),
),
(
r#"<Link target="_blank" to={ dynamicLink }></Link>"#,
Some(serde_json::json!([{ "enforceDynamicLinks": "always" }])),
Some(
serde_json::json!({ "linkComponents": { "name": "Link", "linkAttribute": "to" } }),
serde_json::json!({ "react": { "linkComponents": [{ "name": "Link", "linkAttribute": "to" }] } }),
),
),
(

View file

@ -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)

View file

@ -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))