├── .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 | ![Example output](docs/screenshot.png) 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', 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', 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 . '' . $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 | --------------------------------------------------------------------------------