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