""" Project Scanner Scans file system to discover project structure, frameworks, and style files. """ import json import re from pathlib import Path from typing import List, Dict, Any, Optional, Set, Tuple from dataclasses import dataclass, field from .base import ( Framework, StylingApproach, StyleFile, ProjectAnalysis, ) # Directories to skip during scanning SKIP_DIRS = { 'node_modules', '.git', '.next', '.nuxt', 'dist', 'build', 'out', '.cache', 'coverage', '__pycache__', '.venv', 'venv', '.turbo', '.vercel', } # File extensions to scan SCAN_EXTENSIONS = { # JavaScript/TypeScript '.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', # Styles '.css', '.scss', '.sass', '.less', '.styl', # Config '.json', } @dataclass class ScanResult: """Result of file system scan.""" files: List[Path] = field(default_factory=list) style_files: List[Path] = field(default_factory=list) component_files: List[Path] = field(default_factory=list) config_files: Dict[str, Path] = field(default_factory=dict) total_lines: int = 0 class ProjectScanner: """ Scans a project directory to identify: - Framework (React, Next, Vue, etc.) - Styling approach (CSS modules, styled-components, Tailwind, etc.) - Component files - Style files Results are cached in memory for the session. """ # Class-level cache: path -> (timestamp, analysis) _cache: Dict[str, Tuple[float, ProjectAnalysis]] = {} _cache_ttl: float = 60.0 # Cache for 60 seconds def __init__(self, root_path: str, use_cache: bool = True): self.root = Path(root_path).resolve() self.use_cache = use_cache if not self.root.exists(): raise FileNotFoundError(f"Project path not found: {root_path}") async def scan(self) -> ProjectAnalysis: """ Perform full project scan. Returns: ProjectAnalysis with detected framework, styles, and files """ # Check cache if enabled if self.use_cache: import time cache_key = str(self.root) if cache_key in self._cache: timestamp, cached_analysis = self._cache[cache_key] if time.time() - timestamp < self._cache_ttl: return cached_analysis # Scan file system scan_result = self._scan_files() # Detect framework framework, version = self._detect_framework(scan_result.config_files) # Detect styling approaches styling = self._detect_styling(scan_result) # Collect style files style_files = self._analyze_style_files(scan_result.style_files) # Build analysis result analysis = ProjectAnalysis( project_path=str(self.root), framework=framework, framework_version=version, style_files=style_files, style_file_count=len(style_files), stats={ "total_files_scanned": len(scan_result.files), "total_lines": scan_result.total_lines, "component_files": len(scan_result.component_files), "style_files": len(scan_result.style_files), } ) # Determine primary styling approach if styling: analysis.styling_approaches = styling # Primary is the one with most occurrences analysis.primary_styling = max( styling, key=lambda x: x.count ).type if styling else None # Cache result if enabled if self.use_cache: import time cache_key = str(self.root) self._cache[cache_key] = (time.time(), analysis) return analysis def _scan_files(self) -> ScanResult: """Scan directory for relevant files.""" result = ScanResult() for path in self.root.rglob("*"): # Skip directories in skip list if any(skip in path.parts for skip in SKIP_DIRS): continue if not path.is_file(): continue suffix = path.suffix.lower() if suffix not in SCAN_EXTENSIONS: continue result.files.append(path) # Categorize files if suffix in {'.css', '.scss', '.sass', '.less', '.styl'}: result.style_files.append(path) elif suffix in {'.jsx', '.tsx'}: result.component_files.append(path) elif suffix in {'.js', '.ts'}: # Check if it's a component or config name = path.name.lower() if any(cfg in name for cfg in ['config', 'rc', '.config']): result.config_files[name] = path elif self._looks_like_component(path): result.component_files.append(path) # Count lines (approximate for large files) try: content = path.read_text(encoding='utf-8', errors='ignore') result.total_lines += content.count('\n') + 1 except Exception: pass # Look for specific config files config_names = [ 'package.json', 'tsconfig.json', 'tailwind.config.js', 'tailwind.config.ts', 'next.config.js', 'next.config.mjs', 'vite.config.js', 'vite.config.ts', 'nuxt.config.js', 'nuxt.config.ts', '.eslintrc.json', '.eslintrc.js', ] for name in config_names: config_path = self.root / name if config_path.exists(): result.config_files[name] = config_path return result def _looks_like_component(self, path: Path) -> bool: """Check if a JS/TS file looks like a React component.""" name = path.stem # PascalCase is a strong indicator if name[0].isupper() and not name.isupper(): return True # Common component patterns if any(x in name.lower() for x in ['component', 'page', 'view', 'screen']): return True return False def _detect_framework( self, config_files: Dict[str, Path] ) -> Tuple[Framework, str]: """Detect the UI framework and version.""" # Check package.json for dependencies pkg_json = config_files.get('package.json') if not pkg_json: return Framework.UNKNOWN, "" try: pkg = json.loads(pkg_json.read_text()) deps = { **pkg.get('dependencies', {}), **pkg.get('devDependencies', {}), } # Check for Next.js first (it includes React) if 'next' in deps: return Framework.NEXT, deps.get('next', '').lstrip('^~') # Check for Nuxt (Vue-based) if 'nuxt' in deps: return Framework.NUXT, deps.get('nuxt', '').lstrip('^~') # Check for other frameworks if 'react' in deps: return Framework.REACT, deps.get('react', '').lstrip('^~') if 'vue' in deps: return Framework.VUE, deps.get('vue', '').lstrip('^~') if '@angular/core' in deps: return Framework.ANGULAR, deps.get('@angular/core', '').lstrip('^~') if 'svelte' in deps: return Framework.SVELTE, deps.get('svelte', '').lstrip('^~') if 'solid-js' in deps: return Framework.SOLID, deps.get('solid-js', '').lstrip('^~') except (json.JSONDecodeError, KeyError): pass return Framework.UNKNOWN, "" def _detect_styling(self, scan_result: ScanResult) -> List: """Detect styling approaches used in the project.""" from .base import StylePattern, Location patterns: Dict[StylingApproach, StylePattern] = {} # Check config files for styling indicators pkg_json = scan_result.config_files.get('package.json') if pkg_json: try: pkg = json.loads(pkg_json.read_text()) deps = { **pkg.get('dependencies', {}), **pkg.get('devDependencies', {}), } # Tailwind if 'tailwindcss' in deps: patterns[StylingApproach.TAILWIND] = StylePattern( type=StylingApproach.TAILWIND, count=1, examples=["tailwindcss in dependencies"] ) # styled-components if 'styled-components' in deps: patterns[StylingApproach.STYLED_COMPONENTS] = StylePattern( type=StylingApproach.STYLED_COMPONENTS, count=1, examples=["styled-components in dependencies"] ) # Emotion if '@emotion/react' in deps or '@emotion/styled' in deps: patterns[StylingApproach.EMOTION] = StylePattern( type=StylingApproach.EMOTION, count=1, examples=["@emotion in dependencies"] ) # SASS/SCSS if 'sass' in deps or 'node-sass' in deps: patterns[StylingApproach.SASS_SCSS] = StylePattern( type=StylingApproach.SASS_SCSS, count=1, examples=["sass in dependencies"] ) except (json.JSONDecodeError, KeyError): pass # Check tailwind config if 'tailwind.config.js' in scan_result.config_files or \ 'tailwind.config.ts' in scan_result.config_files: if StylingApproach.TAILWIND not in patterns: patterns[StylingApproach.TAILWIND] = StylePattern( type=StylingApproach.TAILWIND, count=1, examples=["tailwind.config found"] ) # Scan component files for styling patterns for comp_file in scan_result.component_files[:100]: # Limit for performance try: content = comp_file.read_text(encoding='utf-8', errors='ignore') self._detect_patterns_in_file( content, str(comp_file), patterns ) except Exception: pass # Check style files for style_file in scan_result.style_files: suffix = style_file.suffix.lower() if suffix == '.css': # Check for CSS modules if '.module.css' in style_file.name.lower(): approach = StylingApproach.CSS_MODULES else: approach = StylingApproach.VANILLA_CSS if approach not in patterns: patterns[approach] = StylePattern(type=approach) patterns[approach].count += 1 patterns[approach].locations.append( Location(str(style_file), 1) ) elif suffix in {'.scss', '.sass'}: if StylingApproach.SASS_SCSS not in patterns: patterns[StylingApproach.SASS_SCSS] = StylePattern( type=StylingApproach.SASS_SCSS ) patterns[StylingApproach.SASS_SCSS].count += 1 return list(patterns.values()) def _detect_patterns_in_file( self, content: str, file_path: str, patterns: Dict[StylingApproach, Any] ) -> None: """Detect styling patterns in a single file.""" from .base import StylePattern, Location # CSS Modules import css_module_pattern = re.compile( r"import\s+\w+\s+from\s+['\"].*\.module\.(css|scss|sass)['\"]" ) for match in css_module_pattern.finditer(content): if StylingApproach.CSS_MODULES not in patterns: patterns[StylingApproach.CSS_MODULES] = StylePattern( type=StylingApproach.CSS_MODULES ) patterns[StylingApproach.CSS_MODULES].count += 1 line_num = content[:match.start()].count('\n') + 1 patterns[StylingApproach.CSS_MODULES].locations.append( Location(file_path, line_num) ) # styled-components styled_pattern = re.compile( r"(styled\.|styled\()|(from\s+['\"]styled-components['\"])" ) for match in styled_pattern.finditer(content): if StylingApproach.STYLED_COMPONENTS not in patterns: patterns[StylingApproach.STYLED_COMPONENTS] = StylePattern( type=StylingApproach.STYLED_COMPONENTS ) patterns[StylingApproach.STYLED_COMPONENTS].count += 1 # Emotion emotion_pattern = re.compile( r"(css`|@emotion|from\s+['\"]@emotion)" ) for match in emotion_pattern.finditer(content): if StylingApproach.EMOTION not in patterns: patterns[StylingApproach.EMOTION] = StylePattern( type=StylingApproach.EMOTION ) patterns[StylingApproach.EMOTION].count += 1 # Inline styles inline_pattern = re.compile( r'style\s*=\s*\{\s*\{[^}]+\}\s*\}' ) for match in inline_pattern.finditer(content): if StylingApproach.INLINE_STYLES not in patterns: patterns[StylingApproach.INLINE_STYLES] = StylePattern( type=StylingApproach.INLINE_STYLES ) patterns[StylingApproach.INLINE_STYLES].count += 1 line_num = content[:match.start()].count('\n') + 1 patterns[StylingApproach.INLINE_STYLES].locations.append( Location(file_path, line_num) ) patterns[StylingApproach.INLINE_STYLES].examples.append( match.group(0)[:100] ) # Tailwind classes tailwind_pattern = re.compile( r'className\s*=\s*["\'][^"\']*(?:flex|grid|p-|m-|bg-|text-|border-)[^"\']*["\']' ) for match in tailwind_pattern.finditer(content): if StylingApproach.TAILWIND not in patterns: patterns[StylingApproach.TAILWIND] = StylePattern( type=StylingApproach.TAILWIND ) patterns[StylingApproach.TAILWIND].count += 1 def _analyze_style_files(self, style_paths: List[Path]) -> List[StyleFile]: """Analyze style files for metadata.""" style_files = [] for path in style_paths: try: content = path.read_text(encoding='utf-8', errors='ignore') # Determine type suffix = path.suffix.lower() if '.module.' in path.name.lower(): file_type = 'css-module' elif suffix == '.scss': file_type = 'scss' elif suffix == '.sass': file_type = 'sass' elif suffix == '.less': file_type = 'less' else: file_type = 'css' # Count variables var_count = 0 if file_type == 'css' or file_type == 'css-module': var_count = len(re.findall(r'--[\w-]+\s*:', content)) elif file_type in {'scss', 'sass'}: var_count = len(re.findall(r'\$[\w-]+\s*:', content)) # Count selectors (approximate) selector_count = len(re.findall(r'[.#][\w-]+\s*\{', content)) # Find imports imports = re.findall(r'@import\s+["\']([^"\']+)["\']', content) style_files.append(StyleFile( path=str(path.relative_to(self.root)), type=file_type, size_bytes=path.stat().st_size, line_count=content.count('\n') + 1, variable_count=var_count, selector_count=selector_count, imports=imports, )) except Exception: continue return style_files def get_file_tree(self, max_depth: int = 3) -> Dict[str, Any]: """Get project file tree structure.""" def build_tree(path: Path, depth: int) -> Dict[str, Any]: if depth > max_depth: return {"...": "truncated"} result = {} try: for item in sorted(path.iterdir()): if item.name in SKIP_DIRS: continue if item.is_dir(): result[item.name + "/"] = build_tree(item, depth + 1) elif item.suffix in SCAN_EXTENSIONS: result[item.name] = item.stat().st_size except PermissionError: pass return result return build_tree(self.root, 0)