Passed
Push — master ( daa35d...1046d9 )
by Anton
01:34
created

Parser::__clone()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 1
dl 0
loc 3
rs 10
c 0
b 0
f 0
cc 1
nc 1
nop 0
1
<?php
2
declare(strict_types=1);
3
/**
4
 * Spiral Framework.
5
 *
6
 * @license   MIT
7
 * @author    Anton Titov (Wolfy-J)
8
 */
9
10
namespace Spiral\Annotations;
11
12
use Spiral\Annotations\Exception\AttributeException;
13
use Spiral\Annotations\Exception\ParserException;
14
use Spiral\Annotations\Exception\SyntaxException;
15
use Spiral\Annotations\Exception\ValueException;
16
17
/**
18
 * Parser parses docComments using list of node enter-points. Each node must define list of it's attributes. Attribute
19
 * can point to nested node or array or nested nodes which will be parsed recursively.
20
 */
21
final class Parser
22
{
23
    // Embedded node types
24
    public const STRING  = 1;
25
    public const INTEGER = 2;
26
    public const FLOAT   = 3;
27
    public const BOOL    = 4;
28
    public const MIXED   = 5;
29
30
    /** @var DocLexer */
31
    private $lexer;
32
33
    /** @var AnnotationInterface[] */
34
    private $annotations = [];
35
36
    /** @var string */
37
    private $context = '';
38
39
    /**
40
     * @param DocLexer $lexer
41
     */
42
    public function __construct(DocLexer $lexer = null)
43
    {
44
        $this->lexer = $lexer ?? new DocLexer();
45
    }
46
47
    /**
48
     * Clone the parser.
49
     */
50
    public function __clone()
51
    {
52
        $this->lexer = clone $this->lexer;
53
    }
54
55
    /**
56
     * Register new enter-point annotation. Each parsed node will be based on cloned copy of provided enter-point.
57
     *
58
     * @param AnnotationInterface $annotation
59
     * @return self
60
     *
61
     * @throws ParserException
62
     */
63
    public function register(AnnotationInterface $annotation): self
64
    {
65
        if (isset($this->annotations[$annotation->getName()])) {
66
            throw new ParserException("Node with name {$annotation->getName()} already registered");
67
        }
68
69
        $this->annotations[$annotation->getName()] = $annotation;
70
        return $this;
71
    }
72
73
    /**
74
     * Parse given docComment and return named list of captured nodes. If node has been found more than
75
     * once in a target comment - parse method will return named array.
76
     *
77
     * @param string $body
78
     * @return array
79
     *
80
     * @throws ParserException
81
     */
82
    public function parse(string $body): array
83
    {
84
        $this->context = $body;
85
86
        if ($this->annotations === []) {
87
            throw new ParserException("Unable to parse without starting nodes");
88
        }
89
90
        $this->lexer->setInput(trim(substr($body, $this->findStart($body)), '* /'));
91
        $this->lexer->moveNext();
92
93
        $result = [];
94
        foreach ($this->iterate() as $name => $node) {
95
            if (!isset($result[$name])) {
96
                $result[$name] = $node;
97
                continue;
98
            }
99
100
            // multiple occasions
101
            if (!is_array($result[$name])) {
102
                $result[$name] = [$result[$name]];
103
            }
104
105
            $result[$name][] = $node;
106
        }
107
108
        return $result;
109
    }
110
111
    /**
112
     * Finds the first valid annotation
113
     *
114
     * @param string $input The docblock string to parse
115
     * @return int|null
116
     */
117
    private function findStart(string $input): ?int
118
    {
119
        $pos = 0;
120
121
        // search for first valid annotation
122
        while (($pos = strpos($input, '@', $pos)) !== false) {
123
            $preceding = substr($input, $pos - 1, 1);
124
125
            // if the @ is preceded by a space, a tab or * it is valid
126
            if ($pos === 0 || $preceding === ' ' || $preceding === '*' || $preceding === "\t") {
127
                return $pos;
128
            }
129
130
            $pos++;
131
        }
132
133
        return null;
134
    }
135
136
    /**
137
     * Iterate over all node definitions.
138
     *
139
     * @return \Generator
140
     */
141
    private function iterate(): \Generator
142
    {
143
        while ($this->lexer->lookahead !== null) {
144
            // current token
145
            $t = $this->lexer->token;
146
147
            // next token
148
            $n = $this->lexer->lookahead;
149
150
            // looking for initial token
151
            if ($n['type'] !== DocLexer::T_AT) {
152
                $this->lexer->moveNext();
153
                continue;
154
            }
155
156
            // make sure that @ points to identifier
157
            if ($t !== null && $n['position'] === $t['position'] + strlen($t['value'])) {
158
                $this->lexer->moveNext();
159
                continue;
160
            }
161
162
            yield from $this->node();
163
        }
164
    }
165
166
    /**
167
     * Parse node definition.
168
     *
169
     * @return \Generator
170
     */
171
    private function node(): \Generator
172
    {
173
        $this->match([DocLexer::T_AT]);
174
175
        // check if we have an annotation
176
        $name = $this->identifier();
177
178
        if (!isset($this->annotations[$name])) {
179
            // undefined node or not a node at all
180
            return;
181
        }
182
183
        /** @var AnnotationInterface $ann */
184
        $ann = clone $this->annotations[$name];
185
186
        if ($this->lexer->isNextToken(DocLexer::T_OPEN_PARENTHESIS)) {
187
            foreach ($this->attributes($this->annotations[$name]) as $attribute => $value) {
188
                $ann->setAttribute($attribute, $value);
189
            }
190
        }
191
192
        yield $name => $ann;
193
    }
194
195
    /**
196
     * Parse node attributes;
197
     *
198
     * @param AnnotationInterface $node
199
     * @return \Generator
200
     */
201
    private function attributes(AnnotationInterface $node): \Generator
202
    {
203
        $this->match([DocLexer::T_OPEN_PARENTHESIS]);
204
205
        // Parsing thought list of attributes
206
        while ($this->lexer->lookahead !== null) {
207
            if ($this->lexer->isNextToken(DocLexer::T_CLOSE_PARENTHESIS)) {
208
                $this->lexer->moveNext();
209
210
                // done with attributes definition
211
                return;
212
            }
213
214
            if ($this->lexer->isNextToken(DocLexer::T_COMMA)) {
215
                $this->lexer->moveNext();
216
217
                // next attribute
218
                continue;
219
            }
220
221
            $name = $this->identifier();
222
            $this->match([DocLexer::T_EQUALS]);
223
224
            if (!isset($node->getSchema()[$name])) {
225
                throw new AttributeException(sprintf(
226
                    "Undefined node attribute %s->%s",
227
                    get_class($node),
228
                    $name
229
                ));
230
            }
231
232
            try {
233
                yield $name => $this->value($node->getSchema()[$name]);
234
            } catch (ValueException $e) {
235
                throw new AttributeException(
236
                    sprintf("Invalid attribute %s.%s: %s", get_class($node), $name, $e->getMessage()),
237
                    0,
238
                    $e
239
                );
240
            }
241
        }
242
    }
243
244
    /**
245
     * Parse single value definition (including nested values).
246
     *
247
     * @param mixed $type Expected value type.
248
     * @return mixed
249
     */
250
    private function value($type)
251
    {
252
        if (is_array($type)) {
253
            return iterator_to_array($this->array(current($type)));
254
        }
255
256
        if ($type instanceof AnnotationInterface) {
257
            // name clarification (Doctrine like)
258
            if ($this->lexer->isNextToken(DocLexer::T_AT)) {
259
                $this->lexer->moveNext();
260
261
                $name = $this->identifier();
262
                if ($name != $type->getName()) {
263
                    throw new AttributeException(sprintf(
264
                        "Expected node type %s given %s",
265
                        $type->getName(),
266
                        $name
267
                    ));
268
                }
269
            }
270
271
            $ann = clone $type;
272
            foreach ($this->attributes($ann) as $attribute => $value) {
273
                $ann->setAttribute($attribute, $value);
274
            }
275
276
            return $ann;
277
        }
278
279
        return $this->filter($this->rawValue(), $type);
280
    }
281
282
    /**
283
     * Ensure value type of throw an error.
284
     *
285
     * @param mixed $value
286
     * @param int   $type
287
     * @return mixed
288
     */
289
    private function filter($value, int $type)
290
    {
291
        switch ($type) {
292
            case self::INTEGER:
293
                if (!is_integer($value)) {
294
                    throw new ValueException("value `{$value}` must be integer");
295
                }
296
297
                return (int)$value;
298
299
            case self::FLOAT:
300
                if (!is_float($value)) {
301
                    throw new ValueException("value `{$value}` must be float");
302
                }
303
304
                return (float)$value;
305
306
            case self::BOOL:
307
                if (!is_bool($value)) {
308
                    throw new ValueException("value `{$value}` must be boolean");
309
                }
310
311
                return (bool)$value;
312
        }
313
314
        return $value;
315
    }
316
317
    /**
318
     * Parse array definition.
319
     *
320
     * @param mixed $type
321
     * @return \Generator
322
     */
323
    private function array($type): \Generator
324
    {
325
        $this->match([DocLexer::T_OPEN_CURLY_BRACES]);
326
327
        while ($this->lexer->lookahead !== null) {
328
329
            if ($this->lexer->isNextToken(DocLexer::T_CLOSE_CURLY_BRACES)) {
330
                $this->lexer->moveNext();
331
332
                // done with node definition
333
                return;
334
            }
335
336
            if ($this->lexer->isNextToken(DocLexer::T_COMMA)) {
337
                $this->lexer->moveNext();
338
339
                // next element
340
                continue;
341
            }
342
343
            $next = $this->lexer->glimpse();
344
            if (is_array($next) && $next['type'] === DocLexer::T_COLON) {
345
                $key = $this->rawValue();
346
                $this->match([DocLexer::T_COLON]);
347
348
                // indexed element
349
                yield $key => $this->value($type);
350
                continue;
351
            }
352
353
            // un-indexed element
354
            yield $this->value($type);
355
        }
356
    }
357
358
    /**
359
     * Parse simple raw value definition.
360
     *
361
     * @return bool|float|int|string|null
362
     * @return mixed
363
     *
364
     * @throws SyntaxException
365
     */
366
    private function rawValue()
367
    {
368
        if ($this->lexer->isNextToken(DocLexer::T_IDENTIFIER)) {
369
            return $this->identifier();
370
        }
371
372
        switch ($this->lexer->lookahead['type']) {
373
            case DocLexer::T_STRING:
374
                $this->match([DocLexer::T_STRING]);
375
                return $this->lexer->token['value'];
376
377
            case DocLexer::T_INTEGER:
378
                $this->match([DocLexer::T_INTEGER]);
379
                return (int)$this->lexer->token['value'];
380
381
            case DocLexer::T_FLOAT:
382
                $this->match([DocLexer::T_FLOAT]);
383
                return (float)$this->lexer->token['value'];
384
385
            case DocLexer::T_TRUE:
386
                $this->match([DocLexer::T_TRUE]);
387
                return true;
388
389
            case DocLexer::T_FALSE:
390
                $this->match([DocLexer::T_FALSE]);
391
                return false;
392
393
            case DocLexer::T_NULL:
394
                $this->match([DocLexer::T_NULL]);
395
                return null;
396
        }
397
398
        throw $this->syntaxError(
399
            [
400
                DocLexer::T_NULL,
401
                DocLexer::T_FALSE,
402
                DocLexer::T_TRUE,
403
                DocLexer::T_FLOAT,
404
                DocLexer::T_INTEGER,
405
                DocLexer::T_STRING
406
            ],
407
            $this->lexer->lookahead
408
        );
409
    }
410
411
    /**
412
     * Fetch name identifier (string value).
413
     *
414
     * @return string
415
     *
416
     * @throws SyntaxException
417
     */
418
    private function identifier(): string
419
    {
420
        if (!$this->lexer->isNextTokenAny([DocLexer::T_STRING, DocLexer::T_IDENTIFIER])) {
421
            throw $this->syntaxError([DocLexer::T_STRING, DocLexer::T_IDENTIFIER]);
422
        }
423
424
        $this->lexer->moveNext();
425
        return $this->lexer->token['value'];
426
    }
427
428
    /**
429
     * Attempts to match the given token with the current lookahead token.
430
     * If they match, updates the lookahead token; otherwise raises a syntax error.
431
     *
432
     * @param array $expected Type of token.
433
     * @return boolean True if tokens match; false otherwise.
434
     *
435
     * @throws SyntaxException
436
     */
437
    private function match(array $expected): bool
438
    {
439
        if (!$this->lexer->isNextTokenAny($expected)) {
440
            throw $this->syntaxError($expected, $this->lexer->lookahead);
441
        }
442
443
        return $this->lexer->moveNext();
444
    }
445
446
    /**
447
     * Throw syntax exception.
448
     *
449
     * @param array      $expected
450
     * @param null|array $token
451
     * @return SyntaxException
452
     */
453
    private function syntaxError(array $expected, array $token = null): SyntaxException
454
    {
455
        if ($token === null) {
456
            $token = $this->lexer->lookahead;
457
        }
458
459
        foreach ($expected as &$ex) {
460
            $ex = DocLexer::TOKEN_MAP[$ex];
461
            unset($ex);
462
        }
463
464
        $message = sprintf(
465
            'Expected %s, got %s in %s',
466
            join('|', $expected),
467
            ($this->lexer->lookahead === null)
468
                ? 'end of string'
469
                : sprintf("'%s' at position %s", $token['value'], $token['position']),
470
            $this->context
471
        );
472
473
        return new SyntaxException($message);
474
    }
475
}
476