Yuku
Semantic Analysis
The ECMAScript specification defines a set of early errors that conformant implementations must report before execution. Some are detectable during parsing from local context alone: return outside a function, yield outside a generator, invalid destructuring. Others require knowledge of the program’s scope structure and bindings: redeclarations, unresolved exports, private fields used outside their class, and more.
Yuku defers these scope-dependent checks to a separate semantic analysis pass. This keeps parsing fast and lets each consumer opt in only to the work it actually needs. A formatter, for example, only needs the AST and should not pay the cost of scope resolution.
Analyzing a Tree
semantic.analyze builds a scope tree and symbol table, resolves identifier references to their declarations, and reports the remaining early errors. Together, parsing and semantic analysis cover the full set of early errors required by the specification.
var tree = try parser.parse(allocator, source, .{});
defer tree.deinit();
// run semantic analysis
const result = try parser.semantic.analyze(&tree);
// result.scope_tree - all lexical scopes
// result.symbol_table - all symbols and references
Semantic diagnostics are appended directly to tree.diagnostics alongside parse errors. After analysis, tree.hasErrors() reflects both.
All allocations (scope tree, symbol table) use the tree’s arena, so they are valid for the lifetime of the tree and freed by tree.deinit().
See Traverse for everything the returned ScopeTree and SymbolTable can do.
If you need the scope tree and symbol table without running the early-error checks, call the semantic traverser directly with your own visitor (or an empty struct {} if you only want the tables):
const sem = parser.traverser.semantic;
var noop = struct {}{};
const result = try sem.traverse(@TypeOf(noop), &tree, &noop);
// result.scope_tree - all lexical scopes
// result.symbol_table - all symbols and references (no diagnostics emitted)
semantic.analyze is built exactly that way, with a real visitor that emits the early-error diagnostics on top.
Module Records
semantic.module_record produces a module’s flat import/export records, mirroring the specification’s ImportEntry / ExportEntry model. The 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 symbol table:
var tree = try parser.parse(allocator, source, .{});
defer tree.deinit();
const result = try parser.semantic.analyze(&tree);
const records = try parser.semantic.module_record.collect(&tree, &result.symbol_table);
for (records.imports) |imp| {
std.debug.print("imports '{s}' from \"{s}\"\n", .{
tree.string(imp.name), tree.string(imp.specifier),
});
}
for (records.exports) |exp| {
std.debug.print("exports '{s}'\n", .{tree.string(exp.name)});
}
Both slices are in source order, allocated in the tree’s arena, and freed by tree.deinit().
NameKind
Both record types describe what is taken from (or exposed by) a module with a NameKind:
| Kind | Meaning |
|---|---|
.named | A specific export name. Default imports/exports use the name "default", exactly as the spec models them. |
.star | The module namespace object (* as ns) or all names (export *) |
.none | No binding at all: a side-effect import (import "m") |
.equals | The module’s entire export value: TS export = expr. Exports only. |
.global | A UMD global alias: TS export as namespace N. Exports only, not reachable from the import graph. |
ImportRecord
One imported binding (or side-effect import) of the module:
| Field | Type | Meaning |
|---|---|---|
symbol | SymbolId | The local binding symbol. .none for side-effect imports. |
name_kind | NameKind | What is taken from the source module |
name | ast.String | The imported name when name_kind is .named ("default" for default imports). .empty otherwise. |
specifier | ast.String | Decoded module specifier text |
kind | ast.ImportOrExportKind | .type for import type and import { type x } bindings |
phase | ?ast.ImportPhase | Stage 3 phase modifier (.source, .defer), or null for a regular import |
node | ast.NodeIndex | The specifier node, or the whole declaration for side-effect imports |
How each import form maps:
| Source | name_kind | name | symbol |
|---|---|---|---|
import x from "m" | .named | "default" | binding of x |
import { a } from "m" | .named | "a" | binding of a |
import { a as b } from "m" | .named | "a" | binding of b |
import * as ns from "m" | .star | .empty | binding of ns |
import "m" | .none | .empty | .none |
import x = require("m") | .star | .empty | binding of x |
TS import a = B.C aliases a namespace, not a module, so it produces no record.
ExportRecord
One exported name of the module:
| Field | Type | Meaning |
|---|---|---|
name_kind | NameKind | .named for every named export including "default". .star only for export * from "m" without an alias. .equals for export =, .global for export as namespace. |
name | ast.String | The exported name when .named, or the global name when .global. .empty for export * and export =. |
symbol | SymbolId | The local symbol backing the export. .none for re-exports, anonymous default exports, and expression exports that do not resolve to a module-scope binding. |
from_kind | NameKind | What a re-export takes from its source module. .none when the export is local (no from clause). |
from_name | ast.String | The source-module name when from_kind is .named |
specifier | ast.String | Decoded specifier of the from clause. .empty when local. |
kind | ast.ImportOrExportKind | .type for type-only exports |
node | ast.NodeIndex | The specifier or declaration node, for spans and diagnostics |
How each export form maps:
| Source | name_kind | name | symbol | from_kind |
|---|---|---|---|---|
export const a = 1 | .named | "a" | binding of a | .none |
export default function f() {} | .named | "default" | binding of f | .none |
export default expr | .named | "default" | .none | .none |
export { a as b } | .named | "b" | binding of a | .none |
export { a as b } from "m" | .named | "b" | .none | .named (from_name = "a") |
export * from "m" | .star | .empty | .none | .star |
export * as ns from "m" | .named | "ns" | .none | .star |
export = expr | .equals | .empty | see below | .none |
export as namespace N | .global | "N" | .none | .none |
export <decl> produces one record per bound name: 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.
CommonJS (require, module.exports) is ordinary code, not module syntax, and produces no records.