Passed
Push — main ( 3c75b2...d1c0ce )
by Colin
11:24 queued 08:30
created

ReferenceParser::parse()   B

Complexity

Conditions 10
Paths 28

Size

Total Lines 40
Code Lines 29

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 22
CRAP Score 10.0082

Importance

Changes 0
Metric Value
cc 10
eloc 29
nc 28
nop 1
dl 0
loc 40
ccs 22
cts 23
cp 0.9565
crap 10.0082
rs 7.6666
c 0
b 0
f 0

How to fix   Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
declare(strict_types=1);
4
5
/*
6
 * This file is part of the league/commonmark package.
7
 *
8
 * (c) Colin O'Dell <[email protected]>
9
 *
10
 * Original code based on the CommonMark JS reference parser (https://bitly.com/commonmark-js)
11
 *  - (c) John MacFarlane
12
 *
13
 * For the full copyright and license information, please view the LICENSE
14
 * file that was distributed with this source code.
15
 */
16
17
namespace League\CommonMark\Reference;
18
19
use League\CommonMark\Parser\Cursor;
20
use League\CommonMark\Util\LinkParserHelper;
21
22
final class ReferenceParser
23
{
24
    // Looking for the start of a definition, i.e. `[`
25
    private const START_DEFINITION = 0;
26
    // Looking for and parsing the label, i.e. `[foo]` within `[foo]`
27
    private const LABEL = 1;
28
    // Parsing the destination, i.e. `/url` in `[foo]: /url`
29
    private const DESTINATION = 2;
30
    // Looking for the start of a title, i.e. the first `"` in `[foo]: /url "title"`
31
    private const START_TITLE = 3;
32
    // Parsing the content of the title, i.e. `title` in `[foo]: /url "title"`
33
    private const TITLE = 4;
34
    // End state, no matter what kind of lines we add, they won't be references
35
    private const PARAGRAPH = 5;
36
37
    /** @psalm-readonly-allow-private-mutation */
38
    private string $paragraph = '';
39
40
    /**
41
     * @var array<int, ReferenceInterface>
42
     *
43
     * @psalm-readonly-allow-private-mutation
44
     */
45
    private array $references = [];
46
47
    /** @psalm-readonly-allow-private-mutation */
48
    private int $state = self::START_DEFINITION;
49
50
    /** @psalm-readonly-allow-private-mutation */
51
    private ?string $label = null;
52
53
    /** @psalm-readonly-allow-private-mutation */
54
    private ?string $destination = null;
55
56
    /**
57
     * @var string string
58
     *
59
     * @psalm-readonly-allow-private-mutation
60
     */
61
    private string $title = '';
62
63
    /** @psalm-readonly-allow-private-mutation */
64
    private ?string $titleDelimiter = null;
65
66
    /** @psalm-readonly-allow-private-mutation */
67
    private bool $referenceValid = false;
68
69 1932
    public function getParagraphContent(): string
70
    {
71 1932
        return $this->paragraph;
72
    }
73
74
    /**
75
     * @return ReferenceInterface[]
76
     */
77 1932
    public function getReferences(): iterable
78
    {
79 1932
        $this->finishReference();
80
81 1932
        return $this->references;
82
    }
83
84 1846
    public function hasReferences(): bool
85
    {
86 1846
        return $this->references !== [];
87
    }
88
89 1932
    public function parse(string $line): void
90
    {
91 1932
        if ($this->paragraph !== '') {
92 292
            $this->paragraph .= "\n";
93
        }
94
95 1932
        $this->paragraph .= $line;
96
97 1932
        $cursor = new Cursor($line);
98 1932
        while (! $cursor->isAtEnd()) {
99 1932
            $result = false;
100 1932
            switch ($this->state) {
101
                case self::PARAGRAPH:
102
                    // We're in a paragraph now. Link reference definitions can only appear at the beginning, so once
103
                    // we're in a paragraph, there's no going back.
104 276
                    return;
105
                case self::START_DEFINITION:
106 1932
                    $result = $this->parseStartDefinition($cursor);
107 1932
                    break;
108
                case self::LABEL:
109 328
                    $result = $this->parseLabel($cursor);
110 328
                    break;
111
                case self::DESTINATION:
112 172
                    $result = $this->parseDestination($cursor);
113 172
                    break;
114
                case self::START_TITLE:
115 94
                    $result = $this->parseStartTitle($cursor);
116 94
                    break;
117
                case self::TITLE:
118 78
                    $result = $this->parseTitle($cursor);
119 78
                    break;
120
                default:
121
                    // this should never happen
122
                    break;
123
            }
124
125 1932
            if (! $result) {
126 1928
                $this->state = self::PARAGRAPH;
127
128 1928
                return;
129
            }
130
        }
131
    }
132
133 1932
    private function parseStartDefinition(Cursor $cursor): bool
134
    {
135 1932
        $cursor->advanceToNextNonSpaceOrTab();
136 1932
        if ($cursor->isAtEnd() || $cursor->getCurrentCharacter() !== '[') {
137 1674
            return false;
138
        }
139
140 328
        $this->state = self::LABEL;
141 328
        $this->label = '';
142
143 328
        $cursor->advance();
144 328
        if ($cursor->isAtEnd()) {
145 4
            $this->label .= "\n";
146
        }
147
148 328
        return true;
149
    }
150
151 328
    private function parseLabel(Cursor $cursor): bool
152
    {
153 328
        $cursor->advanceToNextNonSpaceOrTab();
154
155 328
        $partialLabel = LinkParserHelper::parsePartialLinkLabel($cursor);
156 328
        if ($partialLabel === null) {
157
            return false;
158
        }
159
160
        \assert($this->label !== null);
161 328
        $this->label .= $partialLabel;
162
163 328
        if ($cursor->isAtEnd()) {
164
            // label might continue on next line
165 8
            $this->label .= "\n";
166
167 8
            return true;
168
        }
169
170 326
        if ($cursor->getCurrentCharacter() !== ']') {
171 30
            return false;
172
        }
173
174 312
        $cursor->advance();
175
176
        // end of label
177 312
        if ($cursor->getCurrentCharacter() !== ':') {
178 250
            return false;
179
        }
180
181 178
        $cursor->advance();
182
183
        // spec: A link label can have at most 999 characters inside the square brackets
184 178
        if (\mb_strlen($this->label, 'UTF-8') > 999) {
185
            return false;
186
        }
187
188
        // spec: A link label must contain at least one non-whitespace character
189 178
        if (\trim($this->label) === '') {
190 4
            return false;
191
        }
192
193 174
        $cursor->advanceToNextNonSpaceOrTab();
194
195 174
        $this->state = self::DESTINATION;
196
197 174
        return true;
198
    }
199
200 172
    private function parseDestination(Cursor $cursor): bool
201
    {
202 172
        $cursor->advanceToNextNonSpaceOrTab();
203
204 172
        $destination = LinkParserHelper::parseLinkDestination($cursor);
205 172
        if ($destination === null) {
206 2
            return false;
207
        }
208
209 170
        $this->destination = $destination;
210
211 170
        $advanced = $cursor->advanceToNextNonSpaceOrTab();
212 170
        if ($cursor->isAtEnd()) {
213
            // Destination was at end of line, so this is a valid reference for sure (and maybe a title).
214
            // If not at end of line, wait for title to be valid first.
215 96
            $this->referenceValid = true;
216 96
            $this->paragraph      = '';
217 76
        } elseif ($advanced === 0) {
218
            // spec: The title must be separated from the link destination by whitespace
219 2
            return false;
220
        }
221
222 168
        $this->state = self::START_TITLE;
223
224 168
        return true;
225
    }
226
227 94
    private function parseStartTitle(Cursor $cursor): bool
228
    {
229 94
        $cursor->advanceToNextNonSpaceOrTab();
230 94
        if ($cursor->isAtEnd()) {
231
            $this->state = self::START_DEFINITION;
232
233
            return true;
234
        }
235
236 94
        $this->titleDelimiter = null;
237 94
        switch ($c = $cursor->getCurrentCharacter()) {
238 94
            case '"':
239 26
            case "'":
240 78
                $this->titleDelimiter = $c;
241 78
                break;
242 16
            case '(':
243
                $this->titleDelimiter = ')';
244
                break;
245
            default:
246
                // no title delimter found
247 16
                break;
248
        }
249
250 94
        if ($this->titleDelimiter !== null) {
251 78
            $this->state = self::TITLE;
252 78
            $cursor->advance();
253 78
            if ($cursor->isAtEnd()) {
254 2
                $this->title .= "\n";
255
            }
256
        } else {
257 16
            $this->finishReference();
258
            // There might be another reference instead, try that for the same character.
259 16
            $this->state = self::START_DEFINITION;
260
        }
261
262 94
        return true;
263
    }
264
265 78
    private function parseTitle(Cursor $cursor): bool
266
    {
267
        \assert($this->titleDelimiter !== null);
268 78
        $title = LinkParserHelper::parsePartialLinkTitle($cursor, $this->titleDelimiter);
269
270 78
        if ($title === null) {
271
            // Invalid title, stop
272
            return false;
273
        }
274
275
        // Did we find the end delimiter?
276 78
        $endDelimiterFound = false;
277 78
        if (\substr($title, -1) === $this->titleDelimiter) {
278 76
            $endDelimiterFound = true;
279
            // Chop it off
280 76
            $title = \substr($title, 0, -1);
281
        }
282
283 78
        $this->title .= $title;
284
285 78
        if (! $endDelimiterFound && $cursor->isAtEnd()) {
286
            // Title still going, continue on next line
287 4
            $this->title .= "\n";
288
289 4
            return true;
290
        }
291
292
        // We either hit the end delimiter or some extra whitespace
293 76
        $cursor->advanceToNextNonSpaceOrTab();
294 76
        if (! $cursor->isAtEnd()) {
295
            // spec: No further non-whitespace characters may occur on the line.
296 4
            return false;
297
        }
298
299 72
        $this->referenceValid = true;
300 72
        $this->finishReference();
301 72
        $this->paragraph = '';
302
303
        // See if there's another definition
304 72
        $this->state = self::START_DEFINITION;
305
306 72
        return true;
307
    }
308
309 1932
    private function finishReference(): void
310
    {
311 1932
        if (! $this->referenceValid) {
312 1928
            return;
313
        }
314
315
        /** @psalm-suppress PossiblyNullArgument -- these can't possibly be null if we're in this state */
316 162
        $this->references[] = new Reference($this->label, $this->destination, $this->title);
0 ignored issues
show
Bug introduced by
It seems like $this->label can also be of type null; however, parameter $label of League\CommonMark\Refere...eference::__construct() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

316
        $this->references[] = new Reference(/** @scrutinizer ignore-type */ $this->label, $this->destination, $this->title);
Loading history...
Bug introduced by
It seems like $this->destination can also be of type null; however, parameter $destination of League\CommonMark\Refere...eference::__construct() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

316
        $this->references[] = new Reference($this->label, /** @scrutinizer ignore-type */ $this->destination, $this->title);
Loading history...
317
318 162
        $this->label          = null;
319 162
        $this->referenceValid = false;
320 162
        $this->destination    = null;
321 162
        $this->title          = '';
322 162
        $this->titleDelimiter = null;
323
    }
324
}
325