Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion lib/Extension/WorseReflection/WorseReflectionExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
use Phpactor\Extension\OpenTelemetry\OpenTelemetryExtension;
use Phpactor\Extension\WorseReflection\Telemetry\WorseTelemetry;
use Phpactor\FilePathResolver\PathResolver;
use Phpactor\TolerantAstDiff\AstDiff;
use Phpactor\WorseReflection\Bridge\Phpactor\MemberProvider\DocblockMemberProvider;
use Phpactor\WorseReflection\Bridge\TolerantParser\Diagnostics\AssignmentToMissingPropertyProvider;
use Phpactor\WorseReflection\Bridge\TolerantParser\Diagnostics\DeprecatedUsageDiagnosticProvider;
Expand All @@ -26,6 +27,7 @@
use Phpactor\WorseReflection\Bridge\TolerantParser\Diagnostics\UndefinedVariableProvider;
use Phpactor\WorseReflection\Bridge\TolerantParser\Diagnostics\UnresolvableNameProvider;
use Phpactor\WorseReflection\Bridge\TolerantParser\Diagnostics\UnusedImportProvider;
use Phpactor\WorseReflection\Bridge\TolerantParser\Parser\MergingParser;
use Phpactor\WorseReflection\Core\Cache;
use Phpactor\WorseReflection\Core\CacheForDocument;
use Phpactor\WorseReflection\Core\Cache\StaticCache;
Expand Down Expand Up @@ -156,7 +158,7 @@ private function registerReflection(ContainerBuilder $container): void
});

$container->register(self::SERVICE_PARSER, function (Container $container) {
return new CachedParser($container->get(Cache::class));
return new MergingParser(new AstDiff());
});

$container->register(Cache::class, function (Container $container) {
Expand Down
299 changes: 299 additions & 0 deletions lib/TolerantAstDiff/AstDiff.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,299 @@
<?php

namespace Phpactor\TolerantAstDiff;

use Microsoft\PhpParser\Node;
use Microsoft\PhpParser\Node\DelimitedList\ArrayElementList;
use Microsoft\PhpParser\Node\SourceFileNode;
use Microsoft\PhpParser\Token;
use Phpactor\TextDocument\TextEdit;
use Phpactor\TextDocument\TextEdits;
use Phpactor\WorseReflection\Core\Util\NodeUtil;
use RuntimeException;
use function DeepCopy\deep_copy;

final class AstDiff
{
private SourceFileNode $fileSource1;

private SourceFileNode $fileSource2;

private array $visited = [];

Check failure on line 21 in lib/TolerantAstDiff/AstDiff.php

View workflow job for this annotation

GitHub Actions / PHPStan (8.1)

Property Phpactor\TolerantAstDiff\AstDiff::$visited type has no value type specified in iterable type array.

public function merge(Node $node1, Node $node2): void
{
$this->visited = [];
$this->fileSource1 = $node1->getRoot();
$this->fileSource2 = $node2->getRoot();

$this->doMerge($node1, $node2);
}

private function doMerge(Node $node1, Node $node2): void
{
if ($node1->getFullText() === $node2->getFullText()) {
return;
}

// check for circular references
// TODO: this was added for debugging can probably be removed
if (isset($this->visited[spl_object_id($node1)])) {
throw new \RuntimeException('Circular reference');
}

$this->visited[spl_object_id($node1)] = true;

if ($node1::class !== $node2::class) {
throw new RuntimeException(sprintf(
'Can only compare nodes of the same type, got: %s vs %s',
$node1::class,
$node2::class
));
}

$node1ChildNames = $node1->getChildNames();
$node2ChildNames = $node2->getChildNames();

$lastPosition = $node1->getFullStartPosition();
foreach ($node1ChildNames as $childName) {

$member1 = $node1->$childName;
$member2 = $node2->$childName;

// this property is a list of nodes we need to figure out which
// items to add, remove or update
if (is_array($member1)) {

/** @var list<Token|Node> $member1 */
/** @var list<Token|Node> $member2 */

$elementIndex = -1;
foreach (array_keys($member1) as $elementIndex) {
$node1Child = $member1[$elementIndex];
$node2Child = $member2[$elementIndex] ?? null;

$lastPosition = $node1Child->getFullStartPosition();

if ($node2Child === null) {

// if there's no corresponding node in the second AST
// then remove it's correspondant and all subsequent
// nodes from the list
$this->removeChildFrom($node1, $childName, $elementIndex);

// we can break early noow
break;
}

if ($node1Child instanceof Token) {
// if we have a single token, then just replace it
// with the corresponding token/node/null

/** @phpstan-ignore-next-line */
$node1->$childName[$elementIndex] = $node2Child;
$replacement = $node2Child->getFullText(
$this->fileSource2->getFileContents()
);
$this->applyEdit(TextEdit::create(
$node1Child->getFullStartPosition(),
$node1Child->getFullWidth(),
$replacement,
));

continue;
}

// if the class type is different then it's
// replace the whole subtree.
if ($node1Child::class !== $node2Child::class) {
/** @phpstan-ignore-next-line */
$node1->$childName[$elementIndex] = $node2Child;
$this->applyEdit(TextEdit::create(
$node1Child->getFullStartPosition(),
$node1Child->getFullWidth(),
$node2Child->getFullText($this->fileSource2->getFileContents()),
));
continue;
}

if ($node2Child instanceof Node) {
// recurse on the listed node:
$this->doMerge($node1Child, $node2Child);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but node1...

continue;
}
}

// if we get here then we are adding elements
// so we take all elements after the last index
// (or after -1 if we didn't enter the above loop).
$this->appendChildren($node1, $childName, array_slice($member2, ++$elementIndex));

// we're done with this list property, move on to
// the next property in the Node.
continue;
}

// it's possible that property is NULL, if it's not then the last
// position of the last property would be used by ommission
if ($member1 instanceof Node || $member1 instanceof Token) {
$lastPosition = $member1->getStartPosition();
}

// if the original member is NULL but the new member is
// NOT null then just replace it
if ($member2 === null && $member1 !== null) {
$node1->$childName = $this->copyNode($member2);

Check failure on line 145 in lib/TolerantAstDiff/AstDiff.php

View workflow job for this annotation

GitHub Actions / PHPStan (8.1)

Parameter #1 $node of method Phpactor\TolerantAstDiff\AstDiff::copyNode() expects Microsoft\PhpParser\Node|Microsoft\PhpParser\Token, null given.
$this->applyEdit(TextEdit::create(
$lastPosition,
0,
$member2?->getFullText($this->fileSource2->getFileContents()) ?? '',

Check failure on line 149 in lib/TolerantAstDiff/AstDiff.php

View workflow job for this annotation

GitHub Actions / PHPStan (8.1)

Expression on left side of ?? is always null.

Check failure on line 149 in lib/TolerantAstDiff/AstDiff.php

View workflow job for this annotation

GitHub Actions / PHPStan (8.1)

Cannot call method getFullText() on null.
));
continue;
}

// if the members are both nodes then just replace it and continue
if ($member2 instanceof Node && $member2::class !== $member1::class) {

Check failure on line 155 in lib/TolerantAstDiff/AstDiff.php

View workflow job for this annotation

GitHub Actions / PHPStan (8.1)

Cannot access constant class on mixed.
// update the reference content in the source node by
// applying a text edit - we'll reindex the offsets later.
$node1->$childName = $this->copyNode($member2);
$this->applyEdit(TextEdit::create(
$lastPosition,
$member1?->getFullWidth(),

Check failure on line 161 in lib/TolerantAstDiff/AstDiff.php

View workflow job for this annotation

GitHub Actions / PHPStan (8.1)

Parameter #2 $length of static method Phpactor\TextDocument\TextEdit::create() expects int, mixed given.

Check failure on line 161 in lib/TolerantAstDiff/AstDiff.php

View workflow job for this annotation

GitHub Actions / PHPStan (8.1)

Cannot call method getFullWidth() on mixed.
$member2?->getFullText($this->fileSource2->getFileContents()) ?? '',

Check failure on line 162 in lib/TolerantAstDiff/AstDiff.php

View workflow job for this annotation

GitHub Actions / PHPStan (8.1)

Using nullsafe method call on non-nullable type Microsoft\PhpParser\Node. Use -> instead.

Check failure on line 162 in lib/TolerantAstDiff/AstDiff.php

View workflow job for this annotation

GitHub Actions / PHPStan (8.1)

Method Microsoft\PhpParser\Node::getFullText() invoked with 1 parameter, 0 required.

Check failure on line 162 in lib/TolerantAstDiff/AstDiff.php

View workflow job for this annotation

GitHub Actions / PHPStan (8.1)

Expression on left side of ?? is not nullable.
));
continue;
}

// if the new member is a token then just replace it
if ($member2 instanceof Token) {
/** @phpstan-ignore-next-line */
$replacement = $member2->getFullText($this->fileSource2->getFileContents());
$node1->$childName = $this->copyNode($member2);
$this->applyEdit(TextEdit::create(
$member1->getFullStartPosition(),
$member1->getFullWidth(),
$replacement ?? '',
));
continue;
}

// if we got here then the nodes are of the same class so let's
// merge them
if ($member2 instanceof Node && $member1 instanceof Node) {
$this->doMerge($member1, $member2);
continue;
}

// nothing and nothing is nothing
if ($member2 === null && $member1 === null) {
continue;
}

throw new \RuntimeException(sprintf(
'Do not know what to do with %s vs. %s',
get_debug_type($member1), get_debug_type($member2)
));
}

return;
}

private function removeChildFrom(Node $parent, string $childName, int $index): void
{
/** @var list<Node> */
$existingNodes = $parent->$childName;
// we need to figure out the length of the nodes we're removing
// so that we can compensate
$removedNodes = array_slice($existingNodes, $index);

// if we didn't remove anything then there's nothing to see here.
if (count($removedNodes) === 0) {
return;
}

// we want to truncate the source code from this point
$firstRemovedNode = $removedNodes[0];

// update the AST with just the nodes that were not removed
$keepNodes = array_slice($existingNodes, 0, $index);
$parent->$childName = $keepNodes;

$removeLength = array_sum(array_map(
fn (Node|Token $node) => $node->getFullWidth(),
$removedNodes
));

$this->applyEdit(
TextEdit::create(
$firstRemovedNode->getFullStartPosition(),
$removeLength,
''
)
);
}

/**
* @param list<Node|Token> $newNodes
*/
private function appendChildren(Node $parent, string $childName, array $newNodes): void
{
$newNodes = array_map(function (Node|Token $node) {
return $this->copyNode($node);
}, $newNodes);
if (empty($newNodes)) {
return;
}
$firstNewNode = $newNodes[array_key_first($newNodes)];

/** @var Node[] */
$existingNodes = $parent->$childName;
$lastExistingNode = $newNodes[array_key_last($newNodes)];
$parent->$childName = array_merge($existingNodes, $newNodes);

$addLength = array_sum(array_map(
fn (Node|Token $node) => $node->getFullWidth(),
$newNodes
));

$addContent = substr(
$this->fileSource2->getFileContents(),
$firstNewNode->getFullStartPosition(),
$addLength,
);

$this->applyEdit(
TextEdit::create(
$lastExistingNode->getFullStartPosition(),
0,
$addContent,
),
);

}

private function applyEdit(TextEdit $edit): void

{
$source = $this->fileSource1;
$source->fileContents = TextEdits::one($edit)->apply($source->getFileContents());
self::reindex($this->fileSource1);

@dantleech dantleech Dec 7, 2025 •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note that depending on the number of edits this can probably done far more efficiently by creating a TextEdits collection and applying them all and reindexingt once after updating the AST.

}

private static function reindex(Node $node): void
{
$offset = 0;
foreach ($node->getDescendantTokens() as $token) {
$leading = $token->start - $token->fullStart;

$token->fullStart = $offset;
$token->start = $offset + $leading;

$offset += $token->length;
}
}

private function copyNode(Node|Token $node): Node|Token
{
return $node;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

here to abstract the place where we could deep_copy the node, but performance wise we're probably happy to corrupt the second AST,

}
}
24 changes: 24 additions & 0 deletions lib/TolerantAstDiff/Tests/Benchmark/AstDiffBench.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

namespace Phpactor\TolerantAstDiff\Tests\Benchmark;

use Phpactor\TolerantAstDiff\AstDiff;
use Phpactor\WorseReflection\Bridge\TolerantParser\Parser\MergingParser;

final class AstDiffBench
{
public function benchMerge(): void
{
$largeFile = __DIR__ . '/../../AstDiff.php';
$mergingParser = new MergingParser(new AstDiff());
$contents = file_get_contents($largeFile);

$ast1 = $mergingParser->parseSourceFile($contents, __FILE__);

$contents = (string)substr($contents, 0, 150);
$contents .= "\n";
$contents .= "\n";

$ast2 = $mergingParser->parseSourceFile($contents, __FILE__);
}
}
Loading
Loading