Skip to content

Commit 385e7bc

Browse files
committed
Verify PHP classes
1 parent bcbabbe commit 385e7bc

File tree

7 files changed

+197
-24
lines changed

7 files changed

+197
-24
lines changed

config/services.yaml

+6-2
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@ services:
1010

1111
_instanceof:
1212
SymfonyTools\CodeBlockChecker\Service\CodeValidator\Validator:
13-
tags:
14-
- 'app.code_validator'
13+
tags: ['app.code_validator']
14+
SymfonyTools\CodeBlockChecker\Service\CodeRunner\Runner:
15+
tags: ['app.code_runner']
1516

1617
SymfonyTools\CodeBlockChecker\:
1718
resource: '../src/'
@@ -21,3 +22,6 @@ services:
2122

2223
SymfonyTools\CodeBlockChecker\Service\CodeValidator:
2324
arguments: [!tagged_iterator app.code_validator]
25+
26+
Symfony\CodeBlockChecker\Service\CodeRunner:
27+
arguments: [!tagged_iterator app.code_runner]

src/Command/CheckDocsCommand.php

+4-4
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,14 @@ class CheckDocsCommand extends Command
3434
private CodeNodeCollector $collector;
3535
private CodeValidator $validator;
3636
private Baseline $baseline;
37-
private CodeNodeRunner $codeNodeRunner;
37+
private CodeRunner $codeRunner;
3838

39-
public function __construct(CodeValidator $validator, Baseline $baseline, CodeNodeRunner $codeNodeRunner)
39+
public function __construct(CodeValidator $validator, Baseline $baseline, CodeRunner $codeRunner)
4040
{
4141
parent::__construct(self::$defaultName);
4242
$this->validator = $validator;
4343
$this->baseline = $baseline;
44-
$this->codeNodeRunner = $codeNodeRunner;
44+
$this->codeRunner = $codeRunner;
4545
}
4646

4747
protected function configure()
@@ -88,7 +88,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
8888
// Verify code blocks
8989
$issues = $this->validator->validateNodes($this->collector->getNodes());
9090
if ($applicationDir = $input->getOption('symfony-application')) {
91-
$issues->append($this->codeNodeRunner->runNodes($this->collector->getNodes(), $applicationDir));
91+
$issues->append($this->codeRunner->runNodes($this->collector->getNodes(), $applicationDir));
9292
}
9393

9494
if ($baselineFile = $input->getOption('generate-baseline')) {

src/Service/CodeRunner.php

+43
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Symfony\CodeBlockChecker\Service;
6+
7+
use Doctrine\RST\Nodes\CodeNode;
8+
use Symfony\CodeBlockChecker\Issue\IssueCollection;
9+
use Symfony\CodeBlockChecker\Service\CodeRunner\Runner;
10+
11+
/**
12+
* Run a Code Node inside a real application.
13+
*
14+
* @author Tobias Nyholm <[email protected]>
15+
*/
16+
class CodeRunner
17+
{
18+
/**
19+
* @var iterable<Runner>
20+
*/
21+
private $runners;
22+
23+
/**
24+
* @param iterable<Runner> $runners
25+
*/
26+
public function __construct(iterable $runners)
27+
{
28+
$this->runners = $runners;
29+
}
30+
31+
/**
32+
* @param list<CodeNode> $nodes
33+
*/
34+
public function runNodes(array $nodes, string $applicationDirectory): IssueCollection
35+
{
36+
$issues = new IssueCollection();
37+
foreach ($this->runners as $runner) {
38+
$runner->run($nodes, $issues, $applicationDirectory);
39+
}
40+
41+
return $issues;
42+
}
43+
}

src/Service/CodeRunner/ClassExist.php

+113
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
<?php
2+
3+
namespace Symfony\CodeBlockChecker\Service\CodeRunner;
4+
5+
use Doctrine\RST\Nodes\CodeNode;
6+
use Symfony\CodeBlockChecker\Issue\Issue;
7+
use Symfony\CodeBlockChecker\Issue\IssueCollection;
8+
use Symfony\Component\Process\Process;
9+
10+
/**
11+
* Verify that any reference to a PHP class is actually a real class.
12+
*
13+
* @author Tobias Nyholm <[email protected]>
14+
*/
15+
class ClassExist implements Runner
16+
{
17+
/**
18+
* @param list<CodeNode> $nodes
19+
*/
20+
public function run(array $nodes, IssueCollection $issues, string $applicationDirectory): void
21+
{
22+
$classes = [];
23+
foreach ($nodes as $node) {
24+
$classes = array_merge($classes, $this->getClasses($node));
25+
}
26+
27+
$this->testClasses($classes, $issues, $applicationDirectory);
28+
}
29+
30+
private function getClasses(CodeNode $node): array
31+
{
32+
$language = $node->getLanguage() ?? 'php';
33+
if (!in_array($language, ['php', 'php-symfony', 'php-standalone', 'php-annotations'])) {
34+
return [];
35+
}
36+
37+
$classes = [];
38+
foreach (explode("\n", $node->getValue()) as $i => $line) {
39+
$matches = [];
40+
if (0 !== strpos($line, 'use ') || !preg_match('|^use (.*\\\.*); *?$|m', $line, $matches)) {
41+
continue;
42+
}
43+
44+
$class = $matches[1];
45+
if (false !== $pos = strpos($class, ' as ')) {
46+
$class = substr($class, 0, $pos);
47+
}
48+
49+
if (false !== $pos = strpos($class, 'function ')) {
50+
continue;
51+
}
52+
53+
$explode = explode('\\', $class);
54+
if (
55+
'App' === $explode[0] || 'Acme' === $explode[0]
56+
|| (3 === count($explode) && 'Symfony' === $explode[0] && ('Component' === $explode[1] || 'Config' === $explode[1]))
57+
) {
58+
continue;
59+
}
60+
61+
$classes[] = ['class' => $class, 'line' => $i + 1, 'node' => $node];
62+
}
63+
64+
return $classes;
65+
}
66+
67+
/**
68+
* Make sure PHP classes exists in the application directory.
69+
*
70+
* @param array{int, array{ class: string, line: int, node: CodeNode } } $classes
71+
*/
72+
private function testClasses(array $classes, IssueCollection $issues, string $applicationDirectory): void
73+
{
74+
$fileBody = '';
75+
foreach ($classes as $i => $data) {
76+
$fileBody .= sprintf('%s => isLoaded("%s"),', $i, $data['class'])."\n";
77+
}
78+
79+
file_put_contents($applicationDirectory.'/class_exist.php', strtr('<?php
80+
require __DIR__.\'/vendor/autoload.php\';
81+
82+
function isLoaded($class) {
83+
return class_exists($class) || interface_exists($class) || trait_exists($class);
84+
}
85+
86+
echo json_encode([ARRAY_CONTENT]);
87+
88+
', ['ARRAY_CONTENT' => $fileBody]));
89+
90+
$process = new Process(['php', 'class_exist.php'], $applicationDirectory);
91+
$process->run();
92+
93+
if (!$process->isSuccessful()) {
94+
// TODO handle this
95+
return;
96+
}
97+
98+
$output = $process->getOutput();
99+
try {
100+
$results = json_decode($output, true, 512, JSON_THROW_ON_ERROR);
101+
} catch (\JsonException $e) {
102+
// TODO handle this
103+
return;
104+
}
105+
106+
foreach ($classes as $i => $data) {
107+
if (!$results[$i]) {
108+
$text = sprintf('Class, interface or trait with name "%s" does not exist', $data['class']);
109+
$issues->addIssue(new Issue($data['node'], $text, 'Missing class', $data['node']->getEnvironment()->getCurrentFileName(), $data['line']));
110+
}
111+
}
112+
}
113+
}

src/Service/CodeNodeRunner.php renamed to src/Service/CodeRunner/ConfigurationRunner.php

+13-18
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<?php
22

3-
namespace SymfonyTools\CodeBlockChecker\Service;
3+
namespace SymfonyTools\CodeBlockChecker\Service\CodeRunner;
44

55
use Doctrine\RST\Nodes\CodeNode;
66
use Symfony\Component\Filesystem\Filesystem;
@@ -13,24 +13,23 @@
1313
*
1414
* @author Tobias Nyholm <[email protected]>
1515
*/
16-
class CodeNodeRunner
16+
class ConfigurationRunner
1717
{
1818
/**
1919
* @param list<CodeNode> $nodes
2020
*/
21-
public function runNodes(array $nodes, string $applicationDirectory): IssueCollection
21+
public function run(array $nodes, IssueCollection $issues, string $applicationDirectory): void
2222
{
23-
$issues = new IssueCollection();
2423
foreach ($nodes as $node) {
2524
$this->processNode($node, $issues, $applicationDirectory);
2625
}
27-
28-
return $issues;
2926
}
3027

3128
private function processNode(CodeNode $node, IssueCollection $issues, string $applicationDirectory): void
3229
{
33-
$file = $this->getFile($node);
30+
$explodedNode = explode("\n", $node->getValue());
31+
$file = $this->getFile($node, $explodedNode);
32+
3433
if ('config/packages/' !== substr($file, 0, 16)) {
3534
return;
3635
}
@@ -45,13 +44,13 @@ private function processNode(CodeNode $node, IssueCollection $issues, string $ap
4544
}
4645

4746
// Write config
48-
file_put_contents($fullPath, $this->getNodeContents($node));
47+
file_put_contents($fullPath, $this->getNodeContents($node, $explodedNode));
4948

5049
// Clear cache
5150
$filesystem->remove($applicationDirectory.'/var/cache');
5251

5352
// Warmup and log errors
54-
$this->warmupCache($node, $issues, $applicationDirectory);
53+
$this->warmupCache($node, $issues, $applicationDirectory, count($explodedNode) - 1);
5554
} finally {
5655
// Remove added file and restore original
5756
$filesystem->remove($fullPath);
@@ -62,20 +61,19 @@ private function processNode(CodeNode $node, IssueCollection $issues, string $ap
6261
}
6362
}
6463

65-
private function warmupCache(CodeNode $node, IssueCollection $issues, string $applicationDirectory): void
64+
private function warmupCache(CodeNode $node, IssueCollection $issues, string $applicationDirectory, int $numberOfLines): void
6665
{
6766
$process = new Process(['php', 'bin/console', 'cache:warmup', '--env', 'dev'], $applicationDirectory);
6867
$process->run();
6968
if ($process->isSuccessful()) {
7069
return;
7170
}
7271

73-
$issues->addIssue(new Issue($node, trim($process->getErrorOutput()), 'Cache Warmup', $node->getEnvironment()->getCurrentFileName(), count(explode("\n", $node->getValue())) - 1));
72+
$issues->addIssue(new Issue($node, trim($process->getErrorOutput()), 'Cache Warmup', $node->getEnvironment()->getCurrentFileName(), $numberOfLines));
7473
}
7574

76-
private function getFile(CodeNode $node): string
75+
private function getFile(CodeNode $node, array $contents): string
7776
{
78-
$contents = explode("\n", $node->getValue());
7977
$regex = match ($node->getLanguage()) {
8078
'php' => '|^// ?([a-z1-9A-Z_\-/]+\.php)$|',
8179
'yaml' => '|^# ?([a-z1-9A-Z_\-/]+\.yaml)$|',
@@ -90,20 +88,17 @@ private function getFile(CodeNode $node): string
9088
return $matches[1];
9189
}
9290

93-
private function getNodeContents(CodeNode $node): string
91+
private function getNodeContents(CodeNode $node, array $contents): string
9492
{
9593
$language = $node->getLanguage();
9694
if ('php' === $language) {
9795
return '<?php'."\n".$node->getValue();
9896
}
9997

10098
if ('xml' === $language) {
101-
$contents = explode("\n", $node->getValue());
10299
unset($contents[0]);
103-
104-
return implode("\n", $contents);
105100
}
106101

107-
return $node->getValue();
102+
return implode("\n", $contents);
108103
}
109104
}

src/Service/CodeRunner/Runner.php

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?php
2+
3+
namespace Symfony\CodeBlockChecker\Service\CodeRunner;
4+
5+
use Doctrine\RST\Nodes\CodeNode;
6+
use Symfony\CodeBlockChecker\Issue\IssueCollection;
7+
8+
interface Runner
9+
{
10+
/**
11+
* @param list<CodeNode> $nodes
12+
*/
13+
public function run(array $nodes, IssueCollection $issues, string $applicationDirectory): void;
14+
}

src/Service/CodeValidator.php

+4
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
namespace SymfonyTools\CodeBlockChecker\Service;
66

7+
use Doctrine\RST\Nodes\CodeNode;
78
use SymfonyTools\CodeBlockChecker\Issue\IssueCollection;
89
use SymfonyTools\CodeBlockChecker\Service\CodeValidator\Validator;
910

@@ -27,6 +28,9 @@ public function __construct(iterable $validators)
2728
$this->validators = $validators;
2829
}
2930

31+
/**
32+
* @param list<CodeNode> $nodes
33+
*/
3034
public function validateNodes(array $nodes): IssueCollection
3135
{
3236
$issues = new IssueCollection();

0 commit comments

Comments
 (0)