PreParser::safeguardBrackets()   A
last analyzed

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