Lexer Context
Every Alpaca lexer carries a context object that evolves as the input is processed. Context lets you do stateful lexing: counting brackets, tracking indentation, recording whether you are inside a string literal, or anything else that depends on the tokens seen so far.
By default, the lexer uses LexerCtx.Default, which gives you position and line tracking with no extra setup.
Under the hood: how tracking fields update
When you write lexer[MyCtx]:, the Alpaca macro inspects MyCtx's case fields at compile time. For every field whose type provides a given Tracking, it wires the corresponding per-token update into the generated tokenizer. After each match the engine threads a single functional copy of the context through those updates -- so tracked fields stay immutable vals and still advance automatically.
Default Context
When you write a lexer: block without a type parameter, the lexer uses LexerCtx.Default. It is a case class with two tracking fields:
import halotukozak.alpaca.*
final case class Default(
position: Column = Column.Start,
line: Line = Line.Start,
) extends LexerCtx
position-- 1-based column within the current line, incremented by the matched length and reset to 1 on newlinesline-- 1-based line number, incremented on each newline character
Column and Line are opaque subtypes of Int, so ctx.position and ctx.line read as plain Ints everywhere. Each carries a given Tracking that the lexer macro finds and applies after every match.
import halotukozak.alpaca.*
val BrainLexer = lexer:
case "\\+" => Token["inc"]
case "-" => Token["dec"]
case "\\s+" => Token.Ignored
val (ctx, lexemes) = BrainLexer.tokenize("+ - +")
// ctx.position == 6
// ctx.line == 1
//
// Each lexeme carries a snapshot of context fields at match time:
// inc: text="+", position=2, line=1
// dec: text="-", position=4, line=1
// inc: text="+", position=6, line=1
Position advances by the matched length after each token. The snapshot captures values after the token was consumed, not before.
The LexerCtx Trait
LexerCtx is the base trait for all lexer contexts. Any custom context must satisfy two rules:
- It must be a case class --
LexerCtxextendsProductdirectly, and the auto-derivation machinery requires aProductinstance. - All fields must have default values -- The
Empty[T]derivation macro reads default parameter values from the companion to construct the initial context. If any parameter lacks a default, the macro fails at compile time.
Warning: Do not declare
var text,var lastLexeme, orvar lastRawMatchedin your case class. These fields are provided by theLexerCtxtrait and managed internally by the lexer. Redeclaring them shadows the internal fields and breaks tokenization.
State fields are ordinary immutable val case-class parameters. Writing ctx.count += 1 in a rule body still type-checks and does the expected thing -- the lexer macro rewrites every such assignment into a functional copy before the rule is compiled. A field of a mutable collection type (e.g., scala.collection.mutable.Stack) works too: you mutate the collection in place and never reassign the field.
Custom Context
The BrainFuck lexer from Getting Started does not validate bracket matching -- it tokenizes ] even without a prior [. To fix that, we track bracket depth in a custom context:
import halotukozak.alpaca.*
case class BrainLexContext(
brackets: Int = 0,
squareBrackets: Int = 0,
) extends LexerCtx
val BrainLexer = lexer[BrainLexContext]:
case ">" => Token["next"]
case "<" => Token["prev"]
case "\\+" => Token["inc"]
case "-" => Token["dec"]
case "\\." => Token["print"]
case "," => Token["read"]
case "\\[" =>
ctx.squareBrackets += 1
Token["jumpForward"]
case "\\]" =>
require(ctx.squareBrackets > 0, "Mismatched brackets")
ctx.squareBrackets -= 1
Token["jumpBack"]
case name @ "[A-Za-z]+" => Token["functionName"](name)
case "\\(" =>
ctx.brackets += 1
Token["functionOpen"]
case "\\)" =>
require(ctx.brackets > 0, "Mismatched brackets")
ctx.brackets -= 1
Token["functionClose"]
case "!" => Token["functionCall"]
case "." => Token.Ignored
case "\n" => Token.Ignored
The type parameter lexer[BrainLexContext] tells the macro which context to use. The final context state is returned as the ctx component of the named tuple from tokenize():
import halotukozak.alpaca.*
case class BrainLexContext(
brackets: Int = 0,
squareBrackets: Int = 0,
) extends LexerCtx
val BrainLexer = lexer[BrainLexContext]:
case ">" => Token["next"]
case "<" => Token["prev"]
case "\\+" => Token["inc"]
case "-" => Token["dec"]
case "\\." => Token["print"]
case "," => Token["read"]
case "\\[" =>
ctx.squareBrackets += 1
Token["jumpForward"]
case "\\]" =>
require(ctx.squareBrackets > 0, "Mismatched brackets")
ctx.squareBrackets -= 1
Token["jumpBack"]
case name @ "[A-Za-z]+" => Token["functionName"](name)
case "\\(" =>
ctx.brackets += 1
Token["functionOpen"]
case "\\)" =>
require(ctx.brackets > 0, "Mismatched brackets")
ctx.brackets -= 1
Token["functionClose"]
case "!" => Token["functionCall"]
case "." => Token.Ignored
case "\n" => Token.Ignored
val (finalCtx, lexemes) = BrainLexer.tokenize("[>+<-]")
// finalCtx.squareBrackets == 0 -- balanced
Accessing Context in Patterns
Inside a lexer[Ctx]: block, the name ctx is implicitly available and refers to the current context object. You can read any field and assign to it -- the assignment is rewritten to a copy:
import halotukozak.alpaca.*
case class BrainLexContext(
brackets: Int = 0,
squareBrackets: Int = 0,
) extends LexerCtx
val BrainLexer = lexer[BrainLexContext]:
case ">" => Token["next"]
case "<" => Token["prev"]
case "\\+" => Token["inc"]
case "-" => Token["dec"]
case "\\." => Token["print"]
case "," => Token["read"]
case "\\[" =>
ctx.squareBrackets += 1
Token["jumpForward"]
case "\\]" =>
require(ctx.squareBrackets > 0, "Mismatched brackets")
ctx.squareBrackets -= 1
Token["jumpBack"]
case name @ "[A-Za-z]+" => Token["functionName"](name)
case "\\(" =>
ctx.brackets += 1
Token["functionOpen"]
case "\\)" =>
require(ctx.brackets > 0, "Mismatched brackets")
ctx.brackets -= 1
Token["functionClose"]
case "!" => Token["functionCall"]
case "." => Token.Ignored
case "\n" => Token.Ignored
val ExampleLexer = lexer[BrainLexContext]:
case "\\[" =>
ctx.squareBrackets += 1 // write
Token["jumpForward"]
case "\\]" =>
require(ctx.squareBrackets > 0, "Mismatched brackets") // read + validate
ctx.squareBrackets -= 1 // write
Token["jumpBack"]
Note on guards: Guards (
case "regex" if condition =>) are not supported in lexer rules. Use the rule body to read context state and decide what to emit -- you cannot filter matches before they occur.
Context Snapshots in Lexemes
Each Lexeme carries a snapshot of all context fields at the moment of the match. Access them by name via Selectable:
import halotukozak.alpaca.*
val BrainLexer = lexer:
case "\\+" => Token["inc"]
case "\\s+" => Token.Ignored
val (_, lexemes) = BrainLexer.tokenize("+ +")
lexemes(0).position // 2: Int (post-match position)
lexemes(0).line // 1: Int
lexemes(0).text // "+": String (the matched text, not remaining input)
The type safety comes from Selectable: the tokenize() return type carries a structural refinement that encodes every context field and its type. If you access a field that does not exist on the context type (e.g., .brackets when using LexerCtx.Default), the compiler reports a type error.
Two important details:
textis the matched string, not the remaining input. The snapshot replacestextwith the actual matched characters for that token.- Snapshots are independent. Each lexeme captures the context state at its own match time. Modifying the context after a match does not retroactively change earlier lexemes.
For custom contexts, all case class fields appear in the snapshot:
import halotukozak.alpaca.*
case class BrainLexContext(
squareBrackets: Int = 0,
) extends LexerCtx
val BrainLexer = lexer[BrainLexContext]:
case "\\[" =>
ctx.squareBrackets += 1
Token["jumpForward"]
case "\\]" =>
ctx.squareBrackets -= 1
Token["jumpBack"]
case "\\+" => Token["inc"]
case "." => Token.Ignored
val (_, lexemes) = BrainLexer.tokenize("[+[+]]")
// lexemes(0).squareBrackets == 1 -- after first [
// lexemes(2).squareBrackets == 2 -- after second [
// lexemes(4).squareBrackets == 1 -- after first ]
Built-in Tracking Fragments
Alpaca ships two ready-made tracking fields, both re-exported from halotukozak.alpaca:
Column -- an opaque Int that advances by the matched length after each token and resets to 1 on a newline.
Line -- an opaque Int that increments when the matched token is a newline.
Each is a plain case-class field with a given Tracking in its companion. Use either one, both, or neither. LexerCtx.Default uses both. To add them to a custom context:
import halotukozak.alpaca.*
case class BrainLexContext(
squareBrackets: Int = 0,
position: Column = Column.Start,
line: Line = Line.Start,
) extends LexerCtx
With this context, every lexeme carries squareBrackets, position, and line. squareBrackets changes only where a rule body assigns it; position and line advance automatically after every match.
The Post-Match Update
After every successful token match, the lexer:
- applies each tracked field's
Trackingupdate (position,line, and any custom fragments), - applies the rule body's own context changes,
- advances the text cursor and records the lexeme snapshot.
Steps 1 and 3 are derived by the lexer macro from the context's case fields -- there is nothing to wire up by hand.
Under the hood: custom tracking fragments
A tracking fragment is any field type that provides a given Tracking[F]. Tracking[F] is a single-method function (matched: String, field: F) => F: given the raw text just matched and the field's current value, return its next value. The lexer macro finds one for each case field and threads a functional copy through them after every match.
import halotukozak.alpaca.*
// Step 1: a distinct type for what you track
opaque type Indent <: Int = Int
object Indent:
val Start: Indent = 0
// Step 2: a given Tracking in its companion
given Tracking[Indent] =
case ("\t", n) => n + 1
case ("\n", _) => 0
case (_, n) => n
// Step 3: use it as a context field
case class MyCtx(indent: Indent = Indent.Start) extends LexerCtx
// Step 4: the lexer macro applies the update automatically
val Lexer = lexer[MyCtx]:
case "\t" => Token.Ignored
case "\n" => Token.Ignored
case id @ "[a-z]+" => Token["ID"](id)
No inheritance, no trait companion, no composition macro: a fragment is just a field type plus its given.
LexerCtx.Empty
For cases where you need no tracking at all -- no position, no line counter, no custom fields -- use LexerCtx.Empty:
import halotukozak.alpaca.*
val Lexer = lexer[LexerCtx.Empty]:
case "\\+" => Token["inc"]
case "." => Token.Ignored
val (_, lexemes) = Lexer.tokenize("+ +")
// lexemes(0).fields == Map("text" -> "+") -- only the text field
See Between Stages to learn how context snapshots in lexemes flow into the parser.
