Repository navigation
POC: Ast Merging and Scoped Cache Invalidation #2980
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
dantleech
wants to merge
13
commits into
master
Choose a base branch
from
pseudo-incremental-parser
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from 1 commit
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
bc42d76
Ast Merging
dantleech 94066b9
Sanity test for merging parser
dantleech 263cea9
Failing test
dantleech 9030385
Argh
dantleech 6f773d2
It's in some kind of working state
dantleech 6c13a82
Nope
dantleech da77a89
Parser still not working
dantleech 946503e
Failing test
dantleech ce5774d
Seems to work
dantleech 51b68d8
Update diff test for shortcut
dantleech 4bce110
Add debug node ID
dantleech 74366da
Get full text
dantleech 3a8f0d0
Refactor test to simulate session
dantleech File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next
Next commit
Ast Merging
Experimenting with merging ASTs to preserve the object IDs of the nodes which might help with far more efficient caching techniques.
- Loading branch information
commit bc42d7682d015fd45b22d322bf2afcf86e44a2cc
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,219 @@ | ||
| <?php | ||
|
|
||
| namespace Phpactor\TolerantAstDiff; | ||
|
|
||
| use Microsoft\PhpParser\Node; | ||
| use Microsoft\PhpParser\Node\SourceFileNode; | ||
| use Microsoft\PhpParser\Token; | ||
| use Phpactor\TextDocument\TextEdit; | ||
| use Phpactor\TextDocument\TextEdits; | ||
| use ReflectionClass; | ||
| use ReflectionProperty; | ||
| use RuntimeException; | ||
| use function Amp\Promise\wait; | ||
|
|
||
| final class AstDiff | ||
| { | ||
| private SourceFileNode $fileSource1; | ||
| private SourceFileNode $fileSource2; | ||
|
|
||
| public function merge(Node $node1, Node $node2): void | ||
| { | ||
| $this->fileSource1 = $node1->getRoot(); | ||
| $this->fileSource2 = $node2->getRoot(); | ||
|
|
||
| $this->doMerge($node1, $node2); | ||
| } | ||
|
|
||
| private function doMerge(Node $node1, Node $node2): void | ||
| { | ||
| 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 | ||
| )); | ||
| } | ||
|
|
||
|
|
||
| if ($this->isSame($node1, $node2)) { | ||
| return; | ||
| } | ||
|
|
||
| $this->mapNode($node1, $node2); | ||
|
|
||
| $node1ChildNames = $node1->getChildNames(); | ||
| $node2ChildNames = $node2->getChildNames(); | ||
|
|
||
| foreach ($node1ChildNames as $childName) { | ||
|
|
||
| $node1Prop = $node1->$childName; | ||
| $node2Prop = $node2->$childName; | ||
|
|
||
| if (is_array($node1Prop)) { | ||
| assert(is_array($node2Prop)); | ||
| $offset = -1; | ||
| foreach (array_keys($node1Prop) as $offset) { | ||
| $node1Child = $node1Prop[$offset]; | ||
| $node2Child = $node2Prop[$offset] ?? null; | ||
|
|
||
| if ($node2Child === null) { | ||
| $this->removeChildFrom($node1, $childName, $offset); | ||
| break; | ||
| } | ||
|
|
||
| if ($node1Child instanceof Token) { | ||
| continue; | ||
| } | ||
|
|
||
| if (!$node1Child instanceof Node || !$node2Child instanceof Node) { | ||
| // should never happen | ||
| continue; | ||
| } | ||
|
|
||
| if ($node1Child::class !== $node2Child::class) { | ||
| $node1->$childName[$offset] = $node2Child; | ||
| $this->applyEdit($node1, TextEdit::create( | ||
| $node1Child->getFullStartPosition(), | ||
| $node1Child->getFullWidth(), | ||
| $node2Child->getFullText(), | ||
| )); | ||
| continue; | ||
| } | ||
|
|
||
| $this->doMerge($node1Child, $node2Child); | ||
| } | ||
|
|
||
| $this->appendChildren($node1, $childName, array_slice($node2Prop, ++$offset)); | ||
| continue; | ||
| } | ||
|
|
||
| if ($node1Prop instanceof Node) { | ||
| $this->doMerge($node1Prop, $node2Prop); | ||
| } | ||
| } | ||
|
|
||
| return; | ||
| } | ||
|
|
||
| /** | ||
| * Compare the inner node content | ||
| */ | ||
| private static function isSame(Node $node1, Node $node2): bool | ||
| { | ||
| return $node1->getFullText() === $node2->getFullText(); | ||
| } | ||
|
|
||
| private function removeChildFrom(Node $parent, string $childName, int $offset): void | ||
| { | ||
| /** @var list<Node> */ | ||
| $keepNodes = $parent->$childName; | ||
| $removedNodes = array_slice($keepNodes, $offset); | ||
|
|
||
| if (count($removedNodes) === 0) { | ||
| return; | ||
| } | ||
|
|
||
| $firstRemovedNode = $removedNodes[0]; | ||
| $keepNodes = array_slice($keepNodes, 0, $offset); | ||
|
|
||
| $parent->$childName = $keepNodes; | ||
|
|
||
| $removeLength = array_sum(array_map(fn (Node|Token $node) => $node->getFullWidth(), $removedNodes)); | ||
| $this->applyEdit( | ||
| $parent, | ||
| TextEdit::create($firstRemovedNode->getFullStartPosition(), $removeLength, '') | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * @param list<Node> $newNodes | ||
| */ | ||
| private function appendChildren(Node $parent, string $childName, array $newNodes): void | ||
| { | ||
| 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( | ||
| $parent, | ||
| TextEdit::create( | ||
| $lastExistingNode->getFullStartPosition(), | ||
| 0, | ||
| $addContent, | ||
| ), | ||
| ); | ||
|
|
||
| } | ||
|
|
||
| private function applyEdit(Node $node, TextEdit $edit): void | ||
| { | ||
| $source = $this->fileSource1; | ||
| $distance = strlen($edit->replacement()) - $edit->length(); | ||
|
|
||
| foreach ($source->getDescendantTokens() as $token) { | ||
| if ($token->getFullStartPosition() <= $edit->start()->toInt()) { | ||
| continue; | ||
| } | ||
| $token->start += $distance; | ||
| $token->fullStart += $distance; | ||
| } | ||
|
|
||
| $source->fileContents = TextEdits::one($edit)->apply($source->getFileContents()); | ||
| } | ||
|
|
||
| private function mapNode(Node $node1, Node $node2): void | ||
| { | ||
| if ($node2::class !== $node1::class) { | ||
| throw new \RuntimeException(sprintf( | ||
| 'Can only map nodes of the same type to eachother, got %s and %s', | ||
| $node2::class, $node1::class | ||
| )); | ||
| } | ||
|
|
||
| $lastPosition = $node1->getFullStartPosition(); | ||
| foreach ($node1->getChildNames() as $childName) { | ||
| $member1 = $node1->$childName; | ||
| $member2 = $node2->$childName; | ||
|
|
||
| if (is_array($member1)) { | ||
| //foreach ($member1 as $member) { | ||
| // $lastPosition = $member->getFullStartPosition(); | ||
|
dantleech marked this conversation as resolved.
Outdated
|
||
| //} | ||
| continue; | ||
| } | ||
|
|
||
| if ($member1 !== null) { | ||
| $lastPosition = $member1->getFullStartPosition(); | ||
| } | ||
|
|
||
| if ($member2 instanceof Token || $member2 === null) { | ||
| $node1->$childName = $member2; | ||
|
|
||
| $this->applyEdit($node1, TextEdit::create( | ||
| $lastPosition, | ||
| $member1?->getFullWidth() ?? 0, | ||
|
Check failure on line 212 in lib/TolerantAstDiff/AstDiff.php
|
||
| $member2?->getFullText($this->fileSource2->getFileContents()) ?? '', | ||
| )); | ||
| continue; | ||
| } | ||
| } | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,110 @@ | ||
| <?php | ||
|
|
||
| namespace Phpactor\TolerantAstDiff\Tests\Unit; | ||
|
|
||
| use Generator; | ||
| use Microsoft\PhpParser\Parser; | ||
| use PHPUnit\Framework\Attributes\DataProvider; | ||
| use Phpactor\ConfigLoader\Tests\TestCase; | ||
| use Phpactor\TolerantAstDiff\AstDiff; | ||
| use Phpactor\WorseReflection\Core\Util\NodeUtil; | ||
|
|
||
| final class AstDiffTest extends TestCase | ||
| { | ||
| #[DataProvider('provideDiffTree')] | ||
| public function testDiffTree(string $source1, string $source2): void | ||
| { | ||
| $parser = new Parser(); | ||
| $ast1 = $parser->parseSourceFile($source1); | ||
| $ast2 = $parser->parseSourceFile($source2); | ||
|
|
||
| $diff = (new AstDiff()); | ||
| $diff->merge($ast1, $ast2); | ||
|
|
||
| self::assertEquals($ast2->getText(), $ast1->getText()); | ||
| } | ||
| /** | ||
| * @return Generator<string,array{string,string}> | ||
| */ | ||
| public static function provideDiffTree(): Generator | ||
| { | ||
| yield 'same' => [ | ||
| '<?php function hello(): string { echo "hello"; }', | ||
| '<?php function hello(): string { echo "hello"; }', | ||
| ]; | ||
|
|
||
| yield 'remove 1' => [ | ||
| '<?php function hello(): string {echo "hello";}', | ||
| '<?php function hello(): string {}', | ||
| ]; | ||
| yield 'remove 2' => [ | ||
| <<<'PHP' | ||
| <?php | ||
| class Foo | ||
| { | ||
| public function bar() | ||
| { | ||
| echo 'foobar'; | ||
| } | ||
|
|
||
| public function foo() | ||
| { | ||
| } | ||
| } | ||
| PHP, | ||
| <<<'PHP' | ||
| <?php | ||
| class Foo | ||
| { | ||
| public function bar() | ||
| { | ||
| echo 'foobar'; | ||
| } | ||
| } | ||
| PHP | ||
| ]; | ||
|
|
||
| yield 'add 1 node' => [ | ||
| '<?php function hello(): string {echo "hello";}', | ||
| '<?php function hello(): string {echo "hello";echo 2;}', | ||
| ]; | ||
|
|
||
| yield 'change 1 node' => [ | ||
| '<?php function hello(): string {echo "hello";echo 2;}', | ||
| '<?php function hello(): string {echo "hello";echo 3;}', | ||
| ]; | ||
|
|
||
| yield 'update array' => [ | ||
| '<?php function hello(): string { $foo = [1, 2, 3]; }', | ||
| '<?php function hello(): string { $foo = [5, 10]; }', | ||
| ]; | ||
|
|
||
| yield 'replace node' => [ | ||
| '<?php function hello(): string {echo "hello";echo 2;}', | ||
| '<?php class Bar {}', | ||
| ]; | ||
|
|
||
| yield 'artbitrary change' => [ | ||
| <<<'PHP' | ||
| <?php | ||
| class Foo | ||
| { | ||
| public function bar() | ||
| { | ||
| echo 'foobar'; | ||
| } | ||
| } | ||
| PHP, | ||
| <<<'PHP' | ||
| <?php | ||
| class Foo | ||
| { | ||
| public function baz() | ||
| { | ||
| echo 'baz'; | ||
| } | ||
| } | ||
| PHP | ||
| ]; | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.