# Yuku, a JavaScript/TypeScript Compiler Toolchain in Zig > Yuku is a high-performance JavaScript and TypeScript compiler toolchain written in Zig. Spec-compliant, zero dependencies, fast by design. > Full documentation, every page concatenated for LLMs. Index: https://yuku.fyi/llms.txt # Introduction Source: https://yuku.fyi/ A high-performance JavaScript/TypeScript compiler toolchain written in Zig, bringing modern JavaScript tooling infrastructure to the Zig ecosystem. [Try it in the playground →](https://playground.yuku.fyi) ## Why Yuku Yuku is a JavaScript/TypeScript toolchain built from the ground up in Zig. It is designed for correctness, performance, and clarity. **Correctness first.** Yuku is 100% ECMAScript spec compliant. It passes the entire [parser test suite](https://github.com/yuku-toolchain/parser-test-suite), tens of thousands of cases from [Test262](https://github.com/tc39/test262), TypeScript, and Babel, with exact AST matching. Zero failures, zero AST mismatches. Read [how Yuku is tested](https://yuku.fyi/testing/). **Fast by design.** The parser is built using data-oriented design principles and generous performance engineering. **Pure Zig, zero dependencies.** The entire toolchain is written in Zig with no external C libraries or runtime dependencies. This makes it easy to build, embed, and cross-compile. **Modern JavaScript.** Full support for modern and experimental features including decorators, source phase imports, deferred imports, `using`/`await using` declarations, and more. ## Current Status The `.js(x)`/`.ts(x)` parser is stable and ready for production use in both Node.js and Zig. The API is stable, the implementation is 100% spec-compliant, and the AST output is fully validated against the [parser test suite](https://yuku.fyi/testing/). The [semantic model](https://yuku.fyi/parser/semantic/) and [traverser](https://yuku.fyi/parser/traverse/) are stable and power Yuku's semantic checker in production. ## Performance On native (Zig/Rust) parsing, Yuku is faster than Oxc and roughly 2× faster than SWC. ![Parsing `typescript.js` (7.8 MB) · macOS (ARM), Apple M3, 16 GB · [benchmark source](https://github.com/yuku-toolchain/ecmascript-parser-benchmark-native)](https://raw.githubusercontent.com/yuku-toolchain/ecmascript-parser-benchmark-native/refs/heads/main/charts/typescript.png "Native benchmark comparing Yuku, Oxc, and other Zig/Rust parsers parsing typescript.js") On the JavaScript side (npm), Yuku is **3-10x faster** than alternatives. ![Parsing `react.js` (72 KB) via npm · macOS (ARM), Apple M3, 16 GB · [benchmark source](https://github.com/yuku-toolchain/ecmascript-parser-benchmark-js)](https://raw.githubusercontent.com/yuku-toolchain/ecmascript-parser-benchmark-js/refs/heads/main/charts/react.png "npm benchmark comparing Yuku, Oxc, Acorn, and other parsers parsing react.js") But does it scale? Yes. ![Parsing `typescript.js` (7.8 MB) via npm · macOS (ARM), Apple M3, 16 GB · [benchmark source](https://github.com/yuku-toolchain/ecmascript-parser-benchmark-js)](https://raw.githubusercontent.com/yuku-toolchain/ecmascript-parser-benchmark-js/refs/heads/main/charts/typescript.png "npm benchmark comparing Yuku, Oxc, Acorn, and other parsers parsing typescript.js") > **A note on Oxc's npm performance** >You may wonder why Oxc is slower in this npm benchmark, even slower than Babel. This is because Oxc's npm package has the overhead of passing the AST from Rust to JavaScript. What it does is serialize the AST to a JSON string on the Rust side and then call `JSON.parse` on the JavaScript side. This overhead makes it slower, even though Oxc is very fast at raw parsing speed. > >If you benchmark Babel and Oxc by just calling their `parse` functions, Oxc will appear faster than Babel. This is because the `program` field returned by Oxc's parse call is a getter that only runs `JSON.parse` when you actually access it. That deserialization is the main bottleneck, and it grows as the AST gets larger (i.e., as the source file gets longer). The npm benchmarks above measure the time to actually obtain the full AST for all parsers. > >Yuku takes a different approach. Its AST is designed from the ground up to be transfer-friendly, flat, compact, and near-binary. This makes passing the AST from Zig to JavaScript fast, lightweight, and simple, without modifying the core parser. Zig's comptime makes this safe by design. There are no multi-gigabyte allocations, only the memory the source being parsed actually needs. It works reliably on any platform and is already fully validated, passing the entire [parser test suite](https://yuku.fyi/testing/) with exact AST matching between Zig and JavaScript. This is still an early version, with further AST transfer performance improvements already planned. # Parser Source: https://yuku.fyi/parser/ Yuku's parser turns JavaScript and TypeScript source code into an [Abstract Syntax Tree](https://yuku.fyi/parser/ast/). ## Node.js ```bash npm install yuku-parser ``` ```js import { parse } from "yuku-parser"; const { program, comments, diagnostics } = parse("const x = 1 + 2;"); ``` Outputs an [ESTree](https://github.com/estree/estree) / [TS-ESTree](https://www.npmjs.com/package/@typescript-eslint/typescript-estree)-compatible AST matching [Oxc](https://oxc.rs). Runs 3-10x faster than alternatives on npm. See [yuku-parser on npm](https://www.npmjs.com/package/yuku-parser) for the full API. ## WebAssembly ```bash npm install @yuku-parser/wasm ``` ```js import { parse } from "@yuku-parser/wasm"; const { program, comments, diagnostics } = parse("const x = 1 + 2;"); ``` The same API and AST as `yuku-parser`, as a single portable WebAssembly module for browsers and other environments without native bindings. See [@yuku-parser/wasm on npm](https://www.npmjs.com/package/@yuku-parser/wasm). ## Zig ```bash zig fetch --save git+https://github.com/yuku-toolchain/yuku.git ``` In your `build.zig`. ```zig const yuku_dep = b.dependency("yuku", .{ .target = target, .optimize = optimize, }); my_module.addImport("parser", yuku_dep.module("parser")); ``` > **Note** >Yuku requires Zig 0.16.0 or later. ## Quick Start ```zig const std = @import("std"); const parser = @import("parser"); pub fn main() !void { // the smp allocator is used as the backing allocator for the tree's internal arena var tree = try parser.parse(std.heap.smp_allocator, "const x = 5;", .{}); defer tree.deinit(); for (tree.diagnostics.items) |d| { std.debug.print("{s}\n", .{d.message}); } } ``` ## Options `parse` takes an `Options` struct to configure the parsing mode. ```zig const tree = try parser.parse(allocator, source, .{ .source_type = .module, .lang = .jsx, }); ``` | Field | Values | Default | Description | | ------------------------------- | ------------------------------------ | --------- | --------------------------------------------------------------------------------- | | `source_type` | `.script`, `.module`, `.commonjs` | `.module` | Script mode, ES module mode (strict mode), or CommonJS mode, where the top level behaves like a function body (top-level `return`, `new.target`, `using`) | | `lang` | `.js`, `.ts`, `.jsx`, `.tsx`, `.dts` | `.js` | Language variant and syntax features to enable | | `preserve_parens` | `true`, `false` | `true` | Keep `ParenthesizedExpression` nodes in the AST | | `comments` | `.none`, `.flat`, `.attached`, `.both` | `.flat` | Collect comments as a flat list (`tree.comments`), attached to host nodes (`tree.commentsOf`), or both. See [Comments](https://yuku.fyi/parser/ast/#comments) | | `tokens` | `true`, `false` | `false` | Keep every token the parser consumed in `tree.tokens`. See [Tokens](#tokens) | Both fields can be inferred from a file path. ```zig const tree = try parser.parse(allocator, source, .{ .source_type = .fromPath("app.cjs"), // .commonjs .lang = .fromPath("app.tsx"), // .tsx }); ``` ## The Tree `parse` returns a `Tree` containing the full AST, diagnostics, and source metadata. The allocator passed to `parse` is used as the backing allocator for the tree's internal arena, so `tree.deinit()` frees everything at once. The AST is a flat array of nodes referenced by integer index. `tree.root` is always a `program` node, so unpack it directly. Everything below it is a tagged union you `switch` on. ```zig var tree = try parser.parse(allocator, source, .{}); defer tree.deinit(); const program = tree.data(tree.root).program; for (tree.extra(program.body)) |child_idx| { switch (tree.data(child_idx)) { .variable_declaration => |decl| { for (tree.extra(decl.declarators)) |d| { _ = d; } }, .function => |func| { _ = func; }, else => {}, } } ``` The four read primitives are `tree.data(idx)` for a node's typed payload, `tree.span(idx)` for its source range, `tree.extra(range)` for a variadic child list, and `tree.string(handle)` for string content. See the [AST reference](https://yuku.fyi/parser/ast/) for the full node catalog, the field conventions, and the eight categorical predicates on `NodeData`. ## Diagnostics The parser recovers from errors and continues, so a single parse produces the full AST alongside all diagnostics. ```zig for (tree.diagnostics.items) |d| { std.debug.print("[{s}] {s} at {d}..{d}\n", .{ d.severity.toString(), d.message, d.span.start, d.span.end, }); for (d.labels) |label| { std.debug.print(" {d}..{d}: {s}\n", .{ label.span.start, label.span.end, label.message }); } if (d.help) |help| { std.debug.print(" help: {s}\n", .{help}); } } ``` Each diagnostic has a `severity` (`.error`, `.warning`, `.hint`, `.info`), a `message`, a source `span`, optional `labels` pointing to related code regions, and optional `help` text. ## Tokens `tokens` keeps every token the parser consumed, in source order. Off by default. ```zig var tree = try parser.parse(allocator, "let x = a / b;", .{ .tokens = true }); defer tree.deinit(); for (tree.tokens) |token| { std.debug.print("{t} {s}\n", .{ token.tag, token.text(tree.source) }); } ``` Tokens are as the parser resolved them: a regex is one `regex_literal`, and the `>>` closing a nested generic is two `greater_than`. See [Tokens](https://yuku.fyi/parser/ast/#tokens) for the `Token` layout. In JavaScript the same option returns a `TokenList`, a view over the parser's token table. A token is an index, and every accessor is one typed-array read. ```js import { parse, TokenKind } from "yuku-parser"; const { program, tokens } = parse(source, { tokens: true }); for (let i = 0; i < tokens.length; i++) { if (tokens.kind(i) === TokenKind.Arrow) console.log(tokens.start(i), tokens.text(i)); } tokens.isKeyword(i); // and isReserved, isBinaryOperator, precedence, ... tokens.newlineBefore(i); // what ASI looks at tokens.range(program.body[0]); // [from, to) of the node's tokens tokens.before(program.body[1]); // the token ending before it, -1 when none ``` See [yuku-parser on npm](https://www.npmjs.com/package/yuku-parser#tokens) for the full API, and [tokens.d.ts](https://github.com/yuku-toolchain/yuku/blob/main/npm/yuku-types/tokens.d.ts) for the 160 kinds in `TokenKind`. Why an index and not an array of objects? On a 1 MB file, about 215,000 tokens, `tokens: true` adds 1 ms to the parse and scanning every `kind(i)` another 0.4 ms. Building an object per token would add 8 ms and 20 to 50 MB of heap, which is what tokens cost in espree, acorn, and Babel, and 70 ms in typescript-estree. ## Going Further - [Semantic Analysis](https://yuku.fyi/parser/semantic/) documents the semantic model of a tree, with scopes, symbols, resolved references, early errors, and module records. - [Traverse](https://yuku.fyi/parser/traverse/) walks the AST with typed visitor hooks in four modes (basic, scoped, semantic, transform). - [Codegen](https://yuku.fyi/parser/codegen/) prints a tree back to JavaScript/TypeScript source. # AST Source: https://yuku.fyi/parser/ast/ The AST that comes out of `parser.parse()` is a flat array of nodes that reference each other by integer index. Reading and walking it is fast, predictable, and explicit. There are no boxed structs, no virtual dispatch, no surprise allocations. Every operation is a tagged-union switch and a slice index away. The same tree, when exposed through the [`yuku-parser`](https://www.npmjs.com/package/yuku-parser) npm package, becomes [ESTree](https://github.com/estree/estree)-compatible output matching [Oxc](https://oxc.rs). - **JavaScript / JSX** is fully conformant with [ESTree](https://github.com/estree/estree), identical to [Acorn](https://www.npmjs.com/package/acorn). - **TypeScript** conforms to [TS-ESTree](https://www.npmjs.com/package/@typescript-eslint/typescript-estree) used by `@typescript-eslint`. Comments can be attached to the AST nodes they belong to, exposed as a flat offset-indexed array, or both. See [Comments](#comments). The tokens the parser consumed can be kept as well. See [Tokens](#tokens). On top of the base specs, the AST also carries Stage 3 [decorators](https://github.com/tc39/proposal-decorators), [import defer](https://github.com/tc39/proposal-defer-import-eval), [import source](https://github.com/tc39/proposal-source-phase-imports), and a `hashbang` field on `program`. These extensions are present in Oxc as well. > **Building tools on the AST?** >The [traverser](https://yuku.fyi/parser/traverse/) is the recommended way to work with the AST. It gives you ergonomic visitor hooks, scopes, symbols, and transforms, everything you need to walk, analyze, and rewrite the tree without managing indices yourself. Reach for it first when building lints, codemods, or any pass over the tree. > >This page covers the AST itself, the node types, their fields, and how to read them directly when you need to. ## Memory model The AST is not a graph of heap-allocated structs. Every node lives in a single flat array (`Tree.nodes`), and child references are indices into that array. Variable-length child lists live in a second flat array (`Tree.extras`), and string content lives in a string pool. Three arrays, one arena. ``` Tree nodes NodeList flat array of all nodes (data + span, struct-of-arrays) extras []NodeIndex variable-length child lists (IndexRange points here) strings StringPool all string content (source refs + interned extras) ``` `NodeList` is a `MultiArrayList(Node)`, so `data` and `span` are stored in two separate parallel arrays. Code that only reads spans, or only reads data, touches one array. All memory is owned by a single `ArenaAllocator`. `tree.deinit()` frees the entire tree at once. ## The Tree `Tree` is the root container returned by `parser.parse()`. The fields you read directly. | Field | Type | Description | | ------------- | ----------------------- | ---------------------------------------------------------- | | `root` | `NodeIndex` | Index of the root node (a `program`) | | `diagnostics` | `ArrayList(Diagnostic)` | Parse errors, warnings, hints | | `source` | `[]const u8` | Original source text | | `source_type` | `SourceType` | `.script` or `.module` | | `lang` | `Lang` | `.js`, `.ts`, `.jsx`, `.tsx`, or `.dts` | Everything else is reached through methods. ```zig tree.data(idx) // NodeData for the node at idx tree.span(idx) // Span (source byte range) for the node at idx tree.extra(range) // []const NodeIndex for an IndexRange tree.string(handle) // []const u8 for a String handle tree.commentsOf(idx) // []const AttachedComment attached to the node at idx tree.isTs() // language is .ts, .tsx, or .dts tree.isJsx() // language is .jsx or .tsx tree.isModule() // source_type is .module tree.hasErrors() // any diagnostic with severity .error tree.hasDiagnostics() // any diagnostic at all tree.setData(idx, data) // write API, see the transform traverser tree.addNode(data, span) // append a node, returns its NodeIndex tree.addString("x") // intern text; sourceSlice(a, b) points into the source instead ``` Every enum a node field holds also carries `toString()`, returning the construct's source text (`decl.kind.toString()` is `"const"`, `expr.operator.toString()` is `"==="`). ## Core types Four small types carry every reference inside the AST. ### NodeIndex ```zig pub const NodeIndex = enum(u32) { null = std.math.maxInt(u32), _ }; ``` Every node is identified by its position in `Tree.nodes`. Optional child slots use `.null` to signal absence. ```zig // if_statement.alternate is .null when there is no else branch if (node.alternate != .null) { const else_data = tree.data(node.alternate); } ``` ### IndexRange ```zig pub const IndexRange = struct { start: u32, len: u32 }; ``` Variable-length children are stored as a contiguous slice in `Tree.extras`. An `IndexRange` is a `(start, len)` window into that array. Resolve it with `tree.extra(range)`. ```zig const children = tree.extra(node.body); // []const NodeIndex for (children) |child| { const child_data = tree.data(child); } ``` `IndexRange.empty` is the zero-length range. ### String ```zig pub const String = struct { start: u32, end: u32 }; ``` `String` is a lightweight handle to text. It points into one of two backing stores. - **Source slice (zero-copy)** holds most identifiers and string literals parsed from input. The bytes live inside `tree.source` directly. - **Pool entry** holds interned strings such as escaped identifiers and names produced by transforms. These live in the string pool's extra buffer. `tree.string(handle)` resolves both transparently and always returns `[]const u8`. ```zig const name = tree.string(node.name); ``` ### Span ```zig pub const Span = struct { start: u32, end: u32 }; ``` Byte offsets into the source text. `start` is inclusive, `end` is exclusive. ```zig const span = tree.span(idx); const text = tree.source[span.start..span.end]; ``` ## Reading a node `NodeData` is a tagged union with one variant per node type. The variant tags are snake_case (`binary_expression`, `if_statement`, `ts_type_alias_declaration`, ...). `tree.data(idx)` returns one. `switch` on the tag and unpack. ```zig switch (tree.data(idx)) { .binary_expression => |expr| { // expr.left and expr.right are NodeIndex (recurse with data) // expr.operator is a BinaryOperator enum const left = tree.data(expr.left); }, .variable_declaration => |decl| { // decl.kind is VariableKind (.var, .let, .const, .using, .await_using) // decl.declarators is IndexRange (read with extra) for (tree.extra(decl.declarators)) |d| { /* ... */ } }, .identifier_reference => |id| { // id.name is a String (resolve with string) const text = tree.string(id.name); }, else => {}, } ``` The same snake_case tag names are used as visitor hook names. A method called `enter_binary_expression` on your visitor struct fires when the [traverser](https://yuku.fyi/parser/traverse/) enters that node kind. ### Reading children A node's children sit in two kinds of fields. - **Single child** fields are `NodeIndex`, either a real index or `.null` for an absent optional slot. Read with `tree.data(field)`. - **Variadic children** fields are `IndexRange`. Resolve with `tree.extra(range)` to get `[]const NodeIndex`, then iterate. ```zig switch (tree.data(idx)) { .function => |func| { // single child const body = tree.data(func.body); // variadic children, function params -> formal_parameters -> items const params = tree.data(func.params).formal_parameters; for (tree.extra(params.items)) |param| { /* ... */ } }, else => {}, } ``` For tree-wide walks, use the [traverser](https://yuku.fyi/parser/traverse/) instead of writing recursion by hand. It handles every node kind correctly without per-tag bookkeeping. ## Predicates Eight methods on `NodeData` answer the categorical questions linters and analyzers ask most often. They collapse a family of tags into a single boolean. | Method | True for | | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `isExpression()` | Any node that produces a value at runtime, meaning literals, identifier references, operator expressions, member access, calls, function and class expression forms, JSX elements, and the TypeScript value-position wrappers. | | `isStatement()` | Any node valid at statement position, meaning control flow, structural statements, declarations, imports, exports, and TypeScript top-level declarations. Function and class declaration forms are included, expression forms are not. | | `isLiteral()` | `string_literal`, `numeric_literal`, `bigint_literal`, `boolean_literal`, `null_literal`, `regexp_literal`, `template_literal`. | | `isCallable()` | `function` (any form) and `arrow_function_expression`. Excludes `method_definition`, which wraps a `function` in its `value` field. | | `isPattern()` | `binding_identifier`, `array_pattern`, `object_pattern`, `assignment_pattern`. | | `isDeclaration()` | `variable_declaration`, function and class declaration forms, `import_declaration`, `export_named_declaration`, `export_default_declaration`, `export_all_declaration`, `ts_type_alias_declaration`, `ts_interface_declaration`, `ts_enum_declaration`, `ts_module_declaration`, `ts_global_declaration`, `ts_import_equals_declaration`. | | `isIteration()` | `for_statement`, `for_in_statement`, `for_of_statement`, `while_statement`, `do_while_statement`. Useful for `break` and `continue` scope checks. | | `isTypeContext()` | Any node that roots a TypeScript type-only subtree, meaning type annotations, type references, function and constructor types, conditional and mapped types, type literals, interface heritage, and the various TS signatures. The semantic traverser uses this to drive its `inTypePosition()` context flag. | For dual-purpose nodes (`function` and `class`) the predicates consult the `type` field internally, so `isExpression()` returns true only for the expression forms and `isStatement()` / `isDeclaration()` only for the declaration forms. ```zig const data = tree.data(idx); if (data.isExpression()) { // any value-producing node } if (data.isCallable()) { // function or arrow_function_expression // The body, params, etc. are still type-specific, // so switch on the tag to access them. } ``` For anything narrower than these eight, `switch` directly. ```zig switch (data) { .arrow_function_expression => |arrow| { /* ... */ }, else => {}, } ``` ## Node reference Every entry in `NodeData` is a distinct node tag. The tag name is the exact name used in `tree.data()` switches and visitor hooks (`enter_`). Optional child fields are noted with `.null`. Optional child lists are noted with `.empty`. ### Program The root of every tree. There is always exactly one `program` node at `tree.root`. ```zig pub const Program = struct { source_type: SourceType, // .script or .module body: IndexRange, // (any statement | directive)[] hashbang: ?Hashbang, // non-null for #!/usr/bin/env node lines }; ``` `directive` nodes (such as `"use strict";`) appear at the start of the body. Imports and exports appear in source order alongside other statements. ### Statements | Tag | Syntax | Description | | ---------------------- | ------------------------------- | -------------------------------------------------------------------------- | | `expression_statement` | `expr;` | An expression used as a statement. | | `block_statement` | `{ ... }` | A braced block. | | `empty_statement` | `;` | A standalone semicolon. | | `debugger_statement` | `debugger;` | A debugger breakpoint. | | `if_statement` | `if (test) cons else alt` | An `if` / `else` branch. | | `switch_statement` | `switch (d) { cases }` | A `switch` with one or more `case` and `default` clauses. | | `switch_case` | `case x: ...` / `default: ...` | A single clause inside a `switch`. | | `for_statement` | `for (init; test; update) body` | A C-style `for` loop. | | `for_in_statement` | `for (x in y) body` | A `for ... in` loop iterating over enumerable property keys. | | `for_of_statement` | `for (x of y) body` | A `for ... of` or `for await ... of` loop iterating over an iterable. | | `while_statement` | `while (test) body` | A `while` loop. | | `do_while_statement` | `do body while (test)` | A `do ... while` loop. | | `break_statement` | `break;` / `break label;` | A `break` exiting the nearest loop, switch, or labeled statement. | | `continue_statement` | `continue;` / `continue label;` | A `continue` jumping to the next iteration of the nearest or labeled loop. | | `labeled_statement` | `label: stmt` | A statement prefixed with a label that `break` and `continue` can target. | | `return_statement` | `return;` / `return expr;` | A `return` from the enclosing function. | | `throw_statement` | `throw expr;` | A `throw` raising an exception. | | `try_statement` | `try {} catch {} finally {}` | A `try` with optional `catch` and `finally` clauses. | | `catch_clause` | `catch (e) { body }` | The `catch` clause of a `try`, with an optional binding. | | `with_statement` | `with (obj) body` | A `with` block. Forbidden in strict mode. | ### Declarations | Tag | Syntax | Description | | ---------------------- | ----------------------------- | ---------------------------------------------------------------------------- | | `variable_declaration` | `var/let/const/using x = ...` | A `var`, `let`, `const`, `using`, or `await using` declaration. | | `variable_declarator` | `x = init` | A single binding inside a variable declaration. | | `directive` | `"use strict";` | A directive prologue, only valid at the top of a function or module body. | | `function` | `function foo() {}` | Every function form (declaration, expression, ambient, body-less signature). | | `class` | `class Foo {}` | Both class declarations and class expressions. | `function` and `class` are dual-purpose nodes. The `type` field distinguishes the form. ```zig // FunctionType function_declaration // function foo() {} function_expression // const x = function () {} ts_declare_function // declare function foo(): void // also plain overload signatures ts_empty_body_function_expression // body-less class methods (overloads, // abstract, ambient) // ClassType class_declaration // class Foo {} class_expression // const x = class {} ``` ### Expressions | Tag | Syntax | Description | | ---------------------------- | --------------------------------------- | -------------------------------------------------------------------------------- | | `binary_expression` | `a + b`, `a === b`, `a instanceof b` | A non-logical, non-assignment binary operation. | | `logical_expression` | `a && b`, `a \|\| b`, `a ?? b` | A short-circuiting logical operation. | | `unary_expression` | `!x`, `typeof x`, `void x`, `delete x` | A unary prefix operation. | | `update_expression` | `x++`, `++x`, `x--` | A prefix or postfix increment or decrement. | | `assignment_expression` | `x = y`, `x += y`, `x ??= y` | An assignment or compound assignment. | | `conditional_expression` | `test ? a : b` | A ternary expression. | | `sequence_expression` | `a, b, c` | A comma-separated sequence of expressions. | | `parenthesized_expression` | `(expr)` | An expression wrapped in parentheses, preserved in the tree. | | `member_expression` | `obj.prop`, `obj[x]`, `obj.#priv` | Property access, in static, computed, or optional form. | | `call_expression` | `fn(args)`, `fn?.()` | A function call, optionally with type arguments or optional invocation. | | `new_expression` | `new Foo(args)` | A `new` constructor invocation. | | `chain_expression` | `a?.b`, `a?.()` | A wrapper that scopes optional-chain short-circuiting to a member or call chain. | | `tagged_template_expression` | `` tag`hello` `` | A template literal preceded by a tag function. | | `await_expression` | `await expr` | An `await` of a promise inside an async context. | | `yield_expression` | `yield expr`, `yield* expr` | A `yield` or delegating `yield*` inside a generator. | | `meta_property` | `import.meta`, `new.target` | A meta property reference such as `import.meta` or `new.target`. | | `array_expression` | `[a, , b, ...c]` | An array literal, including holes and spread elements. | | `object_expression` | `{a: 1, b, ...c}` | An object literal, including spread elements. | | `object_property` | `key: value`, getters, setters, methods | A property entry inside an object literal. | | `spread_element` | `...expr` | A spread element used in arrays, calls, and object literals. | | `import_expression` | `import(src)`, `import.source(src)` | A dynamic `import()` call or phased import. | | `this_expression` | `this` | The `this` keyword used as an expression. | | `super` | `super` | The `super` keyword used as an expression head. | ### Literals | Tag | Syntax | Description | | ------------------ | ----------------------------- | ---------------------------------------------------- | | `string_literal` | `"hello"`, `'world'` | A string literal with escape sequences resolved. | | `numeric_literal` | `42`, `0xFF`, `0o7`, `0b1010` | A numeric literal in decimal, hex, octal, or binary. | | `bigint_literal` | `42n` | A BigInt literal. | | `boolean_literal` | `true`, `false` | The `true` or `false` keyword as a value. | | `null_literal` | `null` | The `null` literal. | | `regexp_literal` | `/pattern/flags` | A regular expression literal. | | `template_literal` | `` `hello ${name}` `` | A template literal with zero or more interpolations. | | `template_element` | text part between `${...}` | A static text span inside a template literal. | ### Identifiers Five tags, all carrying a single `name: String` field. They are structurally identical but appear in different syntactic positions and resolve differently. | Tag | Used for | | ---------------------- | -------------------------------------------------------------------------------------------------------------------------- | | `identifier_reference` | A name used as a value, like `x`, `console`, `Math` | | `binding_identifier` | A name being declared, like `const x`, `function foo`, `import { x }` | | `identifier_name` | A bare name in non-expression position, like object keys (`{foo: 1}`), member access right-hand side (`obj.foo`), `import.meta` | | `label_identifier` | A label name in `break label`, `continue label`, or `label: stmt` | | `private_identifier` | A private class member, like `#field` (the `#` is not part of `name`) | ```js const foo = bar.baz; // ^^^ ^^^ ^^^ // | | identifier_name (property, never resolved) // | identifier_reference (variable use, resolved by scope chain) // binding_identifier (declaration, recorded as a symbol) ``` `binding_identifier` additionally carries decorators, an optional type annotation, and an optional `?` flag when it appears in a parameter position. ### Patterns (destructuring) | Tag | Syntax | Description | | ---------------------- | ------------------------------- | ---------------------------------------------------------------------------------- | | `array_pattern` | `[a, , b, ...rest]` | An array destructuring pattern. | | `object_pattern` | `{a, b: c, ...rest}` | An object destructuring pattern. | | `binding_property` | `key: value` or shorthand `key` | A single property inside an object pattern. | | `assignment_pattern` | `x = default` | A binding pattern with a default value, used in destructuring and parameter lists. | | `binding_rest_element` | `...rest` | A `...rest` element inside a binding pattern or parameter list. | | `formal_parameters` | `(a, b = 1, ...rest)` | The parameter list of a function. | | `formal_parameter` | a single parameter slot | A single parameter slot wrapping a binding pattern. | ### Functions ```zig pub const Function = struct { type: FunctionType, // declaration, expression, or TS forms id: NodeIndex, // binding_identifier (.null for anonymous) generator: bool, // true for function* async: bool, // true for async function declare: bool, // true for declare function params: NodeIndex, // formal_parameters body: NodeIndex, // function_body (.null for TS overloads / abstract) type_parameters: NodeIndex, // ts_type_parameter_declaration or .null return_type: NodeIndex, // ts_type_annotation or .null }; ``` | Tag | Description | | --------------------------- | ------------------------------------------------------------------------------ | | `function` | Every named and anonymous function form, including ambient and body-less ones. | | `function_body` | The braced body of a function. | | `arrow_function_expression` | An arrow function, with either an expression or a block body. | ### Classes ```zig pub const Class = struct { type: ClassType, // class_declaration or class_expression decorators: IndexRange, // decorator[] (empty if none) id: NodeIndex, // binding_identifier (.null for anonymous expressions) super_class: NodeIndex, // any expression (.null if no extends clause) body: NodeIndex, // class_body type_parameters: NodeIndex, // ts_type_parameter_declaration or .null super_type_arguments: NodeIndex, // ts_type_parameter_instantiation or .null implements: IndexRange, // ts_class_implements[] (empty if none) abstract: bool, // true for abstract class declare: bool, // true for declare class }; ``` | Tag | Description | | --------------------- | --------------------------------------------------------- | | `class` | Both class declarations and class expressions. | | `class_body` | The braced body of a class, holding its members. | | `method_definition` | A method, getter, setter, or constructor inside a class. | | `property_definition` | A class field or auto-accessor declaration. | | `static_block` | A `static { ... }` initialization block inside a class. | | `decorator` | A decorator (`@expr`) applied to a class or class member. | | `super` | The `super` keyword used as an expression head. | ### Modules | Tag | Syntax | Description | | ---------------------------- | ------------------------------ | ---------------------------------------------------------------------------- | | `import_declaration` | `import x from 'y'` | A static `import` declaration, including side-effect and phased forms. | | `import_specifier` | `{ imported as local }` | A named binding specifier in an import declaration. | | `import_default_specifier` | `import x from ...` | The default-binding specifier in an import declaration. | | `import_namespace_specifier` | `import * as x from ...` | A `* as local` namespace import specifier. | | `import_attribute` | `{ type: "json" }` | A single attribute in a `with { ... }` clause on an import or export. | | `export_named_declaration` | `export { x }`, `export var x` | An `export { ... }` or `export ` declaration, with optional re-export. | | `export_default_declaration` | `export default expr` | An `export default` declaration. | | `export_all_declaration` | `export * from 'y'` | An `export * from "m"` or `export * as ns from "m"` declaration. | | `export_specifier` | `{ local as exported }` | A named binding specifier in an export declaration. | ### JSX JSX nodes are only present in `.jsx` and `.tsx` trees. | Tag | Syntax | Description | | -------------------------- | --------------------------- | -------------------------------------------------------- | | `jsx_element` | `...` | A JSX element, possibly self-closing. | | `jsx_opening_element` | `` | The opening tag of a JSX element. | | `jsx_closing_element` | `` | The closing tag of a JSX element. | | `jsx_fragment` | `<>...` | A JSX fragment. | | `jsx_opening_fragment` | `<>` | The opening `<>` of a JSX fragment. | | `jsx_closing_fragment` | `` | The closing `` of a JSX fragment. | | `jsx_identifier` | `Foo` in JSX position | An identifier used as a JSX tag or attribute name. | | `jsx_namespaced_name` | `namespace:name` | A namespaced JSX name. | | `jsx_member_expression` | `Foo.Bar.Baz` | A dotted JSX tag name. | | `jsx_attribute` | `foo="bar"` or `foo={expr}` | A single JSX attribute, including boolean-only forms. | | `jsx_spread_attribute` | `{...props}` | A spread attribute on a JSX element. | | `jsx_expression_container` | `{expression}` | An `{ expression }` slot inside JSX. | | `jsx_empty_expression` | `{}` | The empty `{}` placeholder inside a JSX expression slot. | | `jsx_text` | text content between tags | A span of raw text inside a JSX element or fragment. | | `jsx_spread_child` | `{...children}` | A spread child inside a JSX element. | A JSX tag name (the `name` field on `jsx_opening_element`, `jsx_closing_element`, and one form of `jsx_attribute`) is one of `jsx_identifier`, `jsx_namespaced_name`, or `jsx_member_expression`. A JSX child (entries in the `children` list on `jsx_element` and `jsx_fragment`) is one of `jsx_text`, `jsx_expression_container`, `jsx_spread_child`, `jsx_element`, or `jsx_fragment`. ## TypeScript TypeScript nodes are present in `.ts`, `.tsx`, and `.dts` trees. ### Type wrapper | Tag | Syntax | Description | | -------------------- | ------ | ---------------------------------------------------------------------------- | | `ts_type_annotation` | `: T` | A `: T` annotation wrapping an inner type. The span starts at the `:` token. | ### Keyword types Each keyword is its own zero-field node. | Tag | Syntax | | ---------------------- | ----------- | | `ts_any_keyword` | `any` | | `ts_unknown_keyword` | `unknown` | | `ts_never_keyword` | `never` | | `ts_void_keyword` | `void` | | `ts_null_keyword` | `null` | | `ts_undefined_keyword` | `undefined` | | `ts_string_keyword` | `string` | | `ts_number_keyword` | `number` | | `ts_bigint_keyword` | `bigint` | | `ts_boolean_keyword` | `boolean` | | `ts_symbol_keyword` | `symbol` | | `ts_object_keyword` | `object` | | `ts_intrinsic_keyword` | `intrinsic` | | `ts_this_type` | `this` | ### Type references | Tag | Syntax | Description | | ------------------- | -------------------- | ---------------------------------------------------------------------------- | | `ts_type_reference` | `Foo`, `Promise` | A reference to a named type, optionally with type arguments. | | `ts_qualified_name` | `A.B.C` | A left-associative dotted type name. | | `ts_type_query` | `typeof console.log` | The `typeof` type operator applied to a value reference. | | `ts_import_type` | `import("m").Foo` | A reference to a type imported from a module path, written in type position. | ### Type parameters and arguments | Tag | Syntax | Description | | --------------------------------- | ---------------------- | ------------------------------------------------------------------------------ | | `ts_type_parameter` | `T`, `T extends U = V` | A single type parameter introduced by a generic declaration. | | `ts_type_parameter_declaration` | `` | The `<...>` parameter list introduced by a generic declaration. | | `ts_type_parameter_instantiation` | `` | The `<...>` argument list applied at a call site, reference, or instantiation. | ### Literal and template types | Tag | Syntax | Description | | -------------------------- | ----------------------------- | -------------------------------------------------------------------- | | `ts_literal_type` | `"hello"`, `42`, `true`, `-1` | A literal value used in type position. | | `ts_template_literal_type` | `` `Hello, ${N}!` `` | A template literal in type position with one or more interpolations. | ### Composite types | Tag | Syntax | Description | | ------------------------ | ----------------------- | ---------------------------------------------------------------------------- | | `ts_array_type` | `T[]` | A postfix array type. | | `ts_indexed_access_type` | `T[K]` | An indexed access type that looks up a property type. | | `ts_tuple_type` | `[T, U?, ...V[]]` | A fixed-length tuple type with positional, optional, rest, or named entries. | | `ts_named_tuple_member` | `label: T`, `label?: T` | A labeled element inside a tuple type. | | `ts_optional_type` | `T?` (in tuple slot) | An optional element inside a tuple type. | | `ts_rest_type` | `...T` (in tuple slot) | A rest element inside a tuple type. | ### Set-operation types | Tag | Syntax | Description | | ---------------------- | ----------------------------------- | ------------------------------------------------------------------ | | `ts_union_type` | `A \| B \| C` | A union of two or more types. | | `ts_intersection_type` | `A & B & C` | An intersection of two or more types. | | `ts_conditional_type` | `T extends U ? X : Y` | A conditional type selecting between two branches. | | `ts_infer_type` | `infer R`, `infer R extends string` | An `infer` placeholder inside a conditional type's extends branch. | ### Type operators | Tag | Syntax | Description | | ----------------------- | ------------------------------------------ | ----------------------------------------------------------- | | `ts_type_operator` | `keyof T`, `unique symbol`, `readonly T[]` | A `keyof`, `unique`, or `readonly` prefix on an inner type. | | `ts_parenthesized_type` | `(T)` | A parenthesized type used for grouping or precedence. | ### Callable types | Tag | Syntax | Description | | --------------------- | --------------------------------------- | ----------------------------------------------------------------------------- | | `ts_function_type` | `(x: T) => U` | A callable signature in type position. | | `ts_constructor_type` | `new (x: T) => U`, `abstract new ...` | A constructor signature in type position, optionally `abstract`. | | `ts_type_predicate` | `x is T`, `asserts x is T`, `asserts x` | A type predicate that narrows a parameter or `this` in control-flow analysis. | ### Object-shape types | Tag | Syntax | Description | | ----------------- | ----------------- | ------------------------------------------------------------------------ | | `ts_type_literal` | `{ x: T; y: U }` | An anonymous object type holding a list of signatures. | | `ts_mapped_type` | `{ [K in T]: V }` | A mapped type that projects every key in a union to a new property type. | ### JSDoc types | Tag | Syntax | Description | | ---------------------------- | ----------------- | --------------------------------------------------------------- | | `ts_jsdoc_nullable_type` | `?T` or `T?` | A JSDoc-style nullable type marker. | | `ts_jsdoc_non_nullable_type` | `!T` or `T!` | A JSDoc-style non-nullable type marker. | | `ts_jsdoc_unknown_type` | `?` (in `Foo`) | A JSDoc-style unknown type, valid only in a type argument slot. | ### Signature members These appear inside `ts_type_literal.members` and `ts_interface_body.body`. | Tag | Syntax | Description | | ------------------------------------ | ----------------------------------------- | -------------------------------------------------------------------------------- | | `ts_property_signature` | `key: T`, `readonly key?: T` | A property declaration inside a type literal or interface body. | | `ts_method_signature` | `m(x: T): U`, `get x(): T`, `set x(v: T)` | A method, getter, or setter declaration inside a type literal or interface body. | | `ts_call_signature_declaration` | `(x: T): U` | A bare call signature inside a type literal or interface body. | | `ts_construct_signature_declaration` | `new (x: T): U` | A bare construct signature inside a type literal or interface body. | | `ts_index_signature` | `[k: K]: V`, `readonly [...]: V` | An index signature inside a type literal, interface body, or class body. | ### Type, interface, and enum declarations | Tag | Syntax | Description | | --------------------------- | -------------------------------------- | ---------------------------------------------------------------------- | | `ts_type_alias_declaration` | `type Maybe = T \| null` | A `type` alias declaration, optionally generic and optionally ambient. | | `ts_interface_declaration` | `interface Foo extends Bar { ... }` | An `interface` declaration, optionally generic and optionally ambient. | | `ts_interface_body` | `{ ... }` of an interface | The body of an interface, holding its signature members. | | `ts_interface_heritage` | one entry of an `extends` clause | A single parent listed in an interface's `extends` clause. | | `ts_class_implements` | one entry of an `implements` clause | A single interface listed in a class's `implements` clause. | | `ts_enum_declaration` | `enum Color { ... }` | An `enum` declaration, optionally `const` and optionally ambient. | | `ts_enum_body` | `{ ... }` of an enum | The body of an enum, holding its members in source order. | | `ts_enum_member` | `A = 1` inside an enum body | A single member of an enum body, with an optional initializer. | ### Module and namespace declarations | Tag | Syntax | Description | | ----------------------- | ---------------------------------------- | --------------------------------------------------------------------- | | `ts_module_declaration` | `namespace Foo { ... }`, `module "x" {}` | A `namespace` or `module` declaration, optionally ambient. | | `ts_module_block` | the `{ ... }` of a module | The body of a `namespace`, `module`, or `declare global` declaration. | | `ts_global_declaration` | `declare global { ... }` | A `declare global` augmentation block. | ### Parameters and `this` | Tag | Syntax | Description | | ----------------------- | ----------------------------- | ------------------------------------------------------------------------------- | | `ts_parameter_property` | `constructor(public x: T) {}` | A constructor parameter that implicitly declares a class field. | | `ts_this_parameter` | `function f(this: T)` | An explicit `this` parameter declaring the type of `this` in the function body. | ### TypeScript expressions | Tag | Syntax | Description | | ----------------------------- | ------------------ | --------------------------------------------------------- | | `ts_as_expression` | `expr as T` | A postfix `as` type assertion. | | `ts_satisfies_expression` | `expr satisfies T` | A postfix `satisfies` constraint check. | | `ts_type_assertion` | `expr` | A prefix `` type assertion. Forbidden in `.tsx`. | | `ts_non_null_expression` | `expr!` | A postfix non-null assertion. | | `ts_instantiation_expression` | `expr` | A type instantiation expression without call parentheses. | ### TypeScript module forms | Tag | Syntax | Description | | --------------------------------- | ----------------------------------------------- | --------------------------------------------------------------------------------- | | `ts_export_assignment` | `export = expr` | A CommonJS-style ambient export. | | `ts_namespace_export_declaration` | `export as namespace Name` | A UMD ambient namespace export. | | `ts_import_equals_declaration` | `import x = require("m")`, `import x = Foo.Bar` | An `import =` declaration binding to a require or entity name. | | `ts_external_module_reference` | `require("m")` | The `require("module")` form on the right-hand side of `import x = require(...)`. | ## Comments The `comments` option controls collection. It has four modes. - `.flat` (default) puts every comment in a flat, source-ordered list, `tree.comments`, each carrying its source span. No per-node attachment. - `.attached` attaches each comment to the AST node it sits next to, read with `tree.commentsOf(idx)`. - `.both` produces the flat list and per-node attachment. - `.none` skips comments like whitespace. ```zig var tree = try parser.parse(allocator, source, .{ .comments = .flat }); defer tree.deinit(); for (tree.comments) |c| { // c.span is the whole comment, delimiters included std.debug.print("{s} @ {d}..{d}\n", .{ tree.string(c.value), c.span.start, c.span.end }); } ``` Each entry is a `Comment`. ```zig pub const Comment = struct { type: Type, // .line or .block value: String, // body without `//` or `/* */` delimiters span: Span, // full comment span, delimiters included }; ``` ### Attached comments `.attached` (and `.both`) bind each comment to its host node, read with `tree.commentsOf(idx)`. ```zig var tree = try parser.parse(allocator, source, .{ .comments = .attached }); defer tree.deinit(); for (tree.commentsOf(some_node_idx)) |c| { std.debug.print("{s} {s} {s}\n", .{ @tagName(c.position), @tagName(c.type), tree.string(c.value), }); } ``` Each is an `AttachedComment`. ```zig pub const AttachedComment = struct { type: Comment.Type, // .line or .block position: Position, // .before, .after, or .inside (relative to host) same_line: bool, // shares a source line with the host's adjacent edge value: String, // body without `//` or `/* */` delimiters }; ``` `position` tells you where the comment sits relative to its host. - `.before` leads the host node. - `.after` trails the host node. - `.inside` sits interior to an otherwise empty host, like `function f() { /* hi */ }`. `same_line` is true when the comment shares a source line with the host's adjacent edge (host start for `.before`, host end for `.after`). For `.inside` it is always false. ## Tokens The `tokens` option keeps every token the parser consumed in `tree.tokens`, a slice in source order. Off by default. ```zig var tree = try parser.parse(allocator, source, .{ .tokens = true }); defer tree.deinit(); for (tree.tokens) |token| { std.debug.print("{t} {s}\n", .{ token.tag, token.text(tree.source) }); } ``` A `Token` is its `span`, its `tag`, and a few flags, read through methods. ```zig token.span // byte range in the source token.text(tree.source) // the source text token.tag // one of the 160 TokenTag variants, .identifier, .arrow, ... token.tag.isKeyword() // and isReserved, isIdentifierLike, isBinaryOperator, ... token.tag.precedence() // binary precedence, 0 when none token.tag.toString() // "=>" for .arrow, null for tokens with free text token.hasLineTerminatorBefore() // what ASI looks at token.isEscaped() // `\u0061sync` is an .async token with this set token.hasInvalidEscape() // a template chunk whose cooked value is undefined token.hasLoneSurrogates() // a string with an unpaired surrogate ``` `TokenTag` is defined in [token.zig](https://github.com/yuku-toolchain/yuku/blob/main/src/parser/token.zig), one variant per punctuator, literal form, keyword, and identifier form. Tokens are as the parser resolved them: a regex is one `regex_literal`, and the `>>` closing a nested generic is two `greater_than`. The list closes with one zero-length `eof`. Comments are not tokens, see [Comments](#comments). Every node other than `program`, `template_element`, and `jsx_empty_expression` starts on a token start and ends on a token end, so a node's tokens are a contiguous slice found by binary search on `span.start`. # Semantic Analysis Source: https://yuku.fyi/parser/semantic/ Semantic analysis turns a parsed tree into a queryable model of the program. It records every lexical scope, every declared symbol, and every reference, and resolves each reference to the binding it names, space-aware the way TypeScript resolves names. The same pass reports the scope-dependent [early errors](https://tc39.es/ecma262/#early-error) that parsing alone cannot detect, such as redeclarations, unresolved exports, and private fields used outside their class. Yuku runs this as a separate pass over the parsed tree. This keeps parsing fast and lets each consumer opt in only to the work it needs. ## Analyzing a Tree `semantic.analyze` is the entry point. It returns the complete `Semantic` model and appends any early-error diagnostics to the tree: ```zig var tree = try parser.parse(allocator, source, .{}); defer tree.deinit(); const semantic = try parser.semantic.analyze(&tree); semantic.symbolOf(node); // what does this node declare or refer to? semantic.uses(symbol_id); // every use site of a symbol semantic.lookup(scope_id, "x", .value); // name resolution from any scope ``` Semantic diagnostics land in `tree.diagnostics` alongside parse errors. After analysis, `tree.hasErrors()` reflects both. Together, parsing and semantic analysis cover the full set of early errors required by the specification. All allocations use the tree's arena, so the model is valid for the lifetime of the tree and freed by `tree.deinit()`. > **Note** >If you want the model without the early-error checks, or you want to run your own hooks during the analysis walk, use the [semantic traverser](https://yuku.fyi/parser/traverse/#semantic-traverser) directly. `semantic.analyze` is built [exactly that way](https://github.com/yuku-toolchain/yuku/blob/main/src/parser/semantic/root.zig), with a visitor that emits the early-error diagnostics. ## The Semantic Model `Semantic` is one object that answers every scope, symbol, and reference question about a tree. References are already resolved and all indexes are already built, so every query below works immediately. Queries that may have no answer return optionals. Ids are dense indexes with defined orders. `SymbolId` orders symbols by first declaration, `ReferenceId` orders references by source position, and `ScopeId` orders scopes by creation during the walk (`ScopeId.root` is always `0`, and in modules `ScopeId.module` is always `1`). Every slice and iterator preserves these orders, so "first declaration", "earliest use", and "outermost scope" are index comparisons. The model is a snapshot of the tree at analysis time. Mutating the tree afterwards does not update it, so run `analyze` again for a fresh model. > **Note** >Working from JavaScript? The [Analyzer](https://yuku.fyi/analyzer/) exposes this same model per file, plus cross-file import/export linking. ### From a Node Node queries answer for a node you are holding, such as a match from a walk, a diagnostic location, or a codemod target: ```zig const sym = semantic.symbolOf(node); // ?SymbolId: declared here, or resolved to const ref = semantic.referenceOf(node); // ?ReferenceId recorded at this node const scope = semantic.scopeOf(node); // innermost ScopeId containing the node const parent = semantic.parentOf(node); // ?NodeIndex, null at the root // Walk up from any node. Yields the node itself first. var it = semantic.ancestors(node); while (it.next()) |ancestor| { if (tree.data(ancestor) == .variable_declaration) { // found the enclosing declaration statement } } ``` `symbolOf` answers for both sides. A `binding_identifier` gives its declared symbol, and an `identifier_reference` gives the symbol it resolved to. Only `binding_identifier` nodes declare, the nodes that reference are listed under [References](#references), and every other node kind answers `null`. `parentOf` reads a per-node parent table built during the walk, so walking up from any node needs no visitor state. The root answers `null`, and every other visited node has its parent recorded (nesting beyond the path capacity of 256 records none). `scopeOf` returns the innermost scope containing the node. A scope-creating node (function, block, class, ...) maps to the scope it creates. When you want the scope enclosing such a node instead, take one step up with `semantic.scope(semantic.scopeOf(node)).parent`. ### From a Symbol ```zig const symbol = semantic.symbol(id); // name, flags, scope // The `binding_identifier` node of each declaration, in source order. for (semantic.decls(id)) |binding_node| { ... } // Every use site, in source order. A plain slice of ReferenceIds. for (semantic.uses(id)) |ref_id| { const use = semantic.reference(ref_id); // use.node, use.flags.space, use.flags.write } ``` `decls` returns the name node at each declaration. For `let a = 1`, that is the `binding_identifier` `a`, and `semantic.parentOf` reaches the enclosing declarator or declaration statement from there. Most symbols have exactly one declaration. The slice grows when declarations legally merge into one symbol, such as `var` redeclarations, TS function overloads, and `class` + `interface` merging (see [Symbols](#symbols)). A conflicting redeclaration (`let x; let x;`) is recorded here too: the early error lands in `tree.diagnostics`, and the conflicting declarator is aliased onto the existing symbol so tooling on broken code still maps the node somewhere. Check `tree.hasErrors()` when only legal declarations matter. Any per-symbol fact derives from one loop over `uses`, since every use site carries its flags: ```zig // never used at all const unused = semantic.uses(id).len == 0; // never reassigned (initializers are not writes) var mutated = false; for (semantic.uses(id)) |ref_id| { if (semantic.reference(ref_id).flags.write) mutated = true; } ``` The same loop answers space questions, such as "is every use type-only", see [Declaration Spaces](#declaration-spaces). Every declaration and use carries its node, so rewrites compose directly with the [tree's write API](https://yuku.fyi/parser/traverse/#transform-traverser). A complete rename of one binding (check the new name against `lookup(scope, name, .any)` first if it might collide): ```zig const new_name = try tree.addString("renamed"); for (semantic.decls(id)) |node| { tree.setIdentifierName(node, new_name); } for (semantic.uses(id)) |ref_id| { tree.setIdentifierName(semantic.reference(ref_id).node, new_name); } ``` Rewrite as the final step before printing, or run `analyze` again afterwards. ### By Name ```zig // The binding of a name at one scope (including a hoisting `var` // passing through). No chain walk. const local = semantic.binding(scope_id, "x"); // The nearest binding visible from a scope, walking up the chain // exactly like reference resolution. The space picks what the name // can see: `.value` resolves like runtime code, `.type` like a type // annotation, and `.any` matches purely by name. const found = semantic.lookup(scope_id, "x", .value); const ty = semantic.lookup(scope_id, "T", .type); // Every symbol declared directly in a scope. var it = semantic.bindings(scope_id); while (it.next()) |sym_id| { ... } ``` `binding` checks a single scope, `lookup` walks the scope chain the way references resolve (see [Declaration Spaces](#declaration-spaces)), and `bindings` enumerates a scope. ### Iterating Everything `semantic.symbols` and `semantic.references` are plain slices in declaration/source order, and `semantic.scopes` holds every scope. When you also need the ids, the entry iterators pair them for you: ```zig var syms = semantic.iterSymbols(); while (syms.next()) |entry| { // entry.id, entry.symbol if (semantic.uses(entry.id).len == 0) { ... } // unused binding } var refs = semantic.iterReferences(); while (refs.next()) |entry| { if (entry.reference.symbol == .none) { ... } // unresolved name } var scopes = semantic.iterScopes(); while (scopes.next()) |entry| { // entry.id, entry.scope if (entry.scope.kind == .function) { ... } } ``` ### Semantic Reference The whole surface, in one place: ```zig semantic.scopes // ScopeTree: .get(id), .ancestors(id) semantic.symbols // []const Symbol, in declaration order semantic.references // []const Reference, in source order semantic.symbol(id) // Symbol by id semantic.reference(id) // Reference by id semantic.scope(id) // Scope by id semantic.symbolOf(node) // ?SymbolId: declared at node, or resolved to semantic.referenceOf(node) // ?ReferenceId recorded at node semantic.scopeOf(node) // innermost ScopeId containing the node semantic.parentOf(node) // ?NodeIndex, null at the root semantic.ancestors(node) // iterator from node up to the root semantic.decls(sym_id) // binding_identifier node of each declaration semantic.uses(sym_id) // []const ReferenceId, all use sites semantic.binding(scope, name) // ?SymbolId at one scope, no chain walk semantic.bindings(scope) // iterator over a scope's symbols semantic.lookup(scope, name, space) // ?SymbolId, walking the scope // chain like reference resolution // (.any matches purely by name) semantic.iterScopes() // (id, scope) entries semantic.iterSymbols() // (id, symbol) entries semantic.iterReferences() // (id, reference) entries ``` ## Scopes Every lexical scope is a `Scope`, stored in `semantic.scopes` (a `ScopeTree`) and indexed by `ScopeId`: ```zig pub const Scope = struct { node: ast.NodeIndex, // the AST node that created this scope parent: ScopeId, // parent scope, .none at the root hoist_target: ScopeId, // nearest ancestor (or self) where `var` hoists to kind: Kind, // .global, .module, .function, .block, .class, ... flags: Flags, // flags.strict }; ``` `ScopeId.root` (the global scope) is always `0`, and when `source_type` is `.module`, `ScopeId.module` is always `1`. `ScopeTree` has two methods. `get(id)` returns a scope and `ancestors(start)` iterates from a scope up to the root. ### What Creates a Scope Every construct in the spec that introduces a new lexical environment creates a scope, plus the TypeScript-specific ones that scope type parameters and infer bindings: | Node | Scope kind | | ------------------------------------------------------------ | ------------------------------------------------------------- | | Program | `global` (plus a child `module` if `source_type` is `module`) | | Function declaration / expression | `function`, wrapping a child `function_body` (see below) | | Arrow function | `function`, wrapping a child `function_body` | | Block statement | `block` | | `for` / `for...in` / `for...of` | `block` | | `catch` clause | `block` (the body block reuses this scope) | | `switch` statement | `block` | | Class declaration / expression | `class` (always strict per spec) | | Class static block | `static_block` | | Named function or class expression | `expression_name` wrapping a `function` / `class` scope | | TS interface / type alias | `block` | | TS function/constructor type, call/construct/index signature | `block` | | TS method signature | `block` | | TS mapped / conditional type | `block` (conditional isolates `infer T` per branch) | | TS namespace body | `ts_module` (its own kind, see below) | A few details worth knowing: **Catch clauses share their body block.** Per spec section 14.15.2 the catch parameter and the block body live in the same environment. One `block` scope is created on `catch_clause`, and the body's `block_statement` reuses it. This is what lets the single-scope `binding` lookup detect the early-error case where a `var` inside the body collides with the parameter. **Named function and class expressions create two scopes.** For `const x = function foo() { ... }`, ECMAScript section 15.2.5 wraps the body in an extra environment that holds an immutable binding for `foo`: ``` outer scope (x lives here) expression_name (foo lives here, immutable) function scope (body bindings live here) ``` Without this, `const foo = 1` inside the body would conflict with the expression name. Same pattern for `const C = class D { ... }` per section 15.7.14. **`ts_module` is its own scope kind, not a block.** TypeScript namespace bodies act as a `var`-hoist target, so a `var` inside a namespace stays inside the namespace instead of escaping to the surrounding scope. That difference is encoded as a separate `Scope.Kind` so `Kind.isHoistTarget()` returns true for it. **`function_body` gives every body its own scope.** Matching TypeScript's block locals, body-level `let`/`const`/`class` and local types (`interface`, `type`, `enum`) bind inside the body, invisible to the signature: in `function f(a: T) { type T = {} }` the parameter annotation stays unresolved. Body `var`s and function declarations are var-scoped and hoist past into the `function` scope, where they merge with parameters. A parameter list containing expressions makes the body their hoist target instead, the separate `var` environment of FunctionDeclarationInstantiation (section 10.2.11, step 28), so closures created in parameter defaults cannot see body `var`s: in `function f(a = () => x) { var x }` the default's `x` resolves to the outer scope. **Decorators evaluate in the scope enclosing the class.** Per the decorator proposal, neither the class's type parameters nor a class expression's own name are visible inside a decorator expression. `scopeOf` on nodes inside a decorator reflects that, even though the decorator sits inside the class syntactically. ### Strict Mode Strict mode propagates automatically: - Module scopes are always strict. - Class scopes are always strict. - A `"use strict"` directive at the top of any scope sets `flags.strict` on that scope. - Child scopes inherit strict mode from their parent. - Functions whose body opens with `"use strict"` are strict from the moment their scope is created, because the directive applies retroactively to the parameter list, where rules like "no duplicate parameters" only apply under strict mode. ## Symbols Each declared binding is a `Symbol`: ```zig pub const Symbol = struct { name: String, // index into the tree's string pool flags: Flags, // declaration kind + modifiers (see below) scope: ScopeId, // the scope this symbol was declared in }; ``` `scope` is where the binding actually lives. For a hoisting `var` that is the hoist target it lands in (function, module, or global scope), not the block it is written in. A single symbol can collect multiple declarations when the language allows merging, such as `var` redeclarations, TS function overloads, `class` + `interface` merging, `namespace` + `enum` merging, and ambient module patterns. The `binding_identifier` node of every declaration is recorded and `semantic.decls(id)` returns them all, so a renamer or a "go to definition" feature can show each one. ### Symbol Flags `Symbol.Flags` describes everything semantic analysis knows about a binding. A single symbol can carry several flags at once. A `class` lives in both value and type space, an exported `var` is both `function_scoped_var` and `exported`, and an interface and a class of the same name merge into a single symbol that satisfies both kinds. The flags group into three categories. **Declaration kind** (what created the binding): ```zig symbol.flags.function_scoped_var // var, parameter, catch_var symbol.flags.block_scoped_var // let, const, using, await_using symbol.flags.function // function declaration / expression symbol.flags.class // class declaration / expression symbol.flags.interface // TS interface symbol.flags.type_alias // TS type alias symbol.flags.type_parameter // TS , infer T, mapped key symbol.flags.regular_enum // TS enum symbol.flags.const_enum // TS const enum symbol.flags.value_module // TS namespace whose body has runtime content symbol.flags.namespace_module // TS namespace (any kind) symbol.flags.import // value or unspecified-kind import symbol.flags.type_import // `import type ...` or `import { type x }` symbol.flags.enum_member // TS enum member, bound in its enum's body scope ``` **Modifiers** (qualifiers on the binding): ```zig symbol.flags.const_var // const or using binding symbol.flags.parameter // function/method parameter symbol.flags.catch_var // catch (e) binding symbol.flags.ambient // TS `declare` symbol.flags.exported // exported from a module symbol.flags.is_default // default export ``` **Helpers** on the flag struct itself: ```zig flags.intersects(other) // true if `flags` and `other` share at least one flag flags.merge(other) // union of two flag sets (used when merging compatible declarations) flags.isHoistingVar() // true for a real `var` (not a parameter, not a catch_var) flags.isBlockScopedLike() // names a hoisting `var` cannot pass through // (block_scoped_var, class, function) flags.toString() // human-readable category for diagnostics ``` `Symbol.variable`, `Symbol.any_import`, `Symbol.value_space`, `Symbol.type_space`, and `Symbol.namespace_space` are ready-made masks for `intersects`, the same sets the JS decoder mirrors as [`SymbolFlags`](https://yuku.fyi/analyzer/#flags) composites. The space predicates (`inValueSpace`, `inTypeSpace`, `inNamespaceSpace`, `visibleIn`) answer which declaration spaces a symbol occupies. They drive reference resolution and have their own section: [Declaration Spaces](#declaration-spaces). ### Redeclaration Excludes Each declaration kind has a precomputed `Symbol.Excludes.X` flag set. The rule is uniform: > A new declaration with `Excludes.X` conflicts with any existing symbol whose flags **intersect** `Excludes.X`. Otherwise the two declarations merge into a single symbol with the union of their flags. ```zig Symbol.Excludes.block_scoped_var // let / const / using Symbol.Excludes.function_scoped_var // var Symbol.Excludes.function // function (allows overloads in TS, var-merge in sloppy) Symbol.Excludes.class Symbol.Excludes.interface Symbol.Excludes.type_alias Symbol.Excludes.regular_enum Symbol.Excludes.const_enum Symbol.Excludes.value_module Symbol.Excludes.namespace_module Symbol.Excludes.import_binding Symbol.Excludes.parameter Symbol.Excludes.catch_var Symbol.Excludes.type_parameter Symbol.Excludes.enum_member ``` This single mechanism handles function overloads, `class` + `interface` declaration merging, `namespace` + `enum` merging, and ambient module patterns without any per-construct branching. ## Declaration Spaces TypeScript names live in three declaration spaces. Value space is what exists at runtime, type space is what annotations can name, and namespace space is what a dotted type name (`ns.T`, `E.A`) can start from. Plain JavaScript has only value space, and everything below reduces to it automatically. Both sides of the model speak in spaces. A **symbol occupies** one or more spaces, decided by its declarations: ```zig flags.inValueSpace() // var, let, const, function, class, enum + members, value namespace flags.inTypeSpace() // class, enum + members, interface, type alias, type parameter flags.inNamespaceSpace() // namespace, enum ``` `class` and `regular_enum` deliberately satisfy both `inValueSpace` and `inTypeSpace`. That is what makes "use a class as a type" work without special-casing. Import bindings alias another module's symbol, whose space one file cannot know, so they count as occupying every space. A **reference resolves in** exactly one space, decided by its syntactic position and carried as `flags.space`: | `space` | Position | Resolves against | | ------------ | ------------------------------------------------------------------- | ------------------------------ | | `.value` | runtime uses | value space | | `.type` | annotations, heritage clauses, type arguments | type space | | `.namespace` | the qualifier of a dotted type name (`ns.T`, `E.A`) | namespaces and enums | | `.typeof` | the entity of a `typeof` query, a type-predicate parameter | value space | | `.any` | `export { x }`, `export default x`, `export = x`, `import a = x` | every space | Resolution connects the two. Walking up the scope chain, a name match counts only when the symbol is visible in the reference's space (`Symbol.Flags.visibleIn`), so a binding outside the space does not shadow: ```ts type T = string; function f() { const T = 1; let x: T; // resolves to the outer `type T`, exactly like tsc T; // resolves to the inner `const T` } ``` The annotation skips the inner `const T` and keeps walking the chain. A reference whose space has no binding anywhere resolves to `.none` even when another space binds the name, which is tsc's "cannot find name" behavior. Two helpers expose the machinery directly. `semantic.lookup(scope, name, space)` runs the same walk as a query, with `.any` matching by name alone. `space.inTypePosition()` is true for the three spaces sitting inside a type-only subtree (`.type`, `.namespace`, `.typeof`), which is what rename-aware tooling checks to change a value without touching a same-named type, and vice versa: ```zig // does this import only back type uses? then `import type` is safe var type_only = true; for (semantic.uses(id)) |ref_id| { if (!semantic.reference(ref_id).flags.space.inTypePosition()) type_only = false; } ``` ## References Every `identifier_reference` node produces one `Reference`, and so do JSX component tag names (``) and TS type-predicate parameters (`x is T`), so rename-aware tooling sees every site that must change together: ```zig pub const Reference = struct { name: String, scope: ScopeId, // the scope the reference appears in node: ast.NodeIndex, // the referencing node symbol: SymbolId, // the declaring symbol, .none if unresolved flags: Flags, // flags.space, flags.write }; ``` `symbol` is the resolution, the binding this reference refers to. Resolution is scope-based, exactly like name binding in the language: a reference resolves to the nearest binding of its name up the scope chain that is visible in its space (see [Declaration Spaces](#declaration-spaces)), regardless of where in those scopes the declaration appears (a use above a `let` still resolves to it, and the temporal dead zone is a runtime concern, not a binding concern). `.none` means the reference's space has no binding of that name anywhere in the chain, which makes it a global, an undeclared name, or a free variable. Language-provided names are not bindings. `this`, `super`, and (unless user-declared) `arguments` produce no symbols, so a reference to `arguments` inside a function is unresolved. Labels are their own namespace and produce neither symbols nor references. `flags.write` is true when the reference (re)assigns its binding, meaning the target of an assignment, the operand of `++`/`--`, the iteration variable of a `for...in`/`for...of`, or a destructuring assignment leaf. Compound targets (`+=`, `++`) both read and write. Initializers in declarations are part of the declaration, not references, so `let x = 1` produces no write reference. Only a later `x = 2` does. ## Module Records `semantic.module_record` produces a module's flat import and export records, mirroring the specification's [ImportEntry](https://tc39.es/ecma262/#table-importentry-record-fields) / [ExportEntry](https://tc39.es/ecma262/#table-exportentry-records) model. The [Analyzer](https://yuku.fyi/analyzer/)'s cross-file linking is built on it. Records carry the `SymbolId` of each local binding, so a linker joins an importing module's record to the defining module's symbol with no name re-resolution. That is why `collect` takes the semantic model: ```zig var tree = try parser.parse(allocator, source, .{}); defer tree.deinit(); const semantic = try parser.semantic.analyze(&tree); const records = try parser.semantic.module_record.collect(&tree, &semantic); records.imports // []const Import records.exports // []const Export records.flags // CommonJS classification signals ``` Static records come first in source order, then dynamic `import()` and `require` records in source order. Everything is allocated in the tree's arena and freed by `tree.deinit()`. ### Imports One `Import` per dependency edge, static or dynamic. A single `kind` pins down the form: | Source | `kind` | `name` | `symbol` | | ---------------------------- | ---------------- | ----------- | --------------- | | `import x from "m"` | `.named` | `"default"` | binding of `x` | | `import { a as b } from "m"` | `.named` | `"a"` | binding of `b` | | `import * as ns from "m"` | `.namespace` | `.empty` | binding of `ns` | | `import "m"` | `.side_effect` | `.empty` | `.none` | | `import x = require("m")` | `.import_equals` | `.empty` | binding of `x` | | `import("m")` | `.dynamic` | `.empty` | `.none` | | `require("m")` | `.require` | `.empty` | `.none` | | Field | Type | Meaning | | ----------- | ------------------ | ---------------------------------------------------------------------- | | `kind` | `Import.Kind` | The import form | | `name` | `ast.String` | Imported name of a `.named` record, `"default"` for default imports | | `symbol` | `SymbolId` | Local binding, `.none` when the form binds nothing | | `specifier` | `ast.String` | Decoded module specifier | | `type_only` | `bool` | True for `import type` and `import { type x }` | | `phase` | `?ast.ImportPhase` | Stage 3 phase modifier (`.source`, `.defer`), `null` otherwise | | `node` | `ast.NodeIndex` | The smallest node identifying the record (see below) | `node` is the import specifier for `.named` and `.namespace` records, the call expression for `.dynamic` and `.require`, and the whole declaration for `.side_effect` and `.import_equals`. `.dynamic` and `.require` records are collected from any depth, only for string-literal specifiers. A `require` call counts only when `require` is a free name, so a parameter or local named `require` never produces a record. TS `import a = B.C` aliases a namespace, not a module, and produces no record. ### Exports One `Export` per exported name: | Source | `kind` | `name` | `symbol` | `from_name` | | -------------------------------- | ------------ | ----------- | -------------- | ----------- | | `export const a = 1` | `.named` | `"a"` | binding of `a` | | | `export default function f() {}` | `.named` | `"default"` | binding of `f` | | | `export default expr` | `.named` | `"default"` | `.none` | | | `export { a as b }` | `.named` | `"b"` | binding of `a` | | | `export { a as b } from "m"` | `.re_export` | `"b"` | `.none` | `"a"` | | `export * as ns from "m"` | `.namespace` | `"ns"` | `.none` | | | `export * from "m"` | `.star` | `.empty` | `.none` | | | `export = expr` | `.equals` | `.empty` | see below | | | `export as namespace N` | `.global` | `"N"` | `.none` | | | Field | Type | Meaning | | ----------- | --------------- | --------------------------------------------------------------- | | `kind` | `Export.Kind` | The export form | | `name` | `ast.String` | Exported name, the global name for `.global`, empty otherwise | | `symbol` | `SymbolId` | Backing local binding, `.none` when nothing local backs it | | `from_name` | `ast.String` | Name taken from the source module by a `.re_export` | | `specifier` | `ast.String` | Decoded specifier of the `from` clause, empty for local exports | | `type_only` | `bool` | True for type-only exports | | `node` | `ast.NodeIndex` | The smallest node identifying the record (see below) | `node` is the export specifier for `.named` and `.re_export` records with specifiers, the binding identifier for names exported by a declaration (`export const a = 1` points at `a`), and the whole declaration otherwise (`export default`, `.star`, `.namespace`, `.equals`, `.global`). `export ` produces one record per bound name, so `export const { a, b: [c] } = x` records `a` and `c`. `export default expr` and `export = expr` resolve `symbol` only when the expression is an identifier naming a module-scope binding. Following the specification, `default` is an export *name*, not a kind of its own. ### Flags CommonJS exports are runtime assignments with no sound static shape, so they never become export records. `records.flags` classifies the file instead, from free (unshadowed, value-position) references at any depth: ```zig records.flags.uses_require // a free `require` reference appears records.flags.uses_module // a free `module` reference appears records.flags.uses_exports // a free `exports` reference appears records.flags.uses_import_meta // `import.meta` appears ``` # Traverse Source: https://yuku.fyi/parser/traverse/ The traverser is how you do real work on a Yuku AST. It walks the tree for you, calls your visitor hooks at every node, and (depending on the mode you pick) hands you the surrounding lexical scopes, the full [semantic model](https://yuku.fyi/parser/semantic/), or a mutable handle on the tree itself. | Mode | What you get | Returns | | ------------- | ------------------------------------------------- | ----------- | | **Basic** | Path from root, plus the full immutable tree | nothing | | **Scoped** | Path + lexical scopes (with strict-mode tracking) | `ScopeTree` | | **Semantic** | Path + scopes + symbols and references | `Semantic` | | **Transform** | Path + a mutable `*Tree` for in-place rewrites | nothing | The three read-only modes (basic, scoped, semantic) hand your visitor a `*const Tree`, so the type system guarantees you cannot accidentally mutate the AST while analysing it. Transform is the only mode that gives you `*Tree`. This is intentional. Tracked state (scopes, symbols) and tree mutation cannot safely coexist in a single pass, so the API splits them. This page covers the hooks, the path, the four modes, and the transform patterns. The data the walks produce, with scopes, symbols, and resolved references, is documented in [Semantic Analysis](https://yuku.fyi/parser/semantic/). > **Note** >The traverser is stable and powers Yuku's semantic checker in production. Breaking changes are called out in release notes. ## Your First Visitor A visitor is just a struct with `enter_*` and `exit_*` methods. Pick a node type, write a method named after it, and the walker will call you when it gets there. ```zig const std = @import("std"); const parser = @import("parser"); const ast = parser.ast; const traverser = parser.traverser; const basic = traverser.basic; const Counter = struct { functions: u32 = 0, pub fn enter_function( self: *Counter, _: ast.Function, _: ast.NodeIndex, _: *basic.Ctx, ) traverser.Action { self.functions += 1; return .proceed; } }; var tree = try parser.parse(allocator, source, .{}); defer tree.deinit(); var counter = Counter{}; try basic.traverse(Counter, &tree, &counter); std.debug.print("found {d} functions\n", .{counter.functions}); ``` That is the entire shape of every traverser-based tool you will write. The rest of this page is "what else you can do inside a hook". ## Hooks Every hook follows the same four-argument shape: ```zig pub fn enter_( self: *V, // your visitor payload: ast., // the node's unpacked payload index: ast.NodeIndex, // the node's index in the tree ctx: *.Ctx, // mode-specific context ) traverser.Action { ... } pub fn exit_( self: *V, payload: ast., index: ast.NodeIndex, ctx: *.Ctx, ) void { ... } ``` The hook name must match a field in `ast.NodeData`. Misspell it (`enter_funciton`) or use the wrong payload type and Zig produces a clear compile error at the `traverse` call site. There are no silent mismatches, and no runtime "method not found" surprises. Enter hooks may return either `traverser.Action` or `Allocator.Error!traverser.Action`. Both are accepted, so a hook that never allocates can write `return .proceed;` directly, while one that calls `addNode` writes `return .proceed;` from inside a `try` block. Exit hooks always return `void`. ### Receiving the Payload Already Unpacked Each typed hook receives its node's payload pre-extracted from the tagged union, so you never write a `switch` to "unwrap" the node you just matched on: ```zig pub fn enter_binary_expression( _: *V, expr: ast.BinaryExpression, // already unpacked, no switch needed _: ast.NodeIndex, _: *basic.Ctx, ) traverser.Action { if (expr.operator == .add) { ... } return .proceed; } ``` When you do need to peek at a child node before the walker reaches it, switch on `ctx.tree.data(child_index)`: ```zig pub fn enter_call_expression( _: *V, call: ast.CallExpression, _: ast.NodeIndex, ctx: *basic.Ctx, ) traverser.Action { switch (ctx.tree.data(call.callee)) { .member_expression => |mem| { // callee looks like obj.method(...) _ = mem; }, .identifier_reference => |id| { const name = ctx.tree.string(id.name); // callee is a bare identifier _ = name; }, else => {}, } return .proceed; } ``` See the [AST reference](https://yuku.fyi/parser/ast/) for every node type and its fields. ### Catch-All Hooks If you want one hook that fires for every node regardless of type, define `enter_node` and/or `exit_node`. The payload is `ast.NodeData` (the full union): ```zig pub fn enter_node( self: *V, data: ast.NodeData, index: ast.NodeIndex, ctx: *basic.Ctx, ) traverser.Action { return .proceed; } pub fn exit_node( self: *V, data: ast.NodeData, index: ast.NodeIndex, ctx: *basic.Ctx, ) void {} ``` When both a typed hook and a catch-all are defined, the order is: - **Enter**: `enter_node` first, then `enter_` - **Exit**: `exit_` first, then `exit_node` That ordering lets a catch-all enter "gate" a subtree (return `.skip` to opt out before the typed hooks run) and a catch-all exit "summarise" what just happened. ### Walk Order The walk is a depth-first, source-order traversal with two guarantees you can build on: - **Enter hooks fire pre-order, exit hooks post-order.** A parent's enter runs before any of its children, and a parent's exit runs after all of its children. - **Siblings are visited in source order.** Node payloads declare their child fields in source order and the walker visits fields in declaration order, an invariant enforced across the whole parser corpus. The one exception is template literals, whose quasis and expressions interleave in the source but are visited field by field, matching every ESTree walker. ### Actions Every enter hook returns one of three actions: | Action | Effect | | ---------- | --------------------------------------------- | | `.proceed` | Walk into this node's children | | `.skip` | Do not descend, move to the next sibling | | `.stop` | No further enter hooks fire anywhere | Enter and exit hooks are always paired. Once a node's enter hooks have run, its exit hooks run too. `.skip` skips the children but still fires the node's own exit hooks. After a `.stop`, the exit hooks of every already-entered node still run as the walk unwinds to the root. The pairing is what keeps path, scope, and symbol tracking balanced, so a scoped or semantic traversal that skipped or stopped still returns a consistent result covering everything that was visited. `.skip` is what you return after hand-walking a subtree yourself, or after a transform that should not be re-entered. `.stop` is how you implement "find the first X and exit". ### Running Without Hooks Sometimes the only thing you want from the traverser is its output, a `ScopeTree` from the scoped mode or the full [`Semantic`](https://yuku.fyi/parser/semantic/#the-semantic-model) model from the semantic mode. In that case you do not need to define any hooks. Pass an empty struct as the visitor and the walker still runs end-to-end, the trackers still produce their result, and nothing fires in between. ```zig const NoopVisitor = struct {}; var noop = NoopVisitor{}; // Just the scope tree: const scope_tree = try scoped.traverse(NoopVisitor, &tree, &noop); // The full semantic model: const semantic = try sem.traverse(NoopVisitor, &tree, &noop); ``` The empty struct makes the absence of hooks explicit. A tool that wants the result of a walk without any per-node logic spells that out at the call site. ## The Path Every mode tracks the path from the root down to the current node. The path is a small fixed-capacity stack of `NodeIndex` values you can read at any time through `ctx.path`. ```zig ctx.path.parent() // immediate parent NodeIndex, or null at root ctx.path.ancestor(0) // current node ctx.path.ancestor(1) // parent (same as parent()) ctx.path.ancestor(2) // grandparent ctx.path.depth() // 0 at root, grows as you descend var it = ctx.path.ancestors(); while (it.next()) |idx| { // walks from current node up to root } ``` Combined with the full tree (always available as `ctx.tree`), the path lets you navigate freely: ```zig pub fn enter_identifier_reference( _: *V, id: ast.IdentifierReference, _: ast.NodeIndex, ctx: *basic.Ctx, ) traverser.Action { // is this identifier the callee of a call expression? if (ctx.path.parent()) |parent_idx| { if (ctx.tree.data(parent_idx) == .call_expression) { const name = ctx.tree.string(id.name); _ = name; } } return .proceed; } ``` The path records up to 256 entries. Deeper nodes are still walked and `depth()` stays accurate, but `parent()` and `ancestor()` return `null` past the recorded depth. 256 levels is far beyond any realistic ECMAScript nesting, so in practice you can treat the path as unbounded. ## Basic Traverser The lightest mode, with path tracking plus the full immutable tree. No allocator needed, nothing returned. Use it for tools that only need structural pattern matching: - `eslint`-style rules that look at "this node and its parent" - counters and statistics - AST-shape assertions in tests - pretty-printers that read but never write the tree ```zig const basic = traverser.basic; var visitor = MyVisitor{}; try basic.traverse(MyVisitor, &tree, &visitor); ``` `basic.Ctx` carries: ```zig ctx.tree // *const ast.Tree, full read access ctx.path // NodePath, the current path stack ``` That is it. Step up to scoped or semantic when you need to know what bindings are in scope. ## Scoped Traverser Scoped mode adds automatic lexical scope tracking. Whenever the walker enters a scope-creating node, the tracker pushes a new scope. On exit, it pops. Your hooks see `ctx.scope.currentScope()` already pointing at the right place. ```zig const scoped = traverser.scoped; var visitor = MyVisitor{}; const scope_tree = try scoped.traverse(MyVisitor, &tree, &visitor); // scope_tree contains every scope the walk produced ``` The tracker pushes a scope for every construct that introduces a lexical environment and tracks strict mode as it goes. [Scopes](https://yuku.fyi/parser/semantic/#scopes) documents the full list of scope-creating constructs, the scope kinds, and the strict-mode rules. ### Querying the Tracker Inside a hook, `ctx.scope` is a live `ScopeTracker` you can interrogate: ```zig pub fn enter_node( _: *V, _: ast.NodeData, _: ast.NodeIndex, ctx: *scoped.Ctx, ) traverser.Action { const id = ctx.scope.current; // ScopeId of the current scope const cur = ctx.scope.currentScope(); // Scope value (kind, flags, parent, ...) const hoist = ctx.scope.hoistTarget(); // where `var` declarations would land const strict = ctx.scope.isStrict(); if (cur.kind == .function and !strict) { ... } var it = ctx.scope.ancestors(id); while (it.next()) |ancestor_id| { const ancestor = ctx.scope.get(ancestor_id); _ = ancestor; } return .proceed; } ``` ### Using the ScopeTree After Traversal `scoped.traverse` returns an immutable `ScopeTree` containing every scope that was created. `get(id)` returns a [`Scope`](https://yuku.fyi/parser/semantic/#scopes) and `ancestors(start)` walks from a scope up to the root: ```zig const scope_tree = try scoped.traverse(MyVisitor, &tree, &visitor); const root = scope_tree.get(.root); var it = scope_tree.ancestors(some_scope_id); while (it.next()) |id| { const scope = scope_tree.get(id); _ = scope; } ``` The tree is backed by the parser's arena, so it lives as long as the source `Tree` does. Calling `tree.deinit()` invalidates it. ## Semantic Traverser Semantic mode is the full-power one, with path, scopes, symbols, references, redeclaration handling, and TypeScript context tracking. ```zig const sem = traverser.semantic; var visitor = MyVisitor{}; const semantic = try sem.traverse(MyVisitor, &tree, &visitor); ``` It returns a [`Semantic`](https://yuku.fyi/parser/semantic/#the-semantic-model), the complete model of the tree with every reference resolved to its declaring symbol. This is the same model `semantic.analyze` returns. Drive the traverser directly when you want your own hooks to run during the analysis walk. `sem.Ctx` exposes: ```zig ctx.tree // *const ast.Tree ctx.path // NodePath ctx.scope // ScopeTracker (same API as scoped mode) ctx.symbols // SymbolTracker ctx.inTypePosition() // true inside a TS type-only subtree ctx.inTsNamespace() // true inside a TS `namespace` body ``` ### Querying the Symbol Tracker Inside a hook, `ctx.symbols` is the live symbol tracker, and `ctx.scope` works exactly as in [scoped mode](#querying-the-tracker). The tracker answers name questions about everything declared so far: ```zig pub fn enter_identifier_reference( _: *V, id: ast.IdentifierReference, _: ast.NodeIndex, ctx: *sem.Ctx, ) traverser.Action { const name = ctx.tree.string(id.name); // is this name bound anywhere in the current scope chain? var it = ctx.scope.ancestors(ctx.scope.current); while (it.next()) |scope_id| { if (ctx.symbols.binding(scope_id, name)) |sym_id| { const symbol = ctx.symbols.symbol(sym_id); // name, flags, scope const first = ctx.symbols.firstDeclOf(sym_id); // its first declaration _ = symbol; _ = first; break; } } return .proceed; } ``` The live surface: ```zig ctx.symbols.pending // the pending binding (see below) ctx.symbols.symbol(id) // Symbol by id ctx.symbols.binding(scope, name) // binding at one scope, including a // hoisting `var` passing through ctx.symbols.ownBinding(scope, name) // declared directly in `scope` only ctx.symbols.firstDeclOf(id) // binding_identifier node of the // first declaration ``` > **Note** >The trackers are live, so they know only what the walk has visited so far. A name declared later in the file is not bound yet, and the full [`Semantic`](https://yuku.fyi/parser/semantic/#the-semantic-model) model (resolved references, use indexes, node queries) exists only after `traverse` returns. Query the trackers for the mid-walk view. Run the traversal first and query the model when a tool needs whole-file answers. ### Two-Phase Binding Symbol declaration is split across two phases per node: 1. **Phase 1, on enter**: when entering a parent declaration node (`variable_declaration`, `function`, `class`, `import_declaration`, `formal_parameters`, `catch_clause`, `ts_interface_declaration`, etc.), the tracker records _what kind_ of binding the next `binding_identifier` should produce: its flags, its redeclaration excludes, and its target scope. This happens **before** your enter hook runs. 2. **Phase 2, on `post_enter`**: after your enter hook returns, but before the walker descends into children, the tracker materialises the actual symbol or reference. `binding_identifier` becomes a `Symbol`, `identifier_reference` becomes a `Reference`. Why the split? It guarantees a useful invariant for your visitor: > **Note** >Your enter hook on a `binding_identifier` sees the scope state **before** that binding has been declared. You can inspect what is _about to_ be declared, look up whether something with the same name already exists, and decide what to do, before the tracker commits the new symbol. ```zig pub fn enter_binding_identifier( _: *V, id: ast.BindingIdentifier, _: ast.NodeIndex, ctx: *sem.Ctx, ) !traverser.Action { const pending = ctx.symbols.pending; // pending.flags - what the new symbol will be // pending.excludes - what it conflicts with // pending.scope - which scope it lands in const name = ctx.tree.string(id.name); if (ctx.symbols.binding(pending.scope, name)) |existing_id| { const existing = ctx.symbols.symbol(existing_id); if (existing.flags.intersects(pending.excludes)) { // genuine conflict: emit a redeclaration diagnostic } else { // compatible merge (e.g. function overload, class + interface, // namespace + value). The tracker will merge them automatically // in post_enter. } } return .proceed; } ``` `ctx.symbols.pending` is the pending state. Reading it inside an enter hook on a `binding_identifier` is always safe. Reading it at any other node is undefined. The flags and excludes it carries are documented in [Symbol Flags](https://yuku.fyi/parser/semantic/#symbol-flags) and [Redeclaration Excludes](https://yuku.fyi/parser/semantic/#redeclaration-excludes). ### TypeScript Context Flags Two booleans on `sem.Ctx` track whether the walker is currently inside TS-only territory: ```zig pub fn enter_identifier_reference( _: *V, id: ast.IdentifierReference, _: ast.NodeIndex, ctx: *sem.Ctx, ) traverser.Action { if (ctx.inTypePosition()) { // Inside a type annotation, type reference, type parameter, // type literal, mapped/conditional type, etc. // References here are tagged as `.type` automatically. } if (ctx.inTsNamespace()) { // Inside a TS `namespace` body. } _ = id; return .proceed; } ``` `sem.refSpace(tree, &ctx.path, ctx.inTypePosition())` and `sem.isWriteTarget(tree, &ctx.path)` are the two rules exported, so a hook can classify an identifier position itself. They are what fill `Reference.flags.space` and `flags.write`. `inTypePosition()` is also what the tracker uses internally to decide that a `binding_identifier` inside a function-type or index signature is a parameter _label_ (not a real declaration). Only `type_parameter` bindings are real in type position. ## Transform Traverser Transform mode is for rewrites such as codemods, desugaring passes, and AST-level optimisations. Your visitor receives `*Tree` (mutable) and can call `setData`, `setSpan`, `setIdentifierName`, `addNode`, and `addExtra` from inside any hook. ```zig const transform = traverser.transform; var visitor = MyTransform{}; try transform.traverse(MyTransform, &tree, &visitor); ``` `transform.Ctx` is intentionally minimal: ```zig ctx.tree // *ast.Tree, full read AND write access ctx.path // NodePath ``` There is no scope or symbol tracking in this mode. Mutating the tree would invalidate any tracked state mid-walk, so the design splits "analyse" from "rewrite" at the type level. If you need both, run two passes (see [Combining Modes](#combining-modes) below). A [`Semantic`](https://yuku.fyi/parser/semantic/#the-semantic-model) model built before a transform describes the tree as it was, so nodes you add and rewrites you make are not in it. Run `analyze` again after mutating when a later pass needs fresh semantics. ### Replacing a Node In Place The simplest transform replaces a node's data inside its enter hook. The walker re-reads the node after every enter, so the replacement's children are walked automatically: ```zig pub fn enter_binary_expression( _: *MyTransform, expr: ast.BinaryExpression, index: ast.NodeIndex, ctx: *transform.Ctx, ) traverser.Action { if (expr.operator == .add) { ctx.tree.setData(index, .{ .binary_expression = .{ .left = expr.left, .right = expr.right, .operator = .multiply, }}); } return .proceed; } ``` ### Renaming an Identifier `setIdentifierName` rewrites the name field of any identifier-shaped node (`binding_identifier`, `identifier_reference`, `identifier_name`, `label_identifier`, `private_identifier`, `jsx_identifier`) without changing its variant or span: ```zig const new_name = try ctx.tree.addString("a"); ctx.tree.setIdentifierName(node_index, new_name); ``` This is the primitive Yuku's minifier uses to rename every site of a symbol in lock-step. Combined with `decls` and `uses` on the semantic model, you can rewrite a whole binding in a few lines. ### Creating New Nodes Use `addNode` to append a brand-new node and get its index. Use `addExtra` to allocate variable-length child lists for fields typed as `IndexRange`: ```zig const lit = try ctx.tree.addNode( .{ .numeric_literal = .{ .raw = "42" } }, .none, // span: .none if it has no source location ); const args = try ctx.tree.addExtra(&.{ child1, child2, child3 }); ``` Both are safe to call during traversal and use the tree's arena, so there is nothing to free. ### Wrapping a Node A common pattern is "take this node, move it to a fresh node, replace this one with a wrapper pointing at the moved copy". Useful for parenthesising expressions, wrapping in `await`, etc. ```zig pub fn enter_binary_expression( _: *MyTransform, expr: ast.BinaryExpression, index: ast.NodeIndex, ctx: *transform.Ctx, ) !traverser.Action { const span = ctx.tree.span(index); // 1. Move the original data into a new node, keeping its span. const inner = try ctx.tree.addNode( .{ .binary_expression = expr }, span, ); // 2. Replace the current node with a wrapper that points at the moved copy. ctx.tree.setData(index, .{ .parenthesized_expression = .{ .expression = inner } }); // 3. Skip so the walker does not re-enter and re-wrap the moved node. return .skip; } ``` Returning `.skip` here is essential. If you let the walker descend, it will re-read the new wrapper, find its child (the moved copy), and call `enter_binary_expression` again on the same data, infinitely. ### Self-Reference Safety > **Caution** >Never set a node's child to its own index. The walker re-reads node data after every enter hook, so a self-referential node causes infinite recursion. ```zig // WRONG: cycle. The wrapper points to its own index. const wrapper = try ctx.tree.addNode( .{ .parenthesized_expression = .{ .expression = index } }, span, ); ctx.tree.setData(index, ctx.tree.data(wrapper)); // RIGHT: move original data to a fresh node, wrap that. const inner = try ctx.tree.addNode(original_data, span); ctx.tree.setData(index, .{ .parenthesized_expression = .{ .expression = inner } }); ``` ## Building ASTs From Scratch `Tree.initEmpty(allocator)` creates a tree with no source text, intended for programmatic AST construction. Because there is no source backing it, every string must be created with `tree.addString(...)`. A valid tree starts from a `program` root node: ```zig var out = ast.Tree.initEmpty(allocator); defer out.deinit(); const hello = try out.addString("hello"); const lit = try out.addNode( .{ .string_literal = .{ .value = hello } }, .none, ); const stmt = try out.addNode( .{ .expression_statement = .{ .expression = lit } }, .none, ); const body = try out.addExtra(&.{stmt}); out.root = try out.addNode( .{ .program = .{ .source_type = .module, .body = body } }, .none, ); ``` That is enough for a tree that any read-only consumer (a printer, an emitter, another traverser pass) will accept. ### Building One Tree While Walking Another A particularly powerful pattern, central to transpilers and source-to-source compilers, is walking an _input_ tree with full semantic context (scopes, symbols, path) while building a completely separate _output_ tree: ```zig const sem = traverser.semantic; const Transpiler = struct { out: *ast.Tree, pub fn enter_function( self: *Transpiler, func: ast.Function, index: ast.NodeIndex, ctx: *sem.Ctx, ) !traverser.Action { // Read context from the *source* tree: const is_strict = ctx.scope.isStrict(); const span = ctx.tree.span(index); _ = is_strict; _ = func; // Build into the *output* tree: const name = try self.out.addString("transpiledFn"); const id = try self.out.addNode( .{ .binding_identifier = .{ .name = name } }, span, ); _ = id; return .proceed; } }; var source_tree = try parser.parse(allocator, source, .{}); defer source_tree.deinit(); var out = ast.Tree.initEmpty(allocator); defer out.deinit(); var transpiler = Transpiler{ .out = &out }; _ = try sem.traverse(Transpiler, &source_tree, &transpiler); // `out` is a fresh AST you built using full knowledge of the source's // scopes, symbols, and structure. The two trees have independent arenas. ``` The two trees never touch each other's storage, and either can be freed independently. This is the recommended structure for any tool that produces a transformed AST from an input AST without mutating the input. ## Your Own Mode The four modes are four context types over one walker. `traverser.walk(C, V, &visitor, &ctx)` drives any context with a `.tree` field, and `traverser.Layer(C, V)` wraps a visitor so the context's own `enter`, `post_enter`, and `exit` run around each hook. That is all a built-in mode is, in [about thirty lines](https://github.com/yuku-toolchain/yuku/blob/main/src/parser/traverser/basic.zig). ## Combining Modes The four modes compose via multiple passes. A typical pipeline looks like: ```zig var tree = try parser.parse(allocator, source, .{}); defer tree.deinit(); // Pass 1: rewrite syntax (sugar lowering, JSX transform, etc.) var rewriter = MyTransform{}; try transform.traverse(MyTransform, &tree, &rewriter); // Pass 2: semantic analysis on the rewritten tree var analyser = MyAnalyser{}; const semantic = try sem.traverse(MyAnalyser, &tree, &analyser); // Pass 3: emit, lint, minify, etc. ``` Because read-only modes hand visitors a `*const Tree` and transform hands a `*Tree`, the type system keeps analysis and mutation apart. A function that takes `*const Tree` cannot change the AST. # Codegen Source: https://yuku.fyi/parser/codegen/ The codegen takes a `Tree` and writes it back as source code. It walks the AST directly and emits each node from its tag, so the output is always syntactically valid and faithful to the tree's structure. ## Node.js ```bash npm install yuku-codegen ``` ```js import { parse } from "yuku-parser"; import { generate } from "yuku-codegen"; const { program } = parse("const x = 1 + 2;"); const { code } = generate(program); ``` `generate` takes the `Program` node off the `ParseResult` returned by [yuku-parser](https://www.npmjs.com/package/yuku-parser). For source maps, pass the original source text via `sourceMap: { source }`. `minify` also takes an object there (`{ syntax, whitespace, quotes }`) to pick individual switches, and `comments` accepts `true` / `false` as sugar for `"all"` / `"none"`. See [yuku-codegen on npm](https://www.npmjs.com/package/yuku-codegen) for the full API. ## WebAssembly ```bash npm install @yuku-codegen/wasm ``` ```js import { parse } from "@yuku-parser/wasm"; import { generate } from "@yuku-codegen/wasm"; const { program } = parse("const x = 1 + 2;"); const code = generate(program); ``` The same options as `yuku-codegen`, except that `generate` returns the code string directly and source maps are unavailable. Paired with `@yuku-parser/wasm`, as a single portable WebAssembly module for browsers and other environments without native bindings. See [@yuku-codegen/wasm on npm](https://www.npmjs.com/package/@yuku-codegen/wasm). ## Zig ```bash zig fetch --save git+https://github.com/yuku-toolchain/yuku.git ``` ```zig const std = @import("std"); const parser = @import("parser"); pub fn main() !void { const allocator = std.heap.smp_allocator; var tree = try parser.parse(allocator, "const x = 1 + 2;", .{}); defer tree.deinit(); const result = try parser.codegen.generate(allocator, &tree, .{}); defer result.deinit(allocator); std.debug.print("{s}", .{result.code}); } ``` `generate` reads from the tree, writes a fresh buffer, and never mutates anything. The `Tree` remains valid after the call. The returned `Result` owns its buffers, freed via `result.deinit(allocator)`. ## Result ```zig pub const Result = struct { code: []const u8, errors: []const Diagnostic, map: ?SourceMap = null, }; ``` | Field | Type | Description | | -------- | -------------- | --------------------------------------------------- | | `code` | `[]const u8` | Generated source | | `errors` | `[]Diagnostic` | Codegen-detected problems, empty for a clean run | | `map` | `?SourceMap` | Source Map V3 when `source_map` was set, else null | The only return-value error from `generate` is allocation failure. Codegen-detected problems are reported in `errors` and do not abort the run. For a plain run, `errors` is always empty. They appear only when stripping (see [Type stripping](#type-stripping)). A `Diagnostic` carries. ```zig pub const Diagnostic = struct { message: []const u8, start: u32, end: u32, }; ``` ## Options ```zig const result = try parser.codegen.generate(allocator, &tree, .{ .strip = false, .minify = false, .format = .pretty, .indent = 2, .quotes = .preserve, .comments = .some, .source_map = null, }); ``` | Field | Type | Default | Description | | ------------ | ------------------- | ----------- | ---------------------------------------------------------- | | `strip` | `bool` | `false` | Drop TypeScript-only syntax. See [Type stripping](#type-stripping). | | `minify` | `bool` | `false` | Apply size-reducing syntax rewrites. See [Minification](#minification). | | `format` | `Format` | `.pretty` | `.pretty` (indented) or `.compact` (no extra whitespace) | | `indent` | `u8` | `2` | Spaces per level in pretty format | | `quotes` | `Quotes` | `.preserve` | `.preserve` keeps each string's source quote style, `.double` / `.single` force one (content always re-escaped), `.shortest` picks the quote with fewer escapes | | `comments` | `Comments` | `.some` | Comment passthrough filter. See [Comments](#comments). | | `source_map` | `?SourceMapOptions` | `null` | Set to emit a Source Map V3 alongside the code | Every transformation is an independent flag, so they compose freely, e.g. `.strip = true` together with `.minify = true`. `Format` controls only discretionary whitespace. Grammar-required separators (semicolons, commas, parentheses) are always emitted regardless of mode. ## Source maps Pass the original `source` to `source_map` to emit a Source Map V3 alongside the generated code. Without a `source` there is nothing to map back to, so no map is produced. ```zig var tree = try parser.parse(allocator, source, .{}); defer tree.deinit(); const result = try parser.codegen.generate(allocator, &tree, .{ .source_map = .{ .source = source, .file = "out.js", .source_file_name = "in.js", .sources_content = source, }, }); defer result.deinit(allocator); // result.map is non-null when a source was provided. const map = result.map.?; ``` ### `SourceMapOptions` ```zig pub const SourceMapOptions = struct { source: ?[]const u8 = null, file: ?[]const u8 = null, source_file_name: ?[]const u8 = null, source_root: ?[]const u8 = null, sources_content: ?[]const u8 = null, }; ``` | Field | Type | Description | | ------------------ | ------------- | -------------------------------------------------------------------- | | `source` | `?[]const u8` | The original source text. Required to emit a map | | `file` | `?[]const u8` | Output filename, embedded as the map's `file` | | `source_file_name` | `?[]const u8` | Source filename, embedded as the single entry of `sources` | | `source_root` | `?[]const u8` | Prefix embedded as `sourceRoot` | | `sources_content` | `?[]const u8` | When set, embedded as the single entry of the map's `sourcesContent` | ### Result shape `SourceMap` is the Source Map V3 wire format, ready to serialize. ```zig pub const SourceMap = struct { version: u8 = 3, file: ?[]const u8, source_root: ?[]const u8, sources: []const []const u8, sources_content: ?[]const ?[]const u8, names: []const []const u8, mappings: []const u8, }; ``` Columns are 0-indexed UTF-16 code units, matching the convention used by Chrome DevTools and consumer-side libraries (`@jridgewell/trace-mapping`, `source-map`, etc.). ## Comments Comments live on the AST nodes they were attached to during parsing (see [Comments](https://yuku.fyi/parser/ast/#comments) in the AST reference). For codegen to print them, the tree must have been parsed with `comments = .attached` (or `.both`). The `comments` option selects which attached comments are emitted. The default is `.some`, matching the bundler convention of preserving legal banners, JSDoc, and tree-shaking annotations while dropping plain noise. ```zig pub const Comments = enum { none, // drop every comment all, // emit every comment some, // legal banners, jsdoc, and tree-shaking annotations line, // emit `// ...` only block, // emit `/* ... */` only }; ``` Because comments are attached to nodes, they survive AST transforms. Move or replace a node and its comments come with it. ## Type stripping Strip TypeScript syntax from a `Tree`, leaving JavaScript. Same codegen, same options, with TypeScript-only nodes and fields removed from the output. ```zig var tree = try parser.parse(allocator, source, .{ .lang = .ts }); defer tree.deinit(); const result = try parser.codegen.generate(allocator, &tree, .{ .strip = true }); defer result.deinit(allocator); std.debug.print("{s}", .{result.code}); ``` ### How it works Stripping is not regex, not a separate transform pass, not a whitespace overlay on top of the original source. It is the codegen with one extra rule per node visit. Skip nodes that are TypeScript-only, and skip TypeScript-only fields on shared nodes. That makes it always accurate and reliable. Nothing is parsed by hand a second time. The parser already classified every byte, and the codegen reads that classification directly. Comments, whitespace, nested template literal types, generic call expressions, conditional types, and every other awkward boundary case are tree nodes like any other, not regex edge cases. And it is extremely fast, one traversal of the parsed tree, writing directly into an output buffer. ### What stripping does not do A few TypeScript features (`enum`, `namespace`, `module`, `export =`, `import = require()`, parameter properties) emit JavaScript runtime values. Converting them to JavaScript equivalents is a transpilation step, not a syntax-stripping step. The stripper does exactly what its name says, it strips TypeScript syntax. When a runtime-emitting construct is encountered, it is reported as a `Diagnostic` and skipped, and the rest of the file is still emitted. The ambient forms of these constructs (`declare enum`, `declare namespace`, `declare module`, `import type X = require(...)`) carry no runtime, and are stripped silently along with the rest of the type system. All other TypeScript syntax (types, interfaces, type aliases, generics, type assertions, `satisfies`, non-null `!`, `declare`, `abstract`) strips cleanly today. ## Minification `minify` applies size-reducing syntax rewrites. Combine with `format` and `quotes` for maximum minification. ```zig const result = try parser.codegen.generate(allocator, &tree, .{ .minify = true, .format = .compact, .quotes = .shortest, }); defer result.deinit(allocator); ``` The substitutions. - `true` / `false` → `!0` / `!1` - numeric literals shortened to their shortest form (`1000000` → `1e6`, `0.5` → `.5`, etc.) - `obj["foo"]` → `obj.foo` when the key is a valid identifier - `{ "foo": x }` → `{ foo: x }` when safe # Analyzer Source: https://yuku.fyi/analyzer/ `yuku-analyzer` is full semantic analysis for JavaScript and TypeScript, with scopes, symbols, resolved references, closures, and cross-file module linking, computed natively in Zig and queried as plain JavaScript objects. **No single library gives you all of this.** Scopes and resolved references mean `eslint-scope` or `@typescript-eslint/scope-manager`. Cross-file go-to-definition means the TypeScript compiler or `ts-morph`. A parser sits underneath both. `yuku-analyzer` is all of them in one native pass behind one API. **At native speed.** Up to ~15× faster per file than `eslint-scope`, `@typescript-eslint/scope-manager`, and `@babel/traverse`, with zero per-query cost after the single native call. Stitch those separate tools together yourself and the gap only widens. Each re-walks the AST, you re-parse to resolve across files, and you keep the indexes between them in sync by hand. `yuku-analyzer` pays all of that once, in Zig. ```bash npm install yuku-analyzer ``` For one file, `analyze` returns the full per-file semantics in a call: ```js import { analyze } from "yuku-analyzer"; const module = analyze(`const double = (n: number) => n * 2; double(21);`, { lang: "ts" }); module.walk({ Identifier(node, ctx) { console.log(node.name, ctx.scope.kind, ctx.symbol, ctx.reference); }, }); module.rootScope.find("double").references.length; // 1 ``` `analyze` takes the same options as `addFile`, plus `path` (default `"input.js"`), which names the module and supplies the default `lang` and `sourceType`. The module still carries its own import and export records, but nothing links, since the analyzer behind it holds a single file. Reach for `Analyzer` the moment there is more than one. For a project, `Analyzer` adds files and links them: ```js import { Analyzer, SymbolFlags } from "yuku-analyzer"; const a = new Analyzer(); a.addFile("math.ts", `export const add = (x: number, y: number) => x + y;`); a.addFile("app.ts", `import { add } from "./math.ts"; add(1, 2);`); a.addFile("ui.ts", `import { add } from "./math.ts"; add(3, 4);`); const app = a.module("app.ts"); const add = app.rootScope.find("add"); add.has(SymbolFlags.Import); // true const def = add.definition(); def.module.path; // "math.ts" def.symbol.has(SymbolFlags.Const); // true a.referencesOf(def.symbol).map((r) => r.module.path); // ["app.ts", "ui.ts"] // and many more ``` That is real cross-file resolution, not a string search. It follows import, re-export, and `export *` chains to the binding that actually defines the name, the same as an editor's go-to-definition, in plain JavaScript. ## WebAssembly ```bash npm install @yuku-analyzer/wasm ``` ```js import { analyze } from "@yuku-analyzer/wasm"; const module = analyze(`const double = (n: number) => n * 2; double(21);`, { lang: "ts" }); module.rootScope.find("double").references.length; // 1 ``` The same API and semantic model as `yuku-analyzer`, as a single portable WebAssembly module for browsers and other environments without native bindings. See [@yuku-analyzer/wasm on npm](https://www.npmjs.com/package/@yuku-analyzer/wasm). ## The problem it solves Assembling this yourself is not only slower, it is harder to get right. The lightweight tools give you a scope stack but leave the binding rules to you, meaning hoisting, catch clauses, named function expressions, TypeScript declaration merging, and value space versus type space. Each tool implements a subset, and each subset has its own bugs. `yuku-analyzer` computes none of that in JavaScript. The binder, scope tree, reference resolution, and module records all come from the same well-tested native analyzer that powers the rest of Yuku, so there is one implementation to keep correct, not a JavaScript copy that drifts from it. JavaScript receives a finished model to query, not events to track. ## Architecture The design rests on one observation. A semantic model is mostly integers. Scopes point to parents, symbols point to scopes, references point to symbols, and everything points to AST nodes. Integers serialize for free. **One native call.** `addFile` parses the source, runs scope construction, binding, and reference resolution in Zig, and serializes the result into a single binary buffer, the AST in Yuku's flat transfer format followed by the semantic tables as fixed-stride sections. One FFI crossing per file, total. **Zero-copy decode.** On the JavaScript side, the semantic sections are read through typed-array views directly over the transferred buffer. Nothing is parsed, nothing is copied. A symbol's name, flags, scope, and declaration list are reads at computed offsets. **Lazy objects, eager answers.** `Scope`, `Symbol`, `Reference`, `Import`, and `Export` are flyweight objects over the tables, tiny, allocated once per row on first access, with getters that read the buffer. Cross-indexes (which references belong to which symbol, which symbols belong to which scope) build lazily on first use and amortize across every later query. **Node identity.** AST nodes decode lazily and are memoized by node index. The node you reach by walking `module.ast` and the node a semantic query hands back are the same JavaScript object. `symbol.declarations[0] === someNodeYouWalkedTo` is a meaningful comparison, and a `WeakMap` resolves any node back to its index, which is what makes `symbolOf(node)` a lookup instead of a search. The result is native-code analysis speed, JavaScript-object ergonomics, and a wire format that is provably synchronized with the code that reads it. ## The Analyzer The `Analyzer` is the project, a set of modules plus the links between them. ```js import { Analyzer } from "yuku-analyzer"; const analyzer = new Analyzer(); const module = analyzer.addFile("src/app.tsx", source); analyzer.removeFile("src/app.tsx"); // true if it existed analyzer.module("src/app.tsx"); // Module | undefined analyzer.modules; // ReadonlyMap ``` `addFile` accepts the same options as `yuku-parser`'s `parse`, with `lang` and `sourceType` defaulting from the file extension. ```js analyzer.addFile("legacy.cjs", source, { // lang "js", inferred from the extension // sourceType "commonjs", inferred from the extension preserveParens: true, attachComments: false, tokens: false, }); ``` The two functions behind those defaults are exported, so a host that needs the same mapping never has to re-derive it. ```js import { langFromPath, sourceTypeFromPath } from "yuku-analyzer"; langFromPath("types.d.ts"); // "dts" (.d.ts, .d.mts, .d.cts) langFromPath("app.tsx"); // "tsx"; .ts/.mts/.cts give "ts", .jsx gives "jsx", else "js" sourceTypeFromPath("a.cjs"); // "commonjs" for .cjs and .cts, "module" for everything else ``` Adding a path that already exists replaces the module and marks the graph for relinking. The call returns a new `Module`, and any scopes, symbols, or nodes you held from the previous version belong to that earlier parse. A change in `analyzer.module(path)` identity is the signal to drop a cache keyed on the old one. ### Module resolution Cross-file linking needs to map import specifiers to added files. The default resolver handles relative specifiers with standard extension probing (`./util` matches `util.ts`, `util/index.ts`, and so on). For anything else, supply your own. ```js const analyzer = new Analyzer({ resolve(specifier, importerPath) { // return the path of an added file, or null for external modules return myAliasMap.get(specifier) ?? null; }, }); ``` Returning `null` marks the import as external. `import.resolvedModule` stays `null` and definition chains stop there, without diagnostics. ## The Module `addFile` returns a `Module`, the per-file unit of the analysis. Everything on it is local JavaScript. No native calls happen after `addFile` returns. ```js module.analyzer; // the Analyzer that owns it module.path; // the path it was added under module.source; // the original source text module.ast; // ESTree / TS-ESTree Program, lazily decoded module.diagnostics; // syntax and semantic errors for this file module.comments; // every comment in source order module.tokens; // TokenList, present when added with tokens: true ``` `tokens` is the same `TokenList` as `yuku-parser`'s, see [Tokens](https://yuku.fyi/parser/#tokens). The AST is the same ESTree / TypeScript-ESTree output as `yuku-parser`, and nodes are plain mutable objects. Edit them, run them through any ESTree tool, print them with `yuku-codegen`. The semantic surface. ```js module.scopes; // Scope[], index is the scope id module.rootScope; // the scope top-level code runs in module.symbols; // Symbol[], index is the symbol id module.references; // Reference[], in source order module.unresolvedReferences; // references that resolve to no binding module.imports; // Import[], dynamic import() and require() included module.exports; // Export[], in source order module.moduleFlags; // CommonJS classification signals module.dependencies; // Module[], the files this one imports from module.dependents; // Module[], the files that import this one ``` The last two are the graph edges rather than per-file semantics, and they link on demand (see [Linking](#linking)). Everything above them is answered from this file alone. Ids are stable within a parse, so `(module.path, symbol.id)` is a persistable key for caches and incremental tooling. Re-adding a path reparses it into a new `Module` and can renumber, so pair the key with module identity and invalidate when `analyzer.module(path)` changes. ## Scopes Every lexical environment in the file, as a tree. ```js const scope = module.scopes[3]; scope.id; // stable index into module.scopes scope.module; // the owning Module scope.kind; // "global" | "module" | "function" | "block" | "class" // | "staticBlock" | "expressionName" | "tsModule" | "functionBody" scope.node; // the AST node that created the scope scope.parent; // parent Scope, or null at the global scope scope.strict; // strict mode, propagated per spec scope.hoistTarget; // the scope where a `var` declared here actually lands scope.bindings; // symbols declared directly in this scope scope.find("x"); // direct binding lookup, no chain walk scope.contains(other); // is `other` this scope or a descendant? for (const s of scope.ancestors()) { /* this scope up to global */ } ``` Most kinds map one to one onto the construct that created them. `block` covers every lexical environment: braced blocks, loop heads, catch clauses, the case block a `switch` shares across its cases (its discriminant sits outside, in the enclosing scope), and TypeScript enum bodies, where members are lexically visible (`enum E { a, b = a }` resolves `a` to the member). `functionBody` is the body's own scope, matching tsc's block locals: body-level `let`/`const`/`class` and local types live there, invisible to the signature, so in `function f(a: T) { type T = {} }` the parameter annotation stays unresolved. Body `var`s hoist past it into the function scope, unless the parameter list contains expressions, which makes the body a separate `var` environment: in `function f(a = () => x) { var x = 2 }` the default's closure then resolves `x` to the outer scope. The scope tree is the native binder's exact output, and resolution applies the rules the spec and TypeScript layer on top of it: `arguments` in a value position never resolves past the enclosing non-arrow function, where the implicit arguments object shadows any outer binding of that name, `infer` variables are visible only in their conditional's true branch, and class type parameters are out of scope in static members and computed member keys. ## Symbols A `Symbol` is one declared binding. ```js const sym = module.rootScope.find("render"); sym.name; // "render" sym.module; // the owning Module sym.scope; // the Scope it is declared in sym.declarations; // every declarator node, in source order sym.references; // every resolved use site in this module sym.flags; // the raw SymbolFlags bitset behind has / hasAll sym.id; // stable index into module.symbols ``` One symbol can have several declarations when the language merges them, such as TypeScript function overloads, `class` + `interface` merging, and `namespace` + `enum` merging. The analyzer records every declarator, which is exactly what go-to-definition and rename need. Enum members are ordinary symbols (`SymbolFlags.EnumMember`), declared in their enum's body scope. ### Flags What a symbol is lives in a bitset. There is exactly one way to query it, `has` (any of the given flags) and `hasAll` (all of them), against the exported `SymbolFlags` constants. No parallel boolean getters, so the API stays small and predictable. ```js import { SymbolFlags } from "yuku-analyzer"; sym.has(SymbolFlags.Function); // is it a function? sym.has(SymbolFlags.TypeAlias | SymbolFlags.Interface); // either kind? sym.hasAll(SymbolFlags.Function | SymbolFlags.Exported); // an exported function? ``` Alongside the single-bit flags, five **composites** answer the common categorical questions directly. ```js sym.has(SymbolFlags.Variable); // var / let / const, parameters and catch bindings included sym.has(SymbolFlags.Import); // any import binding, value or `import type` sym.has(SymbolFlags.ValueSpace); // visible at runtime sym.has(SymbolFlags.TypeSpace); // referencable from a TS type position sym.has(SymbolFlags.NamespaceSpace); // a dotted type name (`ns.T`) can start from it ``` Beyond `has` and `hasAll`, one method is left: `sym.visibleIn(space)`, the acceptance rule of name resolution: whether a reference resolving in `space` may bind to this symbol (see [References](#references)). Import bindings alias another module's symbol, whose space one file cannot know, so they are visible in every space. A `class` satisfies both `ValueSpace` and `TypeSpace`, which is what makes "use a class as a type" work without special cases. The flag values, composites included, are generated from the native binder's bit layout at build time, so they can never disagree with what the binder wrote. ## References A `Reference` is one identifier in use position, already resolved. ```js const ref = module.references[0]; ref.name; // the identifier text ref.module; // the owning Module ref.id; // stable index into module.references ref.node; // the Identifier node, identity-shared with the AST ref.scope; // the scope the use occurs in ref.symbol; // the resolved Symbol, or null for free names ref.space; // "value" | "type" | "namespace" | "typeof" | "any" ref.inTypePosition; // true inside a type-only subtree ref.isWrite; // true when this use (re)assigns the binding ``` Resolution is space-aware, the way TypeScript resolves names. `space` is the declaration space the position resolves in: `"value"` for runtime uses, `"type"` for annotations and other type positions, `"namespace"` for the qualifier of a dotted type name (`ns.T`, `E.A`), `"typeof"` for value uses inside a type (the entity of `typeof x`, `x is T` parameters), and `"any"` for alias positions that accept every space (`export { x }`, `export default x`, `export = x`, `import a = x`). A binding outside the reference's space does not shadow: ```ts type T = string; function f() { const T = 1; let x: T; // resolves to the outer `type T`, exactly like tsc T; // resolves to the inner `const T` } ``` `inTypePosition` collapses the space to the question rename and dead-code tools ask, whether the use is erased at compile time, so a value and a same-named type stay independent. `isWrite` is computed structurally in the native pass. `module.unresolvedReferences` is the complement, every name whose space has no binding anywhere in scope. That list is precisely what a no-undef lint rule or a globals collector wants. ## Node queries These methods connect AST nodes to the semantic model. All of them work on node object identity, not positions or names. ```js module.symbolOf(node); // the symbol a node declares or references, or null module.referenceOf(node); // the Reference for an identifier node, or null module.scopeOf(node); // the innermost scope whose extent contains the node module.parentOf(node); // the node that structurally contains it, or null module.resolve("fetch"); // scope-chain lookup from the root scope module.resolve("x", someScope); // or from any scope, like the engine would module.resolve("T", someScope, "type"); // or in another space ("any" is by name alone) ``` `symbolOf` is the workhorse. Hand it a declaration identifier and you get the symbol it declares, hand it a reference identifier and you get the symbol it resolves to. `parentOf` walks upward from a node you already hold, with no ancestor stack and no full walk. Because nodes are memoized by index, it is the same constant-time lookup as the others. It returns `null` at the program root and for any node that is not part of this module's AST. `scopeOf` never returns `null`. A node this module's analysis never saw, such as one you built during a transform, falls back to the root scope. `resolve` walks the chain the way the engine would, with `from` defaulting to the root scope and `space` to `"value"`: a binding outside the requested space does not stop the walk, `"any"` matches by name alone, and a value-position `arguments` lookup stops at the first non-arrow function or static block, where the implicit arguments object shadows any outer binding of that name. ## Editing the AST Node identity is what makes the analyzer a refactoring engine, not just a query layer. The `node` on every symbol and reference is the same object you reach by walking `module.ast` (`===` holds), so a transform is a plain assignment, and [`yuku-codegen`](https://yuku.fyi/parser/codegen/) prints the mutated tree back to source. ```js import { generate } from "yuku-codegen"; const m = analyzer.addFile("util.ts", `const tmp = load();\nexport const data = tmp.value + tmp.size;`); const tmp = m.rootScope.find("tmp"); tmp.declarations[0].name = "raw"; // rename the binding for (const ref of tmp.references) ref.node.name = "raw"; // and every resolved use // across files, analyzer.referencesOf(symbol) returns these same live nodes for every use generate(m.ast).code; // const raw = load(); // export const data = raw.value + raw.size; ``` The uses come from resolved references, not a name search, so a shadowing inner `tmp` is left untouched. The walk in the next section is the same edit applied with full context at every node. ## Walking `module.walk` is a typed visitor walk with the semantic model in context. Handlers are keyed by node type and receive the exact node type, not a generic node. ```js module.walk({ // bare function = enter handler CallExpression(node, ctx) { if (node.callee.type === "Identifier") { const target = ctx.module.symbolOf(node.callee); if (target?.has(SymbolFlags.Import)) { console.log(`calls imported ${node.callee.name}`); } } }, // or an enter/leave pair FunctionDeclaration: { enter(node, ctx) { console.log("entering", node.id.name); }, leave(node, ctx) { console.log("leaving", node.id.name); }, }, // universal catch-alls enter(node, ctx) {}, leave(node, ctx) {}, }); ``` Per node, the order is catch-all `enter`, typed enter, children, typed leave, catch-all `leave`. Pass a node as the second argument to walk only a subtree, as in `module.walk(visitors, someFunction)`. `module.walkAsync` is the async counterpart: same traversal order, mutation semantics, and semantic context, with every handler awaited before the walk moves on. Reach for it when a handler needs I/O mid-walk, such as checking the filesystem before rewriting an import. ### The context One context object is reused across the whole walk (do not store it). It carries the position and the semantics. ```js ctx.node; // the current node ctx.parent; // its parent, or null at the walk root ctx.key; // the field on the parent holding this node ctx.index; // position in an array field, or null ctx.ancestors(); // a copy of the ancestor chain, root first ctx.scope; // the innermost Scope at this node ctx.symbol; // shorthand for module.symbolOf(node) ctx.reference; // shorthand for module.referenceOf(node) ctx.module; // the module being walked ``` `ctx.scope` is not tracked during the walk. The binder records the scope at every node and ships it as a per-node table, so `ctx.scope` (like `module.scopeOf`) is a single read off that table. No scoping rule is evaluated in JavaScript, and the answer is exact even where scopes do not nest with spans, such as decorators. ### Mutation The walk mutates the AST in place, with precise semantics. | Operation | Effect | | ----------------------- | ------------------------------------------------------------------------------------------------------------- | | `ctx.skip()` | Do not descend into this node's children. `leave` still fires. | | `ctx.stop()` | End the walk immediately. | | `ctx.replace(node)` | Swap the current node. The walk continues into the replacement's children and `leave` fires for its new type. | | `ctx.remove()` | Splice the node out of an array field, or null a plain field. Children are not walked, `leave` does not fire. | | `ctx.insertBefore(node)`| Insert a sibling before the current node. The inserted node is not visited. | | `ctx.insertAfter(node)` | Insert a sibling after the current node. The walk visits it. | A replacement node created with `start: 0, end: 0` inherits the original node's span, which keeps source maps meaningful through `yuku-codegen`. ```js module.walk({ DebuggerStatement(node, ctx) { ctx.remove(); }, Identifier(node, ctx) { if (ctx.symbol === legacyName) node.name = "modernName"; }, }); ``` One rule to remember. The semantic tables are a snapshot of the parsed source. Nodes you create have no symbols or references of their own. Analyze, transform, print, and re-analyze the output if you need fresh semantics for the transformed code. ## findAll For the simplest queries there is a one-liner. ```js module.findAll("FunctionDeclaration"); // FunctionDeclaration[] module.findAll(["ClassDeclaration", "TSInterfaceDeclaration"]); ``` ## Closure analysis `capturesOf` computes the free variables of a function, every binding referenced inside it (nested closures included) that is declared outside it. ```js const source = ` let count = 0; const step = 2; export function tick() { count += step; return () => count; } `; const module = analyzer.addFile("counter.ts", source); const [tick] = module.findAll("FunctionDeclaration"); for (const capture of module.capturesOf(tick)) { console.log(capture.symbol.name, capture.isWritten); } // count true (tick writes to it) // step false (read only) ``` Each `Capture` carries the outer `symbol`, the capturing `references` inside the function, and `isWritten`. Type-only references are excluded, since they do not exist at runtime. Only bindings appear. `this`, `arguments`, and unresolved globals carry no symbol and are never reported, while module-scope and imported bindings count like any other outer binding. `capturesOf` throws a `TypeError` when the node is not part of this module's AST, or when it is a node that creates no function scope. Ask it about a function or arrow, nothing else. Because the computation rides the resolved reference table, it is shadowing-correct and alias-correct by construction. A local `count` declared inside the function does not produce a false capture, and a reference is attributed to the binding it actually resolves to, not to the nearest matching name. ## Cross-file analysis ### Import and export records Each module carries records of its module surface, computed natively. Every record has one `kind` that pins down its form. ```js for (const imp of module.imports) { imp.kind; // "named" | "namespace" | "sideEffect" // | "importEquals" | "dynamic" | "require" imp.specifier; // "./lib.ts" imp.name; // imported name of a named record, "default" for // default imports, null otherwise imp.local; // the local binding Symbol, or null when nothing binds imp.typeOnly; // import type / import { type x } imp.phase; // "source" | "defer" | null (stage 3 phase imports) imp.node; // the specifier, the declaration, or the // import() / require() call itself imp.module; // the importing Module imp.id; // stable index into module.imports imp.resolvedModule; // the defining Module, or null when external // the same question as `kind`, asked as a predicate imp.isNamespace; // "namespace" or "importEquals": binds a whole module imp.isSideEffect; // "sideEffect" imp.isDynamic; // "dynamic" imp.isRequire; // "require" } for (const exp of module.exports) { exp.kind; // "named" | "reExport" | "namespace" // | "star" | "equals" | "global" exp.name; // exported name, "default" included, null for // star, equals, and global records exp.local; // backing local Symbol, when there is one exp.specifier; // re-export source, or null for local exports exp.fromName; // the name a reExport takes from its source module exp.globalName; // TS export as namespace N, else null exp.typeOnly; // export type exp.node; // the specifier, declaration, or statement node exp.module; // the exporting Module exp.id; // stable index into module.exports exp.resolvedModule; // the source Module for re-exports // the same question as `kind`, asked as a predicate exp.isStar; // "star": export * from "m" exp.isExportEquals; // "equals": TS export = expr exp.isNamespaceReexport; // "namespace": export * as ns from "m" } ``` `kind` is the field everything else hangs off. A `name` exists only where the kind has one, `fromName` only on a `reExport`, `globalName` only on a `global` record, and the `is*` getters are that one classification asked as a predicate. Read `kind` when you want to switch, read a predicate when you want a branch. Import records cover every statically known dependency edge. Dynamic `import("m")` and CommonJS `require("m")` anywhere in the file produce records like static declarations do, and link into the graph the same way. Only literal specifiers become records, and a `require` call counts only when `require` is a free name, so every edge is sound. Following the specification, `default` is modeled as an export *name*, not a separate kind, and `export *` never forwards `default`. CommonJS exports are runtime assignments with no sound static shape, so they never become export records. `module.moduleFlags` (`usesRequire`, `usesModule`, `usesExports`, `usesImportMeta`) classifies the file instead. ### Linking `analyzer.link()` joins the graph. It resolves every specifier through the resolver, populates `resolvedModule` on imports and re-exports, builds `dependencies` / `dependents`, and validates every imported name and named re-export. Name resolution implements the spec's `ResolveExport`. Renaming re-export chains are followed per name, `default` is never satisfied by `export *`, and a name supplied by multiple `export *` declarations through different bindings is reported as ambiguous, the same conditions an engine raises at link time. Calling it is optional. Every cross-file surface links on demand after files change, so reading `import.resolvedModule` or `module.dependencies` is always correct. Call `link()` explicitly when you want to control when the work happens and collect the diagnostics at a known point. ```js analyzer.link(); for (const d of analyzer.diagnostics) { console.log(`${d.module}: ${d.message}`); // "main.ts: Module './lib.ts' has no export 'helpr'" } ``` Each record carries `severity`, `message`, `module` (the path of the file it belongs to), and the `start` / `end` offsets of the offending import or re-export. Severity is `"error"` for a name the source module does not export or supplies ambiguously, and `"warning"` when the host resolver returns a path that was never added, which is a host configuration problem rather than a broken source file. ### Definitions across modules `definitionOf` follows import, re-export, and `export *` chains to the place a binding is actually defined, however many files away. ```js // a.ts export const value = 1; // b.ts export { value as renamed } from "./a.ts"; // c.ts import { renamed } from "./b.ts"; const c = analyzer.module("c.ts"); const sym = c.rootScope.find("renamed"); const def = analyzer.definitionOf(sym); def.module.path; // "a.ts" def.symbol.name; // "value" ``` `symbol.definition()` is the instance-method shorthand. A result with `symbol: null` means the definition is a whole module namespace, which is what `import * as ns`, `import ns = require("m")`, and a re-exported `export * as ns` all bind. A `null` result means the chain leaves the added file set (an external package, by design not an error), cannot be resolved, or is ambiguous. Chains with cycles terminate safely. A circular request is detected per (module, name) pair, so a chain may legitimately pass through the same module twice under different names. ### References across modules The inverse direction gives every use of a symbol anywhere in the graph, with imports followed back to the definition. ```js const uses = analyzer.referencesOf(def.symbol); for (const { module, reference } of uses) { console.log(module.path, reference.name, reference.isWrite); } ``` This is find-all-references as a compiler primitive, powering rename across files, unused-export detection, and impact analysis. ```js // unused exports, whole project for (const module of analyzer.modules.values()) { for (const exp of module.exports) { if (exp.local && analyzer.referencesOf(exp.local).length === 0) { console.log(`${module.path}: '${exp.name}' is exported but never used`); } } } ``` ### Exported names `module.exportedNames()` lists everything a module exports with `export *` chains followed, the spec's `GetExportedNames`. Per the spec, ambiguous star names are included (ambiguity is a resolution error, not an enumeration one) and `default` never arrives through a star. ```js // a.ts export const one = 1; // lib.ts export const two = 2; export default x; export * from "./a.ts"; analyzer.module("lib.ts").exportedNames(); // ["two", "default", "one"] analyzer.module("a.ts").exportedNames(); // ["one"] ``` This is what namespace-member completion and re-export expansion build on. ## SymbolFlags reference The full bitset, generated from the native binder's layout. | Flag | Meaning | | ----------------------- | ------------------------------------------------ | | `FunctionScopedVariable`| `var`, parameter, or catch variable | | `BlockScopedVariable` | `let`, `const`, `using`, `await using` | | `Function` | function declaration or expression | | `Class` | class declaration or expression | | `RegularEnum` | TS `enum` | | `ConstEnum` | TS `const enum` | | `ValueModule` | TS namespace with runtime content | | `Interface` | TS `interface` | | `TypeAlias` | TS `type` alias | | `TypeParameter` | TS ``, `infer T`, mapped-type key | | `NamespaceModule` | TS namespace of any kind | | `ValueImport` | a value import binding (`import x` / `import { x }`) | | `TypeImport` | `import type` / `import { type x }` binding | | `Const` | `const` or `using` binding | | `Ambient` | TS `declare` | | `Parameter` | function or method parameter | | `CatchVariable` | `catch (e)` binding | | `Exported` | exported from its module | | `Default` | the default export | | `EnumMember` | a TS enum member, declared in its enum's body scope | Plus five composites (unions of the above), for the common categorical questions. | Composite | Matches | | ---------------- | ------------------------------------------------------------------ | | `Variable` | `var` / `let` / `const`, parameters and catch bindings included | | `Import` | any import binding, value or `import type` | | `ValueSpace` | visible at runtime (var, function, class, enum and its members, value namespace) | | `TypeSpace` | referencable from a type position (class, enum and its members, interface, alias, type param) | | `NamespaceSpace` | what a dotted type name can start from (namespace of any kind, enum) | ## Performance Analysis runs in the native parser pass, so full semantics cost roughly half of parsing time on top of the parse itself. Validated against every file in the [parser test corpus](https://yuku.fyi/testing/). Concretely, on an Apple M-series machine, parsing plus complete semantic analysis of a typical source file lands well under a millisecond, walking sustains tens of millions of nodes per second, and linking a 2,000-module graph takes about a millisecond. ## TypeScript Everything is fully typed. Visitor handlers receive exact node types, and every type in the model is exported, so a function that takes a piece of the analysis can name it. ```ts import type { Module, Scope, Symbol, Reference, Import, Export, Capture, Definition, ModuleReference, LinkDiagnostic, ModuleFlags, Space, ScopeKind, ImportKind, ExportKind, Visitors, WalkHandler, WalkHooks, WalkContext, AsyncVisitors, AsyncWalkHandler, AsyncWalkHooks, AnalyzerOptions, AddFileOptions, AnalyzeOptions, NodeType, NodeOfType, } from "yuku-analyzer"; ``` `NodeType` and `NodeOfType` are re-exported from the toolchain's AST types, so visitor keys and `findAll` stay in step with the parser without a second import. # Testing Source: https://yuku.fyi/testing/ Correctness in Yuku is not an afterthought. A large share of the work behind the toolchain went into the test suite and the infrastructure around it, built and maintained with the same care as the parser itself. It covers everything from spec conformance and exact AST shapes to codegen soundness, source map accuracy, and memory behavior under simulated out-of-memory conditions. This page describes how it all works. ## The parser test suite The parser is validated against a dedicated repository, [parser-test-suite](https://github.com/yuku-toolchain/parser-test-suite), a comprehensive ECMAScript test suite targeting the [ESTree](https://github.com/estree/estree) and [TypeScript-ESTree](https://typescript-eslint.io/packages/typescript-estree/) AST formats. Its tests come from three sources. - [tc39/test262](https://github.com/tc39/test262), the official ECMAScript conformance suite, covering language grammar, built-ins, Annex B, Intl, and staging proposals. - [microsoft/TypeScript](https://github.com/microsoft/TypeScript) compiler test fixtures. - [babel](https://github.com/babel/babel) parser test fixtures, across JavaScript, TypeScript, and JSX. The suite holds over 55,000 tests, and it grows as upstream grows. As a rough shape, JavaScript makes up the bulk with around 48,000 cases, TypeScript adds around 9,000, and JSX contributes a smaller set of focused cases. Each language suite splits into three categories. | Category | Expectation | | ---------- | ------------------------------------------------------------------ | | `pass` | Must parse cleanly, and the AST must exactly match a snapshot | | `fail` | Must produce a parse error | | `semantic` | Parses cleanly, but must be rejected by semantic analysis | ## What a passing test verifies A `pass` test is much stricter than "it parses". - **Zero diagnostics.** The file must parse without a single error. - **Exact AST match.** The full parse result, the program, every node type, every field, every position, plus comments, is compared structurally against a snapshot, ESTree for JS/JSX and TypeScript-ESTree for TS. - **Walk order.** Traversing the tree must visit children in source order, an invariant every ESTree walker relies on. Pass files also run with [semantic analysis](https://yuku.fyi/parser/semantic/) enabled, so the early-error checker is simultaneously validated for zero false positives on valid code. `fail` tests cover syntax the parser must reject. `semantic` tests cover the scope-dependent [early errors](https://tc39.es/ecma262/#early-error) that cannot be caught from local context alone, such as strict mode violations, duplicate lexical bindings, unresolved exports, and private fields used outside their class. These files parse fine, but must fail once semantic analysis runs. ## Where the snapshots come from The expected ASTs are not produced by Yuku. Snapshots are generated with an independent reference parser, [Oxc](https://oxc.rs), which emits ESTree output for JS/JSX and TypeScript-ESTree output for TS. This keeps Yuku from grading its own homework. Matching an independently generated AST proves Yuku produces the exact tree shape the ESTree ecosystem agrees on, not just output consistent with itself. ## Synced with the spec, daily Test262, the TypeScript compiler, and Babel are moving targets, with new proposals and features landing in them continuously. An automated job in the suite repository checks upstream every day and adds the new tests, and Yuku's test runner refreshes its local copy of the suite daily. When a proposal reaches Test262, Yuku is tested against it almost immediately. ## Conformance status Yuku is 100% ECMAScript spec compliant. It passes the entire suite, every `pass`, `fail`, and `semantic` test, with zero failures and zero AST mismatches. Per-file results are generated on every run and published in [test/parser/results](https://github.com/yuku-toolchain/yuku/tree/main/test/parser/results). The suites run through the published npm packages, built from the local Zig source. So every run validates not just the Zig core but the whole chain, from the parser through the native bridge to the JavaScript API, with exact AST matching between Zig and JavaScript. ## Codegen Curated inline-snapshot tests pin the exact output of `generate` on inputs chosen by hand, across its option sets (plain, `strip`, `minify`). On top of that, the corpus suite runs all three option sets over every parseable file in the parser test suite and checks a set of invariants. - Every option set's output parses cleanly again. - A plain `generate` is semantics-preserving, meaning the reparsed AST equals the original, modulo positions. - Every option set is a fixed point on its own output, and no comment is lost or duplicated. ## Source maps Every corpus file is round-tripped through `generate` with source maps enabled, and every identifier in the output must trace back to the right name and position in the original source. ## Fuzzing Beyond the fixed suites, a Zig-side fuzzer throws millions of randomized inputs per run at the parser, adversarial fragments (malformed surrogate escapes, unterminated literals, numeric and regex corner cases, raw invalid UTF-8 bytes) and mutations of structurally complete programs, across every language mode, with simulated out-of-memory conditions injected along the way. The parser must never crash, whatever the input. Any failure dumps a self-contained reproducer. ## Memory The parser is deliberate about memory. Everything a parse allocates, the nodes, spans, strings, and diagnostics, lives in a single arena owned by the tree, and `tree.deinit()` is a single free. There are no per-node allocations to leak and no ownership graph to get wrong. The fuzzer's simulated allocation failures exercise exactly this pattern, so the expectation holds even when memory runs out. No crashes, no segfaults, no leaks. Yuku is memory safe and memory efficient by construction, and it is tested to stay that way. ## Running the suites ```bash bun run test ``` On first run this downloads the parser test suite (cached for a day), builds the native addons from your local Zig, then runs every suite. Each suite can also be run on its own, and you can add your own cases. See [CONTRIBUTING.md](https://github.com/yuku-toolchain/yuku/blob/main/CONTRIBUTING.md) for the details. # Security Source: https://yuku.fyi/security/ Yuku's core job is to process untrusted input. The source code it parses may be attacker controlled, so we take its security seriously. ## Reporting a vulnerability Please do not open a public issue for security reports. Report privately through GitHub's [private vulnerability reporting](https://github.com/yuku-toolchain/yuku/security/advisories/new), under "Report a vulnerability" in the repository's Security tab. If you cannot use that channel, email [arshadpyaseen@gmail.com](mailto:arshadpyaseen@gmail.com). We aim to acknowledge reports within 72 hours. Please give us a reasonable window to ship a fix before public disclosure, and we are glad to credit you in the advisory. Fixes ship against the latest published version of each package, so please upgrade before reporting. ## Our practices - **Zero runtime dependencies.** Every Yuku package published to npm ships with no runtime dependencies, and installing one runs no lifecycle scripts. There is no transitive tree to audit or to be compromised through, only Yuku's own code. - **Provenance publishing.** Packages are published to npm with provenance over an OIDC trusted-publisher flow, with no long-lived tokens, so each release is cryptographically tied to the workflow that built it. - **Hardened CI.** Every GitHub Actions step is pinned to a full commit hash and kept current by Dependabot, which holds new releases for a cooldown period before adopting them. The workflow token defaults to read-only, our workflows are statically analyzed with [zizmor](https://github.com/zizmorcore/zizmor), the project is continuously graded by [OpenSSF Scorecard](https://securityscorecards.dev), and outbound network on release runners is monitored. - **Reproducible builds.** Dependencies install from a frozen lockfile, and the one Zig build dependency is pinned by content hash. - **Memory safety by construction.** Everything a parse allocates lives in a single arena freed in one step, so there is nothing to leak and no ownership graph to get wrong. The WASM build runs the same parser inside a memory-sandboxed container, so a parser bug cannot reach host memory. - **Fuzzing.** A fuzzer throws millions of randomized and adversarial inputs at the parser, across every language mode, with simulated out-of-memory conditions injected. See [how Yuku is tested](https://yuku.fyi/testing/).