Module refinery.lib.scripts.js.lexer
Expand source code Browse git
from __future__ import annotations
import re
import unicodedata
from dataclasses import dataclass, field
from typing import Generator
from refinery.lib.scripts.js.token import (
KEYWORDS,
LINE_TERMINATORS,
WHITESPACE,
JsToken,
JsTokenKind,
)
from refinery.lib.scripts.js.utf16 import (
code_units,
to_code_units,
)
_ESCAPE_MAP: dict[str, str] = {
'b' : '\b',
'f' : '\f',
'n' : '\n',
'r' : '\r',
't' : '\t',
'v' : '\v',
'\\' : '\\',
"'" : "'",
'"' : '"',
'`' : '`',
}
_HEX = frozenset('0123456789abcdefABCDEF')
_OCTAL = frozenset('01234567')
_DECIMAL = frozenset('0123456789')
_WHITESPACE = frozenset(WHITESPACE)
_LINE_TERMINATOR = re.compile(F'[{re.escape(LINE_TERMINATORS)}]')
"""
The two productions `refinery.lib.scripts.js.token` spells, in the shapes this scan reads them. The
spelling stays a string there because `refinery.lib.scripts.js.numbers.TRIMMABLE_WHITESPACE` is the
concatenation of the two, and neither shape here is the right one for the other: whitespace is asked
about one character at a time, where a comment asks only where the next terminator is.
"""
_MAX_CODE_POINT = 0x10FFFF
"""
The largest code point a `\\u{...}` escape may name. A larger one is no escape at all, and it is
asked about here rather than left to `chr`, which raises: the scan runs inside a generator that the
parser reads statement by statement, so an exception raised in it is not a diagnostic but the end
of the token stream, and every statement behind the escape disappears with it.
"""
_IDENTIFIER_JOINERS = frozenset('\u200c\u200d')
"""
The zero width non-joiner and the zero width joiner, which are IdentifierPart and nothing else: they
may stand inside a name but not open one. They carry no width, so a name written with one reads as
the same name, which is exactly what makes them worth writing in an obfuscated file.
"""
def _begins_unicode_escape(src: str, pos: int) -> bool:
return src[pos:pos + 2] == '\\u'
_ASCII_NAME_START = frozenset('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_$')
_ASCII_NAME_PART = _ASCII_NAME_START | _DECIMAL
"""
The characters almost every name is written with, asked first because asking the Unicode database
per character is what the scan spends its time on otherwise. These two are the whole of ASCII that
a name may hold — `_` is the one Pc below the eighth bit and `$` is in no category IdentifierStart
names at all — so an ASCII character that is in neither is in no name, and the character that ends
a name is the one the scan asks about most.
"""
_ID_START_CATEGORIES = frozenset({'Lu', 'Ll', 'Lt', 'Lm', 'Lo', 'Nl'})
_ID_CONTINUE_CATEGORIES = _ID_START_CATEGORIES | frozenset({'Mn', 'Mc', 'Nd', 'Pc'})
_OTHER_ID_START = frozenset('\u1885\u1886\u2118\u212e\u309b\u309c')
_OTHER_ID_CONTINUE = frozenset(
'\u00b7\u0387\u1369\u136a\u136b\u136c\u136d\u136e\u136f\u1370\u1371\u19da\u30fb\uff65'
)
"""
ID_Start and ID_Continue as UAX #31 defines them and ECMA-262 11.6 adopts them, with the two lists
of characters those properties name outright because their category alone would leave them out. A
name is written with these and not with what a locale calls a letter: an accent written as its own
combining character, the middle dot of a Catalan `l·l`, the katakana middle dot, and an undertie
are all IdentifierPart, and reading a name as though they were not ends it early and reads what
follows as an operator.
Other_ID_Continue is a list Unicode extends, and the two lists here are the ones Unicode 15.1
names, which is what `unicodedata` on this interpreter answers the categories from. A database
holding a different revision than these lists were written against disagrees with the categories
beside them rather than with anything stated here.
"""
_EXTRA_NAME_PART = _IDENTIFIER_JOINERS | _OTHER_ID_START | _OTHER_ID_CONTINUE
"""
Every character IdentifierPart takes beyond what its categories give it, asked as one membership
so that the tail of the scan's per-character question is a single lookup.
"""
def _opens_a_name(c: str) -> bool:
if c in _ASCII_NAME_START:
return True
if c.isascii():
return False
return unicodedata.category(c) in _ID_START_CATEGORIES or c in _OTHER_ID_START
def _continues_a_name(c: str) -> bool:
if c in _ASCII_NAME_PART:
return True
if c.isascii():
return False
return unicodedata.category(c) in _ID_CONTINUE_CATEGORIES or c in _EXTRA_NAME_PART
def _at_identifier_start(src: str, pos: int) -> bool:
"""
Whether an IdentifierName begins at *pos*. A backslash opens one only where it opens a
unicode escape; one that begins no escape is a character no name may hold, and reading it as
the start of a name is how a scan that consumed nothing yielded empty identifiers for as long
as anything read them.
"""
c = src[pos:pos + 1]
if not c:
return False
return _opens_a_name(c) or _begins_unicode_escape(src, pos)
def _decode_one_escape(src: str, pos: int, length: int) -> tuple[str, int, bool]:
"""
What the escape opened at *pos* denotes, where it ends, and whether it is an escape the grammar
has. The third answer is what the two literals disagree about: a string keeps the escapes Annex
B holds open for sloppy code and reads anything else as the character behind the backslash,
while a template admits neither, so a run written with one denotes nothing at all.
Refusal is reported here rather than scanned for again, because the classification is this
decode: which spellings of `\\x` and `\\u` are malformed is a fact about the escape grammar, and
a second reader of the same text would be a second statement of it.
"""
if pos >= length:
return '', pos, False
c = src[pos]
pos += 1
mapped = _ESCAPE_MAP.get(c)
if mapped is not None:
return mapped, pos, True
if c in _OCTAL:
value = int(c, 8)
remaining = 2 if c in '0123' else 1
legacy = c != '0'
while remaining > 0 and pos < length and src[pos] in _OCTAL:
value = value * 8 + int(src[pos], 8)
pos += 1
remaining -= 1
legacy = True
if not legacy and pos < length and src[pos] in _DECIMAL:
legacy = True
return chr(value), pos, not legacy
if c in '89':
return c, pos, False
if c == 'x':
hexstr = src[pos:pos + 2]
if len(hexstr) == 2 and _HEX.issuperset(hexstr):
return chr(int(hexstr, 16)), pos + 2, True
return 'x', pos, False
if c == 'u':
if pos < length and src[pos] == '{':
end = pos + 1
while end < length and src[end] in _HEX:
end += 1
if end > pos + 1 and end < length and src[end] == '}':
value = int(src[pos + 1:end], 16)
if value <= _MAX_CODE_POINT:
return code_units(value), end + 1, True
return 'u', end + 1, False
else:
hexstr = src[pos:pos + 4]
if len(hexstr) == 4 and _HEX.issuperset(hexstr):
return chr(int(hexstr, 16)), pos + 4, True
return 'u', pos, False
if c in LINE_TERMINATORS:
if c == '\r' and pos < length and src[pos] == '\n':
pos += 1
return '', pos, True
return c, pos, True
_FOUR_CHAR_OPS: dict[str, JsTokenKind] = {
'>>>=' : JsTokenKind.GT3_ASSIGN,
}
_THREE_CHAR_OPS: dict[str, JsTokenKind] = {
'===' : JsTokenKind.EQ3,
'!==' : JsTokenKind.BANG_EQ2,
'>>>' : JsTokenKind.GT3,
'**=' : JsTokenKind.STAR2_ASSIGN,
'<<=' : JsTokenKind.LT2_ASSIGN,
'>>=' : JsTokenKind.GT2_ASSIGN,
'&&=' : JsTokenKind.AND_ASSIGN,
'||=' : JsTokenKind.OR_ASSIGN,
'??=' : JsTokenKind.NULLISH_ASSIGN,
'...' : JsTokenKind.ELLIPSIS,
}
_TWO_CHAR_OPS: dict[str, JsTokenKind] = {
'==' : JsTokenKind.EQ2,
'!=' : JsTokenKind.BANG_EQ,
'<=' : JsTokenKind.LT_EQ,
'>=' : JsTokenKind.GT_EQ,
'+=' : JsTokenKind.PLUS_ASSIGN,
'-=' : JsTokenKind.MINUS_ASSIGN,
'*=' : JsTokenKind.STAR_ASSIGN,
'/=' : JsTokenKind.SLASH_ASSIGN,
'%=' : JsTokenKind.PERCENT_ASSIGN,
'&=' : JsTokenKind.AMP_ASSIGN,
'|=' : JsTokenKind.PIPE_ASSIGN,
'^=' : JsTokenKind.CARET_ASSIGN,
'**' : JsTokenKind.STAR2,
'++' : JsTokenKind.INC,
'--' : JsTokenKind.DEC,
'&&' : JsTokenKind.AND,
'||' : JsTokenKind.OR,
'??' : JsTokenKind.QQ,
'?.' : JsTokenKind.QUESTION_DOT,
'=>' : JsTokenKind.ARROW,
'<<' : JsTokenKind.LT2,
'>>' : JsTokenKind.GT2,
}
_ONE_CHAR_OPS: dict[str, JsTokenKind] = {
'+' : JsTokenKind.PLUS,
'-' : JsTokenKind.MINUS,
'*' : JsTokenKind.STAR,
'/' : JsTokenKind.SLASH,
'%' : JsTokenKind.PERCENT,
'=' : JsTokenKind.EQUALS,
'!' : JsTokenKind.BANG,
'<' : JsTokenKind.LT,
'>' : JsTokenKind.GT,
'&' : JsTokenKind.AMP,
'|' : JsTokenKind.PIPE,
'^' : JsTokenKind.CARET,
'~' : JsTokenKind.TILDE,
'.' : JsTokenKind.DOT,
'?' : JsTokenKind.QUESTION,
':' : JsTokenKind.COLON,
'(' : JsTokenKind.LPAREN,
')' : JsTokenKind.RPAREN,
'{' : JsTokenKind.LBRACE,
'}' : JsTokenKind.RBRACE,
'[' : JsTokenKind.LBRACKET,
']' : JsTokenKind.RBRACKET,
';' : JsTokenKind.SEMICOLON,
',' : JsTokenKind.COMMA,
'@' : JsTokenKind.AT,
}
@dataclass(frozen=True)
class JsLexerState:
"""
What a rewind has to put back. The position is not part of it, because a rewind always goes to
the start of a token the parser is already holding: it is the template nesting alone that no
longer follows from that offset once scanning has moved past it.
"""
template_depth: int
brace_stack: tuple[int, ...]
@dataclass
class JsLexer:
source: str
pos: int = 0
_template_depth: int = 0
_brace_stack: list[int] = field(default_factory=list)
def capture(self) -> JsLexerState:
return JsLexerState(self._template_depth, tuple(self._brace_stack))
def rewind(self, pos: int, state: JsLexerState) -> None:
self.pos = pos
self._template_depth = state.template_depth
self._brace_stack = list(state.brace_stack)
def scan_regexp(self) -> JsToken | None:
"""
Read a regular expression literal where scanning currently stands. ECMA-262 clause 12 picks
the lexical goal symbol from the syntactic grammar context, which only the parser knows, so
no path through `JsLexer.tokenize` reaches this scan: a slash is spelled as the operator it
looks like until someone who is expecting an expression asks for it again.
The answer is `None` where no literal stands here, which is what makes asking affordable:
a RegularExpressionLiteral holds no line terminator, so a scan that reaches the end of its
line has read something that is not one, and the position it started from is given back
untouched for the caller to read as the operator it already looked like.
"""
start = self.pos
if self._peek() != '/':
return None
text = self._read_regexp()
if text is None:
return None
return JsToken(JsTokenKind.REGEXP, text, start)
def _peek(self, count: int = 1) -> str:
return self.source[self.pos:self.pos + count]
def _at_end(self) -> bool:
return self.pos >= len(self.source)
def _skip_whitespace(self) -> bool:
"""
Consume the ECMA-262 WhiteSpace between two tokens. It is the whole production and not the
space and the tab alone, because every other character in it separates tokens just as they
do: a file that opens with a byte order mark is ordinary, and reading that mark as a token
of its own splits one program into two statements.
Line terminators are deliberately not consumed here. They separate tokens too, but they also
end a line, and the parser reads the end of a line as a place a semicolon may be inserted.
"""
start = self.pos
src = self.source
length = len(src)
while self.pos < length and src[self.pos] in _WHITESPACE:
self.pos += 1
return self.pos > start
def _read_line_comment(self) -> str:
"""
Consume a comment that runs to the end of its line, and the `#!` line, which is one. The end
is looked for at once rather than one character at a time, because a comment is the longest
run of characters this scan ever walks and the run is over as soon as a terminator is
anywhere in it.
"""
start = self.pos
src = self.source
end = _LINE_TERMINATOR.search(src, self.pos + 2)
self.pos = end.start() if end else len(src)
return src[start:self.pos]
def _read_block_comment(self) -> tuple[str, bool]:
start = self.pos
src = self.source
length = len(src)
self.pos += 2
has_newline = False
while self.pos < length - 1:
if src[self.pos] == '*' and src[self.pos + 1] == '/':
self.pos += 2
return src[start:self.pos], has_newline
if src[self.pos] in LINE_TERMINATORS:
has_newline = True
self.pos += 1
self.pos = length
return src[start:self.pos], has_newline
def _read_string_escape(self) -> str:
self.pos += 1
result, self.pos, _ = _decode_one_escape(
self.source, self.pos, len(self.source))
return result
def _read_string(self, quote: str) -> tuple[str, bool]:
"""
The string literal that begins here, and whether the closing quote was there. A literal ends
at a line feed or a carriage return and at no other line terminator — `U+2028` and `U+2029`
stand inside one since ES2019 — and the terminator that ends it is left unread, so the line
still ends where the source ended it and a semicolon may be inserted there.
The escape is consumed before that check rather than after it, which is what lets a
backslash continue a literal onto the next line.
"""
start = self.pos
src = self.source
length = len(src)
self.pos += 1
while self.pos < length:
c = src[self.pos]
if c == '\\':
self._read_string_escape()
continue
if c in '\r\n':
break
self.pos += 1
if c == quote:
return src[start:self.pos], True
return src[start:self.pos], False
def _scan_template_content(
self,
start: int,
close_kind: JsTokenKind,
interp_kind: JsTokenKind,
depth_delta: int,
) -> JsToken:
src = self.source
length = len(src)
while self.pos < length:
c = src[self.pos]
if c == '\\':
self._read_string_escape()
continue
if c == '`':
self.pos += 1
self._template_depth += depth_delta
return JsToken(close_kind, src[start:self.pos], start)
if c == '$' and self.pos + 1 < length and src[self.pos + 1] == '{':
self.pos += 2
if depth_delta == 0:
self._template_depth += 1
self._brace_stack.append(0)
return JsToken(interp_kind, src[start:self.pos], start)
self.pos += 1
self._template_depth += depth_delta
return JsToken(close_kind, src[start:self.pos], start, False)
def _read_template(self) -> JsToken:
start = self.pos
self.pos += 1
return self._scan_template_content(
start, JsTokenKind.TEMPLATE_FULL, JsTokenKind.TEMPLATE_HEAD, 0)
def _resume_template(self) -> JsToken:
start = self.pos
self.pos += 1
return self._scan_template_content(
start, JsTokenKind.TEMPLATE_TAIL, JsTokenKind.TEMPLATE_MIDDLE, -1)
def _read_regexp(self) -> str | None:
"""
The RegularExpressionLiteral that begins here, or `None` where the text spells none. A
backslash escapes the character after it but never a line terminator, so a literal that
reaches the end of its line is unterminated rather than continued, and the position is
restored so that the same text can be read again as whatever else it may be.
RegularExpressionFirstChar admits neither a slash nor a star, which is what leaves `//` and
`/*` to spell the two comments and nothing else.
"""
start = self.pos
src = self.source
length = len(src)
self.pos += 1
if self._peek() in ('*', '/'):
self.pos = start
return None
in_class = False
while self.pos < length:
c = src[self.pos]
if c == '\\' and self.pos + 1 < length and src[self.pos + 1] not in LINE_TERMINATORS:
self.pos += 2
continue
if c == '[':
in_class = True
self.pos += 1
continue
if c == ']' and in_class:
in_class = False
self.pos += 1
continue
if c == '/' and not in_class:
self.pos += 1
while self.pos < length and src[self.pos].isalpha():
self.pos += 1
return src[start:self.pos]
if c in LINE_TERMINATORS:
break
self.pos += 1
self.pos = start
return None
def _read_prefixed_int(self, start: int, valid_digits: str) -> JsToken:
src = self.source
length = len(src)
while self.pos < length and src[self.pos] in valid_digits:
self.pos += 1
if self.pos < length and src[self.pos] == 'n':
self.pos += 1
return JsToken(JsTokenKind.BIGINT, src[start:self.pos], start)
return JsToken(JsTokenKind.INTEGER, src[start:self.pos], start)
def _read_number(self) -> JsToken:
"""
The numeric literal that begins here. The digits after the point are optional where there
are digits in front of it — `3.` is the number three — which is what makes `1..toString()`
a call on a numeral rather than a member of a member. The point belongs to the numeral
whenever it can, so `1.toString()` is a numeral with a name behind it and no program at all.
A literal that opens with the point has no such option: `.5` needs its digits, and the
point that would follow them belongs to whatever comes next. Neither has a prefixed literal,
which ends at its digits.
"""
start = self.pos
src = self.source
length = len(src)
if src[self.pos] == '0' and self.pos + 1 < length:
nc = src[self.pos + 1]
if nc in 'xX':
self.pos += 2
return self._read_prefixed_int(start, '0123456789abcdefABCDEF_')
if nc in 'oO':
self.pos += 2
return self._read_prefixed_int(start, '01234567_')
if nc in 'bB':
self.pos += 2
return self._read_prefixed_int(start, '01_')
while self.pos < length and (src[self.pos] in _DECIMAL or src[self.pos] == '_'):
self.pos += 1
has_integer_part = self.pos > start
is_float = False
if self.pos < length and src[self.pos] == '.':
next_pos = self.pos + 1
if next_pos < length and src[next_pos] in _DECIMAL:
is_float = True
self.pos += 1
while self.pos < length and (
src[self.pos] in _DECIMAL or src[self.pos] == '_'
):
self.pos += 1
elif has_integer_part:
is_float = True
self.pos += 1
if self.pos < length and src[self.pos] in 'eE':
is_float = True
self.pos += 1
if self.pos < length and src[self.pos] in '+-':
self.pos += 1
while self.pos < length and (
src[self.pos] in _DECIMAL or src[self.pos] == '_'
):
self.pos += 1
if not is_float and self.pos < length and src[self.pos] == 'n':
self.pos += 1
return JsToken(JsTokenKind.BIGINT, src[start:self.pos], start)
kind = JsTokenKind.FLOAT if is_float else JsTokenKind.INTEGER
return JsToken(kind, src[start:self.pos], start)
def _read_identifier_or_keyword(self) -> JsToken:
start = self.pos
src = self.source
length = len(src)
while self.pos < length:
c = src[self.pos]
if _continues_a_name(c):
self.pos += 1
elif _begins_unicode_escape(src, self.pos):
self._read_string_escape()
else:
break
word = src[start:self.pos]
kw = KEYWORDS.get(word)
if kw is not None:
return JsToken(kw, word, start)
return JsToken(JsTokenKind.IDENTIFIER, word, start)
def tokenize(self) -> Generator[JsToken, None, None]:
src = self.source
length = len(src)
if self.pos == 0 and src.startswith('#!'):
self._read_line_comment()
while True:
self._skip_whitespace()
if self._at_end():
yield JsToken(JsTokenKind.EOF, '', self.pos)
return
start = self.pos
c = src[self.pos]
c2 = src[self.pos:self.pos + 2]
if c == '\r' and self.pos + 1 < length and src[self.pos + 1] == '\n':
self.pos += 2
yield JsToken(JsTokenKind.NEWLINE, '\r\n', start)
continue
if c in LINE_TERMINATORS:
self.pos += 1
yield JsToken(JsTokenKind.NEWLINE, c, start)
continue
if c2 == '//':
text = self._read_line_comment()
yield JsToken(JsTokenKind.COMMENT, text, start)
continue
if c2 == '/*':
text, has_newline = self._read_block_comment()
yield JsToken(JsTokenKind.COMMENT, text, start)
if has_newline:
yield JsToken(JsTokenKind.NEWLINE, '', self.pos)
continue
if c == "'":
text, terminated = self._read_string("'")
yield JsToken(JsTokenKind.STRING_SINGLE, text, start, terminated)
continue
if c == '"':
text, terminated = self._read_string('"')
yield JsToken(JsTokenKind.STRING_DOUBLE, text, start, terminated)
continue
if c == '`':
yield self._read_template()
continue
if c == '}' and self._template_depth > 0 and self._brace_stack:
if self._brace_stack[-1] == 0:
self._brace_stack.pop()
yield self._resume_template()
continue
else:
self._brace_stack[-1] -= 1
if c in _DECIMAL or (
c == '.' and src[self.pos + 1:self.pos + 2] in _DECIMAL
):
yield self._read_number()
continue
if _at_identifier_start(src, self.pos):
yield self._read_identifier_or_keyword()
continue
if c == '#':
if _at_identifier_start(src, self.pos + 1):
self.pos += 1
name = self._read_identifier_or_keyword()
yield JsToken(JsTokenKind.PRIVATE_IDENTIFIER, '#' + name.value, start)
continue
c4 = src[self.pos:self.pos + 4]
if c4 in _FOUR_CHAR_OPS:
self.pos += 4
yield JsToken(_FOUR_CHAR_OPS[c4], c4, start)
continue
c3 = src[self.pos:self.pos + 3]
if c3 in _THREE_CHAR_OPS:
self.pos += 3
yield JsToken(_THREE_CHAR_OPS[c3], c3, start)
continue
if c2 in _TWO_CHAR_OPS:
self.pos += 2
yield JsToken(_TWO_CHAR_OPS[c2], c2, start)
continue
if c in _ONE_CHAR_OPS:
self.pos += 1
kind = _ONE_CHAR_OPS[c]
if kind == JsTokenKind.LBRACE and self._brace_stack:
self._brace_stack[-1] += 1
yield JsToken(kind, c, start)
continue
self.pos += 1
yield JsToken(JsTokenKind.ERROR, c, start)
def _decode_body(text: str) -> tuple[str, bool]:
"""
The text a literal body denotes, and whether every escape in it is one the grammar has.
The text is the code units the string is made of. A character above the basic plane is two of
them however it was written — as itself, as one `\\u{...}` escape, or as the two `\\uXXXX`
escapes naming its surrogates — so the three spellings denote one string here as they do in an
engine, and a program asking how long that string is, what stands at either half of it, or
whether it equals a string written the other way, is answered with what it would be answered.
A lone surrogate is left standing, because JavaScript admits one in a string and no pairing rule
may invent the partner it lacks.
"""
if '\\' not in text:
return to_code_units(text), True
parts: list[str] = []
valid = True
i = 0
length = len(text)
while i < length:
c = text[i]
if c != '\\' or i + 1 >= length:
parts.append(c)
i += 1
continue
decoded, i, ok = _decode_one_escape(text, i + 1, length)
valid = valid and ok
if decoded:
parts.append(decoded)
return to_code_units(''.join(parts)), valid
def _read_unicode_escape(text: str, pos: int) -> tuple[int, int] | None:
"""
The code point the escape at *pos* names and where it ends, or `None` where the text at *pos*
is no unicode escape at all. A name admits this escape and no other, so this reads for it alone
rather than through `_decode_one_escape`, which answers for every escape a string has and
reports a malformed one as the character behind the backslash. That reading is the right one
inside a literal and the wrong one here: `\\u061` is a string holding `u061` and is no name.
What comes back is the code point rather than the units that spell it, because the rule the
caller applies is about the code point: `\\ud801\\udc00` names two of them, each half of a pair
and neither a character any name may hold, where `\\u{10400}` names the one they would have
spelled together.
"""
if text[pos:pos + 1] != 'u':
return None
pos += 1
if text[pos:pos + 1] == '{':
end = pos + 1
while end < len(text) and text[end] in _HEX:
end += 1
if end == pos + 1 or text[end:end + 1] != '}':
return None
value = int(text[pos + 1:end], 16)
return None if value > _MAX_CODE_POINT else (value, end + 1)
digits = text[pos:pos + 4]
if len(digits) != 4 or not _HEX.issuperset(digits):
return None
return int(digits, 16), pos + 4
def identifier_string_value(text: str) -> str | None:
"""
The name *text* spells, or `None` where it spells no name at all. An escape and the character it
names are one name written two ways, so this is what says that `\\u0061bc` and `abc` are the
same binding, and it is the only reading of an identifier that any question about names may be
asked of.
The answer is code units, like every other string this package holds, so that a name written
with a `\\u{...}` escape and the same name written with the character itself compare equal.
Validity is decided one escape at a time and before that joining happens, which is the whole of
the difference between the two spellings of an astral character: the pair `\\ud801\\udc00`
names two lone surrogates, neither of which any name may hold, while `\\u{10400}` names the
character they encode.
Text holding no escape is answered as itself. What the scan read as a name is a name, and
this is asked only of what it read: a text no scan produced, `0a`, would come back as
itself here and as nothing at all written `\\u0030a`.
"""
if '\\' not in text:
return to_code_units(text) or None
parts: list[str] = []
i = 0
length = len(text)
while i < length:
c = text[i]
if c == '\\':
escape = _read_unicode_escape(text, i + 1)
if escape is None:
return None
value, i = escape
c = chr(value)
else:
i += 1
if not (_opens_a_name(c) if not parts else _continues_a_name(c)):
return None
parts.append(code_units(ord(c)))
return ''.join(parts) or None
def decode_js_string_body(text: str) -> str:
"""
The text a string literal body denotes. Every spelling denotes something here, because the one
a string has no rule for is the character behind the backslash; the reading a template gives
the same text is `decode_js_template_body`.
"""
return _decode_body(text)[0]
def has_legacy_numeric_escape(text: str) -> bool:
"""
Whether the body of a literal was written with a legacy octal or non-octal-decimal escape: a
backslash followed by `1` through `9`, or by `0` with another decimal digit behind it. A plain
`\\0` is the NUL escape and is none of these, and a backslash that escapes a backslash opens no
escape at all, so the scan steps over both rather than counting them.
Strict code rejects such an escape, which is what reads this. A template refuses it too, but
refuses more besides, so that rule asks the decode rather than this scan: an escape naming no
character is a syntax error in either mode and no strict violation at all.
"""
i = 0
n = len(text)
while i < n:
if text[i] != '\\':
i += 1
continue
if i + 1 >= n:
return False
nxt = text[i + 1]
if nxt in '123456789':
return True
if nxt == '0':
if i + 2 < n and text[i + 2] in '0123456789':
return True
i += 2
return False
def decode_js_template_body(text: str) -> str | None:
"""
What a run of template text denotes, or `None` where it denotes nothing. It is the body of a
string literal with two rules more.
A template is the one literal that may span lines, and every line terminator sequence in it
denotes a line feed, so a file saved with CRLF endings holds the same template as one saved
with LF. Normalizing before the escapes are read is what keeps a backslash at the end of a line
a continuation in either file.
The escapes a template admits are those of a string minus the ones Annex B keeps alive for
sloppy code, and minus every spelling of `\\x` and `\\u` that names no character. A template
carrying one of those is not a template at all: untagged it is a syntax error, and tagged it is
a run whose cooked value the language states is `undefined`. There is no text it denotes, and
answering with the text the same spelling would denote in a string is how a script no engine
will run gets a value computed for it anyway.
"""
if '\r' in text:
text = text.replace('\r\n', '\n').replace('\r', '\n')
decoded, valid = _decode_body(text)
return decoded if valid else None
Functions
def identifier_string_value(text)-
The name text spells, or
Nonewhere it spells no name at all. An escape and the character it names are one name written two ways, so this is what says that\u0061bcandabcare the same binding, and it is the only reading of an identifier that any question about names may be asked of.The answer is code units, like every other string this package holds, so that a name written with a
\u{...}escape and the same name written with the character itself compare equal. Validity is decided one escape at a time and before that joining happens, which is the whole of the difference between the two spellings of an astral character: the pair\ud801\udc00names two lone surrogates, neither of which any name may hold, while\u{10400}names the character they encode.Text holding no escape is answered as itself. What the scan read as a name is a name, and this is asked only of what it read: a text no scan produced,
0a, would come back as itself here and as nothing at all written\u0030a.Expand source code Browse git
def identifier_string_value(text: str) -> str | None: """ The name *text* spells, or `None` where it spells no name at all. An escape and the character it names are one name written two ways, so this is what says that `\\u0061bc` and `abc` are the same binding, and it is the only reading of an identifier that any question about names may be asked of. The answer is code units, like every other string this package holds, so that a name written with a `\\u{...}` escape and the same name written with the character itself compare equal. Validity is decided one escape at a time and before that joining happens, which is the whole of the difference between the two spellings of an astral character: the pair `\\ud801\\udc00` names two lone surrogates, neither of which any name may hold, while `\\u{10400}` names the character they encode. Text holding no escape is answered as itself. What the scan read as a name is a name, and this is asked only of what it read: a text no scan produced, `0a`, would come back as itself here and as nothing at all written `\\u0030a`. """ if '\\' not in text: return to_code_units(text) or None parts: list[str] = [] i = 0 length = len(text) while i < length: c = text[i] if c == '\\': escape = _read_unicode_escape(text, i + 1) if escape is None: return None value, i = escape c = chr(value) else: i += 1 if not (_opens_a_name(c) if not parts else _continues_a_name(c)): return None parts.append(code_units(ord(c))) return ''.join(parts) or None def decode_js_string_body(text)-
The text a string literal body denotes. Every spelling denotes something here, because the one a string has no rule for is the character behind the backslash; the reading a template gives the same text is
decode_js_template_body().Expand source code Browse git
def decode_js_string_body(text: str) -> str: """ The text a string literal body denotes. Every spelling denotes something here, because the one a string has no rule for is the character behind the backslash; the reading a template gives the same text is `decode_js_template_body`. """ return _decode_body(text)[0] def has_legacy_numeric_escape(text)-
Whether the body of a literal was written with a legacy octal or non-octal-decimal escape: a backslash followed by
1through9, or by0with another decimal digit behind it. A plain\0is the NUL escape and is none of these, and a backslash that escapes a backslash opens no escape at all, so the scan steps over both rather than counting them.Strict code rejects such an escape, which is what reads this. A template refuses it too, but refuses more besides, so that rule asks the decode rather than this scan: an escape naming no character is a syntax error in either mode and no strict violation at all.
Expand source code Browse git
def has_legacy_numeric_escape(text: str) -> bool: """ Whether the body of a literal was written with a legacy octal or non-octal-decimal escape: a backslash followed by `1` through `9`, or by `0` with another decimal digit behind it. A plain `\\0` is the NUL escape and is none of these, and a backslash that escapes a backslash opens no escape at all, so the scan steps over both rather than counting them. Strict code rejects such an escape, which is what reads this. A template refuses it too, but refuses more besides, so that rule asks the decode rather than this scan: an escape naming no character is a syntax error in either mode and no strict violation at all. """ i = 0 n = len(text) while i < n: if text[i] != '\\': i += 1 continue if i + 1 >= n: return False nxt = text[i + 1] if nxt in '123456789': return True if nxt == '0': if i + 2 < n and text[i + 2] in '0123456789': return True i += 2 return False def decode_js_template_body(text)-
What a run of template text denotes, or
Nonewhere it denotes nothing. It is the body of a string literal with two rules more.A template is the one literal that may span lines, and every line terminator sequence in it denotes a line feed, so a file saved with CRLF endings holds the same template as one saved with LF. Normalizing before the escapes are read is what keeps a backslash at the end of a line a continuation in either file.
The escapes a template admits are those of a string minus the ones Annex B keeps alive for sloppy code, and minus every spelling of
\xand\uthat names no character. A template carrying one of those is not a template at all: untagged it is a syntax error, and tagged it is a run whose cooked value the language states isundefined. There is no text it denotes, and answering with the text the same spelling would denote in a string is how a script no engine will run gets a value computed for it anyway.Expand source code Browse git
def decode_js_template_body(text: str) -> str | None: """ What a run of template text denotes, or `None` where it denotes nothing. It is the body of a string literal with two rules more. A template is the one literal that may span lines, and every line terminator sequence in it denotes a line feed, so a file saved with CRLF endings holds the same template as one saved with LF. Normalizing before the escapes are read is what keeps a backslash at the end of a line a continuation in either file. The escapes a template admits are those of a string minus the ones Annex B keeps alive for sloppy code, and minus every spelling of `\\x` and `\\u` that names no character. A template carrying one of those is not a template at all: untagged it is a syntax error, and tagged it is a run whose cooked value the language states is `undefined`. There is no text it denotes, and answering with the text the same spelling would denote in a string is how a script no engine will run gets a value computed for it anyway. """ if '\r' in text: text = text.replace('\r\n', '\n').replace('\r', '\n') decoded, valid = _decode_body(text) return decoded if valid else None
Classes
class JsLexerState (template_depth, brace_stack)-
What a rewind has to put back. The position is not part of it, because a rewind always goes to the start of a token the parser is already holding: it is the template nesting alone that no longer follows from that offset once scanning has moved past it.
Expand source code Browse git
@dataclass(frozen=True) class JsLexerState: """ What a rewind has to put back. The position is not part of it, because a rewind always goes to the start of a token the parser is already holding: it is the template nesting alone that no longer follows from that offset once scanning has moved past it. """ template_depth: int brace_stack: tuple[int, ...]Instance variables
var template_depth-
The type of the None singleton.
var brace_stack-
The type of the None singleton.
class JsLexer (source, pos=0)-
JsLexer(source: 'str', pos: 'int' = 0, _template_depth: 'int' = 0, _brace_stack: 'list[int]' =
) Expand source code Browse git
@dataclass class JsLexer: source: str pos: int = 0 _template_depth: int = 0 _brace_stack: list[int] = field(default_factory=list) def capture(self) -> JsLexerState: return JsLexerState(self._template_depth, tuple(self._brace_stack)) def rewind(self, pos: int, state: JsLexerState) -> None: self.pos = pos self._template_depth = state.template_depth self._brace_stack = list(state.brace_stack) def scan_regexp(self) -> JsToken | None: """ Read a regular expression literal where scanning currently stands. ECMA-262 clause 12 picks the lexical goal symbol from the syntactic grammar context, which only the parser knows, so no path through `JsLexer.tokenize` reaches this scan: a slash is spelled as the operator it looks like until someone who is expecting an expression asks for it again. The answer is `None` where no literal stands here, which is what makes asking affordable: a RegularExpressionLiteral holds no line terminator, so a scan that reaches the end of its line has read something that is not one, and the position it started from is given back untouched for the caller to read as the operator it already looked like. """ start = self.pos if self._peek() != '/': return None text = self._read_regexp() if text is None: return None return JsToken(JsTokenKind.REGEXP, text, start) def _peek(self, count: int = 1) -> str: return self.source[self.pos:self.pos + count] def _at_end(self) -> bool: return self.pos >= len(self.source) def _skip_whitespace(self) -> bool: """ Consume the ECMA-262 WhiteSpace between two tokens. It is the whole production and not the space and the tab alone, because every other character in it separates tokens just as they do: a file that opens with a byte order mark is ordinary, and reading that mark as a token of its own splits one program into two statements. Line terminators are deliberately not consumed here. They separate tokens too, but they also end a line, and the parser reads the end of a line as a place a semicolon may be inserted. """ start = self.pos src = self.source length = len(src) while self.pos < length and src[self.pos] in _WHITESPACE: self.pos += 1 return self.pos > start def _read_line_comment(self) -> str: """ Consume a comment that runs to the end of its line, and the `#!` line, which is one. The end is looked for at once rather than one character at a time, because a comment is the longest run of characters this scan ever walks and the run is over as soon as a terminator is anywhere in it. """ start = self.pos src = self.source end = _LINE_TERMINATOR.search(src, self.pos + 2) self.pos = end.start() if end else len(src) return src[start:self.pos] def _read_block_comment(self) -> tuple[str, bool]: start = self.pos src = self.source length = len(src) self.pos += 2 has_newline = False while self.pos < length - 1: if src[self.pos] == '*' and src[self.pos + 1] == '/': self.pos += 2 return src[start:self.pos], has_newline if src[self.pos] in LINE_TERMINATORS: has_newline = True self.pos += 1 self.pos = length return src[start:self.pos], has_newline def _read_string_escape(self) -> str: self.pos += 1 result, self.pos, _ = _decode_one_escape( self.source, self.pos, len(self.source)) return result def _read_string(self, quote: str) -> tuple[str, bool]: """ The string literal that begins here, and whether the closing quote was there. A literal ends at a line feed or a carriage return and at no other line terminator — `U+2028` and `U+2029` stand inside one since ES2019 — and the terminator that ends it is left unread, so the line still ends where the source ended it and a semicolon may be inserted there. The escape is consumed before that check rather than after it, which is what lets a backslash continue a literal onto the next line. """ start = self.pos src = self.source length = len(src) self.pos += 1 while self.pos < length: c = src[self.pos] if c == '\\': self._read_string_escape() continue if c in '\r\n': break self.pos += 1 if c == quote: return src[start:self.pos], True return src[start:self.pos], False def _scan_template_content( self, start: int, close_kind: JsTokenKind, interp_kind: JsTokenKind, depth_delta: int, ) -> JsToken: src = self.source length = len(src) while self.pos < length: c = src[self.pos] if c == '\\': self._read_string_escape() continue if c == '`': self.pos += 1 self._template_depth += depth_delta return JsToken(close_kind, src[start:self.pos], start) if c == '$' and self.pos + 1 < length and src[self.pos + 1] == '{': self.pos += 2 if depth_delta == 0: self._template_depth += 1 self._brace_stack.append(0) return JsToken(interp_kind, src[start:self.pos], start) self.pos += 1 self._template_depth += depth_delta return JsToken(close_kind, src[start:self.pos], start, False) def _read_template(self) -> JsToken: start = self.pos self.pos += 1 return self._scan_template_content( start, JsTokenKind.TEMPLATE_FULL, JsTokenKind.TEMPLATE_HEAD, 0) def _resume_template(self) -> JsToken: start = self.pos self.pos += 1 return self._scan_template_content( start, JsTokenKind.TEMPLATE_TAIL, JsTokenKind.TEMPLATE_MIDDLE, -1) def _read_regexp(self) -> str | None: """ The RegularExpressionLiteral that begins here, or `None` where the text spells none. A backslash escapes the character after it but never a line terminator, so a literal that reaches the end of its line is unterminated rather than continued, and the position is restored so that the same text can be read again as whatever else it may be. RegularExpressionFirstChar admits neither a slash nor a star, which is what leaves `//` and `/*` to spell the two comments and nothing else. """ start = self.pos src = self.source length = len(src) self.pos += 1 if self._peek() in ('*', '/'): self.pos = start return None in_class = False while self.pos < length: c = src[self.pos] if c == '\\' and self.pos + 1 < length and src[self.pos + 1] not in LINE_TERMINATORS: self.pos += 2 continue if c == '[': in_class = True self.pos += 1 continue if c == ']' and in_class: in_class = False self.pos += 1 continue if c == '/' and not in_class: self.pos += 1 while self.pos < length and src[self.pos].isalpha(): self.pos += 1 return src[start:self.pos] if c in LINE_TERMINATORS: break self.pos += 1 self.pos = start return None def _read_prefixed_int(self, start: int, valid_digits: str) -> JsToken: src = self.source length = len(src) while self.pos < length and src[self.pos] in valid_digits: self.pos += 1 if self.pos < length and src[self.pos] == 'n': self.pos += 1 return JsToken(JsTokenKind.BIGINT, src[start:self.pos], start) return JsToken(JsTokenKind.INTEGER, src[start:self.pos], start) def _read_number(self) -> JsToken: """ The numeric literal that begins here. The digits after the point are optional where there are digits in front of it — `3.` is the number three — which is what makes `1..toString()` a call on a numeral rather than a member of a member. The point belongs to the numeral whenever it can, so `1.toString()` is a numeral with a name behind it and no program at all. A literal that opens with the point has no such option: `.5` needs its digits, and the point that would follow them belongs to whatever comes next. Neither has a prefixed literal, which ends at its digits. """ start = self.pos src = self.source length = len(src) if src[self.pos] == '0' and self.pos + 1 < length: nc = src[self.pos + 1] if nc in 'xX': self.pos += 2 return self._read_prefixed_int(start, '0123456789abcdefABCDEF_') if nc in 'oO': self.pos += 2 return self._read_prefixed_int(start, '01234567_') if nc in 'bB': self.pos += 2 return self._read_prefixed_int(start, '01_') while self.pos < length and (src[self.pos] in _DECIMAL or src[self.pos] == '_'): self.pos += 1 has_integer_part = self.pos > start is_float = False if self.pos < length and src[self.pos] == '.': next_pos = self.pos + 1 if next_pos < length and src[next_pos] in _DECIMAL: is_float = True self.pos += 1 while self.pos < length and ( src[self.pos] in _DECIMAL or src[self.pos] == '_' ): self.pos += 1 elif has_integer_part: is_float = True self.pos += 1 if self.pos < length and src[self.pos] in 'eE': is_float = True self.pos += 1 if self.pos < length and src[self.pos] in '+-': self.pos += 1 while self.pos < length and ( src[self.pos] in _DECIMAL or src[self.pos] == '_' ): self.pos += 1 if not is_float and self.pos < length and src[self.pos] == 'n': self.pos += 1 return JsToken(JsTokenKind.BIGINT, src[start:self.pos], start) kind = JsTokenKind.FLOAT if is_float else JsTokenKind.INTEGER return JsToken(kind, src[start:self.pos], start) def _read_identifier_or_keyword(self) -> JsToken: start = self.pos src = self.source length = len(src) while self.pos < length: c = src[self.pos] if _continues_a_name(c): self.pos += 1 elif _begins_unicode_escape(src, self.pos): self._read_string_escape() else: break word = src[start:self.pos] kw = KEYWORDS.get(word) if kw is not None: return JsToken(kw, word, start) return JsToken(JsTokenKind.IDENTIFIER, word, start) def tokenize(self) -> Generator[JsToken, None, None]: src = self.source length = len(src) if self.pos == 0 and src.startswith('#!'): self._read_line_comment() while True: self._skip_whitespace() if self._at_end(): yield JsToken(JsTokenKind.EOF, '', self.pos) return start = self.pos c = src[self.pos] c2 = src[self.pos:self.pos + 2] if c == '\r' and self.pos + 1 < length and src[self.pos + 1] == '\n': self.pos += 2 yield JsToken(JsTokenKind.NEWLINE, '\r\n', start) continue if c in LINE_TERMINATORS: self.pos += 1 yield JsToken(JsTokenKind.NEWLINE, c, start) continue if c2 == '//': text = self._read_line_comment() yield JsToken(JsTokenKind.COMMENT, text, start) continue if c2 == '/*': text, has_newline = self._read_block_comment() yield JsToken(JsTokenKind.COMMENT, text, start) if has_newline: yield JsToken(JsTokenKind.NEWLINE, '', self.pos) continue if c == "'": text, terminated = self._read_string("'") yield JsToken(JsTokenKind.STRING_SINGLE, text, start, terminated) continue if c == '"': text, terminated = self._read_string('"') yield JsToken(JsTokenKind.STRING_DOUBLE, text, start, terminated) continue if c == '`': yield self._read_template() continue if c == '}' and self._template_depth > 0 and self._brace_stack: if self._brace_stack[-1] == 0: self._brace_stack.pop() yield self._resume_template() continue else: self._brace_stack[-1] -= 1 if c in _DECIMAL or ( c == '.' and src[self.pos + 1:self.pos + 2] in _DECIMAL ): yield self._read_number() continue if _at_identifier_start(src, self.pos): yield self._read_identifier_or_keyword() continue if c == '#': if _at_identifier_start(src, self.pos + 1): self.pos += 1 name = self._read_identifier_or_keyword() yield JsToken(JsTokenKind.PRIVATE_IDENTIFIER, '#' + name.value, start) continue c4 = src[self.pos:self.pos + 4] if c4 in _FOUR_CHAR_OPS: self.pos += 4 yield JsToken(_FOUR_CHAR_OPS[c4], c4, start) continue c3 = src[self.pos:self.pos + 3] if c3 in _THREE_CHAR_OPS: self.pos += 3 yield JsToken(_THREE_CHAR_OPS[c3], c3, start) continue if c2 in _TWO_CHAR_OPS: self.pos += 2 yield JsToken(_TWO_CHAR_OPS[c2], c2, start) continue if c in _ONE_CHAR_OPS: self.pos += 1 kind = _ONE_CHAR_OPS[c] if kind == JsTokenKind.LBRACE and self._brace_stack: self._brace_stack[-1] += 1 yield JsToken(kind, c, start) continue self.pos += 1 yield JsToken(JsTokenKind.ERROR, c, start)Instance variables
var source-
The type of the None singleton.
var pos-
The type of the None singleton.
Methods
def capture(self)-
Expand source code Browse git
def capture(self) -> JsLexerState: return JsLexerState(self._template_depth, tuple(self._brace_stack)) def rewind(self, pos, state)-
Expand source code Browse git
def rewind(self, pos: int, state: JsLexerState) -> None: self.pos = pos self._template_depth = state.template_depth self._brace_stack = list(state.brace_stack) def scan_regexp(self)-
Read a regular expression literal where scanning currently stands. ECMA-262 clause 12 picks the lexical goal symbol from the syntactic grammar context, which only the parser knows, so no path through
JsLexer.tokenize()reaches this scan: a slash is spelled as the operator it looks like until someone who is expecting an expression asks for it again.The answer is
Nonewhere no literal stands here, which is what makes asking affordable: a RegularExpressionLiteral holds no line terminator, so a scan that reaches the end of its line has read something that is not one, and the position it started from is given back untouched for the caller to read as the operator it already looked like.Expand source code Browse git
def scan_regexp(self) -> JsToken | None: """ Read a regular expression literal where scanning currently stands. ECMA-262 clause 12 picks the lexical goal symbol from the syntactic grammar context, which only the parser knows, so no path through `JsLexer.tokenize` reaches this scan: a slash is spelled as the operator it looks like until someone who is expecting an expression asks for it again. The answer is `None` where no literal stands here, which is what makes asking affordable: a RegularExpressionLiteral holds no line terminator, so a scan that reaches the end of its line has read something that is not one, and the position it started from is given back untouched for the caller to read as the operator it already looked like. """ start = self.pos if self._peek() != '/': return None text = self._read_regexp() if text is None: return None return JsToken(JsTokenKind.REGEXP, text, start) def tokenize(self)-
Expand source code Browse git
def tokenize(self) -> Generator[JsToken, None, None]: src = self.source length = len(src) if self.pos == 0 and src.startswith('#!'): self._read_line_comment() while True: self._skip_whitespace() if self._at_end(): yield JsToken(JsTokenKind.EOF, '', self.pos) return start = self.pos c = src[self.pos] c2 = src[self.pos:self.pos + 2] if c == '\r' and self.pos + 1 < length and src[self.pos + 1] == '\n': self.pos += 2 yield JsToken(JsTokenKind.NEWLINE, '\r\n', start) continue if c in LINE_TERMINATORS: self.pos += 1 yield JsToken(JsTokenKind.NEWLINE, c, start) continue if c2 == '//': text = self._read_line_comment() yield JsToken(JsTokenKind.COMMENT, text, start) continue if c2 == '/*': text, has_newline = self._read_block_comment() yield JsToken(JsTokenKind.COMMENT, text, start) if has_newline: yield JsToken(JsTokenKind.NEWLINE, '', self.pos) continue if c == "'": text, terminated = self._read_string("'") yield JsToken(JsTokenKind.STRING_SINGLE, text, start, terminated) continue if c == '"': text, terminated = self._read_string('"') yield JsToken(JsTokenKind.STRING_DOUBLE, text, start, terminated) continue if c == '`': yield self._read_template() continue if c == '}' and self._template_depth > 0 and self._brace_stack: if self._brace_stack[-1] == 0: self._brace_stack.pop() yield self._resume_template() continue else: self._brace_stack[-1] -= 1 if c in _DECIMAL or ( c == '.' and src[self.pos + 1:self.pos + 2] in _DECIMAL ): yield self._read_number() continue if _at_identifier_start(src, self.pos): yield self._read_identifier_or_keyword() continue if c == '#': if _at_identifier_start(src, self.pos + 1): self.pos += 1 name = self._read_identifier_or_keyword() yield JsToken(JsTokenKind.PRIVATE_IDENTIFIER, '#' + name.value, start) continue c4 = src[self.pos:self.pos + 4] if c4 in _FOUR_CHAR_OPS: self.pos += 4 yield JsToken(_FOUR_CHAR_OPS[c4], c4, start) continue c3 = src[self.pos:self.pos + 3] if c3 in _THREE_CHAR_OPS: self.pos += 3 yield JsToken(_THREE_CHAR_OPS[c3], c3, start) continue if c2 in _TWO_CHAR_OPS: self.pos += 2 yield JsToken(_TWO_CHAR_OPS[c2], c2, start) continue if c in _ONE_CHAR_OPS: self.pos += 1 kind = _ONE_CHAR_OPS[c] if kind == JsTokenKind.LBRACE and self._brace_stack: self._brace_stack[-1] += 1 yield JsToken(kind, c, start) continue self.pos += 1 yield JsToken(JsTokenKind.ERROR, c, start)