Completed
Push — master ( 58a355...7779cf )
by Mike
02:36
created

DocumentParser::mergeIncludedFiles()   A

Complexity

Conditions 3
Paths 1

Size

Total Lines 25

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
nc 1
nop 1
dl 0
loc 25
rs 9.52
c 0
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
namespace phpDocumentor\Guides\RestructuredText\Parser;
6
7
use Doctrine\Common\EventManager;
8
use Exception;
9
use phpDocumentor\Guides\Environment;
10
use phpDocumentor\Guides\Nodes\AnchorNode;
11
use phpDocumentor\Guides\Nodes\BlockNode;
12
use phpDocumentor\Guides\Nodes\CodeNode;
13
use phpDocumentor\Guides\Nodes\DefinitionListNode;
14
use phpDocumentor\Guides\Nodes\DocumentNode;
15
use phpDocumentor\Guides\Nodes\ListNode;
16
use phpDocumentor\Guides\Nodes\Node;
17
use phpDocumentor\Guides\Nodes\ParagraphNode;
18
use phpDocumentor\Guides\Nodes\QuoteNode;
19
use phpDocumentor\Guides\Nodes\SectionBeginNode;
20
use phpDocumentor\Guides\Nodes\SectionEndNode;
21
use phpDocumentor\Guides\Nodes\SeparatorNode;
22
use phpDocumentor\Guides\Nodes\SpanNode;
23
use phpDocumentor\Guides\Nodes\TableNode;
24
use phpDocumentor\Guides\Nodes\TitleNode;
25
use phpDocumentor\Guides\RestructuredText\Directives\Directive;
0 ignored issues
show
Bug introduced by
This use statement conflicts with another class in this namespace, phpDocumentor\Guides\Res...edText\Parser\Directive.

Let’s assume that you have a directory layout like this:

.
|-- OtherDir
|   |-- Bar.php
|   `-- Foo.php
`-- SomeDir
    `-- Foo.php

and let’s assume the following content of Bar.php:

// Bar.php
namespace OtherDir;

use SomeDir\Foo; // This now conflicts the class OtherDir\Foo

If both files OtherDir/Foo.php and SomeDir/Foo.php are loaded in the same runtime, you will see a PHP error such as the following:

PHP Fatal error:  Cannot use SomeDir\Foo as Foo because the name is already in use in OtherDir/Foo.php

However, as OtherDir/Foo.php does not necessarily have to be loaded and the error is only triggered if it is loaded before OtherDir/Bar.php, this problem might go unnoticed for a while. In order to prevent this error from surfacing, you must import the namespace with a different alias:

// Bar.php
namespace OtherDir;

use SomeDir\Foo as SomeDirFoo; // There is no conflict anymore.
Loading history...
26
use phpDocumentor\Guides\RestructuredText\Event\PostParseDocumentEvent;
27
use phpDocumentor\Guides\RestructuredText\Event\PreParseDocumentEvent;
28
use phpDocumentor\Guides\RestructuredText\Parser;
29
use phpDocumentor\Guides\RestructuredText\Parser\Directive as ParserDirective;
30
use RuntimeException;
31
use Throwable;
32
use function array_search;
33
use function chr;
34
use function explode;
35
use function preg_replace_callback;
36
use function sprintf;
37
use function str_replace;
38
use function strlen;
39
use function substr;
40
use function trim;
41
42
class DocumentParser
43
{
44
    /** @var Parser */
45
    private $parser;
46
47
    /** @var Environment */
48
    private $environment;
49
50
    /** @var EventManager */
51
    private $eventManager;
52
53
    /** @var Directive[] */
54
    private $directives;
55
56
    /** @var DocumentNode */
57
    private $document;
58
59
    /** @var false|string|null */
60
    private $specialLetter;
61
62
    /** @var ParserDirective|null */
63
    private $directive;
64
65
    /** @var LineDataParser */
66
    private $lineDataParser;
67
68
    /** @var LineChecker */
69
    private $lineChecker;
70
71
    /** @var TableParser */
72
    private $tableParser;
73
74
    /** @var Buffer */
75
    private $buffer;
76
77
    /** @var Node|null */
78
    private $nodeBuffer;
79
80
    /** @var bool */
81
    private $isCode = false;
82
83
    /** @var Lines */
84
    private $lines;
85
86
    /** @var string */
87
    private $state;
88
89
    /** @var ListLine|null */
90
    private $listLine;
91
92
    /** @var bool */
93
    private $listFlow = false;
94
95
    /** @var TitleNode */
96
    private $lastTitleNode;
97
98
    /** @var TitleNode[] */
99
    private $openTitleNodes = [];
100
101
    /**
102
     * @param Directive[] $directives
103
     */
104
    public function __construct(
105
        Parser $parser,
106
        EventManager $eventManager,
107
        array $directives
108
    ) {
109
        $this->parser = $parser;
110
        $this->environment = $parser->getEnvironment();
111
        $this->eventManager = $eventManager;
112
        $this->directives = $directives;
113
        $this->lineDataParser = new LineDataParser($this->parser, $eventManager);
114
        $this->lineChecker = new LineChecker($this->lineDataParser);
115
        $this->tableParser = new TableParser();
116
        $this->buffer = new Buffer();
117
    }
118
119
    public function getDocument() : DocumentNode
120
    {
121
        return $this->document;
122
    }
123
124
    public function parse(string $contents) : DocumentNode
125
    {
126
        $preParseDocumentEvent = new PreParseDocumentEvent($this->parser, $contents);
127
128
        $this->eventManager->dispatchEvent(
129
            PreParseDocumentEvent::PRE_PARSE_DOCUMENT,
130
            $preParseDocumentEvent
131
        );
132
133
        $this->document = new DocumentNode($this->environment);
134
135
        $this->init();
136
137
        $this->parseLines(trim($preParseDocumentEvent->getContents()));
138
139
        foreach ($this->directives as $directive) {
140
            $directive->finalize($this->document);
141
        }
142
143
        $this->eventManager->dispatchEvent(
144
            PostParseDocumentEvent::POST_PARSE_DOCUMENT,
145
            new PostParseDocumentEvent($this->document)
146
        );
147
148
        return $this->document;
149
    }
150
151
    private function init() : void
152
    {
153
        $this->specialLetter = false;
154
        $this->buffer = new Buffer();
155
        $this->nodeBuffer = null;
156
    }
157
158
    private function setState(string $state) : void
159
    {
160
        $this->state = $state;
161
    }
162
163
    private function prepareDocument(string $document) : string
164
    {
165
        $document = str_replace("\r\n", "\n", $document);
166
        $document = sprintf("\n%s\n", $document);
167
168
        $document = $this->mergeIncludedFiles($document);
169
170
        // Removing UTF-8 BOM
171
        $document = str_replace("\xef\xbb\xbf", '', $document);
172
173
        // Replace \u00a0 with " "
174
        $document = str_replace(chr(194) . chr(160), ' ', $document);
175
176
        return $document;
177
    }
178
179
    private function createLines(string $document) : Lines
180
    {
181
        return new Lines(explode("\n", $document));
182
    }
183
184
    private function parseLines(string $document) : void
185
    {
186
        $document = $this->prepareDocument($document);
187
188
        $this->lines = $this->createLines($document);
189
        $this->setState(State::BEGIN);
190
191
        foreach ($this->lines as $line) {
192
            while (true) {
193
                if ($this->parseLine($line)) {
194
                    break;
195
                }
196
            }
197
        }
198
199
        // DocumentNode is flushed twice to trigger the directives
200
        $this->flush();
201
        $this->flush();
202
203
        foreach ($this->openTitleNodes as $titleNode) {
204
            $this->endOpenSection($titleNode);
205
        }
206
    }
207
208
    private function parseLine(string $line) : bool
209
    {
210
        switch ($this->state) {
211
            case State::BEGIN:
212
                if (trim($line) !== '') {
213
                    if ($this->lineChecker->isListLine($line, $this->isCode)) {
214
                        $this->setState(State::LIST);
215
216
                        $listNode = new ListNode();
217
218
                        $this->nodeBuffer = $listNode;
219
220
                        $this->listLine = null;
221
                        $this->listFlow = true;
222
223
                        return false;
224
                    }
225
226
                    if ($this->lineChecker->isBlockLine($line)) {
227
                        if ($this->isCode) {
228
                            $this->setState(State::CODE);
229
                        } else {
230
                            $this->setState(State::BLOCK);
231
                        }
232
233
                        return false;
234
                    }
235
236
                    if ($this->parseLink($line)) {
237
                        return true;
238
                    }
239
240
                    if ($this->lineChecker->isDirective($line)) {
241
                        $this->setState(State::DIRECTIVE);
242
                        $this->buffer = new Buffer();
243
                        $this->flush();
244
                        $this->initDirective($line);
245
                    } elseif ($this->lineChecker->isDefinitionList($this->lines->getNextLine())) {
246
                        $this->setState(State::DEFINITION_LIST);
247
                        $this->buffer->push($line);
248
249
                        return true;
250
                    } else {
251
                        $separatorLineConfig = $this->tableParser->parseTableSeparatorLine($line);
252
253
                        if ($separatorLineConfig === null) {
254
                            $this->setState(State::NORMAL);
255
256
                            return false;
257
                        }
258
259
                        $this->setState(State::TABLE);
260
261
                        $tableNode = new TableNode(
262
                            $separatorLineConfig,
263
                            $this->tableParser->guessTableType($line),
264
                            $this->lineChecker
265
                        );
266
267
                        $this->nodeBuffer = $tableNode;
268
                    }
269
                }
270
271
                break;
272
273
            case State::LIST:
274
                if (!$this->parseListLine($line)) {
275
                    $this->flush();
276
                    $this->setState(State::BEGIN);
277
278
                    return false;
279
                }
280
281
                break;
282
283
            case State::DEFINITION_LIST:
284
                if ($this->lineChecker->isDefinitionListEnded($line, $this->lines->getNextLine())) {
285
                    $this->flush();
286
                    $this->setState(State::BEGIN);
287
288
                    return false;
289
                }
290
291
                $this->buffer->push($line);
292
293
                break;
294
295
            case State::TABLE:
296
                if (trim($line) === '') {
297
                    $this->flush();
298
                    $this->setState(State::BEGIN);
299
                } else {
300
                    $separatorLineConfig = $this->tableParser->parseTableSeparatorLine($line);
301
302
                    // not sure if this is possible, being cautious
303
                    if (!$this->nodeBuffer instanceof TableNode) {
304
                        throw new Exception('Node Buffer should be a TableNode instance');
305
                    }
306
307
                    // push the separator or content line onto the TableNode
308
                    if ($separatorLineConfig !== null) {
309
                        $this->nodeBuffer->pushSeparatorLine($separatorLineConfig);
310
                    } else {
311
                        $this->nodeBuffer->pushContentLine($line);
312
                    }
313
                }
314
315
                break;
316
317
            case State::NORMAL:
318
                if (trim($line) !== '') {
319
                    $specialLetter = $this->lineChecker->isSpecialLine($line);
320
321
                    if ($specialLetter !== null) {
322
                        $this->specialLetter = $specialLetter;
323
324
                        $lastLine = $this->buffer->pop();
325
326
                        if ($lastLine !== null) {
327
                            $this->buffer = new Buffer([$lastLine]);
328
                            $this->setState(State::TITLE);
329
                        } else {
330
                            $this->buffer->push($line);
331
                            $this->setState(State::SEPARATOR);
332
                        }
333
334
                        $this->flush();
335
                        $this->setState(State::BEGIN);
336
                    } elseif ($this->lineChecker->isDirective($line)) {
337
                        $this->flush();
338
                        $this->setState(State::BEGIN);
339
340
                        return false;
341
                    } elseif ($this->lineChecker->isComment($line)) {
342
                        $this->flush();
343
                        $this->setState(State::COMMENT);
344
                    } else {
345
                        $this->buffer->push($line);
346
                    }
347
                } else {
348
                    $this->flush();
349
                    $this->setState(State::BEGIN);
350
                }
351
352
                break;
353
354
            case State::COMMENT:
355
                if (!$this->lineChecker->isComment($line) && (trim($line) === '' || $line[0] !== ' ')) {
356
                    $this->setState(State::BEGIN);
357
358
                    return false;
359
                }
360
361
                break;
362
363
            case State::BLOCK:
364
            case State::CODE:
365
                if (!$this->lineChecker->isBlockLine($line)) {
366
                    $this->flush();
367
                    $this->setState(State::BEGIN);
368
369
                    return false;
370
                }
371
372
                $this->buffer->push($line);
373
374
                break;
375
376
            case State::DIRECTIVE:
377
                if (!$this->isDirectiveOption($line)) {
378
                    if (!$this->lineChecker->isDirective($line)) {
379
                        $directive = $this->getCurrentDirective();
380
                        $this->isCode = $directive !== null ? $directive->wantCode() : false;
381
                        $this->setState(State::BEGIN);
382
383
                        return false;
384
                    }
385
386
                    $this->flush();
387
                    $this->initDirective($line);
388
                }
389
390
                break;
391
392
            default:
393
                $this->environment->addError('Parser ended in an unexcepted state');
394
        }
395
396
        return true;
397
    }
398
399
    private function flush() : void
400
    {
401
        $node = null;
402
403
        $this->isCode = false;
404
405
        if ($this->hasBuffer()) {
406
            switch ($this->state) {
407
                case State::TITLE:
408
                    $data = $this->buffer->getLinesString();
409
410
                    $level = $this->environment->getLevel((string) $this->specialLetter);
411
                    $level = $this->environment->getInitialHeaderLevel() + $level - 1;
412
413
                    $token = $this->environment->createTitle($level);
414
415
                    $node = new TitleNode(
416
                        new SpanNode($this->environment, $data),
417
                        $level,
418
                        $token
419
                    );
420
421
                    if ($this->lastTitleNode !== null) {
422
                        // current level is less than previous so we need to end all open sections
423
                        if ($node->getLevel() < $this->lastTitleNode->getLevel()) {
424
                            foreach ($this->openTitleNodes as $titleNode) {
425
                                $this->endOpenSection($titleNode);
426
                            }
427
428
                            // same level as the last so just close the last open section
429
                        } elseif ($node->getLevel() === $this->lastTitleNode->getLevel()) {
430
                            $this->endOpenSection($this->lastTitleNode);
431
                        }
432
                    }
433
434
                    $this->lastTitleNode = $node;
435
436
                    $this->document->addNode(new SectionBeginNode($node));
437
438
                    $this->openTitleNodes[] = $node;
439
440
                    break;
441
442
                case State::SEPARATOR:
443
                    $level = $this->environment->getLevel((string) $this->specialLetter);
444
445
                    $node = new SeparatorNode($level);
446
447
                    break;
448
449
                case State::CODE:
450
                    /** @var string[] $buffer */
451
                    $buffer = $this->buffer->getLines();
452
453
                    $node = new CodeNode($buffer);
454
455
                    break;
456
457
                case State::BLOCK:
458
                    /** @var string[] $lines */
459
                    $lines = $this->buffer->getLines();
460
461
                    $blockNode = new BlockNode($lines);
462
463
                    $document = $this->parser->getSubParser()->parseLocal($blockNode->getValue());
0 ignored issues
show
Documentation introduced by
$blockNode->getValue() is of type callable|null, but the function expects a string.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
464
465
                    $node = new QuoteNode($document);
466
467
                    break;
468
469
                case State::LIST:
470
                    $this->parseListLine(null, true);
471
472
                    /** @var ListNode $node */
473
                    $node = $this->nodeBuffer;
474
475
                    break;
476
477
                case State::DEFINITION_LIST:
478
                    $definitionList = $this->lineDataParser->parseDefinitionList(
479
                        $this->buffer->getLines()
480
                    );
481
482
                    $node = new DefinitionListNode($definitionList);
483
484
                    break;
485
486
                case State::TABLE:
487
                    /** @var TableNode $node */
488
                    $node = $this->nodeBuffer;
489
490
                    $node->finalize($this->parser);
491
492
                    break;
493
494
                case State::NORMAL:
495
                    $this->isCode = $this->prepareCode();
496
497
                    $buffer = $this->buffer->getLinesString();
498
499
                    $node = new ParagraphNode(new SpanNode($this->environment, $buffer));
500
501
                    break;
502
            }
503
        }
504
505
        if ($this->directive !== null) {
506
            $currentDirective = $this->getCurrentDirective();
507
508
            if ($currentDirective !== null) {
509
                try {
510
                    $currentDirective->process(
511
                        $this->parser,
512
                        $node,
513
                        $this->directive->getVariable(),
514
                        $this->directive->getData(),
515
                        $this->directive->getOptions()
516
                    );
517
                } catch (Throwable $e) {
518
                    $message = sprintf(
519
                        'Error while processing "%s" directive%s: %s',
520
                        $currentDirective->getName(),
521
                        $this->environment->getCurrentFileName() !== '' ? sprintf(
522
                            ' in "%s"',
523
                            $this->environment->getCurrentFileName()
524
                        ) : '',
525
                        $e->getMessage()
526
                    );
527
528
                    $this->environment->addError($message);
529
                }
530
            }
531
532
            $node = null;
533
        }
534
535
        $this->directive = null;
536
537
        if ($node !== null) {
538
            $this->document->addNode($node);
539
        }
540
541
        $this->init();
542
    }
543
544
    private function hasBuffer() : bool
545
    {
546
        return !$this->buffer->isEmpty() || $this->nodeBuffer !== null;
547
    }
548
549
    private function getCurrentDirective() : ?Directive
550
    {
551
        if ($this->directive === null) {
552
            return null;
553
        }
554
555
        $name = $this->directive->getName();
556
557
        return $this->directives[$name];
558
    }
559
560
    private function isDirectiveOption(string $line) : bool
561
    {
562
        if ($this->directive === null) {
563
            return false;
564
        }
565
566
        $directiveOption = $this->lineDataParser->parseDirectiveOption($line);
567
568
        if ($directiveOption === null) {
569
            return false;
570
        }
571
572
        $this->directive->setOption($directiveOption->getName(), $directiveOption->getValue());
573
574
        return true;
575
    }
576
577
    private function initDirective(string $line) : bool
578
    {
579
        $parserDirective = $this->lineDataParser->parseDirective($line);
580
581
        if ($parserDirective === null) {
582
            return false;
583
        }
584
585
        if (!isset($this->directives[$parserDirective->getName()])) {
586
            $message = sprintf(
587
                'Unknown directive: "%s" %sfor line "%s"',
588
                $parserDirective->getName(),
589
                $this->environment->getCurrentFileName() !== '' ? sprintf(
590
                    'in "%s" ',
591
                    $this->environment->getCurrentFileName()
592
                ) : '',
593
                $line
594
            );
595
596
            $this->environment->addError($message);
597
598
            return false;
599
        }
600
601
        $this->directive = $parserDirective;
602
603
        return true;
604
    }
605
606
    private function prepareCode() : bool
607
    {
608
        $lastLine = $this->buffer->getLastLine();
609
610
        if ($lastLine === null) {
611
            return false;
612
        }
613
614
        $trimmedLastLine = trim($lastLine);
615
616
        if (strlen($trimmedLastLine) >= 2) {
617
            if (substr($trimmedLastLine, -2) === '::') {
618
                if (trim($trimmedLastLine) === '::') {
619
                    $this->buffer->pop();
620
                } else {
621
                    $this->buffer->set($this->buffer->count() - 1, substr($trimmedLastLine, 0, -1));
622
                }
623
624
                return true;
625
            }
626
        }
627
628
        return false;
629
    }
630
631
    private function parseLink(string $line) : bool
632
    {
633
        $link = $this->lineDataParser->parseLink($line);
634
635
        if ($link === null) {
636
            return false;
637
        }
638
639
        if ($link->getType() === Link::TYPE_ANCHOR) {
640
            $anchorNode = new AnchorNode($link->getName());
641
642
            $this->document->addNode($anchorNode);
643
        }
644
645
        $this->environment->setLink($link->getName(), $link->getUrl());
646
647
        return true;
648
    }
649
650
    private function parseListLine(?string $line, bool $flush = false) : bool
651
    {
652
        if ($line !== null && trim($line) !== '') {
653
            $listLine = $this->lineDataParser->parseListLine($line);
654
655
            if ($listLine !== null) {
656
                if ($this->listLine instanceof ListLine) {
657
                    $this->listLine->setText(new SpanNode($this->environment, $this->listLine->getText()));
658
659
                    /** @var ListNode $listNode */
660
                    $listNode = $this->nodeBuffer;
661
662
                    $listNode->addLine($this->listLine->toArray());
663
                }
664
665
                $this->listLine = $listLine;
666
            } else {
667
                if ($this->listLine instanceof ListLine && ($this->listFlow || $line[0] === ' ')) {
668
                    $this->listLine->addText($line);
669
                } else {
670
                    $flush = true;
671
                }
672
            }
673
674
            $this->listFlow = true;
675
        } else {
676
            $this->listFlow = false;
677
        }
678
679
        if ($flush) {
680
            if ($this->listLine instanceof ListLine) {
681
                $this->listLine->setText(new SpanNode($this->environment, $this->listLine->getText()));
682
683
                /** @var ListNode $listNode */
684
                $listNode = $this->nodeBuffer;
685
686
                $listNode->addLine($this->listLine->toArray());
687
688
                $this->listLine = null;
689
            }
690
691
            return false;
692
        }
693
694
        return true;
695
    }
696
697
    private function endOpenSection(TitleNode $titleNode) : void
698
    {
699
        $this->document->addNode(new SectionEndNode($titleNode));
700
701
        $key = array_search($titleNode, $this->openTitleNodes, true);
702
703
        if ($key === false) {
704
            return;
705
        }
706
707
        unset($this->openTitleNodes[$key]);
708
    }
709
710
    public function mergeIncludedFiles(string $document) : string
711
    {
712
        return preg_replace_callback(
713
            '/^\.\. include:: (.+)$/m',
714
            function ($match) {
715
                $path = $this->environment->absoluteRelativePath($match[1]);
716
717
                $origin = $this->environment->getOrigin();
718
                if (!$origin->has($path)) {
719
                    throw new RuntimeException(
720
                        sprintf('Include "%s" (%s) does not exist or is not readable.', $match[0], $path)
721
                    );
722
                }
723
724
                $contents = $origin->read($path);
725
726
                if ($contents === false) {
727
                    throw new RuntimeException(sprintf('Could not load file from path %s', $path));
728
                }
729
730
                return $this->mergeIncludedFiles($contents);
731
            },
732
            $document
733
        );
734
    }
735
}
736