diff --git a/crates/oxc_linter/src/rules.rs b/crates/oxc_linter/src/rules.rs index 118cdd62d25ee..f9dc34db7731f 100644 --- a/crates/oxc_linter/src/rules.rs +++ b/crates/oxc_linter/src/rules.rs @@ -281,6 +281,7 @@ mod react { pub mod iframe_missing_sandbox; pub mod jsx_boolean_value; pub mod jsx_curly_brace_presence; + pub mod jsx_filename_extension; pub mod jsx_key; pub mod jsx_no_comment_textnodes; pub mod jsx_no_duplicate_props; @@ -873,6 +874,7 @@ oxc_macros::declare_all_lint_rules! { react::checked_requires_onchange_or_readonly, react::exhaustive_deps, react::iframe_missing_sandbox, + react::jsx_filename_extension, react::jsx_boolean_value, react::jsx_curly_brace_presence, react::jsx_key, diff --git a/crates/oxc_linter/src/rules/react/jsx_filename_extension.rs b/crates/oxc_linter/src/rules/react/jsx_filename_extension.rs new file mode 100644 index 0000000000000..2eb2386d16532 --- /dev/null +++ b/crates/oxc_linter/src/rules/react/jsx_filename_extension.rs @@ -0,0 +1,374 @@ +use oxc_ast::AstKind; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; +use oxc_span::{CompactStr, GetSpan, Span}; +use serde_json::Value; +use std::ffi::OsStr; + +use crate::{context::LintContext, rule::Rule}; + +fn no_jsx_with_filename_extension_diagnostic(ext: &str, span: Span) -> OxcDiagnostic { + // See for details + OxcDiagnostic::warn(format!("JSX not allowed in files with extension '.{ext}'")) + .with_help("Rename the file with a good extension.") + .with_label(span) +} + +fn extension_only_for_jsx_diagnostic(ext: &str) -> OxcDiagnostic { + // See for details + OxcDiagnostic::warn(format!("Only files containing JSX may use the extension '.{ext}'")) + .with_help("Rename the file with a good extension.") +} + +#[derive(Debug, Default, Clone)] +enum AllowType { + #[default] + Always, + AsNeeded, +} + +impl AllowType { + pub fn from(raw: &str) -> Self { + match raw { + "as-needed" => Self::AsNeeded, + _ => Self::Always, + } + } +} + +#[derive(Debug, Default, Clone)] +pub struct JsxFilenameExtension(Box); + +#[derive(Debug, Default, Clone)] +pub struct JsxFilenameExtensionConfig { + allow: AllowType, + extensions: Vec, + ignore_files_without_code: bool, +} + +impl std::ops::Deref for JsxFilenameExtension { + type Target = JsxFilenameExtensionConfig; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +declare_oxc_lint!( + /// ### What it does + /// Enforces consistent use of the JSX file extension. + /// + /// ### Why is this bad? + /// Some bundlers or parsers need to know by the file extension that it contains JSX + /// + /// ### Examples + /// + /// Examples of **incorrect** code for this rule: + /// ```jsx + /// // filename: MyComponent.js + /// function MyComponent() { + /// return
; + /// } + /// ``` + /// + /// Examples of **correct** code for this rule: + /// ```jsx + /// // filename: MyComponent.jsx + /// function MyComponent() { + /// return
; + /// } + /// ``` + /// + /// ### Rule options + /// + /// #### `allow` (default: `"always"`) + /// When to allow a JSX filename extension. By default all files may have a JSX extension. + /// Set this to `as-needed` to only allow JSX file extensions in files that contain JSX syntax. + /// ```js + /// "rules": { + /// "react/jsx-filename-extension": ["error", { "allow": "as-needed" }] + /// } + /// ``` + /// + /// #### `extensions` (default: `[".jsx"]`) + /// The set of allowed extensions is configurable. By default `'.jsx'` is allowed. If you wanted to allow both `'.jsx'` and `'.tsx'`, the configuration would be: + /// ```js + /// "rules": { + /// "react/jsx-filename-extension": ["error", { "extensions": [".jsx", ".tsx"] }] + /// } + /// ``` + /// + /// #### `ignoreFilesWithoutCode` (default: `false`) + /// If enabled, files that do not contain code (i.e. are empty, contain only whitespaces or comments) will not be rejected. + /// ```js + /// "rules": { + /// "react/jsx-filename-extension": ["error", { "ignoreFilesWithoutCode": true }] + /// } + /// ``` + JsxFilenameExtension, + react, + restriction, + pending +); + +impl Rule for JsxFilenameExtension { + fn from_configuration(value: Value) -> Self { + let config = value.get(0); + + let ignore_files_without_code = config + .and_then(|config| config.get("ignoreFilesWithoutCode")) + .and_then(Value::as_bool) + .unwrap_or(false); + + let allow = config + .and_then(|config| config.get("allow")) + .and_then(Value::as_str) + .map(AllowType::from) + .unwrap_or_default(); + + let extensions = config + .and_then(|v| v.get("extensions")) + .and_then(Value::as_array) + .map(|v| { + v.iter() + .filter_map(serde_json::Value::as_str) + .filter(|&s| s.starts_with('.')) + .map(|s| &s[1..]) + .map(CompactStr::from) + .collect() + }) + .unwrap_or(vec![CompactStr::from("jsx")]); + + Self(Box::new(JsxFilenameExtensionConfig { allow, extensions, ignore_files_without_code })) + } + + fn run_once(&self, ctx: &LintContext) { + let file_extension = ctx.file_path().extension().and_then(OsStr::to_str).unwrap_or(""); + let has_ext_allowed = self.extensions.contains(&CompactStr::new(file_extension)); + + if !has_ext_allowed { + if let Some(jsx_elt) = ctx + .nodes() + .iter() + .find(|&&x| matches!(x.kind(), AstKind::JSXElement(_) | AstKind::JSXFragment(_))) + { + ctx.diagnostic(no_jsx_with_filename_extension_diagnostic( + file_extension, + jsx_elt.span(), + )); + } + return; + } + + if matches!(self.allow, AllowType::AsNeeded) { + if self.ignore_files_without_code && ctx.nodes().len() == 1 { + return; + } + if ctx + .nodes() + .iter() + .all(|&x| !matches!(x.kind(), AstKind::JSXElement(_) | AstKind::JSXFragment(_))) + { + ctx.diagnostic(extension_only_for_jsx_diagnostic(file_extension)); + } + } + } +} + +#[test] +fn test() { + use crate::tester::Tester; + use std::path::PathBuf; + + let pass = vec![ + ( + "module.exports = function MyComponent() { return
jsx\n
\n
; }", + None, + None, + Some(PathBuf::from("foo.jsx")), + ), + ( + "export default function MyComponent() { return ;}", + None, + None, + Some(PathBuf::from("foo.jsx")), + ), + ( + "export function MyComponent() { return
;}", + None, + None, + Some(PathBuf::from("foo.jsx")), + ), + ( + "const MyComponent = () => (
); export default MyComponent;", + None, + None, + Some(PathBuf::from("foo.jsx")), + ), + ( + "export function MyComponent() { return
;}", + Some(serde_json::json!([{ "allow": "as-needed" }])), + None, + Some(PathBuf::from("foo.jsx")), + ), + ( + "module.exports = function MyComponent() { return
jsx\n
\n
; }", + Some(serde_json::json!([{ "allow": "as-needed" }])), + None, + Some(PathBuf::from("foo.jsx")), + ), + ( + "module.exports = function MyComponent() { return <>; }", + None, + None, + Some(PathBuf::from("foo.jsx")), + ), + ( + "export function MyComponent() { return <>;}", + None, + None, + Some(PathBuf::from("foo.jsx")), + ), + ( + "export function MyComponent() { return <>;}", + Some(serde_json::json!([{ "allow": "as-needed" }])), + None, + Some(PathBuf::from("foo.jsx")), + ), + ( + "module.exports = function MyComponent() { return <>; }", + Some(serde_json::json!([{ "allow": "as-needed" }])), + None, + Some(PathBuf::from("foo.jsx")), + ), + ("module.exports = {}", None, None, Some(PathBuf::from("foo.js"))), + ("export const foo = () => 'foo';", None, None, Some(PathBuf::from("foo.js"))), + ( + "module.exports = {}", + Some(serde_json::json!([{ "allow": "as-needed" }])), + None, + Some(PathBuf::from("foo.js")), + ), + ("module.exports = {}", None, None, Some(PathBuf::from("foo.jsx"))), + ( + "module.exports = function MyComponent() { return
jsx\n
\n
; }", + Some(serde_json::json!([{ "extensions": [".js", ".jsx"] }])), + None, + Some(PathBuf::from("foo.js")), + ), + ( + "export function MyComponent() { return
;}", + Some(serde_json::json!([{ "extensions": [".js", ".jsx"] }])), + None, + Some(PathBuf::from("foo.js")), + ), + ( + "module.exports = function MyComponent() { return <>; }", + Some(serde_json::json!([{ "extensions": [".js", ".jsx"] }])), + None, + Some(PathBuf::from("foo.js")), + ), + ( + "export function MyComponent() { return <>;}", + Some(serde_json::json!([{ "extensions": [".js", ".jsx"] }])), + None, + Some(PathBuf::from("foo.js")), + ), + ( + "//test\n\n//comment", + Some(serde_json::json!([{ "allow": "as-needed" }])), + None, + Some(PathBuf::from("foo.js")), + ), + ( + "//test\n\n//comment", + Some(serde_json::json!([{ "allow": "as-needed", "ignoreFilesWithoutCode": true }])), + None, + Some(PathBuf::from("foo.jsx")), + ), + ( + "", + Some(serde_json::json!([{ "allow": "as-needed", "ignoreFilesWithoutCode": true }])), + None, + Some(PathBuf::from("foo.jsx")), + ), + ]; + + let fail = vec![ + ( + "module.exports = function MyComponent() { return
\n
\n
; }", + None, + None, + Some(PathBuf::from("foo.js")), + ), + ( + "export default function MyComponent() { return ;}", + None, + None, + Some(PathBuf::from("foo.js")), + ), + ( + "export function MyComponent() { return
;}", + None, + None, + Some(PathBuf::from("foo.js")), + ), + ( + "const MyComponent = () => (
); export default MyComponent;", + None, + None, + Some(PathBuf::from("foo.js")), + ), + ( + "module.exports = {}", + Some(serde_json::json!([{ "allow": "as-needed" }])), + None, + Some(PathBuf::from("foo.jsx")), + ), + ( + "export function foo() { return 'foo'; }", + Some(serde_json::json!([{ "allow": "as-needed" }])), + None, + Some(PathBuf::from("foo.jsx")), + ), + ( + "module.exports = function MyComponent() { return
\n
\n
; }", + Some(serde_json::json!([{ "allow": "as-needed" }])), + None, + Some(PathBuf::from("foo.js")), + ), + ( + "module.exports = function MyComponent() { return
\n
\n
; }", + Some(serde_json::json!([{ "extensions": [".js"] }])), + None, + Some(PathBuf::from("foo.jsx")), + ), + ( + "export function MyComponent() { return <>;}", + None, + None, + Some(PathBuf::from("foo.js")), + ), + ( + "module.exports = function MyComponent() { return <>; }", + None, + None, + Some(PathBuf::from("foo.js")), + ), + ( + "export function MyComponent() { return <>;}", + Some(serde_json::json!([{ "extensions": [".js"] }])), + None, + Some(PathBuf::from("foo.jsx")), + ), + ( + "module.exports = function MyComponent() { return <>; }", + Some(serde_json::json!([{ "extensions": [".js"] }])), + None, + Some(PathBuf::from("foo.jsx")), + ), + ]; + + Tester::new(JsxFilenameExtension::NAME, JsxFilenameExtension::PLUGIN, pass, fail) + .test_and_snapshot(); +} diff --git a/crates/oxc_linter/src/snapshots/react_jsx_filename_extension.snap b/crates/oxc_linter/src/snapshots/react_jsx_filename_extension.snap new file mode 100644 index 0000000000000..7c94f014cf704 --- /dev/null +++ b/crates/oxc_linter/src/snapshots/react_jsx_filename_extension.snap @@ -0,0 +1,81 @@ +--- +source: crates/oxc_linter/src/tester.rs +--- + ⚠ eslint-plugin-react(jsx-filename-extension): JSX not allowed in files with extension '.js' + ╭─[jsx_filename_extension.tsx:1:50] + 1 │ ╭─▶ module.exports = function MyComponent() { return
+ 2 │ │
+ 3 │ ╰─▶
; } + ╰──── + help: Rename the file with a good extension. + + ⚠ eslint-plugin-react(jsx-filename-extension): JSX not allowed in files with extension '.js' + ╭─[jsx_filename_extension.tsx:1:48] + 1 │ export default function MyComponent() { return ;} + · ──────── + ╰──── + help: Rename the file with a good extension. + + ⚠ eslint-plugin-react(jsx-filename-extension): JSX not allowed in files with extension '.js' + ╭─[jsx_filename_extension.tsx:1:40] + 1 │ export function MyComponent() { return
;} + · ─────────────────── + ╰──── + help: Rename the file with a good extension. + + ⚠ eslint-plugin-react(jsx-filename-extension): JSX not allowed in files with extension '.js' + ╭─[jsx_filename_extension.tsx:1:28] + 1 │ const MyComponent = () => (
); export default MyComponent; + · ─────────────────── + ╰──── + help: Rename the file with a good extension. + + ⚠ eslint-plugin-react(jsx-filename-extension): Only files containing JSX may use the extension '.jsx' + help: Rename the file with a good extension. + + ⚠ eslint-plugin-react(jsx-filename-extension): Only files containing JSX may use the extension '.jsx' + help: Rename the file with a good extension. + + ⚠ eslint-plugin-react(jsx-filename-extension): JSX not allowed in files with extension '.js' + ╭─[jsx_filename_extension.tsx:1:50] + 1 │ ╭─▶ module.exports = function MyComponent() { return
+ 2 │ │
+ 3 │ ╰─▶
; } + ╰──── + help: Rename the file with a good extension. + + ⚠ eslint-plugin-react(jsx-filename-extension): JSX not allowed in files with extension '.jsx' + ╭─[jsx_filename_extension.tsx:1:50] + 1 │ ╭─▶ module.exports = function MyComponent() { return
+ 2 │ │
+ 3 │ ╰─▶
; } + ╰──── + help: Rename the file with a good extension. + + ⚠ eslint-plugin-react(jsx-filename-extension): JSX not allowed in files with extension '.js' + ╭─[jsx_filename_extension.tsx:1:40] + 1 │ export function MyComponent() { return <>;} + · ───────────────────── + ╰──── + help: Rename the file with a good extension. + + ⚠ eslint-plugin-react(jsx-filename-extension): JSX not allowed in files with extension '.js' + ╭─[jsx_filename_extension.tsx:1:50] + 1 │ module.exports = function MyComponent() { return <>; } + · ───────────────────── + ╰──── + help: Rename the file with a good extension. + + ⚠ eslint-plugin-react(jsx-filename-extension): JSX not allowed in files with extension '.jsx' + ╭─[jsx_filename_extension.tsx:1:40] + 1 │ export function MyComponent() { return <>;} + · ───────────────────── + ╰──── + help: Rename the file with a good extension. + + ⚠ eslint-plugin-react(jsx-filename-extension): JSX not allowed in files with extension '.jsx' + ╭─[jsx_filename_extension.tsx:1:50] + 1 │ module.exports = function MyComponent() { return <>; } + · ───────────────────── + ╰──── + help: Rename the file with a good extension.