Skip to content

Commit 5e1cbb2

Browse files
committed
Add methods to extract types from class-level docblocks and implement tests for magic methods
1 parent 6ad2fd3 commit 5e1cbb2

8 files changed

Lines changed: 405 additions & 1 deletion

File tree

src/Contract/DocblockExtractor.php

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
namespace TypePHP\Contract;
66

7+
use PHPStan\PhpDocParser\Ast\PhpDoc\MethodTagValueNode;
78
use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocNode;
89
use PHPStan\PhpDocParser\Ast\PhpDoc\TemplateTagValueNode;
910
use PHPStan\PhpDocParser\Ast\Type\TypeNode;
@@ -168,4 +169,55 @@ public static function resolveImportedTypeAlias(string $fqcn, string $importedAl
168169

169170
return null;
170171
}
172+
173+
/**
174+
* Extracts a TypeNode from a class-level @property, @property-read, or @property-write docblock.
175+
*/
176+
public static function extractTypeFromClassPropertyDoc(string $doc, string $propName): ?TypeNode
177+
{
178+
try {
179+
$phpDocNode = self::parseDocString($doc);
180+
181+
foreach ($phpDocNode->getPropertyTagValues() as $tag) {
182+
if (ltrim($tag->propertyName, '$') === $propName) {
183+
return $tag->type;
184+
}
185+
}
186+
187+
foreach ($phpDocNode->getPropertyWriteTagValues() as $tag) {
188+
if (ltrim($tag->propertyName, '$') === $propName) {
189+
return $tag->type;
190+
}
191+
}
192+
193+
foreach ($phpDocNode->getPropertyReadTagValues() as $tag) {
194+
if (ltrim($tag->propertyName, '$') === $propName) {
195+
return $tag->type;
196+
}
197+
}
198+
} catch (\Throwable $e) {
199+
// Silently ignore malformed class docblocks
200+
}
201+
202+
return null;
203+
}
204+
205+
/**
206+
* Extracts a MethodTagValueNode from a class-level @method docblock.
207+
*/
208+
public static function extractMagicMethodContract(string $doc, string $methodName): ?MethodTagValueNode
209+
{
210+
try {
211+
$phpDocNode = self::parseDocString($doc);
212+
foreach ($phpDocNode->getMethodTagValues() as $tag) {
213+
if ($tag->methodName === $methodName) {
214+
return $tag;
215+
}
216+
}
217+
} catch (\Throwable $e) {
218+
// Silently ignore malformed class docblocks
219+
}
220+
221+
return null;
222+
}
171223
}

tests/Contract/DocblockExtractorTest.php

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,4 +56,20 @@
5656

5757
expect($aliases)->toHaveKey('LocalUserShape');
5858
});
59-
});
59+
60+
test('extracts type from class-level @property, @property-read, and @property-write docblocks', function () {
61+
$doc = "/**\n * @property positive-int \$score\n * @property-read non-empty-string \$title\n * @property-write list<string> \$tags\n */";
62+
63+
$scoreType = DocblockExtractor::extractTypeFromClassPropertyDoc($doc, 'score');
64+
expect((string) $scoreType)->toBe('positive-int');
65+
66+
$titleType = DocblockExtractor::extractTypeFromClassPropertyDoc($doc, 'title');
67+
expect((string) $titleType)->toBe('non-empty-string');
68+
69+
$tagsType = DocblockExtractor::extractTypeFromClassPropertyDoc($doc, 'tags');
70+
expect((string) $tagsType)->toBe('list<string>');
71+
72+
$missingType = DocblockExtractor::extractTypeFromClassPropertyDoc($doc, 'missing');
73+
expect($missingType)->toBeNull();
74+
});
75+
});
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TypePHP\Tests\Fixtures\Types;
6+
7+
/**
8+
* @property 'admin'|'user' $magicRole
9+
*/
10+
abstract class BaseMagicPropertyFixture
11+
{
12+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TypePHP\Tests\Fixtures\Types;
6+
7+
class ChildMagicPropertyFixture extends BaseMagicPropertyFixture
8+
{
9+
public string $magicRole = 'user';
10+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TypePHP\Tests\Fixtures\Types;
6+
7+
use TypePHP\Tests\Fixtures\Domain\Dog;
8+
use TypePHP\Tests\Fixtures\Generics\Producer;
9+
10+
/**
11+
* @phpstan-type LocalUserShape array{id: positive-int, role: 'admin'|'user'}
12+
* @phpstan-type PayloadShape array{id: positive-int, tags: list<non-empty-string>}
13+
* @phpstan-type StatusUnion 'active'|'pending'
14+
*
15+
* @method positive-int processId(positive-int $id, non-empty-string $name)
16+
* @method static list<int> fetchList(int ...$items)
17+
* @method PayloadShape buildPayload(list<positive-int> $ids, StatusUnion $status)
18+
* @method Producer<Dog> getProducer(Producer<Dog> $producer)
19+
* @method bool checkCollection((\Countable&\ArrayAccess)|null $collection)
20+
* @method LocalUserShape saveUser(LocalUserShape $user)
21+
*/
22+
class MagicMethodFixture
23+
{
24+
public function __call(string $name, array $arguments): mixed
25+
{
26+
if ($name === 'processId') {
27+
return $arguments[0] ?? null;
28+
}
29+
30+
if ($name === 'buildPayload') {
31+
$ids = $arguments[0] ?? [];
32+
33+
return [
34+
'id' => $ids[0] ?? 1,
35+
'tags' => ['php', 'typephp'],
36+
];
37+
}
38+
39+
if ($name === 'getProducer') {
40+
return $arguments[0] ?? null;
41+
}
42+
43+
if ($name === 'checkCollection') {
44+
return true;
45+
}
46+
47+
if ($name === 'saveUser') {
48+
return $arguments[0] ?? null;
49+
}
50+
51+
return null;
52+
}
53+
54+
public static function __callStatic(string $name, array $arguments): mixed
55+
{
56+
if ($name === 'fetchList') {
57+
return $arguments;
58+
}
59+
60+
return null;
61+
}
62+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TypePHP\Tests\Fixtures\Types;
6+
7+
/**
8+
* @property positive-int $magicScore
9+
* @property-write non-empty-string $magicName
10+
* @property-read list<int> $magicTags
11+
*/
12+
class MagicPropertyFixture
13+
{
14+
public array $data = [];
15+
16+
public function __set(string $name, mixed $value): void
17+
{
18+
$this->data[$name] = $value;
19+
}
20+
21+
public function __get(string $name): mixed
22+
{
23+
return $this->data[$name] ?? null;
24+
}
25+
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use TypePHP\Internal\Config;
6+
use TypePHP\Tests\Fixtures\Domain\Car;
7+
use TypePHP\Tests\Fixtures\Domain\Dog;
8+
use TypePHP\Tests\Fixtures\Generics\Producer;
9+
use TypePHP\Tests\Fixtures\Types\CountableArrayAccess;
10+
use TypePHP\Tests\Fixtures\Types\CountableOnly;
11+
use TypePHP\Tests\Fixtures\Types\MagicMethodFixture;
12+
13+
beforeEach(function () {
14+
Config::reset();
15+
});
16+
17+
afterEach(function () {
18+
Config::reset();
19+
});
20+
21+
describe('Class-Level Magic Methods (@method) with Complex Types', function () {
22+
describe('Basic Parameters & Variadics', function () {
23+
test('validates arguments passed into dynamic instance method', function () {
24+
$fixture = new MagicMethodFixture();
25+
26+
expect($fixture->processId(42, 'Alice'))->toBe(42);
27+
28+
expect(fn () => $fixture->processId(-5, 'Alice'))
29+
->toThrow(TypeError::class, "TypePHP\\Tests\\Fixtures\\Types\\MagicMethodFixture::processId(): Argument \$id must be of type positive-int, negative int (-5) given")
30+
;
31+
32+
expect(fn () => $fixture->processId(42, ''))
33+
->toThrow(TypeError::class, "TypePHP\\Tests\\Fixtures\\Types\\MagicMethodFixture::processId(): Argument \$name must be of type non-empty-string, empty string ('') given")
34+
;
35+
});
36+
37+
test('validates variadic arguments passed into dynamic static method', function () {
38+
expect(MagicMethodFixture::fetchList(1, 2, 3))->toBe([1, 2, 3]);
39+
40+
expect(fn () => MagicMethodFixture::fetchList(1, 2, 'hello'))
41+
->toThrow(TypeError::class, "TypePHP\\Tests\\Fixtures\\Types\\MagicMethodFixture::fetchList(): Argument \$items[2] must be of type int, string 'hello' given")
42+
;
43+
});
44+
});
45+
46+
describe('Array Shapes & Lists in @method', function () {
47+
test('validates list arguments and array shape returns on dynamic method', function () {
48+
$fixture = new MagicMethodFixture();
49+
50+
$result = $fixture->buildPayload([10, 20], 'active');
51+
expect($result)->toBe(['id' => 10, 'tags' => ['php', 'typephp']]);
52+
53+
expect(fn () => $fixture->buildPayload([10, -5], 'active'))
54+
->toThrow(TypeError::class, "TypePHP\\Tests\\Fixtures\\Types\\MagicMethodFixture::buildPayload(): Argument \$ids[1] must be of type positive-int")
55+
;
56+
57+
expect(fn () => $fixture->buildPayload([10, 20], 'archived'))
58+
->toThrow(TypeError::class, "TypePHP\\Tests\\Fixtures\\Types\\MagicMethodFixture::buildPayload(): Argument \$status must be of type ('active' | 'pending')")
59+
;
60+
});
61+
});
62+
63+
describe('Generics in @method', function () {
64+
test('validates generic object instances passed to dynamic method', function () {
65+
$fixture = new MagicMethodFixture();
66+
$dogProducer = new Producer(new Dog());
67+
68+
expect($fixture->getProducer($dogProducer))->toBe($dogProducer);
69+
70+
$carProducer = new Producer(new Car());
71+
expect(fn () => $fixture->getProducer($carProducer))
72+
->toThrow(TypeError::class, "TypePHP\\Tests\\Fixtures\\Types\\MagicMethodFixture::getProducer(): Argument \$producer expects TypePHP\\Tests\\Fixtures\\Generics\\Producer<covariant TypePHP\\Tests\\Fixtures\\Domain\\Dog>")
73+
;
74+
});
75+
});
76+
77+
describe('Intersections & Nullable Types in @method', function () {
78+
test('validates intersection types and nullable null on dynamic method', function () {
79+
$fixture = new MagicMethodFixture();
80+
81+
expect($fixture->checkCollection(null))->toBeTrue();
82+
expect($fixture->checkCollection(new CountableArrayAccess()))->toBeTrue();
83+
84+
expect(fn () => $fixture->checkCollection(new CountableOnly()))
85+
->toThrow(TypeError::class, "TypePHP\\Tests\\Fixtures\\Types\\MagicMethodFixture::checkCollection(): Argument \$collection must be of type ((Countable & ArrayAccess) | null)")
86+
;
87+
});
88+
});
89+
90+
describe('Type Aliases (@phpstan-type) in @method', function () {
91+
test('resolves local class-level type aliases inside @method definitions', function () {
92+
$fixture = new MagicMethodFixture();
93+
94+
$validUser = ['id' => 10, 'role' => 'admin'];
95+
expect($fixture->saveUser($validUser))->toBe($validUser);
96+
97+
$badUser = ['id' => 10, 'role' => 'superadmin'];
98+
expect(fn () => $fixture->saveUser($badUser))
99+
->toThrow(TypeError::class, "TypePHP\\Tests\\Fixtures\\Types\\MagicMethodFixture::saveUser(): Argument \$user['role'] must be of type ('admin' | 'user')")
100+
;
101+
});
102+
});
103+
104+
describe('Configuration Control', function () {
105+
test('ignores magic method validation when magic_methods config is false', function () {
106+
Config::set(['magic_methods' => false]);
107+
108+
$fixture = new MagicMethodFixture();
109+
110+
$result = $fixture->processId(-5, '');
111+
expect($result)->toBe(-5);
112+
});
113+
});
114+
});

0 commit comments

Comments
 (0)