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
19 changes: 19 additions & 0 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
@@ -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
106 changes: 106 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -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 で解析します。先頭に `<?php` がなければ `<?php declare(strict_types = 1);` が補われます。

* 注釈 (後述) を付けた行は、注釈と実際の出力が**完全に一致**することを検査します
* 注釈のない行にエラーが出た場合は失敗します (`dumpType()` の出力は除く)。本文で触れないエラーが紛れ込むのを防ぐためです

### 注釈

コードブロック内のコメントで、その行に PHPStan が出力する内容を宣言します。ファイルとの一致検査では注釈は取り除いてから比較されるので、演習ファイル側に注釈を書く必要はありません。

| 書き方 | 意味 |
|---|---|
| `// DumpedType: int` | その行の `\PHPStan\dumpType()` の出力 (`// Dumped type: int` や `# Dumped type: int` も可) |
| `// Error: Function f() has no return type specified.` | その行で発生するエラーメッセージ |

注釈は**行末**に書くか、**直前の行**に1行ずつ書きます。直前の行に書いた注釈は、次のコード行 (注釈でない行) に適用されます。

```php
// Error: Function label() has no return type specified.
// Error: Function label() has parameter $title with no type specified.
function label($title)
{
return "label:{$title}";
}

\PHPStan\dumpType($x); // DumpedType: int
```

注釈を付けた行は、注釈で宣言した dump 出力とエラーの集合が、実際の出力と**過不足なく**一致しなければなりません (順序は問いません)。

## 解答例

`answers/*/*.php` はそれぞれ単体で PHPStan を実行し、`dumpType()` の出力以外のエラーが出ないことを検査します。

## Playground のリンク

各節の NOTE ブロックにある **PHPStan Playground** のリンクは、`tools/playground.php` で検査・更新できます。

```bash
composer check-playground # リンク先に保存されたコード・設定が演習ファイルと一致するか (読み取りのみ)
composer update-playground # 一致しない/TODO のリンクを新規発行して README を書き換える
```

`update` は Playground の **Share** ボタンと同じ API (`POST https://api.phpstan.org/analyse` に `saveResult: true`) を叩き、返ってきた ID で `https://phpstan.org/r/<id>` を README に書き込みます。NOTE ブロック直後に `<!-- TODO: ... Playground ... -->` があれば取り除きます。`--dry-run` を付けると発行せず対象だけ表示します。

保存する設定はローカルの `phpstan.dist.neon` に合わせて `tools/playground.php` の先頭に定数で定義しています (Level 10, bleedingEdge オン, strictRules オフ)。設定を変えたら両方を揃えてください。

演習ファイルを変更したら `composer update-playground` を実行し、README のリンク更新をコミットに含めてください。CI では `composer check-playground` が走り、リンク先とファイルの食い違いを検出します。
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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自由文書ライセンス]により自由に利用できます。
Expand Down
41 changes: 41 additions & 0 deletions answers/basic/1.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php declare(strict_types = 1);

use function PHPStan\dumpPhpDocType;
use function PHPStan\dumpType;
use function PHPStan\Testing\assertType;

class UsersBuilder
{
/**
* @return array{ID: int, Name: string, BirthDay: DateTimeImmutable}
*/
public function buildUser(int $id, string $name, string $birthday): array
{
$result = [
'ID' => $id,
'Name' => $name,
'BirthDay' => new DateTimeImmutable($birthday),
];

return $result;
}

/**
* @return array<array{
* ID: int,
* Name: string,
* BirthDay: DateTimeImmutable,
* }>
*/
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;
}
}
11 changes: 11 additions & 0 deletions answers/beginner/2.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php declare(strict_types = 1);

use function PHPStan\dumpType;
use function PHPStan\Testing\assertType;

function label(string $title): string
{
return "label:{$title}";
}

\PHPStan\Testing\assertType('string', label('foo'));
63 changes: 63 additions & 0 deletions answers/beginner/3.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?php declare(strict_types = 1);

use function PHPStan\dumpType;
use function PHPStan\Testing\assertType;

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<Author> $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<Book>
*/
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'));
29 changes: 29 additions & 0 deletions answers/beginner/4.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php declare(strict_types = 1);

use function PHPStan\dumpType;
use function PHPStan\Testing\assertType;

/**
* $s が数値文字列だったら int に変換して返す
*
* @return ($s is numeric-string ? int : null)
*/
function to_int(string $s): ?int
{
$int = filter_var($s, FILTER_VALIDATE_INT);
if ($int !== false) {
return $int;
}

$float = filter_var($s, FILTER_VALIDATE_FLOAT);
if ($float !== false) {
return (int)$float;
}

return null;
}

\PHPStan\Testing\assertType('int', to_int('1'));
\PHPStan\Testing\assertType('int', to_int('1.1'));
\PHPStan\Testing\assertType('null', to_int('php'));
\PHPStan\Testing\assertType('int|null', to_int(random_bytes(1)));
4 changes: 4 additions & 0 deletions basic/1.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
<?php declare(strict_types = 1);

use function PHPStan\dumpPhpDocType;
use function PHPStan\dumpType;
use function PHPStan\Testing\assertType;

class UsersBuilder
{
public function buildUser(int $id, string $name, string $birthday): array
Expand Down
10 changes: 8 additions & 2 deletions basic/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,20 @@

> [!NOTE]
> この節のコードは以下で確認できます
> * **PHPStan Playground**: <https://phpstan.org/r/99e38017-017c-4ca0-9a41-48750b676c8a>
> * **PHPStan Playground**: <https://phpstan.org/r/b50f2044-ebc6-48a7-ab73-a2423d2c1883>
> * **File**: [`1.php`](./1.php)
> * **CLI**: `./vendor/bin/phpstan analyze basic/1.php`

``` php
```php file=1.php
<?php declare(strict_types = 1);

use function PHPStan\dumpPhpDocType;
use function PHPStan\dumpType;
use function PHPStan\Testing\assertType;

class UsersBuilder
{
// Error: Method UsersBuilder::buildUser() return type has no value type specified in iterable type array.
public function buildUser(int $id, string $name, string $birthday): array
{
$result = [
Expand All @@ -29,6 +34,7 @@ class UsersBuilder
return $result;
}

// Error: Method UsersBuilder::fetchUsers() return type has no value type specified in iterable type array.
public function fetchUsers(): array
{
// 仮実装なので仮データを返す
Expand Down
24 changes: 24 additions & 0 deletions beginner/1.5.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php declare(strict_types = 1);

use function PHPStan\dumpType;
use function PHPStan\Testing\assertType;

$n = 5;
$n = $n + 1;
\PHPStan\dumpType($n);

$count = 0;
foreach (['a', 'b', 'c'] as $s) {
$count++;
}
\PHPStan\dumpType($count);

$total = 0;
foreach ($_GET as $value) {
$total++;
}
\PHPStan\dumpType($total);

$r = rand();
\PHPStan\dumpType($r);
\PHPStan\dumpType($r + 1);
3 changes: 3 additions & 0 deletions beginner/1.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
<?php declare(strict_types = 1);

use function PHPStan\dumpType;
use function PHPStan\Testing\assertType;

$a = 'foo';
$b = 'bar';
$c = $a . $b;
Expand Down
3 changes: 3 additions & 0 deletions beginner/2.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
<?php declare(strict_types = 1);

use function PHPStan\dumpType;
use function PHPStan\Testing\assertType;

function label($title)
{
return "label:{$title}";
Expand Down
3 changes: 3 additions & 0 deletions beginner/3.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
<?php declare(strict_types = 1);

use function PHPStan\dumpType;
use function PHPStan\Testing\assertType;

final readonly class Author {
/**
* @param non-empty-string $name
Expand Down
3 changes: 3 additions & 0 deletions beginner/4.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
<?php declare(strict_types = 0);

use function PHPStan\dumpType;
use function PHPStan\Testing\assertType;

/**
* $s が数値文字列だったら int に変換して返す
*/
Expand Down
Loading
Loading