diff --git a/crates/oxc_linter/src/rules.rs b/crates/oxc_linter/src/rules.rs index 3326856202a63..11cdfc70de79a 100644 --- a/crates/oxc_linter/src/rules.rs +++ b/crates/oxc_linter/src/rules.rs @@ -104,6 +104,7 @@ mod eslint { pub mod no_redeclare; pub mod no_regex_spaces; pub mod no_restricted_globals; + pub mod no_restricted_imports; pub mod no_return_assign; pub mod no_script_url; pub mod no_self_assign; @@ -536,6 +537,7 @@ oxc_macros::declare_all_lint_rules! { eslint::max_classes_per_file, eslint::max_lines, eslint::max_params, + eslint::no_restricted_imports, eslint::no_object_constructor, eslint::no_duplicate_imports, eslint::no_alert, diff --git a/crates/oxc_linter/src/rules/eslint/no_restricted_imports.rs b/crates/oxc_linter/src/rules/eslint/no_restricted_imports.rs new file mode 100644 index 0000000000000..1ef31236f44d6 --- /dev/null +++ b/crates/oxc_linter/src/rules/eslint/no_restricted_imports.rs @@ -0,0 +1,338 @@ +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; +use oxc_span::{CompactStr, Span}; +use rustc_hash::FxHashMap; +use serde::Deserialize; +use serde_json::Value; + +use crate::{context::LintContext, module_record::ImportImportName, rule::Rule}; + +fn no_restricted_imports_diagnostic( + ctx: &LintContext, + span: Span, + message: Option, + source: &str, +) { + let msg = message.unwrap_or_else(|| { + CompactStr::new(&format!("'{source}' import is restricted from being used.")) + }); + ctx.diagnostic( + OxcDiagnostic::warn(msg).with_help("Remove the import statement.").with_label(span), + ); +} + +#[derive(Debug, Default, Clone)] +pub struct NoRestrictedImports { + paths: Box, +} + +#[derive(Debug, Default, Clone)] +struct NoRestrictedImportsConfig { + paths: Box<[RestrictedPath]>, +} + +#[derive(Debug, Clone, Deserialize)] +struct RestrictedPath { + name: CompactStr, + #[serde(rename = "importNames")] + import_names: Option>, + message: Option, +} + +declare_oxc_lint!( + /// ### What it does + /// This rule allows you to specify imports that you don’t want to use in your application. + /// It applies to static imports only, not dynamic ones. + /// + /// ### Why is this bad? + ///Some imports might not make sense in a particular environment. For example, Node.js’ fs module would not make sense in an environment that didn’t have a file system. + /// + /// Some modules provide similar or identical functionality, think lodash and underscore. Your project may have standardized on a module. You want to make sure that the other alternatives are not being used as this would unnecessarily bloat the project and provide a higher maintenance cost of two dependencies when one would suffice. + /// + /// ### Examples + /// + /// Examples of **incorrect** code for this rule: + /// ```js + /// /*eslint no-restricted-imports: ["error", { + /// "name": "disallowed-import", + /// "message": "Please use 'allowed-import' instead" + /// }]*/ + /// + /// import foo from 'disallowed-import'; + /// ``` + /// + /// Examples of **correct** code for this rule: + /// ```js + /// /*eslint no-restricted-imports: ["error", {"name": "fs"}]*/ + /// + /// import crypto from 'crypto'; + /// export { foo } from "bar"; + /// ``` + NoRestrictedImports, + style, +); + +impl Rule for NoRestrictedImports { + fn from_configuration(value: serde_json::Value) -> Self { + let mut paths = Vec::new(); + match value { + Value::Array(module_names) => { + for module_name in module_names { + if let Some(module_name) = module_name.as_str() { + paths.push(RestrictedPath { + name: CompactStr::new(module_name), + import_names: None, + message: None, + }); + } + } + } + Value::String(module_name) => { + paths.push(RestrictedPath { + name: CompactStr::new(module_name.as_str()), + import_names: None, + message: None, + }); + } + Value::Object(obj) => { + if let Some(paths_value) = obj.get("paths") { + if let Some(paths_array) = paths_value.as_array() { + for path_value in paths_array { + if let Ok(mut path) = + serde_json::from_value::(path_value.clone()) + { + if let Some(import_names) = path.import_names { + path.import_names = Some( + import_names + .iter() + .map(|s| CompactStr::new(s)) + .collect::>() + .into_boxed_slice(), + ); + } + paths.push(path); + } + } + } + } + } + _ => {} + } + + Self { paths: Box::new(NoRestrictedImportsConfig { paths: paths.into_boxed_slice() }) } + } + + fn run_once(&self, ctx: &LintContext<'_>) { + let module_record = ctx.module_record(); + let mut side_effect_import_map: FxHashMap<&CompactStr, Vec> = FxHashMap::default(); + + for path in &self.paths.paths { + for entry in &module_record.import_entries { + let source = entry.module_request.name(); + let span = entry.module_request.span(); + + if source == path.name.as_str() { + if let Some(import_names) = &path.import_names { + match &entry.import_name { + ImportImportName::Name(import) => { + let name = CompactStr::new(import.name()); + + if !import_names.contains(&name) { + no_restricted_imports_diagnostic( + ctx, + span, + path.message.clone(), + source, + ); + return; + } + } + ImportImportName::Default(_) | ImportImportName::NamespaceObject => { + let name = CompactStr::new(entry.local_name.name()); + if !import_names.contains(&name) { + no_restricted_imports_diagnostic( + ctx, + span, + path.message.clone(), + source, + ); + return; + } + } + } + } else { + no_restricted_imports_diagnostic(ctx, span, path.message.clone(), source); + } + } + } + + for (source, requests) in &module_record.requested_modules { + for request in requests { + if request.is_import && module_record.import_entries.is_empty() { + side_effect_import_map.entry(source).or_default().push(request.span); + } + } + } + + for (source, spans) in &side_effect_import_map { + if source.as_str() == path.name.as_str() { + if let Some(span) = spans.iter().next() { + no_restricted_imports_diagnostic(ctx, *span, path.message.clone(), source); + } + return; + } + } + + for entry in &module_record.local_export_entries { + if let Some(module_request) = &entry.module_request { + let source = module_request.name(); + let span = entry.span; + + if source == path.name.as_str() { + no_restricted_imports_diagnostic(ctx, span, path.message.clone(), source); + return; + } + } + } + for entry in &module_record.indirect_export_entries { + if let Some(module_request) = &entry.module_request { + let source = module_request.name(); + let span = entry.span; + + if source == path.name.as_str() { + no_restricted_imports_diagnostic(ctx, span, path.message.clone(), source); + return; + } + } + } + } + } +} + +#[test] +fn test() { + use crate::tester::Tester; + + let pass = vec![ + // Basic cases - no matches + ( + r#"import os from "os";"#, + Some(serde_json::json!({ + "paths": [{ "name": "fs" }] + })), + ), + ( + r#"import fs from "fs";"#, + Some(serde_json::json!({ + "paths": [{ "name": "crypto" }] + })), + ), + ( + r#"import path from "path";"#, + Some(serde_json::json!({ + "paths": [ + { "name": "crypto" }, + { "name": "stream" }, + { "name": "os" } + ] + })), + ), + // Testing with import names + ( + r#"import AllowedObject from "foo";"#, + Some(serde_json::json!({ + "paths": [{ + "name": "foo", + "importNames": ["AllowedObject"] + }] + })), + ), + // Testing relative paths + ( + "import relative from '../foo';", + Some(serde_json::json!({ + "paths": [{ "name": "../notFoo" }] + })), + ), + // Multiple restricted imports + ( + r#"import { DisallowedObjectOne, DisallowedObjectTwo } from "foo";"#, + Some(serde_json::json!({ + "paths": [{ + "name": "foo", + "importNames": ["DisallowedObjectOne", "DisallowedObjectTwo"], + }] + })), + ), + ]; + + let fail = vec![ + // Basic restrictions + ( + r#"import "fs""#, + Some(serde_json::json!({ + "paths": [{ "name": "fs" }] + })), + ), + // With custom message + ( + r#"import withGitignores from "foo";"#, + Some(serde_json::json!({ + "paths": [{ + "name": "foo", + "message": "Please import from 'bar' instead." + }] + })), + ), + // Restricting default import + ( + r#"import DisallowedObject from "foo";"#, + Some(serde_json::json!({ + "paths": [{ + "name": "foo", + "importNames": ["default"], + "message": "Please import the default import of 'foo' from /bar/ instead." + }] + })), + ), + // Namespace imports + ( + r#"import * as All from "foo";"#, + Some(serde_json::json!({ + "paths": [{ + "name": "foo", + "importNames": ["DisallowedObject"], + "message": "Please import 'DisallowedObject' from /bar/ instead." + }] + })), + ), + // Export restrictions + ( + r#"export { something } from "fs";"#, + Some(serde_json::json!({ + "paths": [{ "name": "fs" }] + })), + ), + // Complex case with multiple restrictions + ( + r#"import { foo, bar, baz } from "mod""#, + Some(serde_json::json!({ + "paths": [ + { + "name": "mod", + "importNames": ["foo"], + "message": "Import foo from qux instead." + }, + { + "name": "mod", + "importNames": ["baz"], + "message": "Import baz from qux instead." + } + ] + })), + ), + ]; + + Tester::new(NoRestrictedImports::NAME, NoRestrictedImports::CATEGORY, pass, fail) + .test_and_snapshot(); +} diff --git a/crates/oxc_linter/src/snapshots/eslint_no_restricted_imports.snap b/crates/oxc_linter/src/snapshots/eslint_no_restricted_imports.snap new file mode 100644 index 0000000000000..1af015ef61a63 --- /dev/null +++ b/crates/oxc_linter/src/snapshots/eslint_no_restricted_imports.snap @@ -0,0 +1,45 @@ +--- +source: crates/oxc_linter/src/tester.rs +snapshot_kind: text +--- + ⚠ eslint(no-restricted-imports): 'fs' import is restricted from being used. + ╭─[no_restricted_imports.tsx:1:8] + 1 │ import "fs" + · ──── + ╰──── + help: Remove the import statement. + + ⚠ eslint(no-restricted-imports): Please import from 'bar' instead. + ╭─[no_restricted_imports.tsx:1:28] + 1 │ import withGitignores from "foo"; + · ───── + ╰──── + help: Remove the import statement. + + ⚠ eslint(no-restricted-imports): Please import the default import of 'foo' from /bar/ instead. + ╭─[no_restricted_imports.tsx:1:30] + 1 │ import DisallowedObject from "foo"; + · ───── + ╰──── + help: Remove the import statement. + + ⚠ eslint(no-restricted-imports): Please import 'DisallowedObject' from /bar/ instead. + ╭─[no_restricted_imports.tsx:1:22] + 1 │ import * as All from "foo"; + · ───── + ╰──── + help: Remove the import statement. + + ⚠ eslint(no-restricted-imports): 'fs' import is restricted from being used. + ╭─[no_restricted_imports.tsx:1:10] + 1 │ export { something } from "fs"; + · ───────── + ╰──── + help: Remove the import statement. + + ⚠ eslint(no-restricted-imports): Import foo from qux instead. + ╭─[no_restricted_imports.tsx:1:31] + 1 │ import { foo, bar, baz } from "mod" + · ───── + ╰──── + help: Remove the import statement.