Files
rspade_system/app/RSpade/CodeQuality/Parsers/ScssContextParser.php
root f6fac6c4bc Fix bin/publish: copy docs.dist from project root
Fix bin/publish: use correct .env path for rspade_system
Fix bin/publish script: prevent grep exit code 1 from terminating script

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-21 02:08:33 +00:00

278 lines
9.6 KiB
PHP
Executable File

<?php
namespace App\RSpade\CodeQuality\Parsers;
/**
* Lightweight SCSS context parser for code quality rules
*
* PURPOSE: This is NOT a full SCSS compiler/parser. It only tracks:
* - Selector nesting and building full selector paths
* - Property declarations within each selector context
* - Pseudo-state detection (:hover, :focus, :active)
*
* DESIGN: Simple state machine that builds selector context by tracking braces
* and nesting. Designed for code quality rules that need to understand what
* properties are set in hover/focus states vs base states.
*
* USAGE:
* $contexts = ScssContextParser::parse_contexts($scss_content);
* foreach ($contexts as $context) {
* if (ScssContextParser::is_in_hover_context($context['selector'])) {
* // Check properties...
* }
* }
*
* FUTURE: Can be extended to track @media queries, mixins, or other SCSS
* features as needed by new rules. Each code quality rule documents what
* parsing features it requires.
*/
class ScssContextParser
{
/**
* Parse SCSS content into selector contexts with their properties
*
* @param string $scss Raw SCSS content
* @return array Array of contexts, each with:
* - 'line': Line number where selector starts
* - 'selector': Full selector path (e.g., '.nav-link:hover')
* - 'properties': Associative array of property => value
* - 'is_hover': Boolean if selector contains :hover
* - 'is_focus': Boolean if selector contains :focus
* - 'is_active': Boolean if selector contains :active
*/
public static function parse_contexts(string $scss): array
{
$lines = explode("\n", $scss);
$contexts = [];
$selector_stack = [];
$current_context = null;
$brace_depth = 0;
$in_comment = false;
for ($i = 0; $i < count($lines); $i++) {
$line = $lines[$i];
$line_num = $i + 1;
$trimmed = trim($line);
// Skip empty lines
if (empty($trimmed)) {
continue;
}
// Handle multi-line comments
if (str_contains($line, '/*')) {
$in_comment = true;
}
if ($in_comment) {
if (str_contains($line, '*/')) {
$in_comment = false;
}
continue;
}
// Skip single-line comments
if (str_starts_with($trimmed, '//')) {
continue;
}
// Remove inline comments for processing
$clean_line = preg_replace('/\/\/.*$/', '', $line);
$clean_line = preg_replace('/\/\*.*?\*\//', '', $clean_line);
$trimmed_clean = trim($clean_line);
// Count braces before processing
$open_braces = substr_count($clean_line, '{');
$close_braces = substr_count($clean_line, '}');
// Handle closing braces - pop from selector stack
for ($j = 0; $j < $close_braces; $j++) {
if (!empty($selector_stack)) {
array_pop($selector_stack);
}
$brace_depth--;
// Save current context when closing its block
if ($current_context && $brace_depth < $current_context['depth']) {
$contexts[] = $current_context;
$current_context = null;
}
}
// Check if this line starts a new selector block
if ($open_braces > 0 && !empty($trimmed_clean)) {
// Extract the selector part (before the {)
$selector_part = trim(str_replace('{', '', $trimmed_clean));
// Skip @keyframes, @media, @import etc
if (str_starts_with($selector_part, '@')) {
$brace_depth += $open_braces;
continue;
}
// Build full selector path
$full_selector = self::build_selector_path($selector_stack, $selector_part);
// Push to stack for nested selectors
$selector_stack[] = $selector_part;
$brace_depth += $open_braces;
// Create new context
$current_context = [
'line' => $line_num,
'selector' => $full_selector,
'properties' => [],
'depth' => $brace_depth,
'is_hover' => str_contains($full_selector, ':hover'),
'is_focus' => str_contains($full_selector, ':focus'),
'is_active' => str_contains($full_selector, ':active')
];
} elseif ($open_braces > 0) {
// Opening brace without selector (continuation from previous line)
$brace_depth += $open_braces;
}
// Parse property declarations within current context
if ($current_context && $brace_depth === $current_context['depth']) {
if (preg_match('/^\s*([a-z-]+)\s*:\s*(.+?);?\s*$/i', $trimmed_clean, $matches)) {
$property = $matches[1];
$value = trim($matches[2], '; ');
$current_context['properties'][$property] = $value;
}
}
}
// Save any remaining context
if ($current_context) {
$contexts[] = $current_context;
}
return $contexts;
}
/**
* Build full selector path from selector stack
* Handles SCSS & parent reference properly
*/
private static function build_selector_path(array $stack, string $current): string
{
if (empty($stack)) {
return $current;
}
$parent = implode(' ', $stack);
// Handle & parent reference
if (str_starts_with($current, '&')) {
// Replace & with the immediate parent (last item in stack)
$immediate_parent = end($stack);
$current = str_replace('&', '', $current);
// Remove last item and rebuild
$stack_without_last = array_slice($stack, 0, -1);
if (empty($stack_without_last)) {
return $immediate_parent . $current;
}
return implode(' ', $stack_without_last) . ' ' . $immediate_parent . $current;
}
// Handle nested selectors without &
return $parent . ' ' . $current;
}
/**
* Check if a selector represents a hover/focus/active state
*/
public static function is_in_hover_context(string $selector): bool
{
return str_contains($selector, ':hover') ||
str_contains($selector, ':focus') ||
str_contains($selector, ':active');
}
/**
* Get the base selector without pseudo-states
* Example: '.btn:hover' => '.btn'
*/
public static function get_base_selector(string $selector): string
{
return preg_replace('/:(hover|focus|active|visited|disabled)/', '', $selector);
}
/**
* Compare properties between two contexts to find differences
* Useful for detecting redundant declarations or actual changes
*/
public static function compare_properties(array $base_props, array $state_props): array
{
$differences = [
'added' => [],
'changed' => [],
'same' => [],
'removed' => []
];
foreach ($state_props as $prop => $value) {
if (!isset($base_props[$prop])) {
$differences['added'][$prop] = $value;
} elseif ($base_props[$prop] !== $value) {
$differences['changed'][$prop] = [
'from' => $base_props[$prop],
'to' => $value
];
} else {
$differences['same'][$prop] = $value;
}
}
foreach ($base_props as $prop => $value) {
if (!isset($state_props[$prop])) {
$differences['removed'][$prop] = $value;
}
}
return $differences;
}
/**
* Check if a property is position/size related
* These are typically prohibited in hover states
*/
public static function is_position_property(string $property): bool
{
$position_properties = [
'margin', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
'padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
'top', 'right', 'bottom', 'left',
'width', 'height', 'min-width', 'min-height', 'max-width', 'max-height',
'font-size', 'line-height', 'letter-spacing', 'word-spacing'
];
return in_array($property, $position_properties);
}
/**
* Check if a property is visual-only (safe for hover)
* These don't affect layout or position
*/
public static function is_visual_only_property(string $property): bool
{
$visual_properties = [
'color', 'background-color', 'background', 'background-image',
'opacity', 'visibility',
'border-color', 'outline', 'outline-color',
'text-decoration', 'text-decoration-color',
'box-shadow', 'text-shadow',
'filter', 'backdrop-filter',
'cursor'
];
// Check for exact match or if property starts with one of these
foreach ($visual_properties as $visual_prop) {
if ($property === $visual_prop || str_starts_with($property, $visual_prop . '-')) {
return true;
}
}
return false;
}
}