diff --git a/bin/lint.php b/bin/lint.php new file mode 100644 index 00000000..0c938339 --- /dev/null +++ b/bin/lint.php @@ -0,0 +1,80 @@ +#!/usr/bin/env php + + * phpc lint [-r 'code'] [--json] + */ + +use PHPCompiler\Lint\Linter; + +require __DIR__.'/../src/tokenizer-compat.php'; +require __DIR__.'/../src/yay-php8-compat.php'; +require __DIR__.'/../vendor/autoload.php'; + +$json = false; +$code = null; +$filename = null; +$args = array_slice($argv, 1); + +while ([] !== $args) { + $arg = array_shift($args); + if ('--json' === $arg) { + $json = true; + continue; + } + if ('-r' === $arg) { + if ([] === $args) { + fwrite(STDERR, "Option -r requires code argument\n"); + exit(1); + } + $snippet = array_shift($args); + $code = str_starts_with(ltrim($snippet), '\n"); + exit(1); +} + +$linter = new Linter(); + +try { + $issues = $linter->lintSource($code, $filename); +} catch (\InvalidArgumentException $e) { + fwrite(STDERR, $e->getMessage()."\n"); + exit(1); +} + +if ($json) { + $payload = array_map(static fn ($i) => $i->toArray(), $issues); + fwrite(STDOUT, json_encode(['issues' => $payload], JSON_PRETTY_PRINT)."\n"); +} else { + foreach ($issues as $issue) { + fwrite(STDOUT, $issue->formatHuman()."\n"); + } +} + +exit([] === $issues ? 0 : 1); diff --git a/bin/phpc.php b/bin/phpc.php index 22ae966f..d2d8313f 100755 --- a/bin/phpc.php +++ b/bin/phpc.php @@ -11,6 +11,7 @@ * phpc serve --aot [host:port] [docroot] [--binary path] * phpc run script.php [args...] * phpc build [-o outfile] entry.php + * phpc lint [-r 'code'] [--json] entry.php * phpc test [-- phpunit/ci-local args...] */ @@ -28,6 +29,7 @@ [--binary path] Explicit binary or phpc.json "binary" phpc run [args...] Run a script in the VM phpc build [-o out] AOT compile to a native binary + phpc lint [-r 'code'] [--json] Report unsupported syntax (line-accurate) phpc test [args...] Run ./script/ci-local.sh HELP); @@ -65,6 +67,9 @@ } exit(runProcess(array_merge($php, [$repoRoot.'/bin/compile.php'], $args), $repoRoot)); + case 'lint': + exit(runProcess(array_merge($php, [$repoRoot.'/bin/lint.php'], $args), $repoRoot)); + case 'test': $testScript = $repoRoot.'/script/ci-local.sh'; if (!is_executable($testScript)) { diff --git a/docs/bootstrap-inventory.md b/docs/bootstrap-inventory.md index f8c4d2f3..e8ae112b 100644 --- a/docs/bootstrap-inventory.md +++ b/docs/bootstrap-inventory.md @@ -8,9 +8,9 @@ Regenerate: `php script/bootstrap-inventory.php` | Metric | Count | |--------|------:| -| PHP files on vm.php path | 188 | +| PHP files on vm.php path | 192 | | Source constructs flagged (blockers) | 10 | -| Source constructs flagged (warnings) | 501 | +| Source constructs flagged (warnings) | 514 | ## Compiler CFG gaps (`lib/Compiler.php`) @@ -195,6 +195,10 @@ These `LogicException` messages indicate CFG ops or expressions not yet lowered: | `lib/JIT/SuperglobalInit.php` | 0 | 3 | | `lib/JIT/ValueEchoHelper.php` | 0 | 1 | | `lib/JIT/Variable.php` | 0 | 18 | +| `lib/Lint/Issue.php` | 0 | 2 | +| `lib/Lint/LintCompiler.php` | 0 | 6 | +| `lib/Lint/Linter.php` | 0 | 4 | +| `lib/Lint/UnsupportedRegistry.php` | 0 | 1 | | `lib/Module.php` | 0 | 1 | | `lib/ModuleAbstract.php` | 0 | 1 | | `lib/OpCode.php` | 0 | 1 | @@ -1300,6 +1304,35 @@ These `LogicException` messages indicate CFG ops or expressions not yet lowered: - new Variable (line 473) - 15 class method(s) — PHPCfg Op\Stmt\ClassMethod not lowered in Compiler +### `lib/Lint/Issue.php` + +**Warnings** (review for bootstrap subset): +- new self (line 39) +- 4 class method(s) — PHPCfg Op\Stmt\ClassMethod not lowered in Compiler + +### `lib/Lint/LintCompiler.php` + +**Warnings** (review for bootstrap subset): +- new OpCode (line 55) +- new Block (line 63) +- new OpCode (line 74) +- new OpCode (line 106) +- 9 class method(s) — PHPCfg Op\Stmt\ClassMethod not lowered in Compiler +- 2 closure(s) + +### `lib/Lint/Linter.php` + +**Warnings** (review for bootstrap subset): +- new Runtime (line 24) +- new State (line 83) +- new LintCompiler (line 98) +- 9 class method(s) — PHPCfg Op\Stmt\ClassMethod not lowered in Compiler + +### `lib/Lint/UnsupportedRegistry.php` + +**Warnings** (review for bootstrap subset): +- 2 class method(s) — PHPCfg Op\Stmt\ClassMethod not lowered in Compiler + ### `lib/Module.php` **Warnings** (review for bootstrap subset): diff --git a/docs/unsupported-syntax.md b/docs/unsupported-syntax.md new file mode 100644 index 00000000..9f0ae2e7 --- /dev/null +++ b/docs/unsupported-syntax.md @@ -0,0 +1,33 @@ +# Unsupported syntax (lint) + +`phpc lint` and `bin/lint.php` compile PHP through the same CFG pipeline as `phpc run`, but record unsupported nodes instead of throwing opaque `LogicException` messages. + +## Usage + +```bash +./phpc lint path/to/entry.php +./phpc lint -r 'for ($i = 0; $i < 3; $i++) echo $i;' +./phpc lint --json path/to/entry.php +``` + +Exit code `0` when the entry (and best-effort `include`/`require` targets with string literals) compiles; `1` when any unsupported construct is found. + +## Known gaps (tracking issues) + +| CFG kind | Tracking | +|----------|----------| +| `Stmt_While`, `Stmt_Do`, `Stmt_For` | [#192](https://github.com/PurHur/php-compiler/issues/192) | +| `Stmt_Foreach` | [#53](https://github.com/PurHur/php-compiler/issues/53) | +| `Expr_BinaryOp_Coalesce` (`??`) | [#99](https://github.com/PurHur/php-compiler/issues/99) | +| `Expr_Throw`, `Stmt_Try`, `Stmt_Catch`, `Stmt_Finally` | [#195](https://github.com/PurHur/php-compiler/issues/195) | +| `Expr_Yield`, `Expr_YieldFrom`, closures | [#114](https://github.com/PurHur/php-compiler/issues/114) | +| `Expr_New` (non-trivial) | [#136](https://github.com/PurHur/php-compiler/issues/136) | +| Named arguments, traits, enums | [#168](https://github.com/PurHur/php-compiler/issues/168), [#169](https://github.com/PurHur/php-compiler/issues/169) | + +The mapping lives in `lib/Lint/UnsupportedRegistry.php`. Compiler gaps are also listed in `docs/bootstrap-inventory.md` (self-host bootstrap). + +## Related + +- [#236](https://github.com/PurHur/php-compiler/issues/236) — structured lint CLI +- [#48](https://github.com/PurHur/php-compiler/issues/48) — README capability list +- [#176](https://github.com/PurHur/php-compiler/issues/176) — capability matrix diff --git a/lib/Lint/Issue.php b/lib/Lint/Issue.php new file mode 100644 index 00000000..b329410b --- /dev/null +++ b/lib/Lint/Issue.php @@ -0,0 +1,87 @@ +file = $file; + $this->line = $line; + $this->kind = $kind; + $this->message = $message; + $this->trackingIssue = $trackingIssue; + } + + public static function fromOp(Op $op, string $compilerMessage): self + { + $kind = self::kindFromMessage($compilerMessage); + $tracking = UnsupportedRegistry::trackingIssueForKind($kind); + + return new self( + $op->getFile(), + $op->getLine(), + $kind, + $compilerMessage, + $tracking + ); + } + + public static function kindFromMessage(string $message): string + { + if (preg_match('/^Unsupported expression: (.+)$/', $message, $m)) { + return trim($m[1]); + } + if (preg_match('/^Unknown (?:Stmt|Op|BinaryOp|CastOp|UnaryOp|Terminal|Operand|Literal) Type: (.+)$/', $message, $m)) { + return trim($m[1]); + } + if (preg_match('/^Unknown Literal Operand Type:/', $message)) { + return 'Literal'; + } + if (preg_match('/^Unsupported (?:class type|class body element): /', $message)) { + return trim($message); + } + + return $message; + } + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'file' => $this->file, + 'line' => $this->line, + 'kind' => $this->kind, + 'message' => $this->message, + 'issue' => $this->trackingIssue, + ]; + } + + public function formatHuman(): string + { + $where = $this->line > 0 ? "line {$this->line}" : 'line ?'; + $suffix = null !== $this->trackingIssue ? " (see #{$this->trackingIssue})" : ''; + + return "{$this->file}: {$where}: unsupported {$this->kind}{$suffix}"; + } +} diff --git a/lib/Lint/LintCompiler.php b/lib/Lint/LintCompiler.php new file mode 100644 index 00000000..60b2020d --- /dev/null +++ b/lib/Lint/LintCompiler.php @@ -0,0 +1,144 @@ + */ + public array $issues = []; + + /** @var array */ + private array $issueKeys = []; + + protected function compileOp(Op $op, Block $block): void + { + $this->guarded($op, fn () => parent::compileOp($op, $block)); + } + + protected function compileStmt(Op\Stmt $stmt, Block $block): void + { + $this->guarded($stmt, fn () => parent::compileStmt($stmt, $block)); + } + + /** + * @return OpCode[] + */ + protected function compileExpr(Op\Expr $expr, Block $block): array + { + try { + return parent::compileExpr($expr, $block); + } catch (\LogicException $e) { + if ($this->recordIfUnsupported($expr, $e)) { + return []; + } + throw $e; + } + } + + protected function compileClassLike(Op\Stmt\ClassLike $class, Block $block): OpCode + { + try { + return parent::compileClassLike($class, $block); + } catch (\LogicException $e) { + if ($this->recordIfUnsupported($class, $e)) { + return new OpCode(OpCode::TYPE_RETURN_VOID); + } + throw $e; + } + } + + protected function compileClassBody(CfgBlock $block, int $type): Block + { + $result = new Block($block); + foreach ($block->children as $child) { + switch (get_class($child)) { + case Op\Stmt\Property::class: + try { + if ($type !== OpCode::TYPE_DECLARE_CLASS) { + throw new \LogicException('Properties are only supported on classes for now'); + } + if (!is_null($child->defaultBlock)) { + $this->compileOps($child->defaultBlock, $result); + } + $result->addOpCode(new OpCode( + OpCode::TYPE_DECLARE_PROPERTY, + $this->compileOperand($child->name, $result, true), + is_null($child->defaultVar) ? null : $this->compileOperand($child->defaultVar, $result, true), + $this->compileTypeConstrainedVariable($result, $child->type) + )); + } catch (\LogicException $e) { + if (!$this->recordIfUnsupported($child, $e)) { + throw $e; + } + } + break; + default: + try { + throw new \LogicException('Unsupported class body element: '.get_class($child)); + } catch (\LogicException $e) { + if (!$this->recordIfUnsupported($child, $e)) { + throw $e; + } + } + } + } + + return $result; + } + + protected function compileTerminal(Op\Terminal $terminal, Block $block): OpCode + { + try { + return parent::compileTerminal($terminal, $block); + } catch (\LogicException $e) { + if ($this->recordIfUnsupported($terminal, $e)) { + return new OpCode(OpCode::TYPE_RETURN_VOID); + } + throw $e; + } + } + + private function guarded(Op $op, callable $compile): void + { + try { + $compile(); + } catch (\LogicException $e) { + if (!$this->recordIfUnsupported($op, $e)) { + throw $e; + } + } + } + + private function recordIfUnsupported(Op $op, \LogicException $e): bool + { + if (!$this->isUnsupportedMessage($e->getMessage())) { + return false; + } + $issue = Issue::fromOp($op, $e->getMessage()); + $key = $issue->file.'|'.$issue->line.'|'.$issue->kind; + if (isset($this->issueKeys[$key])) { + return true; + } + $this->issueKeys[$key] = true; + $this->issues[] = $issue; + + return true; + } + + private function isUnsupportedMessage(string $message): bool + { + return str_contains($message, 'Unknown ') + || str_contains($message, 'Unsupported '); + } +} diff --git a/lib/Lint/Linter.php b/lib/Lint/Linter.php new file mode 100644 index 00000000..5557d4e1 --- /dev/null +++ b/lib/Lint/Linter.php @@ -0,0 +1,199 @@ +runtime = $runtime ?? new Runtime(); + } + + /** + * @return list + */ + public function lintFile(string $filename): array + { + if (!is_file($filename)) { + throw new \InvalidArgumentException("Could not open file {$filename}"); + } + + return $this->lintSource((string) file_get_contents($filename), $filename); + } + + /** + * @return list + */ + public function lintSource(string $code, string $filename): array + { + $script = $this->parseForLint($code, $filename); + $issues = $this->lintScript($script); + $queue = [$filename => $script]; + $seenFiles = [$filename => true]; + + while ([] !== $queue) { + $currentFile = array_key_first($queue); + $currentScript = $queue[$currentFile]; + unset($queue[$currentFile]); + + foreach ($this->discoverIncludePaths($currentScript, $currentFile) as $includePath) { + $resolved = $this->resolveIncludePath($includePath, $currentFile); + if (null === $resolved || isset($seenFiles[$resolved])) { + continue; + } + $seenFiles[$resolved] = true; + try { + $included = $this->parseForLint( + (string) file_get_contents($resolved), + $resolved + ); + } catch (\Throwable $e) { + continue; + } + foreach ($this->lintScript($included) as $issue) { + $issues[] = $issue; + } + $queue[$resolved] = $included; + } + } + + return $this->dedupeIssues($issues); + } + + private function parseForLint(string $code, string $filename): Script + { + $script = $this->runtime->parser->parse($code, $filename); + $this->runtime->preprocessor->traverse($script); + try { + $this->runtime->typeReconstructor->resolve(new State($script)); + } catch (\LogicException $e) { + // Type reconstruction may fail before compile; lint still runs the compiler pass. + } + $this->runtime->postprocessor->traverse($script); + $this->runtime->detector->detect($script); + + return $script; + } + + /** + * @return list + */ + private function lintScript(Script $script): array + { + $compiler = new LintCompiler(); + $this->runtime->compiler = $compiler; + try { + $this->runtime->compile($script); + } catch (\Throwable $e) { + // Parse/type errors are outside lint scope; let callers handle separately. + } finally { + $this->runtime->compiler = new \PHPCompiler\Compiler(); + } + + return $compiler->issues; + } + + /** + * @param list $issues + * @return list + */ + private function dedupeIssues(array $issues): array + { + $out = []; + $keys = []; + foreach ($issues as $issue) { + $key = $issue->file.'|'.$issue->line.'|'.$issue->kind; + if (isset($keys[$key])) { + continue; + } + $keys[$key] = true; + $out[] = $issue; + } + + return $out; + } + + /** + * @return list + */ + private function discoverIncludePaths(Script $script, string $entryFile): array + { + $paths = []; + $seen = new \SplObjectStorage(); + $this->walkCfgBlock($script->main->cfg, $paths, $seen); + foreach ($script->functions as $func) { + if ($func instanceof CfgFunc) { + $this->walkCfgBlock($func->cfg, $paths, $seen); + } + } + + return array_values(array_unique($paths)); + } + + /** + * @param list $paths + */ + private function walkCfgBlock(CfgBlock $block, array &$paths, \SplObjectStorage $seen): void + { + if ($seen->contains($block)) { + return; + } + $seen[$block] = true; + + foreach ($block->children as $child) { + if ($child instanceof Op\Expr\Include_) { + $literal = $this->literalStringOperand($child->expr); + if (null !== $literal) { + $paths[] = $literal; + } + } + foreach ($child->getSubBlocks() as $name) { + $sub = $child->{$name} ?? null; + if ($sub instanceof CfgBlock) { + $this->walkCfgBlock($sub, $paths, $seen); + } + } + } + } + + private function literalStringOperand(Operand $operand): ?string + { + if ($operand instanceof Operand\Literal && is_string($operand->value)) { + return $operand->value; + } + + return null; + } + + private function resolveIncludePath(string $path, string $fromFile): ?string + { + if ('' === $path) { + return null; + } + if ($path[0] === '/' || (strlen($path) > 1 && $path[1] === ':')) { + return is_file($path) ? $path : null; + } + $base = dirname($fromFile); + $candidate = $base.'/'.$path; + if (is_file($candidate)) { + return realpath($candidate) ?: $candidate; + } + + return null; + } +} diff --git a/lib/Lint/UnsupportedRegistry.php b/lib/Lint/UnsupportedRegistry.php new file mode 100644 index 00000000..d0745197 --- /dev/null +++ b/lib/Lint/UnsupportedRegistry.php @@ -0,0 +1,63 @@ + */ + private const KIND_TO_ISSUE = [ + 'Stmt_While' => 192, + 'Stmt_Do' => 192, + 'Stmt_For' => 192, + 'Stmt_Foreach' => 53, + 'Iterator_Reset' => 53, + 'Iterator_Valid' => 53, + 'Iterator_Next' => 53, + 'Iterator_Key' => 53, + 'Iterator_Current' => 53, + 'Iterator_Value' => 53, + 'Expr_BinaryOp_Coalesce' => 99, + 'Expr_Coalesce' => 99, + 'Expr_Throw' => 195, + 'Expr_New' => 136, + 'Stmt_Try' => 195, + 'Stmt_Catch' => 195, + 'Stmt_Finally' => 195, + 'Expr_Yield' => 114, + 'Expr_YieldFrom' => 114, + 'Stmt_Enum' => 169, + 'Expr_ArrowFunction' => 114, + 'Expr_Closure' => 114, + 'Stmt_Trait' => 168, + 'Expr_NamedArgument' => 168, + ]; + + public static function trackingIssueForKind(string $kind): ?int + { + if (isset(self::KIND_TO_ISSUE[$kind])) { + return self::KIND_TO_ISSUE[$kind]; + } + foreach (self::KIND_TO_ISSUE as $prefix => $issue) { + if (str_starts_with($kind, $prefix)) { + return $issue; + } + } + + return null; + } + + /** + * @return array + */ + public static function knownKinds(): array + { + return self::KIND_TO_ISSUE; + } +} diff --git a/test/unit/LintTest.php b/test/unit/LintTest.php new file mode 100644 index 00000000..c7298b8d --- /dev/null +++ b/test/unit/LintTest.php @@ -0,0 +1,130 @@ +runLint(['-r', $code]); + $this->assertSame(1, $exit['code']); + $this->assertStringContainsString('unsupported', $exit['stdout']); + $this->assertStringContainsString('#53', $exit['stdout']); + $this->assertMatchesRegularExpression('/line \d+/', $exit['stdout']); + } + + public function testLintCoalesceReportsIssue99(): void + { + $code = 'runLint(['-r', $code]); + $this->assertSame(1, $exit['code']); + $this->assertStringContainsString('#99', $exit['stdout']); + } + + public function testLintCleanScriptExitsZero(): void + { + $code = 'runLint(['-r', $code]); + $this->assertSame(0, $exit['code']); + $this->assertSame('', trim($exit['stdout'])); + } + + public function testLintJsonOutput(): void + { + $code = 'runLint(['--json', '-r', $code]); + $this->assertSame(1, $exit['code']); + $decoded = json_decode($exit['stdout'], true); + $this->assertIsArray($decoded); + $this->assertNotEmpty($decoded['issues']); + $this->assertArrayHasKey('line', $decoded['issues'][0]); + $this->assertSame(53, $decoded['issues'][0]['issue']); + } + + public function testPhpcLintDelegatesToLintScript(): void + { + $repoRoot = dirname(__DIR__, 2); + $cmd = array_merge(self::phpCommand(), [$repoRoot.'/bin/phpc.php', 'lint', '-r', 'runCommand($cmd, $repoRoot); + $this->assertSame(1, $exit['code']); + $this->assertStringContainsString('#53', $exit['stdout']); + } + + /** + * @param list $lintArgs arguments after bin/lint.php + * + * @return array{code: int, stdout: string, stderr: string} + */ + private function runLint(array $lintArgs): array + { + $repoRoot = dirname(__DIR__, 2); + $cmd = array_merge(self::phpCommand(), [$repoRoot.'/bin/lint.php'], $lintArgs); + + return $this->runCommand($cmd, $repoRoot); + } + + /** + * @param list $cmd + * + * @return array{code: int, stdout: string, stderr: string} + */ + private function runCommand(array $cmd, string $cwd): array + { + $descriptorSpec = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + $proc = proc_open($cmd, $descriptorSpec, $pipes, $cwd); + $this->assertIsResource($proc); + fclose($pipes[0]); + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + $code = proc_close($proc); + + return [ + 'code' => $code, + 'stdout' => $stdout !== false ? $stdout : '', + 'stderr' => $stderr !== false ? $stderr : '', + ]; + } + + /** + * @return list + */ + private static function phpCommand(): array + { + $phpEnv = getenv('PHP_COMPILER_PHP'); + if (false !== $phpEnv && '' !== $phpEnv) { + return preg_split('/\s+/', $phpEnv) ?: [PHP_BINARY]; + } + $cmd = [PHP_BINARY]; + $extDir = getenv('PHP_COMPILER_EXT_DIR') ?: '/usr/lib/php/20220829'; + if (is_dir($extDir)) { + foreach (['tokenizer', 'mbstring', 'dom', 'xml', 'xmlwriter', 'ffi', 'posix', 'phar'] as $ext) { + $so = $extDir.'/'.$ext.'.so'; + if (is_file($so)) { + $cmd[] = '-d'; + $cmd[] = 'extension='.$so; + } + } + } + + return $cmd; + } +} diff --git a/test/unit/PhpcCliTest.php b/test/unit/PhpcCliTest.php index e89b3e8d..8dab4c94 100644 --- a/test/unit/PhpcCliTest.php +++ b/test/unit/PhpcCliTest.php @@ -32,6 +32,7 @@ public function testHelpListsSubcommands(): void $this->assertStringContainsString('phpc run', $out !== false ? $out : ''); $this->assertStringContainsString('phpc build', $out !== false ? $out : ''); $this->assertStringContainsString('phpc test', $out !== false ? $out : ''); + $this->assertStringContainsString('phpc lint', $out !== false ? $out : ''); } /**