-
Notifications
You must be signed in to change notification settings - Fork 0
TypeScript and JSX
TypeScript and JSX are the two places where the JavaScript grammar turns genuinely ambiguous for a recursive-descent parser, and es-parser handles both the same way: it parses speculatively and backtracks when a guess doesn't pan out. This is a syntactic parser — it parses the full TS surface grammar but does no type checking. Two backtracking mechanisms carry the weight: state snapshots and a token-rewrite log.
typescript.zig parses the full type grammar. A tour, with the AST tags it
produces:
Types and operators — union A | B (ts_union_type, typescript.zig:253),
intersection (ts_intersection_type, :262), conditional T extends U ? X : Y
(ts_conditional_type, :142), array and indexed-access (ts_array_type /
ts_indexed_access_type, :271), typeof / keyof (:342, :378), infer T
(ts_infer_type, :389), mapped types { [K in T as U]: V } (ts_mapped_type,
:1314), template-literal types (ts_template_literal_type, :1461), tuples with
labeled members [a: T] (ts_named_tuple_member, :1223), constructor types
(ts_constructor_type, :1402), and predicates x is T / asserts x
(ts_type_predicate).
Declarations — interface (ts_interface_decl, :1664), type aliases
(ts_type_alias_decl, :1783), enum (ts_enum_decl, :1871), and
namespace / module (:2027). The module X {} keyword form emits a TS1540
warning per name segment, not a parse error (:2058).
Function and method type syntax — type-parameter lists <T extends U = D>
(ts_type_parameter, :1530), in / out variance (:1551), and the interface
member kinds, which are distinct tags so consumers need no charCode heuristics:
ts_call_signature, ts_construct_signature, ts_method_signature,
ts_property_signature, ts_index_signature (:2141).
Expression-level TS — expr as T / expr satisfies T
(ts_as_expr / ts_satisfies_expr, expressions.zig:184), the non-null assertion
expr! (ts_non_null_expr, :202), the angle-bracket assertion <T>expr
(ts_type_assertion, :7290), instantiation expressions expr<T>
(ts_instantiation_expr, :218), parameter properties (ts_parameter_property,
parser.zig:6383), and decorators (@expr) — parsed and placement-checked (e.g. TS1206), but the decorator
expression is consumed without a retained AST node.
Two granularities:
checkpoint() / restore(saved) position-only — saves tok_i; cheap
lookahead that appends nothing (parser.zig:8796)
saveSpeculative() / restoreSpeculative(s)
full trial parse — snapshots
{tok_i, diag_len, nodes_len, extra_len},
truncating them on failure (parser.zig:8805)
The full snapshot is used wherever a trial parse can append nodes that must be
undone: conditional type vs. constraint (does a ? follow the extends?),
parenthesized type vs. function type, generic arrow vs. type assertion. On failure
it rewinds the cursor and shrinks the diagnostics, nodes, and extra_data buffers
back to the snapshot.
The hard problem is that >>, >>>, >=, >>=, >>>=, and << are single
tokens, but inside generics a run of >s has to close several type-argument lists
(Map<string, Array<number>> ends in one >> token that is really two >s).
Re-lexing would be expensive and stateful, so the parser rewrites the token in
place and logs the rewrite so it can be undone:
// src/parser.zig:217
pub const TokMut = struct { idx: u32, tag: TokenTag, start: u32 };To peel one > off a >>, expectClosingAngleBracket retags it to
greater_than and advances its start by one byte so the remaining > stays
valid (typescript.zig:2584); << is split into < the same way in
parseTypeArguments (:1619). The crucial detail: the in-place rewrite always
happens — tags_ptr and tok_starts_ptr are cached as mutable column pointers
(parser.zig:224, :232), so the edit is O(1) and visible to the next peek.
recordTokMut only logs the rewrite, and only while the record_tok_muts flag
is set (parser.zig:8675), which it is during speculation. So undoTokMuts can
restore the original token stream byte-for-byte after a backtrack, while a rewrite
on a committed path is permanent and intentionally unlogged.
Type syntax participates in scope analysis without polluting value semantics. A
user-defined type name emits a type_read reference — so an unused-vars rule
won't flag a type-only import — while the built-in keyword types (string,
number, any, …) emit no reference and become keyword type nodes
(typescript.zig:681). Type parameters emit a type_param declare when the
position calls for it (emit_fn_type_params), so generic scopes resolve
(typescript.zig:1587).
Interfaces, namespaces, enums, and overload signatures may legally redeclare the
same name. es-parser handles that bluntly: the redeclaration early-error pass
checkRedeclarations bails out for any TypeScript AST —
if (ast.is_ts) return &.{}; (event_resolver.zig:1411) — so duplicate TS bindings
never reach the duplicate-binding check. The consumer-visible consequence is that
diagnose_redeclare produces no diagnostics on .ts / .tsx / .d.ts input.
jsx.zig parses JSX once the lexer is in JSX context (see
Lexer and Tokens).
Elements and fragments. parseJsxElement (jsx.zig:30) produces jsx_element
(opening + children + closing), jsx_self_closing for <x/>, or jsx_fragment
for <>…</>. The closing name is validated against the opening one
(jsxNameMatches, :701). Names may be simple (jsx_identifier), dotted
(Foo.Bar → jsx_member_expr), namespaced (a:b → jsx_namespaced_name), or
hyphenated (aria-label, recovered via token adjacency).
Attributes and children. jsx_attribute and jsx_spread_attribute for
{...x} (jsx.zig:579). Children (parseJsxChildren, :377) coalesce text into one
jsx_text_node, record pure-whitespace runs as jsx_gap_node, wrap {expr} as
jsx_expression_container (with jsx_empty_expr for {}), and handle
{...expr} as jsx_spread_child.
Component references. emitJsxComponentRef (jsx.zig:743) emits a read
reference only for an upper-case-initial name (<Foo/>) or the root object of a
member name (<a.b/> → a ref to a); lowercase intrinsics (<div/>) and
namespaced names emit none.
In .tsx, a < starting a primary expression is ambiguous between JSX and a type
construct, and the dispatch resolves it like this (expressions.zig:2703): if
looksLikeTsxGenericArrow matches one of the ESBuild-style markers — <T,>,
<T extends …>, <T = …> — it re-enters parseTsTypeAssertion, which tries a
generic arrow and falls back to a cast; otherwise it routes to JSX. So a bare
<Type>expr cast is never reached in TSX — that token shape simply goes to JSX,
and the cast code isn't disabled. Inside an opening element the parser
additionally tries parseTypeArguments for <Foo<T> /> and accepts the
type-argument reading only when a plausible continuation follows (>, /, {,
identifier, or keyword), restoring otherwise (jsx.zig:111). Both paths reuse the
same << / >> token-splitting, so <Foo<<T>> resolves correctly.
es-parser — MIT licensed. This wiki documents the implementation under src/; when a detail matters, the source is authoritative.