LinkParserHelper   A
last analyzed

Complexity

Total Complexity 31

Size/Duplication

Total Lines 140
Duplicated Lines 0 %

Test Coverage

Coverage 92.42%

Importance

Changes 2
Bugs 0 Features 0
Metric Value
eloc 61
c 2
b 0
f 0
dl 0
loc 140
ccs 61
cts 66
cp 0.9242
rs 9.92
wmc 31

7 Methods

Rating   Name   Duplication   Size   Complexity  
A parseLinkDestination() 0 13 3
A parseLinkTitle() 0 8 2
A parseLinkLabel() 0 14 3
A parsePartialLinkTitle() 0 9 2
A parsePartialLinkLabel() 0 3 1
A parseDestinationBraces() 0 26 5
C manuallyParseLinkDestination() 0 38 15
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\Util;
18
19
use League\CommonMark\Parser\Cursor;
20
21
/**
22
 * @psalm-immutable
23
 */
24
final class LinkParserHelper
25
{
26
    /**
27
     * Attempt to parse link destination
28
     *
29
     * @return string|null The string, or null if no match
30
     */
31 402
    public static function parseLinkDestination(Cursor $cursor): ?string
32
    {
33 402
        if ($cursor->getCurrentCharacter() === '<') {
34 34
            return self::parseDestinationBraces($cursor);
35
        }
36
37 370
        $destination = self::manuallyParseLinkDestination($cursor);
38 370
        if ($destination === null) {
39
            return null;
40
        }
41
42 370
        return UrlEncoder::unescapeAndEncode(
43 370
            RegexHelper::unescape($destination)
44 370
        );
45
    }
46
47 226
    public static function parseLinkLabel(Cursor $cursor): int
48
    {
49 226
        $match = $cursor->match('/^\[(?:[^\\\\\[\]]|\\\\.){0,1000}\]/');
50 226
        if ($match === null) {
51 172
            return 0;
52
        }
53
54 62
        $length = \mb_strlen($match, 'UTF-8');
55
56 62
        if ($length > 1001) {
57
            return 0;
58
        }
59
60 62
        return $length;
61
    }
62
63 338
    public static function parsePartialLinkLabel(Cursor $cursor): ?string
64
    {
65 338
        return $cursor->match('/^(?:[^\\\\\[\]]++|\\\\.?)*+/');
66
    }
67
68
    /**
69
     * Attempt to parse link title (sans quotes)
70
     *
71
     * @return string|null The string, or null if no match
72
     */
73 44
    public static function parseLinkTitle(Cursor $cursor): ?string
74
    {
75 44
        if ($title = $cursor->match('/' . RegexHelper::PARTIAL_LINK_TITLE . '/')) {
76
            // Chop off quotes from title and unescape
77 22
            return RegexHelper::unescape(\substr($title, 1, -1));
78
        }
79
80 22
        return null;
81
    }
82
83 82
    public static function parsePartialLinkTitle(Cursor $cursor, string $endDelimiter): ?string
84
    {
85 82
        $endDelimiter = \preg_quote($endDelimiter, '/');
86 82
        $regex        = \sprintf('/(%s|[^%s\x00])*(?:%s)?/', RegexHelper::PARTIAL_ESCAPED_CHAR, $endDelimiter, $endDelimiter);
87 82
        if (($partialTitle = $cursor->match($regex)) === null) {
88
            return null;
89
        }
90
91 82
        return RegexHelper::unescape($partialTitle);
92
    }
93
94 370
    private static function manuallyParseLinkDestination(Cursor $cursor): ?string
95
    {
96 370
        $remainder  = $cursor->getRemainder();
97 370
        $openParens = 0;
98 370
        $len        = \strlen($remainder);
99 370
        for ($i = 0; $i < $len; $i++) {
100 370
            $c = $remainder[$i];
101 370
            if ($c === '\\' && $i + 1 < $len && RegexHelper::isEscapable($remainder[$i + 1])) {
102 12
                $i++;
103 370
            } elseif ($c === '(') {
104 16
                $openParens++;
105
                // Limit to 32 nested parens for pathological cases
106 16
                if ($openParens > 32) {
107 16
                    return null;
108
                }
109 370
            } elseif ($c === ')') {
110 160
                if ($openParens < 1) {
111 156
                    break;
112
                }
113
114 16
                $openParens--;
115 364
            } elseif (\ord($c) <= 32 && RegexHelper::isWhitespace($c)) {
116 126
                break;
117
            }
118
        }
119
120 370
        if ($openParens !== 0) {
0 ignored issues
show
introduced by
The condition $openParens !== 0 is always false.
Loading history...
121
            return null;
122
        }
123
124 370
        if ($i === 0 && (! isset($c) || $c !== ')')) {
125
            return null;
126
        }
127
128 370
        $destination = \substr($remainder, 0, $i);
129 370
        $cursor->advanceBy(\mb_strlen($destination, 'UTF-8'));
130
131 370
        return $destination;
132
    }
133
134
    /** @var \WeakReference<Cursor>|null */
135
    private static ?\WeakReference $lastCursor       = null;
136
    private static bool $lastCursorLacksClosingBrace = false;
137
138 34
    private static function parseDestinationBraces(Cursor $cursor): ?string
139
    {
140
        // Optimization: If we've previously parsed this cursor and returned `null`, we know
141
        // that no closing brace exists, so we can skip the regex entirely. This helps avoid
142
        // certain pathological cases where the regex engine can take a very long time to
143
        // determine that no match exists.
144 34
        if (self::$lastCursor !== null && self::$lastCursor->get() === $cursor) {
145 2
            if (self::$lastCursorLacksClosingBrace) {
146 2
                return null;
147
            }
148
        } else {
149 34
            self::$lastCursor = \WeakReference::create($cursor);
150
        }
151
152 34
        if ($res = $cursor->match(RegexHelper::REGEX_LINK_DESTINATION_BRACES)) {
153 24
            self::$lastCursorLacksClosingBrace = false;
154
155
            // Chop off surrounding <..>:
156 24
            return UrlEncoder::unescapeAndEncode(
157 24
                RegexHelper::unescape(\substr($res, 1, -1))
158 24
            );
159
        }
160
161 10
        self::$lastCursorLacksClosingBrace = true;
162
163 10
        return null;
164
    }
165
}
166