packages/deriver/tests/Unit/Deriver/QueryTest.php
1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Deriver;
6
7use Deriver\Deriver\Query;
8use Deriver\Exception\InvalidInputException;
9use PHPUnit\Framework\Attributes\CoversClass;
10use PHPUnit\Framework\Attributes\DataProvider;
11use PHPUnit\Framework\TestCase;
12
13#[CoversClass(Query::class)]
14final class QueryTest extends TestCase
15{
16 public function testValidatePathRejectsAnUnanchoredEllipsis(): void
17 {
18 $this->expectException(InvalidInputException::class);
19 Query::validatePath(['...']);
20 }
21
22 public function testParseKeepsTheCallerPathAsSyntax(): void
23 {
24 self::assertSame(['entry', '...', 'bar'], Query::parse('entry -> ... -> bar -> foo#return')->path);
25 self::assertSame([], Query::parse('foo#return')->path);
26 }
27
28 public function testParseExtractsCallableSelectors(): void
29 {
30 $query = Query::parse('App\\Repository::find#parameter(id)');
31 self::assertSame('App\\Repository::find', $query->target);
32 self::assertNull($query->line);
33 self::assertNull($query->column);
34 self::assertSame('parameter', $query->selector);
35 self::assertSame(['id'], $query->arguments);
36 }
37
38 public function testParseExtractsUnquotedPathsAndCoordinatesWithoutReadingFiles(): void
39 {
40 $query = Query::parse('not present/source #1.php:25:5#argument(0)');
41 self::assertSame('not present/source #1.php', $query->target);
42 self::assertSame(25, $query->line);
43 self::assertSame(5, $query->column);
44 self::assertSame('argument', $query->selector);
45 self::assertSame([0], $query->arguments);
46 }
47
48 public function testParsePreservesSelectorSyntaxWithoutResolvingItsMeaning(): void
49 {
50 $query = Query::parse('App\\missing#selection(name, 2)');
51 self::assertSame('selection', $query->selector);
52 self::assertSame(['name', 2], $query->arguments);
53 }
54
55 #[DataProvider('providerRoundTrips')]
56 public function testToStringRoundTripsTheParsedFields(string $input, string $canonical): void
57 {
58 $query = Query::parse($input);
59 self::assertSame($canonical, $query->toString());
60 self::assertEquals($query, Query::parse($query->toString()));
61 }
62
63 /**
64 * @return iterable<string, array{string, string}>
65 */
66 public static function providerRoundTrips(): iterable
67 {
68 yield 'direct path' => ['bar->foo#return', 'bar -> foo#return'];
69 yield 'ancestor path' => ['entry -> ... -> bar -> foo#parameter(x)', 'entry -> ... -> bar -> foo#parameter(x)'];
70 yield 'call site path' => ['bar -> src/a.php:1:2#argument(0)', 'bar -> src/a.php:1:2#argument(0)'];
71 yield 'return' => ['App\\answer#return', 'App\\answer#return'];
72 yield 'parameter' => ['App\\Repository::find#parameter(id)', 'App\\Repository::find#parameter(id)'];
73 yield 'position' => ['src/Repository.php:25:5#argument(0)', 'src/Repository.php:25:5#argument(0)'];
74 yield 'named argument' => ['src/Repository.php:25:5#argument(sql)', 'src/Repository.php:25:5#argument(sql)'];
75 yield 'windows path' => ['C:\\source files\\Run.php:2:1#argument(1)', 'C:\\source files\\Run.php:2:1#argument(1)'];
76 yield 'leading namespace' => ['\\App\\answer#return', '\\App\\answer#return'];
77 yield 'whitespace' => [" \n App\\answer #return() \n", 'App\\answer#return'];
78 yield 'argument whitespace' => ['target#selection( name, 2 )', 'target#selection(name,2)'];
79 yield 'integer boundary' => ['target#selection(' . PHP_INT_MAX . ')', 'target#selection(' . PHP_INT_MAX . ')'];
80 }
81
82 #[DataProvider('providerInvalidSelectors')]
83 public function testParseRejectsMalformedSyntax(string $input): void
84 {
85 $this->expectException(InvalidInputException::class);
86 Query::parse($input);
87 }
88
89 /**
90 * @return iterable<string, array{string}>
91 */
92 public static function providerInvalidSelectors(): iterable
93 {
94 foreach (['-> foo#return', 'bar -> -> foo#return', 'bar -> #return', 'bar -> ...#return', '... -> foo#return', 'bar -> ... -> ... -> foo#return', 'bar#return -> foo#return', '', '#return', ' #return', 'target', 'target#', 'target#return(', 'target#return)', 'target#return()junk', 'target#parameter($id)', 'target#argument(-1)', 'target#argument(1.5)', 'target#argument(01)', 'target#selection(a,)', 'target#selection(a,,b)', 'target#selection(a b)', 'target#selection(nested(0))', 'file.php:0:1#argument(0)', 'file.php:1:0#argument(0)', 'file.php:01:1#argument(0)', '"file.php":1:1#argument(0)', "'file.php':1:1#argument(0)", ':1:1#argument(0)', "file\0.php:1:1#argument(0)", "file\n.php:1:1#argument(0)", 'target#argument(' . PHP_INT_MAX . '0)', 'file.php:' . PHP_INT_MAX . '0:1#argument(0)'] as $index => $input) {
95 yield 'invalid ' . $index => [$input];
96 }
97 }
98}
99