Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
99.19% covered (success)
99.19%
246 / 248
88.89% covered (warning)
88.89%
16 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
Js_Structure_Scanner
99.19% covered (success)
99.19%
246 / 248
88.89% covered (warning)
88.89%
16 / 18
116
0.00% covered (danger)
0.00%
0 / 1
 looks_broken
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 run
95.24% covered (success)
95.24%
20 / 21
0.00% covered (danger)
0.00%
0 / 1
12
 scan_code
100.00% covered (success)
100.00%
68 / 68
100.00% covered (success)
100.00%
1 / 1
30
 scan_string
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
7
 scan_template
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
7
 scan_regex
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
12
 scan_line_comment
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 scan_block_comment
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 is_broken_at_eof
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
5.03
 closes_interpolation
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 regex_allowed_here
100.00% covered (success)
100.00%
48 / 48
100.00% covered (success)
100.00%
1 / 1
10
 record_prev
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 return_to_code
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 peek
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 is_ident_char
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
4
 is_ident_start
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
4
 is_exponent_at
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
6
1<?php
2/**
3 * Heuristic structural validator for minified JavaScript.
4 *
5 * @link       https://automattic.com
6 * @package    automattic/jetpack-boost
7 */
8
9namespace Automattic\Jetpack_Boost\Lib;
10
11/**
12 * Answers one narrow question: does a chunk of minified JS look structurally
13 * broken/truncated? It is NOT a full JS validator (PHP has none built in) -- it
14 * is a single left-to-right lexer pass that tracks string/template/regex/comment
15 * state so brackets inside those are ignored, counts bracket nesting in code, and
16 * reports "broken" when, at EOF, a string/template/regex/block-comment is still
17 * open or brackets are unbalanced. That is exactly the signature of the truncation
18 * corruption the bundled MatthiasMullie minifier produces on modern JS.
19 *
20 * It deliberately fails safe: a "broken" verdict only causes the caller to skip
21 * re-minification for that bundle, so ambiguity is cheap to get wrong.
22 *
23 * Known blind spot: corruption that stays perfectly balanced (e.g. semantic-only
24 * damage that still parses) returns "intact". Those do not crash the page and are
25 * not what this guard targets.
26 *
27 * Known (harmless) false positives: a `/` after `}` is read as a regex, not
28 * division, because telling a block `}` apart from an object-literal `}` needs a
29 * full parser; so valid object-literal division such as `({}/2)` is reported
30 * "broken". This generalizes to any object-literal `}` followed by `/` anywhere
31 * in a source file (`f({}/2)`, `[{}/2]`, `var o={a:1}/2`), and because one
32 * occurrence skips re-minification of that whole file's contribution to the
33 * bundle, the cost is more than a single statement's worth of compression. It is
34 * still fail-safe
35 * (it only skips re-minification, never corrupts output), so the heuristic stays
36 * simple rather than guess at block-vs-expression context; the new fallback
37 * observability hook lets the real-world frequency be measured.
38 */
39class Js_Structure_Scanner {
40
41    /**
42     * Lexer states.
43     */
44    private const ST_CODE          = 0;
45    private const ST_STRING        = 1; // Inside a '...' or "..." string.
46    private const ST_TEMPLATE      = 2; // Inside a `...` template literal.
47    private const ST_REGEX         = 3; // Inside a /.../ regex literal.
48    private const ST_LINE_COMMENT  = 4; // Inside a // comment.
49    private const ST_BLOCK_COMMENT = 5; // Inside a /* */ comment.
50
51    /**
52     * Upper bound (in bytes) on output we will fully scan per call.
53     *
54     * The scan only runs on a cache miss, but where the concatenation cache cannot
55     * store the result (not writable, or at its size limit) every request
56     * re-minifies (and would re-scan), so the per-call cost must stay bounded.
57     * This is set high enough to cover realistic modern bundles
58     * (Tailwind/SPA/plugin-heavy output routinely runs a few MB) so the
59     * truncation signature is actually caught,
60     * rather than silently bypassed for anything above an aggressive cap.
61     *
62     * For the rare bundle larger than this, the full lexer scan is skipped, but a
63     * cheap gross-truncation backstop still runs when the original input is known
64     * (see looks_broken()).
65     */
66    private const MAX_SCAN_BYTES = 8388608; // 8 MB.
67
68    /**
69     * Above MAX_SCAN_BYTES we cannot afford the full scan, but a minified output
70     * that is dramatically smaller than its input is the truncation signature by
71     * itself. Real-world minification shrinks JS by ~20-40%; an output below this
72     * fraction of the original is treated as truncated. The threshold is
73     * deliberately conservative so it only trips on gross truncation, never on
74     * ordinary (even aggressive) minification.
75     *
76     * Blind spot: a size-preserving break above the cap (e.g. an unterminated
77     * template at EOF, which drops only a byte or two) leaves the ratio normal and
78     * is not caught -- and because no fallback fires, the observability hook does not
79     * surface it either. Accepted because minification runs per source file, so the
80     * cap only engages for a pathological multi-MB single source file, a vanishingly
81     * rare input for this corruption.
82     */
83    private const TRUNCATION_RATIO = 0.5;
84
85    /**
86     * Upper bound on simultaneous open brackets / template-interpolation frames.
87     *
88     * $stack and $frames grow with structural nesting depth, which is bounded by
89     * input bytes but not proportional to them: a small file of runaway `${`/`[`/`(`
90     * openers can amplify into hundreds of MB of frame state and trip the PHP memory
91     * limit. That fatal is NOT a \Throwable, so Minify::js()'s try/catch cannot catch
92     * it -- it would white-screen the page, the exact failure this scanner exists to
93     * prevent. Nesting this deep never occurs in legitimate output (real code nests a
94     * few levels), so reaching the cap is itself a corruption/abuse signature: the
95     * scan stops and returns "broken", routing the caller to the fail-safe original
96     * bytes.
97     *
98     * Kept to a few thousand rather than higher: the check runs after each push, so
99     * the cap also bounds peak frame state, and each interp frame is a small array.
100     * A higher cap (e.g. 100k) lets ~150 KB of runaway `${` openers build ~22 MB of
101     * state before tripping -- enough to OOM-fatal a low-memory shared host (Boost's
102     * core audience) before the cap engages, reintroducing the white-screen. At a few
103     * thousand the peak stays well under 1 MB while still sitting orders of magnitude
104     * above any real nesting.
105     */
106    private const MAX_NESTING_DEPTH = 2000;
107
108    /**
109     * JS being scanned and its length.
110     *
111     * @var string
112     */
113    private $js;
114
115    /**
116     * @var int
117     */
118    private $len;
119
120    /**
121     * Current scan offset.
122     *
123     * @var int
124     */
125    private $pos = 0;
126
127    /**
128     * Current lexer state (one of the ST_* constants).
129     *
130     * @var int
131     */
132    private $state = self::ST_CODE;
133
134    /**
135     * The quote character that opened the current string ('  or ").
136     *
137     * @var string
138     */
139    private $string_quote = '';
140
141    /**
142     * Stack of currently open brackets ('{', '(', '[').
143     *
144     * Coordinates with $frames: an `interp` frame snapshots count($stack) at
145     * push time, and closes_interpolation() uses that snapshot to tell a '}'
146     * that ends `${ ... }` apart from one that closes an ordinary block.
147     *
148     * @var string[]
149     */
150    private $stack = array();
151
152    /**
153     * Template / interpolation frames.
154     *
155     * Each frame is one of:
156     *   - array( 'type' => 'template' ): inside the body of a `...` literal.
157     *   - array( 'type' => 'interp', 'depth' => int ): inside `${ ... }`; the
158     *     'depth' field captures count($stack) at the moment the frame was
159     *     pushed, so closes_interpolation() can pair this '}' with the matching
160     *     '${' rather than a sibling code block. See scan_template() and
161     *     closes_interpolation().
162     *
163     * @var array[]
164     */
165    private $frames = array();
166
167    /**
168     * True once an extra or mismatched closing bracket has been seen.
169     *
170     * @var bool
171     */
172    private $unmatched = false;
173
174    /**
175     * True while inside a regex [...] character class.
176     *
177     * @var bool
178     */
179    private $re_class = false;
180
181    /**
182     * Last significant char seen in code state.
183     *
184     * @var string
185     */
186    private $prev = '';
187
188    /**
189     * The significant char before $prev.
190     *
191     * @var string
192     */
193    private $prev_prev = '';
194
195    /**
196     * Last identifier/keyword seen in code state.
197     *
198     * @var string
199     */
200    private $prev_word = '';
201
202    /**
203     * Whether $prev_word was a member access (immediately after a '.').
204     *
205     * @var bool
206     */
207    private $prev_dot = false;
208
209    /**
210     * Whether the given minified JS looks structurally broken/truncated.
211     *
212     * @since 4.6.0
213     *
214     * @param string      $js          Minified JS to inspect.
215     * @param string|null $original_js The pre-minification input, when available. Used
216     *                                 only for the gross-truncation backstop on inputs
217     *                                 too large to scan in full (see MAX_SCAN_BYTES).
218     *
219     * @return bool True if it looks broken; false if it looks intact.
220     */
221    public static function looks_broken( $js, $original_js = null ) {
222        $js = (string) $js;
223
224        // Empty output is handled by the caller.
225        if ( '' === $js ) {
226            return false;
227        }
228
229        // Output larger than the scan budget is not lexed in full. Fall back to a
230        // cheap size-delta check against the original: a minified output far smaller
231        // than its input is the truncation signature even without a full scan. With
232        // no original to compare against, assume intact.
233        $len = strlen( $js );
234        if ( $len > self::MAX_SCAN_BYTES ) {
235            if ( null === $original_js ) {
236                return false;
237            }
238            $original_len = strlen( (string) $original_js );
239            return $original_len > 0 && $len < ( $original_len * self::TRUNCATION_RATIO );
240        }
241
242        return ( new self( $js ) )->run();
243    }
244
245    /**
246     * @param string $js Minified JS to inspect.
247     */
248    private function __construct( $js ) {
249        $this->js  = $js;
250        $this->len = strlen( $js );
251    }
252
253    /**
254     * Run the scan and return the verdict.
255     *
256     * @return bool
257     */
258    private function run() {
259        while ( $this->pos < $this->len ) {
260            switch ( $this->state ) {
261                case self::ST_CODE:
262                    $broken = $this->scan_code();
263                    break;
264                case self::ST_STRING:
265                    $broken = $this->scan_string();
266                    break;
267                case self::ST_TEMPLATE:
268                    $broken = $this->scan_template();
269                    break;
270                case self::ST_REGEX:
271                    $broken = $this->scan_regex();
272                    break;
273                case self::ST_LINE_COMMENT:
274                    $broken = $this->scan_line_comment();
275                    break;
276                case self::ST_BLOCK_COMMENT:
277                    $broken = $this->scan_block_comment();
278                    break;
279                default:
280                    return true; // Unknown state: fail safe.
281            }
282            if ( $broken ) {
283                return true;
284            }
285
286            // Structural nesting this deep never occurs in legitimate output; stop
287            // before unbounded $stack/$frames growth can exhaust memory (an
288            // uncatchable fatal). The depth itself is a corruption signature, so the
289            // fail-safe verdict is "broken".
290            if ( count( $this->stack ) > self::MAX_NESTING_DEPTH
291                || count( $this->frames ) > self::MAX_NESTING_DEPTH ) {
292                return true;
293            }
294        }
295
296        return $this->is_broken_at_eof();
297    }
298
299    /**
300     * Scan one step while in code state.
301     *
302     * @return bool True to short-circuit as broken.
303     */
304    private function scan_code() {
305        $c = $this->js[ $this->pos ];
306
307        // `true`/`false` shortened to !0/!1 before a member access (e.g.
308        // true.toString() -> !0.toString()) is invalid: `0.` is a numeric literal,
309        // so the following identifier is a syntax error. This stays bracket-balanced,
310        // so check it explicitly. Excludes the exponent case: `0.e5` / `0.e+5` is a
311        // valid numeric literal, so `!0.e5` is valid and must not be flagged.
312        if ( '!' === $c
313            && ( '0' === $this->peek( 1 ) || '1' === $this->peek( 1 ) )
314            && '.' === $this->peek( 2 )
315            && self::is_ident_start( $this->peek( 3 ) )
316            && ! $this->is_exponent_at( 3 ) ) {
317            return true;
318        }
319
320        if ( '/' === $c ) {
321            $next = $this->peek();
322            if ( '/' === $next ) {
323                $this->state = self::ST_LINE_COMMENT;
324                $this->pos  += 2;
325                return false;
326            }
327            if ( '*' === $next ) {
328                $this->state = self::ST_BLOCK_COMMENT;
329                $this->pos  += 2;
330                return false;
331            }
332            if ( $this->regex_allowed_here() ) {
333                $this->state    = self::ST_REGEX;
334                $this->re_class = false;
335            }
336            $this->record_prev( '/' );
337            ++$this->pos;
338            return false;
339        }
340
341        if ( "'" === $c || '"' === $c ) {
342            $this->state        = self::ST_STRING;
343            $this->string_quote = $c;
344            ++$this->pos;
345            return false;
346        }
347
348        if ( '`' === $c ) {
349            $this->frames[] = array( 'type' => 'template' );
350            $this->state    = self::ST_TEMPLATE;
351            ++$this->pos;
352            return false;
353        }
354
355        if ( '{' === $c || '(' === $c || '[' === $c ) {
356            $this->stack[] = $c;
357            $this->record_prev( $c );
358            ++$this->pos;
359            return false;
360        }
361
362        if ( '}' === $c || ')' === $c || ']' === $c ) {
363            // A '}' may close a template interpolation (${ ... }) rather than a
364            // code block: the interpolation pushed its own '{' on the stack, so this
365            // '}' closes the interpolation when that '{' is the current stack top.
366            if ( '}' === $c && $this->closes_interpolation() ) {
367                array_pop( $this->frames );
368                array_pop( $this->stack ); // The '{' from ${.
369                $this->state = self::ST_TEMPLATE;
370                $this->record_prev( '`' );
371                ++$this->pos;
372                return false;
373            }
374
375            $expected = ( '}' === $c ) ? '{' : ( ( ')' === $c ) ? '(' : '[' );
376            if ( empty( $this->stack ) || end( $this->stack ) !== $expected ) {
377                $this->unmatched = true;
378            } else {
379                array_pop( $this->stack );
380            }
381            $this->record_prev( $c );
382            ++$this->pos;
383            return false;
384        }
385
386        if ( ctype_space( $c ) ) {
387            ++$this->pos;
388            return false;
389        }
390
391        if ( self::is_ident_char( $c ) ) {
392            $start = $this->pos;
393            while ( $this->pos < $this->len && self::is_ident_char( $this->js[ $this->pos ] ) ) {
394                ++$this->pos;
395            }
396            $this->prev_dot  = ( '.' === $this->prev );
397            $this->prev_prev = $this->prev;
398            $this->prev      = $this->js[ $this->pos - 1 ];
399            $this->prev_word = substr( $this->js, $start, $this->pos - $start );
400            return false;
401        }
402
403        // Any other significant char (operators like + - * . etc.).
404        $this->record_prev( $c );
405        ++$this->pos;
406        return false;
407    }
408
409    /**
410     * Scan one step while inside a '...' or "..." string.
411     *
412     * @return bool True to short-circuit as broken (unterminated string).
413     */
414    private function scan_string() {
415        $c = $this->js[ $this->pos ];
416        if ( '\\' === $c ) {
417            // Escape sequence, including a line continuation (`\` then a line
418            // terminator). A CRLF continuation is three bytes (`\` + CR + LF), so
419            // consume the trailing LF too -- otherwise it is left at the new position
420            // and trips the raw-newline check below, falsely flagging a valid
421            // continuation as broken. A lone CR or LF after `\` is the normal 2-byte
422            // skip.
423            if ( "\r" === $this->peek() && "\n" === $this->peek( 2 ) ) {
424                $this->pos += 3;
425            } else {
426                $this->pos += 2;
427            }
428            return false;
429        }
430        // A raw LF/CR inside a string literal is a syntax error (a real newline must
431        // be escaped or a line continuation), so it never appears in valid minified
432        // output -- only in truncated/corrupted output where the closing quote was
433        // lost and a later quote happened to re-open the state. Checking ASCII LF/CR
434        // is zero-false-positive and keeps the byte-oriented lexer simple.
435        if ( "\n" === $c || "\r" === $c ) {
436            return true; // Unterminated string literal.
437        }
438        if ( $c === $this->string_quote ) {
439            $this->return_to_code( $c );
440        }
441        ++$this->pos;
442        return false;
443    }
444
445    /**
446     * Scan one step while inside a `...` template literal.
447     *
448     * @return bool
449     */
450    private function scan_template() {
451        $c = $this->js[ $this->pos ];
452        if ( '\\' === $c ) {
453            $this->pos += 2;
454            return false;
455        }
456        if ( '`' === $c ) {
457            $top = end( $this->frames );
458            if ( $top && 'template' === $top['type'] ) {
459                array_pop( $this->frames );
460            }
461            $this->return_to_code( '`' );
462            ++$this->pos;
463            return false;
464        }
465        if ( '$' === $c && '{' === $this->peek() ) {
466            $this->frames[] = array(
467                'type'  => 'interp',
468                'depth' => count( $this->stack ),
469            );
470            $this->stack[]  = '{'; // The '{' from ${.
471            $this->return_to_code( '{' );
472            $this->pos += 2;
473            return false;
474        }
475        ++$this->pos;
476        return false;
477    }
478
479    /**
480     * Scan one step while inside a /.../ regex literal.
481     *
482     * @return bool True to short-circuit as broken (unterminated regex).
483     */
484    private function scan_regex() {
485        $c = $this->js[ $this->pos ];
486        if ( '\\' === $c ) {
487            // Unlike a string (where `\<LF>` is a valid line continuation), a line
488            // terminator cannot be escaped inside a regex literal -- a backslash
489            // immediately before a raw LF/CR is the truncation signature. Check the
490            // escaped byte before skipping over the pair.
491            $escaped = $this->peek();
492            if ( "\n" === $escaped || "\r" === $escaped ) {
493                return true; // Unterminated regex literal.
494            }
495            $this->pos += 2;
496            return false;
497        }
498        // A raw line terminator (LF or CR) is invalid anywhere in a regex literal,
499        // including inside a [...] character class, so this check sits above the
500        // re_class branch.
501        if ( "\n" === $c || "\r" === $c ) {
502            return true; // Unterminated regex literal.
503        }
504        if ( $this->re_class ) {
505            if ( ']' === $c ) {
506                $this->re_class = false;
507            }
508            ++$this->pos;
509            return false;
510        }
511        if ( '[' === $c ) {
512            $this->re_class = true;
513            ++$this->pos;
514            return false;
515        }
516        if ( '/' === $c ) {
517            $this->return_to_code( '/' );
518            ++$this->pos;
519            while ( $this->pos < $this->len && ctype_alpha( $this->js[ $this->pos ] ) ) {
520                ++$this->pos; // Skip regex flags.
521            }
522            return false;
523        }
524        ++$this->pos;
525        return false;
526    }
527
528    /**
529     * Scan one step while inside a // line comment.
530     *
531     * @return bool
532     */
533    private function scan_line_comment() {
534        // CR, LF, and CRLF all end a line comment in JS; a bare CR must close it too,
535        // otherwise code after a CR-only line ending is swallowed and broken input
536        // can read as intact. (CRLF closes on the CR; the trailing LF is then a
537        // no-op space in code state.)
538        $c = $this->js[ $this->pos ];
539        if ( "\n" === $c || "\r" === $c ) {
540            $this->state = self::ST_CODE;
541        }
542        ++$this->pos;
543        return false;
544    }
545
546    /**
547     * Scan one step while inside a block comment.
548     *
549     * @return bool
550     */
551    private function scan_block_comment() {
552        if ( '*' === $this->js[ $this->pos ] && '/' === $this->peek() ) {
553            $this->state = self::ST_CODE;
554            $this->pos  += 2;
555            return false;
556        }
557        ++$this->pos;
558        return false;
559    }
560
561    /**
562     * Verdict once the whole input has been consumed.
563     *
564     * @return bool
565     */
566    private function is_broken_at_eof() {
567        // A line comment running to EOF is valid (e.g. a trailing
568        // `//# sourceMappingURL=...` with no final newline). Every other open state
569        // at EOF is a genuinely unterminated construct.
570        if ( self::ST_LINE_COMMENT === $this->state ) {
571            $this->state = self::ST_CODE;
572        }
573
574        if ( self::ST_CODE !== $this->state ) {
575            return true; // Unterminated string/template/regex/block-comment.
576        }
577        if ( ! empty( $this->stack ) ) {
578            return true; // Unbalanced brackets.
579        }
580        if ( ! empty( $this->frames ) ) {
581            return true; // Unterminated template/interpolation.
582        }
583
584        return $this->unmatched; // Saw an extra/mismatched closing bracket.
585    }
586
587    /**
588     * Whether the current '}' closes a template interpolation rather than a block.
589     *
590     * @return bool
591     */
592    private function closes_interpolation() {
593        $top = end( $this->frames );
594        return $top && 'interp' === $top['type'] && ( $top['depth'] + 1 ) === count( $this->stack );
595    }
596
597    /**
598     * Whether a `/` at the current position begins a regex literal (vs division).
599     *
600     * JavaScript cannot decide this locally; lexers approximate it from the
601     * previous significant token: after a value (identifier, number, `)`, `]`,
602     * postfix ++/--) `/` is division; after an operator/keyword that expects an
603     * operand next, `/` begins a regex.
604     *
605     * @return bool
606     */
607    private function regex_allowed_here() {
608        $prev = $this->prev;
609
610        if ( '' === $prev ) {
611            return true; // Start of input.
612        }
613
614        // Postfix ++ / -- yields a value, so a following `/` is division.
615        if ( ( '+' === $prev && '+' === $this->prev_prev ) || ( '-' === $prev && '-' === $this->prev_prev ) ) {
616            return false;
617        }
618
619        // Punctuation/operators after which a regex literal is legal. Includes the
620        // arithmetic/comparison binary operators so real minified expressions like
621        // `x+"|"+/\d{1,2}/.source` (regex after a binary +) are not misread.
622        static $puncts = array(
623            '(' => 1,
624            ',' => 1,
625            '=' => 1,
626            ':' => 1,
627            '[' => 1,
628            '!' => 1,
629            '&' => 1,
630            '|' => 1,
631            '?' => 1,
632            '{' => 1,
633            ';' => 1,
634            '}' => 1,
635            '+' => 1,
636            '-' => 1,
637            '*' => 1,
638            '%' => 1,
639            '<' => 1,
640            '>' => 1,
641            '~' => 1,
642            '^' => 1,
643        );
644        if ( isset( $puncts[ $prev ] ) ) {
645            return true;
646        }
647
648        // After a word: only the keywords below allow a regex, and only when the
649        // word is not a property access (e.g. `a.return/b` is division).
650        if ( self::is_ident_char( $prev ) ) {
651            static $kw = array(
652                'return'     => 1,
653                'throw'      => 1,
654                'typeof'     => 1,
655                'in'         => 1,
656                'of'         => 1,
657                'new'        => 1,
658                'do'         => 1,
659                'else'       => 1,
660                'void'       => 1,
661                'delete'     => 1,
662                'instanceof' => 1,
663                'case'       => 1,
664                'yield'      => 1,
665                'await'      => 1,
666            );
667            return ! $this->prev_dot && '' !== $this->prev_word && isset( $kw[ $this->prev_word ] );
668        }
669
670        // Closing bracket ) ], a '.', etc. -> division.
671        return false;
672    }
673
674    /**
675     * Record the most recent significant token (without changing state).
676     *
677     * @param string $char The token char.
678     */
679    private function record_prev( $char ) {
680        $this->prev_prev = $this->prev;
681        $this->prev      = $char;
682        $this->prev_word = '';
683        $this->prev_dot  = false;
684    }
685
686    /**
687     * Return to code state, recording $char as the most recent token.
688     *
689     * @param string $char The token char (the closing delimiter / interpolation brace).
690     */
691    private function return_to_code( $char ) {
692        $this->state = self::ST_CODE;
693        $this->record_prev( $char );
694    }
695
696    /**
697     * Look ahead from the current position.
698     *
699     * @param int $offset How far ahead to peek (default the next char).
700     *
701     * @return string The char, or '' if out of range.
702     */
703    private function peek( $offset = 1 ) {
704        $i = $this->pos + $offset;
705        return ( $i < $this->len ) ? $this->js[ $i ] : '';
706    }
707
708    /**
709     * Whether $c can appear within a JS identifier.
710     *
711     * @param string $c Single character.
712     * @return bool
713     */
714    private static function is_ident_char( $c ) {
715        return '' !== $c && ( ctype_alnum( $c ) || '_' === $c || '$' === $c );
716    }
717
718    /**
719     * Whether $c can start a JS identifier.
720     *
721     * @param string $c Single character.
722     * @return bool
723     */
724    private static function is_ident_start( $c ) {
725        return '' !== $c && ( ctype_alpha( $c ) || '_' === $c || '$' === $c );
726    }
727
728    /**
729     * Whether the chars at $offset begin a valid exponent part (the `e5` of the
730     * numeric literal `0.e5`). Lets the !0/!1 member-access check tell a real,
731     * broken member access (`!0.toString()`) apart from a valid exponent
732     * literal (`!0.e5`), which is not a member access at all.
733     *
734     * @param int $offset Offset from the current position.
735     * @return bool
736     */
737    private function is_exponent_at( $offset ) {
738        $c = $this->peek( $offset );
739        if ( 'e' !== $c && 'E' !== $c ) {
740            return false;
741        }
742        $next = $this->peek( $offset + 1 );
743        if ( '+' === $next || '-' === $next ) {
744            $next = $this->peek( $offset + 2 );
745        }
746        return '' !== $next && ctype_digit( $next );
747    }
748}