431 lines
15 KiB
Python
431 lines
15 KiB
Python
"""
|
|
React Project Analyzer.
|
|
|
|
Analyzes React codebases to extract component information,
|
|
detect patterns, and identify style usage.
|
|
"""
|
|
|
|
import re
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional, Set
|
|
|
|
from .base import ComponentInfo, Location
|
|
|
|
# Patterns for React component detection
|
|
FUNCTIONAL_COMPONENT = re.compile(
|
|
r"(?:export\s+)?(?:const|let|var|function)\s+([A-Z][A-Za-z0-9]*)\s*(?::\s*(?:React\.)?FC)?"
|
|
r"\s*(?:=\s*(?:\([^)]*\)|[a-zA-Z_]\w*)\s*=>|\()",
|
|
re.MULTILINE,
|
|
)
|
|
|
|
CLASS_COMPONENT = re.compile(
|
|
r"class\s+([A-Z][A-Za-z0-9]*)\s+extends\s+(?:React\.)?(?:Component|PureComponent)", re.MULTILINE
|
|
)
|
|
|
|
FORWARD_REF = re.compile(
|
|
r"(?:export\s+)?(?:const|let)\s+([A-Z][A-Za-z0-9]*)\s*=\s*(?:React\.)?forwardRef", re.MULTILINE
|
|
)
|
|
|
|
MEMO_COMPONENT = re.compile(
|
|
r"(?:export\s+)?(?:const|let)\s+([A-Z][A-Za-z0-9]*)\s*=\s*(?:React\.)?memo\(", re.MULTILINE
|
|
)
|
|
|
|
# Import patterns
|
|
IMPORT_PATTERN = re.compile(
|
|
r'import\s+(?:\{[^}]+\}|\*\s+as\s+\w+|\w+)\s+from\s+["\']([^"\']+)["\']', re.MULTILINE
|
|
)
|
|
|
|
STYLE_IMPORT = re.compile(
|
|
r'import\s+(?:(\w+)\s+from\s+)?["\']([^"\']+\.(?:css|scss|sass|less|styl))["\']', re.MULTILINE
|
|
)
|
|
|
|
# Inline style patterns
|
|
INLINE_STYLE_OBJECT = re.compile(r"style\s*=\s*\{\s*\{([^}]+)\}\s*\}", re.MULTILINE | re.DOTALL)
|
|
|
|
INLINE_STYLE_VAR = re.compile(r"style\s*=\s*\{(\w+)\}", re.MULTILINE)
|
|
|
|
# Props extraction
|
|
PROPS_DESTRUCTURE = re.compile(r"\(\s*\{\s*([^}]+)\s*\}\s*(?::\s*[^)]+)?\)", re.MULTILINE)
|
|
|
|
PROPS_INTERFACE = re.compile(r"interface\s+\w*Props\s*\{([^}]+)\}", re.MULTILINE | re.DOTALL)
|
|
|
|
PROPS_TYPE = re.compile(r"type\s+\w*Props\s*=\s*\{([^}]+)\}", re.MULTILINE | re.DOTALL)
|
|
|
|
|
|
class ReactAnalyzer:
|
|
"""Analyzes React projects for component structure and style usage."""
|
|
|
|
def __init__(self, root_path: str):
|
|
self.root = Path(root_path).resolve()
|
|
|
|
async def analyze(self, component_files: Optional[List[Path]] = None) -> List[ComponentInfo]:
|
|
"""
|
|
Analyze React components in the project.
|
|
|
|
Args:
|
|
component_files: Optional list of files to analyze.
|
|
If None, scans the project.
|
|
|
|
Returns:
|
|
List of ComponentInfo for each detected component.
|
|
"""
|
|
if component_files is None:
|
|
component_files = self._find_component_files()
|
|
|
|
components = []
|
|
|
|
for file_path in component_files:
|
|
try:
|
|
file_components = await self._analyze_file(file_path)
|
|
components.extend(file_components)
|
|
except Exception:
|
|
# Log error but continue
|
|
continue
|
|
|
|
return components
|
|
|
|
def _find_component_files(self) -> List[Path]:
|
|
"""Find all potential React component files."""
|
|
skip_dirs = {"node_modules", ".git", "dist", "build", ".next"}
|
|
component_files = []
|
|
|
|
for ext in ["*.jsx", "*.tsx"]:
|
|
for path in self.root.rglob(ext):
|
|
if not any(skip in path.parts for skip in skip_dirs):
|
|
component_files.append(path)
|
|
|
|
# Also check .js/.ts files that look like components
|
|
for ext in ["*.js", "*.ts"]:
|
|
for path in self.root.rglob(ext):
|
|
if any(skip in path.parts for skip in skip_dirs):
|
|
continue
|
|
# Skip config and utility files
|
|
if any(
|
|
x in path.name.lower() for x in ["config", "util", "helper", "hook", "context"]
|
|
):
|
|
continue
|
|
# Check if PascalCase (likely component)
|
|
if path.stem[0].isupper():
|
|
component_files.append(path)
|
|
|
|
return component_files
|
|
|
|
async def _analyze_file(self, file_path: Path) -> List[ComponentInfo]:
|
|
"""Analyze a single file for React components."""
|
|
content = file_path.read_text(encoding="utf-8", errors="ignore")
|
|
components = []
|
|
|
|
# Find all components in the file
|
|
component_matches = []
|
|
|
|
# Functional components
|
|
for match in FUNCTIONAL_COMPONENT.finditer(content):
|
|
name = match.group(1)
|
|
if self._is_valid_component_name(name):
|
|
component_matches.append((name, "functional", match.start()))
|
|
|
|
# Class components
|
|
for match in CLASS_COMPONENT.finditer(content):
|
|
name = match.group(1)
|
|
component_matches.append((name, "class", match.start()))
|
|
|
|
# forwardRef components
|
|
for match in FORWARD_REF.finditer(content):
|
|
name = match.group(1)
|
|
component_matches.append((name, "forwardRef", match.start()))
|
|
|
|
# memo components
|
|
for match in MEMO_COMPONENT.finditer(content):
|
|
name = match.group(1)
|
|
component_matches.append((name, "memo", match.start()))
|
|
|
|
# Dedupe by name (keep first occurrence)
|
|
seen_names = set()
|
|
unique_matches = []
|
|
for name, comp_type, pos in component_matches:
|
|
if name not in seen_names:
|
|
seen_names.add(name)
|
|
unique_matches.append((name, comp_type, pos))
|
|
|
|
# Extract imports (shared across all components in file)
|
|
imports = self._extract_imports(content)
|
|
style_files = self._extract_style_imports(content)
|
|
inline_styles = self._find_inline_styles(content)
|
|
|
|
# Create ComponentInfo for each
|
|
for name, comp_type, pos in unique_matches:
|
|
# Extract props for this component
|
|
props = self._extract_props(content, name)
|
|
|
|
# Find child components used
|
|
children = self._find_child_components(content, seen_names)
|
|
|
|
# Check if component has styles
|
|
has_styles = bool(style_files) or bool(inline_styles)
|
|
|
|
components.append(
|
|
ComponentInfo(
|
|
name=name,
|
|
path=str(file_path.relative_to(self.root)),
|
|
type=comp_type,
|
|
props=props,
|
|
has_styles=has_styles,
|
|
style_files=style_files,
|
|
inline_style_count=len(inline_styles),
|
|
imports=imports,
|
|
exports=self._find_exports(content, name),
|
|
children=children,
|
|
line_count=content.count("\n") + 1,
|
|
)
|
|
)
|
|
|
|
return components
|
|
|
|
def _is_valid_component_name(self, name: str) -> bool:
|
|
"""Check if a name is a valid React component name."""
|
|
# Must be PascalCase
|
|
if not name[0].isupper():
|
|
return False
|
|
|
|
# Filter out common non-component patterns
|
|
invalid_names = {
|
|
"React",
|
|
"Component",
|
|
"PureComponent",
|
|
"Fragment",
|
|
"Suspense",
|
|
"Provider",
|
|
"Consumer",
|
|
"Context",
|
|
"Error",
|
|
"ErrorBoundary",
|
|
"Wrapper",
|
|
"Container",
|
|
"Props",
|
|
"State",
|
|
"Type",
|
|
"Interface",
|
|
}
|
|
|
|
return name not in invalid_names
|
|
|
|
def _extract_imports(self, content: str) -> List[str]:
|
|
"""Extract import paths from file."""
|
|
imports = []
|
|
for match in IMPORT_PATTERN.finditer(content):
|
|
import_path = match.group(1)
|
|
# Skip node_modules style imports for brevity
|
|
if not import_path.startswith(".") and "/" not in import_path:
|
|
continue
|
|
imports.append(import_path)
|
|
return imports
|
|
|
|
def _extract_style_imports(self, content: str) -> List[str]:
|
|
"""Extract style file imports."""
|
|
style_files = []
|
|
for match in STYLE_IMPORT.finditer(content):
|
|
style_path = match.group(2)
|
|
style_files.append(style_path)
|
|
return style_files
|
|
|
|
def _find_inline_styles(self, content: str) -> List[Location]:
|
|
"""Find inline style usage locations."""
|
|
locations = []
|
|
|
|
# style={{ ... }}
|
|
for match in INLINE_STYLE_OBJECT.finditer(content):
|
|
line = content[: match.start()].count("\n") + 1
|
|
locations.append(
|
|
Location(
|
|
file_path="", # Will be set by caller
|
|
line=line,
|
|
)
|
|
)
|
|
|
|
return locations
|
|
|
|
def _extract_props(self, content: str, component_name: str) -> List[str]:
|
|
"""Extract props for a component."""
|
|
props = set()
|
|
|
|
# Look for destructured props
|
|
for match in PROPS_DESTRUCTURE.finditer(content):
|
|
props_str = match.group(1)
|
|
# Extract prop names from destructuring
|
|
for prop in re.findall(r"(\w+)(?:\s*[=:])?", props_str):
|
|
if prop and not prop[0].isupper(): # Skip types
|
|
props.add(prop)
|
|
|
|
# Look for Props interface/type
|
|
for pattern in [PROPS_INTERFACE, PROPS_TYPE]:
|
|
for match in pattern.finditer(content):
|
|
props_str = match.group(1)
|
|
# Extract prop names
|
|
for line in props_str.split("\n"):
|
|
prop_match = re.match(r"\s*(\w+)\s*[?:]", line)
|
|
if prop_match:
|
|
props.add(prop_match.group(1))
|
|
|
|
return list(props)
|
|
|
|
def _find_child_components(self, content: str, current_components: Set[str]) -> List[str]:
|
|
"""Find child components used in JSX."""
|
|
children = set()
|
|
|
|
# Find JSX elements that look like components (PascalCase)
|
|
jsx_pattern = re.compile(r"<([A-Z][A-Za-z0-9]*)")
|
|
for match in jsx_pattern.finditer(content):
|
|
component_name = match.group(1)
|
|
# Skip current file's components and React built-ins
|
|
if component_name not in current_components:
|
|
if component_name not in {"Fragment", "Suspense", "Provider"}:
|
|
children.add(component_name)
|
|
|
|
return list(children)
|
|
|
|
def _find_exports(self, content: str, component_name: str) -> List[str]:
|
|
"""Find export type for component."""
|
|
exports = []
|
|
|
|
# Default export
|
|
if re.search(rf"export\s+default\s+{component_name}\b", content):
|
|
exports.append("default")
|
|
if re.search(rf"export\s+default\s+(?:function|const)\s+{component_name}\b", content):
|
|
exports.append("default")
|
|
|
|
# Named export
|
|
if re.search(rf"export\s+(?:const|function|class)\s+{component_name}\b", content):
|
|
exports.append("named")
|
|
if re.search(r"export\s*\{[^}]*\b" + re.escape(component_name) + r"\b[^}]*\}", content):
|
|
exports.append("named")
|
|
|
|
return exports
|
|
|
|
async def find_inline_styles(self, path: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
"""
|
|
Find all inline style usage in the project.
|
|
|
|
Returns list of inline style occurrences with:
|
|
- file path
|
|
- line number
|
|
- style content
|
|
- component name (if detectable)
|
|
"""
|
|
search_path = Path(path) if path else self.root
|
|
results = []
|
|
|
|
for ext in ["*.jsx", "*.tsx", "*.js", "*.ts"]:
|
|
for file_path in search_path.rglob(ext):
|
|
if any(
|
|
skip in file_path.parts for skip in {"node_modules", ".git", "dist", "build"}
|
|
):
|
|
continue
|
|
|
|
try:
|
|
content = file_path.read_text(encoding="utf-8", errors="ignore")
|
|
|
|
# Find style={{ ... }}
|
|
for match in INLINE_STYLE_OBJECT.finditer(content):
|
|
line = content[: match.start()].count("\n") + 1
|
|
style_content = match.group(1).strip()
|
|
|
|
results.append(
|
|
{
|
|
"file": str(file_path.relative_to(self.root)),
|
|
"line": line,
|
|
"content": style_content[:200],
|
|
"type": "object",
|
|
}
|
|
)
|
|
|
|
# Find style={variable}
|
|
for match in INLINE_STYLE_VAR.finditer(content):
|
|
line = content[: match.start()].count("\n") + 1
|
|
var_name = match.group(1)
|
|
|
|
results.append(
|
|
{
|
|
"file": str(file_path.relative_to(self.root)),
|
|
"line": line,
|
|
"content": f"style={{{var_name}}}",
|
|
"type": "variable",
|
|
"variable": var_name,
|
|
}
|
|
)
|
|
|
|
except Exception:
|
|
continue
|
|
|
|
return results
|
|
|
|
async def get_component_tree(self) -> Dict[str, List[str]]:
|
|
"""
|
|
Build component dependency tree.
|
|
|
|
Returns dict mapping component names to their child components.
|
|
"""
|
|
components = await self.analyze()
|
|
|
|
tree = {}
|
|
for comp in components:
|
|
tree[comp.name] = comp.children
|
|
|
|
return tree
|
|
|
|
async def find_style_patterns(self) -> Dict[str, List[Dict]]:
|
|
"""
|
|
Find different styling patterns used across the project.
|
|
|
|
Returns dict with pattern types and their occurrences.
|
|
"""
|
|
patterns = {
|
|
"inline_styles": [],
|
|
"css_modules": [],
|
|
"styled_components": [],
|
|
"emotion": [],
|
|
"tailwind": [],
|
|
"css_classes": [],
|
|
}
|
|
|
|
component_files = self._find_component_files()
|
|
|
|
for file_path in component_files:
|
|
try:
|
|
content = file_path.read_text(encoding="utf-8", errors="ignore")
|
|
rel_path = str(file_path.relative_to(self.root))
|
|
|
|
# CSS Modules
|
|
if re.search(r'import\s+\w+\s+from\s+["\'].*\.module\.', content):
|
|
patterns["css_modules"].append({"file": rel_path})
|
|
|
|
# styled-components
|
|
if re.search(r'styled\.|from\s+["\']styled-components', content):
|
|
patterns["styled_components"].append({"file": rel_path})
|
|
|
|
# Emotion
|
|
if re.search(r"@emotion|css`", content):
|
|
patterns["emotion"].append({"file": rel_path})
|
|
|
|
# Tailwind (className with utility classes)
|
|
if re.search(r'className\s*=\s*["\'][^"\']*(?:flex|grid|p-\d|m-\d|bg-)', content):
|
|
patterns["tailwind"].append({"file": rel_path})
|
|
|
|
# Regular CSS classes
|
|
if re.search(r'className\s*=\s*["\'][a-zA-Z]', content):
|
|
patterns["css_classes"].append({"file": rel_path})
|
|
|
|
# Inline styles
|
|
for match in INLINE_STYLE_OBJECT.finditer(content):
|
|
line = content[: match.start()].count("\n") + 1
|
|
patterns["inline_styles"].append(
|
|
{
|
|
"file": rel_path,
|
|
"line": line,
|
|
}
|
|
)
|
|
|
|
except Exception:
|
|
continue
|
|
|
|
return patterns
|