ai_upgrade_assistant-0.2.0-alpha2/src/Service/PatchGenerator.php
src/Service/PatchGenerator.php
<?php
namespace Drupal\ai_upgrade_assistant\Service;
use Drupal\Core\File\FileSystemInterface;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Logger\LoggerChannelFactoryInterface;
use Symfony\Component\Process\Process;
use Drupal\ai_upgrade_assistant\Service\PatchValidator;
/**
* Service for generating and applying patches.
*/
class PatchGenerator {
/**
* The file system service.
*
* @var \Drupal\Core\File\FileSystemInterface
*/
protected $fileSystem;
/**
* The config factory.
*
* @var \Drupal\Core\Config\ConfigFactoryInterface
*/
protected $configFactory;
/**
* The logger factory.
*
* @var \Drupal\Core\Logger\LoggerChannelFactoryInterface
*/
protected $loggerFactory;
/**
* The patch validator service.
*
* @var \Drupal\ai_upgrade_assistant\Service\PatchValidator
*/
protected $patchValidator;
/**
* Constructs a new PatchGenerator object.
*
* @param \Drupal\Core\File\FileSystemInterface $file_system
* The file system service.
* @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
* The config factory.
* @param \Drupal\Core\Logger\LoggerChannelFactoryInterface $logger_factory
* The logger factory service.
* @param \Drupal\ai_upgrade_assistant\Service\PatchValidator $patch_validator
* The patch validator service.
*/
public function __construct(
FileSystemInterface $file_system,
ConfigFactoryInterface $config_factory,
LoggerChannelFactoryInterface $logger_factory,
PatchValidator $patch_validator
) {
$this->fileSystem = $file_system;
$this->configFactory = $config_factory;
$this->loggerFactory = $logger_factory;
$this->patchValidator = $patch_validator;
}
/**
* Generates a patch for the specified changes.
*
* @param string $module_path
* Path to the module directory.
* @param array $changes
* Array of changes to include in the patch.
*
* @return array
* Array containing the patch file path and validation results.
*/
public function generatePatch($module_path, array $changes) {
try {
// Create a temporary directory for patch generation
$temp_dir = $this->fileSystem->getTempDirectory() . '/patch_gen_' . uniqid();
$this->fileSystem->mkdir($temp_dir);
// Copy module files to temp directory
$this->fileSystem->copyDirectory($module_path, $temp_dir);
// Apply changes in temp directory
foreach ($changes as $file => $content) {
$temp_file = $temp_dir . '/' . $file;
file_put_contents($temp_file, $content);
}
// Generate patch file
$patch_file = $temp_dir . '/changes.patch';
$process = new Process(['git', 'diff', '--no-index', $module_path, $temp_dir], $module_path);
$process->run();
if (!$process->isSuccessful()) {
throw new \Exception('Failed to generate patch: ' . $process->getErrorOutput());
}
file_put_contents($patch_file, $process->getOutput());
// Validate the patch
$validation_results = $this->patchValidator->validatePatch($patch_file, $module_path);
return [
'status' => TRUE,
'patch_file' => $patch_file,
'validation' => $validation_results,
];
}
catch (\Exception $e) {
$this->loggerFactory->get('ai_upgrade_assistant')->error(
'Failed to generate patch: @error',
['@error' => $e->getMessage()]
);
return [
'status' => FALSE,
'error' => $e->getMessage(),
];
}
finally {
// Clean up temporary directory
if (isset($temp_dir) && is_dir($temp_dir)) {
$this->fileSystem->deleteRecursive($temp_dir);
}
}
}
/**
* Applies a patch to a file.
*
* @param string $patch_uri
* URI of the patch file.
* @param string $target_file
* Path to the file to patch.
*
* @return bool
* TRUE if patch was applied successfully, FALSE otherwise.
*/
public function applyPatch($patch_uri, $target_file) {
$patch_path = $this->fileSystem->realpath($patch_uri);
// Create backup
$backup_file = $target_file . '.bak';
copy($target_file, $backup_file);
// Apply patch
$process = new Process(['patch', '-p0', $target_file, $patch_path]);
$process->run();
if ($process->isSuccessful()) {
unlink($backup_file);
return TRUE;
}
// Restore backup if patch failed
copy($backup_file, $target_file);
unlink($backup_file);
$this->loggerFactory->get('ai_upgrade_assistant')
->error('Failed to apply patch to @file: @error', [
'@file' => $target_file,
'@error' => $process->getErrorOutput(),
]);
return FALSE;
}
/**
* Applies a single change to file content.
*
* @param string $content
* Original file content.
* @param array $change
* Change to apply.
*
* @return string
* Modified content.
*/
protected function applyChange($content, array $change) {
if (empty($change['code_example'])) {
return $content;
}
$lines = explode("\n", $content);
$original_code = $change['current_code'];
$new_code = $change['code_example'];
// If we have line numbers, use them for more precise replacement
if (!empty($change['start_line']) && !empty($change['end_line'])) {
$start = $change['start_line'] - 1; // Convert to 0-based index
$length = $change['end_line'] - $change['start_line'] + 1;
// Verify the original code matches
$original_lines = array_slice($lines, $start, $length);
$original_block = implode("\n", $original_lines);
if (trim($original_block) === trim($original_code)) {
// Replace the lines
array_splice($lines, $start, $length, explode("\n", $new_code));
return implode("\n", $lines);
}
}
// Fallback to string replacement if line numbers don't match
// Use regular expressions to handle whitespace variations
$escaped_original = preg_quote($original_code, '/');
$pattern = "/^[ \t]*" . str_replace("\n", "\\n[ \t]*", $escaped_original) . "[ \t]*$/m";
return preg_replace($pattern, $new_code, $content);
}
/**
* Validates changes before applying them.
*
* @param array $changes
* Array of changes to validate.
* @param string $file_path
* Path to the file being changed.
*
* @return array
* Array of validation results with 'valid' boolean and 'errors' array.
*/
public function validateChanges(array $changes, $file_path) {
$results = [
'valid' => TRUE,
'errors' => [],
];
$content = file_get_contents($file_path);
if ($content === FALSE) {
$results['valid'] = FALSE;
$results['errors'][] = "Could not read file: $file_path";
return $results;
}
foreach ($changes as $i => $change) {
// Check required fields
if (empty($change['current_code']) || empty($change['code_example'])) {
$results['valid'] = FALSE;
$results['errors'][] = "Change $i is missing required fields";
continue;
}
// Verify current code exists in file
if (strpos($content, $change['current_code']) === FALSE) {
$results['valid'] = FALSE;
$results['errors'][] = "Original code for change $i not found in file";
continue;
}
// Validate line numbers if provided
if (!empty($change['start_line']) && !empty($change['end_line'])) {
$lines = explode("\n", $content);
if ($change['start_line'] < 1 || $change['end_line'] > count($lines)) {
$results['valid'] = FALSE;
$results['errors'][] = "Invalid line numbers for change $i";
continue;
}
}
// Basic syntax validation for PHP files
if (pathinfo($file_path, PATHINFO_EXTENSION) === 'php') {
if (!$this->validatePhpSyntax($change['code_example'])) {
$results['valid'] = FALSE;
$results['errors'][] = "Invalid PHP syntax in change $i";
continue;
}
}
}
return $results;
}
/**
* Validates PHP syntax.
*
* @param string $code
* PHP code to validate.
*
* @return bool
* TRUE if syntax is valid, FALSE otherwise.
*/
protected function validatePhpSyntax($code) {
// Create a temporary file
$temp_file = tempnam(sys_get_temp_dir(), 'php_syntax_check');
file_put_contents($temp_file, "<?php\n" . $code);
// Check syntax
$process = new Process(['php', '-l', $temp_file]);
$process->run();
// Clean up
unlink($temp_file);
return $process->isSuccessful();
}
/**
* Checks if a patch can be safely applied.
*
* @param string $patch_uri
* URI of the patch file.
* @param string $target_file
* Path to the file to patch.
*
* @return bool
* TRUE if patch can be safely applied, FALSE otherwise.
*/
public function isSafePatch($patch_uri, $target_file) {
$patch_path = $this->fileSystem->realpath($patch_uri);
// Check if patch can be applied with --dry-run
$process = new Process(['patch', '--dry-run', '-p0', $target_file, $patch_path]);
$process->run();
return $process->isSuccessful();
}
}
