211 lines
6.9 KiB
Python
211 lines
6.9 KiB
Python
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
|