Parser Context

Parser context lets you carry state through parsing reductions. Stateless parsers use ParserCtx.Empty by default; custom contexts carry domain-specific state like symbol tables, function registries, or error accumulators.

Under the hood: context threading

When you define Parser[Ctx], Ctx must extend ParserCtx, and the macro that builds the parse tables also builds a factory of the initial context from its constructor defaults -- which only works for a case class whose fields all have default values. At runtime, parse() calls that factory once to create the initial context and passes it to the first reduction. An assignment to a context field in a rule body is rewritten into a copy, which the parser passes to the reductions that follow, as a lexer does with its context.

ParserCtx.Empty (Default)

When you extend Parser without a type parameter, the parser uses ParserCtx.Empty. No context definition is needed:

import halotukozak.alpaca.*

val BrainLexer = lexer:
  case "\\+" => Token["inc"]
  case "-" => Token["dec"]
  case name @ "[A-Za-z]+" => Token["functionName"](name)
  case "\\(" => Token["functionOpen"]
  case "\\)" => Token["functionClose"]
  case "!" => Token["functionCall"]
  case "\\s+" => Token.Ignored

enum BrainAST:
  case Root(ops: List[BrainAST])
  case FunctionDef(name: String, ops: List[BrainAST])
  case FunctionCall(name: String)
  case Inc, Dec
import halotukozak.alpaca.*

object BrainParser extends Parser:    // uses ParserCtx.Empty
  val root: Rule[BrainAST] = rule:
    case Operation.List(stmts) => BrainAST.Root(stmts)

  val Operation: Rule[BrainAST] = rule(
    { case BrainLexer.inc(_) => BrainAST.Inc },
    { case BrainLexer.dec(_) => BrainAST.Dec },
    // ...
  )

Custom Parser Context

The BrainFuck> extension adds function definitions and calls. To track which functions have been defined (so we can reject calls to undefined functions), we use a custom parser context:

import halotukozak.alpaca.*

case class BrainParserCtx(
  functions: Set[String] = Set.empty,
) extends ParserCtx

Two rules apply:

  1. Must be a case class -- the initial context is built from the case class constructor (otherwise: ... should be a case class.).
  2. All fields must have default values -- the initial context is constructed from constructor defaults.

The fields can be vals holding immutable values: assigning one in a rule body (ctx.functions += name) changes the context for the reductions that follow. A var field, or a mutable collection, is changed in place instead.

Accessing Context in Rule Bodies

The ctx identifier is available inside every production's body, typed as your specific ParserCtx subtype:

import halotukozak.alpaca.*

val BrainLexer = lexer:
  case "\\+" => Token["inc"]
  case "-" => Token["dec"]
  case name @ "[A-Za-z]+" => Token["functionName"](name)
  case "\\(" => Token["functionOpen"]
  case "\\)" => Token["functionClose"]
  case "!" => Token["functionCall"]
  case "\\s+" => Token.Ignored

enum BrainAST:
  case Root(ops: List[BrainAST])
  case FunctionDef(name: String, ops: List[BrainAST])
  case FunctionCall(name: String)
  case Inc, Dec
import halotukozak.alpaca.*

case class BrainParserCtx(
  functions: Set[String] = Set.empty,
) extends ParserCtx

object BrainParser extends Parser[BrainParserCtx]:
  val root: Rule[BrainAST] = rule:
    case Operation.List(stmts) => BrainAST.Root(stmts)

  val FunctionDef: Rule[BrainAST] = rule:
    case (BrainLexer.functionName(name), BrainLexer.functionOpen(_),
          Operation.List(ops), BrainLexer.functionClose(_)) =>
      require(!ctx.functions(name.value), s"Function ${name.value} is already defined")
      ctx.functions += name.value
      BrainAST.FunctionDef(name.value, ops)

  val FunctionCall: Rule[BrainAST] = rule:
    case (BrainLexer.functionName(name), BrainLexer.functionCall(_)) =>
      require(ctx.functions.contains(name.value), s"Function ${name.value} is not defined")
      BrainAST.FunctionCall(name.value)

  val Operation: Rule[BrainAST] = rule(
    { case BrainLexer.inc(_) => BrainAST.Inc },
    { case BrainLexer.dec(_) => BrainAST.Dec },
    { case FunctionDef(fdef) => fdef },
    { case FunctionCall(call) => call },
    // ... other operations
  )
brain-parser-ctx-example

FunctionDef adds the function name to ctx.functions. FunctionCall checks that the name exists, so it sees the assignment of an earlier FunctionDef.

Shared State Across Reductions

An assignment made in one rule body is visible to all subsequent reductions of the same parse() call, and the final context is the ctx of its Result:

import halotukozak.alpaca.*

val BrainLexer = lexer:
  case "\\+" => Token["inc"]
  case "-" => Token["dec"]
  case name @ "[A-Za-z]+" => Token["functionName"](name)
  case "\\(" => Token["functionOpen"]
  case "\\)" => Token["functionClose"]
  case "!" => Token["functionCall"]
  case "\\s+" => Token.Ignored

enum BrainAST:
  case Root(ops: List[BrainAST])
  case FunctionDef(name: String, ops: List[BrainAST])
  case FunctionCall(name: String)
  case Inc, Dec
import halotukozak.alpaca.*

case class BrainParserCtx(
  functions: Set[String] = Set.empty,
) extends ParserCtx

object BrainParser extends Parser[BrainParserCtx]:
  val root: Rule[BrainAST] = rule:
    case Operation.List(stmts) => BrainAST.Root(stmts)

  val FunctionDef: Rule[BrainAST] = rule:
    case (BrainLexer.functionName(name), BrainLexer.functionOpen(_),
          Operation.List(ops), BrainLexer.functionClose(_)) =>
      require(!ctx.functions(name.value), s"Function ${name.value} is already defined")
      ctx.functions += name.value
      BrainAST.FunctionDef(name.value, ops)

  val FunctionCall: Rule[BrainAST] = rule:
    case (BrainLexer.functionName(name), BrainLexer.functionCall(_)) =>
      require(ctx.functions.contains(name.value), s"Function ${name.value} is not defined")
      BrainAST.FunctionCall(name.value)

  val Operation: Rule[BrainAST] = rule(
    { case BrainLexer.inc(_) => BrainAST.Inc },
    { case BrainLexer.dec(_) => BrainAST.Dec },
    { case FunctionDef(fdef) => fdef },
    { case FunctionCall(call) => call },
    // ... other operations
  )
// Parsing "foo(+++)foo!":
val lexemes = BrainLexer.tokenize("foo(+++)foo!").getOrThrow
val finalCtx = BrainParser.parse(lexemes).ctx
// 1. FunctionDef reduced "foo(+++)": ctx.functions += "foo"
// 2. FunctionCall reduced "foo!", seeing the assignment from step 1:
finalCtx.functions.contains("foo")  // true

The initial context is created once per parse() call, so a second call starts again from the defaults.

Positional Info from Lexemes, Not ctx

ParserCtx and LexerCtx are independent: the parser context has no column or line. Positions come from the lexemes a rule binds -- name.column and name.line after BrainLexer.functionName(name), when the lexer context tracks them (see Lexeme Bindings).

See Parser for grammar rules and Extractors for EBNF operators.