""" SCSS Token Source Extracts design tokens from SCSS/Sass variables. Supports $variable declarations and @use module variables. """ import re from pathlib import Path from typing import List, Dict, Optional from .base import DesignToken, TokenCollection, TokenSource class SCSSTokenSource(TokenSource): """ Extract tokens from SCSS/Sass files. Parses: - $variable: value; - $variable: value !default; - // Comment descriptions - @use module variables - Maps: $colors: (primary: #3B82F6, secondary: #10B981); """ @property def source_type(self) -> str: return "scss" async def extract(self, source: str) -> TokenCollection: """ Extract tokens from SCSS file or content. Args: source: File path or SCSS content string Returns: TokenCollection with extracted tokens """ if self._is_file_path(source): file_path = Path(source) if not file_path.exists(): raise FileNotFoundError(f"SCSS file not found: {source}") content = file_path.read_text(encoding="utf-8") source_file = str(file_path.absolute()) else: content = source source_file = "" tokens = [] # Extract simple variables tokens.extend(self._parse_variables(content, source_file)) # Extract map variables tokens.extend(self._parse_maps(content, source_file)) return TokenCollection( tokens=tokens, name=f"SCSS Tokens from {Path(source_file).name if source_file != '' else 'inline'}", sources=[self._create_source_id(source_file)], ) def _is_file_path(self, source: str) -> bool: """Check if source looks like a file path.""" if '$' in source and ':' in source: return False if source.endswith('.scss') or source.endswith('.sass'): return True return Path(source).exists() def _parse_variables(self, content: str, source_file: str) -> List[DesignToken]: """Parse simple $variable declarations.""" tokens = [] lines = content.split('\n') # Pattern for variable declarations var_pattern = re.compile( r'^\s*' r'(\$[\w-]+)\s*:\s*' # Variable name r'([^;!]+)' # Value r'(\s*!default)?' # Optional !default r'\s*;', re.MULTILINE ) # Track comments for descriptions prev_comment = "" for i, line in enumerate(lines, 1): # Check for comment comment_match = re.match(r'^\s*//\s*(.+)$', line) if comment_match: prev_comment = comment_match.group(1).strip() continue # Check for variable var_match = var_pattern.match(line) if var_match: var_name = var_match.group(1) var_value = var_match.group(2).strip() is_default = bool(var_match.group(3)) # Skip if value is a map (handled separately) if var_value.startswith('(') and var_value.endswith(')'): prev_comment = "" continue # Skip if value references another variable that we can't resolve if var_value.startswith('$') and '(' not in var_value: # It's a simple variable reference, try to extract pass token = DesignToken( name=self._normalize_var_name(var_name), value=self._process_value(var_value), description=prev_comment, source=self._create_source_id(source_file, i), source_file=source_file, source_line=i, original_name=var_name, original_value=var_value, ) if is_default: token.tags.append("default") tokens.append(token) prev_comment = "" else: # Reset comment if line doesn't match if line.strip() and not line.strip().startswith('//'): prev_comment = "" return tokens def _parse_maps(self, content: str, source_file: str) -> List[DesignToken]: """Parse SCSS map declarations.""" tokens = [] # Pattern for map declarations (handles multi-line) map_pattern = re.compile( r'\$(\w[\w-]*)\s*:\s*\(([\s\S]*?)\)\s*;', re.MULTILINE ) for match in map_pattern.finditer(content): map_name = match.group(1) map_content = match.group(2) # Get line number line_num = content[:match.start()].count('\n') + 1 # Parse map entries entries = self._parse_map_entries(map_content) for key, value in entries.items(): token = DesignToken( name=f"{self._normalize_var_name('$' + map_name)}.{key}", value=self._process_value(value), source=self._create_source_id(source_file, line_num), source_file=source_file, source_line=line_num, original_name=f"${map_name}.{key}", original_value=value, ) token.tags.append("from-map") tokens.append(token) return tokens def _parse_map_entries(self, map_content: str) -> Dict[str, str]: """Parse entries from a SCSS map.""" entries = {} # Handle nested maps and simple key-value pairs # This is a simplified parser for common cases # Remove comments map_content = re.sub(r'//[^\n]*', '', map_content) # Split by comma (not inside parentheses) depth = 0 current = "" parts = [] for char in map_content: if char == '(': depth += 1 current += char elif char == ')': depth -= 1 current += char elif char == ',' and depth == 0: parts.append(current.strip()) current = "" else: current += char if current.strip(): parts.append(current.strip()) # Parse each part for part in parts: if ':' in part: key, value = part.split(':', 1) key = key.strip().strip('"\'') value = value.strip() entries[key] = value return entries def _normalize_var_name(self, var_name: str) -> str: """Convert SCSS variable name to token name.""" # Remove $ prefix name = var_name.lstrip('$') # Convert kebab-case and underscores to dots name = re.sub(r'[-_]', '.', name) return name.lower() def _process_value(self, value: str) -> str: """Process SCSS value for token storage.""" value = value.strip() # Handle function calls (keep as-is for now) if '(' in value and ')' in value: return value # Handle quotes if (value.startswith('"') and value.endswith('"')) or \ (value.startswith("'") and value.endswith("'")): return value[1:-1] return value class SCSSVariableResolver: """ Resolve SCSS variable references. Builds a dependency graph and resolves $var references to actual values. """ def __init__(self): self.variables: Dict[str, str] = {} self.resolved: Dict[str, str] = {} def add_variable(self, name: str, value: str) -> None: """Add a variable to the resolver.""" self.variables[name] = value def resolve(self, name: str) -> Optional[str]: """Resolve a variable to its final value.""" if name in self.resolved: return self.resolved[name] value = self.variables.get(name) if not value: return None # Check if value references other variables if '$' in value: resolved_value = self._resolve_references(value) self.resolved[name] = resolved_value return resolved_value self.resolved[name] = value return value def _resolve_references(self, value: str, depth: int = 0) -> str: """Recursively resolve variable references in a value.""" if depth > 10: # Prevent infinite loops return value # Find variable references var_pattern = re.compile(r'\$[\w-]+') def replace_var(match): var_name = match.group(0) resolved = self.resolve(var_name.lstrip('$')) return resolved if resolved else var_name return var_pattern.sub(replace_var, value) def resolve_all(self) -> Dict[str, str]: """Resolve all variables.""" for name in self.variables: self.resolve(name) return self.resolved