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:

KindMeaning
.namedA specific export name. Default imports/exports use the name "default", exactly as the spec models them.
.starThe module namespace object (* as ns) or all names (export *)
.noneNo binding at all: a side-effect import (import "m")
.equalsThe module’s entire export value: TS export = expr. Exports only.
.globalA 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:

FieldTypeMeaning
symbolSymbolIdThe local binding symbol. .none for side-effect imports.
name_kindNameKindWhat is taken from the source module
nameast.StringThe imported name when name_kind is .named ("default" for default imports). .empty otherwise.
specifierast.StringDecoded module specifier text
kindast.ImportOrExportKind.type for import type and import { type x } bindings
phase?ast.ImportPhaseStage 3 phase modifier (.source, .defer), or null for a regular import
nodeast.NodeIndexThe specifier node, or the whole declaration for side-effect imports

How each import form maps:

Sourcename_kindnamesymbol
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.emptybinding of ns
import "m".none.empty.none
import x = require("m").star.emptybinding 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:

FieldTypeMeaning
name_kindNameKind.named for every named export including "default". .star only for export * from "m" without an alias. .equals for export =, .global for export as namespace.
nameast.StringThe exported name when .named, or the global name when .global. .empty for export * and export =.
symbolSymbolIdThe 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_kindNameKindWhat a re-export takes from its source module. .none when the export is local (no from clause).
from_nameast.StringThe source-module name when from_kind is .named
specifierast.StringDecoded specifier of the from clause. .empty when local.
kindast.ImportOrExportKind.type for type-only exports
nodeast.NodeIndexThe specifier or declaration node, for spans and diagnostics

How each export form maps:

Sourcename_kindnamesymbolfrom_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.emptysee 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.