""" 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 List, Dict, Any, Optional, Set, Tuple from dataclasses import dataclass, field from .base import ( ComponentInfo, Location, StylePattern, StylingApproach, ) # 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 as e: # 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