├── .gitignore
├── bin
├── php
├── composer
├── phpstan
├── rector
├── ecs
├── test
├── fix
└── install
├── docs
├── screenshot.png
└── example.php
├── docker
└── php
│ ├── Dockerfile
│ └── php.ini
├── phpstan-baseline.neon
├── .editorconfig
├── composer-bin
└── ast-inspect
├── phpstan.neon
├── utils
└── PHPStan
│ ├── tests
│ └── GetHelperRule
│ │ ├── Fixtures
│ │ ├── CallsGetHelperInClassThatIsNotACommand.php
│ │ ├── CallsGetHelperInConstructor.php
│ │ └── CallsGetHelperInExecute.php
│ │ └── GetHelperRuleTest.php
│ └── src
│ └── GetHelperRule.php
├── rector.php
├── src
└── PhpAstInspector
│ ├── Console
│ ├── Application.php
│ ├── Highlight.php
│ ├── IntegrationTest.php
│ ├── RenderNodeInfo.php
│ ├── CodeFormatter.php
│ ├── CodeFormatterTest.php
│ ├── NavigateToNode.php
│ └── InspectCommand.php
│ └── PhpParser
│ ├── Parser.php
│ ├── GetNodeInfo.php
│ ├── GetNodeInfoTest.php
│ ├── NodeNavigatorTest.php
│ └── NodeNavigator.php
├── docker-compose.yml
├── ecs.php
├── phpunit.xml.dist
├── README.md
├── composer.json
└── .github
└── workflows
└── code_analysis.yaml
/.gitignore:
--------------------------------------------------------------------------------
1 | /vendor
2 | /composer.lock
3 | /.env
4 | /.idea
5 |
--------------------------------------------------------------------------------
/bin/php:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | docker compose run --rm php "$@"
4 |
--------------------------------------------------------------------------------
/bin/composer:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | docker compose run --rm composer "$@"
4 |
--------------------------------------------------------------------------------
/bin/phpstan:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | docker compose run php vendor/bin/phpstan "$@"
4 |
--------------------------------------------------------------------------------
/bin/rector:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | docker compose run php vendor/bin/rector "$@"
4 |
--------------------------------------------------------------------------------
/bin/ecs:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | set -eu
4 |
5 | docker compose run php vendor/bin/ecs check "$@"
6 |
--------------------------------------------------------------------------------
/docs/screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/matthiasnoback/php-ast-inspector/HEAD/docs/screenshot.png
--------------------------------------------------------------------------------
/bin/test:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | bin/phpstan
4 |
5 | docker compose run --rm php vendor/bin/phpunit "$@"
6 |
--------------------------------------------------------------------------------
/docker/php/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM php:8.1-cli-alpine
2 |
3 | COPY php.ini ${PHP_INI_DIR}
4 |
5 | RUN apk add icu-dev \
6 | && docker-php-ext-install intl
7 |
--------------------------------------------------------------------------------
/phpstan-baseline.neon:
--------------------------------------------------------------------------------
1 | parameters:
2 | ignoreErrors:
3 | -
4 | message: "#^While loop condition is always true\\.$#"
5 | count: 1
6 | path: src/PhpAstInspector/Console/InspectCommand.php
7 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | charset = utf-8
5 | end_of_line = lf
6 | insert_final_newline = true
7 | trim_trailing_whitespace = true
8 | indent_style = space
9 | indent_size = 4
10 |
--------------------------------------------------------------------------------
/docker/php/php.ini:
--------------------------------------------------------------------------------
1 | date.timezone = "UTC"
2 |
3 | error_reporting = E_ALL
4 |
5 | # This will print any errors to STDOUT, Docker picks this up as log output
6 | log_errors = "1"
7 |
8 | memory_limit = 2G
9 |
--------------------------------------------------------------------------------
/bin/fix:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | set -eu
4 |
5 | docker compose run php vendor/bin/rector
6 |
7 | docker compose run php vendor/bin/ecs check --fix
8 |
9 | docker compose run php vendor/bin/ecs check --fix
10 |
--------------------------------------------------------------------------------
/docs/example.php:
--------------------------------------------------------------------------------
1 | run());
11 |
--------------------------------------------------------------------------------
/phpstan.neon:
--------------------------------------------------------------------------------
1 | includes:
2 | - phpstan-baseline.neon
3 |
4 | parameters:
5 | level: max
6 | paths:
7 | - src
8 | - utils
9 | excludePaths:
10 | - utils/PHPStan/tests/GetHelperRule/Fixtures
11 | rules:
12 | - Utils\PHPStan\GetHelperRule
13 |
--------------------------------------------------------------------------------
/utils/PHPStan/tests/GetHelperRule/Fixtures/CallsGetHelperInClassThatIsNotACommand.php:
--------------------------------------------------------------------------------
1 | getHelper('question');
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/bin/install:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | set -eu
4 |
5 | echo "Creating .env file"
6 | printf "HOST_UID=%s\nHOST_GID=%s\n" "$(id -u)" "$(id -g)" > .env
7 |
8 | echo "Pulling Docker images"
9 | docker compose pull
10 |
11 | echo "Installing Composer dependencies"
12 | docker compose run --rm composer install --ignore-platform-reqs --prefer-dist
13 |
14 | echo ""
15 | echo "Done"
16 | echo ""
17 |
--------------------------------------------------------------------------------
/rector.php:
--------------------------------------------------------------------------------
1 | paths([__DIR__ . '/src', __DIR__ . '/utils', __DIR__ . '/rector.php', __DIR__ . '/ecs.php']);
10 | $config->importNames();
11 |
12 | $config->sets([LevelSetList::UP_TO_PHP_81]);
13 | };
14 |
--------------------------------------------------------------------------------
/utils/PHPStan/tests/GetHelperRule/Fixtures/CallsGetHelperInConstructor.php:
--------------------------------------------------------------------------------
1 | getHelper('question');
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/src/PhpAstInspector/Console/Application.php:
--------------------------------------------------------------------------------
1 | addCommands([new InspectCommand()]);
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: "3"
2 | services:
3 | php:
4 | build: docker/php/
5 | image: matthiasnoback/php-ast-inspector-php
6 | working_dir: /opt
7 | volumes:
8 | - ./:/opt
9 | entrypoint: php
10 | user: ${HOST_UID}:${HOST_GID}
11 | env_file:
12 | - .env
13 | init: true
14 |
15 | composer:
16 | image: composer:latest
17 | volumes:
18 | - ./:/app
19 | user: ${HOST_UID}:${HOST_GID}
20 | env_file:
21 | - .env
22 |
--------------------------------------------------------------------------------
/ecs.php:
--------------------------------------------------------------------------------
1 | paths([__DIR__ . '/src', __DIR__ . '/utils', __DIR__ . '/rector.php', __DIR__ . '/ecs.php']);
12 | $config->skip([PhpUnitStrictFixer::class]);
13 |
14 | $config->sets([SetList::CONTROL_STRUCTURES, SetList::PSR_12, SetList::COMMON, SetList::SYMPLIFY]);
15 | };
16 |
--------------------------------------------------------------------------------
/utils/PHPStan/tests/GetHelperRule/Fixtures/CallsGetHelperInExecute.php:
--------------------------------------------------------------------------------
1 | getHelper('question');
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/phpunit.xml.dist:
--------------------------------------------------------------------------------
1 |
2 |
5 |
6 |
7 | ./src
8 |
9 |
10 | ./utils/PHPStan/tests
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/src/PhpAstInspector/Console/Highlight.php:
--------------------------------------------------------------------------------
1 | getStartFilePos() > -1 && $node->getEndFilePos() > -1) {
20 | return new self($node->getStartFilePos(), $node->getEndFilePos() + 1);
21 | }
22 |
23 | return null;
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # PHP AST Inspector
2 |
3 | This package offers a command-line utility for parsing a PHP script and navigating the resulting Abstract Syntax Tree (AST), showing information about the nodes and highlighting the corresponding part of the original script.
4 |
5 | 
6 |
7 | ## Installation
8 |
9 | ```
10 | composer require --dev matthiasnoback/php-ast-inspector
11 | ```
12 |
13 | ## Usage
14 |
15 | Run `ast-inspect` by pointing it at a script that you want to inspect, e.g. if your script is called `file.php`:
16 |
17 | ```
18 | vendor/bin/ast-inspect inspect file.php
19 | ```
20 |
21 | Now use the keys (`a`, `s`, `d`, `w`) to navigate the node tree. Quit using `Ctrl + C`.
22 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "matthiasnoback/php-ast-inspector",
3 | "require": {
4 | "php": "^8.1",
5 | "symfony/console": "^6.1",
6 | "nikic/php-parser": "^4.14"
7 | },
8 | "require-dev": {
9 | "symplify/easy-coding-standard": "^11.1",
10 | "symplify/coding-standard": "^11.1",
11 | "phpunit/phpunit": "^9.5.21",
12 | "phpstan/phpstan": "^1.8.2",
13 | "rector/rector": "^0.14.2"
14 | },
15 | "autoload": {
16 | "psr-4": {
17 | "PhpAstInspector\\": "src/PhpAstInspector/"
18 | }
19 | },
20 | "autoload-dev": {
21 | "psr-4": {
22 | "Utils\\PHPStan\\": "utils/PHPStan/src",
23 | "Utils\\PHPStan\\Tests\\": "utils/PHPStan/tests"
24 | }
25 | },
26 | "config": {
27 | "allow-plugins": {
28 | "phpstan/extension-installer": true
29 | }
30 | },
31 | "bin": ["composer-bin/ast-inspect"]
32 | }
33 |
--------------------------------------------------------------------------------
/.github/workflows/code_analysis.yaml:
--------------------------------------------------------------------------------
1 | name: Code Analysis
2 |
3 | on:
4 | pull_request: null
5 | push:
6 | branches:
7 | - main
8 |
9 | jobs:
10 | code_analysis:
11 | strategy:
12 | fail-fast: false
13 | matrix:
14 | actions:
15 | -
16 | name: "PHPStan"
17 | run: bin/phpstan
18 |
19 | -
20 | name: "PHPUnit"
21 | run: bin/test
22 |
23 | -
24 | name: "Rector"
25 | run: bin/rector --dry-run
26 |
27 | -
28 | name: "ECS"
29 | run: bin/ecs
30 |
31 | name: ${{ matrix.actions.name }}
32 | runs-on: ubuntu-latest
33 |
34 | steps:
35 | - uses: actions/checkout@v2
36 | - run: bin/install
37 | - run: ${{ matrix.actions.run }}
38 |
--------------------------------------------------------------------------------
/src/PhpAstInspector/Console/IntegrationTest.php:
--------------------------------------------------------------------------------
1 | start();
17 | $process->waitUntil(fn () => str_contains($process->getOutput(), 'Next?'));
18 |
19 | // Code section
20 | self::assertStringContainsString('Hello, world!', $process->getOutput(), $process->getErrorOutput());
21 |
22 | // Info section
23 | self::assertStringContainsString(Declare_::class, $process->getOutput(), $process->getErrorOutput());
24 |
25 | // Question section
26 | self::assertStringContainsString('Next?', $process->getOutput(), $process->getErrorOutput());
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/src/PhpAstInspector/PhpParser/Parser.php:
--------------------------------------------------------------------------------
1 |
19 | */
20 | public function parse(string $code): array
21 | {
22 | $parser = (new ParserFactory())->create(ParserFactory::PREFER_PHP7, self::createLexer());
23 |
24 | $nodes = $parser->parse($code);
25 |
26 | if ($nodes === null) {
27 | throw new RuntimeException('Parser returned no nodes');
28 | }
29 |
30 | $traverser = new NodeTraverser();
31 | $traverser->addVisitor(new NodeConnectingVisitor());
32 | $traverser->traverse($nodes);
33 |
34 | return $nodes;
35 | }
36 |
37 | private static function createLexer(): Lexer
38 | {
39 | return new Emulative([
40 | 'usedAttributes' => ['startFilePos', 'endFilePos'],
41 | ]);
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/utils/PHPStan/tests/GetHelperRule/GetHelperRuleTest.php:
--------------------------------------------------------------------------------
1 |
12 | */
13 | final class GetHelperRuleTest extends RuleTestCase
14 | {
15 | public function testCallToGetHelperInConstructor(): void
16 | {
17 | $this->analyse(
18 | [__DIR__ . '/Fixtures/CallsGetHelperInConstructor.php'],
19 | [[
20 | 'getHelper() should not be called in the constructor because helpers have not been registered at that point',
21 | 13,
22 | ]]
23 | );
24 | }
25 |
26 | public function testSkipCallToGetHelperInExecute(): void
27 | {
28 | $this->analyse(
29 | [__DIR__ . '/Fixtures/CallsGetHelperInExecute.php'],
30 | [] // no errors
31 | );
32 | }
33 |
34 | public function testSkipCallToGetHelperInClassThatIsNotACommand(): void
35 | {
36 | $this->analyse(
37 | [__DIR__ . '/Fixtures/CallsGetHelperInClassThatIsNotACommand.php'],
38 | [] // no errors
39 | );
40 | }
41 |
42 | protected function getRule(): GetHelperRule
43 | {
44 | return new GetHelperRule();
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/src/PhpAstInspector/Console/RenderNodeInfo.php:
--------------------------------------------------------------------------------
1 | breadcrumbs();
28 | $breadcrumbs[count($breadcrumbs) - 1] = sprintf(
29 | '<%1$s>%2$s%1$s>',
30 | self::CURRENT_NODE_TAG,
31 | $breadcrumbs[array_key_last($breadcrumbs)]
32 | );
33 | $tempOutput->writeln('Current node: ' . implode(' > ', $breadcrumbs) . "\n");
34 |
35 | $table = new Table($tempOutput);
36 | $table->setStyle('compact');
37 | $nodeInfo = $this->getNodeInfo->forNode($node);
38 | foreach ($nodeInfo as $key => $value) {
39 | $table->addRow([sprintf('<%1$s>%2$s%1$s>', self::SUBNODE_TAG, $key), $value]);
40 | }
41 | $table->render();
42 |
43 | return $tempOutput->fetch();
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/utils/PHPStan/src/GetHelperRule.php:
--------------------------------------------------------------------------------
1 |
18 | */
19 | final class GetHelperRule implements Rule
20 | {
21 | public function getNodeType(): string
22 | {
23 | return MethodCall::class;
24 | }
25 |
26 | /**
27 | * @param MethodCall $node
28 | */
29 | public function processNode(Node $node, Scope $scope): array
30 | {
31 | if (! $node->name instanceof Identifier) {
32 | // This is a dynamic method call
33 | return [];
34 | }
35 |
36 | if ($node->name->name !== 'getHelper') {
37 | // This is not a call to getHelper()
38 | return [];
39 | }
40 |
41 | if (! $scope->getFunction() instanceof MethodReflection) {
42 | // This method call happens outside a method
43 | return [];
44 | }
45 |
46 | if (! $scope->getFunction()->getDeclaringClass()->isSubclassOf(Command::class)) {
47 | // This is not a command class
48 | return [];
49 | }
50 |
51 | if ($scope->getFunctionName() !== '__construct') {
52 | // The call happens outside the constructor
53 | return [];
54 | }
55 |
56 | return [
57 | RuleErrorBuilder::message(
58 | 'getHelper() should not be called in the constructor because helpers have not been registered at that point'
59 | )->build(),
60 | ];
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/src/PhpAstInspector/PhpParser/GetNodeInfo.php:
--------------------------------------------------------------------------------
1 |
18 | */
19 | public function forNode(Node $node): array
20 | {
21 | $info = [];
22 |
23 | foreach ($node->getSubNodeNames() as $key) {
24 | assert(is_string($key));
25 |
26 | $info[$key] = $this->dumpValue($node, $key, $node->{$key});
27 | }
28 |
29 | return $info;
30 | }
31 |
32 | protected function dumpValue(Node $node, string $key, mixed $value): string
33 | {
34 | if ($value === null) {
35 | return 'null';
36 | } elseif ($value === false) {
37 | return 'false';
38 | } elseif ($value === true) {
39 | return 'true';
40 | } elseif (is_object($value)) {
41 | return $value::class;
42 | } elseif (is_array($value)) {
43 | return '[' . implode(
44 | ', ',
45 | array_map(fn (mixed $element) => is_object($element) ? $element::class : '...', $value)
46 | ) . ']';
47 | } elseif (is_scalar($value)) {
48 | if ($key === 'flags' || $key === 'newModifier') {
49 | return (string) $this->dumpFlags($value);
50 | } elseif ($key === 'type' && $node instanceof Include_) {
51 | return (string) $this->dumpIncludeType($value);
52 | } elseif ($key === 'type'
53 | && ($node instanceof Use_ || $node instanceof UseUse || $node instanceof GroupUse)) {
54 | return (string) $this->dumpUseType($value);
55 | }
56 | return (string) $value;
57 | }
58 |
59 | return '...';
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/src/PhpAstInspector/PhpParser/GetNodeInfoTest.php:
--------------------------------------------------------------------------------
1 | getInfo($this->getNode(<<<'CODE_SAMPLE'
19 | 'false',
28 | // boolean: show string equivalent
29 | 'name' => Identifier::class,
30 | // object: show class
31 | 'attrGroups' => '[]',
32 | // empty array
33 | 'params' => '[PhpParser\Node\Param]',
34 | // array with objects
35 | 'returnType' => 'null',
36 | 'stmts' => '[]',
37 | ],
38 | $info
39 | );
40 | }
41 |
42 | public function testItDumpsUseTypes(): void
43 | {
44 | $info = $this->getInfo(new Use_([], Use_::TYPE_FUNCTION));
45 |
46 | self::assertEquals([
47 | 'type' => 'TYPE_FUNCTION (2)',
48 | 'uses' => '[]',
49 | ], $info);
50 | }
51 |
52 | public function testItDumpsIncludeTypes(): void
53 | {
54 | $info = (new GetNodeInfo())->forNode(new Include_(new String_('file.php'), Include_::TYPE_REQUIRE));
55 |
56 | self::assertEquals([
57 | 'type' => 'TYPE_REQUIRE (3)',
58 | 'expr' => String_::class,
59 | ], $info);
60 | }
61 |
62 | private function getNode(string $code): Node
63 | {
64 | return NodeNavigator::selectFirstFrom((new Parser())->parse($code))->currentNode();
65 | }
66 |
67 | /**
68 | * @return array
69 | */
70 | private function getInfo(Node $node): array
71 | {
72 | return (new GetNodeInfo())->forNode($node);
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/src/PhpAstInspector/Console/CodeFormatter.php:
--------------------------------------------------------------------------------
1 | ';
14 |
15 | private const END_HIGHLIGHT = '';
16 |
17 | public function format(string $code, ?Highlight $highlight = null): string
18 | {
19 | if ($highlight instanceof Highlight) {
20 | $beforeHighlighted = substr($code, 0, $highlight->startPosition);
21 | $highlighted = substr(
22 | $code,
23 | $highlight->startPosition,
24 | $highlight->endPosition - $highlight->startPosition
25 | );
26 | $afterHighlighted = substr($code, $highlight->endPosition);
27 | $code = $beforeHighlighted . self::START_HIGHLIGHT . $highlighted . self::END_HIGHLIGHT . $afterHighlighted;
28 | }
29 |
30 | $lines = explode("\n", $code);
31 | $maximumLineNumberWidth = strlen((string) count($lines));
32 |
33 | $inHighlightedSection = false;
34 |
35 | foreach ($lines as $index => $line) {
36 | $lineNumber = $index + 1;
37 |
38 | if (str_contains($line, self::START_HIGHLIGHT)) {
39 | $inHighlightedSection = true;
40 | } elseif ($inHighlightedSection) {
41 | $line = self::START_HIGHLIGHT . $line;
42 | }
43 | if (str_contains($line, self::END_HIGHLIGHT)) {
44 | $inHighlightedSection = false;
45 | } elseif ($inHighlightedSection) {
46 | $line .= self::END_HIGHLIGHT;
47 | }
48 |
49 | $paddedLeft = str_pad((string) $lineNumber, $maximumLineNumberWidth, ' ', STR_PAD_LEFT);
50 | $paddedRight = str_pad($paddedLeft, 4);
51 |
52 | $lineWithNumber = '<' . self::LINE_NUMBER_TAG . '>' . $paddedRight . '' . self::LINE_NUMBER_TAG . '>' . $line;
53 |
54 | $lines[$index] = rtrim($lineWithNumber);
55 | }
56 |
57 | return implode("\n", $lines);
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/src/PhpAstInspector/Console/CodeFormatterTest.php:
--------------------------------------------------------------------------------
1 | formatter = new CodeFormatter();
16 | }
17 |
18 | public function testItAddsLineNumbers(): void
19 | {
20 | $code = <<<'CODE_SAMPLE'
21 | formatter->format($code);
27 |
28 | self::assertSame(
29 | <<<'CODE_SAMPLE'
30 | 1 2
32 | 3 echo 'Line 3';
33 | CODE_SAMPLE
34 | ,
35 | $formatted
36 | );
37 | }
38 |
39 | public function testItAlignsLineNumbersToTheRight(): void
40 | {
41 | $code = <<<'CODE_SAMPLE'
42 | formatter->format($code);
55 |
56 | self::assertSame(
57 | <<<'CODE_SAMPLE'
58 | 1 2
60 | 3 echo 'Line 3';
61 | 4 echo 'Line 4';
62 | 5 echo 'Line 5';
63 | 6 echo 'Line 6';
64 | 7 echo 'Line 7';
65 | 8 echo 'Line 8';
66 | 9 echo 'Line 9';
67 | 10 echo 'Line 10';
68 | CODE_SAMPLE
69 | ,
70 | $formatted
71 | );
72 | }
73 |
74 | public function testItHighlightsPartOfAGivenLine(): void
75 | {
76 | $code = <<<'CODE_SAMPLE'
77 | formatter->format($code, new Highlight(12, 20));
83 |
84 | self::assertSame(
85 | <<<'CODE_SAMPLE'
86 | 1 2
88 | 3 echo 'Line 3';
89 | CODE_SAMPLE
90 | ,
91 | $formatted
92 | );
93 | }
94 |
95 | public function testItHighlightsAcrossMultipleLines(): void
96 | {
97 | $code = <<<'CODE_SAMPLE'
98 | formatter->format($code, new Highlight(12, 35));
105 |
106 | self::assertSame(
107 | <<<'CODE_SAMPLE'
108 | 1 2
110 | 3 echo 'Line 3';
111 | 4 echo 'Line 4';
112 | CODE_SAMPLE
113 | ,
114 | $formatted
115 | );
116 | }
117 | }
118 |
--------------------------------------------------------------------------------
/src/PhpAstInspector/PhpParser/NodeNavigatorTest.php:
--------------------------------------------------------------------------------
1 | createNavigator(
17 | <<<'CODE_SAMPLE'
18 | hasNextNode());
29 | $navigator = $navigator->navigateToNextNode();
30 | self::assertInstanceOf(Class_::class, $navigator->currentNode());
31 |
32 | self::assertFalse($navigator->hasNextNode());
33 | }
34 |
35 | public function testPreviousNodeExists(): void
36 | {
37 | $navigator = $this->createNavigator(
38 | <<<'CODE_SAMPLE'
39 | hasPreviousNode());
50 |
51 | $navigator = $navigator->navigateToNextNode();
52 |
53 | self::assertTrue($navigator->hasPreviousNode());
54 | $navigator = $navigator->navigateToPreviousNode();
55 | self::assertInstanceOf(Function_::class, $navigator->currentNode());
56 | }
57 |
58 | public function testSubnodeExists(): void
59 | {
60 | $navigator = $this->createNavigator(<<<'CODE_SAMPLE'
61 | hasSubnode());
69 |
70 | $navigator = $navigator->navigateToFirstSubnode();
71 | self::assertInstanceOf(Identifier::class, $navigator->currentNode());
72 |
73 | self::assertFalse($navigator->hasSubnode());
74 | }
75 |
76 | public function testParentNodeExists(): void
77 | {
78 | $navigator = $this->createNavigator(<<<'CODE_SAMPLE'
79 | navigateToFirstSubnode();
87 |
88 | self::assertTrue($navigator->hasParentNode());
89 | $navigator = $navigator->navigateToParentNode();
90 | self::assertInstanceOf(Function_::class, $navigator->currentNode());
91 |
92 | self::assertFalse($navigator->hasParentNode());
93 | }
94 |
95 | public function testBreadcrumbs(): void
96 | {
97 | $navigator = $this->createNavigator(<<<'CODE_SAMPLE'
98 | breadcrumbs());
106 |
107 | $navigator = $navigator->navigateToFirstSubnode();
108 | self::assertSame([Function_::class, Identifier::class], $navigator->breadcrumbs());
109 | }
110 |
111 | private function createNavigator(string $code): NodeNavigator
112 | {
113 | $parser = new Parser();
114 | $nodes = $parser->parse($code);
115 |
116 | return NodeNavigator::selectFirstFrom($nodes);
117 | }
118 | }
119 |
--------------------------------------------------------------------------------
/src/PhpAstInspector/PhpParser/NodeNavigator.php:
--------------------------------------------------------------------------------
1 | $nodes
19 | */
20 | public static function selectFirstFrom(array $nodes): self
21 | {
22 | if ($nodes === []) {
23 | throw new RuntimeException('Parser did not return any nodes');
24 | }
25 |
26 | return new self($nodes[0]);
27 | }
28 |
29 | public function hasNextNode(): bool
30 | {
31 | return $this->currentNode->getAttribute('next') instanceof Node;
32 | }
33 |
34 | public function navigateToNextNode(): self
35 | {
36 | $nextNode = $this->currentNode->getAttribute('next');
37 | assert($nextNode instanceof Node);
38 |
39 | return new self($nextNode);
40 | }
41 |
42 | public function hasPreviousNode(): bool
43 | {
44 | return $this->currentNode->getAttribute('previous') instanceof Node;
45 | }
46 |
47 | public function navigateToPreviousNode(): self
48 | {
49 | $previousNode = $this->currentNode->getAttribute('previous');
50 | assert($previousNode instanceof Node);
51 |
52 | return new self($previousNode);
53 | }
54 |
55 | public function currentNode(): Node
56 | {
57 | return $this->currentNode;
58 | }
59 |
60 | public function hasSubnode(): bool
61 | {
62 | $subnodes = self::collectSubnodes($this->currentNode);
63 |
64 | return $subnodes !== [];
65 | }
66 |
67 | public function navigateToFirstSubnode(): self
68 | {
69 | $subnodes = self::collectSubnodes($this->currentNode);
70 |
71 | if ($subnodes === []) {
72 | throw new \RuntimeException('No subnodes found');
73 | }
74 |
75 | return new self($subnodes[0]);
76 | }
77 |
78 | public function hasParentNode(): bool
79 | {
80 | return $this->currentNode->getAttribute('parent') instanceof Node;
81 | }
82 |
83 | public function navigateToParentNode(): self
84 | {
85 | $parentNode = $this->currentNode->getAttribute('parent');
86 | assert($parentNode instanceof Node);
87 |
88 | return new self($parentNode);
89 | }
90 |
91 | /**
92 | * @return array
93 | */
94 | public function breadcrumbs(): array
95 | {
96 | $breadcrumbs = [$this->currentNode::class];
97 |
98 | if ($this->hasParentNode()) {
99 | $navigator = $this->navigateToParentNode();
100 | array_unshift($breadcrumbs, $navigator->currentNode::class);
101 | }
102 |
103 | return $breadcrumbs;
104 | }
105 |
106 | /**
107 | * @return array
108 | */
109 | private static function collectSubnodes(Node $node): array
110 | {
111 | $subnodes = [];
112 | foreach ($node->getSubNodeNames() as $key) {
113 | $subnode = $node->{$key};
114 | if ($subnode instanceof Node) {
115 | $subnodes[] = $subnode;
116 | } elseif (is_array($subnode)) {
117 | foreach ($subnode as $actualSubnode) {
118 | if ($actualSubnode instanceof Node) {
119 | $subnodes[] = $actualSubnode;
120 | }
121 | }
122 | }
123 | }
124 |
125 | return $subnodes;
126 | }
127 | }
128 |
--------------------------------------------------------------------------------
/src/PhpAstInspector/Console/NavigateToNode.php:
--------------------------------------------------------------------------------
1 | getStream();
40 | }
41 |
42 | if ($inputStream === null) {
43 | $inputStream = STDIN;
44 | }
45 |
46 | $this->inputStream = $inputStream;
47 | }
48 |
49 | public function basedOnUserInput(
50 | NodeNavigator $navigator,
51 | ConsoleSectionOutput $outputSection
52 | ): NodeNavigator {
53 | $choices = [];
54 |
55 | if ($navigator->hasNextNode()) {
56 | $choices[self::NEXT_NODE_KEY] = '' . self::NEXT_NODE_KEY . ' = next node';
57 | }
58 | if ($navigator->hasPreviousNode()) {
59 | $choices[self::PREVIOUS_NODE_KEY] = '' . self::PREVIOUS_NODE_KEY . ' = previous node';
60 | }
61 | if ($navigator->hasParentNode()) {
62 | $choices[self::PARENT_NODE_KEY] = '' . self::PARENT_NODE_KEY . ' = parent node';
63 | }
64 | if ($navigator->hasSubnode()) {
65 | $choices[self::SUBNODES_KEY] = '' . self::SUBNODES_KEY . ' = inspect subnodes';
66 | }
67 |
68 | $choices[] = 'Ctrl + C = quit';
69 |
70 | $outputSection->overwrite('Next? (' . implode(', ', $choices) . ')');
71 |
72 | $nextAction = null;
73 | while (! isset($choices[$nextAction])) {
74 | $nextAction = $this->readCharacter($this->inputStream);
75 | }
76 |
77 | $outputSection->clear();
78 |
79 | return match ($nextAction) {
80 | self::NEXT_NODE_KEY => $navigator->navigateToNextNode(),
81 | self::PREVIOUS_NODE_KEY => $navigator->navigateToPreviousNode(),
82 | self::SUBNODES_KEY => $navigator->navigateToFirstSubnode(),
83 | self::PARENT_NODE_KEY => $navigator->navigateToParentNode(),
84 | default => throw new LogicException('Action not supported: ' . $nextAction),
85 | };
86 | }
87 |
88 | /**
89 | * @param resource $inputStream
90 | */
91 | private function readCharacter($inputStream): string
92 | {
93 | if (Terminal::hasSttyAvailable()) {
94 | /*
95 | * We have to change the `stty` configuration here, so we can read a single character and not wait for
96 | * Enter. Normally we'd have to reset it to its original configuration, but Symfony already does this on
97 | * SIGINT and SIGTERM (see Symfony\Component\Console\Application)
98 | */
99 | exec('stty -icanon 2>&1');
100 | }
101 |
102 | $c = fread($inputStream, 1);
103 |
104 | if ($c === false) {
105 | throw new RuntimeException('Could not read from input stream');
106 | }
107 |
108 | return $c;
109 | }
110 | }
111 |
--------------------------------------------------------------------------------
/src/PhpAstInspector/Console/InspectCommand.php:
--------------------------------------------------------------------------------
1 | codeFormatter = new CodeFormatter();
35 | $this->parser = new Parser();
36 | $this->renderNodeInfo = new RenderNodeInfo(new GetNodeInfo());
37 | }
38 |
39 | protected function configure(): void
40 | {
41 | $this
42 | ->setName(self::COMMAND_NAME)
43 | ->addArgument('file', InputArgument::REQUIRED, 'The PHP script that should be parsed');
44 | }
45 |
46 | protected function execute(InputInterface $input, OutputInterface $output): int
47 | {
48 | assert($output instanceof ConsoleOutputInterface);
49 |
50 | $navigateToNode = new NavigateToNode($input);
51 |
52 | $this->registerStyles($output);
53 |
54 | $codeSection = $output->section();
55 | $infoSection = $output->section();
56 | $questionSection = $output->section();
57 |
58 | $fileArgument = $input->getArgument('file');
59 | assert(is_string($fileArgument));
60 | $code = file_get_contents($fileArgument);
61 | if (! is_string($code)) {
62 | throw new RuntimeException('Could not read file: ' . $fileArgument);
63 | }
64 |
65 | $nodes = $this->parser->parse($code);
66 | $navigator = NodeNavigator::selectFirstFrom($nodes);
67 |
68 | while (true) {
69 | $this->printCodeWithHighlightedNode($code, $navigator->currentNode(), $codeSection, $infoSection);
70 |
71 | $navigator = $navigateToNode->basedOnUserInput($navigator, $questionSection);
72 | }
73 | }
74 |
75 | private function printCodeWithHighlightedNode(
76 | string $code,
77 | Node $node,
78 | ConsoleSectionOutput $codeSection,
79 | ConsoleSectionOutput $infoSection
80 | ): void {
81 | $codeSection->overwrite(
82 | $this->codeFormatter->format($code, Highlight::createForPhpParserNode($node)) . "\n"
83 | );
84 |
85 | $infoSection->overwrite($this->renderNodeInfo->forNode($node));
86 | }
87 |
88 | private function registerStyles(OutputInterface $output): void
89 | {
90 | $output->getFormatter()
91 | ->setStyle(CodeFormatter::HIGHLIGHT_TAG, new OutputFormatterStyle('yellow', '', ['bold']));
92 | $output->getFormatter()
93 | ->setStyle(CodeFormatter::LINE_NUMBER_TAG, new OutputFormatterStyle('gray', '', []));
94 | $output->getFormatter()
95 | ->setStyle(NavigateToNode::CHOICE_TAG, new OutputFormatterStyle('', '', ['bold']));
96 | $output->getFormatter()
97 | ->setStyle(RenderNodeInfo::SUBNODE_TAG, new OutputFormatterStyle('yellow', '', []));
98 | $output->getFormatter()
99 | ->setStyle(RenderNodeInfo::CURRENT_NODE_TAG, new OutputFormatterStyle('green', '', []));
100 | }
101 | }
102 |
--------------------------------------------------------------------------------