Skip to content

Semantic Analysis

Semantic analysis is the process of checking whether our source code is correct or not. We need to check against all the "Early Error" rules in the ECMAScript specification.


For grammar contexts such as [Yield] or [Await], an error need to be raised when the grammar forbids them, for example:

BindingIdentifier[Yield, Await] :

13.1.1 Static Semantics: Early Errors

BindingIdentifier[Yield, Await] : yield
* It is a Syntax Error if this production has a [Yield] parameter.

* BindingIdentifier[Yield, Await] : await
It is a Syntax Error if this production has an [Await] parameter.

need to raise an error for

async *
  function foo() {
    var yield, await;

because AsyncGeneratorDeclaration has [+Yield] and [+Await] for AsyncGeneratorBody:

AsyncGeneratorBody :
  FunctionBody[+Yield, +Await]

An example in Biome checking for the yield keyword:


pub(super) fn parse_identifier(p: &mut Parser, kind: JsSyntaxKind) -> ParsedSyntax {
    if !is_at_identifier(p) {
        return Absent;

    let error = match p.cur() {
        T![yield] if p.state.in_generator() => Some(
            p.err_builder("Illegal use of `yield` as an identifier in generator function")
                .primary(p.cur_range(), ""),


For declaration errors:

14.2.1 Static Semantics: Early Errors

Block : { StatementList }
* It is a Syntax Error if the LexicallyDeclaredNames of StatementList contains any duplicate entries.
* It is a Syntax Error if any element of the LexicallyDeclaredNames of StatementList also occurs in the VarDeclaredNames of StatementList.

We need to add a scope tree. A scope tree has all the vars and lets declared inside it. It is also a parent pointing tree where we want to navigate up the tree and search for binding identifiers in parent scopes. The data structure we can use is a indextree.

use indextree::{Arena, Node, NodeId};
use bitflags::bitflags;

pub type Scopes = Arena<Scope>;
pub type ScopeId = NodeId;

bitflags! {
    pub struct ScopeFlags: u8 {
        const TOP = 1 << 0;
        const FUNCTION = 1 << 1;
        const ARROW = 1 << 2;
        const CLASS_STATIC_BLOCK = 1 << 4;
        const VAR = Self::TOP.bits | Self::FUNCTION.bits | Self::CLASS_STATIC_BLOCK.bits;

#[derive(Debug, Clone)]
pub struct Scope {
    /// [Strict Mode Code](
    /// [Use Strict Directive Prologue](
    pub strict_mode: bool,

    pub flags: ScopeFlags,

    /// [Lexically Declared Names](
    pub lexical: IndexMap<Atom, SymbolId, FxBuildHasher>,

    /// [Var Declared Names](
    pub var: IndexMap<Atom, SymbolId, FxBuildHasher>,

    /// Function Declarations
    pub function: IndexMap<Atom, SymbolId, FxBuildHasher>,

The scope tree can either be built inside the parser for performance reasons, or built-in a separate AST pass.

Generally, a ScopeBuilder is needed:

pub struct ScopeBuilder {
    scopes: Scopes,
    root_scope_id: ScopeId,
    current_scope_id: ScopeId,

impl ScopeBuilder {
    pub fn current_scope(&self) -> &Scope {

    pub fn enter_scope(&mut self, flags: ScopeFlags) {
        // Inherit strict mode for functions
        let mut strict_mode = self.scopes[self.root_scope_id].get().strict_mode;
        let parent_scope = self.current_scope();
        if !strict_mode
            && parent_scope.flags.intersects(ScopeFlags::FUNCTION)
            && parent_scope.strict_mode
            strict_mode = true;

        let scope = Scope::new(flags, strict_mode);
        let new_scope_id = self.scopes.new_node(scope);
        self.current_scope_id.append(new_scope_id, &mut self.scopes);
        self.current_scope_id = new_scope_id;

    pub fn leave_scope(&mut self) {
      self.current_scope_id = self.scopes[self.current_scope_id].parent().unwrap();

We then call enter_scope and leave_scope accordingly inside the parse functions, for example in acorn:



One of the downsides of this approach is that for arrow functions, we may need to create a temporary scope and then drop it afterwards if it is not an arrow function but a sequence expression. This is detailed in cover grammar.

The Visitor Pattern

If we decide to build the scope tree in another pass for simplicity, then every node in the AST need to be visited in depth-first preorder and build the scope tree.

We can use the Visitor Pattern to separate out the traversal process from the operations performed on each object.

Upon visit, we can call enter_scope and leave_scope accordingly to build the scope tree.

Released under the MIT License.