Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.95% covered (success)
98.95%
188 / 190
88.89% covered (warning)
88.89%
16 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
QueryLexer
98.95% covered (success)
98.95%
188 / 190
88.89% covered (warning)
88.89%
16 / 18
95
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 tokens
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 scan
100.00% covered (success)
100.00%
79 / 79
100.00% covered (success)
100.00%
1 / 1
36
 addOperator
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addBooleanOperator
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isIdentifier
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 consumeIdentifier
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
14
 consumeNumber
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
7
 consumeString
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
7.01
 advance
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 peek
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 peekNext
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 matchNext
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 atEnd
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addToken
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getLexeme
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getIdentifierType
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
13
 error
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Finder;
6
7use Cosray\Exception\ParserException;
8use Cosray\Finder\Input\Token;
9use Cosray\Finder\Input\TokenGroup;
10use Cosray\Finder\Input\TokenType;
11
12final class QueryLexer
13{
14    private int $start = 0;
15    private int $current = 0;
16
17    /** @var list<Token> */
18    private array $tokens = [];
19
20    private string $query;
21    /** @var list<string> */
22    private array $source;
23    private int $length;
24
25    /** @param list<string> $builtins */
26    public function __construct(
27        private readonly array $builtins = [],
28    ) {}
29
30    /** @return list<Token> */
31    public function tokens(string $query): array
32    {
33        $this->query = $query;
34        $this->source = mb_str_split($query);
35        $this->length = count($this->source);
36
37        while (!$this->atEnd()) {
38            $this->start = $this->current;
39            $this->scan();
40        }
41
42        return $this->tokens;
43    }
44
45    private function scan(): void
46    {
47        $char = $this->advance();
48
49        switch ($char) {
50            case ' ':
51            case ',': // comma is ignored that it can be used inside of lists
52            case "\t":
53            case "\n":
54            case "\r":
55                break;
56            case '(':
57                $this->addToken(TokenGroup::LeftParen, TokenType::LeftParen);
58                break;
59            case ')':
60                $this->addToken(TokenGroup::RightParen, TokenType::RightParen);
61                break;
62            case '[':
63                $this->addToken(TokenGroup::LeftBracket, TokenType::LeftBracket);
64                break;
65            case ']':
66                $this->addToken(TokenGroup::RightBracket, TokenType::RightBracket);
67                break;
68            case '&':
69                $this->addBooleanOperator(TokenType::And);
70                break;
71            case '|':
72                $this->addBooleanOperator(TokenType::Or);
73                break;
74            case '=':
75                $this->addOperator(TokenType::Equal);
76                break;
77            case '@':
78                $this->addOperator(TokenType::In);
79                break;
80            case '~':
81                if ($this->matchNext('~')) {
82                    if ($this->matchNext('*')) {
83                        // ~~*
84                        $this->addOperator(TokenType::ILike);
85                    } else {
86                        // ~~
87                        $this->addOperator(TokenType::Like);
88                    }
89                } elseif ($this->matchNext('*')) {
90                    // ~*
91                    $this->addOperator(TokenType::IRegex);
92                } else {
93                    // ~
94                    $this->addOperator(TokenType::Regex);
95                }
96                break;
97            case '!':
98                if ($this->matchNext('=')) {
99                    // !=
100                    $this->addOperator(TokenType::Unequal);
101                } elseif ($this->matchNext('@')) {
102                    $this->addOperator(TokenType::NotIn);
103                } elseif ($this->matchNext('~')) {
104                    if ($this->matchNext('~')) {
105                        if ($this->matchNext('*')) {
106                            // !~~*
107                            $this->addOperator(TokenType::IUnlike);
108                        } else {
109                            // !~~
110                            $this->addOperator(TokenType::Unlike);
111                        }
112                    } elseif ($this->matchNext('*')) {
113                        // !~*
114                        $this->addOperator(TokenType::INotRegex);
115                    } else {
116                        // !~
117                        $this->addOperator(TokenType::NotRegex);
118                    }
119                } else {
120                    $this->error(
121                        "Invalid operator '!'. "
122                            . "It can only be used in combination with '=' "
123                            . "and '~', i. e. '!=' and '!~'",
124                    );
125                }
126                break;
127            case '>':
128                if ($this->matchNext('=')) {
129                    $this->addOperator(TokenType::GreaterEqual);
130                } else {
131                    $this->addOperator(TokenType::Greater);
132                }
133                break;
134            case '<':
135                if ($this->matchNext('=')) {
136                    $this->addOperator(TokenType::LessEqual);
137                } else {
138                    $this->addOperator(TokenType::Less);
139                }
140                break;
141            case '"':
142            case "'":
143            case '/':
144                $this->consumeString($char);
145                break;
146            default:
147                if (is_numeric($char) || $char === '-') {
148                    $this->consumeNumber($char);
149                } elseif ($this->isIdentifier($char)) {
150                    $this->consumeIdentifier();
151                } else {
152                    $this->error("Syntax error, unknown character '{$char}'");
153                }
154        }
155    }
156
157    private function addOperator(TokenType $type): void
158    {
159        $this->addToken(TokenGroup::Operator, $type);
160    }
161
162    private function addBooleanOperator(TokenType $type): void
163    {
164        $this->addToken(TokenGroup::BooleanOperator, $type);
165    }
166
167    private function isIdentifier(string $char): bool
168    {
169        return ctype_alpha($char);
170    }
171
172    private function consumeIdentifier(): void
173    {
174        $wasDot = false;
175        $wasSpecial = false;
176
177        while (true) {
178            $char = $this->peek();
179            $isDot = $char === '.';
180            $isSpecial = $char === '*' || $char === '?';
181
182            $valid =
183                ctype_alpha($char)
184                || ctype_digit($char)
185                || $char === '_'
186                || $char === '-'
187                || $isDot
188                || $wasDot && $isSpecial;
189
190            if ($valid && $wasSpecial && !$isDot) {
191                $this->error('Invalid use of special character (like ? or *) in identifier.');
192            }
193
194            $wasDot = $isDot;
195            $wasSpecial = $isSpecial;
196
197            if ($valid && !$this->atEnd()) {
198                $this->advance();
199            } else {
200                $lexeme = $this->getLexeme();
201                $type = $this->getIdentifierType($lexeme);
202                $this->tokens[] = new Token(TokenGroup::Operand, $type, $this->start, $lexeme);
203
204                break;
205            }
206        }
207    }
208
209    private function consumeNumber(string $char): void
210    {
211        if ($char === '-') {
212            if (!is_numeric($this->peek())) {
213                $this->error("Syntax error, unknown character '-'");
214            }
215        }
216
217        while (is_numeric($this->peek())) {
218            $this->advance();
219        }
220
221        if ($this->peek() === '.') {
222            $this->advance();
223            $hasFraction = false;
224
225            while (is_numeric($this->peek())) {
226                $hasFraction = true;
227                $this->advance();
228            }
229
230            if (!$hasFraction) {
231                $this->error('Invalid number.');
232            }
233        }
234
235        $this->addToken(TokenGroup::Operand, TokenType::Number);
236    }
237
238    private function consumeString(string $char): void
239    {
240        while ($this->peek() !== $char && !$this->atEnd()) {
241            if ($this->peek() === '\\' && $this->peekNext() === $char) {
242                $this->advance();
243            }
244
245            $this->advance();
246        }
247
248        if ($this->atEnd()) {
249            $this->error('Unterminated string.');
250        }
251
252        // Hop to the closing "
253        $this->advance();
254
255        if ($this->start === $this->current) {
256            $lexeme = '';
257        } else {
258            $start = $this->start + 1;
259            $length = $this->current - $this->start - 2;
260            $slice = array_slice($this->source, $start, $length);
261            $lexeme = implode('', $slice);
262        }
263
264        // $this->afterValue = true;
265        $this->tokens[] = new Token(
266            TokenGroup::Operand,
267            TokenType::String,
268            $this->start,
269            str_replace('\\' . $char, $char, $lexeme),
270        );
271    }
272
273    private function advance(): string
274    {
275        $result = $this->source[$this->current];
276        $this->current++;
277
278        return $result;
279    }
280
281    private function peek(): string
282    {
283        if ($this->atEnd()) {
284            return '';
285        }
286
287        return $this->source[$this->current];
288    }
289
290    private function peekNext(): string
291    {
292        if (($this->current + 1) > ($this->length - 1)) {
293            return '';
294        }
295
296        return $this->source[$this->current + 1];
297    }
298
299    /** @phpstan-impure */
300    private function matchNext(string $expected): bool
301    {
302        if ($this->atEnd()) {
303            return false;
304        }
305
306        if ($this->source[$this->current] === $expected) {
307            $this->current++;
308
309            return true;
310        }
311
312        return false;
313    }
314
315    private function atEnd(): bool
316    {
317        return $this->current > ($this->length - 1);
318    }
319
320    private function addToken(TokenGroup $group, TokenType $type): void
321    {
322        $lexeme = $this->getLexeme();
323        $this->tokens[] = new Token($group, $type, $this->start, $lexeme);
324    }
325
326    private function getLexeme(): string
327    {
328        $length = $this->current - $this->start;
329        $slice = array_slice($this->source, $this->start, $length);
330
331        return implode('', $slice);
332    }
333
334    private function getIdentifierType(string $lexeme): TokenType
335    {
336        if (str_ends_with($lexeme, '.') || str_contains($lexeme, '..')) {
337            $this->error('Invalid use of dot (.) in indentifier');
338        }
339
340        switch ($lexeme) {
341            case 'true':
342            case 'false':
343                return TokenType::Boolean;
344            case 'null':
345                return TokenType::Null;
346            case 'now':
347                return TokenType::Keyword;
348            default:
349                if ($lexeme === 'path' || str_starts_with($lexeme, 'path.')) {
350                    return TokenType::Path;
351                }
352
353                if ($lexeme === 'references' || str_starts_with($lexeme, 'references.')) {
354                    return TokenType::Reference;
355                }
356
357                if (in_array($lexeme, $this->builtins, true)) {
358                    return TokenType::Builtin;
359                }
360
361                return TokenType::Field;
362        }
363    }
364
365    /**
366     * @throws ParserException
367     */
368    private function error(string $msg): never
369    {
370        throw new ParserException(
371            "Parse error at position {$this->start}. {$msg}\n\n"
372                . "Query: `{$this->query}`\n"
373                . str_repeat(' ', $this->start + 8)
374                . str_repeat('^', $this->current - $this->start)
375                . "\n\n",
376        );
377    }
378}