• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

dg / texy / 29705051386

19 Jul 2026 09:41PM UTC coverage: 94.641% (+1.2%) from 93.477%
29705051386

push

github

dg
Dead code removed

- Engine::getPatternNames() and Texy::getEngine(): no callers in src or tests.
- LinkDefinitionModule::resolveUrl(): unreachable. Both bracket forms it
  handles ([*img*] and [ref]) are already consumed by earlier returns in
  resolveLinkNodeUrl(), so it could only ever return its argument unchanged.
- Text\Renderer: CodeBlockNode type 'comment' never exists; comments are a
  separate CommentNode. Leftover from before the node split.

1 of 1 new or added line in 1 file covered. (100.0%)

35 existing lines in 7 files now uncovered.

3338 of 3527 relevant lines covered (94.64%)

0.95 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

98.52
/src/Texy/Modules/PhraseModule.php
1
<?php declare(strict_types=1);
2

3
/**
4
 * This file is part of the Texy! (https://texy.nette.org)
5
 * Copyright (c) 2004 David Grudl (https://davidgrudl.com)
6
 */
7

8
namespace Texy\Modules;
9

10
use Texy;
11
use Texy\Compat;
12
use Texy\Modifier;
13
use Texy\Nodes\ContentNode;
14
use Texy\Nodes\LinkNode;
15
use Texy\Nodes\PhraseNode;
16
use Texy\Nodes\RawTextNode;
17
use Texy\Nodes\TextNode;
18
use Texy\ParseContext;
19
use Texy\Patterns;
20
use Texy\Range;
21
use Texy\Syntax;
22
use function str_replace, strlen, trim;
23

24

25
/**
26
 * Processes inline text formatting (bold, italic, code, etc.).
27
 */
28
final class PhraseModule extends Texy\Module
29
{
30
        /**
31
         * Paired phrases: a delimiter, some content, an optional modifier, the same
32
         * delimiter again. They all share one skeleton (see buildPhrasePattern()),
33
         * so a row only says what the delimiters are and which characters must not
34
         * touch them:
35
         *
36
         *   char        the delimiter character, as it appears inside a class
37
         *   guard       characters that must not precede either delimiter
38
         *   guardAfter  characters that must not follow either delimiter
39
         *   link        the phrase can carry a :link suffix
40
         *   multiline   the content may span lines
41
         *
42
         * The remaining keys are overrides for the places where a syntax deviates
43
         * from the skeleton; each one is commented, because the deviations are the
44
         * only interesting thing here.
45
         */
46
        private const Phrases = [
47
                Syntax::StrongEmphasis => ['open' => '\*\*\*', 'char' => '\*', 'guard' => '\\\\', 'link' => true, 'multiline' => true],
48
                Syntax::Strong => ['open' => '\*\*', 'char' => '\*', 'guard' => '\\\\', 'link' => true, 'multiline' => true],
49
                // : guards the // in http://
50
                Syntax::Emphasis => ['open' => '//', 'char' => '/', 'guard' => ':', 'link' => true, 'multiline' => true],
51
                Syntax::EmphasisSingleAsterisk => [
52
                        'open' => '\*', 'char' => '\*', 'guard' => '\\\\', 'link' => true, 'multiline' => true,
53
                        'content' => '\s\*', 'contentPair' => '\*', // a single * phrase holds no whitespace at all
54
                ],
55
                Syntax::EmphasisSingleAsterisk2 => [
56
                        'open' => '\*', 'char' => '\*', 'guard' => '\\\\', 'link' => true, 'multiline' => true,
57
                        'beforeOpen' => '[^\s.,;:<>()"\'-]',    // must stand at a word boundary, ...
58
                        'afterClose' => '[^\s.,;:<>()"?!\'-]',  // ... hence the inverted classes
59
                ],
60
                Syntax::Inserted => ['open' => '\+\+', 'char' => '\+'],
61
                // < and > guard the arrows <-- and -->
62
                Syntax::Deleted => ['open' => '--', 'char' => '\-', 'guard' => '<', 'guardAfter' => '>'],
63
                Syntax::Superscript => ['open' => '\^\^', 'char' => '\^'],
64
                Syntax::Subscript => ['open' => '__', 'char' => '_'],
65
                Syntax::SpanQuotes => [
66
                        'open' => '"', 'char' => '"', 'link' => true,
67
                        'afterOpen' => '\s', 'beforeClose' => '\s', // the delimiter may touch itself: ""quoted""
68
                        'content' => '\r "', 'contentPair' => ' ',  // and the content may span lines
69
                ],
70
                Syntax::SpanTilde => [
71
                        'open' => '\~', 'char' => '\~', 'link' => true,
72
                        'afterOpen' => '\s', 'beforeClose' => '\s',
73
                        'content' => '\r \~', 'contentPair' => ' ',
74
                ],
75
                Syntax::Quote => ['open' => '>>', 'close' => '<<', 'char' => '>', 'closeChar' => '<', 'link' => true],
76
        ];
77

78
        public bool $linksAllowed = true;
79

80

81
        public function __construct(
1✔
82
                private Texy\Texy $texy,
83
        ) {
84
                $texy->allowed[Syntax::Inserted] = false;
1✔
85
                $texy->allowed[Syntax::Deleted] = false;
1✔
86
                $texy->allowed[Syntax::Superscript] = false;
1✔
87
                $texy->allowed[Syntax::Subscript] = false;
1✔
88
        }
1✔
89

90

91
        public function beforeParse(string &$text): void
1✔
92
        {
93
                $texy = $this->texy;
1✔
94

95
                foreach (self::Phrases as $syntax => $spec) {
1✔
96
                        $texy->registerLinePattern($this->parsePhrase(...), self::buildPhrasePattern($spec), $syntax);
1✔
97
                }
98

99
                // m^2 alternative superscript
100
                $texy->registerLinePattern(
1✔
101
                        $this->parseSupSub(...),
1✔
102
                        '~
1✔
103
                                (?<= [a-z0-9] )                  # preceded by letter or number
104
                                \^
105
                                ( [n0-9+-]{1,4}? )               # 1-4 digits, n, + or - (1)
106
                                (?! [a-z0-9] )                   # not followed by letter or number
107
                        ~Uix',
108
                        Syntax::SuperscriptShort,
1✔
109
                );
110

111
                // m_2 alternative subscript
112
                $texy->registerLinePattern(
1✔
113
                        $this->parseSupSub(...),
1✔
114
                        '~
1✔
115
                                (?<= [a-z] )                     # preceded by letter
116
                                _
117
                                ( [n0-9]{1,3} )                  # 1-3 digits or n (1)
118
                                (?! [a-z0-9] )                   # not followed by letter or number
119
                        ~Uix',
120
                        Syntax::SubscriptShort,
1✔
121
                );
122

123
                // acronym/abbr "et al."((and others))
124
                $texy->registerLinePattern(
1✔
125
                        $this->parseAcronym(...),
1✔
126
                        '~
127
                                (?<! " )                         # not preceded by "
128
                                "
129
                                (?! \s )                         # not followed by space
130
                                ( (?: [^\r\n "]++ | [ ] )+ )     # content (1)
131
                                ' . Patterns::Modifier . '?      # modifier (2)
1✔
132
                                (?<! \s )                        # not preceded by space
133
                                "
134
                                (?! " )                          # not followed by "
135
                                \(\(
136
                                ( .+ )                           # explanation (3)
137
                                \)\)
138
                        ~Ux',
139
                        Syntax::AbbreviationQuotes,
1✔
140
                );
141

142
                // acronym/abbr NATO((North Atlantic Treaty Organisation))
143
                $texy->registerLinePattern(
1✔
144
                        $this->parseAcronym(...),
1✔
145
                        '~
146
                                (?<! [' . Patterns::Letter . '] )  # not preceded by char
147
                                ( [' . Patterns::Letter . ']{2,} ) # at least 2 chars (1)
1✔
148
                                ()                               # modifier placeholder (2)
149
                                \(\(
150
                                ( (?: [^\n )]++ | [ )] )+ )      # explanation (3)
151
                                \)\)
152
                        ~Ux',
153
                        Syntax::Abbreviation,
1✔
154
                );
155

156
                // ''notexy''
157
                $texy->registerLinePattern(
1✔
158
                        $this->parseNoTexy(...),
1✔
159
                        '~
1✔
160
                                (?<! \' )                         # not preceded by quote
161
                                \'\'
162
                                (?! [\s\'] )                      # not followed by space or quote
163
                                ( (?: [^\r\n\']++ | \' )+ )       # content (1)
164
                                (?<! [\s\'] )                     # not preceded by space or quote
165
                                \'\'
166
                                (?! \' )                          # not followed by quote
167
                        ~Ux',
168
                        Syntax::Raw,
1✔
169
                );
170

171
                // `code`
172
                $texy->registerLinePattern(
1✔
173
                        $this->parseCode(...),
1✔
174
                        '~
175
                                `
176
                                ( \S (?: [^\r\n `]++ | [ `] )* )        # content (1)
177
                                ' . Patterns::Modifier . '?             # modifier (2)
178
                                (?<! \s )                               # not preceded by space
179
                                `
180
                                (?: : (' . Patterns::LinkUrl . ') )??  # optional link (3)
1✔
181
                        ~Ux',
182
                        Syntax::Code,
1✔
183
                );
184

185
                // ....:LINK
186
                $texy->registerLinePattern(
1✔
187
                        $this->parseLink(...),
1✔
188
                        '~
189
                                ( [' . Patterns::Letter . '0-9@#$%&.,_-]++ )  # allowed chars (1)
190
                                ()                                    # modifier placeholder (2)
191
                                : (?= \[ )                            # followed by :[
192
                                (' . Patterns::LinkUrl . ')          # link (3)
1✔
193
                        ~Ux',
194
                        Syntax::QuickLink,
1✔
195
                );
196

197
                // [text |link]
198
                $texy->registerLinePattern(
1✔
199
                        $this->parseWikilink(...),
1✔
200
                        '~
201
                                (?<! \[ )                        # not preceded by [
202
                                \[
203
                                (?! [\s*] )                      # not followed by space or *
204
                                ( [^|\r\n\]]++ )                 # text (1)
205
                                \|
206
                                ( (?: [^|\r\n \]]++ | [ ] )+ )   # link (2)
207
                                ' . Patterns::Modifier . '?      # modifier (3)
1✔
208
                                (?<! \s )                        # not preceded by space
209
                                ]
210
                                (?! ] )                          # not followed by ]
211
                        ~Ux',
212
                        Syntax::WikiLink,
1✔
213
                );
214

215
                // [text](link)
216
                $texy->registerLinePattern(
1✔
217
                        $this->parseLink(...),
1✔
218
                        '~
219
                                (?<! [[.] )                     # not preceded by [ or .
220
                                \[
221
                                (?! [\s*] )                     # not followed by space or *
222
                                ( (?: [^|\r\n \]]++ | [ ] )+ )  # text (1)
223
                                ' . Patterns::Modifier . '?     # modifier (2)
1✔
224
                                (?<! \s )                       # not preceded by space
225
                                ]
226
                                \(
227
                                ( (?: [^\r )]++ | [ ] )+ )      # link (3)
228
                                \)
229
                        ~Ux',
230
                        Syntax::MarkdownLink,
1✔
231
                );
232

233
                // \* escaped asterisk
234
                $texy->registerLinePattern(
1✔
235
                        fn($context, $matches, $offsets) => new TextNode('*', new Range($offsets[0], 2)),
1✔
236
                        '~\\\\\*~',
1✔
237
                        Syntax::EscapedAsterisk,
1✔
238
                );
239
        }
1✔
240

241

242
        /**
243
         * Builds the pattern of a paired phrase from its delimiter spec:
244
         *
245
         *   (?<! guard ) open (?! space or char ) content modifier? (?<! space or guard ) close (?! char ) (:link)??
246
         *
247
         * @param  array<string, string|bool>  $spec
248
         */
249
        private static function buildPhrasePattern(array $spec): string
1✔
250
        {
251
                $char = (string) $spec['char'];
1✔
252
                $closeChar = (string) ($spec['closeChar'] ?? $char);
1✔
253
                $guard = (string) ($spec['guard'] ?? '');
1✔
254
                $guardAfter = (string) ($spec['guardAfter'] ?? '');
1✔
255

256
                $beforeOpen = $spec['beforeOpen'] ?? self::charClass($char . $guard);
1✔
257
                $afterOpen = $spec['afterOpen'] ?? self::charClass('\s' . $char . $guardAfter);
1✔
258
                $beforeClose = $spec['beforeClose'] ?? self::charClass('\s' . $closeChar . $guard);
1✔
259
                $afterClose = $spec['afterClose'] ?? self::charClass($closeChar . $guardAfter);
1✔
260
                $content = $spec['content'] ?? (empty($spec['multiline']) ? '\r\n' : '') . ' ' . $closeChar;
1✔
261
                $contentPair = $spec['contentPair'] ?? ' ' . $closeChar;
1✔
262

263
                return '~
264
                                (?<! ' . $beforeOpen . ' ) ' . $spec['open'] . ' (?! ' . $afterOpen . ' )  # opening delimiter
1✔
265
                                ( (?: [^' . $content . ']++ | [' . $contentPair . '] )+ )  # content (1)
1✔
266
                                ' . Patterns::Modifier . '?  # modifier (2)
1✔
267
                                (?<! ' . $beforeClose . ' ) ' . ($spec['close'] ?? $spec['open']) . ' (?! ' . $afterClose . ' )  # closing delimiter'
1✔
268
                        . (empty($spec['link'])
1✔
269
                                ? ''
1✔
270
                                : '
271
                                (?: :(' . Patterns::LinkUrl . ') )??  # optional link (3)')
1✔
272
                        . '
1✔
273
                        ~U' . (empty($spec['multiline']) ? '' : 's') . 'x';
1✔
274
        }
275

276

277
        /**
278
         * Wraps characters in a class, unless a single (possibly escaped) one speaks for itself.
279
         */
280
        private static function charClass(string $chars): string
1✔
281
        {
282
                return preg_match('#^(\\\.|[^\\\])$#D', $chars)
1✔
283
                        ? $chars
1✔
284
                        : '[' . $chars . ']';
1✔
285
        }
286

287

288
        /**
289
         * Parses phrase patterns.
290
         * @param  array<?string>  $matches
291
         * @param  array<?int>  $offsets
292
         */
293
        public function parsePhrase(
1✔
294
                ParseContext $context,
295
                array $matches,
296
                array $offsets,
297
                string $phrase,
298
        ): PhraseNode|LinkNode|null
299
        {
300
                /** @var array{string, string, ?string, ?string} $matches */
301
                [, $mContent, $mMod, $mLink] = $matches + [2 => null, 3 => null];
1✔
302
                $range = new Range($offsets[0], strlen($matches[0]));
1✔
303
                $contentOffset = $offsets[1] ?? $offsets[0];
1✔
304

305
                // For phrase/span and phrase/span-alt, URL makes it a link
306
                if ($phrase === Syntax::SpanQuotes || $phrase === Syntax::SpanTilde) {
1✔
307
                        if ($mLink !== null) {
1✔
308
                                $content = $context->parseInline(trim($mContent), $contentOffset);
1✔
309
                                return new LinkNode($mLink, $content, Modifier::parse($mMod, $offsets[2] ?? null), $range);
1✔
310

311
                        } elseif ($mMod === null) {
1✔
312
                                return null;
1✔
313
                        }
314
                }
315

316
                $mod = Modifier::parse($mMod, $offsets[2] ?? null);
1✔
317
                $content = $context->parseInline(trim($mContent), $contentOffset);
1✔
318

319
                // Other phrases with link
320
                if ($mLink !== null) {
1✔
321
                        // Wrap phrase in link
322
                        $phraseNode = new PhraseNode($content, $phrase, $mod, $range);
1✔
323
                        return new LinkNode($mLink, new ContentNode([$phraseNode]), null, $range);
1✔
324
                }
325

326
                return new PhraseNode($content, $phrase, $mod, $range);
1✔
327
        }
328

329

330
        /**
331
         * Parses code phrase.
332
         * @param  array<?string>  $matches
333
         * @param  array<?int>  $offsets
334
         */
335
        public function parseCode(ParseContext $context, array $matches, array $offsets, string $phrase): PhraseNode
1✔
336
        {
337
                /** @var array{string, string, ?string} $matches */
338
                [, $mContent, $mMod] = $matches + [2 => null];
1✔
339
                $contentOffset = $offsets[1] ?? $offsets[0];
1✔
340
                // Code content is not parsed recursively
341
                $content = [new TextNode($mContent, new Range($contentOffset, strlen($mContent)))];
1✔
342
                return new PhraseNode(
1✔
343
                        new ContentNode($content),
1✔
344
                        $phrase,
345
                        Modifier::parse($mMod, $offsets[2] ?? null),
1✔
346
                        new Range($offsets[0], strlen($matches[0])),
1✔
347
                );
348
        }
349

350

351
        /**
352
         * Parses superscript/subscript alternative (m^2, H_2O).
353
         * @param  array<?string>  $matches
354
         * @param  array<?int>  $offsets
355
         */
356
        public function parseSupSub(ParseContext $context, array $matches, array $offsets, string $phrase): PhraseNode
1✔
357
        {
358
                /** @var array{string, string} $matches */
359
                [, $mContent] = $matches;
1✔
360
                $mContent = str_replace('-', "\u{2212}", $mContent); // &minus;
1✔
361
                $content = [new TextNode(Texy\Helpers::decodeEntities($mContent), new Range($offsets[1] ?? $offsets[0], strlen($matches[1])))];
1✔
362
                return new PhraseNode(
1✔
363
                        new ContentNode($content),
1✔
364
                        $phrase,
365
                        null,
1✔
366
                        new Range($offsets[0], strlen($matches[0])),
1✔
367
                );
368
        }
369

370

371
        /**
372
         * Parses acronym/abbr.
373
         * @param  array<?string>  $matches
374
         * @param  array<?int>  $offsets
375
         */
376
        public function parseAcronym(ParseContext $context, array $matches, array $offsets, string $phrase): PhraseNode
1✔
377
        {
378
                /** @var array{string, string, ?string, string} $matches */
379
                [, $mContent, $mMod, $mTitle] = $matches + [2 => null, 3 => ''];
1✔
380
                $contentOffset = $offsets[1] ?? $offsets[0];
1✔
381

382
                $mod = Modifier::parse($mMod, $offsets[2] ?? null) ?? new Modifier;
1✔
383
                $mod->title = trim(Texy\Helpers::unescapeHtml($mTitle));
1✔
384
                $content = [new TextNode(Texy\Helpers::decodeEntities(trim($mContent)), new Range($contentOffset, strlen(trim($mContent))))];
1✔
385

386
                return new PhraseNode(new ContentNode($content), $phrase, $mod, new Range($offsets[0], strlen($matches[0])));
1✔
387
        }
388

389

390
        /**
391
         * Parses ''notexy''.
392
         * @param  array<?string>  $matches
393
         * @param  array<?int>  $offsets
394
         */
395
        public function parseNoTexy(ParseContext $context, array $matches, array $offsets): RawTextNode
1✔
396
        {
397
                /** @var array{string, string} $matches */
398
                [, $mContent] = $matches;
1✔
399
                return new RawTextNode($mContent, new Range($offsets[0], strlen($matches[0])));
1✔
400
        }
401

402

403
        /**
404
         * Parses links (phrase/quicklink, phrase/markdown).
405
         * Note: Markdown links don't support modifiers - modifier is intentionally ignored.
406
         * @param  array<?string>  $matches
407
         * @param  array<?int>  $offsets
408
         */
409
        public function parseLink(ParseContext $context, array $matches, array $offsets): LinkNode
1✔
410
        {
411
                /** @var array{string, string, ?string, string} $matches */
412
                [, $mContent, $mMod, $mLink] = $matches + [2 => null, 3 => null];
1✔
413
                return new LinkNode(
1✔
414
                        trim($mLink),
1✔
415
                        $context->parseInline(trim($mContent), $offsets[1] ?? $offsets[0]),
1✔
416
                        null,  // quicklink and markdown links don't support modifiers
1✔
417
                        new Range($offsets[0], strlen($matches[0])),
1✔
418
                );
419
        }
420

421

422
        /**
423
         * Parses wikilink [text|link].
424
         * Note: Wikilinks don't support modifiers - modifier is intentionally ignored.
425
         * @param  array<?string>  $matches
426
         * @param  array<?int>  $offsets
427
         */
428
        public function parseWikilink(ParseContext $context, array $matches, array $offsets): LinkNode
1✔
429
        {
430
                /** @var array{string, string, string} $matches */
431
                [, $mContent, $mLink] = $matches;
1✔
432
                return new LinkNode(
1✔
433
                        trim($mLink),
1✔
434
                        $context->parseInline(trim($mContent), $offsets[1] ?? $offsets[0]),
1✔
435
                        null,  // wikilinks don't support modifiers
1✔
436
                        new Range($offsets[0], strlen($matches[0])),
1✔
437
                );
438
        }
439

440

441
        /**
442
         * @deprecated use $texy->htmlOutput->phraseTags etc. instead
443
         */
444
        public function &__get(string $name): mixed
1✔
445
        {
446
                return Compat\Legacy::ref($this->texy, Compat\Legacy::OfModule['phraseModule'], '$texy->phraseModule', $name, 'read');
1✔
447
        }
448

449

450
        /**
451
         * @deprecated use $texy->htmlOutput->phraseTags etc. instead
452
         */
453
        public function __set(string $name, mixed $value): void
454
        {
UNCOV
455
                Compat\Legacy::set($this->texy, Compat\Legacy::OfModule['phraseModule'], '$texy->phraseModule', $name, $value);
×
456
        }
457

458

459
        public function __isset(string $name): bool
460
        {
UNCOV
461
                return Compat\Legacy::isSet($this->texy, Compat\Legacy::OfModule['phraseModule'], $name);
×
462
        }
463
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc