Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions bin/lint.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
#!/usr/bin/env php
<?php

declare(strict_types=1);

/**
* Lint PHP sources for constructs the static compiler cannot lower yet.
*
* Usage:
* bin/lint.php [-r 'code'] [--json] <file.php>
* phpc lint [-r 'code'] [--json] <file.php>
*/

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), '<?') ? $snippet : '<?php '.$snippet;
$filename = 'Command line code';
continue;
}
if (str_starts_with($arg, '-')) {
fwrite(STDERR, "Unknown option: {$arg}\n");
exit(1);
}
if (null !== $filename) {
fwrite(STDERR, "Extra argument: {$arg}\n");
exit(1);
}
if (!is_file($arg)) {
fwrite(STDERR, "Could not open file {$arg}\n");
exit(1);
}
$filename = $arg;
$code = (string) file_get_contents($arg);
}

if (null === $filename) {
fwrite(STDERR, "Usage: lint.php [-r 'code'] [--json] <file.php>\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);
5 changes: 5 additions & 0 deletions bin/phpc.php
Original file line number Diff line number Diff line change
Expand Up @@ -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...]
*/

Expand All @@ -28,6 +29,7 @@
[--binary path] Explicit binary or phpc.json "binary"
phpc run <script.php> [args...] Run a script in the VM
phpc build [-o out] <entry.php> AOT compile to a native binary
phpc lint [-r 'code'] [--json] <entry.php> Report unsupported syntax (line-accurate)
phpc test [args...] Run ./script/ci-local.sh

HELP);
Expand Down Expand Up @@ -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)) {
Expand Down
37 changes: 35 additions & 2 deletions docs/bootstrap-inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)

Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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):
Expand Down
33 changes: 33 additions & 0 deletions docs/unsupported-syntax.md
Original file line number Diff line number Diff line change
@@ -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
87 changes: 87 additions & 0 deletions lib/Lint/Issue.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<?php

declare(strict_types=1);

namespace PHPCompiler\Lint;

use PHPCfg\Op;

/**
* One unsupported CFG construct discovered during lint.
*/
final class Issue
{
public string $file;
public int $line;
public string $kind;
public string $message;
public ?int $trackingIssue;

public function __construct(
string $file,
int $line,
string $kind,
string $message,
?int $trackingIssue = null
) {
$this->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<string, mixed>
*/
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}";
}
}
Loading
Loading