Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
75.65% covered (warning)
75.65%
205 / 271
73.33% covered (warning)
73.33%
11 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
Block_Delimiter
75.65% covered (warning)
75.65%
205 / 271
73.33% covered (warning)
73.33%
11 / 15
203.42
0.00% covered (danger)
0.00%
0 / 1
 next_delimiter
82.91% covered (warning)
82.91%
97 / 117
0.00% covered (danger)
0.00%
0 / 1
39.77
 scan_delimiters
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
11
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 find_html_comment_end
64.71% covered (warning)
64.71%
11 / 17
0.00% covered (danger)
0.00%
0 / 1
12.56
 freeform_pair
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
1
 get_last_error
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_last_json_error
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 allocate_and_detach_from_source_text
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 get_delimiter_type
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 has_void_flag
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_block_type
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
10.02
 allocate_and_return_block_type
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 get_attributes
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 allocate_and_return_parsed_attributes
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
 debug_print_structure
0.00% covered (danger)
0.00%
0 / 39
0.00% covered (danger)
0.00%
0 / 1
90
1<?php
2/**
3 * Efficiently working with block structure.
4 *
5 * @package automattic/block-delimiter
6 */
7
8declare( strict_types = 1 );
9
10namespace Automattic;
11
12use Exception;
13
14/**
15 * Class for efficiently working with block structure.
16 *
17 * This class follows design values of the HTML API:
18 *  - minimize allocations and strive for zero memory overhead
19 *  - make costs explicit; pay only for what you need
20 *  - follow a streaming, re-entrant design for pausing and aborting
21 *
22 * For usage, jump straight to {@see static::next_delimiter}.
23 */
24class Block_Delimiter {
25    /**
26     * Indicates if the last operation failed, otherwise
27     * will be `null` for success.
28     *
29     * @var string|null
30     */
31    private static $last_error = self::UNINITIALIZED;
32
33    /**
34     * Indicates failures from decoding JSON attributes.
35     *
36     * @var int
37     */
38    private $last_json_error = JSON_ERROR_NONE;
39
40    /**
41     * Holds a reference to the original source text from which to
42     * extract the parsed spans of the delimiter.
43     *
44     * @var string
45     */
46    private $source_text;
47
48    /**
49     * Byte offset into source text where entire delimiter begins.
50     *
51     * @var int
52     */
53    private $delimiter_at;
54
55    /**
56     * Byte length of full span of delimiter.
57     *
58     * @var int
59     */
60    private $delimiter_length;
61
62    /**
63     * Byte offset where namespace span begins.
64     *
65     * @var int
66     */
67    private $namespace_at;
68
69    /**
70     * Byte length of namespace span, or `0` if implicitly in the "core" namespace.
71     *
72     * @var int
73     */
74    private $namespace_length;
75
76    /**
77     * Byte offset where block name span begins.
78     *
79     * @var int
80     */
81    private $name_at;
82
83    /**
84     * Byte length of block name span.
85     *
86     * @var int
87     */
88    private $name_length;
89
90    /**
91     * Whether the delimiter contains the block self-closing flag.
92     *
93     * This may be erroneous if present within a block closer,
94     * therefore the {@see self::has_void_flag} can be used by
95     * calling code to perform appropriate error-handling.
96     *
97     * @var bool
98     */
99    private $has_void_flag = false;
100
101    /**
102     * Byte offset where JSON attributes span begins.
103     *
104     * @var int
105     */
106    private $json_at;
107
108    /**
109     * Byte length of JSON attributes span, or `0` if none are present.
110     *
111     * @var int
112     */
113    private $json_length;
114
115    /**
116     * Indicates what kind of block comment delimiter this represents.
117     *
118     * One of:
119     *
120     *  - `static::OPENER` If the delimiter is opening a block.
121     *  - `static::CLOSER` If the delimiter is closing an open block.
122     *  - `static::VOID`   If the delimiter represents a void block with no inner content.
123     *
124     * If a parsed comment delimiter contains both the closing and the void
125     * flags then it will be interpreted as a void block to match the behavior
126     * of the official block parser, however, this is a mistake and probably
127     * the block ought to close an open block of the same name, if one is open.
128     *
129     * @var string
130     */
131    private $type;
132
133    /**
134     * Finds the next block delimiter in a text document and returns a parsed
135     * block delimiter info record if it parses, otherwise returns `null`.
136     *
137     * Block comment delimiters must be valid HTML comments and may contain JSON.
138     * This search does not determine, however, if the JSON is valid.
139     *
140     * Example delimiters:
141     *
142     *     `<!-- wp:paragraph {"dropCap": true} -->`
143     *     `<!-- wp:separator /-->`
144     *     `<!-- /wp:paragraph -->`
145     *
146     * In the case that a block comment delimiter contains both the void indicator and
147     * also the closing indicator, it will be treated as a void block.
148     *
149     * Example:
150     *
151     *     // Find all image block opening delimiters.
152     *     $at     = 0;
153     *     $end    = strlen( $html );
154     *     $images = array();
155     *     while ( $at < $end ) {
156     *         $delimiter = Block_Delimiter::next_delimiter( $html, $at, $next_at, $next_length );
157     *         if ( ! isset( $delimiter ) ) {
158     *             break;
159     *         }
160     *
161     *         if (
162     *             Block_Delimiter::OPENER === $delimiter->get_delimiter_type() &&
163     *             $delimiter->is_block_type( 'core/image' )
164     *         ) {
165     *             $images[] = $delimiter;
166     *         }
167     *
168     *         $at = $next_at + $next_length;
169     *     }
170     *
171     * @param string   $text                 Input document possibly containing block comment delimiters.
172     * @param int      $starting_byte_offset Where in the input document to begin searching.
173     * @param int|null $match_byte_offset    Optional. When provided, will be set to the byte offset in
174     *                                       the input document where the delimiter was found, if one
175     *                                       is found, otherwise not set.
176     * @param int|null $match_byte_length    Optional. When provided, will be set to the byte length of
177     *                                       the matched delimiter if one is found, otherwise not set.
178     * @return Block_Delimiter|null Parsed block delimiter info record if found, otherwise `null`.
179     */
180    public static function next_delimiter( string $text, int $starting_byte_offset, ?int &$match_byte_offset = null, ?int &$match_byte_length = null ): ?Block_Delimiter {
181        $end                = strlen( $text );
182        $at                 = $starting_byte_offset;
183        $delimiter          = null;
184        static::$last_error = null;
185
186        while ( $at < $end ) {
187            /*
188             * Find the next possible opening.
189             *
190             * This follows the behavior in the official block parser, which treats a post
191             * as a list of blocks with nested HTML. If HTML comment syntax appears within
192             * an HTML attribute value, SCRIPT or STYLE element, or in other select places,
193             * which it can do inside of HTML, then the block parsing may break.
194             *
195             * For a more robust parse scan through the document with the HTML API. In
196             * practice, this has not been a problem in the entire history of blocks.
197             */
198            $comment_opening_at = strpos( $text, '<!--', $at );
199            if ( false === $comment_opening_at ) {
200                return null;
201            }
202
203            $opening_whitespace_at     = $comment_opening_at + 4;
204            $opening_whitespace_length = strspn( $text, " \t\f\r\n", $opening_whitespace_at );
205            if ( 0 === $opening_whitespace_length ) {
206                $at = self::find_html_comment_end( $text, $comment_opening_at, $end );
207                continue;
208            }
209
210            $wp_prefix_at = $opening_whitespace_at + $opening_whitespace_length;
211            if ( $wp_prefix_at >= $end ) {
212                static::$last_error = self::INCOMPLETE_INPUT;
213                return null;
214            }
215
216            $has_closer = false;
217            if ( '/' === $text[ $wp_prefix_at ] ) {
218                $has_closer = true;
219                ++$wp_prefix_at;
220            }
221
222            if ( 0 !== substr_compare( $text, 'wp:', $wp_prefix_at, 3 ) ) {
223                $at = self::find_html_comment_end( $text, $comment_opening_at, $end );
224                continue;
225            }
226
227            $namespace_at = $wp_prefix_at + 3;
228            if ( $namespace_at >= $end ) {
229                static::$last_error = self::INCOMPLETE_INPUT;
230                return null;
231            }
232
233            $start_of_namespace = $text[ $namespace_at ];
234
235            // The namespace must start with a-z.
236            if ( 'a' > $start_of_namespace || 'z' < $start_of_namespace ) {
237                $at = self::find_html_comment_end( $text, $comment_opening_at, $end );
238                continue;
239            }
240
241            $namespace_length = 1 + strspn( $text, 'abcdefghijklmnopqrstuvwxyz0123456789-_', $namespace_at + 1 );
242            $separator_at     = $namespace_at + $namespace_length;
243            if ( $separator_at >= $end ) {
244                static::$last_error = self::INCOMPLETE_INPUT;
245                return null;
246            }
247
248            $has_separator = '/' === $text[ $separator_at ];
249            if ( $has_separator ) {
250                $name_at       = $separator_at + 1;
251                $start_of_name = $text[ $name_at ];
252                if ( 'a' > $start_of_name || 'z' < $start_of_name ) {
253                    $at = self::find_html_comment_end( $text, $comment_opening_at, $end );
254                    continue;
255                }
256
257                $name_length = 1 + strspn( $text, 'abcdefghijklmnopqrstuvwxyz0123456789-_', $name_at + 1 );
258            } else {
259                $name_at          = $namespace_at;
260                $name_length      = $namespace_length;
261                $namespace_length = 0;
262            }
263
264            $after_name_whitespace_at     = $name_at + $name_length;
265            $after_name_whitespace_length = strspn( $text, " \t\f\r\n", $after_name_whitespace_at );
266            if ( 0 === $after_name_whitespace_length ) {
267                $at = self::find_html_comment_end( $text, $comment_opening_at, $end );
268                continue;
269            }
270
271            $json_at = $after_name_whitespace_at + $after_name_whitespace_length;
272            if ( $json_at >= $end ) {
273                static::$last_error = self::INCOMPLETE_INPUT;
274                return null;
275            }
276            $has_json    = '{' === $text[ $json_at ];
277            $json_length = 0;
278
279            /*
280             * For the final span of the delimiter it's most efficient to find the end
281             * of the HTML comment and work backwards. This prevents complicated parsing
282             * inside the JSON span, which cannot contain the HTML comment terminator.
283             *
284             * This also matches the behavior in the official block parser, though it
285             * allows for matching invalid JSON content.
286             */
287            $comment_closing_at = strpos( $text, '-->', $json_at );
288            if ( false === $comment_closing_at ) {
289                static::$last_error = self::INCOMPLETE_INPUT;
290                return null;
291            }
292
293            /*
294             * It looks like this logic leaves an error in here, when the position
295             * overlaps the JSON or block name. However, for neither of those is it
296             * possible to parse a valid block if that last overlapping character
297             * is the void flag. This, therefore, will be valid regardless of how
298             * the rest of the comment delimiter is written.
299             */
300            if ( '/' === $text[ $comment_closing_at - 1 ] ) {
301                $has_void_flag    = true;
302                $void_flag_length = 1;
303            } else {
304                $has_void_flag    = false;
305                $void_flag_length = 0;
306            }
307
308            /*
309             * If there's no JSON, then the span of text after the name
310             * until the comment closing must be completely whitespace.
311             */
312            if ( ! $has_json ) {
313                $max_whitespace_length = $comment_closing_at - $json_at - $void_flag_length;
314
315                // This shouldn't be possible, but it can't be allowed regardless.
316                if ( $max_whitespace_length < 0 ) {
317                    $at = self::find_html_comment_end( $text, $comment_opening_at, $end );
318                    continue;
319                }
320
321                $closing_whitespace_length = strspn( $text, " \t\f\r\n", $json_at, $comment_closing_at - $json_at - $void_flag_length );
322                if ( 0 === $after_name_whitespace_length + $closing_whitespace_length ) {
323                    $at = self::find_html_comment_end( $text, $comment_opening_at, $end );
324                    continue;
325                }
326
327                // This must be a block delimiter!
328                $delimiter = new static();
329                break;
330            }
331
332            // There's no JSON, so attempt to find its boundary.
333            $after_json_whitespace_length = 0;
334            for ( $char_at = $comment_closing_at - $void_flag_length - 1; $char_at > $json_at; $char_at-- ) {
335                $char = $text[ $char_at ];
336
337                switch ( $char ) {
338                    case ' ':
339                    case "\t":
340                    case "\f":
341                    case "\r":
342                    case "\n":
343                        ++$after_json_whitespace_length;
344                        continue 2;
345
346                    case '}':
347                        $json_length = $char_at - $json_at + 1;
348                        break 2;
349
350                    default:
351                        ++$at;
352                        continue 3;
353                }
354            }
355
356            if ( 0 === $json_length || 0 === $after_json_whitespace_length ) {
357                $at = self::find_html_comment_end( $text, $comment_opening_at, $end );
358                continue;
359            }
360
361            // This must be a block delimiter!
362            $delimiter = new static();
363            break;
364        }
365
366        if ( null === $delimiter ) {
367            return null;
368        }
369
370        $delimiter->source_text = $text;
371
372        $delimiter->delimiter_at     = $comment_opening_at;
373        $delimiter->delimiter_length = $comment_closing_at + 3 - $comment_opening_at;
374
375        $delimiter->namespace_at     = $namespace_at;
376        $delimiter->namespace_length = $namespace_length;
377
378        $delimiter->name_at     = $name_at;
379        $delimiter->name_length = $name_length;
380
381        $delimiter->json_at     = $json_at;
382        $delimiter->json_length = $json_length;
383
384        $delimiter->type = $has_closer
385            ? static::CLOSER
386            : ( $has_void_flag ? static::VOID : static::OPENER );
387
388        $delimiter->has_void_flag = $has_void_flag;
389
390        $match_byte_offset = $delimiter->delimiter_at;
391        $match_byte_length = $delimiter->delimiter_length;
392
393        return $delimiter;
394    }
395
396    /**
397     * Generator function to traverse block delimiters in a text and also
398     * yield the position information for the delimiter as it scans.
399     *
400     * Example:
401     *
402     *     foreach ( Block_Delimiter::scan_delimiters( $post_content ) as $where => $delimiter ) {
403     *         echo "Found a {$delimiter->allocate_and_return_block_type()} at {$where[0]} (length is {$where[1]} bytes)\n";
404     *     }
405     *
406     * @param string  $text             Document potentially containing blocks.
407     * @param ?string $freeform_blocks Optional. 'visit' to visit virtual block delimiters for freeform content.
408     * @return \Generator Visits each block delimiter and provides [ at, length ] => delimiter.
409     */
410    public static function scan_delimiters( string $text, ?string $freeform_blocks = 'skip' ): \Generator {
411        $at    = 0;
412        $depth = 0;
413
414        /*
415         * Although `phpcs` confidently asserts that `$match_at` and `$match_length`
416         * are undefined, it is not aware enough to realize that they are set by the
417         * call to `next_delimiter` and so it’s necessary to alter the code so it
418         * doesn’t get confused and reject valid code.
419         */
420        $match_at     = 0;
421        $match_length = 0;
422
423        while ( null !== ( $delimiter = self::next_delimiter( $text, $at, $match_at, $match_length ) ) ) {
424            // Handle top-level text as freeform blocks
425            if ( 0 === $depth && $match_at > $at && 'visit' === $freeform_blocks ) {
426                list( $text_opener, $text_closer ) = static::freeform_pair( $text, $at, $match_at - $at );
427
428                ++$depth;
429                yield [ $at, 0 ] => $text_opener;
430
431                --$depth;
432                yield [ $match_at, 0 ] => $text_closer;
433            }
434
435            $delimiter_type = $delimiter->get_delimiter_type();
436
437            switch ( $delimiter_type ) {
438                case static::OPENER:
439                case static::VOID:
440                    ++$depth;
441                    break;
442
443                case static::CLOSER:
444                    --$depth;
445                    break;
446            }
447
448            yield [ $match_at, $match_length ] => $delimiter;
449
450            if ( static::VOID === $delimiter_type ) {
451                --$depth;
452            }
453
454            $at = $match_at + $match_length;
455        }
456
457        $end = strlen( $text );
458        if ( 'visit' === $freeform_blocks && $at < $end ) {
459            list( $text_opener, $text_closer ) = static::freeform_pair( $text, $at, $end - $at );
460
461            ++$depth;
462            yield [ $at, 0 ] => $text_opener;
463
464            --$depth;
465            yield [ $end, 0 ] => $text_closer;
466        }
467    }
468
469    /**
470     * Constructor function.
471     */
472    private function __construct() {
473        // This is not to be called from the outside.
474    }
475
476    /**
477     * Returns the byte-offset after the ending character of an HTML comment,
478     * assuming the proper starting byte offset.
479     *
480     * @since 0.2.1
481     *
482     * @param string $text                Document in which to search for HTML comment end.
483     * @param int    $comment_starting_at Where the HTML comment started, the leading `<`.
484     * @param int    $search_end          Last offset in which to search, for limiting search span.
485     * @return int Offset after the current HTML comment ends, or `$end` if no end was found.
486     */
487    private static function find_html_comment_end( string $text, int $comment_starting_at, int $search_end ): int {
488        // Find span-of-dashes comments which look like `<!----->`.
489        $span_of_dashes = strspn( $text, '-', $comment_starting_at + 2 );
490        if (
491            $comment_starting_at + 2 + $span_of_dashes < $search_end &&
492            '>' === $text[ $comment_starting_at + 2 + $span_of_dashes ]
493        ) {
494            return $comment_starting_at + $span_of_dashes + 1;
495        }
496
497        // Otherwise, there are other characters inside the comment, find the first `-->` or `--!>`.
498        $now_at = $comment_starting_at + 4;
499        while ( $now_at < $search_end ) {
500            $dashes_at = strpos( $text, '--', $now_at );
501            if ( false === $dashes_at ) {
502                static::$last_error = self::INCOMPLETE_INPUT;
503                return $search_end;
504            }
505
506            $closer_must_be_at = $dashes_at + 2 + strspn( $text, '-', $dashes_at + 2 );
507            if ( $closer_must_be_at < $search_end && '!' === $text[ $closer_must_be_at ] ) {
508                $closer_must_be_at++;
509            }
510
511            if ( $closer_must_be_at < $search_end && '>' === $text[ $closer_must_be_at ] ) {
512                return $closer_must_be_at + 1;
513            }
514
515            $now_at++;
516        }
517
518        return $search_end;
519    }
520
521    /**
522     * Creates a pair of delimiters for freeform text content
523     * since there are no delimiters in a document for them.
524     *
525     * @param string $text   Source document.
526     * @param int    $at     Where the text region starts (byte offset).
527     * @param int    $length How long the text region spans (byte count).
528     * @return static[] Opening and closing block delimiters for the text region.
529     */
530    private static function freeform_pair( string $text, int $at, int $length ): array {
531        $opener                   = new static();
532        $opener->source_text      = $text;
533        $opener->delimiter_at     = $at;
534        $opener->delimiter_length = 0;
535        $opener->namespace_at     = $at;
536        $opener->namespace_length = 0;
537        $opener->name_at          = $at;
538        $opener->name_length      = 0;
539        $opener->json_at          = $at;
540        $opener->json_length      = 0;
541        $opener->type             = static::OPENER;
542        $opener->has_void_flag    = false;
543
544        $closer               = clone $opener;
545        $end_at               = $at + $length;
546        $closer->delimiter_at = $end_at;
547        $closer->namespace_at = $end_at;
548        $closer->name_at      = $end_at;
549        $closer->json_at      = $end_at;
550        $closer->type         = static::CLOSER;
551
552        return [ $opener, $closer ];
553    }
554
555    /**
556     * Indicates if the last attempt to parse a block comment delimiter
557     * failed, if set, otherwise `null` if the last attempt succeeded.
558     *
559     * @return string|null
560     */
561    public static function get_last_error() {
562        return static::$last_error;
563    }
564
565    /**
566     * Indicates if the last attempt to parse a block’s JSON attributes failed.
567     *
568     * @see JSON_ERROR_NONE, JSON_ERROR_DEPTH, etc…
569     *
570     * @return int JSON_ERROR_ code from last attempt to parse block JSON attributes.
571     */
572    public function get_last_json_error(): int {
573        return $this->last_json_error;
574    }
575
576    /**
577     * Allocates a substring from the source text containing the delimiter
578     * and releases the reference to the source text.
579     *
580     * Use this function when the delimiter is holding on to the source
581     * text and preventing it from being freed by PHP. This function incurs
582     * a string allocation; if the source text will be retained anyway then
583     * there's no need to detach as that memory cannot be freed.
584     *
585     * This is a low-level function available for controlling the performance
586     * of sensitive hot-paths. You probably don't need this.
587     *
588     * Example:
589     *
590     *     function first_block( $html ) {
591     *         return Block_Delimiter::next_delimiter( $really_long_html, 0 );
592     *     }
593     *
594     *     $delimiter = first_block( $really_long_html_document );
595     *     // `$really_long_html_document` is still retained inside `$delimiter`, which could lead to a memory leak.
596     *
597     *     $delimiter->allocate_and_detach_from_source_text();
598     *     // `$really_long_html_document` is no longer referenced, and its memory may be freed or used for something else.
599     *
600     * @return void
601     */
602    public function allocate_and_detach_from_source_text(): void {
603        $this->source_text = substr( $this->source_text, $this->delimiter_at, $this->delimiter_length );
604
605        $byte_delta = $this->delimiter_at;
606
607        $this->delimiter_at -= $byte_delta;
608        $this->namespace_at -= $byte_delta;
609        $this->name_at      -= $byte_delta;
610        $this->json_at      -= $byte_delta;
611    }
612
613    /**
614     * Returns the type of the block comment delimiter.
615     *
616     * One of:
617     *
618     *  - `static::OPENER`
619     *  - `static::CLOSER`
620     *  - `static::VOID`
621     *
622     * @return string type of the block comment delimiter.
623     */
624    public function get_delimiter_type(): string {
625        return $this->type;
626    }
627
628    /**
629     * Returns whether the delimiter contains the void flag.
630     *
631     * This should be avoided except in cases of handling errors with
632     * block closers containing the void flag. For normative use,
633     * {@see self::get_delimiter_type}.
634     *
635     * @return bool
636     */
637    public function has_void_flag(): bool {
638        return $this->has_void_flag;
639    }
640
641    /**
642     * Indicates if the block delimiter represents a block of the given type.
643     *
644     * Since the "core" namespace may be implicit, it's allowable to pass
645     * either the fully-qualified block type with namespace and block name
646     * as well as the shorthand version only containing the block name, if
647     * the desired block is in the "core" namespace.
648     *
649     * Example:
650     *
651     *     $is_core_paragraph = $delimiter->is_block_type( 'paragraph' );
652     *     $is_core_paragraph = $delimiter->is_block_type( 'core/paragraph' );
653     *     $is_formula        = $delimiter->is_block_type( 'math-block/formula' );
654     *
655     * @param string $block_type Block type name for the desired block.
656     *                           E.g. "paragraph", "core/paragraph", "math-blocks/formula".
657     * @return bool Whether this delimiter represents a block of the given type.
658     */
659    public function is_block_type( string $block_type ): bool {
660        // This is a core/freeform text block, it’s special.
661        if ( 0 === $this->name_length ) {
662            return 'core/freeform' === $block_type || 'freeform' === $block_type;
663        }
664
665        $slash_at = strpos( $block_type, '/' );
666        if ( false === $slash_at ) {
667            $namespace  = 'core';
668            $block_name = $block_type;
669        } else {
670            $namespace  = substr( $block_type, 0, $slash_at );
671            $block_name = substr( $block_type, $slash_at + 1 );
672        }
673
674        // Only the 'core' namespace is allowed to be omitted.
675        if ( 0 === $this->namespace_length && 'core' !== $namespace ) {
676            return false;
677        }
678
679        // If given an explicit namespace, they must match.
680        if (
681            0 !== $this->namespace_length && (
682                strlen( $namespace ) !== $this->namespace_length ||
683                0 !== substr_compare( $this->source_text, $namespace, $this->namespace_at, $this->namespace_length )
684            )
685        ) {
686            return false;
687        }
688
689        // The block name must match.
690        return (
691            strlen( $block_name ) === $this->name_length &&
692            0 === substr_compare( $this->source_text, $block_name, $this->name_at, $this->name_length )
693        );
694    }
695
696    /**
697     * Allocates a substring for the block type and returns the
698     * fully-qualified name, including the namespace.
699     *
700     * This function allocates a substring for the given block type. This
701     * allocation will be small and likely fine in most cases, but it's
702     * preferable to call {@link static::is_block_type} if only needing
703     * to know whether the delimiter is for a given block type, as that
704     * function is more efficient for this purpose and avoids the allocation.
705     *
706     * Example:
707     *
708     *     'core/paragraph' = $delimiter->allocate_and_return_block_type();
709     *
710     * @return string Fully-qualified block namespace and type, e.g. "core/paragraph".
711     */
712    public function allocate_and_return_block_type(): string {
713        // This is a core/freeform text block, it’s special.
714        if ( 0 === $this->name_length ) {
715            return 'core/freeform';
716        }
717
718        // This is implicitly in the "core" namespace.
719        if ( 0 === $this->namespace_length ) {
720            $block_name = substr( $this->source_text, $this->name_at, $this->name_length );
721            return "core/{$block_name}";
722        }
723
724        return substr( $this->source_text, $this->namespace_at, $this->namespace_length + $this->name_length + 1 );
725    }
726
727    /**
728     * Returns a lazy wrapper around the block attributes, which can be used
729     * for efficiently interacting with the JSON attributes.
730     *
731     * @throws Exception This function is not yet implemented.
732     *
733     * @todo Create a lazy JSON wrapper so specific attributes can be
734     *       efficiently queried without parsing everything and loading
735     *       the entire object into memory.
736     * @todo After realistic benchmarking, see if JsonStreamingParser\Parser
737     *       could be used — it would need to be fast enough for the reduction
738     *       in memory use to be worth it, compared to {@see \json_decode}.
739     *
740     * @see \JsonStreamingParser\Parser
741     *
742     * @return never
743     */
744    public function get_attributes(): void {
745        throw new Exception( 'Lazy attribute parsing not yet supported' );
746    }
747
748    /**
749     * Attempts to parse and return the entire JSON attributes from the delimiter,
750     * allocating memory and processing the JSON span in the process.
751     *
752     * This does not return any parsed attributes for a closing block delimiter
753     * even if there is a span of JSON content; this JSON is a parsing error.
754     *
755     * Consider calling {@link static::get_attributes} instead if it's not
756     * necessary to read all the attributes at the same time, as that provides
757     * a more efficient mechanism for typical use cases.
758     *
759     * Since the JSON span inside the comment delimiter may not be valid JSON,
760     * this function will return `null` if it cannot parse the span and set the
761     * {@see static::get_last_json_error} to the appropriate JSON_ERROR_ constant.
762     *
763     * If the delimiter contains no JSON span, it will also return `null`,
764     * but the last error will be set to {@see JSON_ERROR_NONE}.
765     *
766     * Example:
767     *
768     *     $delimiter = Block_Delimiter::next_delimiter( '<!-- wp:image {"url": "https://wordpress.org/favicon.ico"} -->', 0 );
769     *     $memory_hungry_and_slow_attributes = $delimiter->allocate_and_return_parsed_attributes();
770     *     $memory_hungry_and_slow_attributes === array( 'url' => 'https://wordpress.org/favicon.ico' );
771     *
772     *     $delimiter      = Block_Delimiter::next_delimiter( '<!-- /wp:image {"url": "https://wordpress.org/favicon.ico"} -->', 0 );
773     *     null            = $delimiter->allocate_and_return_parsed_attributes();
774     *     JSON_ERROR_NONE = $delimiter->get_last_json_error();
775     *
776     *     $delimiter = Block_Delimiter::next_delimiter( '<!-- wp:separator {} /-->', 0 );
777     *     array()    === $delimiter->allocate_and_return_parsed_attributes();
778     *
779     *     $delimiter = Block_Delimiter::next_delimiter( '<!-- wp:separator /-->', 0 );
780     *     null       = $delimiter->allocate_and_return_parsed_attributes();
781     *
782     *     $delimiter           = Block_Delimiter::next_delimiter( '<!-- wp:image {"url} -->', 0 );
783     *     null                 = $delimiter->allocate_and_return_parsed_attributes();
784     *     JSON_ERROR_CTRL_CHAR = $delimiter->get_last_json_error();
785     *
786     * @return array|null Parsed JSON attributes, if present and valid, otherwise `null`.
787     */
788    public function allocate_and_return_parsed_attributes(): ?array {
789        $this->last_json_error = JSON_ERROR_NONE;
790
791        if ( static::CLOSER === $this->type ) {
792            return null;
793        }
794
795        if ( 0 === $this->json_length ) {
796            return null;
797        }
798
799        $json_span = substr( $this->source_text, $this->json_at, $this->json_length );
800        $parsed    = json_decode( $json_span, null, 512, JSON_OBJECT_AS_ARRAY | JSON_INVALID_UTF8_SUBSTITUTE );
801
802        $last_error            = json_last_error();
803        $this->last_json_error = $last_error;
804
805        return ( JSON_ERROR_NONE === $last_error && is_array( $parsed ) )
806            ? $parsed
807            : null;
808    }
809
810    // Debugging methods not meant for production use.
811
812    /**
813     * Prints a debugging message showing the structure of the parsed delimiter.
814     *
815     * This is not meant to be used in production!
816     *
817     * @access private
818     */
819    public function debug_print_structure(): void {
820        $c = ( ! defined( 'STDOUT' ) || posix_isatty( STDOUT ) )
821            ? function ( $color = null ) { return $color; } // phpcs:ignore
822            : function ( $color ) { return ''; }; // phpcs:ignore
823
824        if ( $this->is_block_type( 'core/freeform' ) ) {
825            $closer = static::CLOSER === $this->get_delimiter_type() ? '/' : '';
826            echo "{$c( "\e[90m" )}<!-- "; // phpcs:ignore
827            echo "{$c( "\e[0;31m" )}{$closer}"; // phpcs:ignore
828            echo "{$c("\e[90m" )}wp:"; // phpcs:ignore
829            echo "{$c( "\e[0;34m" )}freeform"; // phpcs:ignore
830            echo "{$c( "\e[0;36m" )} {$c("\e[90m")}-->\n"; // phpcs:ignore
831            return;
832        }
833
834        $namespace  = substr( $this->source_text, $this->namespace_at, $this->namespace_length );
835        $slash      = 0 === $this->namespace_length ? '' : '/';
836        $block_name = substr( $this->source_text, $this->name_at, $this->name_length );
837        $closer     = static::CLOSER === $this->type ? '/' : '';
838        $json       = substr( $this->source_text, $this->json_at, $this->json_length );
839
840        $opener_whitespace_at     = $this->delimiter_at + 4;
841        $opener_whitespace_length = $this->namespace_at - 3 - $opener_whitespace_at - ( static::CLOSER === $this->type ? 1 : 0 );
842
843        $after_name_whitespace_at     = $this->name_at + $this->name_length;
844        $after_name_whitespace_length = $this->json_at - $after_name_whitespace_at;
845
846        $closing_whitespace_at     = $this->json_at + $this->json_length;
847        $closing_whitespace_length = $this->delimiter_at + $this->delimiter_length - 3 - $closing_whitespace_at;
848
849        if ( '/' === $this->source_text[ $this->delimiter_at + $this->delimiter_length - 4 ] ) {
850            $void_flag = '/';
851            --$closing_whitespace_length;
852        } else {
853            $void_flag = '';
854        }
855
856        $w = function ( $whitespace ) use ( $c ) {
857            return $c( "\e[2;90m" ) . str_replace( array( ' ', "\t", "\f", "\r", "\n" ), array( '␣', '␉', '␌', '␍', '␤' ), $whitespace );
858        };
859
860        echo "{$c( "\e[90m" )}<!--"; // phpcs:ignore
861        echo $w( substr( $this->source_text, $opener_whitespace_at, $opener_whitespace_length ) ); // phpcs:ignore
862        echo "{$c( "\e[0;31m" )}{$closer}"; // phpcs:ignore
863        echo "{$c("\e[90m" )}wp:{$c( "\e[2;34m" )}{$namespace}"; // phpcs:ignore
864        echo "{$c( "\e[2;90m" )}{$slash}"; // phpcs:ignore
865        echo "{$c( "\e[0;34m" )}{$block_name}"; // phpcs:ignore
866        echo $w( substr( $this->source_text, $after_name_whitespace_at, $after_name_whitespace_length ) ); // phpcs:ignore
867        echo "{$c("\e[0;2;32m" )}{$json}"; // phpcs:ignore
868        echo $w( substr( $this->source_text, $closing_whitespace_at, $closing_whitespace_length ) ); // phpcs:ignore
869        echo "{$c( "\e[0;36m" )}{$void_flag}{$c("\e[90m")}-->\n"; // phpcs:ignore
870    }
871
872    // Constant declarations that would otherwise pollute the top of the class.
873
874    /**
875     * Indicates that the block comment delimiter closes an open block.
876     */
877    const CLOSER = 'closer';
878
879    /**
880     * Indicates that the parser started parsing a block comment delimiter, but
881     * the input document ended before it could finish. The document was likely truncated.
882     */
883    const INCOMPLETE_INPUT = 'incomplete-input';
884
885    /**
886     * Indicates that the block comment delimiter opens a block.
887     */
888    const OPENER = 'opener';
889
890    /**
891     * Indicates that the parser has not yet attempted to parse a block comment delimiter.
892     */
893    const UNINITIALIZED = 'uninitialized';
894
895    /**
896     * Indicates that the block comment delimiter represents a void block
897     * with no inner content of any kind.
898     */
899    const VOID = 'void';
900}