packages/sql-semantics/src/Core/Analysis/ValueReader.php

1<?php
2
3declare(strict_types=1);
4
5namespace SqlSemantics\Core\Analysis;
6
7use LogicException;
8use SqlParser\Lexer\Token;
9use SqlParser\Parser\Node;
10use SqlSemantics\Statement\Assertion;
11use SqlSemantics\Statement\Comments;
12use SqlSemantics\Statement\Element;
13use SqlSemantics\Statement\Statement;
14
15/**
16 * Lowers transient parser nodes into typed SQL arguments and finite options.
17 *
18 * A comment is kept by the outermost value whose symbol begins with the token
19 * the comment was written before. Comments before the first token and after
20 * the last one belong to the statement.
21 *
22 * @phpstan-type Recipe array{forward: int}|array{constant: string}|array{class: class-string<Element>, fields: list<int>}
23 * @visibility SqlSemantics
24 */
25final class ValueReader
26{
27    use Assertion;
28
29    private readonly Comments $none;
30
31    /**
32     * @param array<string, array<int, Recipe>> $recipes Complete construction vocabulary
33     * @param TriviaReader $trivia Reads the comments of the language
34     */
35    public function __construct(private readonly array $recipes, private readonly TriviaReader $trivia = new TriviaReader())
36    {
37        $this->none = new Comments();
38    }
39
40    /**
41     * Loads the construction vocabulary supplied by a database package.
42     *
43     * @param TriviaReader|null $trivia Reads the comments of the language, or null to read them as plain block and line comments
44     *
45     * @throws LogicException When generated resources are missing or invalid
46     */
47    public static function fromFile(string $path, ?TriviaReader $trivia = null): self
48    {
49        if (!is_file($path)) {
50            throw new LogicException('Missing statement model resource: ' . $path);
51        }
52        $reader = require $path;
53        if (!$reader instanceof self) {
54            throw new LogicException('Invalid statement model resource: ' . $path);
55        }
56
57        return $trivia === null ? $reader : new self($reader->recipes, $trivia);
58    }
59
60    /**
61     * Lowers a complete parse tree into a statement that keeps its comments, and discards the tree.
62     *
63     * @throws LogicException When parser and model resources disagree
64     */
65    public function statement(Node $root): Statement
66    {
67        $comments = $this->trivia->read($root);
68        $command = $this->lower($root, $comments, true);
69        $this->assertCompleteCommand($command);
70        $around = [];
71        if ($comments->leading !== []) {
72            $around[Statement::BEFORE] = $comments->leading;
73        }
74        if ($comments->trailing !== []) {
75            $around[Statement::AFTER] = $comments->trailing;
76        }
77
78        return new Statement($command, $around === [] ? $this->none : new Comments($around));
79    }
80
81    /**
82     * Discards the input node after assigning its values to their named fields.
83     *
84     * Comments before the first token and after the last one are not kept; use
85     * statement() for a complete tree.
86     *
87     * @throws LogicException When parser and model resources disagree
88     */
89    public function read(Node $node): Element
90    {
91        return $this->lower($node, $this->trivia->read($node), true);
92    }
93
94    /**
95     * Builds the value of a node, keeping the comments of the symbols it owns.
96     *
97     * @param SourceComments $comments The comments of the tree the node belongs to
98     * @param bool $firstKept Whether an enclosing value or the statement already keeps the comments before the first token
99     *
100     * @throws LogicException When parser and model resources disagree
101     */
102    public function lower(Node $node, SourceComments $comments, bool $firstKept): Element
103    {
104        $recipe = $this->recipes[$node->name][$node->ordinal] ?? null;
105        if ($recipe === null) {
106            throw new LogicException('Parser/model resource mismatch at ' . $node->name . ':' . $node->ordinal);
107        }
108        if (isset($recipe['forward'])) {
109            $child = $node->children[$recipe['forward']] ?? null;
110            if (!$child instanceof Node) {
111                throw new LogicException('Forwarding model requires a structured value');
112            }
113
114            return $this->lower($child, $comments, $firstKept);
115        }
116        if (isset($recipe['constant'])) {
117            $choice = constant($recipe['constant']);
118            if (!$choice instanceof Element) {
119                throw new LogicException('A model choice must implement Element');
120            }
121
122            return $choice;
123        }
124        $positions = [];
125        $begins = [];
126        foreach ($node->children as $index => $child) {
127            $token = $child instanceof Token ? $child : $comments->first($child);
128            if ($token === null) {
129                continue;
130            }
131            $begins[$index] = true;
132            if ($firstKept) {
133                $firstKept = false;
134                continue;
135            }
136            $texts = $comments->before($token);
137            if ($texts !== []) {
138                $positions[$index] = $texts;
139            }
140        }
141        $arguments = [];
142        foreach ($recipe['fields'] as $index) {
143            $child = $node->children[$index] ?? null;
144            if ($child === null) {
145                throw new LogicException('Missing model argument ' . $index . ' in ' . $node->name);
146            }
147            $arguments[] = $child instanceof Token ? $child->text : $this->lower($child, $comments, isset($begins[$index]));
148        }
149
150        return new ($recipe['class'])(...$arguments, comments: $positions === [] ? $this->none : new Comments($positions));
151    }
152}
153