Skip to content

Commit 3335281

Browse files
committed
feat: implement Change Chunks patch application system
Add comprehensive file-apply-patch MCP tool with progressive fuzzy matching capabilities for applying human-readable change chunks to files.
1 parent a8b34a2 commit 3335281

26 files changed

+2489
-90
lines changed
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Butschster\ContextGenerator\Lib\ApplyPatchParser;
6+
7+
final readonly class ApplyResult
8+
{
9+
public function __construct(
10+
public bool $success,
11+
/**
12+
* @var string[]
13+
*/
14+
public array $modifiedContent,
15+
/**
16+
* @var string[]
17+
*/
18+
public array $appliedChanges = [],
19+
/**
20+
* @var string[]
21+
*/
22+
public array $errors = [],
23+
) {}
24+
25+
public function getModifiedContentAsString(): string
26+
{
27+
return \implode("\n", $this->modifiedContent);
28+
}
29+
30+
public function hasErrors(): bool
31+
{
32+
return !empty($this->errors);
33+
}
34+
35+
public function getSummary(): string
36+
{
37+
if (!$this->success) {
38+
return \sprintf('Failed to apply changes: %s', \implode(', ', $this->errors));
39+
}
40+
41+
return \sprintf(
42+
'Successfully applied %d change chunks. %s',
43+
\count($this->appliedChanges),
44+
\implode('; ', $this->appliedChanges),
45+
);
46+
}
47+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Butschster\ContextGenerator\Lib\ApplyPatchParser;
6+
7+
use Spiral\JsonSchemaGenerator\Attribute\Field;
8+
9+
final readonly class ChangeChunkConfig
10+
{
11+
public function __construct(
12+
#[Field(
13+
description: 'Enable case-sensitive matching',
14+
default: true,
15+
)]
16+
public bool $caseSensitive = true,
17+
#[Field(
18+
description: 'Preserve exact whitespace formatting',
19+
default: false,
20+
)]
21+
public bool $preserveWhitespace = false,
22+
#[Field(
23+
description: 'Maximum number of lines to search for context markers',
24+
default: 100,
25+
)]
26+
public int $maxSearchLines = 100,
27+
#[Field(
28+
description: 'Minimum confidence score required for fuzzy matching (0.0-1.0)',
29+
default: 0.7,
30+
)]
31+
public float $minConfidence = 0.7,
32+
) {}
33+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Butschster\ContextGenerator\Lib\ApplyPatchParser;
6+
7+
use Butschster\ContextGenerator\McpServer\Action\Tools\Filesystem\Dto\FileApplyPatchRequest;
8+
9+
final readonly class ChangeChunkProcessor
10+
{
11+
public function __construct(
12+
private ChunkParser $parser = new ChunkParser(),
13+
private ContextMatcher $contextMatcher = new ContextMatcher(),
14+
private ChangeValidator $validator = new ChangeValidator(),
15+
private ChunkApplier $applier = new ChunkApplier(),
16+
) {}
17+
18+
public function processChanges(
19+
FileApplyPatchRequest $request,
20+
ChangeChunkConfig $config,
21+
string $fileContent,
22+
): ProcessResult {
23+
$contentLines = $this->splitIntoLines($fileContent);
24+
25+
// Step 1: Parse all chunks
26+
$parsedChunks = $this->parser->parseChunks($request->chunks);
27+
28+
// Step 2: Validate chunks
29+
$validation = $this->validator->validateChanges($parsedChunks, $contentLines);
30+
if (!$validation->isValid) {
31+
return new ProcessResult(
32+
success: false,
33+
originalContent: $fileContent,
34+
modifiedContent: $fileContent,
35+
errors: $validation->errors,
36+
warnings: $validation->warnings,
37+
);
38+
}
39+
40+
// Step 3: Apply changes
41+
$applyResult = $this->applier->applyChanges($parsedChunks, $contentLines, $this->contextMatcher, $config);
42+
43+
return new ProcessResult(
44+
success: $applyResult->success,
45+
originalContent: $fileContent,
46+
modifiedContent: $applyResult->getModifiedContentAsString(),
47+
appliedChanges: $applyResult->appliedChanges,
48+
errors: $applyResult->errors,
49+
warnings: $validation->warnings,
50+
);
51+
}
52+
53+
private function splitIntoLines(string $content): array
54+
{
55+
// Handle different line endings and preserve empty lines
56+
$lines = \preg_split('/\r\n|\r|\n/', $content);
57+
58+
// Remove last empty line if it exists (common with file endings)
59+
if (\end($lines) === '') {
60+
\array_pop($lines);
61+
}
62+
63+
return $lines;
64+
}
65+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Butschster\ContextGenerator\Lib\ApplyPatchParser;
6+
7+
final readonly class ChangeOperation
8+
{
9+
public function __construct(
10+
public ChangeType $type,
11+
public string $content,
12+
) {}
13+
14+
public function isAddition(): bool
15+
{
16+
return $this->type === ChangeType::ADD;
17+
}
18+
19+
public function isRemoval(): bool
20+
{
21+
return $this->type === ChangeType::REMOVE;
22+
}
23+
24+
public function isContext(): bool
25+
{
26+
return $this->type === ChangeType::CONTEXT;
27+
}
28+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Butschster\ContextGenerator\Lib\ApplyPatchParser;
6+
7+
enum ChangeType: string
8+
{
9+
case ADD = 'add';
10+
case REMOVE = 'remove';
11+
case CONTEXT = 'context';
12+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Butschster\ContextGenerator\Lib\ApplyPatchParser;
6+
7+
final readonly class ChangeValidator
8+
{
9+
public function validateChanges(array $parsedChunks, array $contentLines): ValidationResult
10+
{
11+
$errors = [];
12+
$warnings = [];
13+
$positions = [];
14+
15+
foreach ($parsedChunks as $index => $chunk) {
16+
$chunkErrors = $this->validateChunk($chunk, $contentLines, $index);
17+
$errors = \array_merge($errors, $chunkErrors);
18+
}
19+
20+
// Check for overlapping changes
21+
$overlaps = $this->detectOverlaps($positions);
22+
if (!empty($overlaps)) {
23+
$errors[] = 'Detected overlapping changes that would conflict';
24+
}
25+
26+
return new ValidationResult(
27+
isValid: empty($errors),
28+
errors: $errors,
29+
warnings: $warnings,
30+
);
31+
}
32+
33+
private function validateChunk(ParsedChunk $chunk, array $contentLines, int $chunkIndex): array
34+
{
35+
$errors = [];
36+
37+
if (empty($chunk->contextMarker)) {
38+
$errors[] = \sprintf('Chunk %d has empty context marker', $chunkIndex);
39+
}
40+
41+
if (empty($chunk->changes)) {
42+
$errors[] = \sprintf('Chunk %d has no changes', $chunkIndex);
43+
}
44+
45+
// Validate that removal operations match existing content
46+
foreach ($chunk->getRemovals() as $removal) {
47+
if (!$this->lineExistsInContent($removal->content, $contentLines)) {
48+
$errors[] = \sprintf('Chunk %d tries to remove non-existent line: "%s"', $chunkIndex, $removal->content);
49+
}
50+
}
51+
52+
return $errors;
53+
}
54+
55+
private function lineExistsInContent(string $line, array $contentLines): bool
56+
{
57+
foreach ($contentLines as $contentLine) {
58+
if (\trim((string) $contentLine) === \trim($line)) {
59+
return true;
60+
}
61+
}
62+
63+
return false;
64+
}
65+
66+
private function detectOverlaps(array $positions): array
67+
{
68+
// Implementation for detecting overlapping line ranges
69+
// For now, return empty array - this would need more sophisticated logic
70+
return [];
71+
}
72+
}

0 commit comments

Comments
 (0)