Skip to content
75 changes: 75 additions & 0 deletions docs/Traversal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@

# AST Traversal

All Nodes implement the `IteratorAggregate` interface, which means their immediate children can be directly traversed with `foreach`:

```php
foreach ($node as $key => $child) {
var_dump($key)
var_dump($child);
}
```

`$key` is set to the child name (e.g. `parameters`).
Multiple child nodes may have the same key.

The Iterator that is returned to `foreach` from `$node->getIterator()` implements the `RecursiveIterator` interface.
To traverse all descendant nodes, you need to "flatten" it with PHP's built-in `RecursiveIteratorIterator`:

```php
$it = new \RecursiveIteratorIterator($node, \RecursiveIteratorIterator::SELF_FIRST);
foreach ($it as $node) {
var_dump($node);
}
```

The code above will walk all nodes and tokens depth-first.
Passing `RecursiveIteratorIterator::CHILD_FIRST` would traverse breadth-first, while `RecursiveIteratorIterator::LEAVES_ONLY` (the default) would only traverse terminal Tokens.

## Exclude Tokens

To exclude terminal Tokens and only traverse Nodes, use PHP's built-in `ParentIterator`:

```php
$nodes = new \ParentIterator(new \RecursiveIteratorIterator($node, \RecursiveIteratorIterator::SELF_FIRST));
```

## Skipping child traversal

To skip traversal of certain Nodes, use PHP's `RecursiveCallbackIterator`.
Naive example of traversing all nodes in the current scope:

```php
// Find all nodes in the current scope
$nodesInScopeReIt = new \RecursiveCallbackFilterIterator($node, function ($current, $key, Iterator $iterator) {
// Don't traverse into function nodes, they form a differnt scope

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

typo

return !($current instanceof Node\Expression\FunctionDeclaration);
});
// Convert the RecursiveIterator to a flat Iterator
$it = new \RecursiveIteratorIterator($nodesInScope, \RecursiveIteratorIterator::SELF_FIRST);
```

## Filtering

Building on that example, to get all variables in that scope us a non-recursive `CallbackFilterIterator`:

```php
// Filter out all variables
$vars = new \CallbackFilterIterator($it, function ($current, $key, $iterator) {
return $current instanceof Node\Expression\Variable && $current->name instanceof Token;
});

foreach ($vars as $var) {
echo $var->name . PHP_EOL;
}
```

## Converting to an array

You can convert your iterator to a flat array with

```php
$arr = iterator_to_array($it, true);
```

The `true` ensures that the array is indexed numerically and not by Iterator keys (otherwise Nodes later Nodes with the same key will override previous).
Empty file removed docs/a.md
Empty file.
1 change: 1 addition & 0 deletions phpunit.xml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
<file>tests/api/getResolvedName.php</file>
<file>tests/api/PositionUtilitiesTest.php</file>
<file>tests/api/TextEditTest.php</file>
<file>tests/api/NodeIteratorTest.php</file>
</testsuite>

<testsuite name="performance">
Expand Down
11 changes: 10 additions & 1 deletion src/Node.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
use Microsoft\PhpParser\Node\Statement\NamespaceDefinition;
use Microsoft\PhpParser\Node\Statement\NamespaceUseDeclaration;

abstract class Node implements \JsonSerializable {
abstract class Node implements \JsonSerializable, \IteratorAggregate {
/** @var array[] Map from node class to array of child keys */
private static $childNames = [];

Expand Down Expand Up @@ -149,6 +149,15 @@ public function getRoot() : Node {
return $node;
}

/**
* Gets an Iterator to iterate all descendant nodes
*
* @return NodeIterator
*/
public function getIterator() {
return new NodeIterator($this);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Should this be Iterator\NodeIterator?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

woops, yes. didn't change it when I moved it to the namespace.

}

/**
* Gets generator containing all descendant Nodes and Tokens.
*
Expand Down
133 changes: 133 additions & 0 deletions src/NodeIterator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
<?php
declare(strict_types = 1);

namespace Microsoft\PhpParser;

/**
* An Iterator to walk a Node and its descendants
*/
class NodeIterator implements \RecursiveIterator {

/**
* Iterator used to iterate the child names of a Node
*
* @var Iterator
*/
private $childNamesIterator;

/**
* Iterator used to iterate the child nodes at the current child name
*
* @var Iterator|null
*/
private $valueIterator;

/**
* @param Node $node The node that should be iterated
*/
public function __construct(Node $node) {
$this->node = $node;
$this->childNamesIterator = new \ArrayIterator($node::CHILD_NAMES);
$this->valueIterator = new \EmptyIterator();
}

/**
* Rewinds the Iterator to the beginning
*
* @return void
*/
public function rewind() {
// Start child names from beginning
$this->childNamesIterator->rewind();
// Begin new children until found a valid one
while ($this->childNamesIterator->valid()) {
$this->beginChild();
if ($this->valueIterator->valid()) {
break;
}
$this->childNamesIterator->next();
}
}

/**
* Returns `true` if `current()` can be called to get the current child.
* Returns `false` if this Node has no more children (direct descendants).
*/
public function valid() {
return $this->childNamesIterator->valid() && $this->valueIterator->valid();
}

/**
* Returns the current child name being iterated.
* Multiple values may have the same key.
*
* @return string
*/
public function key() {
return $this->childNamesIterator->current();
}

/**
* Returns the current child (direct descendant)
*
* @return Node|Token
*/
public function current() {
return $this->valueIterator->current();
}

/**
* Advances the Iterator to the next child (direct descendant)
*
* @return void
*/
public function next() {
// Go to next value of current child name
$this->valueIterator->next();
// Begin new children until found a valid one
while (!$this->valueIterator->valid() && $this->childNamesIterator->valid()) {
$this->childNamesIterator->next();
if (!$this->childNamesIterator->valid()) {
return;
}
$this->beginChild();
}
}

/**
* Initializes the Iterator for iterating the values of the current child name
*
* @return void
*/
private function beginChild() {
$value = $this->node->{$this->childNamesIterator->current()};
// Skip null values
if ($value === null) {
$this->valueIterator = new \EmptyIterator();
return;
}
if (!is_array($value)) {
$value = [$value];
}
$this->valueIterator = new \ArrayIterator($value);
}

/**
* Returns true if the current child is another Node (not a Token)
* and can be used to create another NodeIterator
*
* @return bool
*/
public function hasChildren(): bool {
return $this->valueIterator->current() instanceof Node;
}

/**
* Returns a NodeIterator for the children of the current Node
*
* @return NodeIterator
*/
public function getChildren() {
return new NodeIterator($this->valueIterator->current());
}
}
110 changes: 110 additions & 0 deletions tests/api/NodeIteratorTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
<?php

use PHPUnit\Framework\TestCase;
use Microsoft\PhpParser\{Parser, NodeIterator, Node};

class NodeIteratorTest extends TestCase {

const FILE_CONTENTS = '

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

use nowdoc instead?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

what's the advantage?

<?php
function a() {
$a = 1;
$b = 2;
}
a();
';

/** @var Node\SourceFileNode */
private $sourceFile;

public function setUp() {
$parser = new Parser();
$this->sourceFile = $parser->parseSourceFile(self::FILE_CONTENTS);
}

public function testIteratesChildren() {
$iterator = new NodeIterator($this->sourceFile);
$iterator->rewind();

$this->assertTrue($iterator->valid());
$this->assertSame($this->sourceFile->statementList[0], $iterator->current());
$iterator->next();

$this->assertTrue($iterator->valid());
$this->assertSame($this->sourceFile->statementList[1], $iterator->current());
$iterator->next();

$this->assertTrue($iterator->valid());
$this->assertSame($this->sourceFile->statementList[2], $iterator->current());
$iterator->next();

$this->assertTrue($iterator->valid());
$this->assertSame($this->sourceFile->endOfFileToken, $iterator->current());
$iterator->next();

$this->assertFalse($iterator->valid());
}

public function testRecursiveIteratorIteratorIteratesDescendants() {

$it = new \RecursiveIteratorIterator(new NodeIterator($this->sourceFile), \RecursiveIteratorIterator::SELF_FIRST);
$it->rewind();

// Node\Statement\InlineHtml
$this->assertTrue($it->valid());
$this->assertSame('statementList', $it->key());
$this->assertSame($this->sourceFile->statementList[0], $it->current());
$it->next();

// Token(kind=ScriptSectionStartTag)
$this->assertTrue($it->valid());
$this->assertSame('scriptSectionStartTag', $it->key());
$this->assertSame($this->sourceFile->statementList[0]->scriptSectionStartTag, $it->current());
$it->next();

// Token(kind=InlineHtml)
$this->assertTrue($it->valid());
$this->assertSame('text', $it->key());
$this->assertSame($this->sourceFile->statementList[0]->text, $it->current());
$it->next();

// Node\Statement\FunctionDeclaration
$this->assertTrue($it->valid());
$this->assertSame('statementList', $it->key());
$this->assertSame($this->sourceFile->statementList[1], $it->current());
$it->next();

// Token(kind=FunctionKeyword)
$this->assertTrue($it->valid());
$this->assertSame('functionKeyword', $it->key());
$this->assertSame($this->sourceFile->statementList[1]->functionKeyword, $it->current());
$it->next();

// Token(kind=Name)
$this->assertTrue($it->valid());
$this->assertSame('name', $it->key());
$this->assertSame($this->sourceFile->statementList[1]->name, $it->current());
$it->next();

// Token(kind=OpenParenToken)
$this->assertTrue($it->valid());
$this->assertSame('openParen', $it->key());
$this->assertSame($this->sourceFile->statementList[1]->openParen, $it->current());
$it->next();

// Token(kind=CloseParenToken)
$this->assertTrue($it->valid());
$this->assertSame('closeParen', $it->key());
$this->assertSame($this->sourceFile->statementList[1]->closeParen, $it->current());
$it->next();

// Node\Statement\CompoundStatementNode
$this->assertTrue($it->valid());
$this->assertSame('compoundStatementOrSemicolon', $it->key());
$this->assertSame($this->sourceFile->statementList[1]->compoundStatementOrSemicolon, $it->current());
$it->next();

// TODO finish
$this->markTestIncomplete();
}
}