diff --git a/CHANGELOG.md b/CHANGELOG.md index b1dffab041a3..f4db3d90a267 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,7 @@ our [guidelines for writing a good changelog entry](https://github.com/biomejs/b #### New features - Add [nursery/noMissingVarFunction](https://biomejs.dev/linter/rules/no-missing-var-function). Contributed by @michellocana +- Add [nursery/useComponentExportOnlyModules]((https://biomejs.dev/linter/rules/use-component-export-only-modules). Use this rule in React projects to enforce a code styling that fits React Refresh. Contributed by @GunseiKPaseri #### Bug fixes diff --git a/crates/biome_analyze/src/rule.rs b/crates/biome_analyze/src/rule.rs index 6b5b027ec0b4..4e7b3cc83588 100644 --- a/crates/biome_analyze/src/rule.rs +++ b/crates/biome_analyze/src/rule.rs @@ -106,6 +106,8 @@ pub enum RuleSource { EslintReact(&'static str), /// Rules from [Eslint Plugin React Hooks](https://github.com/facebook/react/blob/main/packages/eslint-plugin-react-hooks/README.md) EslintReactHooks(&'static str), + /// Rules from [Eslint Plugin React Refresh](https://github.com/ArnaudBarre/eslint-plugin-react-refresh) + EslintReactRefresh(&'static str), /// Rules from [Eslint Plugin Solid](https://github.com/solidjs-community/eslint-plugin-solid) EslintSolid(&'static str), /// Rules from [Eslint Plugin Sonar](https://github.com/SonarSource/eslint-plugin-sonarjs) @@ -146,6 +148,7 @@ impl std::fmt::Display for RuleSource { Self::EslintJsxA11y(_) => write!(f, "eslint-plugin-jsx-a11y"), Self::EslintReact(_) => write!(f, "eslint-plugin-react"), Self::EslintReactHooks(_) => write!(f, "eslint-plugin-react-hooks"), + Self::EslintReactRefresh(_) => write!(f, "eslint-plugin-react-refresh"), Self::EslintSolid(_) => write!(f, "eslint-plugin-solid"), Self::EslintSonarJs(_) => write!(f, "eslint-plugin-sonarjs"), Self::EslintStylistic(_) => write!(f, "eslint-plugin-stylistic"), @@ -194,6 +197,7 @@ impl RuleSource { | Self::EslintJsxA11y(rule_name) | Self::EslintReact(rule_name) | Self::EslintReactHooks(rule_name) + | Self::EslintReactRefresh(rule_name) | Self::EslintTypeScript(rule_name) | Self::EslintSolid(rule_name) | Self::EslintSonarJs(rule_name) @@ -217,6 +221,7 @@ impl RuleSource { Self::EslintJsxA11y(rule_name) => format!("jsx-a11y/{rule_name}"), Self::EslintReact(rule_name) => format!("react/{rule_name}"), Self::EslintReactHooks(rule_name) => format!("react-hooks/{rule_name}"), + Self::EslintReactRefresh(rule_name) => format!("react-refresh/{rule_name}"), Self::EslintTypeScript(rule_name) => format!("@typescript-eslint/{rule_name}"), Self::EslintSolid(rule_name) => format!("solidjs/{rule_name}"), Self::EslintSonarJs(rule_name) => format!("sonarjs/{rule_name}"), @@ -241,6 +246,7 @@ impl RuleSource { Self::EslintJsxA11y(rule_name) => format!("https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/docs/rules/{rule_name}.md"), Self::EslintReact(rule_name) => format!("https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/{rule_name}.md"), Self::EslintReactHooks(_) => "https://github.com/facebook/react/blob/main/packages/eslint-plugin-react-hooks/README.md".to_string(), + Self::EslintReactRefresh(_) => "https://github.com/ArnaudBarre/eslint-plugin-react-refresh".to_string(), Self::EslintTypeScript(rule_name) => format!("https://typescript-eslint.io/rules/{rule_name}"), Self::EslintSolid(rule_name) => format!("https://github.com/solidjs-community/eslint-plugin-solid/blob/main/packages/eslint-plugin-solid/docs/{rule_name}.md"), Self::EslintSonarJs(rule_name) => format!("https://github.com/SonarSource/eslint-plugin-sonarjs/blob/HEAD/docs/rules/{rule_name}.md"), diff --git a/crates/biome_cli/src/execute/migrate/eslint_any_rule_to_biome.rs b/crates/biome_cli/src/execute/migrate/eslint_any_rule_to_biome.rs index 69cc5880e960..c0a597546cdb 100644 --- a/crates/biome_cli/src/execute/migrate/eslint_any_rule_to_biome.rs +++ b/crates/biome_cli/src/execute/migrate/eslint_any_rule_to_biome.rs @@ -1276,6 +1276,20 @@ pub(crate) fn migrate_eslint_any_rule( .get_or_insert(Default::default()); rule.set_level(rule_severity.into()); } + "react-refresh/only-export-components" => { + if !options.include_inspired { + results.has_inspired_rules = true; + return false; + } + if !options.include_nursery { + return false; + } + let group = rules.nursery.get_or_insert_with(Default::default); + let rule = group + .use_component_export_only_modules + .get_or_insert(Default::default()); + rule.set_level(rule_severity.into()); + } "react/button-has-type" => { let group = rules.a11y.get_or_insert_with(Default::default); let rule = group.use_button_type.get_or_insert(Default::default()); diff --git a/crates/biome_configuration/src/analyzer/linter/rules.rs b/crates/biome_configuration/src/analyzer/linter/rules.rs index ef6fb0a4670e..a5b5e4ff2230 100644 --- a/crates/biome_configuration/src/analyzer/linter/rules.rs +++ b/crates/biome_configuration/src/analyzer/linter/rules.rs @@ -3349,6 +3349,10 @@ pub struct Nursery { #[serde(skip_serializing_if = "Option::is_none")] pub use_aria_props_supported_by_role: Option>, + #[doc = "Enforce declaring components only within modules that export React Components exclusively."] + #[serde(skip_serializing_if = "Option::is_none")] + pub use_component_export_only_modules: + Option>, #[doc = "This rule enforces consistent use of curly braces inside JSX attributes and JSX children."] #[serde(skip_serializing_if = "Option::is_none")] pub use_consistent_curly_braces: @@ -3419,6 +3423,7 @@ impl Nursery { "noValueAtRule", "useAdjacentOverloadSignatures", "useAriaPropsSupportedByRole", + "useComponentExportOnlyModules", "useConsistentCurlyBraces", "useConsistentMemberAccessibility", "useDeprecatedReason", @@ -3450,9 +3455,9 @@ impl Nursery { RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[16]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[17]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[20]), - RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[22]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[23]), - RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[26]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[24]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[27]), ]; const ALL_RULES_AS_FILTERS: &'static [RuleFilter<'static>] = &[ RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[0]), @@ -3484,6 +3489,7 @@ impl Nursery { RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[26]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[27]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[28]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[29]), ]; #[doc = r" Retrieves the recommended rules"] pub(crate) fn is_recommended_true(&self) -> bool { @@ -3605,46 +3611,51 @@ impl Nursery { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[20])); } } - if let Some(rule) = self.use_consistent_curly_braces.as_ref() { + if let Some(rule) = self.use_component_export_only_modules.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[21])); } } - if let Some(rule) = self.use_consistent_member_accessibility.as_ref() { + if let Some(rule) = self.use_consistent_curly_braces.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[22])); } } - if let Some(rule) = self.use_deprecated_reason.as_ref() { + if let Some(rule) = self.use_consistent_member_accessibility.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[23])); } } - if let Some(rule) = self.use_import_restrictions.as_ref() { + if let Some(rule) = self.use_deprecated_reason.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[24])); } } - if let Some(rule) = self.use_sorted_classes.as_ref() { + if let Some(rule) = self.use_import_restrictions.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[25])); } } - if let Some(rule) = self.use_strict_mode.as_ref() { + if let Some(rule) = self.use_sorted_classes.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[26])); } } - if let Some(rule) = self.use_trim_start_end.as_ref() { + if let Some(rule) = self.use_strict_mode.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[27])); } } - if let Some(rule) = self.use_valid_autocomplete.as_ref() { + if let Some(rule) = self.use_trim_start_end.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[28])); } } + if let Some(rule) = self.use_valid_autocomplete.as_ref() { + if rule.is_enabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[29])); + } + } index_set } pub(crate) fn get_disabled_rules(&self) -> FxHashSet> { @@ -3754,46 +3765,51 @@ impl Nursery { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[20])); } } - if let Some(rule) = self.use_consistent_curly_braces.as_ref() { + if let Some(rule) = self.use_component_export_only_modules.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[21])); } } - if let Some(rule) = self.use_consistent_member_accessibility.as_ref() { + if let Some(rule) = self.use_consistent_curly_braces.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[22])); } } - if let Some(rule) = self.use_deprecated_reason.as_ref() { + if let Some(rule) = self.use_consistent_member_accessibility.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[23])); } } - if let Some(rule) = self.use_import_restrictions.as_ref() { + if let Some(rule) = self.use_deprecated_reason.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[24])); } } - if let Some(rule) = self.use_sorted_classes.as_ref() { + if let Some(rule) = self.use_import_restrictions.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[25])); } } - if let Some(rule) = self.use_strict_mode.as_ref() { + if let Some(rule) = self.use_sorted_classes.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[26])); } } - if let Some(rule) = self.use_trim_start_end.as_ref() { + if let Some(rule) = self.use_strict_mode.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[27])); } } - if let Some(rule) = self.use_valid_autocomplete.as_ref() { + if let Some(rule) = self.use_trim_start_end.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[28])); } } + if let Some(rule) = self.use_valid_autocomplete.as_ref() { + if rule.is_disabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[29])); + } + } index_set } #[doc = r" Checks if, given a rule name, matches one of the rules contained in this category"] @@ -3914,6 +3930,10 @@ impl Nursery { .use_aria_props_supported_by_role .as_ref() .map(|conf| (conf.level(), conf.get_options())), + "useComponentExportOnlyModules" => self + .use_component_export_only_modules + .as_ref() + .map(|conf| (conf.level(), conf.get_options())), "useConsistentCurlyBraces" => self .use_consistent_curly_braces .as_ref() diff --git a/crates/biome_diagnostics_categories/src/categories.rs b/crates/biome_diagnostics_categories/src/categories.rs index 4314e4cdcd2a..9db744c27607 100644 --- a/crates/biome_diagnostics_categories/src/categories.rs +++ b/crates/biome_diagnostics_categories/src/categories.rs @@ -175,6 +175,7 @@ define_categories! { "lint/nursery/useAdjacentOverloadSignatures": "https://biomejs.dev/linter/rules/use-adjacent-overload-signatures", "lint/nursery/useAriaPropsSupportedByRole": "https://biomejs.dev/linter/rules/use-aria-props-supported-by-role", "lint/nursery/useBiomeSuppressionComment": "https://biomejs.dev/linter/rules/use-biome-suppression-comment", + "lint/nursery/useComponentExportOnlyModules": "https://biomejs.dev/linter/rules/use-components-only-module", "lint/nursery/useConsistentCurlyBraces": "https://biomejs.dev/linter/rules/use-consistent-curly-braces", "lint/nursery/useConsistentMemberAccessibility": "https://biomejs.dev/linter/rules/use-consistent-member-accessibility", "lint/nursery/useDeprecatedReason": "https://biomejs.dev/linter/rules/use-deprecated-reason", diff --git a/crates/biome_js_analyze/src/lint/nursery.rs b/crates/biome_js_analyze/src/lint/nursery.rs index e96f7803f12f..cc1fd401314f 100644 --- a/crates/biome_js_analyze/src/lint/nursery.rs +++ b/crates/biome_js_analyze/src/lint/nursery.rs @@ -17,6 +17,7 @@ pub mod no_substr; pub mod no_useless_escape_in_regex; pub mod use_adjacent_overload_signatures; pub mod use_aria_props_supported_by_role; +pub mod use_component_export_only_modules; pub mod use_consistent_curly_braces; pub mod use_consistent_member_accessibility; pub mod use_import_restrictions; @@ -44,6 +45,7 @@ declare_lint_group! { self :: no_useless_escape_in_regex :: NoUselessEscapeInRegex , self :: use_adjacent_overload_signatures :: UseAdjacentOverloadSignatures , self :: use_aria_props_supported_by_role :: UseAriaPropsSupportedByRole , + self :: use_component_export_only_modules :: UseComponentExportOnlyModules , self :: use_consistent_curly_braces :: UseConsistentCurlyBraces , self :: use_consistent_member_accessibility :: UseConsistentMemberAccessibility , self :: use_import_restrictions :: UseImportRestrictions , diff --git a/crates/biome_js_analyze/src/lint/nursery/use_component_export_only_modules.rs b/crates/biome_js_analyze/src/lint/nursery/use_component_export_only_modules.rs new file mode 100644 index 000000000000..e94c1e7f841c --- /dev/null +++ b/crates/biome_js_analyze/src/lint/nursery/use_component_export_only_modules.rs @@ -0,0 +1,327 @@ +use biome_analyze::{ + context::RuleContext, declare_lint_rule, Ast, Rule, RuleDiagnostic, RuleSource, RuleSourceKind, +}; +use biome_console::markup; +use biome_deserialize_macros::Deserializable; +use biome_js_syntax::{ + export_ext::{AnyJsExported, ExportedItem}, + AnyJsBindingPattern, AnyJsCallArgument, AnyJsExpression, AnyJsModuleItem, AnyJsStatement, + JsModule, +}; +use biome_rowan::{AstNode, TextRange}; +use biome_string_case::Case; +use serde::{Deserialize, Serialize}; + +declare_lint_rule! { + /// Enforce declaring components only within modules that export React Components exclusively. + /// + /// This is necessary to enable the [`React Fast Refresh`] feature, which improves development efficiency. + /// The determination of whether something is a component depends on naming conventions. + /// Components should be written in [`PascalCase`] and regular functions in [`camelCase`]. + /// If the framework already has established conventions, consider optionally specifying exceptions. + /// + /// ## Examples + /// + /// ### Invalid + /// + /// ```jsx,expect_diagnostic + /// export const foo = () => {}; + /// export const Bar = () => <>; + /// ``` + /// + /// ```jsx,expect_diagnostic + /// const Tab = () => {}; + /// export const tabs = [, ]; + /// ``` + /// + /// ```jsx,expect_diagnostic + /// const App = () => {} + /// createRoot(document.getElementById("root")).render(); + /// ``` + /// + /// ### Valid + /// + /// ```jsx + /// export default function Foo() { + /// return <>; + /// } + /// ``` + /// + /// ```jsx + /// const foo = () => {}; + /// export const Bar = () => <>; + /// ``` + /// + /// ```jsx + /// import { App } from "./App"; + /// createRoot(document.getElementById("root")).render(); + /// ``` + /// + /// Functions that return standard React components are also permitted. + /// + /// ```jsx + /// import { memo } from 'react'; + /// const Component = () => <> + /// export default memo(Component); + /// ``` + /// + /// ## Options + /// + /// ### `allowConstantExport` + /// + /// Some tools, such as [Vite], allow exporting constants along with components. By enabling the following, the rule will support the pattern. + /// + /// ```json + /// { + /// "//": "...", + /// "options":{ + /// "allowConstantExport" : true + /// } + /// } + /// ``` + /// + /// ### `allowExportNames` + /// + /// If you use a framework that handles [Hot Mudule Replacement(HMR)] of some specific exports, you can use this option to avoid warning for them. + /// + /// Example for [Remix](https://remix.run/docs/en/main/discussion/hot-module-replacement#supported-exports): + /// ```json + /// { + /// "//": "...", + /// "options":{ + /// "allowExportNames": ["json", "loader", "headers", "meta", "links", "scripts"] + /// } + /// } + /// ``` + /// + /// [`meta` in Remix]: https://remix.run/docs/en/main/route/meta + /// [Hot Mudule Replacement(HMR)]: https://remix.run/docs/en/main/discussion/hot-module-replacement + /// [`React Fast Refresh`]: https://github.com/facebook/react/tree/main/packages/react-refresh + /// [Remix]: https://remix.run/ + /// [Vite]: https://vitejs.dev/ + /// [`camelCase`]: https://en.wikipedia.org/wiki/Camel_case + /// [`PascalCase`]: https://en.wikipedia.org/wiki/Camel_case + pub UseComponentExportOnlyModules { + version: "next", + name: "useComponentExportOnlyModules", + language: "jsx", + sources: &[RuleSource::EslintReactRefresh("only-export-components")], + source_kind: RuleSourceKind::Inspired, + recommended: false, + } +} + +#[derive(Debug, Clone, Deserialize, Deserializable, Eq, PartialEq, Serialize, Default)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct UseComponentExportOnlyModulesOptions { + /// Allows the export of constants. This option is for environments that support it, such as [Vite](https://vitejs.dev/) + #[serde(default)] + allow_constant_export: bool, + /// A list of names that can be additionally exported from the module This option is for exports that do not hinder [React Fast Refresh](https://github.com/facebook/react/tree/main/packages/react-refresh), such as [`meta` in Remix](https://remix.run/docs/en/main/route/meta) + #[serde(default, skip_serializing_if = "Vec::is_empty")] + allow_export_names: Vec, +} + +enum ErrorType { + ExportedNonComponentWithComponent, + UnexportedComponent, + NoExport, +} + +pub struct UseComponentExportOnlyModulesState { + error: ErrorType, + range: TextRange, +} + +const JSX_FILE_EXT: [&str; 2] = [".jsx", ".tsx"]; + +impl Rule for UseComponentExportOnlyModules { + type Query = Ast; + type State = UseComponentExportOnlyModulesState; + type Signals = Vec; + type Options = UseComponentExportOnlyModulesOptions; + + fn run(ctx: &RuleContext) -> Self::Signals { + if let Some(file_name) = ctx.file_path().file_name().and_then(|x| x.to_str()) { + if !JSX_FILE_EXT.iter().any(|ext| file_name.ends_with(ext)) { + return vec![]; + } + } + let root = ctx.query(); + let mut local_declaration_ids = Vec::new(); + let mut exported_component_ids = Vec::new(); + let mut exported_non_component_ids = Vec::new(); + for item in root.items() { + if let AnyJsModuleItem::AnyJsStatement(stmt) = item { + // Explore unexported component declarations + if let AnyJsStatement::JsVariableStatement(vstmt) = stmt { + if let Ok(vdec) = vstmt.declaration() { + for vdeclator in vdec.declarators().into_iter().flatten() { + if let Ok(id) = vdeclator.id() { + local_declaration_ids.push(id) + } + } + } + } else if let AnyJsStatement::JsFunctionDeclaration(func) = stmt { + if let Ok(id) = func.id() { + local_declaration_ids.push(AnyJsBindingPattern::AnyJsBinding(id)); + } + } + } else if let AnyJsModuleItem::JsExport(export) = item { + // Explore exported component declarations + for exported_item in export.get_exported_items() { + if let Some(AnyJsExported::AnyTsType(_)) = exported_item.exported { + continue; + } + // Allow exporting specific names + if let Some(exported_item_id) = &exported_item.identifier { + if ctx + .options() + .allow_export_names + .contains(&exported_item_id.text()) + { + continue; + } + } + // Allow exporting constants along with components + if ctx.options().allow_constant_export + && exported_item + .exported + .clone() + .is_some_and(|partof| match partof { + AnyJsExported::AnyJsExpression(expr) => { + expr.is_literal_expression() + } + _ => false, + }) + { + continue; + } + if is_exported_react_component(&exported_item) { + exported_component_ids.push(exported_item); + } else { + exported_non_component_ids.push(exported_item); + } + } + } + } + + let local_component_ids = local_declaration_ids.iter().filter_map(|id| { + if Case::identify(&id.text(), false) == Case::Pascal { + Some(id.range()) + } else { + None + } + }); + + if !exported_component_ids.is_empty() { + return exported_non_component_ids + .iter() + .filter_map(|id| { + let range = id.identifier.as_ref().map_or_else( + || id.exported.as_ref().map(|exported| exported.range()), + |identifier| Some(identifier.range()), + ); + range.map(|range| UseComponentExportOnlyModulesState { + error: ErrorType::ExportedNonComponentWithComponent, + range, + }) + }) + .collect(); + } + + local_component_ids + .map(|id| UseComponentExportOnlyModulesState { + error: if exported_non_component_ids.is_empty() { + ErrorType::UnexportedComponent + } else { + ErrorType::NoExport + }, + range: id, + }) + .collect::>() + } + + fn diagnostic(_ctx: &RuleContext, state: &Self::State) -> Option { + let (message, suggestion, error_item) = match state.error { + ErrorType::ExportedNonComponentWithComponent => ( + "Exporting a non-component with components is not allowed.", + "Consider separating non-component exports into a new file.", + "a component", + ), + ErrorType::UnexportedComponent => ( + "Unexported components are not allowed.", + "Consider separating component exports into a new file.", + "not a component", + ), + ErrorType::NoExport => ( + "Components should be exported.", + "Consider separating component exports into a new file.", + "not a component", + ), + }; + + Some( + RuleDiagnostic::new( + rule_category!(), + state.range, + markup! { + {message} + }, + ) + .note(markup! { + "Fast Refresh"" only works when a file only exports components." + }) + .note(markup! { + {suggestion} + }) + .note(markup! { + "If it is "{error_item}", it may not be following the variable naming conventions." + }), + ) + } +} + +// Function that returns a standard React component +const REACT_HOOKS: [&str; 2] = ["memo", "forwardRef"]; + +fn is_exported_react_component(any_exported_item: &ExportedItem) -> bool { + if let Some(AnyJsExported::AnyJsExpression(AnyJsExpression::JsCallExpression(f))) = + any_exported_item.exported.clone() + { + if let Ok(AnyJsExpression::JsIdentifierExpression(fn_name)) = f.callee() { + if !REACT_HOOKS.contains(&fn_name.text().as_str()) { + return false; + } + let Ok(args) = f.arguments() else { + return false; + }; + let itr = args + .args() + .into_iter() + .filter_map(Result::ok) + .collect::>(); + if itr.len() != 1 { + return false; + } + let AnyJsCallArgument::AnyJsExpression(AnyJsExpression::JsIdentifierExpression(arg)) = + &itr[0] + else { + return false; + }; + let Ok(arg_name) = arg.name() else { + return false; + }; + return Case::identify(&arg_name.text(), false) == Case::Pascal; + } + } + let Some(exported_item_id) = any_exported_item.identifier.clone() else { + return false; + }; + Case::identify(&exported_item_id.text(), false) == Case::Pascal + && match any_exported_item.exported.clone() { + Some(exported) => !matches!(exported, AnyJsExported::TsEnumDeclaration(_)), + None => true, + } +} diff --git a/crates/biome_js_analyze/src/lint/style/no_yoda_expression.rs b/crates/biome_js_analyze/src/lint/style/no_yoda_expression.rs index 48702e82c46b..1a23204a2cc8 100644 --- a/crates/biome_js_analyze/src/lint/style/no_yoda_expression.rs +++ b/crates/biome_js_analyze/src/lint/style/no_yoda_expression.rs @@ -7,9 +7,9 @@ use biome_console::markup; use biome_diagnostics::Applicability; use biome_js_factory::make::{self, js_binary_expression, token}; use biome_js_syntax::{ - AnyJsExpression, AnyJsLiteralExpression, AnyJsStatement, JsBinaryExpression, JsBinaryOperator, - JsLanguage, JsLogicalExpression, JsLogicalOperator, JsSyntaxKind, JsUnaryOperator, - JsYieldArgument, JsYieldExpression, T, + AnyJsExpression, AnyJsStatement, JsBinaryExpression, JsBinaryOperator, JsLanguage, + JsLogicalExpression, JsLogicalOperator, JsSyntaxKind, JsUnaryOperator, JsYieldArgument, + JsYieldExpression, T, }; use biome_rowan::{AstNode, BatchMutationExt, NodeOrToken, SyntaxTriviaPiece, TriviaPieceKind}; @@ -82,8 +82,8 @@ impl Rule for NoYodaExpression { let right = node.right().ok()?; let has_yoda_expression = node.is_comparison_operator() - && is_literal_expression(&left)? - && !is_literal_expression(&right)? + && left.is_literal_expression() + && !right.is_literal_expression() && !is_range_assertion(node); has_yoda_expression.then_some(()) @@ -212,42 +212,6 @@ fn clone_with_trivia( .with_trailing_trivia_pieces(trailing_trivia?.clone()) } -fn is_literal_expression(expression: &AnyJsExpression) -> Option { - match expression { - // Any literal: 1, true, null, etc - AnyJsExpression::AnyJsLiteralExpression(_) => Some(true), - - // Static template literals: `foo` - AnyJsExpression::JsTemplateExpression(template_expression) => Some( - template_expression - .elements() - .into_iter() - .all(|element| element.as_js_template_chunk_element().is_some()), - ), - - // Negative numeric literal: -1 - AnyJsExpression::JsUnaryExpression(unary_expression) => { - let is_minus_operator = - matches!(unary_expression.operator(), Ok(JsUnaryOperator::Minus)); - let is_number_expression = matches!( - unary_expression.argument(), - Ok(AnyJsExpression::AnyJsLiteralExpression( - AnyJsLiteralExpression::JsNumberLiteralExpression(_) - )) - ); - - Some(is_minus_operator && is_number_expression) - } - - // Parenthesized expression: (1) - AnyJsExpression::JsParenthesizedExpression(parenthesized_expression) => { - is_literal_expression(&parenthesized_expression.expression().ok()?) - } - - _ => Some(false), - } -} - /// Determines whether the passed node is inside a range expression, based on the following conditions: /// - The node has a `JsLogicalExpression` parent wrapped in parenthesis /// - Both the `left` and `right` part of this `JsLogicalExpression` are instances of `JsBinaryExpression` diff --git a/crates/biome_js_analyze/src/options.rs b/crates/biome_js_analyze/src/options.rs index 191d4352d5ec..54eb321290b8 100644 --- a/crates/biome_js_analyze/src/options.rs +++ b/crates/biome_js_analyze/src/options.rs @@ -286,6 +286,7 @@ pub type UseButtonType = ::Options; pub type UseCollapsedElseIf = ::Options; +pub type UseComponentExportOnlyModules = < lint :: nursery :: use_component_export_only_modules :: UseComponentExportOnlyModules as biome_analyze :: Rule > :: Options ; pub type UseConsistentArrayType = < lint :: style :: use_consistent_array_type :: UseConsistentArrayType as biome_analyze :: Rule > :: Options ; pub type UseConsistentBuiltinInstantiation = < lint :: style :: use_consistent_builtin_instantiation :: UseConsistentBuiltinInstantiation as biome_analyze :: Rule > :: Options ; pub type UseConsistentCurlyBraces = < lint :: nursery :: use_consistent_curly_braces :: UseConsistentCurlyBraces as biome_analyze :: Rule > :: Options ; diff --git a/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/ignore_jsfile.js b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/ignore_jsfile.js new file mode 100644 index 000000000000..81d115681a2d --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/ignore_jsfile.js @@ -0,0 +1,2 @@ +export const CONSTANT = 3 +export const Foo = () => {} diff --git a/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/ignore_jsfile.js.snap b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/ignore_jsfile.js.snap new file mode 100644 index 000000000000..8c95d13a04d4 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/ignore_jsfile.js.snap @@ -0,0 +1,11 @@ +--- +source: crates/biome_js_analyze/tests/spec_tests.rs +assertion_line: 84 +expression: ignore_jsfile.js +--- +# Input +```jsx +export const CONSTANT = 3 +export const Foo = () => {} + +``` diff --git a/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_component_and_constant.jsx b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_component_and_constant.jsx new file mode 100644 index 000000000000..81d115681a2d --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_component_and_constant.jsx @@ -0,0 +1,2 @@ +export const CONSTANT = 3 +export const Foo = () => {} diff --git a/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_component_and_constant.jsx.snap b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_component_and_constant.jsx.snap new file mode 100644 index 000000000000..e5f8fbc8a77c --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_component_and_constant.jsx.snap @@ -0,0 +1,30 @@ +--- +source: crates/biome_js_analyze/tests/spec_tests.rs +expression: invalid_component_and_constant.jsx +--- +# Input +```jsx +export const CONSTANT = 3 +export const Foo = () => {} + +``` + +# Diagnostics +``` +invalid_component_and_constant.jsx:1:14 lint/nursery/useComponentExportOnlyModules ━━━━━━━━━━━━━━━━━ + + ! Exporting a non-component with components is not allowed. + + > 1 │ export const CONSTANT = 3 + │ ^^^^^^^^ + 2 │ export const Foo = () => {} + 3 │ + + i Fast Refresh only works when a file only exports components. + + i Consider separating non-component exports into a new file. + + i If it is a component, it may not be following the variable naming conventions. + + +``` diff --git a/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_component_and_default_class.jsx b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_component_and_default_class.jsx new file mode 100644 index 000000000000..7d944a81e35d --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_component_and_default_class.jsx @@ -0,0 +1,3 @@ +export const SampleComponent = () => <> +export default class hoge { +} \ No newline at end of file diff --git a/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_component_and_default_class.jsx.snap b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_component_and_default_class.jsx.snap new file mode 100644 index 000000000000..4b56042a46ab --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_component_and_default_class.jsx.snap @@ -0,0 +1,30 @@ +--- +source: crates/biome_js_analyze/tests/spec_tests.rs +expression: invalid_component_and_default_class.jsx +--- +# Input +```jsx +export const SampleComponent = () => <> +export default class hoge { +} +``` + +# Diagnostics +``` +invalid_component_and_default_class.jsx:2:22 lint/nursery/useComponentExportOnlyModules ━━━━━━━━━━━━ + + ! Exporting a non-component with components is not allowed. + + 1 │ export const SampleComponent = () => <> + > 2 │ export default class hoge { + │ ^^^^ + 3 │ } + + i Fast Refresh only works when a file only exports components. + + i Consider separating non-component exports into a new file. + + i If it is a component, it may not be following the variable naming conventions. + + +``` diff --git a/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_component_and_default_function.jsx b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_component_and_default_function.jsx new file mode 100644 index 000000000000..f6045719c0ba --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_component_and_default_function.jsx @@ -0,0 +1,4 @@ +export const SampleComponent = () => <> +export default function hoge () { + return 100 +} diff --git a/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_component_and_default_function.jsx.snap b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_component_and_default_function.jsx.snap new file mode 100644 index 000000000000..7d675861056c --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_component_and_default_function.jsx.snap @@ -0,0 +1,33 @@ +--- +source: crates/biome_js_analyze/tests/spec_tests.rs +expression: invalid_component_and_default_function.jsx +--- +# Input +```jsx +export const SampleComponent = () => <> +export default function hoge () { + return 100 +} + +``` + +# Diagnostics +``` +invalid_component_and_default_function.jsx:2:25 lint/nursery/useComponentExportOnlyModules ━━━━━━━━━━ + + ! Exporting a non-component with components is not allowed. + + 1 │ export const SampleComponent = () => <> + > 2 │ export default function hoge () { + │ ^^^^ + 3 │ return 100 + 4 │ }· + + i Fast Refresh only works when a file only exports components. + + i Consider separating non-component exports into a new file. + + i If it is a component, it may not be following the variable naming conventions. + + +``` diff --git a/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_component_and_default_variable.jsx b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_component_and_default_variable.jsx new file mode 100644 index 000000000000..2c2eb4986ce4 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_component_and_default_variable.jsx @@ -0,0 +1,3 @@ +export const SampleComponent = () => <> +const hoge = 100 +export default hoge \ No newline at end of file diff --git a/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_component_and_default_variable.jsx.snap b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_component_and_default_variable.jsx.snap new file mode 100644 index 000000000000..492658e22f35 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_component_and_default_variable.jsx.snap @@ -0,0 +1,30 @@ +--- +source: crates/biome_js_analyze/tests/spec_tests.rs +expression: invalid_component_and_default_variable.jsx +--- +# Input +```jsx +export const SampleComponent = () => <> +const hoge = 100 +export default hoge +``` + +# Diagnostics +``` +invalid_component_and_default_variable.jsx:3:16 lint/nursery/useComponentExportOnlyModules ━━━━━━━━━━ + + ! Exporting a non-component with components is not allowed. + + 1 │ export const SampleComponent = () => <> + 2 │ const hoge = 100 + > 3 │ export default hoge + │ ^^^^ + + i Fast Refresh only works when a file only exports components. + + i Consider separating non-component exports into a new file. + + i If it is a component, it may not be following the variable naming conventions. + + +``` diff --git a/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_component_and_enum.tsx b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_component_and_enum.tsx new file mode 100644 index 000000000000..6bc811879702 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_component_and_enum.tsx @@ -0,0 +1,6 @@ +export const SampleComponent = () => <> +export enum SampleEnum { + A, + B, + C, +} diff --git a/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_component_and_enum.tsx.snap b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_component_and_enum.tsx.snap new file mode 100644 index 000000000000..81b2750d733e --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_component_and_enum.tsx.snap @@ -0,0 +1,35 @@ +--- +source: crates/biome_js_analyze/tests/spec_tests.rs +expression: invalid_component_and_enum.tsx +--- +# Input +```tsx +export const SampleComponent = () => <> +export enum SampleEnum { + A, + B, + C, +} + +``` + +# Diagnostics +``` +invalid_component_and_enum.tsx:2:13 lint/nursery/useComponentExportOnlyModules ━━━━━━━━━━━━━━━━━━━━━ + + ! Exporting a non-component with components is not allowed. + + 1 │ export const SampleComponent = () => <> + > 2 │ export enum SampleEnum { + │ ^^^^^^^^^^ + 3 │ A, + 4 │ B, + + i Fast Refresh only works when a file only exports components. + + i Consider separating non-component exports into a new file. + + i If it is a component, it may not be following the variable naming conventions. + + +``` diff --git a/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_component_and_export_non_in_ignore_export_names.jsx b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_component_and_export_non_in_ignore_export_names.jsx new file mode 100644 index 000000000000..ec84b2d5191b --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_component_and_export_non_in_ignore_export_names.jsx @@ -0,0 +1,3 @@ +export const loader = () => {} +export const Bar = () => {} +export const foo = () => {} diff --git a/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_component_and_export_non_in_ignore_export_names.jsx.snap b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_component_and_export_non_in_ignore_export_names.jsx.snap new file mode 100644 index 000000000000..ab99bc9c0aa9 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_component_and_export_non_in_ignore_export_names.jsx.snap @@ -0,0 +1,33 @@ +--- +source: crates/biome_js_analyze/tests/spec_tests.rs +assertion_line: 84 +expression: invalid_component_and_export_non_in_ignore_export_names.jsx +--- +# Input +```jsx +export const loader = () => {} +export const Bar = () => {} +export const foo = () => {} + +``` + +# Diagnostics +``` +invalid_component_and_export_non_in_ignore_export_names.jsx:3:14 lint/nursery/useComponentExportOnlyModules ━━━━━━━━━━ + + ! Exporting a non-component with components is not allowed. + + 1 │ export const loader = () => {} + 2 │ export const Bar = () => {} + > 3 │ export const foo = () => {} + │ ^^^ + 4 │ + + i Fast Refresh only works when a file only exports components. + + i Consider separating non-component exports into a new file. + + i If it is a component, it may not be following the variable naming conventions. + + +``` diff --git a/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_component_and_export_non_in_ignore_export_names.options.json b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_component_and_export_non_in_ignore_export_names.options.json new file mode 100644 index 000000000000..0591fc5c0c22 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_component_and_export_non_in_ignore_export_names.options.json @@ -0,0 +1,16 @@ +{ + "$schema": "../../../../../../packages/@biomejs/biome/configuration_schema.json", + "linter": { + "enabled": true, + "rules": { + "nursery": { + "useComponentExportOnlyModules": { + "level": "error", + "options": { + "allowExportNames": ["loader", "meta"] + } + } + } + } + } +} diff --git a/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_component_and_function.jsx b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_component_and_function.jsx new file mode 100644 index 000000000000..a68f4d49ba98 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_component_and_function.jsx @@ -0,0 +1,2 @@ +export const foo = () => {} +export const Bar = () => {} \ No newline at end of file diff --git a/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_component_and_function.jsx.snap b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_component_and_function.jsx.snap new file mode 100644 index 000000000000..88f166200875 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_component_and_function.jsx.snap @@ -0,0 +1,28 @@ +--- +source: crates/biome_js_analyze/tests/spec_tests.rs +expression: invalid_component_and_function.jsx +--- +# Input +```jsx +export const foo = () => {} +export const Bar = () => {} +``` + +# Diagnostics +``` +invalid_component_and_function.jsx:1:14 lint/nursery/useComponentExportOnlyModules ━━━━━━━━━━━━━━━━━ + + ! Exporting a non-component with components is not allowed. + + > 1 │ export const foo = () => {} + │ ^^^ + 2 │ export const Bar = () => {} + + i Fast Refresh only works when a file only exports components. + + i Consider separating non-component exports into a new file. + + i If it is a component, it may not be following the variable naming conventions. + + +``` diff --git a/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_component_and_function_with_ignore_constant_export.jsx b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_component_and_function_with_ignore_constant_export.jsx new file mode 100644 index 000000000000..a68f4d49ba98 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_component_and_function_with_ignore_constant_export.jsx @@ -0,0 +1,2 @@ +export const foo = () => {} +export const Bar = () => {} \ No newline at end of file diff --git a/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_component_and_function_with_ignore_constant_export.jsx.snap b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_component_and_function_with_ignore_constant_export.jsx.snap new file mode 100644 index 000000000000..deefad0be44f --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_component_and_function_with_ignore_constant_export.jsx.snap @@ -0,0 +1,29 @@ +--- +source: crates/biome_js_analyze/tests/spec_tests.rs +assertion_line: 84 +expression: invalid_component_and_function_with_ignore_constant_export.jsx +--- +# Input +```jsx +export const foo = () => {} +export const Bar = () => {} +``` + +# Diagnostics +``` +invalid_component_and_function_with_ignore_constant_export.jsx:1:14 lint/nursery/useComponentExportOnlyModules ━━━━━━━━━━ + + ! Exporting a non-component with components is not allowed. + + > 1 │ export const foo = () => {} + │ ^^^ + 2 │ export const Bar = () => {} + + i Fast Refresh only works when a file only exports components. + + i Consider separating non-component exports into a new file. + + i If it is a component, it may not be following the variable naming conventions. + + +``` diff --git a/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_component_and_function_with_ignore_constant_export.options.json b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_component_and_function_with_ignore_constant_export.options.json new file mode 100644 index 000000000000..77239611058e --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_component_and_function_with_ignore_constant_export.options.json @@ -0,0 +1,16 @@ +{ + "$schema": "../../../../../../packages/@biomejs/biome/configuration_schema.json", + "linter": { + "enabled": true, + "rules": { + "nursery": { + "useComponentExportOnlyModules": { + "level": "error", + "options": { + "allowConstantExport": true + } + } + } + } + } +} diff --git a/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_component_and_variable_clause.jsx b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_component_and_variable_clause.jsx new file mode 100644 index 000000000000..e41645afa070 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_component_and_variable_clause.jsx @@ -0,0 +1,4 @@ +const foo = 4; +const Bar = () => {}; +const baz = 4; +export { foo, Bar, baz as qux } diff --git a/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_component_and_variable_clause.jsx.snap b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_component_and_variable_clause.jsx.snap new file mode 100644 index 000000000000..c9e9563d45b8 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_component_and_variable_clause.jsx.snap @@ -0,0 +1,53 @@ +--- +source: crates/biome_js_analyze/tests/spec_tests.rs +expression: invalid_component_and_variable_clause.jsx +--- +# Input +```jsx +const foo = 4; +const Bar = () => {}; +const baz = 4; +export { foo, Bar, baz as qux } + +``` + +# Diagnostics +``` +invalid_component_and_variable_clause.jsx:4:10 lint/nursery/useComponentExportOnlyModules ━━━━━━━━━━ + + ! Exporting a non-component with components is not allowed. + + 2 │ const Bar = () => {}; + 3 │ const baz = 4; + > 4 │ export { foo, Bar, baz as qux } + │ ^^^ + 5 │ + + i Fast Refresh only works when a file only exports components. + + i Consider separating non-component exports into a new file. + + i If it is a component, it may not be following the variable naming conventions. + + +``` + +``` +invalid_component_and_variable_clause.jsx:4:27 lint/nursery/useComponentExportOnlyModules ━━━━━━━━━━ + + ! Exporting a non-component with components is not allowed. + + 2 │ const Bar = () => {}; + 3 │ const baz = 4; + > 4 │ export { foo, Bar, baz as qux } + │ ^^^ + 5 │ + + i Fast Refresh only works when a file only exports components. + + i Consider separating non-component exports into a new file. + + i If it is a component, it may not be following the variable naming conventions. + + +``` diff --git a/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_hooked_component.jsx b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_hooked_component.jsx new file mode 100644 index 000000000000..dcdeedc646c0 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_hooked_component.jsx @@ -0,0 +1,2 @@ +const Fuga = () => <> +export default hoge(Fuga) diff --git a/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_hooked_component.jsx.snap b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_hooked_component.jsx.snap new file mode 100644 index 000000000000..4273934988c6 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_hooked_component.jsx.snap @@ -0,0 +1,30 @@ +--- +source: crates/biome_js_analyze/tests/spec_tests.rs +expression: invalid_hooked_component.jsx +--- +# Input +```jsx +const Fuga = () => <> +export default hoge(Fuga) + +``` + +# Diagnostics +``` +invalid_hooked_component.jsx:1:7 lint/nursery/useComponentExportOnlyModules ━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Components should be exported. + + > 1 │ const Fuga = () => <> + │ ^^^^ + 2 │ export default hoge(Fuga) + 3 │ + + i Fast Refresh only works when a file only exports components. + + i Consider separating component exports into a new file. + + i If it is not a component, it may not be following the variable naming conventions. + + +``` diff --git a/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_hooked_non_component.jsx b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_hooked_non_component.jsx new file mode 100644 index 000000000000..8199c3aca09c --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_hooked_non_component.jsx @@ -0,0 +1,3 @@ +export const Hoge = () => {} +const func = () => {} +export default memo(func) diff --git a/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_hooked_non_component.jsx.snap b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_hooked_non_component.jsx.snap new file mode 100644 index 000000000000..59d3465bf240 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_hooked_non_component.jsx.snap @@ -0,0 +1,32 @@ +--- +source: crates/biome_js_analyze/tests/spec_tests.rs +expression: invalid_hooked_non_component.jsx +--- +# Input +```jsx +export const Hoge = () => {} +const func = () => {} +export default memo(func) + +``` + +# Diagnostics +``` +invalid_hooked_non_component.jsx:3:16 lint/nursery/useComponentExportOnlyModules ━━━━━━━━━━━━━━━━━━━ + + ! Exporting a non-component with components is not allowed. + + 1 │ export const Hoge = () => {} + 2 │ const func = () => {} + > 3 │ export default memo(func) + │ ^^^^^^^^^^ + 4 │ + + i Fast Refresh only works when a file only exports components. + + i Consider separating non-component exports into a new file. + + i If it is a component, it may not be following the variable naming conventions. + + +``` diff --git a/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_unexported_component.jsx b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_unexported_component.jsx new file mode 100644 index 000000000000..e4f453d323da --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_unexported_component.jsx @@ -0,0 +1,2 @@ +const App = () => {} +createRoot(document.getElementById("root")).render(); \ No newline at end of file diff --git a/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_unexported_component.jsx.snap b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_unexported_component.jsx.snap new file mode 100644 index 000000000000..edbb5f207003 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/invalid_unexported_component.jsx.snap @@ -0,0 +1,28 @@ +--- +source: crates/biome_js_analyze/tests/spec_tests.rs +expression: invalid_unexported_component.jsx +--- +# Input +```jsx +const App = () => {} +createRoot(document.getElementById("root")).render(); +``` + +# Diagnostics +``` +invalid_unexported_component.jsx:1:7 lint/nursery/useComponentExportOnlyModules ━━━━━━━━━━━━━━━━━━━━ + + ! Unexported components are not allowed. + + > 1 │ const App = () => {} + │ ^^^ + 2 │ createRoot(document.getElementById("root")).render(); + + i Fast Refresh only works when a file only exports components. + + i Consider separating component exports into a new file. + + i If it is not a component, it may not be following the variable naming conventions. + + +``` diff --git a/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_component_and_constant_with_igore_constant_export.jsx b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_component_and_constant_with_igore_constant_export.jsx new file mode 100644 index 000000000000..bdd584ec0258 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_component_and_constant_with_igore_constant_export.jsx @@ -0,0 +1,7 @@ +export const camelCase = 4 +export const PascalCase = 4 +export const CONSTANT_NUMBER = -10 +export const CONSTANT_STR = 'Hello world' +export const CONSTANT_TEMPLATE_LITERAL = `Hello` + +export const Component = () => {} diff --git a/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_component_and_constant_with_igore_constant_export.jsx.snap b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_component_and_constant_with_igore_constant_export.jsx.snap new file mode 100644 index 000000000000..764c47dab5d7 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_component_and_constant_with_igore_constant_export.jsx.snap @@ -0,0 +1,16 @@ +--- +source: crates/biome_js_analyze/tests/spec_tests.rs +assertion_line: 84 +expression: valid_component_and_constant_with_igore_constant_export.jsx +--- +# Input +```jsx +export const camelCase = 4 +export const PascalCase = 4 +export const CONSTANT_NUMBER = -10 +export const CONSTANT_STR = 'Hello world' +export const CONSTANT_TEMPLATE_LITERAL = `Hello` + +export const Component = () => {} + +``` diff --git a/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_component_and_constant_with_igore_constant_export.options.json b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_component_and_constant_with_igore_constant_export.options.json new file mode 100644 index 000000000000..77239611058e --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_component_and_constant_with_igore_constant_export.options.json @@ -0,0 +1,16 @@ +{ + "$schema": "../../../../../../packages/@biomejs/biome/configuration_schema.json", + "linter": { + "enabled": true, + "rules": { + "nursery": { + "useComponentExportOnlyModules": { + "level": "error", + "options": { + "allowConstantExport": true + } + } + } + } + } +} diff --git a/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_component_and_ignore_export.jsx b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_component_and_ignore_export.jsx new file mode 100644 index 000000000000..02fa7c5a33d1 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_component_and_ignore_export.jsx @@ -0,0 +1,2 @@ +export const loader = () => {} +export const Bar = () => {} diff --git a/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_component_and_ignore_export.jsx.snap b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_component_and_ignore_export.jsx.snap new file mode 100644 index 000000000000..fb7640cfd6df --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_component_and_ignore_export.jsx.snap @@ -0,0 +1,10 @@ +--- +source: crates/biome_js_analyze/tests/spec_tests.rs +expression: valid_component_and_ignore_export.jsx +--- +# Input +```jsx +export const loader = () => {} +export const Bar = () => {} + +``` diff --git a/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_component_and_ignore_export.options.json b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_component_and_ignore_export.options.json new file mode 100644 index 000000000000..0591fc5c0c22 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_component_and_ignore_export.options.json @@ -0,0 +1,16 @@ +{ + "$schema": "../../../../../../packages/@biomejs/biome/configuration_schema.json", + "linter": { + "enabled": true, + "rules": { + "nursery": { + "useComponentExportOnlyModules": { + "level": "error", + "options": { + "allowExportNames": ["loader", "meta"] + } + } + } + } + } +} diff --git a/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_component_and_ignored_function_export.jsx b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_component_and_ignored_function_export.jsx new file mode 100644 index 000000000000..1b8b31c08eca --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_component_and_ignored_function_export.jsx @@ -0,0 +1,2 @@ +export function loader() {} +export const Bar = () => {} diff --git a/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_component_and_ignored_function_export.jsx.snap b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_component_and_ignored_function_export.jsx.snap new file mode 100644 index 000000000000..604286497554 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_component_and_ignored_function_export.jsx.snap @@ -0,0 +1,10 @@ +--- +source: crates/biome_js_analyze/tests/spec_tests.rs +expression: valid_component_and_ignored_function_export.jsx +--- +# Input +```jsx +export function loader() {} +export const Bar = () => {} + +``` diff --git a/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_component_and_ignored_function_export.options.json b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_component_and_ignored_function_export.options.json new file mode 100644 index 000000000000..0591fc5c0c22 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_component_and_ignored_function_export.options.json @@ -0,0 +1,16 @@ +{ + "$schema": "../../../../../../packages/@biomejs/biome/configuration_schema.json", + "linter": { + "enabled": true, + "rules": { + "nursery": { + "useComponentExportOnlyModules": { + "level": "error", + "options": { + "allowExportNames": ["loader", "meta"] + } + } + } + } + } +} diff --git a/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_component_and_number_constant_with_igore_constant_export.jsx.snap b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_component_and_number_constant_with_igore_constant_export.jsx.snap new file mode 100644 index 000000000000..ab0a4f1c07c4 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_component_and_number_constant_with_igore_constant_export.jsx.snap @@ -0,0 +1,10 @@ +--- +source: crates/biome_js_analyze/tests/spec_tests.rs +expression: valid_component_and_number_constant_with_igore_constant_export.jsx +--- +# Input +```jsx +export const foo = 4 +export const Bar = () => {} + +``` diff --git a/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_component_and_number_constant_with_igore_constant_export.options.json b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_component_and_number_constant_with_igore_constant_export.options.json new file mode 100644 index 000000000000..77239611058e --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_component_and_number_constant_with_igore_constant_export.options.json @@ -0,0 +1,16 @@ +{ + "$schema": "../../../../../../packages/@biomejs/biome/configuration_schema.json", + "linter": { + "enabled": true, + "rules": { + "nursery": { + "useComponentExportOnlyModules": { + "level": "error", + "options": { + "allowConstantExport": true + } + } + } + } + } +} diff --git a/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_component_and_pascalcase_variable.jsx b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_component_and_pascalcase_variable.jsx new file mode 100644 index 000000000000..d3ec25fb2074 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_component_and_pascalcase_variable.jsx @@ -0,0 +1,2 @@ +export function Component() {}; +export const Aa = 'a' diff --git a/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_component_and_pascalcase_variable.jsx.snap b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_component_and_pascalcase_variable.jsx.snap new file mode 100644 index 000000000000..4b69cbbb9639 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_component_and_pascalcase_variable.jsx.snap @@ -0,0 +1,10 @@ +--- +source: crates/biome_js_analyze/tests/spec_tests.rs +expression: valid_component_and_pascalcase_variable.jsx +--- +# Input +```jsx +export function Component() {}; +export const Aa = 'a' + +``` diff --git a/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_component_and_ts_type.tsx b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_component_and_ts_type.tsx new file mode 100644 index 000000000000..eda5f8a726af --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_component_and_ts_type.tsx @@ -0,0 +1,3 @@ +export const SampleComponent = () => <> +export type SampleType = string +export type sampleType = string diff --git a/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_component_and_ts_type.tsx.snap b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_component_and_ts_type.tsx.snap new file mode 100644 index 000000000000..ae8172697d12 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_component_and_ts_type.tsx.snap @@ -0,0 +1,12 @@ +--- +source: crates/biome_js_analyze/tests/spec_tests.rs +assertion_line: 84 +expression: valid_component_and_ts_type.tsx +--- +# Input +```tsx +export const SampleComponent = () => <> +export type SampleType = string +export type sampleType = string + +``` diff --git a/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_component_with_interface.tsx b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_component_with_interface.tsx new file mode 100644 index 000000000000..1d3e55fe89ad --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_component_with_interface.tsx @@ -0,0 +1,4 @@ +export const SampleComponent = () => <> +export interface SampleInterfafce { + hoge: number +} diff --git a/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_component_with_interface.tsx.snap b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_component_with_interface.tsx.snap new file mode 100644 index 000000000000..6338ca399718 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_component_with_interface.tsx.snap @@ -0,0 +1,13 @@ +--- +source: crates/biome_js_analyze/tests/spec_tests.rs +assertion_line: 84 +expression: valid_component_with_interface.tsx +--- +# Input +```tsx +export const SampleComponent = () => <> +export interface SampleInterfafce { + hoge: number +} + +``` diff --git a/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_components.jsx b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_components.jsx new file mode 100644 index 000000000000..0152f882eaa1 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_components.jsx @@ -0,0 +1,10 @@ +export const SampleComponentA = () => <> +export const SampleComponentB = () => <> +export function Hoge () { + return <> +} +export class Fuga extends React.Component { + render() { + return <> + } +} diff --git a/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_components.jsx.snap b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_components.jsx.snap new file mode 100644 index 000000000000..c2b0e87b8404 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_components.jsx.snap @@ -0,0 +1,19 @@ +--- +source: crates/biome_js_analyze/tests/spec_tests.rs +assertion_line: 84 +expression: valid_components.jsx +--- +# Input +```jsx +export const SampleComponentA = () => <> +export const SampleComponentB = () => <> +export function Hoge () { + return <> +} +export class Fuga extends React.Component { + render() { + return <> + } +} + +``` diff --git a/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_components_clause.jsx b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_components_clause.jsx new file mode 100644 index 000000000000..d85da662cb81 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_components_clause.jsx @@ -0,0 +1,11 @@ +const SampleComponentA = () => <> +const SampleComponentB = () => <> +function Hoge () { + return <> +} +class Fuga extends React.Component { + render() { + return <> + } +} +export {SampleComponentA, SampleComponentB, Hoge, Fuga} diff --git a/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_components_clause.jsx.snap b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_components_clause.jsx.snap new file mode 100644 index 000000000000..8acb8d085b0d --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_components_clause.jsx.snap @@ -0,0 +1,20 @@ +--- +source: crates/biome_js_analyze/tests/spec_tests.rs +assertion_line: 84 +expression: valid_components_clause.jsx +--- +# Input +```jsx +const SampleComponentA = () => <> +const SampleComponentB = () => <> +function Hoge () { + return <> +} +class Fuga extends React.Component { + render() { + return <> + } +} +export {SampleComponentA, SampleComponentB, Hoge, Fuga} + +``` diff --git a/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_default_class_component.jsx b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_default_class_component.jsx new file mode 100644 index 000000000000..7d4bc85e90ad --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_default_class_component.jsx @@ -0,0 +1,5 @@ +export default class Fuga extends React.Component { + render() { + return <> + } +}; diff --git a/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_default_class_component.jsx.snap b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_default_class_component.jsx.snap new file mode 100644 index 000000000000..0af62194cdc9 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_default_class_component.jsx.snap @@ -0,0 +1,14 @@ +--- +source: crates/biome_js_analyze/tests/spec_tests.rs +assertion_line: 84 +expression: valid_default_class_component.jsx +--- +# Input +```jsx +export default class Fuga extends React.Component { + render() { + return <> + } +}; + +``` diff --git a/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_default_component.jsx b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_default_component.jsx new file mode 100644 index 000000000000..fa3c2c9d4871 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_default_component.jsx @@ -0,0 +1,2 @@ +const Component = () => <> +export default Component; \ No newline at end of file diff --git a/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_default_component.jsx.snap b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_default_component.jsx.snap new file mode 100644 index 000000000000..f061c7244bd5 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_default_component.jsx.snap @@ -0,0 +1,10 @@ +--- +source: crates/biome_js_analyze/tests/spec_tests.rs +assertion_line: 84 +expression: valid_default_component.jsx +--- +# Input +```jsx +const Component = () => <> +export default Component; +``` diff --git a/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_default_component_as_default.jsx b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_default_component_as_default.jsx new file mode 100644 index 000000000000..930611f09753 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_default_component_as_default.jsx @@ -0,0 +1,2 @@ +const App = () => <>Test; +export { App as default }; \ No newline at end of file diff --git a/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_default_component_as_default.jsx.snap b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_default_component_as_default.jsx.snap new file mode 100644 index 000000000000..49854b9d6a55 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_default_component_as_default.jsx.snap @@ -0,0 +1,10 @@ +--- +source: crates/biome_js_analyze/tests/spec_tests.rs +assertion_line: 84 +expression: valid_default_component_as.jsx +--- +# Input +```jsx +const App = () => <>Test; +export { App as default }; +``` diff --git a/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_default_function_component.jsx b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_default_function_component.jsx new file mode 100644 index 000000000000..50f831079499 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_default_function_component.jsx @@ -0,0 +1,3 @@ +export default function Hoge () { + return <> +} \ No newline at end of file diff --git a/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_default_function_component.jsx.snap b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_default_function_component.jsx.snap new file mode 100644 index 000000000000..ff77a1ee4c93 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_default_function_component.jsx.snap @@ -0,0 +1,11 @@ +--- +source: crates/biome_js_analyze/tests/spec_tests.rs +assertion_line: 84 +expression: valid_default_function_component.jsx +--- +# Input +```jsx +export default function Hoge () { + return <> +} +``` diff --git a/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_hooked_component.jsx b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_hooked_component.jsx new file mode 100644 index 000000000000..00b4630aa137 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_hooked_component.jsx @@ -0,0 +1,5 @@ +import { memo } from 'react'; + +const Component = () => <> + +export default memo(Component); \ No newline at end of file diff --git a/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_hooked_component.jsx.snap b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_hooked_component.jsx.snap new file mode 100644 index 000000000000..da962bc64a34 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_hooked_component.jsx.snap @@ -0,0 +1,13 @@ +--- +source: crates/biome_js_analyze/tests/spec_tests.rs +assertion_line: 84 +expression: valid_hooked_component.jsx +--- +# Input +```jsx +import { memo } from 'react'; + +const Component = () => <> + +export default memo(Component); +``` diff --git a/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_ignored_function_export.jsx b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_ignored_function_export.jsx new file mode 100644 index 000000000000..65d2e38ce622 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_ignored_function_export.jsx @@ -0,0 +1,2 @@ +export const loader = () => {} +export const meta = { title: 'Home' } diff --git a/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_ignored_function_export.jsx.snap b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_ignored_function_export.jsx.snap new file mode 100644 index 000000000000..f2e1c049a360 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_ignored_function_export.jsx.snap @@ -0,0 +1,11 @@ +--- +source: crates/biome_js_analyze/tests/spec_tests.rs +assertion_line: 84 +expression: valid_ignored_function_export.jsx +--- +# Input +```jsx +export const loader = () => {} +export const meta = { title: 'Home' } + +``` diff --git a/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_ignored_function_export.options.json b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_ignored_function_export.options.json new file mode 100644 index 000000000000..0591fc5c0c22 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_ignored_function_export.options.json @@ -0,0 +1,16 @@ +{ + "$schema": "../../../../../../packages/@biomejs/biome/configuration_schema.json", + "linter": { + "enabled": true, + "rules": { + "nursery": { + "useComponentExportOnlyModules": { + "level": "error", + "options": { + "allowExportNames": ["loader", "meta"] + } + } + } + } + } +} diff --git a/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_non_components_only.jsx b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_non_components_only.jsx new file mode 100644 index 000000000000..cb084ab73fb8 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_non_components_only.jsx @@ -0,0 +1,4 @@ +export const sampleConst = 100 +export function hoge () { + return 100 +} diff --git a/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_non_components_only.jsx.snap b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_non_components_only.jsx.snap new file mode 100644 index 000000000000..4f10745d032f --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_non_components_only.jsx.snap @@ -0,0 +1,13 @@ +--- +source: crates/biome_js_analyze/tests/spec_tests.rs +assertion_line: 84 +expression: valid_non_components_only.jsx +--- +# Input +```jsx +export const sampleConst = 100 +export function hoge () { + return 100 +} + +``` diff --git a/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_ts_type_and_non_component.tsx b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_ts_type_and_non_component.tsx new file mode 100644 index 000000000000..2ee534d8adb9 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_ts_type_and_non_component.tsx @@ -0,0 +1,3 @@ +export type SampleType = string +export type sampleType = string +export const hoge = 100 diff --git a/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_ts_type_and_non_component.tsx.snap b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_ts_type_and_non_component.tsx.snap new file mode 100644 index 000000000000..ece236dee650 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useComponentExportOnlyModules/valid_ts_type_and_non_component.tsx.snap @@ -0,0 +1,12 @@ +--- +source: crates/biome_js_analyze/tests/spec_tests.rs +assertion_line: 84 +expression: valid_ts_type_and_non_component.tsx +--- +# Input +```tsx +export type SampleType = string +export type sampleType = string +export const hoge = 100 + +``` diff --git a/crates/biome_js_syntax/src/export_ext.rs b/crates/biome_js_syntax/src/export_ext.rs index 7fa1f8abe367..248ce81cdab6 100644 --- a/crates/biome_js_syntax/src/export_ext.rs +++ b/crates/biome_js_syntax/src/export_ext.rs @@ -1,6 +1,233 @@ -use biome_rowan::{AstNode, SyntaxResult}; +use biome_rowan::{declare_node_union, AstNode, SyntaxResult}; -use crate::{AnyJsExportNamedSpecifier, JsExportNamedClause, JsReferenceIdentifier, JsSyntaxToken}; +use crate::{ + AnyJsBindingPattern, AnyJsDeclarationClause, AnyJsExportClause, AnyJsExportDefaultDeclaration, + AnyJsExportNamedSpecifier, AnyJsExpression, AnyTsIdentifierBinding, AnyTsType, JsExport, + JsExportNamedClause, JsIdentifierExpression, JsLiteralExportName, JsReferenceIdentifier, + JsSyntaxToken, TsEnumDeclaration, +}; + +declare_node_union! { + pub AnyIdentifier = AnyJsBindingPattern | AnyTsIdentifierBinding | JsIdentifierExpression | JsLiteralExportName | JsReferenceIdentifier +} + +declare_node_union! { + pub AnyJsExported = AnyJsExpression | AnyJsExportClause | AnyIdentifier | AnyTsType | TsEnumDeclaration +} + +#[derive(Clone, Debug)] +pub struct ExportedItem { + // The identifier of the exported object + pub identifier: Option, + // The exported object + pub exported: Option, + // Whether it is default exported or not + pub is_default: bool, +} + +impl JsExport { + /// Returns a list of the exported items. + /// ## Example + /// When a named export is made, it returns a list of them. + /// ```js + /// export {foo, bar as baz}; + /// ``` + /// will return + /// ```js + /// [ + /// ExportedItem { identifier: Some(AnyIdentifier::JsLiteralExportName("foo")), exported: None, is_default: false }, + /// ExportedItem { identifier: Some(AnyIdentifier::JsLiteralExportName("baz")), exported: None, is_default: false }, + /// ] + /// ``` + /// + /// + /// When multiple variables are exported, it returns the list of those variables. + /// + /// ```js + /// export const x = 100, y = 200; + /// ``` + /// will return + /// ```js + /// [ + /// ExportedItem { identifier: Some(AnyIdentifier::AnyJsBindingPattern("x")), exported: Some(AnyJsExported::AnyJsExpression(100)), is_default: false }, + /// ExportedItem { identifier: Some(AnyIdentifier::AnyJsBindingPattern("y")), exported: Some(AnyJsExported::AnyJsExpression(200)), is_default: false }, + /// ] + /// ``` + /// When a function is exported, it returns the function name. It also checks whether it is a default export. + /// ```js + /// export default function foo() {}; + /// ``` + /// will return + /// ```js + /// [ + /// ExportedItem { identifier: Some(AnyIdentifier::AnyJsBindingPattern("foo")), exported: None, is_default: true }, + /// ] + /// ``` + pub fn get_exported_items(&self) -> Vec { + self.export_clause() + .ok() + .and_then(|export_clause| match export_clause { + // export const x = 100; + AnyJsExportClause::AnyJsDeclarationClause(declaration_clause) => { + match declaration_clause { + // export function foo() {} + AnyJsDeclarationClause::JsFunctionDeclaration( + function_declaration_clause, + ) => function_declaration_clause.id().ok().map(|function_id| { + vec![ExportedItem { + identifier: Some(AnyIdentifier::AnyJsBindingPattern( + AnyJsBindingPattern::AnyJsBinding(function_id), + )), + exported: None, + is_default: false, + }] + }), + // export const x = 100; + AnyJsDeclarationClause::JsVariableDeclarationClause( + variable_declaration_clause, + ) => variable_declaration_clause.declaration().ok().map( + |variable_declaration| { + variable_declaration + .declarators() + .into_iter() + .filter_map(|declarator| { + let declarator = declarator.ok()?; + let identifier = declarator.id().ok()?; + let initializer = declarator + .initializer() + .and_then(|init| init.expression().ok()); + Some(ExportedItem { + identifier: Some(AnyIdentifier::AnyJsBindingPattern( + identifier, + )), + exported: initializer + .map(AnyJsExported::AnyJsExpression), + is_default: false, + }) + }) + .collect() + }, + ), + // export enum X {} + AnyJsDeclarationClause::TsEnumDeclaration(ts_enum_declaration) => { + ts_enum_declaration.id().ok().map(|enum_id| { + vec![ExportedItem { + identifier: Some(AnyIdentifier::AnyJsBindingPattern( + AnyJsBindingPattern::AnyJsBinding(enum_id), + )), + exported: Some(AnyJsExported::TsEnumDeclaration( + ts_enum_declaration, + )), + is_default: false, + }] + }) + } + // export type X = number; + AnyJsDeclarationClause::TsTypeAliasDeclaration( + ts_type_alias_declaration, + ) => ts_type_alias_declaration.binding_identifier().ok().map( + |type_alias_id| { + vec![ExportedItem { + identifier: Some(AnyIdentifier::AnyTsIdentifierBinding( + type_alias_id, + )), + exported: ts_type_alias_declaration + .ty() + .ok() + .map(AnyJsExported::AnyTsType), + is_default: false, + }] + }, + ), + _ => None, + } + } + AnyJsExportClause::JsExportDefaultDeclarationClause(default_declaration_clause) => { + default_declaration_clause + .declaration() + .ok() + .and_then(|default_declation| match default_declation { + // export default function x() {} + AnyJsExportDefaultDeclaration::JsFunctionExportDefaultDeclaration( + function_declaration, + ) => function_declaration.id(), + // export default class x {} + AnyJsExportDefaultDeclaration::JsClassExportDefaultDeclaration( + class_declaration, + ) => class_declaration.id(), + _ => None, + }) + .map(|any_js_binding| { + vec![ExportedItem { + identifier: Some(AnyIdentifier::AnyJsBindingPattern( + AnyJsBindingPattern::AnyJsBinding(any_js_binding), + )), + exported: None, + is_default: true, + }] + }) + } + // export default x; + AnyJsExportClause::JsExportDefaultExpressionClause(clause) => { + clause.expression().ok().map(|expression| match expression { + AnyJsExpression::JsIdentifierExpression(identifier) => { + vec![ExportedItem { + identifier: Some(AnyIdentifier::JsIdentifierExpression(identifier)), + exported: None, + is_default: true, + }] + } + _ => vec![ExportedItem { + identifier: None, + exported: Some(AnyJsExported::AnyJsExpression(expression)), + is_default: true, + }], + }) + } + // export { x, y, z }; + AnyJsExportClause::JsExportNamedClause(named_clause) => Some( + named_clause + .specifiers() + .into_iter() + .filter_map(|r| r.ok()) + .filter_map(|export_specifier| match export_specifier { + AnyJsExportNamedSpecifier::JsExportNamedShorthandSpecifier( + shorthand, + ) => shorthand.name().ok().map(|name| ExportedItem { + identifier: Some(AnyIdentifier::JsReferenceIdentifier(name)), + exported: None, + is_default: false, + }), + AnyJsExportNamedSpecifier::JsExportNamedSpecifier(specifier) => { + specifier.exported_name().ok().map(|exported_name| { + if exported_name.text() == "default" { + return ExportedItem { + identifier: specifier.local_name().ok().map( + |local_name| { + AnyIdentifier::JsReferenceIdentifier(local_name) + }, + ), + exported: None, + is_default: true, + }; + } + ExportedItem { + identifier: Some(AnyIdentifier::JsLiteralExportName( + exported_name, + )), + exported: None, + is_default: false, + } + }) + } + }) + .collect(), + ), + _ => None, + }) + .unwrap_or_default() + } +} impl AnyJsExportNamedSpecifier { /// Type token of the export specifier. @@ -70,3 +297,189 @@ impl AnyJsExportNamedSpecifier { } } } + +#[cfg(test)] +mod tests { + use biome_js_factory::syntax::{JsExport, JsSyntaxKind::*}; + use biome_js_factory::JsSyntaxTreeBuilder; + use biome_rowan::AstNode; + + #[test] + fn test_get_exported_items() { + let mut tree_builder = JsSyntaxTreeBuilder::new(); + //export {foo, bar as baz} + tree_builder.start_node(JS_EXPORT); + tree_builder.token(EXPORT_KW, "export"); + tree_builder.start_node(JS_EXPORT_NAMED_CLAUSE); + tree_builder.token(L_CURLY, "{"); + tree_builder.start_node(JS_EXPORT_NAMED_SPECIFIER_LIST); + // foo + tree_builder.start_node(JS_EXPORT_NAMED_SHORTHAND_SPECIFIER); + tree_builder.start_node(JS_REFERENCE_IDENTIFIER); + tree_builder.token(IDENT, "foo"); + tree_builder.finish_node(); // JS_REFERENCE_IDENTIFIER + tree_builder.finish_node(); // JS_EXPORT_NAMED_SHORTHAND_SPECIFIER + tree_builder.token(COMMA, ","); + // bar as baz + tree_builder.start_node(JS_EXPORT_NAMED_SPECIFIER); + tree_builder.start_node(JS_REFERENCE_IDENTIFIER); + tree_builder.token(IDENT, "bar"); + tree_builder.finish_node(); // JS_REFERENCE_IDENTIFIER + tree_builder.token(AS_KW, "as"); + tree_builder.start_node(JS_LITERAL_EXPORT_NAME); + tree_builder.token(IDENT, "baz"); + tree_builder.finish_node(); // JS_LITERAL_EXPORT_NAME + tree_builder.finish_node(); // JS_EXPORT_NAMED_SPECIFIER + + tree_builder.finish_node(); // JS_EXPORT_NAMED_SPECIFIER_LIST + tree_builder.token(R_CURLY, "}"); + tree_builder.finish_node(); // JS_EXPORT_NAMED_CLAUSE + tree_builder.finish_node(); // JS_EXPORT + + let node = tree_builder.finish(); + + let export = JsExport::cast(node).unwrap(); + let exported_items = export.get_exported_items(); + assert_eq!(exported_items.len(), 2); + assert_eq!( + exported_items[0].identifier.as_ref().unwrap().to_string(), + "foo" + ); + assert_eq!( + exported_items[1].identifier.as_ref().unwrap().to_string(), + "baz" + ); + assert!(exported_items[0].exported.is_none()); + assert!(exported_items[1].exported.is_none()); + assert!(!exported_items[0].is_default); + assert!(!exported_items[1].is_default); + } + + #[test] + fn test_get_exported_items_default() { + let mut tree_builder = JsSyntaxTreeBuilder::new(); + // export default foo; + tree_builder.start_node(JS_EXPORT); + tree_builder.token(EXPORT_KW, "export"); + tree_builder.start_node(JS_EXPORT_DEFAULT_EXPRESSION_CLAUSE); + tree_builder.token(DEFAULT_KW, "default"); + tree_builder.start_node(JS_IDENTIFIER_EXPRESSION); + tree_builder.start_node(JS_REFERENCE_IDENTIFIER); + tree_builder.token(IDENT, "foo"); + tree_builder.finish_node(); // JS_REFERENCE_IDENTIFIER + tree_builder.finish_node(); // JS_IDENTIFIER_EXPRESSION + tree_builder.finish_node(); // JS_EXPORT_DEFAULT_EXPRESSION_CLAUSE + tree_builder.finish_node(); // JS_EXPORT + + let node = tree_builder.finish(); + let export = JsExport::cast(node).unwrap(); + let exported_items = export.get_exported_items(); + + assert_eq!(exported_items.len(), 1); + assert_eq!( + exported_items[0].identifier.as_ref().unwrap().to_string(), + "foo" + ); + assert!(exported_items[0].exported.is_none()); + assert!(exported_items[0].is_default); + } + + #[test] + fn test_get_exported_items_variable_declaration() { + let mut tree_builder = JsSyntaxTreeBuilder::new(); + // export const x = 100, y = 200; + tree_builder.start_node(JS_EXPORT); + tree_builder.token(EXPORT_KW, "export"); + tree_builder.start_node(JS_VARIABLE_DECLARATION_CLAUSE); + tree_builder.start_node(JS_VARIABLE_DECLARATION); + tree_builder.token(CONST_KW, "const"); + tree_builder.start_node(JS_VARIABLE_DECLARATOR_LIST); + tree_builder.start_node(JS_VARIABLE_DECLARATOR); + tree_builder.start_node(JS_IDENTIFIER_BINDING); + tree_builder.token(IDENT, "x"); + tree_builder.finish_node(); // JS_IDENTIFIER_BINDING + tree_builder.start_node(JS_INITIALIZER_CLAUSE); + tree_builder.token(EQ, "="); + tree_builder.start_node(JS_NUMBER_LITERAL_EXPRESSION); + tree_builder.token(JS_NUMBER_LITERAL, "100"); + tree_builder.finish_node(); // JS_NUMBER_LITERAL_EXPRESSION + tree_builder.finish_node(); // JS_INITIALIZER_CLAUSE + tree_builder.finish_node(); // JS_VARIABLE_DECLARATOR + tree_builder.token(COMMA, ","); + tree_builder.start_node(JS_VARIABLE_DECLARATOR); + tree_builder.start_node(JS_IDENTIFIER_BINDING); + tree_builder.token(IDENT, "y"); + tree_builder.finish_node(); // JS_IDENTIFIER_BINDING + tree_builder.start_node(JS_INITIALIZER_CLAUSE); + tree_builder.token(EQ, "="); + tree_builder.start_node(JS_NUMBER_LITERAL_EXPRESSION); + tree_builder.token(JS_NUMBER_LITERAL, "200"); + tree_builder.finish_node(); // JS_NUMBER_LITERAL_EXPRESSION + tree_builder.finish_node(); // JS_INITIALIZER_CLAUSE + tree_builder.finish_node(); // JS_VARIABLE_DECLARATOR + tree_builder.finish_node(); // JS_VARIABLE_DECLARATION + tree_builder.finish_node(); // JS_VARIABLE_DECLARATION_LIST + tree_builder.finish_node(); // JS_VARIABLE_DECLARATION_CLAUSE + tree_builder.finish_node(); // JS_EXPORT + + let node = tree_builder.finish(); + let export = JsExport::cast(node).unwrap(); + let exported_items = export.get_exported_items(); + + assert_eq!(exported_items.len(), 2); + assert_eq!( + exported_items[0].identifier.as_ref().unwrap().to_string(), + "x" + ); + assert_eq!( + exported_items[1].identifier.as_ref().unwrap().to_string(), + "y" + ); + assert_eq!( + exported_items[0].exported.clone().unwrap().to_string(), + "100" + ); + assert_eq!( + exported_items[1].exported.clone().unwrap().to_string(), + "200" + ); + assert!(!exported_items[0].is_default); + assert!(!exported_items[1].is_default); + } + + #[test] + fn test_get_exported_items_function_declaration() { + let mut tree_builder = JsSyntaxTreeBuilder::new(); + // export function foo() {} + tree_builder.start_node(JS_EXPORT); + tree_builder.token(EXPORT_KW, "export"); + + tree_builder.start_node(JS_FUNCTION_DECLARATION); + tree_builder.token(FUNCTION_KW, "function"); + tree_builder.start_node(JS_IDENTIFIER_BINDING); + tree_builder.token(IDENT, "foo"); + tree_builder.finish_node(); // JS_IDENTIFIER_BINDING + tree_builder.start_node(JS_PARAMETERS); + tree_builder.token(L_PAREN, "("); + tree_builder.token(R_PAREN, ")"); + tree_builder.finish_node(); // JS_PARAMETERS + tree_builder.start_node(JS_FUNCTION_BODY); + tree_builder.token(L_CURLY, "{"); + tree_builder.token(R_CURLY, "}"); + tree_builder.finish_node(); // JS_FUNCTION_BODY + tree_builder.finish_node(); // JS_FUNCTION_DECLARATION + tree_builder.finish_node(); // JS_EXPORT + + let node = tree_builder.finish(); + let export = JsExport::cast(node).unwrap(); + let exported_items = export.get_exported_items(); + + assert_eq!(exported_items.len(), 1); + assert_eq!( + exported_items[0].identifier.as_ref().unwrap().to_string(), + "foo" + ); + assert!(exported_items[0].exported.is_none()); + assert!(!exported_items[0].is_default); + } +} diff --git a/crates/biome_js_syntax/src/expr_ext.rs b/crates/biome_js_syntax/src/expr_ext.rs index 995a533e61cd..07ec15fe3ebb 100644 --- a/crates/biome_js_syntax/src/expr_ext.rs +++ b/crates/biome_js_syntax/src/expr_ext.rs @@ -1160,6 +1160,92 @@ impl AnyJsExpression { None => None, } } + + /// Determining if an expression is literal + /// - Any literal: 1, true, null, etc + /// - Static template literals: `foo` + /// - Negative numeric literal: -1 + /// - Parenthesized expression: (1) + /// + /// ## Example + /// + /// ``` + /// use biome_js_factory::make; + /// use biome_js_syntax::{ + /// AnyJsExpression, AnyJsLiteralExpression, AnyJsTemplateElement, JsSyntaxToken, JsUnaryOperator, T + /// }; + /// + /// // Any literal: 1, true, null, etc + /// let number_literal = AnyJsExpression::AnyJsLiteralExpression( + /// AnyJsLiteralExpression::from(make::js_number_literal_expression(make::js_number_literal("1"))) + /// ); + /// assert_eq!(number_literal.is_literal_expression(), true); + /// + /// // Static template literals: `foo` + /// let template = AnyJsExpression::JsTemplateExpression( + /// make::js_template_expression( + /// make::token(T!['`']), + /// make::js_template_element_list( + /// vec![ + /// AnyJsTemplateElement::from(make::js_template_chunk_element( + /// make::js_template_chunk("foo"), + /// )) + /// ] + /// ), + /// make::token(T!['`']), + /// ) + /// .build() + /// ); + /// assert_eq!(template.is_literal_expression(), true); + /// + /// // Negative numeric literal: -1 + /// let negative_numeric_literal = AnyJsExpression::JsUnaryExpression( + /// make::js_unary_expression(make::token(T![-]), number_literal.clone()) + /// ); + /// assert_eq!(negative_numeric_literal.is_literal_expression(), true); + /// + /// // Parenthesized expression: (1) + /// let parenthesized = AnyJsExpression::JsParenthesizedExpression( + /// make::js_parenthesized_expression(make::token(T!['(']), number_literal, make::token(T![')'])) + /// ); + /// assert_eq!(parenthesized.is_literal_expression(), true); + /// ``` + pub fn is_literal_expression(&self) -> bool { + match self { + // Any literal: 1, true, null, etc + AnyJsExpression::AnyJsLiteralExpression(_) => true, + + // Static template literals: `foo` + AnyJsExpression::JsTemplateExpression(template_expression) => template_expression + .elements() + .into_iter() + .all(|element| element.as_js_template_chunk_element().is_some()), + + // Negative numeric literal: -1 + AnyJsExpression::JsUnaryExpression(unary_expression) => { + let is_minus_operator = + matches!(unary_expression.operator(), Ok(JsUnaryOperator::Minus)); + let is_number_expression = matches!( + unary_expression.argument(), + Ok(AnyJsExpression::AnyJsLiteralExpression( + AnyJsLiteralExpression::JsNumberLiteralExpression(_) + )) + ); + + is_minus_operator && is_number_expression + } + + // Parenthesized expression: (1) + AnyJsExpression::JsParenthesizedExpression(parenthesized_expression) => { + parenthesized_expression + .expression() + .ok() + .map_or(false, |expression| expression.is_literal_expression()) + } + + _ => false, + } + } } /// Iterator that returns the callee names in "top down order". diff --git a/packages/@biomejs/backend-jsonrpc/src/workspace.ts b/packages/@biomejs/backend-jsonrpc/src/workspace.ts index 850143ece78e..eaffb54223fd 100644 --- a/packages/@biomejs/backend-jsonrpc/src/workspace.ts +++ b/packages/@biomejs/backend-jsonrpc/src/workspace.ts @@ -1311,6 +1311,10 @@ export interface Nursery { * Enforce that ARIA properties are valid for the roles that are supported by the element. */ useAriaPropsSupportedByRole?: RuleConfiguration_for_Null; + /** + * Enforce declaring components only within modules that export React Components exclusively. + */ + useComponentExportOnlyModules?: RuleConfiguration_for_UseComponentExportOnlyModulesOptions; /** * This rule enforces consistent use of curly braces inside JSX attributes and JSX children. */ @@ -1981,6 +1985,9 @@ export type RuleConfiguration_for_RestrictedImportsOptions = export type RuleFixConfiguration_for_NoRestrictedTypesOptions = | RulePlainConfiguration | RuleWithFixOptions_for_NoRestrictedTypesOptions; +export type RuleConfiguration_for_UseComponentExportOnlyModulesOptions = + | RulePlainConfiguration + | RuleWithOptions_for_UseComponentExportOnlyModulesOptions; export type RuleConfiguration_for_ConsistentMemberAccessibilityOptions = | RulePlainConfiguration | RuleWithOptions_for_ConsistentMemberAccessibilityOptions; @@ -2139,6 +2146,16 @@ export interface RuleWithFixOptions_for_NoRestrictedTypesOptions { */ options: NoRestrictedTypesOptions; } +export interface RuleWithOptions_for_UseComponentExportOnlyModulesOptions { + /** + * The severity of the emitted diagnostics by the rule + */ + level: RulePlainConfiguration; + /** + * Rule's options + */ + options: UseComponentExportOnlyModulesOptions; +} export interface RuleWithOptions_for_ConsistentMemberAccessibilityOptions { /** * The severity of the emitted diagnostics by the rule @@ -2317,6 +2334,16 @@ export interface RestrictedImportsOptions { export interface NoRestrictedTypesOptions { types: {}; } +export interface UseComponentExportOnlyModulesOptions { + /** + * Allows the export of constants. This option is for environments that support it, such as [Vite](https://vitejs.dev/) + */ + allowConstantExport?: boolean; + /** + * A list of names that can be additionally exported from the module This option is for exports that do not hinder [React Fast Refresh](https://github.com/facebook/react/tree/main/packages/react-refresh), such as [`meta` in Remix](https://remix.run/docs/en/main/route/meta) + */ + allowExportNames: string[]; +} export interface ConsistentMemberAccessibilityOptions { accessibility: Accessibility; } @@ -2829,6 +2856,7 @@ export type Category = | "lint/nursery/useAdjacentOverloadSignatures" | "lint/nursery/useAriaPropsSupportedByRole" | "lint/nursery/useBiomeSuppressionComment" + | "lint/nursery/useComponentExportOnlyModules" | "lint/nursery/useConsistentCurlyBraces" | "lint/nursery/useConsistentMemberAccessibility" | "lint/nursery/useDeprecatedReason" diff --git a/packages/@biomejs/biome/configuration_schema.json b/packages/@biomejs/biome/configuration_schema.json index e56cbf71d553..7c6ebdfc6ed3 100644 --- a/packages/@biomejs/biome/configuration_schema.json +++ b/packages/@biomejs/biome/configuration_schema.json @@ -2227,6 +2227,15 @@ { "type": "null" } ] }, + "useComponentExportOnlyModules": { + "description": "Enforce declaring components only within modules that export React Components exclusively.", + "anyOf": [ + { + "$ref": "#/definitions/UseComponentExportOnlyModulesConfiguration" + }, + { "type": "null" } + ] + }, "useConsistentCurlyBraces": { "description": "This rule enforces consistent use of curly braces inside JSX attributes and JSX children.", "anyOf": [ @@ -2816,6 +2825,23 @@ }, "additionalProperties": false }, + "RuleWithUseComponentExportOnlyModulesOptions": { + "type": "object", + "required": ["level", "options"], + "properties": { + "level": { + "description": "The severity of the emitted diagnostics by the rule", + "allOf": [{ "$ref": "#/definitions/RulePlainConfiguration" }] + }, + "options": { + "description": "Rule's options", + "allOf": [ + { "$ref": "#/definitions/UseComponentExportOnlyModulesOptions" } + ] + } + }, + "additionalProperties": false + }, "RuleWithUseImportExtensionsOptions": { "type": "object", "required": ["level", "options"], @@ -3925,6 +3951,28 @@ } ] }, + "UseComponentExportOnlyModulesConfiguration": { + "anyOf": [ + { "$ref": "#/definitions/RulePlainConfiguration" }, + { "$ref": "#/definitions/RuleWithUseComponentExportOnlyModulesOptions" } + ] + }, + "UseComponentExportOnlyModulesOptions": { + "type": "object", + "properties": { + "allowConstantExport": { + "description": "Allows the export of constants. This option is for environments that support it, such as [Vite](https://vitejs.dev/)", + "default": false, + "type": "boolean" + }, + "allowExportNames": { + "description": "A list of names that can be additionally exported from the module This option is for exports that do not hinder [React Fast Refresh](https://github.com/facebook/react/tree/main/packages/react-refresh), such as [`meta` in Remix](https://remix.run/docs/en/main/route/meta)", + "type": "array", + "items": { "type": "string" } + } + }, + "additionalProperties": false + }, "UseImportExtensionsConfiguration": { "anyOf": [ { "$ref": "#/definitions/RulePlainConfiguration" },