From b8c57d2724bda65a27575a741a70df5600f5c6a4 Mon Sep 17 00:00:00 2001 From: "Arend van Beelen jr." Date: Fri, 24 Jan 2025 12:02:00 +0100 Subject: [PATCH] feat(lint): new rule: `noImportCycles` (#4948) --- .changeset/add_the_new_rule_noimportcycles.md | 5 + Cargo.lock | 4 + .../migrate/eslint_any_rule_to_biome.rs | 11 + .../src/analyzer/linter/rules.rs | 211 +++++++++-------- crates/biome_console/src/markup.rs | 13 ++ .../src/dependency_graph.rs | 18 ++ crates/biome_dependency_graph/src/lib.rs | 2 +- .../src/categories.rs | 1 + crates/biome_js_analyze/Cargo.toml | 1 + crates/biome_js_analyze/src/lib.rs | 98 ++++---- crates/biome_js_analyze/src/lint/nursery.rs | 2 + .../src/lint/nursery/no_import_cycles.rs | 212 ++++++++++++++++++ crates/biome_js_analyze/src/options.rs | 2 + .../src/services/dependency_graph.rs | 67 ++++++ crates/biome_js_analyze/src/services/mod.rs | 4 +- crates/biome_js_analyze/tests/quick_test.rs | 24 +- crates/biome_js_analyze/tests/spec_tests.rs | 48 ++-- .../nursery/noImportCycles/invalidBaz.js | 5 + .../nursery/noImportCycles/invalidBaz.js.snap | 31 +++ .../nursery/noImportCycles/invalidFoobar.js | 9 + .../noImportCycles/invalidFoobar.js.snap | 35 +++ .../specs/nursery/noImportCycles/valid.js | 5 + .../nursery/noImportCycles/valid.js.snap | 13 ++ crates/biome_js_parser/src/lib.rs | 2 +- crates/biome_js_parser/src/parse.rs | 2 +- crates/biome_js_syntax/src/file_source.rs | 19 +- crates/biome_service/src/file_handlers/css.rs | 1 + .../src/file_handlers/graphql.rs | 1 + .../src/file_handlers/javascript.rs | 25 ++- .../biome_service/src/file_handlers/json.rs | 1 + crates/biome_service/src/file_handlers/mod.rs | 4 + crates/biome_service/src/workspace/server.rs | 3 + crates/biome_test_utils/Cargo.toml | 35 +-- crates/biome_test_utils/src/lib.rs | 43 ++++ crates/biome_unicode_table/src/tables.rs | 110 ++++----- .../@biomejs/backend-jsonrpc/src/workspace.ts | 5 + .../@biomejs/biome/configuration_schema.json | 7 + xtask/bench/src/language.rs | 1 - xtask/rules_check/src/lib.rs | 42 ++-- 39 files changed, 846 insertions(+), 276 deletions(-) create mode 100644 .changeset/add_the_new_rule_noimportcycles.md create mode 100644 crates/biome_js_analyze/src/lint/nursery/no_import_cycles.rs create mode 100644 crates/biome_js_analyze/src/services/dependency_graph.rs create mode 100644 crates/biome_js_analyze/tests/specs/nursery/noImportCycles/invalidBaz.js create mode 100644 crates/biome_js_analyze/tests/specs/nursery/noImportCycles/invalidBaz.js.snap create mode 100644 crates/biome_js_analyze/tests/specs/nursery/noImportCycles/invalidFoobar.js create mode 100644 crates/biome_js_analyze/tests/specs/nursery/noImportCycles/invalidFoobar.js.snap create mode 100644 crates/biome_js_analyze/tests/specs/nursery/noImportCycles/valid.js create mode 100644 crates/biome_js_analyze/tests/specs/nursery/noImportCycles/valid.js.snap diff --git a/.changeset/add_the_new_rule_noimportcycles.md b/.changeset/add_the_new_rule_noimportcycles.md new file mode 100644 index 000000000000..a12a6c92caf5 --- /dev/null +++ b/.changeset/add_the_new_rule_noimportcycles.md @@ -0,0 +1,5 @@ +--- +cli: minor +--- + +# Add the new rule [`noImportCycles`](https://biomejs.dev/linter/rules/no-import-cycles) diff --git a/Cargo.lock b/Cargo.lock index f6eee0d6e321..57367a7b31fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -811,6 +811,7 @@ dependencies = [ "biome_aria_metadata", "biome_console", "biome_control_flow", + "biome_dependency_graph", "biome_deserialize", "biome_deserialize_macros", "biome_diagnostics", @@ -1343,9 +1344,12 @@ dependencies = [ "biome_analyze", "biome_configuration", "biome_console", + "biome_dependency_graph", "biome_deserialize", "biome_diagnostics", "biome_formatter", + "biome_fs", + "biome_js_parser", "biome_json_parser", "biome_package", "biome_project_layout", 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 0c841e127fff..0fd2eda9e9e2 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 @@ -681,6 +681,17 @@ pub(crate) fn migrate_eslint_any_rule( .get_or_insert(Default::default()); rule.set_level(rule.level().max(rule_severity.into())); } + "import/no-cycle" => { + if !options.include_nursery { + return false; + } + let group = rules.nursery.get_or_insert_with(Default::default); + let rule = group + .unwrap_group_as_mut() + .no_import_cycles + .get_or_insert(Default::default()); + rule.set_level(rule.level().max(rule_severity.into())); + } "import/no-default-export" => { let group = rules.style.get_or_insert_with(Default::default); let rule = group diff --git a/crates/biome_configuration/src/analyzer/linter/rules.rs b/crates/biome_configuration/src/analyzer/linter/rules.rs index 43e6fed01a65..9bcf2c01a92c 100644 --- a/crates/biome_configuration/src/analyzer/linter/rules.rs +++ b/crates/biome_configuration/src/analyzer/linter/rules.rs @@ -3086,6 +3086,9 @@ pub struct Nursery { #[doc = "Prevent usage of \\ element in a Next.js project."] #[serde(skip_serializing_if = "Option::is_none")] pub no_img_element: Option>, + #[doc = "Prevent import cycles."] + #[serde(skip_serializing_if = "Option::is_none")] + pub no_import_cycles: Option>, #[doc = "Disallows the use of irregular whitespace characters."] #[serde(skip_serializing_if = "Option::is_none")] pub no_irregular_whitespace: @@ -3267,6 +3270,7 @@ impl Nursery { "noHeadElement", "noHeadImportInDocument", "noImgElement", + "noImportCycles", "noIrregularWhitespace", "noMissingVarFunction", "noNestedTernary", @@ -3318,19 +3322,19 @@ impl Nursery { RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[5]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[6]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[7]), - RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[17]), - RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[30]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[18]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[31]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[32]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[33]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[34]), - RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[36]), - RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[41]), - RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[46]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[35]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[37]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[42]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[47]), - RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[53]), - RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[55]), - RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[57]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[48]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[54]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[56]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[58]), ]; const ALL_RULES_AS_FILTERS: &'static [RuleFilter<'static>] = &[ RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[0]), @@ -3393,6 +3397,7 @@ impl Nursery { RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[57]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[58]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[59]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[60]), ]; } impl RuleGroupExt for Nursery { @@ -3484,226 +3489,231 @@ impl RuleGroupExt for Nursery { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[15])); } } - if let Some(rule) = self.no_irregular_whitespace.as_ref() { + if let Some(rule) = self.no_import_cycles.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[16])); } } - if let Some(rule) = self.no_missing_var_function.as_ref() { + if let Some(rule) = self.no_irregular_whitespace.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[17])); } } - if let Some(rule) = self.no_nested_ternary.as_ref() { + if let Some(rule) = self.no_missing_var_function.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[18])); } } - if let Some(rule) = self.no_noninteractive_element_interactions.as_ref() { + if let Some(rule) = self.no_nested_ternary.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[19])); } } - if let Some(rule) = self.no_octal_escape.as_ref() { + if let Some(rule) = self.no_noninteractive_element_interactions.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[20])); } } - if let Some(rule) = self.no_package_private_imports.as_ref() { + if let Some(rule) = self.no_octal_escape.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[21])); } } - if let Some(rule) = self.no_process_env.as_ref() { + if let Some(rule) = self.no_package_private_imports.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[22])); } } - if let Some(rule) = self.no_process_global.as_ref() { + if let Some(rule) = self.no_process_env.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[23])); } } - if let Some(rule) = self.no_restricted_imports.as_ref() { + if let Some(rule) = self.no_process_global.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[24])); } } - if let Some(rule) = self.no_restricted_types.as_ref() { + if let Some(rule) = self.no_restricted_imports.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[25])); } } - if let Some(rule) = self.no_secrets.as_ref() { + if let Some(rule) = self.no_restricted_types.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[26])); } } - if let Some(rule) = self.no_static_element_interactions.as_ref() { + if let Some(rule) = self.no_secrets.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[27])); } } - if let Some(rule) = self.no_substr.as_ref() { + if let Some(rule) = self.no_static_element_interactions.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[28])); } } - if let Some(rule) = self.no_template_curly_in_string.as_ref() { + if let Some(rule) = self.no_substr.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[29])); } } - if let Some(rule) = self.no_ts_ignore.as_ref() { + if let Some(rule) = self.no_template_curly_in_string.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[30])); } } - if let Some(rule) = self.no_unknown_at_rule.as_ref() { + if let Some(rule) = self.no_ts_ignore.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[31])); } } - if let Some(rule) = self.no_unknown_pseudo_class.as_ref() { + if let Some(rule) = self.no_unknown_at_rule.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[32])); } } - if let Some(rule) = self.no_unknown_pseudo_element.as_ref() { + if let Some(rule) = self.no_unknown_pseudo_class.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[33])); } } - if let Some(rule) = self.no_unknown_type_selector.as_ref() { + if let Some(rule) = self.no_unknown_pseudo_element.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[34])); } } - if let Some(rule) = self.no_unwanted_polyfillio.as_ref() { + if let Some(rule) = self.no_unknown_type_selector.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[35])); } } - if let Some(rule) = self.no_useless_escape_in_regex.as_ref() { + if let Some(rule) = self.no_unwanted_polyfillio.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[36])); } } - if let Some(rule) = self.no_useless_string_raw.as_ref() { + if let Some(rule) = self.no_useless_escape_in_regex.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[37])); } } - if let Some(rule) = self.no_useless_undefined.as_ref() { + if let Some(rule) = self.no_useless_string_raw.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[38])); } } - if let Some(rule) = self.no_value_at_rule.as_ref() { + if let Some(rule) = self.no_useless_undefined.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[39])); } } - if let Some(rule) = self.use_adjacent_overload_signatures.as_ref() { + if let Some(rule) = self.no_value_at_rule.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[40])); } } - if let Some(rule) = self.use_aria_props_supported_by_role.as_ref() { + if let Some(rule) = self.use_adjacent_overload_signatures.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[41])); } } - if let Some(rule) = self.use_at_index.as_ref() { + if let Some(rule) = self.use_aria_props_supported_by_role.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[42])); } } - if let Some(rule) = self.use_collapsed_if.as_ref() { + if let Some(rule) = self.use_at_index.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[43])); } } - if let Some(rule) = self.use_component_export_only_modules.as_ref() { + if let Some(rule) = self.use_collapsed_if.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[44])); } } - 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[45])); } } - 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[46])); } } - 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[47])); } } - if let Some(rule) = self.use_explicit_type.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[48])); } } - if let Some(rule) = self.use_exports_last.as_ref() { + if let Some(rule) = self.use_explicit_type.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[49])); } } - if let Some(rule) = self.use_google_font_display.as_ref() { + if let Some(rule) = self.use_exports_last.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[50])); } } - if let Some(rule) = self.use_google_font_preconnect.as_ref() { + if let Some(rule) = self.use_google_font_display.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[51])); } } - if let Some(rule) = self.use_guard_for_in.as_ref() { + if let Some(rule) = self.use_google_font_preconnect.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[52])); } } - if let Some(rule) = self.use_named_operation.as_ref() { + if let Some(rule) = self.use_guard_for_in.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[53])); } } - if let Some(rule) = self.use_naming_convention.as_ref() { + if let Some(rule) = self.use_named_operation.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[54])); } } - if let Some(rule) = self.use_parse_int_radix.as_ref() { + if let Some(rule) = self.use_naming_convention.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[55])); } } - if let Some(rule) = self.use_sorted_classes.as_ref() { + if let Some(rule) = self.use_parse_int_radix.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[56])); } } - 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[57])); } } - 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[58])); } } - 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[59])); } } + 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[60])); + } + } index_set } fn get_disabled_rules(&self) -> FxHashSet> { @@ -3788,226 +3798,231 @@ impl RuleGroupExt for Nursery { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[15])); } } - if let Some(rule) = self.no_irregular_whitespace.as_ref() { + if let Some(rule) = self.no_import_cycles.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[16])); } } - if let Some(rule) = self.no_missing_var_function.as_ref() { + if let Some(rule) = self.no_irregular_whitespace.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[17])); } } - if let Some(rule) = self.no_nested_ternary.as_ref() { + if let Some(rule) = self.no_missing_var_function.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[18])); } } - if let Some(rule) = self.no_noninteractive_element_interactions.as_ref() { + if let Some(rule) = self.no_nested_ternary.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[19])); } } - if let Some(rule) = self.no_octal_escape.as_ref() { + if let Some(rule) = self.no_noninteractive_element_interactions.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[20])); } } - if let Some(rule) = self.no_package_private_imports.as_ref() { + if let Some(rule) = self.no_octal_escape.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[21])); } } - if let Some(rule) = self.no_process_env.as_ref() { + if let Some(rule) = self.no_package_private_imports.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[22])); } } - if let Some(rule) = self.no_process_global.as_ref() { + if let Some(rule) = self.no_process_env.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[23])); } } - if let Some(rule) = self.no_restricted_imports.as_ref() { + if let Some(rule) = self.no_process_global.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[24])); } } - if let Some(rule) = self.no_restricted_types.as_ref() { + if let Some(rule) = self.no_restricted_imports.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[25])); } } - if let Some(rule) = self.no_secrets.as_ref() { + if let Some(rule) = self.no_restricted_types.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[26])); } } - if let Some(rule) = self.no_static_element_interactions.as_ref() { + if let Some(rule) = self.no_secrets.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[27])); } } - if let Some(rule) = self.no_substr.as_ref() { + if let Some(rule) = self.no_static_element_interactions.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[28])); } } - if let Some(rule) = self.no_template_curly_in_string.as_ref() { + if let Some(rule) = self.no_substr.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[29])); } } - if let Some(rule) = self.no_ts_ignore.as_ref() { + if let Some(rule) = self.no_template_curly_in_string.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[30])); } } - if let Some(rule) = self.no_unknown_at_rule.as_ref() { + if let Some(rule) = self.no_ts_ignore.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[31])); } } - if let Some(rule) = self.no_unknown_pseudo_class.as_ref() { + if let Some(rule) = self.no_unknown_at_rule.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[32])); } } - if let Some(rule) = self.no_unknown_pseudo_element.as_ref() { + if let Some(rule) = self.no_unknown_pseudo_class.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[33])); } } - if let Some(rule) = self.no_unknown_type_selector.as_ref() { + if let Some(rule) = self.no_unknown_pseudo_element.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[34])); } } - if let Some(rule) = self.no_unwanted_polyfillio.as_ref() { + if let Some(rule) = self.no_unknown_type_selector.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[35])); } } - if let Some(rule) = self.no_useless_escape_in_regex.as_ref() { + if let Some(rule) = self.no_unwanted_polyfillio.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[36])); } } - if let Some(rule) = self.no_useless_string_raw.as_ref() { + if let Some(rule) = self.no_useless_escape_in_regex.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[37])); } } - if let Some(rule) = self.no_useless_undefined.as_ref() { + if let Some(rule) = self.no_useless_string_raw.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[38])); } } - if let Some(rule) = self.no_value_at_rule.as_ref() { + if let Some(rule) = self.no_useless_undefined.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[39])); } } - if let Some(rule) = self.use_adjacent_overload_signatures.as_ref() { + if let Some(rule) = self.no_value_at_rule.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[40])); } } - if let Some(rule) = self.use_aria_props_supported_by_role.as_ref() { + if let Some(rule) = self.use_adjacent_overload_signatures.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[41])); } } - if let Some(rule) = self.use_at_index.as_ref() { + if let Some(rule) = self.use_aria_props_supported_by_role.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[42])); } } - if let Some(rule) = self.use_collapsed_if.as_ref() { + if let Some(rule) = self.use_at_index.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[43])); } } - if let Some(rule) = self.use_component_export_only_modules.as_ref() { + if let Some(rule) = self.use_collapsed_if.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[44])); } } - 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[45])); } } - 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[46])); } } - 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[47])); } } - if let Some(rule) = self.use_explicit_type.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[48])); } } - if let Some(rule) = self.use_exports_last.as_ref() { + if let Some(rule) = self.use_explicit_type.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[49])); } } - if let Some(rule) = self.use_google_font_display.as_ref() { + if let Some(rule) = self.use_exports_last.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[50])); } } - if let Some(rule) = self.use_google_font_preconnect.as_ref() { + if let Some(rule) = self.use_google_font_display.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[51])); } } - if let Some(rule) = self.use_guard_for_in.as_ref() { + if let Some(rule) = self.use_google_font_preconnect.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[52])); } } - if let Some(rule) = self.use_named_operation.as_ref() { + if let Some(rule) = self.use_guard_for_in.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[53])); } } - if let Some(rule) = self.use_naming_convention.as_ref() { + if let Some(rule) = self.use_named_operation.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[54])); } } - if let Some(rule) = self.use_parse_int_radix.as_ref() { + if let Some(rule) = self.use_naming_convention.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[55])); } } - if let Some(rule) = self.use_sorted_classes.as_ref() { + if let Some(rule) = self.use_parse_int_radix.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[56])); } } - 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[57])); } } - 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[58])); } } - 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[59])); } } + 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[60])); + } + } index_set } #[doc = r" Checks if, given a rule name, matches one of the rules contained in this category"] @@ -4102,6 +4117,10 @@ impl RuleGroupExt for Nursery { .no_img_element .as_ref() .map(|conf| (conf.level(), conf.get_options())), + "noImportCycles" => self + .no_import_cycles + .as_ref() + .map(|conf| (conf.level(), conf.get_options())), "noIrregularWhitespace" => self .no_irregular_whitespace .as_ref() diff --git a/crates/biome_console/src/markup.rs b/crates/biome_console/src/markup.rs index 2d4f6b49c196..42a520f29e18 100644 --- a/crates/biome_console/src/markup.rs +++ b/crates/biome_console/src/markup.rs @@ -182,6 +182,19 @@ impl Markup<'_> { pub struct MarkupBuf(pub Vec); impl MarkupBuf { + /// Extends the buffer with additional markup. + /// + /// ## Example + /// + /// ```rs + /// let mut markup = markup!("Hello").to_owned(); + /// markup.extend_with(markup!("world")); + /// ``` + pub fn extend_with(&mut self, markup: Markup) { + // SAFETY: The implementation of Write for MarkupBuf below always returns Ok + Formatter::new(self).write_markup(markup).unwrap(); + } + pub fn is_empty(&self) -> bool { self.0.iter().all(|node| node.content.is_empty()) } diff --git a/crates/biome_dependency_graph/src/dependency_graph.rs b/crates/biome_dependency_graph/src/dependency_graph.rs index 5c31848fc3e9..a0b9587006f6 100644 --- a/crates/biome_dependency_graph/src/dependency_graph.rs +++ b/crates/biome_dependency_graph/src/dependency_graph.rs @@ -66,6 +66,24 @@ pub struct ModuleImports { pub dynamic_imports: BTreeMap, } +impl ModuleImports { + /// Allows draining a single entry from the imports. + /// + /// Returns a `(specifier, import)` pair from either the static or dynamic + /// imports, whichever is non-empty. Returns `None` if both are empty. + /// + /// Using this method allows for consuming the struct while iterating over + /// it, without necessarily turning the entire struct into an iterator at + /// once. + pub fn drain_one(&mut self) -> Option<(String, Import)> { + if self.static_imports.is_empty() { + self.dynamic_imports.pop_first() + } else { + self.static_imports.pop_first() + } + } +} + #[derive(Clone, Debug, PartialEq)] pub struct Import { /// Absolute path of the resource being imported, if it can be resolved. diff --git a/crates/biome_dependency_graph/src/lib.rs b/crates/biome_dependency_graph/src/lib.rs index d7257a0d9a1c..fd9311947de3 100644 --- a/crates/biome_dependency_graph/src/lib.rs +++ b/crates/biome_dependency_graph/src/lib.rs @@ -2,4 +2,4 @@ mod dependency_graph; mod import_visitor; mod resolver_cache; -pub use dependency_graph::DependencyGraph; +pub use dependency_graph::{DependencyGraph, ModuleImports}; diff --git a/crates/biome_diagnostics_categories/src/categories.rs b/crates/biome_diagnostics_categories/src/categories.rs index e97ca84b3c92..d32aec8359fe 100644 --- a/crates/biome_diagnostics_categories/src/categories.rs +++ b/crates/biome_diagnostics_categories/src/categories.rs @@ -154,6 +154,7 @@ define_categories! { "lint/nursery/noHeadElement": "https://biomejs.dev/linter/rules/no-head-element", "lint/nursery/noHeadImportInDocument": "https://biomejs.dev/linter/rules/no-head-import-in-document", "lint/nursery/noImgElement": "https://biomejs.dev/linter/rules/no-img-element", + "lint/nursery/noImportCycles": "https://biomejs.dev/linter/rules/no-import-cycles", "lint/nursery/noImportantInKeyframe": "https://biomejs.dev/linter/rules/no-important-in-keyframe", "lint/nursery/noInvalidDirectionInLinearGradient": "https://biomejs.dev/linter/rules/no-invalid-direction-in-linear-gradient", "lint/nursery/noInvalidGridAreas": "https://biomejs.dev/linter/rules/use-consistent-grid-areas", diff --git a/crates/biome_js_analyze/Cargo.toml b/crates/biome_js_analyze/Cargo.toml index 037f094b6f44..0567058264d8 100644 --- a/crates/biome_js_analyze/Cargo.toml +++ b/crates/biome_js_analyze/Cargo.toml @@ -16,6 +16,7 @@ biome_aria = { workspace = true } biome_aria_metadata = { workspace = true } biome_console = { workspace = true } biome_control_flow = { workspace = true } +biome_dependency_graph = { workspace = true } biome_deserialize = { workspace = true, features = ["smallvec"] } biome_deserialize_macros = { workspace = true } biome_diagnostics = { workspace = true } diff --git a/crates/biome_js_analyze/src/lib.rs b/crates/biome_js_analyze/src/lib.rs index d49e447c70c6..799dfafb790e 100644 --- a/crates/biome_js_analyze/src/lib.rs +++ b/crates/biome_js_analyze/src/lib.rs @@ -7,6 +7,7 @@ use biome_analyze::{ MatchQueryParams, MetadataRegistry, RuleAction, RuleRegistry, }; use biome_aria::AriaRoles; +use biome_dependency_graph::DependencyGraph; use biome_diagnostics::Error as DiagnosticError; use biome_js_syntax::{JsFileSource, JsLanguage}; use biome_project_layout::ProjectLayout; @@ -39,21 +40,42 @@ pub static METADATA: LazyLock = LazyLock::new(|| { metadata }); +#[derive(Default)] +pub struct JsAnalyzerServices { + dependency_graph: Arc, + project_layout: Arc, + source_type: JsFileSource, +} + +impl From<(Arc, Arc, JsFileSource)> for JsAnalyzerServices { + fn from( + (dependency_graph, project_layout, source_type): ( + Arc, + Arc, + JsFileSource, + ), + ) -> Self { + Self { + dependency_graph, + project_layout, + source_type, + } + } +} + /// Run the analyzer on the provided `root`: this process will use the given `filter` /// to selectively restrict analysis to specific rules / a specific source range, /// then call `emit_signal` when an analysis rule emits a diagnostic or action. /// Additionally, this function takes a `inspect_matcher` function that can be /// used to inspect the "query matches" emitted by the analyzer before they are /// processed by the lint rules registry -#[expect(clippy::too_many_arguments)] pub fn analyze_with_inspect_matcher<'a, V, F, B>( root: &LanguageRoot, filter: AnalysisFilter, inspect_matcher: V, options: &'a AnalyzerOptions, plugins: Vec>, - source_type: JsFileSource, - project_layout: Arc, + services: JsAnalyzerServices, mut emit_signal: F, ) -> (Option, Vec) where @@ -90,6 +112,12 @@ where let mut registry = RuleRegistry::builder(&filter, root); visit_registry(&mut registry); + let JsAnalyzerServices { + dependency_graph, + project_layout, + source_type, + } = services; + let (registry, mut services, diagnostics, visitors) = registry.build(); // Bail if we can't parse a rule option @@ -117,7 +145,7 @@ where services.insert_service(Arc::new(AriaRoles)); services.insert_service(source_type); - + services.insert_service(dependency_graph); services.insert_service(project_layout.get_node_manifest_for_path(&options.file_path)); services.insert_service(project_layout); @@ -140,8 +168,7 @@ pub fn analyze<'a, F, B>( filter: AnalysisFilter, options: &'a AnalyzerOptions, plugins: Vec>, - source_type: JsFileSource, - project_layout: Arc, + services: JsAnalyzerServices, emit_signal: F, ) -> (Option, Vec) where @@ -154,8 +181,7 @@ where |_| {}, options, plugins, - source_type, - project_layout, + services, emit_signal, ) } @@ -195,6 +221,12 @@ let bar = 33; let mut dependencies = Dependencies::default(); dependencies.add("buffer", "latest"); + let services = JsAnalyzerServices::from(( + Default::default(), + project_layout_with_top_level_dependencies(dependencies), + JsFileSource::tsx(), + )); + analyze( &parsed.tree(), AnalysisFilter { @@ -203,8 +235,7 @@ let bar = 33; }, &options, Vec::new(), - JsFileSource::tsx(), - project_layout_with_top_level_dependencies(dependencies), + services, |signal| { if let Some(diag) = signal.diagnostic() { error_ranges.push(diag.location().span.unwrap()); @@ -252,7 +283,6 @@ let bar = 33; AnalysisFilter::default(), &options, Vec::new(), - JsFileSource::js_module(), Default::default(), |signal| { if let Some(diag) = signal.diagnostic() { @@ -338,7 +368,6 @@ let bar = 33; AnalysisFilter::default(), &options, Vec::new(), - JsFileSource::js_module(), Default::default(), |signal| { if let Some(diag) = signal.diagnostic() { @@ -410,7 +439,6 @@ let bar = 33; filter, &options, Vec::new(), - JsFileSource::js_module(), Default::default(), |signal| { if let Some(diag) = signal.diagnostic() { @@ -455,7 +483,6 @@ let bar = 33; filter, &options, Vec::new(), - JsFileSource::js_module(), Default::default(), |signal| { if let Some(diag) = signal.diagnostic() { @@ -507,7 +534,6 @@ debugger; filter, &options, Vec::new(), - JsFileSource::js_module(), Default::default(), |signal| { if let Some(diag) = signal.diagnostic() { @@ -554,7 +580,6 @@ debugger; filter, &options, Vec::new(), - JsFileSource::js_module(), Default::default(), |signal| { if let Some(diag) = signal.diagnostic() { @@ -603,7 +628,6 @@ debugger; filter, &options, Vec::new(), - JsFileSource::js_module(), Default::default(), |signal| { if let Some(diag) = signal.diagnostic() { @@ -653,7 +677,6 @@ let bar = 33; filter, &options, Vec::new(), - JsFileSource::js_module(), Default::default(), |signal| { if let Some(diag) = signal.diagnostic() { @@ -701,7 +724,6 @@ let bar = 33; filter, &options, Vec::new(), - JsFileSource::js_module(), Default::default(), |signal| { if let Some(diag) = signal.diagnostic() { @@ -752,7 +774,6 @@ let c; filter, &options, Vec::new(), - JsFileSource::js_module(), Default::default(), |signal| { if let Some(diag) = signal.diagnostic() { @@ -804,7 +825,6 @@ debugger; filter, &options, Vec::new(), - JsFileSource::js_module(), Default::default(), |signal| { if let Some(diag) = signal.diagnostic() { @@ -857,7 +877,6 @@ let d; filter, &options, Vec::new(), - JsFileSource::js_module(), Default::default(), |signal| { if let Some(diag) = signal.diagnostic() { @@ -896,26 +915,22 @@ const foo0 = function (bar: string) { }; let options = AnalyzerOptions::default(); let root = parsed.tree(); - analyze( - &root, - filter, - &options, - Vec::new(), - JsFileSource::ts(), - Default::default(), - |signal| { - if let Some(diag) = signal.diagnostic() { - let error = diag - .with_file_path("dummyFile") - .with_file_source_code(SOURCE); - let text = print_diagnostic_to_string(&error); - eprintln!("{text}"); - panic!("Unexpected diagnostic"); - } - ControlFlow::::Continue(()) - }, - ); + let services = + JsAnalyzerServices::from((Default::default(), Default::default(), JsFileSource::ts())); + + analyze(&root, filter, &options, Vec::new(), services, |signal| { + if let Some(diag) = signal.diagnostic() { + let error = diag + .with_file_path("dummyFile") + .with_file_source_code(SOURCE); + let text = print_diagnostic_to_string(&error); + eprintln!("{text}"); + panic!("Unexpected diagnostic"); + } + + ControlFlow::::Continue(()) + }); } #[test] @@ -949,7 +964,6 @@ a == b; filter, &options, Vec::new(), - JsFileSource::js_module(), Default::default(), |signal| { if let Some(diag) = signal.diagnostic() { diff --git a/crates/biome_js_analyze/src/lint/nursery.rs b/crates/biome_js_analyze/src/lint/nursery.rs index 7b72aa066636..60e4206ec607 100644 --- a/crates/biome_js_analyze/src/lint/nursery.rs +++ b/crates/biome_js_analyze/src/lint/nursery.rs @@ -14,6 +14,7 @@ pub mod no_global_dirname_filename; pub mod no_head_element; pub mod no_head_import_in_document; pub mod no_img_element; +pub mod no_import_cycles; pub mod no_irregular_whitespace; pub mod no_nested_ternary; pub mod no_noninteractive_element_interactions; @@ -66,6 +67,7 @@ declare_lint_group! { self :: no_head_element :: NoHeadElement , self :: no_head_import_in_document :: NoHeadImportInDocument , self :: no_img_element :: NoImgElement , + self :: no_import_cycles :: NoImportCycles , self :: no_irregular_whitespace :: NoIrregularWhitespace , self :: no_nested_ternary :: NoNestedTernary , self :: no_noninteractive_element_interactions :: NoNoninteractiveElementInteractions , diff --git a/crates/biome_js_analyze/src/lint/nursery/no_import_cycles.rs b/crates/biome_js_analyze/src/lint/nursery/no_import_cycles.rs new file mode 100644 index 000000000000..8a51a8029478 --- /dev/null +++ b/crates/biome_js_analyze/src/lint/nursery/no_import_cycles.rs @@ -0,0 +1,212 @@ +use std::collections::HashSet; + +use biome_analyze::{context::RuleContext, declare_lint_rule, Rule, RuleDiagnostic, RuleSource}; +use biome_console::markup; +use biome_dependency_graph::ModuleImports; +use biome_diagnostics::Severity; +use biome_js_syntax::{inner_string_text, AnyJsImportLike}; +use biome_rowan::AstNode; +use camino::{Utf8Path, Utf8PathBuf}; + +use crate::services::dependency_graph::ResolvedImports; + +declare_lint_rule! { + /// Prevent import cycles. + /// + /// This rule warns when a file imports another file that, either directly + /// or indirectly, imports the original file again. + /// + /// Cycles can lead to symbols that are unexpectedly `undefined` and are + /// generally considered poor code hygiene. + /// + /// If a cycle is detected, it is advised to move code such that imports + /// only go in a single direction, i.e. they don't point "back" to the + /// importing file. + /// + /// ## Examples + /// + /// ### Invalid + /// + /// **`foobar.js`** + /// ```js + /// import { baz } from "./baz.js"; + /// + /// export function foo() { + /// baz(); + /// } + /// + /// export function bar() { + /// console.log("foobar"); + /// } + /// ``` + /// + /// **`baz.js`** + /// ```js + /// import { bar } from "./foobar.js"; + /// + /// export function baz() { + /// bar(); + /// } + /// ``` + /// + /// ### Valid + /// + /// **`foo.js`** + /// ```js + /// import { baz } from "./baz.js"; + /// + /// export function foo() { + /// baz(); + /// } + /// ``` + /// + /// **`bar.js`** + /// ```js + /// export function bar() { + /// console.log("foobar"); + /// } + /// ``` + /// + /// **`baz.js`** + /// ```js + /// import { bar } from "./bar.js"; + /// + /// export function baz() { + /// bar(); + /// } + /// ``` + /// + pub NoImportCycles { + version: "next", + name: "noImportCycles", + language: "js", + sources: &[ + RuleSource::EslintImport("no-cycle"), + ], + severity: Severity::Warning, + recommended: false, + } +} + +impl Rule for NoImportCycles { + type Query = ResolvedImports; + type State = Vec; + type Signals = Option; + type Options = (); + + fn run(ctx: &RuleContext) -> Self::Signals { + let file_imports = ctx.imports_for_path(ctx.file_path())?; + + let node = ctx.query(); + let name_token = node.module_name_token()?; + let specifier_text = inner_string_text(&name_token); + let specifier = specifier_text.text(); + let import = if is_static_import(node) { + file_imports.static_imports.get(specifier) + } else { + file_imports.dynamic_imports.get(specifier) + }?; + let resolved_path = import.resolved_path.as_ref().ok()?; + + let imports = ctx.imports_for_path(resolved_path)?; + find_cycle(ctx, resolved_path, imports) + } + + fn diagnostic(ctx: &RuleContext, state: &Self::State) -> Option { + let node = ctx.query(); + + let cwd = Utf8PathBuf::from( + std::env::current_dir() + .map(|cwd| cwd.to_string_lossy().to_string()) + .unwrap_or_default(), + ); + + let mut note = markup!("This import resolves to ").to_owned(); + for (i, path) in state.iter().enumerate() { + if i > 0 { + note.extend_with(markup!("\n ... which imports ")); + } + + match Utf8Path::new(path).strip_prefix(&cwd) { + Ok(relative_path) => { + note.extend_with(markup!({relative_path.as_str()})) + } + Err(_) => note.extend_with(markup!({path})), + } + } + note.extend_with(markup!("\n ... which is the file we're importing from.")); + + Some( + RuleDiagnostic::new( + rule_category!(), + node.range(), + markup!("This import is part of a cycle."), + ) + .note(note), + ) + } +} + +/// Attempts to find a cycle by traversing all imports from `start_path` and +/// finding those that lead back to `ctx.file_path()`. +/// +/// Cycles that don't lead back to `ctx.file_path()` are not reported, since +/// they should be reported for the respective files instead. +/// +/// `imports` are the imports found in `start_path`. +/// +/// If a cycle is found, this returns a vector with all the paths involved in +/// the cycle, starting with `start_path` and ending with `ctx.file_path()`. +fn find_cycle( + ctx: &RuleContext, + start_path: &Utf8Path, + mut imports: ModuleImports, +) -> Option> { + let mut seen = HashSet::new(); + let mut stack = Vec::new(); + + 'outer: loop { + while let Some((_specifier, import)) = imports.drain_one() { + let Ok(resolved_path) = import.resolved_path else { + continue; + }; + + if resolved_path == ctx.file_path() { + // Return all the paths from `start_path` to `resolved_path`: + let paths = Some(start_path.to_string()) + .into_iter() + .chain(stack.into_iter().map(|(path, _)| path)) + .chain(Some(resolved_path.into())) + .collect(); + return Some(paths); + } + + // FIXME: Use `get_or_insert_with()` once it's stabilized. + // See: https://github.com/rust-lang/rust/issues/60896 + if seen.contains(resolved_path.as_str()) { + continue; + } + + seen.insert(resolved_path.to_string()); + + if let Some(resolved_imports) = ctx.imports_for_path(&resolved_path) { + stack.push((resolved_path.into(), imports)); + imports = resolved_imports; + continue 'outer; + } + } + + match stack.pop() { + Some((_previous_path, previous_imports)) => { + imports = previous_imports; + } + None => break, + } + } + + None +} + +fn is_static_import(node: &AnyJsImportLike) -> bool { + matches!(node, AnyJsImportLike::JsModuleSource(_)) +} diff --git a/crates/biome_js_analyze/src/options.rs b/crates/biome_js_analyze/src/options.rs index 2c76ac356702..910947962c39 100644 --- a/crates/biome_js_analyze/src/options.rs +++ b/crates/biome_js_analyze/src/options.rs @@ -128,6 +128,8 @@ pub type NoImplicitBoolean = ::Options; pub type NoImportAssign = ::Options; +pub type NoImportCycles = + ::Options; pub type NoInferrableTypes = ::Options; pub type NoInnerDeclarations = diff --git a/crates/biome_js_analyze/src/services/dependency_graph.rs b/crates/biome_js_analyze/src/services/dependency_graph.rs new file mode 100644 index 000000000000..2cef5d03d81f --- /dev/null +++ b/crates/biome_js_analyze/src/services/dependency_graph.rs @@ -0,0 +1,67 @@ +use biome_analyze::{ + AddVisitor, FromServices, MissingServicesDiagnostic, Phase, Phases, QueryKey, QueryMatch, + Queryable, RuleKey, ServiceBag, SyntaxVisitor, +}; +use biome_dependency_graph::{DependencyGraph, ModuleImports}; +use biome_js_syntax::{AnyJsImportLike, AnyJsRoot, JsLanguage, JsSyntaxNode}; +use biome_rowan::{AstNode, TextRange}; +use camino::Utf8Path; +use std::sync::Arc; + +#[derive(Debug, Clone)] +pub struct DependencyGraphService(Arc); + +impl DependencyGraphService { + pub fn imports_for_path(&self, path: &Utf8Path) -> Option { + self.0.imports_for_path(path) + } +} + +impl FromServices for DependencyGraphService { + fn from_services( + rule_key: &RuleKey, + services: &ServiceBag, + ) -> Result { + let dependency_graph: &Arc = services.get_service().ok_or_else(|| { + MissingServicesDiagnostic::new(rule_key.rule_name(), &["DependencyGraph"]) + })?; + Ok(Self(dependency_graph.clone())) + } +} + +impl Phase for DependencyGraphService { + fn phase() -> Phases { + Phases::Syntax + } +} + +/// Query type usable by lint rules that matches import statements and uses the +/// [DependencyGraph] to resolve their specifiers. +#[derive(Clone)] +pub struct ResolvedImports(AnyJsImportLike); + +impl QueryMatch for ResolvedImports { + fn text_range(&self) -> TextRange { + self.0.range() + } +} + +impl Queryable for ResolvedImports { + type Input = JsSyntaxNode; + type Output = AnyJsImportLike; + + type Language = JsLanguage; + type Services = DependencyGraphService; + + fn build_visitor(analyzer: &mut impl AddVisitor, _: &AnyJsRoot) { + analyzer.add_visitor(Phases::Syntax, SyntaxVisitor::default); + } + + fn key() -> QueryKey { + QueryKey::Syntax(AnyJsImportLike::KIND_SET) + } + + fn unwrap_match(_: &ServiceBag, node: &Self::Input) -> Self::Output { + AnyJsImportLike::unwrap_cast(node.clone()) + } +} diff --git a/crates/biome_js_analyze/src/services/mod.rs b/crates/biome_js_analyze/src/services/mod.rs index e4e4e47065ef..a98c7c9309c4 100644 --- a/crates/biome_js_analyze/src/services/mod.rs +++ b/crates/biome_js_analyze/src/services/mod.rs @@ -1,5 +1,5 @@ pub mod aria; pub mod control_flow; -pub mod semantic; - +pub mod dependency_graph; pub mod manifest; +pub mod semantic; diff --git a/crates/biome_js_analyze/tests/quick_test.rs b/crates/biome_js_analyze/tests/quick_test.rs index 062abdfc3c15..30c56951c100 100644 --- a/crates/biome_js_analyze/tests/quick_test.rs +++ b/crates/biome_js_analyze/tests/quick_test.rs @@ -1,11 +1,12 @@ use biome_analyze::{AnalysisFilter, ControlFlow, Never, RuleFilter}; use biome_diagnostics::advice::CodeSuggestionAdvice; use biome_diagnostics::{DiagnosticExt, Severity}; +use biome_js_analyze::JsAnalyzerServices; use biome_js_parser::{parse, JsParserOptions}; use biome_js_syntax::JsFileSource; use biome_test_utils::{ - code_fix_to_string, create_analyzer_options, diagnostic_to_string, parse_test_path, - project_layout_with_node_manifest, scripts_from_json, + code_fix_to_string, create_analyzer_options, dependency_graph_for_test_file, + diagnostic_to_string, parse_test_path, project_layout_with_node_manifest, scripts_from_json, }; use camino::Utf8Path; use std::ops::Deref; @@ -15,7 +16,7 @@ use std::{fs::read_to_string, slice}; #[ignore] #[test] fn quick_test() { - let input_file = Utf8Path::new("tests/specs/complexity/noUselessFragments/issue_4751.jsx"); + let input_file = Utf8Path::new("tests/specs/nursery/noImportCycles/invalidFoobar.js"); let file_name = input_file.file_name().unwrap(); let (group, rule) = parse_test_path(input_file); @@ -73,14 +74,12 @@ fn analyze( let options = create_analyzer_options(input_file, &mut diagnostics); let project_layout = project_layout_with_node_manifest(input_file, &mut diagnostics); - let (_, errors) = biome_js_analyze::analyze( - &root, - filter, - &options, - Vec::new(), - source_type, - project_layout, - |event| { + let dependency_graph = dependency_graph_for_test_file(input_file, &project_layout); + + let services = JsAnalyzerServices::from((dependency_graph, project_layout, source_type)); + + let (_, errors) = + biome_js_analyze::analyze(&root, filter, &options, Vec::new(), services, |event| { if let Some(mut diag) = event.diagnostic() { for action in event.actions() { diag = diag.add_code_suggestion(CodeSuggestionAdvice::from(action)); @@ -96,8 +95,7 @@ fn analyze( } ControlFlow::::Continue(()) - }, - ); + }); for error in errors { diagnostics.push(diagnostic_to_string(file_name, input_code, error)); diff --git a/crates/biome_js_analyze/tests/spec_tests.rs b/crates/biome_js_analyze/tests/spec_tests.rs index 528964ef62b0..0dd9d0656075 100644 --- a/crates/biome_js_analyze/tests/spec_tests.rs +++ b/crates/biome_js_analyze/tests/spec_tests.rs @@ -4,17 +4,19 @@ use biome_analyze::{ use biome_diagnostics::advice::CodeSuggestionAdvice; use biome_diagnostics::{DiagnosticExt, Severity}; use biome_fs::OsFileSystem; +use biome_js_analyze::JsAnalyzerServices; use biome_js_parser::{parse, JsParserOptions}; use biome_js_syntax::{JsFileSource, JsLanguage, ModuleKind}; use biome_package::PackageType; use biome_plugin_loader::AnalyzerGritPlugin; use biome_rowan::AstNode; use biome_test_utils::{ - assert_errors_are_absent, code_fix_to_string, create_analyzer_options, diagnostic_to_string, - has_bogus_nodes_or_empty_slots, parse_test_path, project_layout_with_node_manifest, - register_leak_checker, scripts_from_json, write_analyzer_snapshot, CheckActionType, + assert_errors_are_absent, code_fix_to_string, create_analyzer_options, + dependency_graph_for_test_file, diagnostic_to_string, has_bogus_nodes_or_empty_slots, + parse_test_path, project_layout_with_node_manifest, register_leak_checker, scripts_from_json, + write_analyzer_snapshot, CheckActionType, }; -use camino::Utf8Path; +use camino::{Utf8Component, Utf8Path}; use std::ops::Deref; use std::{fs::read_to_string, slice}; @@ -129,14 +131,21 @@ pub(crate) fn analyze_and_snap( let options = create_analyzer_options(input_file, &mut diagnostics); - let (_, errors) = biome_js_analyze::analyze( - &root, - filter, - &options, - plugins, - source_type, - project_layout, - |event| { + // FIXME: We probably want to enable it for all rules? Right now it seems to + // trigger a leak panic... + let dependency_graph = if input_file + .components() + .any(|component| component == Utf8Component::Normal("noImportCycles")) + { + dependency_graph_for_test_file(input_file, &project_layout) + } else { + Default::default() + }; + + let services = JsAnalyzerServices::from((dependency_graph, project_layout, source_type)); + + let (_, errors) = + biome_js_analyze::analyze(&root, filter, &options, plugins, services, |event| { if let Some(mut diag) = event.diagnostic() { for action in event.actions() { if check_action_type.is_suppression() { @@ -192,8 +201,7 @@ pub(crate) fn analyze_and_snap( } ControlFlow::::Continue(()) - }, - ); + }); for error in errors { diagnostics.push(diagnostic_to_string(file_name, input_code, error)); @@ -207,6 +215,18 @@ pub(crate) fn analyze_and_snap( source_type.file_extension(), ); + // FIXME: I wish we could do this more generically, but we cannot do this + // for all tests, since it would cause many incorrect replacements. + // Maybe there's a regular expression that could work, but it feels + // flimsy too... + if input_file + .components() + .any(|component| component == Utf8Component::Normal("noImportCycles")) + { + // Normalize Windows paths. + *snapshot = snapshot.replace('\\', "/"); + } + diagnostics.len() } diff --git a/crates/biome_js_analyze/tests/specs/nursery/noImportCycles/invalidBaz.js b/crates/biome_js_analyze/tests/specs/nursery/noImportCycles/invalidBaz.js new file mode 100644 index 000000000000..b7ad4239cdc5 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/noImportCycles/invalidBaz.js @@ -0,0 +1,5 @@ +import { bar } from "./invalidFoobar.js"; + +export function baz() { + bar(); +} diff --git a/crates/biome_js_analyze/tests/specs/nursery/noImportCycles/invalidBaz.js.snap b/crates/biome_js_analyze/tests/specs/nursery/noImportCycles/invalidBaz.js.snap new file mode 100644 index 000000000000..9618141baa8a --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/noImportCycles/invalidBaz.js.snap @@ -0,0 +1,31 @@ +--- +source: crates/biome_js_analyze/tests/spec_tests.rs +expression: invalidBaz.js +--- +# Input +```js +import { bar } from "./invalidFoobar.js"; + +export function baz() { + bar(); +} + +``` + +# Diagnostics +``` +invalidBaz.js:1:21 lint/nursery/noImportCycles ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! This import is part of a cycle. + + > 1 │ import { bar } from "./invalidFoobar.js"; + │ ^^^^^^^^^^^^^^^^^^^^ + 2 │ + 3 │ export function baz() { + + i This import resolves to tests/specs/nursery/noImportCycles/invalidFoobar.js + ... which imports tests/specs/nursery/noImportCycles/invalidBaz.js + ... which is the file we're importing from. + + +``` diff --git a/crates/biome_js_analyze/tests/specs/nursery/noImportCycles/invalidFoobar.js b/crates/biome_js_analyze/tests/specs/nursery/noImportCycles/invalidFoobar.js new file mode 100644 index 000000000000..e43e481bd384 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/noImportCycles/invalidFoobar.js @@ -0,0 +1,9 @@ +import { baz } from "./invalidBaz.js"; + +export function foo() { + baz(); +} + +export function bar() { + console.log("foobar"); +} diff --git a/crates/biome_js_analyze/tests/specs/nursery/noImportCycles/invalidFoobar.js.snap b/crates/biome_js_analyze/tests/specs/nursery/noImportCycles/invalidFoobar.js.snap new file mode 100644 index 000000000000..de4d30881915 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/noImportCycles/invalidFoobar.js.snap @@ -0,0 +1,35 @@ +--- +source: crates/biome_js_analyze/tests/spec_tests.rs +expression: invalidFoobar.js +--- +# Input +```js +import { baz } from "./invalidBaz.js"; + +export function foo() { + baz(); +} + +export function bar() { + console.log("foobar"); +} + +``` + +# Diagnostics +``` +invalidFoobar.js:1:21 lint/nursery/noImportCycles ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! This import is part of a cycle. + + > 1 │ import { baz } from "./invalidBaz.js"; + │ ^^^^^^^^^^^^^^^^^ + 2 │ + 3 │ export function foo() { + + i This import resolves to tests/specs/nursery/noImportCycles/invalidBaz.js + ... which imports tests/specs/nursery/noImportCycles/invalidFoobar.js + ... which is the file we're importing from. + + +``` diff --git a/crates/biome_js_analyze/tests/specs/nursery/noImportCycles/valid.js b/crates/biome_js_analyze/tests/specs/nursery/noImportCycles/valid.js new file mode 100644 index 000000000000..3088aadc2998 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/noImportCycles/valid.js @@ -0,0 +1,5 @@ +/* should not generate diagnostics */ + +import { foo } from "./invalidFoobar"; + +foo(); diff --git a/crates/biome_js_analyze/tests/specs/nursery/noImportCycles/valid.js.snap b/crates/biome_js_analyze/tests/specs/nursery/noImportCycles/valid.js.snap new file mode 100644 index 000000000000..fef0753daec3 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/noImportCycles/valid.js.snap @@ -0,0 +1,13 @@ +--- +source: crates/biome_js_analyze/tests/spec_tests.rs +expression: valid.js +--- +# Input +```js +/* should not generate diagnostics */ + +import { foo } from "./invalidFoobar"; + +foo(); + +``` diff --git a/crates/biome_js_parser/src/lib.rs b/crates/biome_js_parser/src/lib.rs index fba30d8bc1fc..f4174c802a12 100644 --- a/crates/biome_js_parser/src/lib.rs +++ b/crates/biome_js_parser/src/lib.rs @@ -78,7 +78,7 @@ pub use crate::{ parse::*, }; use biome_js_factory::JsSyntaxFactory; -use biome_js_syntax::{JsLanguage, JsSyntaxKind, LanguageVariant}; +use biome_js_syntax::{JsSyntaxKind, LanguageVariant}; use biome_parser::tree_sink::LosslessTreeSink; pub(crate) use parser::{JsParser, ParseRecoveryTokenSet}; pub(crate) use state::{JsParserState, StrictMode}; diff --git a/crates/biome_js_parser/src/parse.rs b/crates/biome_js_parser/src/parse.rs index 28487f8c405d..89de8dcc1d06 100644 --- a/crates/biome_js_parser/src/parse.rs +++ b/crates/biome_js_parser/src/parse.rs @@ -1,7 +1,7 @@ //! Utilities for high level parsing of js code. use crate::*; -use biome_js_syntax::{ +pub use biome_js_syntax::{ AnyJsRoot, JsFileSource, JsLanguage, JsModule, JsScript, JsSyntaxNode, ModuleKind, }; use biome_parser::token_source::Trivia; diff --git a/crates/biome_js_syntax/src/file_source.rs b/crates/biome_js_syntax/src/file_source.rs index 6764e4b8612d..f85c88ca2606 100644 --- a/crates/biome_js_syntax/src/file_source.rs +++ b/crates/biome_js_syntax/src/file_source.rs @@ -306,9 +306,22 @@ impl JsFileSource { } /// Try to return the JS file source corresponding to this file name from well-known files - pub fn try_from_well_known(_: &Utf8Path) -> Result { - // TODO: to be implemented - Err(FileSourceError::UnknownFileName) + pub fn try_from_well_known(path: &Utf8Path) -> Result { + // Be careful with definition files, because `Path::extension()` only + // returns the extension after the _last_ dot: + let file_name = path.file_name().ok_or(FileSourceError::MissingFileName)?; + if file_name.ends_with(".d.ts") { + return Self::try_from_extension("d.ts"); + } else if file_name.ends_with(".d.mts") { + return Self::try_from_extension("d.mts"); + } else if file_name.ends_with(".d.cts") { + return Self::try_from_extension("d.cts"); + } + + match path.extension() { + Some(extension) => Self::try_from_extension(extension), + None => Err(FileSourceError::MissingFileExtension), + } } /// Try to return the JS file source corresponding to this file extension diff --git a/crates/biome_service/src/file_handlers/css.rs b/crates/biome_service/src/file_handlers/css.rs index 308652faa78a..30990bb48ea2 100644 --- a/crates/biome_service/src/file_handlers/css.rs +++ b/crates/biome_service/src/file_handlers/css.rs @@ -553,6 +553,7 @@ pub(crate) fn code_actions(params: CodeActionsParams) -> PullActionsResult { range, workspace, path, + dependency_graph: _, project_layout, language, only, diff --git a/crates/biome_service/src/file_handlers/graphql.rs b/crates/biome_service/src/file_handlers/graphql.rs index 66616269d189..d21eb9747822 100644 --- a/crates/biome_service/src/file_handlers/graphql.rs +++ b/crates/biome_service/src/file_handlers/graphql.rs @@ -477,6 +477,7 @@ pub(crate) fn code_actions(params: CodeActionsParams) -> PullActionsResult { range, workspace, path, + dependency_graph: _, project_layout, language, only, diff --git a/crates/biome_service/src/file_handlers/javascript.rs b/crates/biome_service/src/file_handlers/javascript.rs index 1e0a1e82e08e..ef49d842bd94 100644 --- a/crates/biome_service/src/file_handlers/javascript.rs +++ b/crates/biome_service/src/file_handlers/javascript.rs @@ -39,7 +39,9 @@ use biome_formatter::{ }; use biome_fs::BiomePath; use biome_js_analyze::utils::rename::{RenameError, RenameSymbolExtensions}; -use biome_js_analyze::{analyze, analyze_with_inspect_matcher, ControlFlowGraph}; +use biome_js_analyze::{ + analyze, analyze_with_inspect_matcher, ControlFlowGraph, JsAnalyzerServices, +}; use biome_js_formatter::context::trailing_commas::TrailingCommas; use biome_js_formatter::context::{ArrowParentheses, JsFormatOptions, QuoteProperties, Semicolons}; use biome_js_formatter::format_node; @@ -617,7 +619,6 @@ fn debug_control_flow(parse: AnyParse, cursor: TextSize) -> String { }, &options, Vec::new(), - JsFileSource::default(), Default::default(), |_| ControlFlow::::Continue(()), ); @@ -678,13 +679,14 @@ pub(crate) fn lint(params: LintParams) -> LintResults { }; let mut process_lint = ProcessLint::new(¶ms); + let services = + JsAnalyzerServices::from((params.dependency_graph, params.project_layout, file_source)); let (_, analyze_diagnostics) = analyze( &tree, filter, &analyzer_options, Vec::new(), - file_source, - params.project_layout, + services, |signal| process_lint.process_signal(signal), ); @@ -698,6 +700,7 @@ pub(crate) fn code_actions(params: CodeActionsParams) -> PullActionsResult { range, workspace, path, + dependency_graph, project_layout, language, only, @@ -738,14 +741,15 @@ pub(crate) fn code_actions(params: CodeActionsParams) -> PullActionsResult { }; }; + let services = JsAnalyzerServices::from((dependency_graph, project_layout, source_type)); + debug!("Javascript runs the analyzer"); analyze( &tree, filter, &analyzer_options, Vec::new(), - source_type, - project_layout, + services, |signal| { actions.extend(signal.actions().into_code_action_iter().map(|item| { debug!("Pulled action category {:?}", item.category); @@ -813,13 +817,18 @@ pub(crate) fn fix_all(params: FixAllParams) -> Result PullActionsResult { range, workspace, path, + dependency_graph: _, project_layout, language, skip, diff --git a/crates/biome_service/src/file_handlers/mod.rs b/crates/biome_service/src/file_handlers/mod.rs index 1e8ae98d32e7..fd3295a596a8 100644 --- a/crates/biome_service/src/file_handlers/mod.rs +++ b/crates/biome_service/src/file_handlers/mod.rs @@ -22,6 +22,7 @@ use biome_console::fmt::Formatter; use biome_console::markup; use biome_css_analyze::METADATA as css_metadata; use biome_css_syntax::{CssFileSource, CssLanguage}; +use biome_dependency_graph::DependencyGraph; use biome_diagnostics::{category, Diagnostic, DiagnosticExt, Severity}; use biome_formatter::Printed; use biome_fs::BiomePath; @@ -388,6 +389,7 @@ pub struct FixAllParams<'a> { /// Whether it should format the code action pub(crate) should_format: bool, pub(crate) biome_path: &'a BiomePath, + pub(crate) dependency_graph: Arc, pub(crate) project_layout: Arc, pub(crate) document_file_source: DocumentFileSource, pub(crate) only: Vec, @@ -457,6 +459,7 @@ pub(crate) struct LintParams<'a> { pub(crate) only: Vec, pub(crate) skip: Vec, pub(crate) categories: RuleCategories, + pub(crate) dependency_graph: Arc, pub(crate) project_layout: Arc, pub(crate) suppression_reason: Option, pub(crate) enabled_rules: Vec, @@ -580,6 +583,7 @@ pub(crate) struct CodeActionsParams<'a> { pub(crate) range: Option, pub(crate) workspace: &'a WorkspaceSettingsHandle, pub(crate) path: &'a BiomePath, + pub(crate) dependency_graph: Arc, pub(crate) project_layout: Arc, pub(crate) language: DocumentFileSource, pub(crate) only: Vec, diff --git a/crates/biome_service/src/workspace/server.rs b/crates/biome_service/src/workspace/server.rs index 67c03b2eaf37..15e9165db188 100644 --- a/crates/biome_service/src/workspace/server.rs +++ b/crates/biome_service/src/workspace/server.rs @@ -904,6 +904,7 @@ impl Workspace for WorkspaceServer { skip, language: self.get_file_source(&path), categories, + dependency_graph: self.dependency_graph.clone(), project_layout: self.project_layout.clone(), suppression_reason: None, enabled_rules, @@ -980,6 +981,7 @@ impl Workspace for WorkspaceServer { range, workspace: &settings.into(), path: &path, + dependency_graph: self.dependency_graph.clone(), project_layout: self.project_layout.clone(), language, only, @@ -1117,6 +1119,7 @@ impl Workspace for WorkspaceServer { workspace: settings.into(), should_format, biome_path: &path, + dependency_graph: self.dependency_graph.clone(), project_layout: self.project_layout.clone(), document_file_source: language, only, diff --git a/crates/biome_test_utils/Cargo.toml b/crates/biome_test_utils/Cargo.toml index 37064da24c26..fd261ba93a22 100644 --- a/crates/biome_test_utils/Cargo.toml +++ b/crates/biome_test_utils/Cargo.toml @@ -14,22 +14,25 @@ version = "0.0.0" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -biome_analyze = { workspace = true } -biome_configuration = { workspace = true } -biome_console = { workspace = true } -biome_deserialize = { workspace = true } -biome_diagnostics = { workspace = true } -biome_formatter = { workspace = true } -biome_json_parser = { workspace = true } -biome_package = { workspace = true } -biome_project_layout = { workspace = true } -biome_rowan = { workspace = true } -biome_service = { workspace = true } -camino = { workspace = true } -countme = { workspace = true, features = ["enable"] } -json_comments = "0.2.2" -serde_json = { workspace = true } -similar = { workspace = true } +biome_analyze = { workspace = true } +biome_configuration = { workspace = true } +biome_console = { workspace = true } +biome_dependency_graph = { workspace = true } +biome_deserialize = { workspace = true } +biome_diagnostics = { workspace = true } +biome_formatter = { workspace = true } +biome_fs = { workspace = true } +biome_js_parser = { workspace = true } +biome_json_parser = { workspace = true } +biome_package = { workspace = true } +biome_project_layout = { workspace = true } +biome_rowan = { workspace = true } +biome_service = { workspace = true } +camino = { workspace = true } +countme = { workspace = true, features = ["enable"] } +json_comments = "0.2.2" +serde_json = { workspace = true } +similar = { workspace = true } [lints] workspace = true diff --git a/crates/biome_test_utils/src/lib.rs b/crates/biome_test_utils/src/lib.rs index 1522a39a0b4a..e624ddb4c015 100644 --- a/crates/biome_test_utils/src/lib.rs +++ b/crates/biome_test_utils/src/lib.rs @@ -3,8 +3,11 @@ use biome_analyze::{AnalyzerAction, AnalyzerConfiguration, AnalyzerOptions}; use biome_configuration::Configuration; use biome_console::fmt::{Formatter, Termcolor}; use biome_console::markup; +use biome_dependency_graph::DependencyGraph; use biome_diagnostics::termcolor::Buffer; use biome_diagnostics::{DiagnosticExt, Error, PrintDiagnostic}; +use biome_fs::{BiomePath, FileSystem, OsFileSystem}; +use biome_js_parser::{JsFileSource, JsParserOptions}; use biome_json_parser::{JsonParserOptions, ParseDiagnostic}; use biome_package::PackageJson; use biome_project_layout::ProjectLayout; @@ -158,6 +161,46 @@ where } } +/// Creates a dependency graph that is initialized for the given `input_file`. +/// +/// It uses an [OsFileSystem] initialized for the directory in which the test +/// file resides and inserts all files from that directory, so that files +/// importing each other within that directory will be picked up correctly. +/// +/// The `project_layout` should be initialized in advance if you want any +/// manifest files to be discovered. +pub fn dependency_graph_for_test_file( + input_file: &Utf8Path, + project_layout: &ProjectLayout, +) -> Arc { + let dependency_graph = DependencyGraph::default(); + + let dir = input_file.parent().unwrap().to_path_buf(); + let paths: Vec<_> = std::fs::read_dir(&dir) + .unwrap() + .filter_map(|path| { + let path = Utf8PathBuf::try_from(path.unwrap().path()).unwrap(); + DocumentFileSource::from_well_known(&path) + .is_javascript_like() + .then(|| BiomePath::new(path)) + }) + .collect(); + let fs = OsFileSystem::new(dir); + + dependency_graph.update_imports_for_js_paths(&fs, project_layout, &paths, &[], |path| { + fs.read_file_from_path(path).ok().and_then(|content| { + let file_source = path + .extension() + .and_then(|extension| JsFileSource::try_from_extension(extension).ok()) + .unwrap_or_default(); + let parsed = biome_js_parser::parse(&content, file_source, JsParserOptions::default()); + parsed.try_tree() + }) + }); + + Arc::new(dependency_graph) +} + pub fn project_layout_with_node_manifest( input_file: &Utf8Path, diagnostics: &mut Vec, diff --git a/crates/biome_unicode_table/src/tables.rs b/crates/biome_unicode_table/src/tables.rs index 2016f15b83d8..1e7c1e35849e 100644 --- a/crates/biome_unicode_table/src/tables.rs +++ b/crates/biome_unicode_table/src/tables.rs @@ -170,8 +170,8 @@ pub mod derived_property { ('ಪ', 'ಳ'), ('ವ', 'ಹ'), ('\u{cbc}', 'ೄ'), - ('\u{cc6}', 'ೈ'), - ('ೊ', '\u{ccd}'), + ('\u{cc6}', '\u{cc8}'), + ('\u{cca}', '\u{ccd}'), ('\u{cd5}', '\u{cd6}'), ('ೝ', 'ೞ'), ('ೠ', '\u{ce3}'), @@ -256,8 +256,8 @@ pub mod derived_property { ('ᚁ', 'ᚚ'), ('ᚠ', 'ᛪ'), ('ᛮ', 'ᛸ'), - ('ᜀ', '᜕'), - ('ᜟ', '᜴'), + ('ᜀ', '\u{1715}'), + ('ᜟ', '\u{1734}'), ('ᝀ', '\u{1753}'), ('ᝠ', 'ᝬ'), ('ᝮ', 'ᝰ'), @@ -290,11 +290,11 @@ pub mod derived_property { ('\u{1b00}', 'ᭌ'), ('᭐', '᭙'), ('\u{1b6b}', '\u{1b73}'), - ('\u{1b80}', '᯳'), + ('\u{1b80}', '\u{1bf3}'), ('ᰀ', '\u{1c37}'), ('᱀', '᱉'), ('ᱍ', 'ᱽ'), - ('ᲀ', '\u{1c8a}'), + ('ᲀ', 'ᲊ'), ('Ა', 'Ჺ'), ('Ჽ', 'Ჿ'), ('\u{1cd0}', '\u{1cd2}'), @@ -378,10 +378,10 @@ pub mod derived_property { ('ꙿ', '\u{a6f1}'), ('ꜗ', 'ꜟ'), ('Ꜣ', 'ꞈ'), - ('Ꞌ', '\u{a7cd}'), + ('Ꞌ', 'ꟍ'), ('Ꟑ', 'ꟑ'), ('ꟓ', 'ꟓ'), - ('ꟕ', '\u{a7dc}'), + ('ꟕ', 'Ƛ'), ('ꟲ', 'ꠧ'), ('\u{a82c}', '\u{a82c}'), ('ꡀ', 'ꡳ'), @@ -390,9 +390,9 @@ pub mod derived_property { ('\u{a8e0}', 'ꣷ'), ('ꣻ', 'ꣻ'), ('ꣽ', '\u{a92d}'), - ('ꤰ', '꥓'), + ('ꤰ', '\u{a953}'), ('ꥠ', 'ꥼ'), - ('\u{a980}', '꧀'), + ('\u{a980}', '\u{a9c0}'), ('ꧏ', '꧙'), ('ꧠ', 'ꧾ'), ('ꨀ', '\u{aa36}'), @@ -479,7 +479,7 @@ pub mod derived_property { ('𐖣', '𐖱'), ('𐖳', '𐖹'), ('𐖻', '𐖼'), - ('\u{105c0}', '\u{105f3}'), + ('𐗀', '𐗳'), ('𐘀', '𐜶'), ('𐝀', '𐝕'), ('𐝠', '𐝧'), @@ -520,13 +520,13 @@ pub mod derived_property { ('𐳀', '𐳲'), ('𐴀', '\u{10d27}'), ('𐴰', '𐴹'), - ('\u{10d40}', '\u{10d65}'), + ('𐵀', '𐵥'), ('\u{10d69}', '\u{10d6d}'), - ('\u{10d6f}', '\u{10d85}'), + ('𐵯', '𐶅'), ('𐺀', '𐺩'), ('\u{10eab}', '\u{10eac}'), ('𐺰', '𐺱'), - ('\u{10ec2}', '\u{10ec4}'), + ('𐻂', '𐻄'), ('\u{10efc}', '𐼜'), ('𐼧', '𐼧'), ('𐼰', '\u{10f50}'), @@ -567,21 +567,21 @@ pub mod derived_property { ('𑌵', '𑌹'), ('\u{1133b}', '𑍄'), ('𑍇', '𑍈'), - ('𑍋', '𑍍'), + ('𑍋', '\u{1134d}'), ('𑍐', '𑍐'), ('\u{11357}', '\u{11357}'), ('𑍝', '𑍣'), ('\u{11366}', '\u{1136c}'), ('\u{11370}', '\u{11374}'), - ('\u{11380}', '\u{11389}'), - ('\u{1138b}', '\u{1138b}'), - ('\u{1138e}', '\u{1138e}'), - ('\u{11390}', '\u{113b5}'), - ('\u{113b7}', '\u{113c0}'), + ('𑎀', '𑎉'), + ('𑎋', '𑎋'), + ('𑎎', '𑎎'), + ('𑎐', '𑎵'), + ('𑎷', '\u{113c0}'), ('\u{113c2}', '\u{113c2}'), ('\u{113c5}', '\u{113c5}'), - ('\u{113c7}', '\u{113ca}'), - ('\u{113cc}', '\u{113d3}'), + ('\u{113c7}', '𑏊'), + ('𑏌', '𑏓'), ('\u{113e1}', '\u{113e2}'), ('𑐀', '𑑊'), ('𑑐', '𑑙'), @@ -597,7 +597,7 @@ pub mod derived_property { ('𑙐', '𑙙'), ('𑚀', '𑚸'), ('𑛀', '𑛉'), - ('\u{116d0}', '\u{116e3}'), + ('𑛐', '𑛣'), ('𑜀', '𑜚'), ('\u{1171d}', '\u{1172b}'), ('𑜰', '𑜹'), @@ -621,8 +621,8 @@ pub mod derived_property { ('𑩐', '\u{11a99}'), ('𑪝', '𑪝'), ('𑪰', '𑫸'), - ('\u{11bc0}', '\u{11be0}'), - ('\u{11bf0}', '\u{11bf9}'), + ('𑯀', '𑯠'), + ('𑯰', '𑯹'), ('𑰀', '𑰈'), ('𑰊', '\u{11c36}'), ('\u{11c38}', '𑱀'), @@ -655,9 +655,9 @@ pub mod derived_property { ('𒾐', '𒿰'), ('𓀀', '𓐯'), ('\u{13440}', '\u{13455}'), - ('\u{13460}', '\u{143fa}'), + ('𓑠', '𔏺'), ('𔐀', '𔙆'), - ('\u{16100}', '\u{16139}'), + ('𖄀', '𖄹'), ('𖠀', '𖨸'), ('𖩀', '𖩞'), ('𖩠', '𖩩'), @@ -670,18 +670,18 @@ pub mod derived_property { ('𖭐', '𖭙'), ('𖭣', '𖭷'), ('𖭽', '𖮏'), - ('\u{16d40}', '\u{16d6c}'), - ('\u{16d70}', '\u{16d79}'), + ('𖵀', '𖵬'), + ('𖵰', '𖵹'), ('𖹀', '𖹿'), ('𖼀', '𖽊'), ('\u{16f4f}', '𖾇'), ('\u{16f8f}', '𖾟'), ('𖿠', '𖿡'), ('𖿣', '\u{16fe4}'), - ('𖿰', '𖿱'), + ('\u{16ff0}', '\u{16ff1}'), ('𗀀', '𘟷'), ('𘠀', '𘳕'), - ('\u{18cff}', '𘴈'), + ('𘳿', '𘴈'), ('𚿰', '𚿳'), ('𚿵', '𚿻'), ('𚿽', '𚿾'), @@ -696,11 +696,11 @@ pub mod derived_property { ('𛲀', '𛲈'), ('𛲐', '𛲙'), ('\u{1bc9d}', '\u{1bc9e}'), - ('\u{1ccf0}', '\u{1ccf9}'), + ('𜳰', '𜳹'), ('\u{1cf00}', '\u{1cf2d}'), ('\u{1cf30}', '\u{1cf46}'), ('\u{1d165}', '\u{1d169}'), - ('𝅭', '\u{1d172}'), + ('\u{1d16d}', '\u{1d172}'), ('\u{1d17b}', '\u{1d182}'), ('\u{1d185}', '\u{1d18b}'), ('\u{1d1aa}', '\u{1d1ad}'), @@ -758,7 +758,7 @@ pub mod derived_property { ('𞊐', '\u{1e2ae}'), ('𞋀', '𞋹'), ('𞓐', '𞓹'), - ('\u{1e5d0}', '\u{1e5fa}'), + ('𞗐', '𞗺'), ('𞟠', '𞟦'), ('𞟨', '𞟫'), ('𞟭', '𞟮'), @@ -1040,7 +1040,7 @@ pub mod derived_property { ('ᰀ', 'ᰣ'), ('ᱍ', 'ᱏ'), ('ᱚ', 'ᱽ'), - ('ᲀ', '\u{1c8a}'), + ('ᲀ', 'ᲊ'), ('Ა', 'Ჺ'), ('Ჽ', 'Ჿ'), ('ᳩ', 'ᳬ'), @@ -1123,10 +1123,10 @@ pub mod derived_property { ('ꚠ', 'ꛯ'), ('ꜗ', 'ꜟ'), ('Ꜣ', 'ꞈ'), - ('Ꞌ', '\u{a7cd}'), + ('Ꞌ', 'ꟍ'), ('Ꟑ', 'ꟑ'), ('ꟓ', 'ꟓ'), - ('ꟕ', '\u{a7dc}'), + ('ꟕ', 'Ƛ'), ('ꟲ', 'ꠁ'), ('ꠃ', 'ꠅ'), ('ꠇ', 'ꠊ'), @@ -1224,7 +1224,7 @@ pub mod derived_property { ('𐖣', '𐖱'), ('𐖳', '𐖹'), ('𐖻', '𐖼'), - ('\u{105c0}', '\u{105f3}'), + ('𐗀', '𐗳'), ('𐘀', '𐜶'), ('𐝀', '𐝕'), ('𐝠', '𐝧'), @@ -1261,11 +1261,11 @@ pub mod derived_property { ('𐲀', '𐲲'), ('𐳀', '𐳲'), ('𐴀', '𐴣'), - ('\u{10d4a}', '\u{10d65}'), - ('\u{10d6f}', '\u{10d85}'), + ('𐵊', '𐵥'), + ('𐵯', '𐶅'), ('𐺀', '𐺩'), ('𐺰', '𐺱'), - ('\u{10ec2}', '\u{10ec4}'), + ('𐻂', '𐻄'), ('𐼀', '𐼜'), ('𐼧', '𐼧'), ('𐼰', '𐽅'), @@ -1304,13 +1304,13 @@ pub mod derived_property { ('𑌽', '𑌽'), ('𑍐', '𑍐'), ('𑍝', '𑍡'), - ('\u{11380}', '\u{11389}'), - ('\u{1138b}', '\u{1138b}'), - ('\u{1138e}', '\u{1138e}'), - ('\u{11390}', '\u{113b5}'), - ('\u{113b7}', '\u{113b7}'), - ('\u{113d1}', '\u{113d1}'), - ('\u{113d3}', '\u{113d3}'), + ('𑎀', '𑎉'), + ('𑎋', '𑎋'), + ('𑎎', '𑎎'), + ('𑎐', '𑎵'), + ('𑎷', '𑎷'), + ('𑏑', '𑏑'), + ('𑏓', '𑏓'), ('𑐀', '𑐴'), ('𑑇', '𑑊'), ('𑑟', '𑑡'), @@ -1345,7 +1345,7 @@ pub mod derived_property { ('𑩜', '𑪉'), ('𑪝', '𑪝'), ('𑪰', '𑫸'), - ('\u{11bc0}', '\u{11be0}'), + ('𑯀', '𑯠'), ('𑰀', '𑰈'), ('𑰊', '𑰮'), ('𑱀', '𑱀'), @@ -1369,9 +1369,9 @@ pub mod derived_property { ('𒾐', '𒿰'), ('𓀀', '𓐯'), ('𓑁', '𓑆'), - ('\u{13460}', '\u{143fa}'), + ('𓑠', '𔏺'), ('𔐀', '𔙆'), - ('\u{16100}', '\u{1611d}'), + ('𖄀', '𖄝'), ('𖠀', '𖨸'), ('𖩀', '𖩞'), ('𖩰', '𖪾'), @@ -1380,7 +1380,7 @@ pub mod derived_property { ('𖭀', '𖭃'), ('𖭣', '𖭷'), ('𖭽', '𖮏'), - ('\u{16d40}', '\u{16d6c}'), + ('𖵀', '𖵬'), ('𖹀', '𖹿'), ('𖼀', '𖽊'), ('𖽐', '𖽐'), @@ -1389,7 +1389,7 @@ pub mod derived_property { ('𖿣', '𖿣'), ('𗀀', '𘟷'), ('𘠀', '𘳕'), - ('\u{18cff}', '𘴈'), + ('𘳿', '𘴈'), ('𚿰', '𚿳'), ('𚿵', '𚿻'), ('𚿽', '𚿾'), @@ -1442,8 +1442,8 @@ pub mod derived_property { ('𞊐', '𞊭'), ('𞋀', '𞋫'), ('𞓐', '𞓫'), - ('\u{1e5d0}', '\u{1e5ed}'), - ('\u{1e5f0}', '\u{1e5f0}'), + ('𞗐', '𞗭'), + ('𞗰', '𞗰'), ('𞟠', '𞟦'), ('𞟨', '𞟫'), ('𞟭', '𞟮'), diff --git a/packages/@biomejs/backend-jsonrpc/src/workspace.ts b/packages/@biomejs/backend-jsonrpc/src/workspace.ts index 3e9432f10af2..709b096c0224 100644 --- a/packages/@biomejs/backend-jsonrpc/src/workspace.ts +++ b/packages/@biomejs/backend-jsonrpc/src/workspace.ts @@ -1502,6 +1502,10 @@ export interface Nursery { * Prevent usage of \ element in a Next.js project. */ noImgElement?: RuleConfiguration_for_Null; + /** + * Prevent import cycles. + */ + noImportCycles?: RuleConfiguration_for_Null; /** * Disallows the use of irregular whitespace characters. */ @@ -3281,6 +3285,7 @@ export type Category = | "lint/nursery/noHeadElement" | "lint/nursery/noHeadImportInDocument" | "lint/nursery/noImgElement" + | "lint/nursery/noImportCycles" | "lint/nursery/noImportantInKeyframe" | "lint/nursery/noInvalidDirectionInLinearGradient" | "lint/nursery/noInvalidGridAreas" diff --git a/packages/@biomejs/biome/configuration_schema.json b/packages/@biomejs/biome/configuration_schema.json index ad222938f77c..730bb7828237 100644 --- a/packages/@biomejs/biome/configuration_schema.json +++ b/packages/@biomejs/biome/configuration_schema.json @@ -2568,6 +2568,13 @@ { "type": "null" } ] }, + "noImportCycles": { + "description": "Prevent import cycles.", + "anyOf": [ + { "$ref": "#/definitions/RuleConfiguration" }, + { "type": "null" } + ] + }, "noIrregularWhitespace": { "description": "Disallows the use of irregular whitespace characters.", "anyOf": [ diff --git a/xtask/bench/src/language.rs b/xtask/bench/src/language.rs index d44eb8a9556d..c68050c6d27a 100644 --- a/xtask/bench/src/language.rs +++ b/xtask/bench/src/language.rs @@ -199,7 +199,6 @@ impl Analyze { filter, &options, Vec::new(), - JsFileSource::default(), Default::default(), |event| { black_box(event.diagnostic()); diff --git a/xtask/rules_check/src/lib.rs b/xtask/rules_check/src/lib.rs index 98f0e046b965..9f664ea93a9c 100644 --- a/xtask/rules_check/src/lib.rs +++ b/xtask/rules_check/src/lib.rs @@ -14,6 +14,7 @@ use biome_deserialize::json::deserialize_from_json_ast; use biome_diagnostics::{DiagnosticExt, PrintDiagnostic, Severity}; use biome_fs::BiomePath; use biome_graphql_syntax::GraphqlLanguage; +use biome_js_analyze::JsAnalyzerServices; use biome_js_parser::JsParserOptions; use biome_js_syntax::{EmbeddingKind, JsFileSource, JsLanguage, TextSize}; use biome_json_factory::make; @@ -426,34 +427,29 @@ fn assert_lint( test, ); - biome_js_analyze::analyze( - &root, - filter, - &options, - vec![], - file_source, - Default::default(), - |signal| { - if let Some(mut diag) = signal.diagnostic() { - for action in signal.actions() { - if !action.is_suppression() { - diag = diag.add_code_suggestion(action.into()); - } + let services = + JsAnalyzerServices::from((Default::default(), Default::default(), file_source)); + + biome_js_analyze::analyze(&root, filter, &options, vec![], services, |signal| { + if let Some(mut diag) = signal.diagnostic() { + for action in signal.actions() { + if !action.is_suppression() { + diag = diag.add_code_suggestion(action.into()); } + } - let error = diag.with_file_path(&file_path).with_file_source_code(code); - let res = diagnostics.write_diagnostic(error); + let error = diag.with_file_path(&file_path).with_file_source_code(code); + let res = diagnostics.write_diagnostic(error); - // Abort the analysis on error - if let Err(err) = res { - eprintln!("Error: {err}"); - return ControlFlow::Break(err); - } + // Abort the analysis on error + if let Err(err) = res { + eprintln!("Error: {err}"); + return ControlFlow::Break(err); } + } - ControlFlow::Continue(()) - }, - ); + ControlFlow::Continue(()) + }); } } DocumentFileSource::Json(file_source) => {