diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..b1c8966 --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,19 @@ +name: Check + +on: + push: + branches: [master] + pull_request: + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + coverage: none + - run: composer install --no-interaction --no-progress + - run: composer check + - run: composer check-playground diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..f6b7cea --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,106 @@ +# チュートリアルの書き方 + +このリポジトリでは、本文 (`*/README.md`) と演習用PHPファイル、そしてPHPStanの実際の出力が食い違わないように、`composer check` で機械的に検査しています。 + +```bash +composer install +composer check +``` + +`composer check` は次を実行します。 + + * `composer check-tools` — `tools/` 以下を PHPStan で解析します + * `composer check-docs` — `tools/check-docs.php` で本文とPHPファイルの整合性を検査します + +ネットワークを使う `composer check-playground` (Playground リンクの検査) は `check` には含めていません。個別に実行してください (後述)。 + +GitHub Actions でも同じ検査が走ります (`.github/workflows/check.yml`)。 + +## ディレクトリ構成 + +``` +beginner/ 入門編 (README.md と演習ファイル N.php) +basic/ 基礎編 +answers/ 演習の解答例 (answers/beginner/N.php のように配置) +tools/ 検査ツール +``` + +演習ファイルと解答例は同名の関数・クラスを定義するため、解答例は `phpstan.dist.neon` の `paths` に**含めない** `answers/` に置きます。同じディレクトリに置くと `phpstan analyse` の一括実行で定義が衝突し、誤ったエラーが出ます。 + +## コードブロックの検査 + +README 中の ```` ```php ```` ブロックは、フェンスに属性を付けると検査対象になります。属性のないブロックは検査されません (件数だけ表示されます)。 + +### `file=` — 演習ファイルとの一致 + +````markdown +```php file=3.php +function search(string $word, string $order, int $page): array +{ + // ... +} +``` +```` + +ブロックの内容が、README と同じディレクトリにある指定ファイルの**連続した行**と一致することを検査します。 + + * タブとスペースの違い、行末の空白は無視されます (PHPファイルはタブ、README は4スペースで構いません) + * `// ...` または `// …` だけの行は「ここに任意の行が入る」という省略記号として扱われます + * ファイルの一部だけを抜粋できます + +### `phpstan` — スニペット単体の解析 + +````markdown +```php phpstan +$word = filter_var($_GET['word'] ?? ''); +\PHPStan\dumpType($word); // DumpedType: string|false +``` +```` + +ブロックの内容を単体のPHPファイルとして PHPStan で解析します。先頭に `` を README に書き込みます。NOTE ブロック直後に `` があれば取り除きます。`--dry-run` を付けると発行せず対象だけ表示します。 + +保存する設定はローカルの `phpstan.dist.neon` に合わせて `tools/playground.php` の先頭に定数で定義しています (Level 10, bleedingEdge オン, strictRules オフ)。設定を変えたら両方を揃えてください。 + +演習ファイルを変更したら `composer update-playground` を実行し、README のリンク更新をコミットに含めてください。CI では `composer check-playground` が走り、リンク先とファイルの食い違いを検出します。 diff --git a/README.md b/README.md index 055aff0..cda5272 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,14 @@ Webブラウザのウィンドウを分割し、記事本文とPHPStan Playgroun どうしても実行できない場合は端末から**CLI**で指定されている`./vendor/bin/phpstan analyze beginner/xxx.php`のようなコマンドを実行してください。 +### 解答例 + +演習の解答例は [`answers/`](./answers/) にあります (例: `beginner/2.php` の解答例は [`answers/beginner/2.php`](./answers/beginner/2.php))。まずは自分で書いてみて、詰まったときに参照してください。 + +## 貢献するには + +本文と演習ファイルの整合性は `composer check` で検査しています。書き方の詳細は [CONTRIBUTING.md](./CONTRIBUTING.md) を参照してください。 + ## Copyright この文書は[GNU自由文書ライセンス]により自由に利用できます。 diff --git a/answers/basic/1.php b/answers/basic/1.php new file mode 100644 index 0000000..bd321e3 --- /dev/null +++ b/answers/basic/1.php @@ -0,0 +1,41 @@ + $id, + 'Name' => $name, + 'BirthDay' => new DateTimeImmutable($birthday), + ]; + + return $result; + } + + /** + * @return array + */ + public function fetchUsers(): array + { + // 仮実装なので仮データを返す + $users = []; + $users[] = $this->buildUser(1, 'Miku', '2007-08-31'); + $users[] = $this->buildUser(2, 'Rin', '2007-12-27'); + $users[] = $this->buildUser(3, 'Len', '2007-12-27'); + $users[] = $this->buildUser(4, 'Luka', '2009-01-30'); + + return $users; + } +} diff --git a/answers/beginner/2.php b/answers/beginner/2.php new file mode 100644 index 0000000..1775486 --- /dev/null +++ b/answers/beginner/2.php @@ -0,0 +1,11 @@ + $authors + */ + public function __construct( + public string $title, + public array $authors, + ) {} +} + +/** + * @param non-empty-string $word + * @param 'asc'|'desc' $order + * @param positive-int $page + * @return list + */ +function search(string $word, string $order, int $page): array +{ + // 本来は検索エンジンからデータを取得する + return match ($page) { + 1 => [new Book('PHPStan型付けチュートリアル', [new Author('USAMI Kenta')])], + default => [], + }; +} + +$word = filter_var($_GET['word'] ?? ''); +$order = filter_var($_GET['order'] ?? 'asc'); +$page = filter_var($_GET['page'] ?? 1, FILTER_VALIDATE_INT); + +\PHPStan\dumpType(compact('word', 'order', 'page')); + +if (in_array($word, [false, ''], true)) { + throw new RangeException('$word を入力してください'); +} + +if (!in_array($order, ['asc', 'desc'], true)) { + throw new RangeException('$order は asc または desc を指定してください'); +} + +if ($page === false || $page < 1) { + throw new RangeException('$page は1以上の整数を指定してください'); +} + +\PHPStan\dumpType(compact('word', 'order', 'page')); + +$books = search($word, $order, $page); + +\PHPStan\dumpType(compact('books')); diff --git a/answers/beginner/4.php b/answers/beginner/4.php new file mode 100644 index 0000000..8f0c375 --- /dev/null +++ b/answers/beginner/4.php @@ -0,0 +1,29 @@ + [!NOTE] > この節のコードは以下で確認できます -> * **PHPStan Playground**: +> * **PHPStan Playground**: > * **File**: [`1.php`](./1.php) > * **CLI**: `./vendor/bin/phpstan analyze basic/1.php` -``` php +```php file=1.php [!NOTE] > この節のコードは以下で確認できます -> * **PHPStan Playground**: +> * **PHPStan Playground**: > * **File**: [`1.php`](./1.php) > * **CLI**: `./vendor/bin/phpstan analyze beginner/1.php` -```php +```php phpstan $a = 'foo'; $b = 'bar'; $c = $a . $b; @@ -27,9 +27,14 @@ $c = $a . $b; \PHPStan\dumpType($c); ``` +> [!TIP] +> ファイルの先頭に `use function PHPStan\dumpType;` と書いておけば、単に `dumpType($a);` とも書けます。このチュートリアルの各ファイルにはこの`use function`を入れてあります。 +> +> ただしデバッグ用途のために毎回関数を`use function`でインポートするのは面倒に感じられるかもしれません。本文中のコードでは「どこの関数か」が一目でわかるように、 `\PHPStan\dumpType($a);` のように名前空間から書く形で統一します。 + 複数の値をまとめてチェックしたいときは[`compact()`]で配列にまとめることでわかりやすくなることもあります。 -```php +```php phpstan $n = 5; $m = 2; $l = $n / $m; @@ -40,7 +45,7 @@ $l = $n / $m; 何を言っているかわからないと思うので、次のようなコードを考えてみましょう。 -```php +```php phpstan $n = 5; if (rand() === 1) { $m = 2; @@ -49,7 +54,7 @@ if (rand() === 1) { } $l = $n / $m; -\PHPStan\dumpType(compact('n', 'm', 'l')); +\PHPStan\dumpType(compact('n', 'm', 'l')); // DumpedType: array{n: 5, m: 2|5, l: 1|2.5} ``` `rand() === 1` という条件が成り立つ確率は大雑把に「**21億分の1**」です。PHPStanは`rand() === 1`という確率的な処理は**行なっていません**。どちらでも僅かにでも可能性があるならば、PHPStanは**どちらの可能性もある**と判断して`2|5`という型をつけます。さらに`$l = $n / $m`という式はどうでしょうか。`$n`には`5`という型がついていますが、`$m`は`2`と`5`の可能性があるので、`$l = 5 / 2` (= `2.5`) と `$l = 5 / 5` (= `1`) という2パターンが考えられます。ここでPHPStanは`$l`に`1|2.5`という型をつけます。これはPHPStanが行なう型付けの特殊な例などではなく、***PHPStanが常に行なっていること***です。 @@ -67,22 +72,73 @@ $l = $n / $m; > [!IMPORTANT] > 🔜 **コードを好きに書き換えてみて、納得できたら次に進んでください** +## 1.5. 型は追跡され、追跡できなくなると広がる + +> [!NOTE] +> この節のコードは以下で確認できます +> * **PHPStan Playground**: +> * **File**: [`1.5.php`](./1.5.php) +> * **CLI**: `./vendor/bin/phpstan analyze beginner/1.5.php` + +PHPStanはコードを実行しているわけではありませんが、**追跡できる限り**は値を追いかけます。 + +```php phpstan +$n = 5; +$n = $n + 1; +\PHPStan\dumpType($n); // DumpedType: 6 + +$count = 0; +foreach (['a', 'b', 'c'] as $s) { + $count++; +} +\PHPStan\dumpType($count); // DumpedType: 3 +``` + +`5`や`6`、`'foo'`のように「その値ひとつだけ」を表す型を**定数型**(constant type)と呼びます。3要素の配列を`foreach`で回して`$count++`すれば`3`になる、というところまでPHPStanは追跡します。 + +では、追跡できなくなるとどうなるでしょうか。 + +```php phpstan +$total = 0; +foreach ($_GET as $value) { + $total++; +} +\PHPStan\dumpType($total); // DumpedType: int<0, max> + +$r = rand(); +\PHPStan\dumpType($r); // DumpedType: int<0, max> +\PHPStan\dumpType($r + 1); // DumpedType: int<1, max> +``` + +`$_GET`に何件の値が入っているかは実行するまでわかりません。ループが0回かもしれないし、100回かもしれない。そこでPHPStanは「**0以上の整数**」という意味の`int<0, max>`という型をつけます。これは**整数範囲型**(integer range type)といい、`int<最小値, 最大値>`の形で書きます。`max`は「上限なし」、`min`は「下限なし」を意味します。 + +`rand()`は0以上の乱数を返すので`int<0, max>`、それに`1`を足せば`int<1, max>`と、範囲も追跡されます。 + +`int<0, max>`と`int<1, max>`はよく使うので、それぞれ`non-negative-int`、`positive-int`という別名でも書けます。次の節以降のエラーメッセージに`int<1, max>`が出てきたら「1以上の整数のことだな」と読み替えてください。 + +> [!IMPORTANT] +> 🔜 **配列の要素数やループの回数を書き換えて、型がどう変わるか確かめられたら次に進んでください** + ## 2. 型宣言で関数に型をつける > [!NOTE] > この節のコードは以下で確認できます -> * **PHPStan Playground**: +> * **PHPStan Playground**: > * **File**: [`2.php`](./2.php) > * **CLI**: `./vendor/bin/phpstan analyze beginner/2.php` PHPの関数に型を付けてみましょう。 -```php +```php file=2.php +// Error: Function label() has no return type specified. +// Error: Function label() has parameter $title with no type specified. function label($title) { + // Error: Part $title (mixed) of encapsed string cannot be cast to string. return "label:{$title}"; } +// Error: Expected type string, actual: mixed \PHPStan\Testing\assertType('string', label('foo')); ``` @@ -90,6 +146,27 @@ function label($title) > `\PHPStan\Testing\assertType(expected, actual)` は値が期待する型とPHPStanが認識している型の **文字列表現の一致** をチェックする関数です。`expected`と`actual`が同じ文字列なら何も出力されなくなります。 > > ここでは使っていませんが、部分型関係を用いてチェックする `\PHPStan\Testing\assertSuperType(expected, actual)`もあります。 +> +> `dumpType()`と同じく、`use function PHPStan\Testing\assertType;` を書いておけば `assertType('string', label('foo'));` とも書けます。 + +### エラーメッセージを読む + +初期状態では4つのエラーが出ます。上のコードには、その行で発生するエラーを`// Error:`として書き添えてあります。PHPStanのエラーはどれも「**どこで**」「**何が**」「**どうなっているか**」を1行で説明しているので、慌てずに読み下してみましょう。 + + * `Function label() has no return type specified.` + * 関数`label()`に戻り値の型宣言がない + * `Function label() has parameter $title with no type specified.` + * 関数`label()`のパラメータ`$title`に型宣言がない + * `Part $title (mixed) of encapsed string cannot be cast to string.` + * 文字列の中に埋め込まれた`$title`は`mixed`型なので、文字列に変換できるかわからない + * `Expected type string, actual: mixed` + * `assertType()`が「`string`を期待したが、実際は`mixed`だった」と言っている + +ここに出てくる`mixed`は「**どんな値でもありうる**」という型です。型宣言のないパラメータには`mixed`が付きます。`mixed`はあらゆる型を含むので、`mixed`の値を文字列として扱ったり、メソッドを呼び出したりしようとするとPHPStanは「それが本当にできるかわからない」と警告します。裏を返せば、**型を付けるとは`mixed`を減らしていくこと**だといえます。 + +> [!TIP] +> * PHPStanには **レベル**(0〜10)があり、レベルが高いほど厳しく検査します。このチュートリアルは最も厳しい`level: max`(Playgroundでは **Level 10**)で動いています。「型宣言がない」というエラーが出るのもレベルが高いためです +> * CLIで実行すると各エラーに`🪪 missingType.return`のような **エラー識別子** が表示されます。 のように識別子をURLに付けると、そのエラーの解説ページを読めます > [!TIP] > 型宣言を含まない関数 `function f($arg1, $arg2) { ... }` は、どんな型の引数も受け入れ、どんな型の値を返すこともできます。 @@ -118,12 +195,13 @@ PHPではパラメータ(仮引数リスト)や戻り値に型宣言を追加で > [!IMPORTANT] > 🔜 **型を追加してエラーが出なくなったら次に進んでください** +> * 詰まったら解答例 [`answers/beginner/2.php`](../answers/beginner/2.php) を見ても構いません ## 3. 型を絞り込む > [!NOTE] > この節のコードは以下で確認できます -> * **PHPStan Playground**: +> * **PHPStan Playground**: > * **File**: [`3.php`](./3.php) > * **CLI**: `./vendor/bin/phpstan analyze beginner/3.php` @@ -131,7 +209,7 @@ PHPではパラメータ(仮引数リスト)や戻り値に型宣言を追加で `search()`関数の実装は次のようになっています。 -```php +```php file=3.php /** * @param non-empty-string $word * @param 'asc'|'desc' $order @@ -142,6 +220,8 @@ function search(string $word, string $order, int $page): array { // 本来は検索エンジンからデータを取得する return match ($page) { + // Error: Parameter #1 $title of class Book constructor expects non-empty-string, '' given. + // Error: Parameter #2 $authors of class Book constructor expects non-empty-array, array{} given. 1 => [new Book('', [])], default => [], }; @@ -161,6 +241,35 @@ function search(string $word, string $order, int $page): array * `Book`クラスのリスト * [`list`型について](https://scrapbox.io/php/list%E5%9E%8B) +`Book`クラスと`Author`クラスは同じファイルの先頭で次のように定義されています。 + +```php file=3.php +final readonly class Author { + /** + * @param non-empty-string $name + */ + public function __construct( + public string $name, + ) {} +} + +final readonly class Book { + /** + * @param non-empty-string $title + * @param non-empty-array $authors + */ + public function __construct( + public string $title, + public array $authors, + ) {} +} +``` + +> [!TIP] +> * `public function __construct(public string $name)` のようにコンストラクタのパラメータに`public`などを付けると、同名のプロパティの宣言と代入を兼ねます (**コンストラクタプロモーション**) +> * `readonly class` は全プロパティが読み取り専用のクラスです。一度作った`Book`の中身は書き換えられません +> * `non-empty-array` は「`Author`を1つ以上含む配列」です。`search()`の初期実装が`new Book('', [])`でエラーになるのは、この制約に反しているためです + > [!WARNING] > Doc commentは、必ず `/** ... */` (`*`が二つ!)から始まります。 > 範囲コメントの `/* ... */` とは区別されるので十分に気をつけてください。 @@ -170,18 +279,21 @@ function search(string $word, string $order, int $page): array 続いて、外部からの入力を値として取得します。 -```php +```php file=3.php $word = filter_var($_GET['word'] ?? ''); $order = filter_var($_GET['order'] ?? 'asc'); $page = filter_var($_GET['page'] ?? 1, FILTER_VALIDATE_INT); +\PHPStan\dumpType(compact('word', 'order', 'page')); // DumpedType: array{word: string|false, order: string|false, page: int|false} + +// Error: Parameter #1 $word of function search expects non-empty-string, string|false given. +// Error: Parameter #2 $order of function search expects 'asc'|'desc', string|false given. +// Error: Parameter #3 $page of function search expects int<1, max>, int|false given. $books = search($word, $order, $page); -// 初期状態では以下のエラーが発生する -// Parameter #1 $word of function search expects non-empty-string, string|false given. -// Parameter #2 $order of function search expects 'asc'|'desc', string|false given. -// Parameter #3 $page of function search expects int<1, max>, int|false given. ``` +初期状態では`// Error:`に書いたエラーが発生します。前の節で読み方を練習したとおり、「`search()`の1番目のパラメータ`$word`は`non-empty-string`を期待しているが、`string|false`が渡されている」と読めます。 + > [!TIP] > * [`filter_var()`](https://www.php.net/filter_var) > * 値をフィルタリングする関数です (名前に反して変数以外もフィルタできます) @@ -190,7 +302,7 @@ $books = search($word, $order, $page); PHPStanは比較により**型を絞り込む**(type narrowing)ことができます。 コードに以下のようなコードを追加して型を確認してみてください。 -```php +```php phpstan $word = filter_var($_GET['word'] ?? ''); \PHPStan\dumpType($word); // DumpedType: string|false @@ -207,7 +319,7 @@ PHPStanは**制御フロー解析**を実装しており、`if`や`foreach`と 型が絞り込まれた状態で制御フローを中断することで、その型を絞り込めます。中断とは、`return` `throw` `continue` `break` `exit` あるいは `never` 型の関数を読み込むなどです。 -```php +```php phpstan $word = filter_var($_GET['word'] ?? ''); \PHPStan\dumpType($word); // DumpedType: string|false @@ -222,37 +334,89 @@ if ($word === '' || $word === false) { これで型が絞り込まれた`else`の状態で固定できました。`else`のコードはまるごと削除しても構いません。さらに、型の絞り込みは**式の内部**でも起こります。 -```php +```php phpstan +$word = filter_var($_GET['word'] ?? ''); + // strlen() に false を渡してしまう可能性があるのでエラー +// Error: Parameter #1 $string of function strlen expects string, string|false given. if (strlen($word) === 0 || $word === false) { -// Parameter #1 $string of function strlen expects string, string|false given. throw new RangeException('$word を入力してください'); } ``` これは `||` の右辺と左辺を入れ替えることで解決します。 -```php +```php phpstan +$word = filter_var($_GET['word'] ?? ''); + // false のときに左辺で処理が打ち切られるので strlen() の呼び出しを防げる if ($word === false || strlen($word) === 0) { + throw new RangeException('$word を入力してください'); +} + +\PHPStan\dumpType($word); // DumpedType: non-empty-string ``` もっとも、このパターンは[`in_array()`](https://www.php.net/in_array)関数を用いて簡潔に絞り込めます。 -```php +```php phpstan +$word = filter_var($_GET['word'] ?? ''); + if (in_array($word, [false, ''], true)) { throw new RangeException('$word を入力してください'); } + +\PHPStan\dumpType($word); // DumpedType: non-empty-string ``` このように`in_array($var, ['foo', 'bar', 'buz'], true)`と書くことで、`$var === 'foo' || $var === 'bar' || $var === 'buz'`と等価になり、PHPStanも型の絞り込みを適切に認識します。 +> [!TIP] +> `if (!$word)` や `if (empty($word))` と書きたくなるかもしれません。PHPStanはこれも理解しますが、`'0'`という文字列も偽と判定されて弾かれるため、絞り込まれた型は`non-falsy-string`になります。「空文字列だけを弾きたい」という意図とは違う型になっていないか、`dumpType()`で確かめる習慣をつけましょう。 + +### ほかの絞り込み方 + +`===`と`in_array()`以外にも、PHPが値を判定するときに使う書き方のほとんどで型を絞り込めます。`<`や`>`のような**比較演算子**は整数範囲型に絞り込みます。 + +```php phpstan +$limit = filter_var($_GET['limit'] ?? 10, FILTER_VALIDATE_INT); +\PHPStan\dumpType($limit); // DumpedType: int|false + +if ($limit === false || $limit < 1 || $limit > 100) { + throw new RangeException('$limit は1以上100以下の整数を入力してください'); +} + +\PHPStan\dumpType($limit); // DumpedType: int<1, 100> +``` + +`is_string()`や`is_int()`のような**型判定関数**は`mixed`から型を取り出す基本の道具です。 + +```php phpstan +$value = $_GET['value'] ?? null; +\PHPStan\dumpType($value); // DumpedType: mixed + +if (!is_string($value)) { + throw new RangeException('$value は文字列で入力してください'); +} + +\PHPStan\dumpType($value); // DumpedType: string +``` + +ほかにも次のような書き方でも型が絞り込まれます。どれも仕組みは同じ**制御フロー解析**です。 + + * `$obj instanceof Book` — オブジェクトのクラス + * `$value !== null` / `$value ?? $default` — `null`の除外 + * `is_array()`, `is_int()`, `is_numeric()`, `is_callable()`, ... — 型判定関数 + * `assert(is_string($value))` — アサーション + * `match (true) { is_string($value) => ..., default => throw ... }` — `match`式 + 同じように、ほかの変数`$order`と`$page`の型も絞り込んでみてください。 > [!IMPORTANT] > 🔜 **実装を修正してエラーが出なくなったら次に進んでください** > * `search()`を呼び出す際に渡す値の型を適切に絞り込みます > * `search()`の実装内部でエラーが出ないように適当な値を埋めてください +> * 詰まったら解答例 [`answers/beginner/3.php`](../answers/beginner/3.php) を見ても構いません ## 4. 型宣言で安全に型をつける @@ -267,13 +431,16 @@ if (in_array($word, [false, ''], true)) { > [!NOTE] > この節のコードは以下で確認できます -> * **PHPStan Playground**: +> * **PHPStan Playground**: > * **File**: [`4.php`](./4.php) > * **CLI**: `./vendor/bin/phpstan analyze beginner/4.php` -```php +```php file=4.php [!IMPORTANT] > 🔜 **実装と型宣言を修正してエラーが出なくなれば、この章は修了です🎉** +> * 詰まったら解答例 [`answers/beginner/4.php`](../answers/beginner/4.php) を見ても構いません ## 入門編の修了 @@ -341,8 +510,12 @@ PHPStanは**条件付き戻り値型**をサポートしているのでPHPDocタ * `\PHPStan\dumpType()`でPHPStanが認識している型を確認できる * `\PHPStan\Testing\assertType()`で期待する型との比較もできる + * PHPStanは値を追跡できる限り定数型(`5`, `'foo'`)で追いかけ、追跡できなくなると`int<0, max>`のように型を広げる + * PHPStanのエラーメッセージを「どこで・何が・どうなっているか」として読み下せる + * `mixed`は「どんな値でもありうる」型で、型を付けるとは`mixed`を減らしていくこと * PHPの基本機能で関数・メソッドに型を付けることができる * PHPStanは制御フロー解析により型を絞り込める + * `===`, `in_array()`, 比較演算子, `is_string()`などの型判定関数, `instanceof` などが使える * PHPでは実行できるがPHPStanが受け付けないコードも存在することを認識できる * PHPDocタグでより詳細な型を付けることができる * `declare(strict_types=1)`の有無での振る舞いの差異がわかる diff --git a/composer.json b/composer.json index ae520df..f2bf7e3 100644 --- a/composer.json +++ b/composer.json @@ -8,5 +8,22 @@ }, "config": { "sort-packages": true + }, + "scripts": { + "check": [ + "@check-tools", + "@check-docs" + ], + "check-docs": "@php tools/check-docs.php", + "check-tools": "phpstan analyse --no-progress tools/", + "check-playground": "@php tools/playground.php check", + "update-playground": "@php tools/playground.php update" + }, + "scripts-descriptions": { + "check": "Run all consistency checks (tools + docs)", + "check-docs": "Verify README code blocks against PHP files and actual PHPStan output", + "check-tools": "Analyse tools/ with PHPStan", + "check-playground": "Verify that PHPStan Playground links in README match the PHP files (network)", + "update-playground": "Publish stale/missing PHPStan Playground samples and rewrite README links (network)" } } diff --git a/tools/check-docs.php b/tools/check-docs.php new file mode 100644 index 0000000..570bb37 --- /dev/null +++ b/tools/check-docs.php @@ -0,0 +1,573 @@ +.*?\S)\s+(?://|#)\s*(?DumpedType|Dumped type|Error):\s*(?\S.*?)\s*$~u'; +const ANNOTATION_LINE = '~^\s*(?://|#)\s*(?DumpedType|Dumped type|Error):\s*(?\S.*?)\s*$~u'; +const ELLIPSIS_LINE = '~^\s*(?://|#)\s*(?:\.\.\.|…)\s*$~u'; + +/** + * @param list $argv + */ +function main(array $argv): int +{ + $root = realpath(__DIR__ . '/..'); + assert(is_string($root)); + + $targets = array_slice($argv, 1); + if ($targets === []) { + $targets = array_map( + static fn (string $path): string => substr($path, strlen($root) + 1), + array_merge(glob($root . '/*/README.md') ?: []), + ); + sort($targets); + } + + $reporter = new Reporter(); + + foreach ($targets as $target) { + checkMarkdown($root, $target, $reporter); + } + + checkAnswers($root, $reporter); + + return $reporter->finish(); +} + +final class Reporter +{ + /** @var list */ + private array $failures = []; + private int $checks = 0; + + public function ok(string $message): void + { + $this->checks++; + fwrite(STDOUT, " ✔ {$message}\n"); + } + + public function fail(string $message): void + { + $this->checks++; + $this->failures[] = $message; + fwrite(STDOUT, " ✘ {$message}\n"); + } + + public function note(string $message): void + { + fwrite(STDOUT, " · {$message}\n"); + } + + public function section(string $title): void + { + fwrite(STDOUT, "\n{$title}\n"); + } + + public function finish(): int + { + $failed = count($this->failures); + fwrite(STDOUT, "\n"); + if ($failed === 0) { + fwrite(STDOUT, "[OK] {$this->checks} checks passed\n"); + return 0; + } + + fwrite(STDOUT, "[ERROR] {$failed} of {$this->checks} checks failed:\n"); + foreach ($this->failures as $failure) { + fwrite(STDOUT, " - {$failure}\n"); + } + + return 1; + } +} + +/** + * @phpstan-type Block array{start: int, info: string, attrs: array, lines: list} + */ +final class Markdown +{ + /** + * Markdown 中の ```php ... ``` ブロックを抜き出す + * + * 行頭 (0〜3スペース) から始まるフェンスのみ対象とし、`>` 引用中のフェンスは無視する + * + * @return list + */ + public static function codeBlocks(string $markdown): array + { + $blocks = []; + $lines = preg_split('/\R/u', $markdown) ?: []; + $fence = null; + $current = null; + + foreach ($lines as $i => $line) { + if ($fence === null) { + if (preg_match('/^ {0,3}(`{3,}|~{3,})\s*(.*)$/u', $line, $m) === 1) { + $fence = $m[1]; + $info = trim($m[2]); + $words = preg_split('/\s+/', $info, -1, PREG_SPLIT_NO_EMPTY) ?: []; + $lang = array_shift($words) ?? ''; + $attrs = []; + foreach ($words as $word) { + [$key, $value] = array_pad(explode('=', $word, 2), 2, ''); + $attrs[$key] = $value; + } + $current = ['start' => $i + 1, 'info' => $lang, 'attrs' => $attrs, 'lines' => []]; + } + continue; + } + + if (preg_match('/^ {0,3}' . preg_quote($fence, '/') . '\s*$/u', $line) === 1) { + assert($current !== null); + $blocks[] = $current; + $fence = null; + $current = null; + continue; + } + + assert($current !== null); + $current['lines'][] = $line; + } + + return $blocks; + } +} + +/** + * コードブロックから注釈を分離した結果 + * + * @phpstan-type Expectation array{dumps: list, errors: list} + */ +final class Snippet +{ + /** + * @param list $code 注釈を取り除いたコード行 + * @param array $expectations $code の行番号(0始まり) => 期待する出力 + * @param array $sourceLines $code の行番号(0始まり) => Markdown の行番号(1始まり) + * @param list $ellipses $code の中で「ここに任意の行が入る」ことを示す位置 ($code の行番号(0始まり)の直前) + */ + public function __construct( + public array $code, + public array $expectations, + public array $sourceLines, + public array $ellipses, + ) { + } + + /** + * @param list $lines + */ + public static function parse(array $lines, int $markdownStart, bool $allowEllipsis): self + { + $code = []; + $expectations = []; + $sourceLines = []; + $ellipses = []; + /** @var Expectation $pending 次のコード行に適用する注釈 */ + $pending = ['dumps' => [], 'errors' => []]; + + foreach ($lines as $offset => $line) { + if ($allowEllipsis && preg_match(ELLIPSIS_LINE, $line) === 1) { + $ellipses[] = count($code); + continue; + } + + if (preg_match(ANNOTATION_LINE, $line, $m) === 1) { + self::push($pending, $m['kind'], $m['value']); + continue; + } + + $expectation = $pending; + $pending = ['dumps' => [], 'errors' => []]; + + if (preg_match(ANNOTATION_TRAILING, $line, $m) === 1) { + self::push($expectation, $m['kind'], $m['value']); + $line = $m['code']; + } + + $index = count($code); + $code[] = $line; + $sourceLines[$index] = $markdownStart + 1 + $offset; + if ($expectation['dumps'] !== [] || $expectation['errors'] !== []) { + $expectations[$index] = $expectation; + } + } + + return new self($code, $expectations, $sourceLines, $ellipses); + } + + /** + * @param Expectation $expectation + */ + private static function push(array &$expectation, string $kind, string $value): void + { + if ($kind === 'Error') { + $expectation['errors'][] = $value; + } else { + $expectation['dumps'][] = $value; + } + } +} + +/** + * @param list $lines + * @return list + */ +function normalize(array $lines): array +{ + return array_map( + static fn (string $line): string => rtrim(str_replace("\t", str_repeat(' ', TAB_WIDTH), $line)), + $lines, + ); +} + +/** + * スニペットの各行がファイルの何行目に対応するかを求める (連続する行として一致しなければ null) + * + * @param list $snippet + * @param list $file + * @param list $ellipses + * @return array|null スニペット行番号(0始まり) => ファイル行番号(1始まり) + */ +function locate(array $snippet, array $file, array $ellipses): ?array +{ + // 省略記号でセグメントに分割し、順番に前方一致させる + $segments = []; + $prev = 0; + foreach ($ellipses as $position) { + $segments[] = [$prev, array_slice($snippet, $prev, $position - $prev)]; + $prev = $position; + } + $segments[] = [$prev, array_slice($snippet, $prev)]; + + $mapping = []; + $cursor = 0; + foreach ($segments as [$offset, $segment]) { + if ($segment === []) { + continue; + } + $found = null; + $last = count($file) - count($segment); + for ($i = $cursor; $i <= $last; $i++) { + if (array_slice($file, $i, count($segment)) === $segment) { + $found = $i; + break; + } + } + if ($found === null) { + return null; + } + foreach ($segment as $j => $_) { + $mapping[$offset + $j] = $found + $j + 1; + } + $cursor = $found + count($segment); + } + + return $mapping; +} + +/** + * PHPStan を実行し、ファイルの絶対パス => 行番号 => メッセージ一覧 を返す + * + * @param list $paths + * @return array{files: array>>, errors: list} + */ +function analyse(array $paths): array +{ + $command = array_merge( + [PHP_BINARY, PHPSTAN, 'analyse', '--error-format=json', '--no-progress', '--no-interaction', '-c', CONFIG, '--'], + $paths, + ); + $process = proc_open($command, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + if ($process === false) { + throw new RuntimeException('Failed to run PHPStan'); + } + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + proc_close($process); + + $json = json_decode(is_string($stdout) ? $stdout : '', true); + if (!is_array($json) || !isset($json['files'], $json['errors'])) { + throw new RuntimeException("PHPStan returned unexpected output:\n{$stdout}\n{$stderr}"); + } + + $files = []; + assert(is_array($json['files'])); + foreach ($json['files'] as $path => $result) { + assert(is_string($path)); + assert(is_array($result) && is_array($result['messages'])); + foreach ($result['messages'] as $message) { + assert(is_array($message) && is_int($message['line']) && is_string($message['message'])); + $identifier = $message['identifier'] ?? ''; + assert(is_string($identifier)); + $files[$path][$message['line']][] = [ + 'line' => $message['line'], + 'message' => $message['message'], + 'identifier' => $identifier, + ]; + } + } + assert(is_array($json['errors'])); + $errors = array_values(array_map(static fn ($e): string => is_string($e) ? $e : json_encode($e, JSON_THROW_ON_ERROR), $json['errors'])); + + return ['files' => $files, 'errors' => $errors]; +} + +/** + * ある行の実際の出力を注釈と同じ形 (dumps / errors) に整理する + * + * @param list $messages + * @return array{dumps: list, errors: list} + */ +function actualOf(array $messages): array +{ + $dumps = []; + $errors = []; + foreach ($messages as $message) { + if (in_array($message['identifier'], DUMP_IDENTIFIERS, true)) { + $dumps[] = preg_replace('/^Dumped type: /', '', $message['message']) ?? $message['message']; + } else { + $errors[] = $message['message']; + } + } + return ['dumps' => $dumps, 'errors' => $errors]; +} + +/** + * @param array{dumps: list, errors: list} $expected + * @param array{dumps: list, errors: list} $actual + * @return list 差分の説明 (空なら一致) + */ +function diff(array $expected, array $actual): array +{ + $problems = []; + foreach (['dumps' => 'DumpedType', 'errors' => 'Error'] as $key => $label) { + $e = $expected[$key]; + $a = $actual[$key]; + sort($e); + sort($a); + if ($e === $a) { + continue; + } + foreach (array_diff($e, $a) as $missing) { + $problems[] = "expected {$label}: {$missing}"; + } + foreach (array_diff($a, $e) as $extra) { + $problems[] = "actual {$label}: {$extra}"; + } + if ($problems === []) { + $problems[] = "{$label} count differs (expected " . count($e) . ', actual ' . count($a) . ')'; + } + } + return $problems; +} + +function checkMarkdown(string $root, string $target, Reporter $reporter): void +{ + $reporter->section($target); + $path = $root . '/' . $target; + $markdown = file_get_contents($path); + if ($markdown === false) { + $reporter->fail("{$target}: cannot read"); + return; + } + $dir = dirname($path); + + // 相対リンク先の存在チェック + preg_match_all('/\]\((\.\.?\/[^)\s#]+)(?:#[^)]*)?\)/u', $markdown, $links); + foreach (array_unique($links[1]) as $link) { + if (file_exists($dir . '/' . $link)) { + $reporter->ok("link {$link}"); + } else { + $reporter->fail("{$target}: link target not found: {$link}"); + } + } + + $blocks = Markdown::codeBlocks($markdown); + $fileBlocks = []; + $standalone = []; + $unchecked = 0; + foreach ($blocks as $block) { + if ($block['info'] !== 'php') { + continue; + } + if (isset($block['attrs']['file'])) { + $fileBlocks[] = $block; + } elseif (isset($block['attrs']['phpstan'])) { + $standalone[] = $block; + } else { + $unchecked++; + } + } + if ($unchecked > 0) { + $reporter->note("{$unchecked} php block(s) without `file=` or `phpstan` attribute are not checked"); + } + + // file= ブロック: 参照される全ファイルをまとめて解析 + $files = []; + foreach ($fileBlocks as $block) { + $file = realpath($dir . '/' . $block['attrs']['file']); + if ($file === false) { + $reporter->fail("{$target}:{$block['start']}: file not found: {$block['attrs']['file']}"); + continue; + } + $files[$file] = true; + } + $analysis = $files === [] ? ['files' => [], 'errors' => []] : analyse(array_keys($files)); + foreach ($analysis['errors'] as $error) { + $reporter->fail("{$target}: PHPStan error: {$error}"); + } + + foreach ($fileBlocks as $block) { + $label = "{$target}:{$block['start']} (file={$block['attrs']['file']})"; + $file = realpath($dir . '/' . $block['attrs']['file']); + if ($file === false) { + continue; + } + $snippet = Snippet::parse($block['lines'], $block['start'], true); + $fileLines = normalize(preg_split('/\R/u', (string) file_get_contents($file)) ?: []); + $mapping = locate(normalize($snippet->code), $fileLines, $snippet->ellipses); + if ($mapping === null) { + $reporter->fail("{$label}: code block does not match the file contents"); + continue; + } + $problems = compareExpectations($snippet, $mapping, $analysis['files'][$file] ?? [], false); + if ($problems === []) { + $reporter->ok("{$label}: matches file" . (count($snippet->expectations) > 0 ? ' and ' . count($snippet->expectations) . ' annotated line(s)' : '')); + } else { + foreach ($problems as $problem) { + $reporter->fail("{$label}: {$problem}"); + } + } + } + + // phpstan ブロック: 単体で解析 + $tmpDir = sys_get_temp_dir() . '/phpstan-typing-tutorial-' . getmypid(); + @mkdir($tmpDir); + foreach ($standalone as $n => $block) { + $label = "{$target}:{$block['start']} (phpstan)"; + $snippet = Snippet::parse($block['lines'], $block['start'], false); + $code = implode("\n", $snippet->code) . "\n"; + $offset = 0; + if (!str_starts_with(ltrim($code), 'code) as $i) { + $mapping[$i] = $i + $offset + 1; + } + $problems = array_merge( + array_map(static fn (string $e): string => "PHPStan error: {$e}", $result['errors']), + compareExpectations($snippet, $mapping, $result['files'][$tmpFile] ?? [], true), + ); + unlink($tmpFile); + if ($problems === []) { + $reporter->ok("{$label}: analysed" . (count($snippet->expectations) > 0 ? ', ' . count($snippet->expectations) . ' annotated line(s) match' : ', no errors')); + } else { + foreach ($problems as $problem) { + $reporter->fail("{$label}: {$problem}"); + } + } + } + @rmdir($tmpDir); +} + +/** + * @param array $mapping スニペット行番号(0始まり) => 解析対象ファイルの行番号(1始まり) + * @param array> $messages 行番号 => メッセージ + * @param bool $strict 注釈のない行にエラーがあれば失敗にする (dumpType の出力は除く) + * @return list + */ +function compareExpectations(Snippet $snippet, array $mapping, array $messages, bool $strict): array +{ + $problems = []; + $reverse = array_flip($mapping); + foreach ($snippet->expectations as $index => $expected) { + $fileLine = $mapping[$index]; + $actual = actualOf($messages[$fileLine] ?? []); + foreach (diff($expected, $actual) as $problem) { + $problems[] = "line {$snippet->sourceLines[$index]}: {$problem}"; + } + } + if ($strict) { + foreach ($messages as $fileLine => $lineMessages) { + $index = $reverse[$fileLine] ?? null; + if ($index !== null && isset($snippet->expectations[$index])) { + continue; + } + foreach (actualOf($lineMessages)['errors'] as $error) { + $where = $index === null ? "(generated line {$fileLine})" : "line {$snippet->sourceLines[$index]}"; + $problems[] = "{$where}: unexpected error: {$error}"; + } + } + } + return $problems; +} + +/** + * answers/ 以下の解答例が PHPStan でエラーにならないことを確認する (dumpType の出力は許容) + * + * 演習ファイルと同名のシンボルを定義するため、ファイルごとに個別に解析する + */ +function checkAnswers(string $root, Reporter $reporter): void +{ + $reporter->section('answers/'); + $answers = glob(ANSWERS_DIR . '/*/*.php') ?: []; + sort($answers); + if ($answers === []) { + $reporter->note('no answer files'); + return; + } + foreach ($answers as $answer) { + $real = realpath($answer); + if ($real === false) { + continue; + } + $label = substr($real, strlen($root) + 1); + $result = analyse([$real]); + $errors = $result['errors']; + foreach ($result['files'][$real] ?? [] as $line => $messages) { + foreach (actualOf($messages)['errors'] as $error) { + $errors[] = "line {$line}: {$error}"; + } + } + if ($errors === []) { + $reporter->ok("{$label}: no errors"); + } else { + foreach ($errors as $error) { + $reporter->fail("{$label}: {$error}"); + } + } + } +} + +$argv = $_SERVER['argv'] ?? []; +$argv = is_array($argv) ? array_values(array_filter($argv, 'is_string')) : []; +exit(main($argv)); diff --git a/tools/playground.php b/tools/playground.php new file mode 100644 index 0000000..4170098 --- /dev/null +++ b/tools/playground.php @@ -0,0 +1,282 @@ + * **PHPStan Playground**: (未発行なら TODO) + * > * **File**: [`1.php`](./1.php) + * + * Playground の Share ボタンと同じ API (https://api.phpstan.org/analyse に saveResult: true) を使います。 + * 詳細は CONTRIBUTING.md を参照。 + */ + +const API_BASE = 'https://api.phpstan.org'; +const USER_AGENT = 'phpstan-typing-tutorial/tools/playground.php'; + +/** + * Playground に保存する設定。ローカルの phpstan.dist.neon (level max, bleedingEdge) に合わせる + */ +const LEVEL = '10'; +const BLEEDING_EDGE = true; +const STRICT_RULES = false; +const TREAT_PHPDOC_TYPES_AS_CERTAIN = true; + +/** + * Playground の Options に相当する項目 (Playground の既定値) + */ +const OPTIONS = [ + 'inferPrivatePropertyTypeFromConstructor' => true, + 'rememberPossiblyImpureFunctionValues' => true, + 'checkBenevolentUnionTypes' => false, + 'checkTooWideTypesInProtectedAndPublicMethods' => false, + 'implicitThrows' => true, + 'missingCheckedExceptionInThrows' => false, + 'reportUncheckedExceptionDeadCatch' => true, + 'uncheckedExceptionClasses' => [], + 'checkedExceptionClasses' => [], + 'tooWideImplicitThrowType' => false, + 'reportUnsafeArrayStringKeyCasting' => null, +]; + +const PLAYGROUND_LINE = '~^(?>\s*\*\s*\*\*PHPStan Playground\*\*:\s*)(?[0-9a-f-]{36})>|TODO)\s*$~u'; +const FILE_LINE = '~^>\s*\*\s*\*\*File\*\*:\s*\[`[^`]+`\]\((?\./[^)]+\.php)\)~u'; +const TODO_LINE = '~^\s*$~u'; + +/** + * @param list $argv + */ +function main(array $argv): int +{ + $root = realpath(__DIR__ . '/..'); + assert(is_string($root)); + + $args = array_slice($argv, 1); + $dryRun = in_array('--dry-run', $args, true); + $args = array_values(array_filter($args, static fn (string $a): bool => $a !== '--dry-run')); + $mode = $args[0] ?? 'check'; + if (!in_array($mode, ['check', 'update'], true)) { + fwrite(STDERR, "Usage: php tools/playground.php [check|update] [--dry-run] [README.md ...]\n"); + return 2; + } + $targets = array_slice($args, 1); + if ($targets === []) { + $targets = array_map( + static fn (string $path): string => substr($path, strlen($root) + 1), + glob($root . '/*/README.md') ?: [], + ); + sort($targets); + } + + $failed = 0; + foreach ($targets as $target) { + $failed += processReadme($root, $target, $mode, $dryRun); + } + + fwrite(STDOUT, "\n"); + if ($failed === 0) { + fwrite(STDOUT, "[OK] all Playground links are up to date\n"); + return 0; + } + fwrite(STDOUT, $mode === 'check' + ? "[ERROR] {$failed} Playground link(s) are missing or stale. Run: php tools/playground.php update\n" + : "[ERROR] {$failed} Playground link(s) could not be updated\n"); + return 1; +} + +/** + * @return int 失敗 (未解決) の件数 + */ +function processReadme(string $root, string $target, string $mode, bool $dryRun): int +{ + fwrite(STDOUT, "\n{$target}\n"); + $path = "{$root}/{$target}"; + $content = file_get_contents($path); + if ($content === false) { + fwrite(STDOUT, " ✘ cannot read\n"); + return 1; + } + $dir = dirname($path); + $lines = preg_split('/\R/u', $content) ?: []; + $failed = 0; + $changed = false; + + // 途中で行を削除するため、コピーを走査する foreach ではなく生の配列を添字で走査する + for ($i = 0; $i < count($lines); $i++) { + if (preg_match(PLAYGROUND_LINE, $lines[$i], $m) !== 1) { + continue; + } + $fileLine = $lines[$i + 1] ?? ''; + if (preg_match(FILE_LINE, $fileLine, $f) !== 1) { + fwrite(STDOUT, ' ✘ line ' . ($i + 1) . ": no **File** line after the Playground line\n"); + $failed++; + continue; + } + $file = $f['path']; + $code = file_get_contents("{$dir}/{$file}"); + if ($code === false) { + fwrite(STDOUT, ' ✘ line ' . ($i + 1) . ": file not found: {$file}\n"); + $failed++; + continue; + } + $label = 'line ' . ($i + 1) . " ({$file})"; + + $id = $m['id'] ?? ''; + $reason = $id === '' ? 'no link yet' : compareSample($id, $code); + if ($reason === null) { + fwrite(STDOUT, " ✔ {$label}: up to date\n"); + continue; + } + if ($mode === 'check') { + fwrite(STDOUT, " ✘ {$label}: {$reason}\n"); + $failed++; + continue; + } + if ($dryRun) { + fwrite(STDOUT, " · {$label}: would publish ({$reason})\n"); + $failed++; + continue; + } + + try { + $newId = publish($code); + } catch (RuntimeException $e) { + fwrite(STDOUT, " ✘ {$label}: {$e->getMessage()}\n"); + $failed++; + continue; + } + $lines[$i] = $m['prefix'] . ""; + $changed = true; + fwrite(STDOUT, " ✔ {$label}: published https://phpstan.org/r/{$newId} ({$reason})\n"); + + // NOTE ブロック直後の を取り除く + for ($j = $i + 2; $j <= $i + 5 && isset($lines[$j]); $j++) { + if (preg_match(TODO_LINE, $lines[$j]) === 1) { + $removeBlank = ($lines[$j - 1] ?? null) === '' && ($lines[$j + 1] ?? null) === ''; + array_splice($lines, $j, $removeBlank ? 2 : 1); + break; + } + } + } + + if ($changed) { + file_put_contents($path, implode("\n", $lines)); + fwrite(STDOUT, " · {$target} updated\n"); + } + + return $failed; +} + +/** + * 保存済みサンプルとローカルのコード・設定を比較する + * + * @return string|null 一致すれば null、そうでなければ理由 + */ +function compareSample(string $id, string $code): ?string +{ + try { + $sample = request('GET', "/sample?id={$id}"); + } catch (RuntimeException $e) { + return "cannot fetch sample {$id}: {$e->getMessage()}"; + } + $remoteCode = $sample['code'] ?? null; + if (!is_string($remoteCode)) { + return "sample {$id} has no code"; + } + if (rtrim($remoteCode) !== rtrim($code)) { + return 'code differs from the file'; + } + $config = is_array($sample['config'] ?? null) ? $sample['config'] : []; + $mismatch = []; + if (($sample['level'] ?? null) !== LEVEL) { + $mismatch[] = 'level'; + } + foreach (['bleedingEdge' => BLEEDING_EDGE, 'strictRules' => STRICT_RULES, 'treatPhpDocTypesAsCertain' => TREAT_PHPDOC_TYPES_AS_CERTAIN] as $key => $expected) { + if (($config[$key] ?? null) !== $expected) { + $mismatch[] = $key; + } + } + if ($mismatch !== []) { + return 'config differs: ' . implode(', ', $mismatch); + } + return null; +} + +/** + * コードを Playground に保存して ID を返す + */ +function publish(string $code): string +{ + $options = OPTIONS + [ + 'strictRules' => STRICT_RULES, + 'bleedingEdge' => BLEEDING_EDGE, + 'treatPhpDocTypesAsCertain' => TREAT_PHPDOC_TYPES_AS_CERTAIN, + ]; + $result = request('POST', '/analyse', [ + 'code' => $code, + 'level' => LEVEL, + 'strictRules' => STRICT_RULES, + 'bleedingEdge' => BLEEDING_EDGE, + 'treatPhpDocTypesAsCertain' => TREAT_PHPDOC_TYPES_AS_CERTAIN, + 'options' => $options, + 'saveResult' => true, + ]); + $id = $result['id'] ?? null; + if (!is_string($id) || preg_match('/^[0-9a-f-]{36}$/', $id) !== 1) { + throw new RuntimeException('API did not return an id'); + } + return $id; +} + +/** + * @param array|null $body + * @return array + */ +function request(string $method, string $path, ?array $body = null): array +{ + $headers = ['User-Agent: ' . USER_AGENT, 'Accept: application/json']; + $options = ['method' => $method, 'timeout' => 120, 'ignore_errors' => true]; + if ($body !== null) { + $headers[] = 'Content-Type: application/json'; + $options['content'] = json_encode($body, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } + $options['header'] = implode("\r\n", $headers); + $context = stream_context_create(['http' => $options]); + $stream = @fopen(API_BASE . $path, 'r', false, $context); + if ($stream === false) { + throw new RuntimeException("request failed: {$method} {$path}"); + } + $response = stream_get_contents($stream); + // $http_response_header は PHP 8.5 で非推奨のため、ストリームのメタデータからステータスを取る + $meta = stream_get_meta_data($stream); + fclose($stream); + + $status = 0; + $wrapperData = $meta['wrapper_data'] ?? []; + foreach (is_array($wrapperData) ? $wrapperData : [] as $header) { + if (is_string($header) && preg_match('~^HTTP/\S+\s+(\d{3})~', $header, $s) === 1) { + $status = (int) $s[1]; + } + } + if ($status < 200 || $status >= 300) { + throw new RuntimeException("HTTP {$status}: {$method} {$path}"); + } + $json = json_decode($response === false ? '' : $response, true); + if (!is_array($json)) { + throw new RuntimeException("invalid JSON from {$method} {$path}"); + } + return $json; +} + +$argv = $_SERVER['argv'] ?? []; +$argv = is_array($argv) ? array_values(array_filter($argv, 'is_string')) : []; +exit(main($argv));