Passed
Push — master ( af11e6...9ff364 )
by Sebastian
03:46
created

PreParser::safeguardBrackets()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 6
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 4
c 0
b 0
f 0
nc 1
nop 1
dl 0
loc 6
rs 10
1
<?php
2
/**
3
 * File containing the class {\Mailcode\Mailcode_Parser_StringPreProcessor}.
4
 *
5
 * @package Mailcode
6
 * @subpackage Parser
7
 * @see \Mailcode\Mailcode_Parser_StringPreProcessor
8
 */
9
10
declare(strict_types=1);
11
12
namespace Mailcode\Parser;
13
14
use Mailcode\Mailcode;
15
use Mailcode\Mailcode_Collection;
16
use Mailcode\Mailcode_Commands_CommonConstants;
17
use Mailcode\Mailcode_Exception;
18
use Mailcode\Mailcode_Parser_Exception;
19
use Mailcode\Parser\PreParser\CommandDef;
20
use Mailcode\Parser\PreParser\Debugger;
21
use function AppUtils\sb;
22
use function Mailcode\t;
23
24
/**
25
 * Does a first parsing pass of the subject string, to
26
 * handle all commands that can have content (like the
27
 * `code` command).
28
 *
29
 * These are handled differently to avoid their contents
30
 * from being parsed by the main parser. To ensure that
31
 * no changes are made to their contents, this is the very
32
 * first step in the parsing process.
33
 *
34
 * This makes it possible for these commands to contain
35
 * Mailcode or any other form of content, without interfering
36
 * in any way with the rest of the Mailcode in the document.
37
 *
38
 * In oder for the parsing of these commands to work, their
39
 * syntax is slightly different. The closing tag must use
40
 * the same name as the command, for example:
41
 *
42
 * <pre>
43
 * {code: "ApacheVelocity"}
44
 *     (Some velocity code here)
45
 * {code}
46
 * </pre>
47
 *
48
 * The preprocessor extracts the information, and replaces
49
 * the command in the string with a regular command that the
50
 * main parser can parse as per normal.
51
 *
52
 * @package Mailcode
53
 * @subpackage Parser
54
 * @author Sebastian Mordziol <[email protected]>
55
 */
56
class PreParser
57
{
58
    public const ERROR_CONTENT_ID_NOT_FOUND = 101401;
59
60
    private string $subject;
61
    private Mailcode_Collection $collection;
62
    private static int $contentCounter = 0;
63
    private bool $parsed = false;
64
65
    /**
66
     * @var CommandDef[]
67
     */
68
    private array $commands = array();
69
70
    /**
71
     * @var array<int,string>
72
     */
73
    private static array $contents = array();
74
75
    private Debugger $debugger;
76
77
    public function __construct(string $subject, Mailcode_Collection $collection)
78
    {
79
        $this->subject = $subject;
80
        $this->collection = $collection;
81
        $this->debugger = new Debugger();
82
    }
83
84
    /**
85
     * @return Mailcode_Collection
86
     */
87
    public function getCollection() : Mailcode_Collection
88
    {
89
        return $this->collection;
90
    }
91
92
    public function isValid() : bool
93
    {
94
        return $this->collection->isValid();
95
    }
96
97
    /**
98
     * Resets the stored contents and content counter.
99
     *
100
     * NOTE: Used primarily for the unit tests.
101
     */
102
    public static function reset() : void
103
    {
104
        self::$contentCounter = 0;
105
        self::$contents = array();
106
    }
107
108
    /**
109
     * Fetches the content string stored under the specified ID.
110
     *
111
     * @param int $id
112
     * @return string
113
     *
114
     * @throws Mailcode_Parser_Exception
115
     * @see PreParser::ERROR_CONTENT_ID_NOT_FOUND
116
     */
117
    public static function getContent(int $id) : string
118
    {
119
        if(isset(self::$contents[$id]))
120
        {
121
            return self::$contents[$id];
122
        }
123
124
        throw new Mailcode_Parser_Exception(
125
            'Command content not found',
126
            sprintf(
127
                'The content stored under ID [%s] does not exist.',
128
                $id
129
            ),
130
            self::ERROR_CONTENT_ID_NOT_FOUND
131
        );
132
    }
133
134
    /**
135
     * Removes the content string stored under the specified ID, if it exists.
136
     *
137
     * @param int $id
138
     * @return void
139
     */
140
    public static function clearContent(int $id) : void
141
    {
142
        if(isset(self::$contents[$id]))
143
        {
144
            unset(self::$contents[$id]);
145
        }
146
    }
147
148
    public static function getContentCounter() : int
149
    {
150
        return self::$contentCounter;
151
    }
152
153
    /**
154
     * @return $this
155
     * @throws Mailcode_Exception
156
     */
157
    public function parse() : self
158
    {
159
        if($this->parsed)
160
        {
161
            return $this;
162
        }
163
164
        $this->parsed = true;
165
        $this->subject = self::safeguardBrackets($this->subject);
166
167
        $this->detectCommands();
168
169
        foreach($this->commands as $commandDef)
170
        {
171
            $this->processCommand($commandDef);
172
        }
173
174
        $this->validateCommandContents();
175
176
        $this->subject = self::restoreBrackets($this->subject);
0 ignored issues
show
Bug introduced by
It seems like $this->subject can also be of type array; however, parameter $subject of Mailcode\Parser\PreParser::restoreBrackets() 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

176
        $this->subject = self::restoreBrackets(/** @scrutinizer ignore-type */ $this->subject);
Loading history...
177
178
        return $this;
179
    }
180
181
    /**
182
     * @return string[]
183
     * @throws Mailcode_Exception
184
     */
185
    private function getContentCommandNames() : array
186
    {
187
        $commands = Mailcode::create()->getCommands()->getContentCommands();
188
        $result = array();
189
190
        foreach($commands as $command)
191
        {
192
            $result[] = $command->getName();
193
        }
194
195
        return $result;
196
    }
197
198
    /**
199
     * @var array<string,string>
200
     */
201
    private static array $escapeChars = array(
202
        '\{' => '__BRACKET_OPEN__',
203
        '\}' => '__BRACKET_CLOSE__'
204
    );
205
206
    public static function safeguardBrackets(string $subject) : string
207
    {
208
        return str_replace(
209
            array_keys(self::$escapeChars),
210
            array_values(self::$escapeChars),
211
            $subject
212
        );
213
    }
214
215
    public static function restoreBrackets(string $subject) : string
216
    {
217
        return str_replace(
218
            array_values(self::$escapeChars),
219
            array_keys(self::$escapeChars),
220
            $subject
221
        );
222
    }
223
224
    public static function unescapeBrackets(string $subject) : string
225
    {
226
        return str_replace(
227
            array(
228
                '\{',
229
                '\}'
230
            ),
231
            array(
232
                '{',
233
                '}'
234
            ),
235
            $subject
236
        );
237
    }
238
239
    public function countCommands() : int
240
    {
241
        $this->parse();
242
243
        return count($this->commands);
244
    }
245
246
    public function getString() : string
247
    {
248
        return $this->subject;
249
    }
250
251
    /**
252
     * @return CommandDef[]
253
     */
254
    public function getCommands() : array
255
    {
256
        $this->parse();
257
258
        return $this->commands;
259
    }
260
261
    private function validateCommandContents() : void
262
    {
263
        foreach($this->commands as $command)
264
        {
265
            if(!$command->validateContent($this, $this->collection))
266
            {
267
                return;
268
            }
269
        }
270
    }
271
272
    private function detectCommands() : void
273
    {
274
        $openingCommands = $this->detectOpeningCommands();
275
        $closingCommands = $this->detectClosingCommands();
276
277
        if(!$this->validateCommandsList($openingCommands, $closingCommands))
278
        {
279
            return;
280
        }
281
282
        foreach($openingCommands as $idx => $def)
283
        {
284
            $this->commands[] = new CommandDef(
285
                $def['name'],
286
                $def['matchedText'],
287
                $def['parameters'],
288
                $closingCommands[$idx]['matchedText']
289
            );
290
        }
291
    }
292
293
    /**
294
     * @param array<int,array{matchedText:string,name:string,parameters:string}> $openingCommands
295
     * @param array<int,array{name:string,matchedText:string}> $closingCommands
296
     * @return bool
297
     */
298
    private function validateCommandsList(array $openingCommands, array $closingCommands) : bool
299
    {
300
        $opening = count($openingCommands);
301
        $closing = count($closingCommands);
302
        $max = $opening;
303
        if($closing > $max) {
304
            $max = $closing;
305
        }
306
307
        for($i=0; $i < $max; $i++)
308
        {
309
            // command closed that was never opened
310
            if(!isset($openingCommands[$i]))
311
            {
312
                return $this->addErrorClosedNeverOpened($closingCommands[$i]['matchedText']);
313
            }
314
315
            // command opened that was never closed
316
            if(!isset($closingCommands[$i]))
317
            {
318
                return $this->addErrorNeverClosed(
319
                    $openingCommands[$i]['matchedText'],
320
                    $openingCommands[$i]['name']
321
                );
322
            }
323
324
            // command closed does not match opening
325
            if($openingCommands[$i]['name'] !== $closingCommands[$i]['name'])
326
            {
327
                return $this->addErrorClosingMismatch(
328
                    $openingCommands[$i]['name'],
329
                    $openingCommands[$i]['matchedText'],
330
                    $closingCommands[$i]['matchedText']
331
                );
332
            }
333
        }
334
335
        return true;
336
    }
337
338
    /**
339
     * @return array<int,array{matchedText:string,name:string,parameters:string}>
340
     */
341
    private function detectOpeningCommands() : array
342
    {
343
        $regex = sprintf(
344
            '/{\s*(%s)\s*:([^}]+)}/sixU',
345
            implode('|', $this->getContentCommandNames())
346
        );
347
348
        preg_match_all($regex, $this->subject, $matches);
349
350
        $result = array();
351
352
        foreach ($matches[0] as $idx => $matchedText)
353
        {
354
            $result[(int)$idx] = array(
355
                'matchedText' => (string)$matchedText,
356
                'name' => strtolower(trim((string)$matches[1][$idx])),
357
                'parameters' => trim((string)$matches[2][$idx])
358
            );
359
        }
360
361
        $this->debugger->debugOpeningCommands($matches);
362
363
        return $result;
364
    }
365
366
    /**
367
     * @return array<int,array{name:string,matchedText:string}>
368
     */
369
    private function detectClosingCommands() : array
370
    {
371
        $regex = sprintf(
372
            '/{\s*(%s)\s*}/sixU',
373
            implode('|', $this->getContentCommandNames())
374
        );
375
376
        preg_match_all($regex, $this->subject, $matches);
377
378
        $result = array();
379
380
        foreach($matches[0] as $idx => $matchedText)
381
        {
382
            $result[] = array(
383
                'name' => strtolower(trim((string)$matches[1][$idx])),
384
                'matchedText' => $matchedText
385
            );
386
        }
387
388
        $this->debugger->debugClosingCommands($result);
389
390
        return $result;
391
    }
392
393
    private function addClosingError(string $name) : void
0 ignored issues
show
Unused Code introduced by
The method addClosingError() is not used, and could be removed.

This check looks for private methods that have been defined, but are not used inside the class.

Loading history...
394
    {
395
        $this->collection->addErrorMessage(
396
            '',
397
            (string)sb()
398
                ->t('Incorrectly closed content command:')
399
                ->t(
400
                    'Please ensure that each of the commands has a matching %1$s closing tag.',
401
                    sb()->code('{'.$name.'}')
402
                ),
403
            Mailcode_Commands_CommonConstants::VALIDATION_MISSING_CONTENT_CLOSING_TAG
404
        );
405
    }
406
407
    private function processCommand(CommandDef $commandDef) : void
408
    {
409
        $commandDef->extractContent($this->subject);
410
411
        $this->debugger->debugCommandDef($commandDef);
412
413
        // Replace the original command and content with the replacement command
414
        $this->subject = substr_replace(
415
            $this->subject,
416
            $commandDef->getReplacementCommand(),
417
            $commandDef->getStartPos(),
418
            $commandDef->getLength()
419
        );
420
    }
421
422
    /**
423
     * Stores the content of the command. The command will retrieve
424
     * it using {@see PreParser::getContent()} when
425
     * it is created by the main parser.
426
     *
427
     * @param string $content
428
     * @return int
429
     */
430
    public static function storeContent(string $content) : int
431
    {
432
        self::$contentCounter++;
433
434
        self::$contents[self::$contentCounter] = self::restoreBrackets($content);
435
436
        return self::$contentCounter;
437
    }
438
439
    /**
440
     * @param string $matchedText
441
     * @return false
442
     */
443
    private function addErrorClosedNeverOpened(string $matchedText) : bool
444
    {
445
        $this->collection->addErrorMessage(
446
            $matchedText,
447
            t('The closing command has no matching opening command.'),
448
            Mailcode_Commands_CommonConstants::VALIDATION_MISSING_CONTENT_OPENING_TAG
449
        );
450
        return false;
451
    }
452
453
    /**
454
     * @param string $matchedText
455
     * @param string $name
456
     * @return false
457
     */
458
    private function addErrorNeverClosed(string $matchedText, string $name) : bool
459
    {
460
        $this->collection->addErrorMessage(
461
            $matchedText,
462
            t(
463
                'The command is never closed with a matching %1$s command.',
464
                sb()->code('{' . $name . '}')
465
            ),
466
            Mailcode_Commands_CommonConstants::VALIDATION_MISSING_CONTENT_CLOSING_TAG
467
        );
468
        return false;
469
    }
470
471
    /**
472
     * @param string $name
473
     * @param string $openingMatchedText
474
     * @param string $closingMatchedText
475
     * @return false
476
     */
477
    private function addErrorClosingMismatch(string $name, string $openingMatchedText, string $closingMatchedText) : bool
478
    {
479
        $this->collection->addErrorMessage(
480
            $openingMatchedText,
481
            (string)sb()
482
                ->t(
483
                    'The command %1$s can not be used to close this command.',
484
                    sb()->code($closingMatchedText)
485
                )
486
                ->t(
487
                    'It must be closed with a matching %1$s command.',
488
                    sb()->code('{' . $name . '}')
489
                ),
490
            Mailcode_Commands_CommonConstants::VALIDATION_CONTENT_CLOSING_MISMATCHED_TAG
491
        );
492
        return false;
493
    }
494
}
495