packages/deriver/src/Deriver/Query.php
1<?php
2
3declare(strict_types=1);
4
5namespace Deriver\Deriver;
6
7use Deriver\Exception\InvalidInputException;
8
9/**
10 * The syntax of a selector, independent of source files and analysis state.
11 * @visibility public
12 * @example Parsing a return selector
13 * \Deriver\Deriver\Query::parse('App\\answer#return')->target // => 'App\\answer'
14 * @example Formatting a source selector
15 * \Deriver\Deriver\Query::parse('src/Repository.php:25:5#argument(0)')->toString() // => 'src/Repository.php:25:5#argument(0)'
16 */
17final class Query
18{
19 /**
20 * @param string $target Callable name or unquoted source path
21 * @param int|null $line One-based source line, or null for a callable
22 * @param int|null $column One-based byte column, or null for a callable
23 * @param string $selector Requested part of the target
24 * @param list<int|string> $arguments Selector arguments, not application inputs
25 * @param list<string> $path Named callers and ellipses; empty when unrestricted
26 */
27 private function __construct(
28 public readonly string $target,
29 public readonly ?int $line,
30 public readonly ?int $column,
31 public readonly string $selector,
32 public readonly array $arguments,
33 public readonly array $path = [],
34 ) {
35 }
36
37 /**
38 * Extracts the caller path, target, optional coordinates and selector without reading source.
39 * @throws InvalidInputException If the selector syntax or integer range is invalid
40 */
41 public static function parse(string $query): self
42 {
43 $query = trim($query);
44 if (preg_match('/[\x00-\x1f\x7f]/', $query) === 1 || preg_match('/\A(.+)#([a-z][a-z0-9]*)(?:\(([^()]*)\))?\z/', $query, $parts) !== 1) {
45 throw new InvalidInputException('Expected target#selector or target#selector(arguments).');
46 }
47 $steps = array_map('trim', explode('->', $parts[1]));
48 $target = array_pop($steps);
49 self::validatePath($steps);
50 $line = $column = null;
51 if (preg_match('/\A(.*):([0-9]+):([0-9]+)\z/', $target, $position) === 1) {
52 $target = $position[1];
53 $line = (int) $position[2];
54 $column = (int) $position[3];
55 if ($line < 1 || $column < 1 || (string) $line !== $position[2] || (string) $column !== $position[3]) {
56 throw new InvalidInputException('Source coordinates must be positive integers without leading zeroes.');
57 }
58 }
59 if ($target === '' || $target === '...' || str_contains($target, '"') || str_contains($target, "'")) {
60 throw new InvalidInputException('Query targets must be nonempty and unquoted.');
61 }
62 $arguments = [];
63 $input = trim($parts[3] ?? '');
64 foreach ($input === '' ? [] : explode(',', $input) as $token) {
65 $token = trim($token);
66 if (preg_match('/\A(?:0|[1-9][0-9]*|[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)\z/', $token) !== 1) {
67 throw new InvalidInputException('Selector arguments must be identifiers or nonnegative integers.');
68 }
69 $numeric = preg_match('/\A[0-9]/', $token) === 1;
70 if ($numeric && (string) (int) $token !== $token) {
71 throw new InvalidInputException('Selector argument exceeds the supported integer range.');
72 }
73 $arguments[] = $numeric ? (int) $token : $token;
74 }
75 return new self($target, $line, $column, $parts[2], $arguments, $steps);
76 }
77
78 /**
79 * Validates caller syntax without constructing an evaluator request.
80 * @param list<string> $callers Prefix before the final selected target
81 * @throws InvalidInputException If a caller or ellipsis is malformed
82 */
83 public static function validatePath(array $callers): void
84 {
85 $identifier = '[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*';
86 foreach ($callers as $index => $caller) {
87 if ($caller === '...' && $index > 0 && $callers[$index - 1] !== '...') {
88 continue;
89 }
90 if (preg_match('~\A\\\\?(?:' . $identifier . '\\\\)*' . $identifier . '(?:::' . $identifier . ')?\z~', $caller) !== 1) {
91 throw new InvalidInputException('Call paths require callable names separated by ->; use ... only between named targets.');
92 }
93 }
94 }
95
96 /**
97 * Formats the parsed fields with canonical spacing and no empty parentheses.
98 */
99 public function toString(): string
100 {
101 $position = $this->line === null ? '' : ':' . $this->line . ':' . $this->column;
102 $arguments = $this->arguments === [] ? '' : '(' . implode(',', $this->arguments) . ')';
103 $prefix = $this->path === [] ? '' : implode(' -> ', $this->path) . ' -> ';
104 return $prefix . $this->target . $position . '#' . $this->selector . $arguments;
105 }
106}
107