Passed
Branch latest (76d169)
by Colin
01:59
created

src/Environment/Environment.php (1 issue)

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\Environment;
18
19
use League\CommonMark\Configuration\Configuration;
20
use League\CommonMark\Configuration\ConfigurationAwareInterface;
21
use League\CommonMark\Delimiter\Processor\DelimiterProcessorCollection;
22
use League\CommonMark\Delimiter\Processor\DelimiterProcessorInterface;
23
use League\CommonMark\Event\AbstractEvent;
24
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
25
use League\CommonMark\Extension\ExtensionInterface;
26
use League\CommonMark\Extension\GithubFlavoredMarkdownExtension;
27
use League\CommonMark\Parser\Block\BlockStartParserInterface;
28
use League\CommonMark\Parser\Inline\InlineParserInterface;
29
use League\CommonMark\Renderer\NodeRendererInterface;
30
use League\CommonMark\Util\HtmlFilter;
31
use League\CommonMark\Util\PrioritizedList;
32
33
final class Environment implements ConfigurableEnvironmentInterface
34
{
35
    /**
36
     * @var ExtensionInterface[]
37
     *
38
     * @psalm-readonly-allow-private-mutation
39
     */
40
    private $extensions = [];
41
42
    /**
43
     * @var ExtensionInterface[]
44
     *
45
     * @psalm-readonly-allow-private-mutation
46
     */
47
    private $uninitializedExtensions = [];
48
49
    /**
50
     * @var bool
51
     *
52
     * @psalm-readonly-allow-private-mutation
53
     */
54
    private $extensionsInitialized = false;
55
56
    /**
57
     * @var PrioritizedList<BlockStartParserInterface>
58
     *
59
     * @psalm-readonly
60
     */
61
    private $blockStartParsers;
62
63
    /**
64
     * @var PrioritizedList<InlineParserInterface>
65
     *
66
     * @psalm-readonly
67
     */
68
    private $inlineParsers;
69
70
    /**
71
     * @var array<string, PrioritizedList<InlineParserInterface>>
72
     *
73
     * @psalm-readonly-allow-private-mutation
74
     */
75
    private $inlineParsersByCharacter = [];
76
77
    /**
78
     * @var DelimiterProcessorCollection
79
     *
80
     * @psalm-readonly
81
     */
82
    private $delimiterProcessors;
83
84
    /**
85
     * @var array<string, PrioritizedList<NodeRendererInterface>>
86
     *
87
     * @psalm-readonly-allow-private-mutation
88
     */
89
    private $renderersByClass = [];
90
91
    /**
92
     * @var array<string, PrioritizedList<callable>>
0 ignored issues
show
Documentation Bug introduced by
The doc comment array<string, PrioritizedList<callable>> at position 4 could not be parsed: Expected '>' at position 4, but found 'PrioritizedList'.
Loading history...
93
     *
94
     * @psalm-readonly-allow-private-mutation
95
     */
96
    private $listeners = [];
97
98
    /**
99
     * @var Configuration
100
     *
101
     * @psalm-readonly
102
     */
103
    private $config;
104
105
    /**
106
     * @var string
107
     *
108
     * @psalm-readonly-allow-private-mutation
109
     */
110
    private $inlineParserCharacterRegex;
111
112
    /**
113
     * @param array<string, mixed> $config
114
     */
115 3009
    public function __construct(array $config = [])
116
    {
117 3009
        $this->config = new Configuration($config);
118
119 3009
        $this->blockStartParsers   = new PrioritizedList();
120 3009
        $this->inlineParsers       = new PrioritizedList();
121 3009
        $this->delimiterProcessors = new DelimiterProcessorCollection();
122 3009
    }
123
124
    /**
125
     * {@inheritdoc}
126
     */
127 2901
    public function mergeConfig(array $config = []): void
128
    {
129 2901
        $this->assertUninitialized('Failed to modify configuration.');
130
131 2898
        $this->config->merge($config);
132 2898
    }
133
134
    /**
135
     * {@inheritdoc}
136
     */
137 18
    public function setConfig(array $config = []): void
138
    {
139 18
        $this->assertUninitialized('Failed to modify configuration.');
140
141 15
        $this->config->replace($config);
142 15
    }
143
144
    /**
145
     * {@inheritdoc}
146
     */
147 2907
    public function getConfig(?string $key = null, $default = null)
148
    {
149 2907
        return $this->config->get($key, $default);
150
    }
151
152 2889
    public function addBlockStartParser(BlockStartParserInterface $parser, int $priority = 0): ConfigurableEnvironmentInterface
153
    {
154 2889
        $this->assertUninitialized('Failed to add block start parser.');
155
156 2886
        $this->blockStartParsers->add($parser, $priority);
157 2886
        $this->injectEnvironmentAndConfigurationIfNeeded($parser);
158
159 2886
        return $this;
160
    }
161
162 2901
    public function addInlineParser(InlineParserInterface $parser, int $priority = 0): ConfigurableEnvironmentInterface
163
    {
164 2901
        $this->assertUninitialized('Failed to add inline parser.');
165
166 2898
        $this->inlineParsers->add($parser, $priority);
167 2898
        $this->injectEnvironmentAndConfigurationIfNeeded($parser);
168
169 2898
        foreach ($parser->getCharacters() as $character) {
170 2895
            if (! isset($this->inlineParsersByCharacter[$character])) {
171 2895
                $this->inlineParsersByCharacter[$character] = new PrioritizedList();
172
            }
173
174 2895
            $this->inlineParsersByCharacter[$character]->add($parser, $priority);
175
        }
176
177 2898
        return $this;
178
    }
179
180 2892
    public function addDelimiterProcessor(DelimiterProcessorInterface $processor): ConfigurableEnvironmentInterface
181
    {
182 2892
        $this->assertUninitialized('Failed to add delimiter processor.');
183 2889
        $this->delimiterProcessors->add($processor);
184 2889
        $this->injectEnvironmentAndConfigurationIfNeeded($processor);
185
186 2889
        return $this;
187
    }
188
189 2910
    public function addRenderer(string $nodeClass, NodeRendererInterface $renderer, int $priority = 0): ConfigurableEnvironmentInterface
190
    {
191 2910
        $this->assertUninitialized('Failed to add renderer.');
192
193 2907
        if (! isset($this->renderersByClass[$nodeClass])) {
194 2907
            $this->renderersByClass[$nodeClass] = new PrioritizedList();
195
        }
196
197 2907
        $this->renderersByClass[$nodeClass]->add($renderer, $priority);
198 2907
        $this->injectEnvironmentAndConfigurationIfNeeded($renderer);
199
200 2907
        return $this;
201
    }
202
203
    /**
204
     * {@inheritdoc}
205
     */
206 2271
    public function getBlockStartParsers(): iterable
207
    {
208 2271
        if (! $this->extensionsInitialized) {
209 33
            $this->initializeExtensions();
210
        }
211
212 2271
        return $this->blockStartParsers->getIterator();
213
    }
214
215
    /**
216
     * {@inheritdoc}
217
     */
218 2598
    public function getInlineParsersForCharacter(string $character): iterable
219
    {
220 2598
        if (! $this->extensionsInitialized) {
221 24
            $this->initializeExtensions();
222
        }
223
224 2598
        if (! isset($this->inlineParsersByCharacter[$character])) {
225 2454
            return [];
226
        }
227
228 1491
        return $this->inlineParsersByCharacter[$character]->getIterator();
229
    }
230
231 2583
    public function getDelimiterProcessors(): DelimiterProcessorCollection
232
    {
233 2583
        if (! $this->extensionsInitialized) {
234 6
            $this->initializeExtensions();
235
        }
236
237 2583
        return $this->delimiterProcessors;
238
    }
239
240
    /**
241
     * {@inheritdoc}
242
     */
243 2895
    public function getRenderersForClass(string $nodeClass): iterable
244
    {
245 2895
        if (! $this->extensionsInitialized) {
246 33
            $this->initializeExtensions();
247
        }
248
249
        // If renderers are defined for this specific class, return them immediately
250 2895
        if (isset($this->renderersByClass[$nodeClass])) {
251 2886
            return $this->renderersByClass[$nodeClass];
252
        }
253
254
        /** @psalm-suppress TypeDoesNotContainType -- Bug: https://github.com/vimeo/psalm/issues/3332 */
255 60
        while (\class_exists($parent = $parent ?? $nodeClass) && $parent = \get_parent_class($parent)) {
256 54
            if (! isset($this->renderersByClass[$parent])) {
257 6
                continue;
258
            }
259
260
            // "Cache" this result to avoid future loops
261 51
            return $this->renderersByClass[$nodeClass] = $this->renderersByClass[$parent];
262
        }
263
264 9
        return [];
265
    }
266
267
    /**
268
     * Get all registered extensions
269
     *
270
     * @return ExtensionInterface[]
271
     */
272 12
    public function getExtensions(): iterable
273
    {
274 12
        return $this->extensions;
275
    }
276
277
    /**
278
     * Add a single extension
279
     *
280
     * @return $this
281
     */
282 2904
    public function addExtension(ExtensionInterface $extension): ConfigurableEnvironmentInterface
283
    {
284 2904
        $this->assertUninitialized('Failed to add extension.');
285
286 2901
        $this->extensions[]              = $extension;
287 2901
        $this->uninitializedExtensions[] = $extension;
288
289 2901
        return $this;
290
    }
291
292 2979
    private function initializeExtensions(): void
293
    {
294
        // Ask all extensions to register their components
295 2979
        while (\count($this->uninitializedExtensions) > 0) {
296 2883
            foreach ($this->uninitializedExtensions as $i => $extension) {
297 2883
                $extension->register($this);
298 2883
                unset($this->uninitializedExtensions[$i]);
299
            }
300
        }
301
302 2976
        $this->extensionsInitialized = true;
303
304
        // Lastly, let's build a regex which matches non-inline characters
305
        // This will enable a huge performance boost with inline parsing
306 2976
        $this->buildInlineParserCharacterRegex();
307 2976
    }
308
309 2949
    private function injectEnvironmentAndConfigurationIfNeeded(object $object): void
310
    {
311 2949
        if ($object instanceof EnvironmentAwareInterface) {
312 2895
            $object->setEnvironment($this);
313
        }
314
315 2949
        if ($object instanceof ConfigurationAwareInterface) {
316 2895
            $object->setConfiguration($this->config);
317
        }
318 2949
    }
319
320 2892
    public static function createCommonMarkEnvironment(): ConfigurableEnvironmentInterface
321
    {
322 2892
        $environment = new static();
323 2892
        $environment->addExtension(new CommonMarkCoreExtension());
324 2892
        $environment->mergeConfig([
325 964
            'renderer' => [
326 1928
                'block_separator' => "\n",
327
                'inner_separator' => "\n",
328
                'soft_break'      => "\n",
329
            ],
330 2892
            'html_input'         => HtmlFilter::ALLOW,
331
            'allow_unsafe_links' => true,
332
            'max_nesting_level'  => \INF,
333
        ]);
334
335 2892
        return $environment;
336
    }
337
338 150
    public static function createGFMEnvironment(): ConfigurableEnvironmentInterface
339
    {
340 150
        $environment = self::createCommonMarkEnvironment();
341 150
        $environment->addExtension(new GithubFlavoredMarkdownExtension());
342
343 150
        return $environment;
344
    }
345
346 2442
    public function getInlineParserCharacterRegex(): string
347
    {
348 2442
        return $this->inlineParserCharacterRegex;
349
    }
350
351 606
    public function addEventListener(string $eventClass, callable $listener, int $priority = 0): ConfigurableEnvironmentInterface
352
    {
353 606
        $this->assertUninitialized('Failed to add event listener.');
354
355 603
        if (! isset($this->listeners[$eventClass])) {
356 603
            $this->listeners[$eventClass] = new PrioritizedList();
357
        }
358
359 603
        $this->listeners[$eventClass]->add($listener, $priority);
360
361 603
        if (\is_object($listener)) {
362 528
            $this->injectEnvironmentAndConfigurationIfNeeded($listener);
363 162
        } elseif (\is_array($listener) && \is_object($listener[0])) {
364 162
            $this->injectEnvironmentAndConfigurationIfNeeded($listener[0]);
365
        }
366
367 603
        return $this;
368
    }
369
370 2889
    public function dispatch(AbstractEvent $event): void
371
    {
372 2889
        if (! $this->extensionsInitialized) {
373 2883
            $this->initializeExtensions();
374
        }
375
376 2886
        $type = \get_class($event);
377
378 2886
        foreach ($this->listeners[$type] ?? [] as $listener) {
379 600
            if ($event->isPropagationStopped()) {
380 3
                return;
381
            }
382
383 600
            $listener($event);
384
        }
385 2880
    }
386
387 2976
    private function buildInlineParserCharacterRegex(): void
388
    {
389 2976
        $chars = \array_unique(\array_merge(
390 2976
            \array_keys($this->inlineParsersByCharacter),
391 2976
            $this->delimiterProcessors->getDelimiterCharacters()
392
        ));
393
394 2976
        if (\count($chars) === 0) {
395
            // If no special inline characters exist then parse the whole line
396 78
            $this->inlineParserCharacterRegex = '/^.+$/';
397
        } else {
398
            // Match any character which inline parsers are not interested in
399 2898
            $this->inlineParserCharacterRegex = '/^[^' . \preg_quote(\implode('', $chars), '/') . ']+/';
400
401
            // Only add the u modifier (which slows down performance) if we have a multi-byte UTF-8 character in our regex
402 2898
            if (\strlen($this->inlineParserCharacterRegex) > \mb_strlen($this->inlineParserCharacterRegex)) {
403 54
                $this->inlineParserCharacterRegex .= 'u';
404
            }
405
        }
406 2976
    }
407
408
    /**
409
     * @throws \RuntimeException
410
     */
411 2997
    private function assertUninitialized(string $message): void
412
    {
413 2997
        if ($this->extensionsInitialized) {
414 24
            throw new \RuntimeException($message . ' Extensions have already been initialized.');
415
        }
416 2973
    }
417
}
418