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