-
-
Notifications
You must be signed in to change notification settings - Fork 774
feat(linter): add react/jsx-filename-extension rule #9474
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
Merged
Boshen
merged 10 commits into
oxc-project:main
from
cdrikd:feature/lintRule-jsx_filename_extension
Mar 10, 2025
Merged
Changes from 1 commit
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
110952a
feat(linter): add react/jsx-filename-extension rule
cdrikd 86f380a
[autofix.ci] apply automated fixes
autofix-ci[bot] ee7e063
refactor: some change suggested by lint
cdrikd 108128a
refactor: optimize config
cdrikd 0112ee2
refactor: update crates/oxc_linter/src/rules/react/jsx_filename_exten…
cdrikd c1e4909
refactor: avoid using unreachable and add test
cdrikd a9b86aa
[autofix.ci] apply automated fixes
autofix-ci[bot] d3e2c2f
refactor: remove type
cdrikd c0d3550
refactor: improve iter on nodes
cdrikd acc18b8
[autofix.ci] apply automated fixes
autofix-ci[bot] File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next
Next commit
feat(linter): add react/jsx-filename-extension rule
- Loading branch information
commit 110952a4f48436325a9de0db9559431b16585bfe
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
277 changes: 277 additions & 0 deletions
277
crates/oxc_linter/src/rules/react/jsx_filename_extension.rs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,277 @@ | ||
| 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 <https://oxc.rs/docs/contribute/linter/adding-rules.html#diagnostics> 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 <https://oxc.rs/docs/contribute/linter/adding-rules.html#diagnostics> 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 { | ||
| allow: AllowType, | ||
| extensions: Vec<CompactStr>, | ||
| ignore_files_without_code: bool, | ||
| } | ||
|
|
||
| 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 <div />; | ||
| /// } | ||
| /// ``` | ||
| /// | ||
| /// Examples of **correct** code for this rule: | ||
| /// ```jsx | ||
| /// // filename: MyComponent.jsx | ||
| /// function MyComponent() { | ||
| /// return <div />; | ||
| /// } | ||
| /// ``` | ||
| /// | ||
| /// ### 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 { allow, extensions, ignore_files_without_code } | ||
| } | ||
|
|
||
| fn run_once(&self, ctx: &LintContext) { | ||
| let jsx_elt = ctx.nodes().iter().find(|&&x| match x.kind() { | ||
| AstKind::JSXElement(_) | AstKind::JSXFragment(_) => true, | ||
| _ => false, | ||
| }); | ||
| 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 jsx_elt.is_some() { | ||
| if !has_ext_allowed { | ||
| let span_elt = jsx_elt.map(|elt| elt.span()); | ||
| ctx.diagnostic(no_jsx_with_filename_extension_diagnostic( | ||
| &file_extension, | ||
| span_elt.unwrap(), | ||
| )); | ||
| } | ||
| return; | ||
| } | ||
|
|
||
| if matches!(self.allow, AllowType::AsNeeded) && has_ext_allowed { | ||
| let Some(root) = ctx.nodes().root_node() else { | ||
| return; | ||
| }; | ||
| let AstKind::Program(program) = root.kind() else { unreachable!() }; | ||
| if self.ignore_files_without_code && program.body.is_empty() { | ||
| return; | ||
| } | ||
| 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 <div>jsx\n<div />\n</div>; }", | ||
Boshen marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| None, | ||
| None, | ||
| Some(PathBuf::from("foo.jsx")), | ||
| ), | ||
| ( | ||
| "module.exports = function MyComponent() { return <div>jsx\n<div />\n</div>; }", | ||
| Some(serde_json::json!([{ "allow": "as-needed" }])), | ||
| None, | ||
| Some(PathBuf::from("foo.jsx")), | ||
| ), | ||
| ( | ||
| "module.exports = function MyComponent() { return <>fragment\n</>; }", | ||
| None, | ||
| None, | ||
| Some(PathBuf::from("foo.jsx")), | ||
| ), | ||
| ( | ||
| "module.exports = function MyComponent() { return <>fragment\n</>; }", | ||
| Some(serde_json::json!([{ "allow": "as-needed" }])), | ||
| None, | ||
| Some(PathBuf::from("foo.jsx")), | ||
| ), | ||
| ("module.exports = {}", 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 <div>jsx\n<div />\n</div>; }", | ||
| Some(serde_json::json!([{ "extensions": [".js", ".jsx"] }])), | ||
| None, | ||
| Some(PathBuf::from("foo.js")), | ||
| ), | ||
| ( | ||
| "module.exports = function MyComponent() { return <>fragment\n</>; }", | ||
| 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 <div>\n<div />\n</div>; }", | ||
| None, | ||
| None, | ||
| Some(PathBuf::from("foo.js")), | ||
| ), | ||
| ( | ||
| "module.exports = {}", | ||
| Some(serde_json::json!([{ "allow": "as-needed" }])), | ||
| None, | ||
| Some(PathBuf::from("foo.jsx")), | ||
| ), | ||
| ( | ||
| "module.exports = function MyComponent() { return <div>\n<div />\n</div>; }", | ||
| Some(serde_json::json!([{ "allow": "as-needed" }])), | ||
| None, | ||
| Some(PathBuf::from("foo.js")), | ||
| ), | ||
| ( | ||
| "module.exports = function MyComponent() { return <div>\n<div />\n</div>; }", | ||
| Some(serde_json::json!([{ "extensions": [".js"] }])), | ||
| None, | ||
| Some(PathBuf::from("foo.jsx")), | ||
| ), | ||
| ( | ||
| "module.exports = function MyComponent() { return <>fragment\n</>; }", | ||
| None, | ||
| None, | ||
| Some(PathBuf::from("foo.js")), | ||
| ), | ||
| ( | ||
| "module.exports = function MyComponent() { return <>fragment\n</>; }", | ||
| Some(serde_json::json!([{ "extensions": [".js"] }])), | ||
| None, | ||
| Some(PathBuf::from("foo.jsx")), | ||
| ), | ||
| ]; | ||
|
|
||
| Tester::new(JsxFilenameExtension::NAME, JsxFilenameExtension::PLUGIN, pass, fail) | ||
| .test_and_snapshot(); | ||
| } | ||
43 changes: 43 additions & 0 deletions
43
crates/oxc_linter/src/snapshots/react_jsx_filename_extension.snap
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| --- | ||
| 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 <div> | ||
| 2 │ │ <div /> | ||
| 3 │ ╰─▶ </div>; } | ||
| ╰──── | ||
| 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 <div> | ||
| 2 │ │ <div /> | ||
| 3 │ ╰─▶ </div>; } | ||
| ╰──── | ||
| 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 <div> | ||
| 2 │ │ <div /> | ||
| 3 │ ╰─▶ </div>; } | ||
| ╰──── | ||
| 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 <>fragment | ||
| 2 │ ╰─▶ </>; } | ||
| ╰──── | ||
| 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 <>fragment | ||
| 2 │ ╰─▶ </>; } | ||
| ╰──── | ||
| help: Rename the file with a good extension. |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.