use crate::config::{RuleConfig, Severity};
use crate::rules::{Rule, RuleBuildError, ScanContext, Violation};
use regex::Regex;
#[derive(Debug)]
pub struct BannedImportRule {
id: String,
severity: Severity,
message: String,
suggest: Option<String>,
glob: Option<String>,
#[allow(dead_code)]
packages: Vec<String>,
import_re: Regex,
}
impl BannedImportRule {
pub fn new(config: &RuleConfig) -> Result<Self, RuleBuildError> {
if config.packages.is_empty() {
return Err(RuleBuildError::MissingField(
config.id.clone(),
"packages",
));
}
let escaped: Vec<String> = config
.packages
.iter()
.map(|p| regex::escape(p))
.collect();
let pkg_group = escaped.join("|");
let pattern = format!(
r#"(?:import\s+.*?\s+from\s+|import\s+|export\s+.*?\s+from\s+|require\s*\(\s*)['"]({})(?:/[^'"]*)?['"]"#,
pkg_group
);
let import_re = Regex::new(&pattern)
.map_err(|e| RuleBuildError::InvalidRegex(config.id.clone(), e))?;
let default_glob = "**/*.{ts,tsx,js,jsx,mjs,cjs}".to_string();
Ok(Self {
id: config.id.clone(),
severity: config.severity,
message: config.message.clone(),
suggest: config.suggest.clone(),
glob: config.glob.clone().or(Some(default_glob)),
packages: config.packages.clone(),
import_re,
})
}
}
impl Rule for BannedImportRule {
fn id(&self) -> &str {
&self.id
}
fn severity(&self) -> Severity {
self.severity
}
fn file_glob(&self) -> Option<&str> {
self.glob.as_deref()
}
fn check_file(&self, ctx: &ScanContext) -> Vec<Violation> {
let mut violations = Vec::new();
for (line_idx, line) in ctx.content.lines().enumerate() {
for cap in self.import_re.captures_iter(line) {
let matched_pkg = cap.get(1).unwrap().as_str();
let full_match = cap.get(0).unwrap();
violations.push(Violation {
rule_id: self.id.clone(),
severity: self.severity,
file: ctx.file_path.to_path_buf(),
line: Some(line_idx + 1),
column: Some(full_match.start() + 1),
message: format!("{}: '{}'", self.message, matched_pkg),
suggest: self.suggest.clone(),
source_line: Some(line.to_string()),
fix: None,
});
}
}
violations
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
fn make_rule(packages: Vec<&str>) -> BannedImportRule {
let config = RuleConfig {
id: "test-banned-import".into(),
severity: Severity::Error,
message: "banned import".into(),
suggest: Some("use an alternative".into()),
packages: packages.into_iter().map(|s| s.to_string()).collect(),
..Default::default()
};
BannedImportRule::new(&config).unwrap()
}
fn check(rule: &BannedImportRule, content: &str) -> Vec<Violation> {
let ctx = ScanContext {
file_path: Path::new("test.ts"),
content,
};
rule.check_file(&ctx)
}
#[test]
fn detects_named_import() {
let rule = make_rule(vec!["moment"]);
let violations = check(&rule, r#"import moment from 'moment';"#);
assert_eq!(violations.len(), 1);
assert!(violations[0].message.contains("moment"));
}
#[test]
fn detects_destructured_import() {
let rule = make_rule(vec!["moment"]);
let violations = check(&rule, r#"import { format } from "moment";"#);
assert_eq!(violations.len(), 1);
}
#[test]
fn detects_side_effect_import() {
let rule = make_rule(vec!["styled-components"]);
let violations = check(&rule, r#"import 'styled-components';"#);
assert_eq!(violations.len(), 1);
}
#[test]
fn detects_require() {
let rule = make_rule(vec!["moment"]);
let violations = check(&rule, r#"const moment = require('moment');"#);
assert_eq!(violations.len(), 1);
}
#[test]
fn detects_subpath_import() {
let rule = make_rule(vec!["lodash"]);
let violations = check(&rule, r#"import debounce from 'lodash/debounce';"#);
assert_eq!(violations.len(), 1);
assert!(violations[0].message.contains("lodash"));
}
#[test]
fn no_false_positive_on_similar_names() {
let rule = make_rule(vec!["moment"]);
let violations = check(&rule, r#"import momentum from 'momentum';"#);
assert!(violations.is_empty(), "should not match 'momentum' when banning 'moment'");
}
#[test]
fn detects_scoped_package() {
let rule = make_rule(vec!["@emotion/styled"]);
let violations = check(&rule, r#"import styled from '@emotion/styled';"#);
assert_eq!(violations.len(), 1);
}
#[test]
fn detects_export_from() {
let rule = make_rule(vec!["moment"]);
let violations = check(&rule, r#"export { default } from 'moment';"#);
assert_eq!(violations.len(), 1);
}
#[test]
fn no_match_on_safe_imports() {
let rule = make_rule(vec!["moment"]);
let violations = check(&rule, r#"import { format } from 'date-fns';"#);
assert!(violations.is_empty());
}
#[test]
fn multiple_banned_packages() {
let rule = make_rule(vec!["styled-components", "@emotion/styled", "@emotion/css"]);
let content = r#"import styled from 'styled-components';
import { css } from '@emotion/css';
import { jsx } from '@emotion/react';"#;
let violations = check(&rule, content);
assert_eq!(violations.len(), 2);
}
#[test]
fn missing_packages_error() {
let config = RuleConfig {
id: "test".into(),
severity: Severity::Error,
message: "test".into(),
..Default::default()
};
let err = BannedImportRule::new(&config).unwrap_err();
assert!(matches!(err, RuleBuildError::MissingField(_, "packages")));
}
#[test]
fn default_glob_set() {
let rule = make_rule(vec!["moment"]);
assert_eq!(rule.file_glob(), Some("**/*.{ts,tsx,js,jsx,mjs,cjs}"));
}
}