Files
rspade_system/app/RSpade/Commands/Refactor/Php/FileUpdater.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

341 lines
12 KiB
PHP
Executable File

<?php
namespace App\RSpade\Commands\Refactor\Php;
use RuntimeException;
/**
* Updates class references in PHP and Blade files with atomic writes
*/
class FileUpdater
{
/**
* Update class references in all affected files
*
* @param array $references Map of file paths to occurrences
* @param string $old_class Old class name
* @param string $new_class New class name
* @param string $source_fqcn Source class FQCN from manifest
* @return int Number of files updated
*/
public function update_class_references(array $references, string $old_class, string $new_class, string $source_fqcn): int
{
$updated_count = 0;
foreach ($references as $file_path => $occurrences) {
if ($this->update_file($file_path, $old_class, $new_class, $source_fqcn)) {
$updated_count++;
}
}
return $updated_count;
}
/**
* Update a single file with class name replacements
*
* @param string $file_path Absolute path to file
* @param string $old_class Old class name
* @param string $new_class New class name
* @param string $source_fqcn Source class FQCN from manifest
* @return bool True if file was updated
*/
protected function update_file(string $file_path, string $old_class, string $new_class, string $source_fqcn): bool
{
$content = file_get_contents($file_path);
if ($content === false) {
throw new RuntimeException("Failed to read file: {$file_path}");
}
// Replace class name with context awareness
$updated_content = $this->replace_class_name($content, $old_class, $new_class, $source_fqcn);
// Check if any changes were made
if ($updated_content === $content) {
return false;
}
// Write atomically using temp file
$temp_file = $file_path . '.refactor-temp';
if (file_put_contents($temp_file, $updated_content) === false) {
throw new RuntimeException("Failed to write temp file: {$temp_file}");
}
if (!rename($temp_file, $file_path)) {
@unlink($temp_file);
throw new RuntimeException("Failed to replace file: {$file_path}");
}
return true;
}
/**
* Replace class name in content with context awareness
*
* Uses token-based replacement to avoid false positives in strings/comments
*/
protected function replace_class_name(string $content, string $old_class, string $new_class, string $source_fqcn): string
{
// For Blade files, we need special handling
if (str_contains($content, '@') || str_contains($content, '{{')) {
return $this->replace_in_blade($content, $old_class, $new_class, $source_fqcn);
}
// For pure PHP files, use token-based replacement
return $this->replace_in_php($content, $old_class, $new_class, $source_fqcn);
}
/**
* Replace class name in PHP content using token analysis
*/
protected function replace_in_php(string $content, string $old_class, string $new_class, string $source_fqcn): string
{
$tokens = token_get_all($content);
$output = '';
// Calculate new FQCN for string literal replacement
$source_fqcn_normalized = ltrim($source_fqcn, '\\');
$new_fqcn = preg_replace('/' . preg_quote($old_class, '/') . '$/', $new_class, $source_fqcn_normalized);
for ($i = 0; $i < count($tokens); $i++) {
$token = $tokens[$i];
// String tokens are passed through as-is
if (!is_array($token)) {
$output .= $token;
continue;
}
$token_type = $token[0];
$token_value = $token[1];
// Replace FQCN in string literals
if ($token_type === T_CONSTANT_ENCAPSED_STRING) {
$quote = $token_value[0];
$string_content = substr($token_value, 1, -1);
// Check if string exactly equals source FQCN (with or without leading \)
if ($string_content === $source_fqcn_normalized || $string_content === '\\' . $source_fqcn_normalized) {
$leading_slash = str_starts_with($string_content, '\\') ? '\\' : '';
$output .= $quote . $leading_slash . $new_fqcn . $quote;
continue;
}
}
// Check if this is start of a FQCN
if ($token_type === T_STRING || $token_type === T_NS_SEPARATOR) {
$fqcn_result = $this->check_and_replace_fqcn($tokens, $i, $old_class, $new_class);
if ($fqcn_result !== null) {
// We found and replaced a FQCN, output the replacement and skip ahead
$output .= $fqcn_result['replacement'];
$i = $fqcn_result['end_index'];
continue;
}
}
// Replace simple T_STRING tokens that match the old class name
if ($token_type === T_STRING && $token_value === $old_class) {
// Verify this is actually a class reference (not in string/comment)
if ($this->is_class_reference_context($tokens, $i)) {
$output .= $new_class;
} else {
$output .= $token_value;
}
} else {
$output .= $token_value;
}
}
return $output;
}
/**
* Check if we're at the start of a FQCN and if it should be replaced
*
* @return array|null Returns ['replacement' => string, 'end_index' => int] or null
*/
protected function check_and_replace_fqcn(array $tokens, int $start_index, string $old_class, string $new_class): ?array
{
// Build the full FQCN from consecutive T_STRING and T_NS_SEPARATOR tokens
$fqcn_parts = [];
$i = $start_index;
while ($i < count($tokens)) {
if (!is_array($tokens[$i])) {
break;
}
$token_type = $tokens[$i][0];
if ($token_type === T_STRING) {
$fqcn_parts[] = $tokens[$i][1];
$i++;
} elseif ($token_type === T_NS_SEPARATOR) {
$fqcn_parts[] = '\\';
$i++;
} elseif ($token_type === T_WHITESPACE) {
// Skip whitespace
$i++;
} else {
break;
}
}
// Need at least 2 parts for a FQCN (namespace + class)
if (count($fqcn_parts) < 3) {
return null;
}
$fqcn = implode('', $fqcn_parts);
// Extract class name (last part after final \)
$class_name = basename(str_replace('\\', '/', $fqcn));
// Check if class name matches
if ($class_name !== $old_class) {
return null;
}
// Check if FQCN starts with Rsx\ or App\RSpade\
$normalized_fqcn = ltrim($fqcn, '\\');
if (!str_starts_with($normalized_fqcn, 'Rsx\\') && !str_starts_with($normalized_fqcn, 'App\\RSpade\\')) {
return null;
}
// Build replacement FQCN with new class name
$namespace = dirname(str_replace('\\', '/', $fqcn));
$namespace = str_replace('/', '\\', $namespace);
if ($namespace === '.') {
$replacement = $new_class;
} else {
// Preserve leading \ if original had it
$leading_slash = str_starts_with($fqcn, '\\') ? '\\' : '';
$replacement = $leading_slash . $namespace . '\\' . $new_class;
}
return [
'replacement' => $replacement,
'end_index' => $i - 1
];
}
/**
* Check if a token is in a valid class reference context
*/
protected function is_class_reference_context(array $tokens, int $index): bool
{
// Look backwards for context clues
for ($i = $index - 1; $i >= max(0, $index - 10); $i--) {
if (!is_array($tokens[$i])) {
// Skip non-token characters like ( ) , etc
continue;
}
$prev_token = $tokens[$i][0];
// Valid contexts
if (in_array($prev_token, [T_CLASS, T_NEW, T_EXTENDS, T_IMPLEMENTS, T_INSTANCEOF, T_USE, T_DOUBLE_COLON])) {
return true;
}
// Skip whitespace, namespace separators, and comments
if (in_array($prev_token, [T_WHITESPACE, T_NS_SEPARATOR, T_COMMENT, T_DOC_COMMENT])) {
continue;
}
// For type hints, check if we're after ( or ,
if ($prev_token === T_STRING) {
// Could be part of a namespace
continue;
}
// If we hit something else meaningful, stop looking
if (!in_array($prev_token, [T_WHITESPACE, T_NS_SEPARATOR, T_STRING])) {
break;
}
}
// Look forwards for static access
for ($i = $index + 1; $i < min(count($tokens), $index + 5); $i++) {
if (!is_array($tokens[$i])) {
continue;
}
$next_token = $tokens[$i][0];
if ($next_token === T_DOUBLE_COLON) {
return true;
}
if (in_array($next_token, [T_WHITESPACE, T_NS_SEPARATOR])) {
continue;
}
break;
}
// Check if this appears to be a type hint (before a variable)
for ($i = $index + 1; $i < min(count($tokens), $index + 5); $i++) {
if (!is_array($tokens[$i])) {
continue;
}
$next_token = $tokens[$i][0];
// Type hint if followed by a variable
if ($next_token === T_VARIABLE) {
return true;
}
if ($next_token === T_WHITESPACE) {
continue;
}
break;
}
return false;
}
/**
* Replace class name in Blade content
*
* Uses simple regex replacement since Blade mixes PHP and HTML
*/
protected function replace_in_blade(string $content, string $old_class, string $new_class, string $source_fqcn): string
{
// Pattern matches class name as a word boundary (not part of another identifier)
// This prevents replacing "User" in "UserController" or in the middle of strings
$pattern = '/\b' . preg_quote($old_class, '/') . '\b/';
// Replace with word boundary check to avoid partial matches
$updated = preg_replace_callback($pattern, function ($matches) use ($old_class, $new_class, $content) {
// Additional safety: check if this looks like it's in a string literal
// This is a simple heuristic - if surrounded by quotes, skip it
$pos = strpos($content, $matches[0]);
if ($pos !== false) {
// Check 50 chars before and after for quote context
$before = substr($content, max(0, $pos - 50), 50);
$after = substr($content, $pos, 50);
// Count quotes before and after
$quotes_before = substr_count($before, '"') + substr_count($before, "'");
$quotes_after = substr_count($after, '"') + substr_count($after, "'");
// If odd number of quotes before/after, likely inside a string
if ($quotes_before % 2 === 1 || $quotes_after % 2 === 1) {
return $matches[0]; // Keep original
}
}
return $new_class;
}, $content);
return $updated;
}
}