from __future__ import annotations import ast import re from dataclasses import dataclass from pathlib import Path from typing import Any from depaudit.checks import UnusedDependency @dataclass class ImportStatement: module_name: str names: list[str] line_number: int is_from: bool alias: str | None = None class SourceParser: def __init__(self, language: str): self.language = language def parse_file(self, file_path: Path) -> list[ImportStatement]: raise NotImplementedError class PythonSourceParser(SourceParser): def __init__(self): super().__init__("python") def parse_file(self, file_path: Path) -> list[ImportStatement]: imports = [] try: with open(file_path, "r", encoding="utf-8") as f: content = f.read() tree = ast.parse(content) for node in ast.walk(tree): if isinstance(node, ast.Import): for alias in node.names: imports.append( ImportStatement( module_name=alias.name, names=[alias.asname or alias.name], line_number=node.lineno, is_from=False, alias=alias.asname, ) ) elif isinstance(node, ast.ImportFrom): module = node.module or "" names = [alias.asname or alias.name for alias in node.names] for alias in node.names: imports.append( ImportStatement( module_name=f"{module}.{alias.name}" if module else alias.name, names=[alias.asname or alias.name], line_number=node.lineno, is_from=True, alias=alias.asname, ) ) except Exception: pass return imports class JavaScriptSourceParser(SourceParser): def __init__(self): super().__init__("javascript") def parse_file(self, file_path: Path) -> list[ImportStatement]: imports = [] content = file_path.read_text(encoding="utf-8") import_patterns = [ (r"import\s+(?:\{[^}]*\}|\*\s+as\s+\w+|\w+)\s+from\s+[\"\']([^\"\']+)[\"\']", True), (r"import\s+[\"\']([^\"\']+)[\"\']", True), (r"require\s*\(\s*[\"\']([^\"\']+)[\"\']\s*\)", False), ] for pattern, is_from in import_patterns: for match in re.finditer(pattern, content): module_name = match.group(1) imports.append( ImportStatement( module_name=module_name, names=[module_name.split("/")[-1].split("@")[0]], line_number=content[:match.start()].count("\n") + 1, is_from=is_from, ) ) return imports class GoSourceParser(SourceParser): def __init__(self): super().__init__("go") def parse_file(self, file_path: Path) -> list[ImportStatement]: imports = [] content = file_path.read_text(encoding="utf-8") import_pattern = r"import\s*\(([^)]+)\)" block_match = re.search(import_pattern, content, re.DOTALL) if block_match: block = block_match.group(1) for line in block.split("\n"): import_match = re.search(r'"([^"]+)"', line) if import_match: module_name = import_match.group(1) imports.append( ImportStatement( module_name=module_name, names=[module_name.split("/")[-1]], line_number=content[:block_match.start()].count("\n") + 1, is_from=True, ) ) inline_imports = re.findall(r'\bimport\s+["\']([^"\']+)["\']', content) for module_name in inline_imports: imports.append( ImportStatement( module_name=module_name, names=[module_name.split("/")[-1]], line_number=1, is_from=True, ) ) return imports PARSERS = { "python": PythonSourceParser, "javascript": JavaScriptSourceParser, "go": GoSourceParser, } def get_source_parser(language: str) -> SourceParser | None: parser_class = PARSERS.get(language) if parser_class: return parser_class() return None def check_unused_dependencies( dependencies: list[tuple[str, str]], source_dir: Path, language: str, exclude_patterns: list[str] | None = None, ) -> list[UnusedDependency]: exclude_patterns = exclude_patterns or [] parser = get_source_parser(language) if parser is None: return [] all_imports: set[str] = set() for file_path in source_dir.rglob("*"): if file_path.is_file(): skip = False for pattern in exclude_patterns: if file_path.match(pattern): skip = True break if skip: continue if language == "python" and file_path.suffix == ".py": file_imports = parser.parse_file(file_path) all_imports.update(imp.module_name.split(".")[0] for imp in file_imports) elif language == "javascript" and file_path.suffix in (".js", ".jsx", ".ts", ".tsx"): file_imports = parser.parse_file(file_path) all_imports.update(imp.module_name.split("/")[0].split("@")[0] for imp in file_imports) elif language == "go" and file_path.suffix == ".go": file_imports = parser.parse_file(file_path) all_imports.update(imp.module_name.split("/")[-1] for imp in file_imports) unused = [] for name, version in dependencies: normalized_name = name.lower().replace("_", "-").split("/")[-1].split("@")[0] found = False for imp in all_imports: normalized_imp = imp.lower().replace("_", "-") if normalized_name in normalized_imp or normalized_imp in normalized_name: found = True break if not found: unused.append( UnusedDependency( package_name=name, version=version, language=language, declared_in="dependency file", file_path=str(source_dir), reason=f"Package '{name}' is declared but never imported or required", ) ) return unused