RFC-0042 — @@fsm Finite-State Recognizer Construct

  • Status: Draft
  • Author: Mark Truluck
  • Amended by: RFC-0042.1 (pluggable input source — a recognizer generic over an owned buffer / borrowed slice / host callback, with a reusable over/scan_at drive form for scan-at-cursor use; narrows the deferred §11.3).
  • Scope: Defines @@fsm as a recognizer construct in the Frame language.
  • Profile placement: F0 (table) or F1 (bare/switch).
  • Composition: Composes with @@system via Modes A and B; composes with other @@fsm declarations via Mode C.
  • Companion: RFC-0020 (runtime reference architecture for @@system; @@fsm has its own runtime model and is not bound by RFC-0020’s compartment shape); RFC-0050 (Frame statement syntax — the grammar @@fsm action bodies use).
  • Depends on: RFC-0050 — must ship before @@fsm action bodies can be parsed.

Note on the construct family. RFC-0020 is the normative runtime reference for @@system. @@fsm is a separate construct with its own runtime model — it does not allocate compartments, has no lifecycle handlers, and runs to completion at construction. Per-construct codegen divergence (separate target-language emission strategies for @@system, @@fsm, and a future @@pda) is anticipated but is not specified by this RFC.


1. Purpose

@@fsm is Frame’s recognizer construct: a finite-state machine over a stream of input symbols, expressed as a sequence of regular-expression match stages with embedded actions, producing a declared return value.

It is the construct to reach for when the work is recognition — input validation, byte-level scanning, protocol decoding, lexical analysis — and the cost model demands bounded memory and predictable time. Recognition is restricted to the regular-language tier: patterns expressible as a DFA. Patterns requiring context-free or context-sensitive recognition are out of scope for @@fsm and must be handled by host code (typically in @@system).

Two existing tools address recognition poorly. A bare regular expression is opaque to the host program: the pattern is a string the compiler cannot inspect, its captures are addressed by anonymous group number, logic cannot be embedded mid-match, and a backtracking engine’s worst-case time is hard to bound. A hand-written scanner is transparent and fast but verbose, and it does not travel — the same recognizer must be rewritten in every target language, its state machine left implicit in cursor arithmetic. @@fsm takes the position that the recognizer should be a first-class construct: written once as decomposed, capturing, action-bearing match stages with explicit transitions; compiled to a minimal deterministic automaton at framepile time (the runtime is a DFA executor, not a regex engine); and emitted into every target language. The regular-language restriction is what makes the cost model a guarantee rather than a hope.

2. Design commitments

These are normative and not subject to relaxation within @@fsm:

  1. Sequenced-match model. A state’s body is one or more matches. Each match is an ordered sequence of /regex/ match stages (each consuming input on success), interleaved with actions. A match succeeds when all of its stages succeed; if any stage fails, the match fails.
  2. Regular-language only. Patterns inside /.../ are restricted to operators that preserve the regular-language guarantee. No backreferences, recursion, or variable-width lookaround. Patterns requiring these constructs are out of scope for @@fsm; they must be handled outside the construct.
  3. DFA-compileable at framepile time. The framepiler compiles every pattern to a deterministic finite automaton at compile time. The runtime is a DFA executor, not a regex engine.
  4. Mandatory typed declarations with default initializers. Every domain variable, every parameter, and the return value are declared with a type and a default. No use-is-declaration. No implicit zero-values.
  5. No lifecycle handlers, no state variables, no compartments. @@fsm has no $>/<$ handlers, no $.varName state variables, and no per-state compartment structure. All persistent data lives in domain. Recognition-time actions attach to stage boundaries; transitions between states are pure state changes.
  6. Single transition arrow. @@fsm uses -> for transitions, whether the target is a static state reference or a runtime-selected member of a statically-enumerable set.
  7. Bare-domain parameter form only; first bare parameter is the input source. @@fsm does not use the $(...) state-arg or $>(...) enter-arg parameter forms — it has no compartments to receive them. The first positional (bare) parameter is the input source; its element type determines the fsm’s alphabet (§6.1).
  8. Parameters are auto-promoted to domain fields. Every parameter declared in the header is automatically available as a same-named domain field, initialized from the parameter value. The user need not redeclare the field in the domain: block (§3.2).
  9. Construction-driven execution. In this revision, an @@fsm is constructed with its full input and runs to completion during construction. Incremental and streaming interfaces are deferred (see §11).
  10. Construct-specific action vocabulary. @@fsm introduces a recognizer-specific extension of Frame’s @@: context vocabulary (cursor manipulation, peek, current-element access, matched-slice retrieval). Only @@:return is shared with @@system; the recognition-specific probes (@@:cursor, @@:fc, @@:peek, @@:remaining, @@:at_end, @@:matched) are introduced by this construct (§7.1).

3. Grammar

3.1 Construct declaration

fsm_decl       ::= "@@fsm" attributes? name "(" input_param ("," param)* ")" ":" return_type "=" default_expr "{" body "}"
attributes     ::= ("@@[" attr_args "]")+
name           ::= identifier
input_param    ::= identifier ":" alphabet_type ("=" default_expr)?
param          ::= identifier ":" type ("=" default_expr)?
alphabet_type  ::= "bytes" | "char" | "token"
return_type    ::= type
default_expr   ::= expression
body           ::= state_decl+ actions_block? domain_block?

A complete @@fsm declares: optional attributes, a name, a parameter list whose first member is the input source, a return type, a mandatory default value, and a body of at least one state declaration optionally followed by actions and domain blocks.

The parameter list is mandatory in v0.1: the fsm must declare an input parameter to receive input at construction. Use of the $(...) or $>(...) sigil groups in an @@fsm parameter list is E701 — these forms have no meaning in a construct without compartments.

3.2 Parameters and auto-domain promotion

Every parameter declared in the @@fsm header is automatically promoted to a same-named domain field, initialized from the parameter value. The user accesses the parameter’s value at any point inside the body as self.<name>.

For example, the following:

@@fsm Counter(text: bytes, initial: int = 0) : int = 0 {
    /[0-9]+/  self.count = self.count + 1
    self.count

    domain:
        count: int = self.initial
}

is equivalent to the explicit form:

@@fsm Counter(text: bytes, initial: int = 0) : int = 0 {
    /[0-9]+/  self.count = self.count + 1
    self.count

    domain:
        text: bytes = text          // auto-generated
        initial: int = initial      // auto-generated
        count: int = self.initial
}

The first parameter is the input source. Its type must be bytes, char, or token (§6.1); other types are E713. The framepiler treats the auto-generated domain field corresponding to the input parameter as the input the fsm consumes during construction.

Explicit redeclaration

A user may explicitly declare a domain field with the same name as a parameter, provided the type matches. This is useful when the user wants a different initial value:

@@fsm Doubled(initial: int) : int = 0 {
    ...
    domain:
        initial: int = initial * 2     // explicit override
}

The framepiler verifies that the explicit declaration’s type matches the parameter’s type. A type mismatch is E707.

Domain block is optional

If no domain fields are declared beyond the auto-generated ones from parameters, the domain: block may be omitted entirely.

3.3 Canonical block order

Sections in the @@fsm body must appear in this canonical order:

<states>  →  actions  →  domain

This order is @@fsm-specific; it differs from @@system’s (which has additional interface/operations/machine sections that @@fsm does not). Out-of-order sections produce E710. Each optional block may appear at most once; duplicates produce E711. At least one state (implicit or explicit) is required.

3.4 State declarations

state_decl  ::= state_label? match ("|" match)*
state_label ::= "$" identifier ":"

A state declaration is an optional $identifier: label followed by one or more matches. The first state declared is the start state.

State labels are optional but governed by one rule: a state can be referenced only by an explicitly declared label. Unlabeled states exist and execute normally — they are valid recognition states — but no transition target may name them, and no stage-capture address may refer to them. If a writer needs to reference a state from elsewhere in the fsm (as a transition target, as a stage-capture prefix), the state must carry a label.

Two practical consequences:

  • The first state may be unlabeled. This is the common case for the minimal fsm: a single anonymous start state holding one match. Recognition begins there by position. Nothing outside the state needs to name it, so no label is needed.
  • Every subsequent state must be labeled. Without a $label: boundary, the parser cannot tell where one state’s matches end and the next state’s matches begin. The label provides the syntactic separator. Putting two unlabeled states in sequence is E704.

A label may be any valid identifier, including a numeric one. $0:, $1:, and $Header: are all valid labels. Numeric labels are convenient when the writer doesn’t want to invent semantic names; named labels are clearer in nontrivial recognizers. The choice is aesthetic; the framepiler treats them identically.

Multiple matches within a single state are separated by | (ordered choice). The first match whose first stage matches the current input wins. If none match, the state’s failure handling applies (§3.5.4).

A reference to an undeclared state label (a transition target naming $Foo when no $Foo: exists) is E731. A stage-capture reference using an unlabeled state (e.g., $0.x when the enclosing state has no label) is E704.

3.5 Matches

A match is the unit of recognition within a state. It is an ordered sequence of match stages (each /regex/ testing input at the cursor) interleaved with actions, all of which must succeed for the match to complete. If any stage fails, the match fails and execution follows the failure_branch; if all stages succeed, execution follows the success_branch.

The match decomposes what would otherwise be a single monolithic regex into addressable stages. A four-stage match .v/HTTP\/[0-9]\.[0-9]/ / / .s/[0-9]{3}/ /\r\n/ recognizes the same input as the single regex /HTTP\/[0-9]\.[0-9] [0-9]{3}\r\n/, but exposes labeled captures, intermediate actions, and stage-by-stage failure handling that the monolithic form cannot.

A state may contain one match or several matches separated by | (ordered choice).

3.5.1 Match structure

match              ::= element+ transition_clause?
element            ::= match_stage | action_block | bare_expression
match_stage        ::= stage_label? "/" regex "/" embedding_action*
stage_label        ::= "." identifier
action_block       ::= "{" statement_list? "}"
bare_expression    ::= expression                  (* implicit @@:return = expression *)

A match is a non-empty sequence of elements optionally followed by a transition clause. Elements are match stages, action blocks, or bare expressions.

Whitespace between elements is not significant. Matches may be written on a single line or split across multiple lines; the parser treats any whitespace (including newlines) between elements as a separator.

Action blocks may be empty ({}); an empty block is a no-op.

3.5.2 Match stages and capture

A match stage is .label? /regex/ followed by zero or more embedding actions. The stage’s regex is tested against the input at the current cursor position. On success:

  • The cursor advances by the number of symbols consumed.
  • The matched symbols are recorded as the stage’s capture.
  • The stage’s embedding actions fire per their attachment rules (§3.5.5).

If a stage is labeled .name, its capture is addressable as $state.name, where $state is the enclosing state’s declared label. Unlabeled stages are addressable by zero-indexed position as $state.0, $state.1, and so on. Stage captures of a state that has no declared label cannot be addressed from elsewhere; if the writer needs such addressing, the state must be given a label.

A stage reference has type matching the input alphabet’s slice type: bytes for byte input, the target’s string type for char input, the target’s token-list type for token input. Mode C composition (§8.3) additionally exposes the inner fsm’s return value via $state.label.return_value (chained-dot access).

3.5.3 Action forms

Actions in @@fsm take five forms:

Form Position Fires when
expr (bare) Match tail Match completes successfully; assigns to @@:return
{ stmts } Between elements Match execution reaches the block
name(args) (call) Inside any action body Invoked from another action
>{ stmts } Embedding (after /regex/) Stage’s DFA enters start state
@{ stmts } Embedding (after /regex/) Stage’s DFA enters an accepting state
${ stmts } Embedding (after /regex/) Stage’s DFA takes any transition
%{ stmts } Embedding (after /regex/) Stage’s DFA leaves its last accepting state
@eof{ stmts } Embedding (after /regex/) End of input reached while stage is mid-match

@eof is a single keyword token; it does not conflict with @{...} (the lexer matches the longer keyword first).

Bare-expression form is sugar for @@:return = expression. The explicit forms inherited from Frame’s @@system@@:return = expr and @@:(expr) — are also valid in @@fsm action blocks.

3.5.4 Transition clauses

transition_clause  ::= success_branch failure_branch?
success_branch     ::= "->" target
failure_branch     ::= ":" "->" target
target             ::= static_target | conditional_target
static_target      ::= state_ref | stage_ref
state_ref          ::= "$" identifier
stage_ref          ::= state_ref "." identifier
conditional_target ::= "(" cond_alt ("," cond_alt)* ")"
cond_alt           ::= static_target "when" condition
condition          ::= expression                  (* type bool *)

A success_branch fires when the match completes (all stages succeed, all action blocks execute). A failure_branch fires when any stage in the match fails; if absent, see §4.3.

A conditional_target selects one of a statically enumerable set of targets based on runtime conditions. Conditions are evaluated in source order; the first true condition selects its target. If no condition is true, §5.6 applies.

3.5.4.1 The when clause

when is a guard clause attached to a transition target. It pairs a candidate target with a boolean predicate; the target is selected at runtime only if the predicate is true. The keyword and shape come from the same tradition as Haskell/OCaml/Rust match guards (Some(x) when x > 0), Erlang function-clause guards (f(X) when X > 0), and SQL CASE WHEN.

Where when is permitted. when appears in exactly one place: as a guard on a transition target within a conditional_target expression. It does not appear inside action bodies (use if/else for imperative control flow), on stage labels, match elements, or domain initializers; nor as a general pattern-match keyword. The restriction is deliberate — see Why one place only below.

What a when predicate may contain. The predicate is a standard Frame boolean expression:

  • Literals (true, false, integers, strings).
  • Domain references (self.<name>).
  • Comparison operators (==, !=, <, <=, >, >=).
  • Logical operators (&&, ||, !).
  • Arithmetic operators (+, -, *, /, %).
  • Function calls to declared actions or Frame built-ins, provided they are pure.
  • Read-only @@: probes (@@:cursor, @@:at_end, etc.).

It may not contain assignment, transition statements, side-effecting calls, or references to other states’ captures. The predicate is a pure read.

Evaluation order and semantics. Given

-> ( $A when P1, $B when P2, $C when P3 ) : -> $Fallback

the runtime evaluates P1; if true, transitions to $A; otherwise evaluates P2, then P3. If none hold, the failure branch fires (here, -> $Fallback). A true predicate stops further evaluation. The semantics match an if/else if/else chain or a switch with no fallthrough; predicates do not share state across alternatives.

Exhaustiveness. when clauses are not required to be exhaustive. If no predicate holds, the failure branch fires. A conditional_target with no failure branch is permitted but triggers W701, since the writer probably did not intend unmatched values to fall through silently. The framepiler does not attempt full exhaustiveness checking over open-type predicates (e.g., int values). Sum-type discriminator predicates (where the framepiler can see all constructors) may receive exhaustiveness checking in future revisions.

Every alternative requires its when. Every cond_alt in a conditional_target must include a when guard. A bare target like $Small without its predicate is E715. To express “this target unconditionally if no other matched,” use the failure branch (: -> $Small) rather than an unguarded alternative.

when true is discouraged. A constant-true predicate adds noise without semantic content. Use a plain -> $Default outside the conditional_target, or place the target in the failure branch. The framepiler emits W705 on constant-true guards.

Why one place only. Three design forces converge on the restriction:

  1. Statically enumerable targets. @@fsm requires that the set of states reachable from any transition is knowable at compile time (§4.4). DFA reachability, dead-state detection, and worst-case state count all depend on this property. A when-guarded target list expresses runtime choice over a statically-bounded set: the framepiler sees exactly which targets are candidates while the user gets a runtime decision. Generalizing when to other positions would undermine this property unless a parallel static-analysis pass were added for each new use.

  2. Visual scannability. A reader scanning Frame source for “what does this state transition to?” gets the answer in the transition clause itself. Compare the guard form

    /[TB]/ -> ( $TextStream when self.mode == 0,
                $BinaryStream when self.mode == 1 ) : -> $Error
    

    with the imperative alternative

    /[TB]/ {
        if self.mode == 0 { -> $TextStream }
        else if self.mode == 1 { -> $BinaryStream }
        else { -> $Error }
    }
    

    The imperative form would allow -> $TextStream to be hidden inside nested conditionals or action calls. That defeats analyzability and obscures the state machine’s structure. The guard form is syntactically limited to a flat list of candidates at the transition site.

  3. Minimum sufficient vocabulary. Frame avoids adding language features that overlap with existing ones unless the new feature solves a problem the existing ones cannot. Imperative control flow already exists (if/else per RFC-0050); when exists precisely because if/else cannot express “transition with multiple candidates” while preserving the static-enumeration property. Extending when elsewhere would either duplicate if/else or violate static enumeration.

When to reach for when. Use a guarded conditional_target when (a) a regex stage succeeds, (b) more than one possible next state exists, and (c) the choice depends on domain state, parameters, or current cursor position. Do not use when when the choice depends on what was matched — the regex itself can encode that, by splitting into multiple matches separated by |, each with its own transition target. Do not use when for a single candidate — a plain -> $Target is clearer; the guard form adds noise.

Anti-patterns.

  • Single-alternative guards. /foo/ -> ( $Accept when self.is_valid ) : -> $Reject is usually clearer as an action-block predicate plus an unconditional transition. The guard form is for choosing among multiple targets.
  • Documentation guards. -> ( $Default when true ) — drop the guard and write -> $Default. The framepiler emits W705.
  • Guards that duplicate the regex. If the predicate checks what was matched, encode the distinction in the regex:

    // anti-pattern: redundant guard
    /[ab]/ -> ( $A when @@:matched == "a",
                $B when @@:matched == "b" )
    
    // better: let the regex do the work
    /a/ -> $A : -> $check_b
    $check_b: /b/ -> $B : -> $error
    

Future considerations. when is reserved exclusively for transition-target guards. If a future Frame revision needs a general pattern-match construct (sum-type destructuring, etc.), it will introduce a separate keyword (likely match or case) rather than overloading when. This preserves the clear reader expectation: see when, expect a transition-target choice; see anything else, look elsewhere.

Cheatsheet.

// Single static target — most common
/regex/ -> $Next : -> $Error

// Multiple candidates, runtime choice
/regex/ -> ( $A when self.x == 1,
             $B when self.x == 2,
             $C when self.x == 3 ) : -> $Default

// Common pattern: guard against domain state
/regex/ -> ( $Authenticated when self.user != "",
             $Anonymous     when self.user == "" ) : -> $Error

3.5.5 Embedding actions

Five operators attach action blocks to specific positions in a stage’s compiled DFA. They appear immediately after the stage’s closing /:

embedding_action ::= embedding_op action_block
embedding_op     ::= ">" | "@" | "$" | "%" | "@eof"
Operator Attaches at
>{...} Transitions entering the stage’s regex from the start state
@{...} Transitions into the regex’s final (accepting) states
${...} Every transition within the stage’s regex
%{...} Transitions leaving a final state of the stage’s regex
@eof{...} At end-of-input while the stage is partially matched

Declared actions (§3.7) may be invoked from any embedding action body.

Example:

.digits/[0-9]+/  >{ self.start_count = @@:cursor }  ${ self.digit_count = self.digit_count + 1 }

3.6 Frame statement syntax — see RFC-0050

@@fsm action bodies admit Frame statement-level code as defined by RFC-0050. That RFC is the canonical reference for the statement grammar (assignment, call, if/else, return-value), expression syntax (literals, references, operators, precedence), lvalue rules, scope rules, and Frame-level comment forms (//, /* */).

The §3.6 grammar that previously lived here has been extracted to RFC-0050 verbatim. Subsequent sections of this RFC reference RFC-0050 productions where relevant. One @@fsm-specific addition: comments do not apply inside /.../ regex delimiters — the interior of a regex is the alphabet’s regex language, where Frame syntax (including comments) has no meaning. To document a regex, place a Frame comment immediately before or after the stage. To match a literal / byte, escape it as \/.

Example illustrating statement syntax + regex-comment exception together:

// recognize an HTTP start-line
@@fsm HttpStart(text: bytes) : Header = Header.Invalid {

    .v/HTTP\/[0-9]\.[0-9]/   // version
    / /                      // separator
    .s/[0-9]{3}/             // status code
    /\r\n/                   /* terminator */

    Header(version: $0.v, status: parse_status($0.s))
      : -> $error

    /* error state — returns the declared default */
    $error: Header.Invalid

    actions:
        parse_status(s: bytes): int { to_int(s) }
}

3.7 Actions block

actions_block ::= "actions" ":" action_decl+
action_decl   ::= identifier "(" param_list? ")" (":" type)? "{" statement_list "}"

The optional actions: block declares private helper functions callable from match actions, embedding actions, and other actions. Actions follow @@system’s actions: constraints:

  • Read and write domain variables.
  • No transitions (-> statements are E712).
  • No state-aware operations (no stage captures, no cursor manipulation).

Actions may be called from any match action block, any embedding action body, or any other declared action.

3.8 Domain block

domain_block ::= "domain" ":" var_decl+
var_decl     ::= identifier ":" type "=" default_expr

The optional domain: block declares additional persistent variables for the fsm instance, beyond those auto-generated from parameters (§3.2). Each variable must have a typed declaration and a default initializer. Domain initializers may reference any in-scope constructor parameter or any already-defined domain field via self.name.

A user may also explicitly re-declare a parameter-derived domain field in this block to override its default initializer; the type must match (E707 on mismatch).

If no additional domain fields are needed, the block may be omitted entirely.

4. Static semantics

4.1 Typing

@@fsm is strictly typed. Every parameter, every domain variable, every assignment target, and the return slot has a declared type. The framepiler verifies type compatibility at compile time. Frame’s canonical types (bool, int, string, bytes, etc.) apply.

A type mismatch in any assignment or return-position expression is E706.

4.2 Variable scope and initialization

  • Constructor parameters are in scope only within the domain: block’s initializer expressions, as bare names. They are not accessible from state matches, actions, or embedding actions; the auto-generated domain fields (or explicitly re-declared ones) provide the long-lived access via self.name.
  • Domain variables (auto-generated or explicit) are in scope from the start of the body to the end of the body, accessed as self.name.
  • All domain variables read their declared default until first assignment.
  • Reading an undeclared name is E703.
  • Writing to an undeclared name is E704.

4.3 Exhaustiveness

A match with a success_branch and no failure_branch is permitted only when the framepiler can prove the match cannot fail. A match is provably non-failing if every stage’s regex accepts the empty string and no action block contains a call that may fail.

Matches that may fail must declare a failure_branch, or the framepiler emits E701.

A match that has neither a success_branch nor a failure_branch is an implicit-terminal match: success leaves the match in its final position with @@:return carrying its current value; failure follows §5.6.

4.4 Target reachability

The set of possible transition targets reachable from any single target expression must be statically enumerable. Static targets trivially satisfy this; conditional targets satisfy this when every cond_alt names a literal state or stage reference. The v0.1 grammar admits only these forms, so this property holds by construction.

The framepiler reports the enumerated target set as a fact about each fsm (§9.3).

5. Runtime semantics

5.1 Instance fields

A constructed @@fsm instance exposes the following observable fields to the host. Access is through the target language’s natural attribute syntax (m.field in Python, m.field() in Rust, m.getField() in Java with bean conventions, etc.) — the spec specifies the field names and types, not the access syntax.

Field Type Meaning Mutability from host
accepted bool true if recognition completed successfully read-only
reject_position int Cursor position at failure when accepted == false; 0 otherwise read-only
cursor int Final input position (0-indexed) read-only
return_value declared return type The value of @@:return read-only
Domain variables declared types All declared and auto-generated domain fields at end of recognition read-only by default, @@[public_write] to override

The @@:return slot inside the fsm body and the return_value field on the host instance refer to the same storage location.

There is no in-flight observability in v0.1: an @@fsm instance is always observed in its post-recognition state. Incremental observation requires the streaming interface deferred in §11.

5.2 Construction

@@FsmName(args) performs the following sequence:

  1. Allocate the fsm instance.
  2. Bind each parameter from the corresponding argument; unprovided parameters with defaults use their defaults.
  3. Initialize each auto-generated domain field from its corresponding parameter.
  4. Initialize each explicitly declared domain field from its default expression. Auto-generated fields are in scope (accessible as self.<name>) for explicit fields’ initializers.
  5. Set @@:return to the declared default.
  6. Set accepted to false, reject_position to 0, cursor to 0.
  7. Set the current state to the start state.
  8. Drive recognition through the input (the domain field corresponding to the first parameter) until acceptance or rejection (§5.3).
  9. Return the finished instance.

5.3 Execution model

Recognition advances through the current state’s match, attempting each stage’s regex against the input starting at the current cursor position. For each element of the match, in order:

  • Match stage: The stage’s regex is tested against the input at the cursor. If the regex succeeds, the cursor advances by the consumed length, the stage’s capture is recorded, and embedding actions fire per attachment rules (§3.5.5). If the regex fails, the stage fails and the match follows its failure_branch (or §5.6).
  • Action block: Statements execute in order. Action blocks do not consume input.
  • Bare expression: Per §3.5.1, evaluates and assigns to @@:return.

When all elements succeed, the match follows its success_branch transition. Recognition terminates with accepted = true when execution reaches a terminal state (one with no pending match elements and no transition out) as the result of a successful match completion.

The governing principle is the RE2 recognizer model: an @@fsm recognizes a language, and accepted answers “is the input in that language?” — exactly RE2’s “did the pattern match?” Recognition is in the language iff it reaches a terminal configuration by completing a match (matching its stages). A failure_branch is the writer’s handler for “this stage did not match here”; following it does not by itself constitute a match. Acceptance is therefore decided by the terminating event, not by the state’s name and not by whether a failure occurred earlier en route. Recognition halts with accepted = true when the terminating event is a successful match completion at a terminal state — reached via a success_branch, or as an implicit-terminal match whose stages all matched. It halts with accepted = false when the terminating event is a stage failure that cannot make further progress: the match’s failure_branch routes to a terminal state, or no failure_branch exists (§5.6).

A failure_branch to a non-terminal state is not a halt — recognition continues there and may still accept. This is what makes the failure-branch-chaining classifier idiom work: an input that fails one stage but matches a later one (reached via the failure branch) terminates on that later success and is therefore accepted.

State names have no semantic meaning to the framepiler. A terminal state the writer names $error that is reached via a success_branch is still accepting; a terminal state named $ok reached via a failure_branch (with no further successful match) is non-accepting. The return_value is whatever the terminating state’s match assigned, independent of accepted — so a recognizer may route a failure to a terminal state purely to set a clean rejected-case return_value while still reporting accepted = false. To carry a richer success/failure distinction in the value itself, encode it in the return type (e.g. a Result-shaped sum or a sentinel).

Actions executing during a match observe @@:cursor at its current position. Declared actions called from match or embedding actions see the same cursor.

5.4 Embedding action firing

Embedding actions attach to positions in a stage’s compiled DFA and fire as the DFA processes input:

  • >{...} fires once when the DFA begins matching.
  • ${...} fires for every element the DFA consumes.
  • @{...} fires each time the DFA enters an accepting state.
  • %{...} fires once when the DFA stops accepting after having previously accepted.
  • @eof{...} fires if end-of-input is reached while the DFA is mid-match.

5.5 Transitions

A transition -> target performs:

  1. Resolve the target. For static targets, this is direct. For conditional targets, evaluate each condition in order; the first true condition’s target is selected. If no condition is true, the transition fails (§5.6).
  2. Set the current state to the target’s state (or, for stage-ref targets, the stage’s enclosing state).
  3. If the target is a stage reference, set the stage index to the labeled stage; otherwise, the stage index is 0.
  4. The cursor is not modified by a transition; recognition resumes at the current cursor position.
  5. Begin execution at the resolved (state, stage) position.

No enter or exit handlers fire. The transition is the state change.

5.6 Failure behavior

When a match has no failure_branch and a stage fails:

  1. accepted becomes false.
  2. reject_position is set to @@:cursor.
  3. @@:return retains its current value.
  4. Recognition terminates.

When a conditional_target has no matching condition and no fallback is provided, the match is treated as failed at the transition point. The framepiler emits W701.

When end of input is reached and the current state is not a terminal state, any @eof embedding actions for the in-progress stage fire, then accepted is set to false and reject_position to @@:cursor.

6. The Frame regex dialect

This section defines what /.../ accepts. The dialect is in the RE2 family — regular-language-only, Thompson-DFA-compileable — with Frame-specific deviations.

6.1 Alphabets

The fsm’s input alphabet is determined by the type of its first positional parameter:

First param type Alphabet Element type Per-step view
bytes Octets 0–255 one byte a byte
char Unicode code points one code point a char
token Application-defined token kinds one token a token

All of §6.2 through §6.6 is defined for the byte alphabet. The char alphabet uses the same operators with character classes operating on Unicode code points (§6.7). The token alphabet replaces literal byte/char syntax with token-kind references (§6.8).

6.2 Core operators (byte alphabet)

Frame follows RE2 semantics for these operators.

Construct Meaning
a Match the literal byte a
\n \t \r \0 Standard byte escapes
\xNN Byte with hex code NN
\/ Literal / (the stage delimiter)
. Any byte except \n (configurable per @@[dot_matches_newline])
[a-z] Character class (range)
[^a-z] Negated character class
[abc] Character class (set)
\d \w \s ASCII digit, word-character, whitespace
\D \W \S Negated forms
\b \B Word boundary, non-word boundary
^ $ Start-of-input, end-of-input anchors
\A \z Absolute start-of-input, absolute end-of-input
xy Concatenation
x\|y Alternation (union)
x* Zero or more (greedy)
x+ One or more (greedy)
x? Zero or one (greedy)
x{n} Exactly n
x{n,m} Between n and m
x{n,} At least n
(x) Grouping (does not capture; capture is stage-based)

Operator precedence follows RE2, tightest to loosest:

  1. Character classes, escapes, grouping (atom level).
  2. Quantifiers (*, +, ?, {n,m}).
  3. Concatenation (juxtaposition).
  4. Alternation (|).

Thus foo|bar baz parses as foo | (bar baz). Parenthesize for other intents: (foo|bar) baz.

6.3 RE2 operators excluded in Frame

  • (?P<name>...) named capture — replaced by stage labels.
  • (?:...) non-capturing group — unnecessary since () doesn’t capture.
  • (?P=name) named backreference — non-regular and unsupported.

6.4 Operators forbidden as non-regular

  • \1, \2, … backreferences — non-regular.
  • (?R), (?-1) recursion — non-regular.
  • Variable-width lookbehind — non-regular in general.

These produce E720. Patterns requiring these constructs are out of scope for @@fsm.

6.5 Regular operators forbidden in v0.1

  • Fixed-width lookahead and lookbehind ((?=foo), (?<=ab)) — regular but multiply DFA state count.

This is forbidden in v0.1; its use produces E720. The v0.1 dialect is conservative to bound implementation cost and DFA sizes across the codegen targets.

Lazy quantifiers (x*?, x+?, x??, …) were forbidden in v0.1; v0.2 supports them (bytes/char alphabets). A stage containing a lazy quantifier compiles to a Pike VM program (priority NFA simulation) for leftmost-first / Perl match-end semantics — a single longest-match DFA cannot express per-quantifier laziness (a*? is minimal but a greedy b+ after it is still maximal). Greedy-only stages keep the pure-DFA path. On the token alphabet lazy is E720 (no scalar element notion); a lazy quantifier in a stage with embedding actions is also rejected (§11.1).

Unicode general-category and script classes (\p{L}, \p{N}, \p{Greek}, …) were forbidden in v0.1; v0.2 admits them on the char alphabet behind the per-fsm opt-in @@[allow(unicode_classes)] (§6.7, §11.6). Without the attribute they remain E720; on a non-char alphabet they are E722 (no codepoint notion).

Inline flags ((?i), (?m), (?s)) were forbidden in v0.1; v0.2 supports them on the bytes/char alphabets (§6.6). The parser threads the active flag set and bakes each into concrete AST, so the engine routing and all backends need no flag awareness: (?i) expands every cased literal/class to its case folds (a small [Aa] class); (?s) rewrites . to [^] (every element, including \n); (?m) retags ^/$ as line-relative anchors (§6.6). A leading group (?ims) sets flags for the rest of the enclosing group; a colon group (?ims:…) / (?-i:…) scopes (or clears) them to the group. On the token alphabet, (?m) line anchors route to the Pike VM and are E720 (no scalar element notion); (?i)/(?s) are no-ops there.

6.6 Anchors and multi-line mode

^ and $ match the absolute start and end of input by default — equivalent to \A/\z, which always do so. Under the (?m) inline flag (§6.5) ^/$ become line-relative: ^ matches at input start and immediately after any \n; $ matches at input end and immediately before any \n. Edge ^/$/\A/\z and edge \b/\B (bytes) compile to position guards around a pure-DFA match; an interior anchor or word boundary, a \b/\B on the char alphabet (Unicode \w), and any (?m) line anchor compile to a zero-width Assert instruction evaluated by the Pike VM (§11.1). Under (?s) (§6.5) . additionally matches \n.

6.7 The char alphabet

When the input element type is char:

  • Literal characters in the regex are Unicode code points.
  • Character classes operate on code points.
  • \d \w \s use Unicode-equivalent sets.
  • \xNN is rejected (E722); use \u{NNNN} for Unicode escapes.
  • \p{Name} / \P{Name} Unicode general-category and script classes are available when the fsm carries @@[allow(unicode_classes)] (§11.6); without it they are E720. They are resolved to codepoint ranges at compile time (the recognition engine stays range-based).
  • All other operators retain their meaning.

6.8 The token alphabet

When the input element type is token:

  • Bare identifiers in the regex refer to token kinds: /IDENT (LPAREN expr RPAREN)*/.
  • Character classes ([a-z], \d, etc.) and byte/char escapes are E722.
  • The dot . matches any token of any kind.
  • Anchors ^ and $ match the start and end of the token stream.
  • All other operators retain their meaning.

6.9 Compilation

Every Frame regex compiles via:

  1. Parse to AST.
  2. Thompson construction → NFA.
  3. Subset construction → DFA.
  4. Hopcroft minimization → minimal DFA.
  5. Lower to backend per the chosen dispatch (switch/table/goto).

The framepiler reports DFA size as a diagnostic property. DFA-size limits are configurable per profile or per fsm via @@[max_dfa_states(N)]. Exceeding the limit is E721.

6.10 RE2 compliance

The dialect targets the RE2 family (§6.1). This matrix records where the implementation stands relative to RE2’s regular-language feature set, so the remaining work is explicit. Legend: at parity; partial supported with the noted scope; deferred planned, currently a clear compile error; deviation intentional Frame replacement (permanent); excluded non-regular and excluded by both RE2 and Frame.

RE2 feature Status Notes
Literals, ., classes [...], alternation, concatenation  
Greedy quantifiers * + ? {n,m} pure-DFA longest match
Lazy quantifiers *? +? ?? {n,m}? partial bytes/char via the Pike VM (§11.1); on token, or in a stage with embedding actions → E720
Bounded repetition {n} {n,} {n,m}  
Anchors ^ $ \A \z edges on the pure-DFA path; interior anchors via the Pike VM’s zero-width Assert (§6.6/§11.1)
Word boundaries \b \B edges (bytes) on the DFA path; interior, and char (Unicode \w), via the Pike VM (§6.6); token is E720
Unicode classes \p{...} \P{...} partial char alphabet, opt-in @@[allow(unicode_classes)] (§6.7/§11.6)
Shorthands \d \w \s ASCII on bytes; Unicode-equivalent sets on char (§6.7)
Inline flags (?i) (?m) (?s), case-insensitive bytes/char; leading (?ims) and scoped (?ims:…)/(?-i:…), baked into the AST at parse time (§6.5/§6.6)
Empty pattern (ε) deviation RE2 accepts "" as the language {ε}; Frame rejects it (E723, engine-internal) — a recognizer stage matching ε consumes no input (degenerate; non-advance hazard in loops). Unwritable in source anyway: // is a comment (§3.5 / framec#163)
Named captures (?P<n>...) deviation replaced by stage labels (§3.5.2)
Non-capturing group (?:...) deviation (...) already doesn’t capture
Backreferences \1, recursion (?R), lookaround (?=)/(?<=) excluded non-regular (lookaround is also unsupported by RE2); E720

The dialect is now at RE2 parity across the regular-language feature set. Residual scope limits, by design rather than missing machinery: \p{} requires the char alphabet and the @@[allow(unicode_classes)] opt-in (the bytes alphabet has no codepoint notion); lazy quantifiers, interior anchors/boundaries, and (?m) line anchors are E720 on the token alphabet (no scalar element); and a lazy quantifier combined with in-stage embedding actions is rejected (§11.1).

7. Action vocabulary and built-ins

7.1 The @@: context vocabulary

Within an fsm body, @@: refers to the self context. @@fsm introduces a recognizer-specific set of @@: probes for cursor inspection and matched-input retrieval. Only @@:return and @@:(expr) are shared with @@system; the rest are introduced by this construct.

Probe Type Meaning Mutable Origin
@@:cursor int Current input position (0-indexed) no @@fsm
@@:fc element type The current input element no @@fsm
@@:peek(n: int) element type The element n ahead (out-of-bounds returns the alphabet’s zero value) no @@fsm
@@:remaining int Number of elements after the cursor no @@fsm
@@:at_end bool True if cursor is at end of input no @@fsm
@@:matched input slice type Elements matched by the immediately-preceding stage; the empty slice of the alphabet’s collection type when no stage has yet completed in the current match no @@fsm
@@:return declared return type The fsm’s return slot yes shared with @@system
@@:(expr) Set @@:return (concise form) n/a shared with @@system

The cursor is read-only: recognition advances monotonically through the input, and no probe is provided to move the cursor backward or forward without consuming.

Stage captures use the addressing form $state.label rather than the probe family.

7.2 Frame built-in functions

@@fsm examples and idiomatic usage assume a small set of Frame-provided built-in functions that map to target-language equivalents. The framepiler emits target-appropriate implementations.

Built-in Signature Meaning
to_int(s) (bytes \| string) -> int Parse a numeric slice to int. The input is parsed as a base-10 signed integer; leading whitespace and a leading sign are accepted. If the input contains any non-digit character (other than a leading sign), the result is 0 and the host instance’s accepted field is not modified — to_int does not by itself cause recognition failure. Authors who need parse-failure semantics should constrain the input via the regex stage before calling to_int.
to_str(x) (any) -> string Target-native string representation of a value.
len(s) (slice) -> int Length of an input slice in alphabet elements (bytes for byte alphabet, code points for char alphabet, tokens for token alphabet).

User-provided host-language functions may be called from action bodies with the same syntax; the framepiler treats them as native calls.

8. Composition

@@fsm composes with @@system and with other @@fsm declarations through three modes.

8.1 Mode A — called from @@system

A @@system handler may invoke an @@fsm as a function call. The expression @@FsmName(input, args) evaluates to the finished fsm instance, accessible via the target’s natural attribute syntax.

@@system Parser {
    interface:
        bytes_received(buf: bytes)

    machine:
        $Receiving {
            bytes_received(buf) {
                m = @@HeaderParser(buf)
                self.header = m.return_value
                self.consumed = m.cursor
                if m.accepted {
                    -> $Body
                } else {
                    -> $Error
                }
            }
        }
        $Body { ... }
        $Error { ... }

    domain:
        header: Header = Header.Invalid
        consumed: int = 0
}

For return types that carry their own success/failure semantics (Result<T, E>, Option<T>), the host may pattern-match on m.return_value directly without consulting m.accepted.

8.2 Mode B — embedded as a state’s recognition step

A @@system state may invoke an fsm and dispatch on its result:

@@system Parser {
    interface:
        scan()

    machine:
        $Scanning {
            $> {
                m = @@HeaderParser(self.input_buffer)
                if m.accepted {
                    self.header = m.return_value
                    -> $Body
                } else {
                    -> $Error
                }
            }
        }
        $Body { ... }
        $Error { ... }

    domain:
        input_buffer: bytes = ""
        header: Header = Header.Invalid
}

In v0.1, the fsm consumes its input buffer at construction time; the @@system state observes the resulting instance and dispatches on it. Incremental delivery (feeding the fsm element-by-element as the host state receives events) is not supported in v0.1.

8.3 Mode C — referenced from another @@fsm

A @@fsm’s regex stage may invoke another @@fsm by name using /@FsmName/ syntax:

@@fsm Digit(input: char) : int = 0 {
    /[0-9]/ to_int(@@:matched)
}

@@fsm Pair(input: char) : (int, int) = (0, 0) {
    $pair:
        .a/@Digit/  /,/  .b/@Digit/
        ($pair.a.return_value, $pair.b.return_value)
}

Mode C semantics:

  • Call-out semantics (v0.1). When recognition reaches the /@Inner/ stage position, the outer fsm pauses, constructs an Inner instance over the input at the current cursor, runs Inner to completion, advances the outer’s cursor by Inner’s cursor, then resumes. $state.label.return_value reads from the Inner instance. True DFA integration (splicing Inner’s compiled DFA into the outer’s DFA) is reserved for v0.2 (§11) — observable semantics are identical; the v0.1 implementation pays a per-invocation allocation cost in exchange for a tractable initial cut.
  • A stage reference to a /@FsmName/ stage exposes two distinct values via chained-dot access:
    • $state.label — the matched elements (input slice type).
    • $state.label.return_value — the inner fsm’s @@:return value.
  • Inner and outer fsms must declare compatible alphabets. Mixing alphabets is E731.
  • The referenced fsm must be statically resolvable; dynamic dispatch is E732.

9. Errors and diagnostics

9.1 Errors

Code Meaning
E700 Construct identity (general structural error)
E701 Match can fail but has no failure_branch; or use of $()/$>() parameter sigil in @@fsm header
E703 Read of undeclared variable
E704 Write to undeclared variable; or two consecutive unlabeled states; or stage-capture reference to a state with no declared label
E705 Missing return type, default value, or domain initializer
E706 Type mismatch in assignment or return
E707 Explicit domain field type mismatches parameter of same name
E710 Block order violation
E711 Duplicate block
E712 Transition statement in actions: body
E713 Input parameter type is not bytes, char, or token
E715 conditional_target alternative is missing its when guard
E720 Forbidden regex construct (non-regular constructs like backreferences and recursion; or regular-but-excluded-in-v0.1 constructs like lookahead and Unicode classes)
E721 DFA size exceeds configured limit
E722 Invalid regex syntax for the current alphabet
E723 Empty regex where non-empty required — engine-internal: // is lexically a line comment (§3.5 / RFC-0043), so an empty regex cannot be written in source; the check guards the compile API (framec#163)
E730 Stage label collision within a state
E731 Mode C alphabet mismatch between inner and outer fsm; or reference to undeclared state
E732 Mode C reference to dynamically-determined fsm; or reference to undeclared stage

9.2 Warnings

Code Meaning
W701 Conditional target with no matching condition may produce silent reject
W702 Unused parameter
W703 Unused domain variable
W704 DFA size approaching configured limit
W705 Constant-true when guard; use unguarded form or move target to failure branch

9.3 Reported properties

For each compiled fsm, the framepiler reports:

  • DFA state count (post-minimization).
  • DFA element-class fan-out (max transitions per state).
  • Statically-enumerated transition target set.
  • Worst-case input position per element.

These properties are emitted as diagnostic output and as IR annotations.

10. Examples

10.1 Minimal recognizer

@@fsm RecognizeFoo(text: bytes) : bool = false {
    /foo/ true
}

10.2 Parsing with named captures

@@fsm ParseDate(input: char) : Date = Date.Invalid {

    $parse:
        .y/[0-9]{4}/
        /-/
        .m/[0-9]{2}/
        /-/
        .d/[0-9]{2}/
        Date(to_int($parse.y), to_int($parse.m), to_int($parse.d))
}

10.3 Multi-state with conditional target

@@fsm Mux(buf: bytes, mode: int) : Result = Result.Invalid {

    /[TB]/ -> ( $TextStream when self.mode == 0,
                $BinaryStream when self.mode == 1 )
              : -> $Error

    $TextStream: /[^\0]*\0/  Result.Text($TextStream.0)
    $BinaryStream: /.{16}/   Result.Binary($BinaryStream.0)
    $Error: Result.Invalid
}

10.4 Token-stream fsm

@@fsm IsCallExpr(toks: token) : bool = false {

    /IDENT LPAREN (expr (COMMA expr)*)? RPAREN/ true
}

10.5 Fully-loaded fsm with all action types

@@fsm ParseQuoted(text: bytes) : string = "" {

    /"/  >{ self.start_pos = @@:cursor }
         @{ self.at_open = true }

    /[^"]+/  ${ self.char_count = self.char_count + 1 }
             %{ self.content_end = @@:cursor }

    /"/  { record_close() }

    summarize()

    actions:
        record_close() { self.at_close = true }
        summarize(): string {
            "match: " + to_str(self.char_count) + " chars from " + to_str(self.start_pos)
        }

    domain:
        start_pos: int = 0
        content_end: int = 0
        char_count: int = 0
        at_open: bool = false
        at_close: bool = false
}

10.6 HTTP start-line scanner

@@fsm HttpStart(text: bytes) : Header = Header.Invalid {

    $start:
        .v/HTTP\/[0-9]\.[0-9]/
        / /
        .s/[0-9]{3}/
        /\r\n/
        Header(version: $start.v, status: parse_status($start.s))
          : -> $error

    $error: Header.Invalid

    actions:
        parse_status(s: bytes): int { to_int(s) }
}

10.7 Cascading dispatch recognizer

A recognizer that classifies an input among several known patterns by chaining failure_branch transitions:

@@fsm ContentType(buf: bytes) : MediaType = MediaType.Unknown {

    /application\/json/ -> $accept_json : -> $check_html
    $check_html: /text\/html/ -> $accept_html : -> $check_plain
    $check_plain: /text\/plain/ -> $accept_plain : -> $unknown

    $accept_json: MediaType.Json
    $accept_html: MediaType.Html
    $accept_plain: MediaType.Plain
    $unknown: MediaType.Unknown
}

Each terminal state is an implicit-terminal match (a single bare expression with no transition). The recognizer always reaches one of the four terminal states; accepted is true in all four cases (recognition completes), and return_value reflects the matched media type.


11. Open extensions

Items deliberately deferred from v0.1. Each is sketched here so future work has a starting point.

11.1 Lazy quantifiers (*?, +?, ??)

v0.1 forbade lazy quantifiers (§6.5; E720). Implemented in v0.2 (bytes/char alphabets) per the plan below: a per-stage check marks regexes containing a lazy quantifier; those stages compile to a priority-ordered NFA program run by a small backtrack-free Pike VM (leftmost-first / Perl semantics — a Split’s first target is higher priority, so a greedy quantifier prefers “repeat” and a lazy one prefers “exit”), while greedy-only stages stay on the pure DFA path. The program is a flat integer array (encoded to ops/ranges) and the VM (~40 lines) is emitted into every backend, exactly as the DFA path is. RE2’s hybrid NFA/DFA architecture is the standard reference. Out of scope: lazy on the token alphabet (no scalar element notion → E720) and lazy in a stage with embedding actions (the VM has no per-element scan to hook into).

11.2 True Mode C DFA integration

v0.1 implements Mode C (§8.3) as a call-out: when the outer fsm reaches /@Inner/, it constructs an Inner instance, runs it, then resumes. v0.2 may replace this with inline DFA integration — splicing Inner’s compiled DFA into the outer’s DFA at the stage position, with Inner’s accepting states becoming “stage complete” transitions in the outer. The result is a single combined DFA per outer fsm; observable semantics are identical to the v0.1 call-out form. The optimization is worth pursuing when Mode C is nested deeply enough that per-invocation allocation becomes measurable.

11.3 Streaming / incremental drive (step(), run(), finish())

v0.1 fsms consume their input at construction time and run to completion. Some applications need incremental delivery — recognition that proceeds as bytes arrive from the network, with the host pausing the fsm between deliveries. This requires:

  • A third instance state besides accepted / failed: in-flight.
  • An API surface — step(buf), finish(), or similar — that lets the host advance recognition incrementally.
  • A persisted DFA-state representation across calls.

This is a substantial extension. Defer to a dedicated streaming RFC.

Narrowed by RFC-0042.1. The suspend/resume across partial deliveries half above (the in-flight state, step()/finish(), persisted DFA-state) remains deferred here. The simpler single-shot scan over a fully-present, host-owned source (an owned buffer, a borrowed slice, or a host callback) via over/scan_at — recognition starting at a host cursor with no suspended state, the lexer-primitive case — is specified by RFC-0042.1 and is not part of this deferred item.

11.4 stream<T> input type

Companion to §11.3. An fsm whose first parameter is stream<T> rather than a buffer. Depends on the streaming RFC.

11.5 Lookahead ((?=foo), (?!foo))

Fixed-width lookaround is regular and DFA-compileable; v0.1 forbids it (§6.5) to keep DFA sizes bounded. v0.2 may admit it with explicit cost reporting via §9.3.

11.6 Unicode general-category / script classes

\p{L}, \p{N}, \p{Greek}, etc. — regular but table-heavy. v0.1 forbade them (§6.5). Implemented in v0.2: admitted per-fsm behind the opt-in attribute @@[allow(unicode_classes)], on the char alphabet only. \p{Name} / \P{Name} resolve to codepoint ranges at compile time (sourced from Unicode tables) and feed the existing range-based DFA engine — the recognizer carries no Unicode tables of its own. Without the attribute the construct is E720; on bytes/token it is E722. This is the only @@fsm feature behind an opt-in attribute, and it established the @@[...] construct-attribute path in the dogfooded fsm parser.

11.7 Multi-error collection

v0.1 fsms produce a single verdict — accepted true or false. Some recognizers want to report all stage failures in a single pass for diagnostic UIs. Requires an errors: list<...> field on the instance and a non-terminating failure mode.

11.8 Persistence (@@[persist] / @@[web_persist])

In-flight fsms are not eligible for @@[persist] or @@[web_persist] in v0.1 — recognition is construction-time and the instance is intended to be consumed by the host before garbage collection. A persisted in-flight form would depend on the streaming RFC (§11.3).

11.9 Per-stage regex-mode attributes

@@[multiline] is fsm-scoped in v0.1 (§6.6). Per-stage attributes would let one stage match across newlines while another does not.

11.10 @@:emit(...) for streaming output

A future operator letting recognition emit values incrementally during the match, rather than only at the tail bare-expression. Depends on the streaming RFC.

11.11 Higher-order operations

Fsms as first-class values passed to other fsms — generalizing Mode C. Out of scope until Mode C’s inline-DFA form (§11.2) lands.

11.12 @@system adoption of RFC-0050

@@system handler bodies retain native passthrough in v0.1. A future RFC may opt @@system into RFC-0050’s statement parsing for action bodies or specific positions; out of scope here.


12. Implementation status

This section is non-normative; it records the state of the framepiler’s @@fsm code generation. The normative content of this RFC is §§1–11.

v0.1 code generation is complete across all 17 framec target languages, each at full parity with the Python reference backend. Python is the reference implementation of the recognition model (§5); the other sixteen backends mirror it. Each @@fsm backend is self-contained — it does not reuse the @@system CodegenNode pipeline (§7) — and is validated by generating recognizer source and executing it through that language’s real toolchain.

Backend Status
Python ✓ v0.1 — reference implementation
Rust ✓ full parity
Erlang ✓ full parity
JavaScript ✓ full parity
TypeScript ✓ full parity
Go ✓ full parity
Ruby ✓ full parity
PHP ✓ full parity
Dart ✓ full parity
Lua ✓ full parity
Java ✓ full parity
Kotlin ✓ full parity
C# ✓ full parity
Swift ✓ full parity
C++ ✓ full parity
C ✓ full parity
GDScript ✓ full parity

“Full parity” means every v0.1 construct in this RFC is generated and runs: single- and multi-match (|) states, .label captures, bare-expression returns, action blocks, declared actions: methods, all transition forms (static, conditional when, stage-ref, failure-only), all five embedding actions (>/@/$/%/@eof), all three alphabets (bytes/char/token), Mode C call-out (§8.3), the edge anchors of §6.6 — leading/trailing ^/$/\A/\z plus, on the bytes alphabet, leading/trailing \b/\B word boundaries (enforced by the matcher against the live cursor) — and, on the char alphabet behind @@[allow(unicode_classes)], \p{...}/\P{...} Unicode classes (§6.7/§11.6), resolved to codepoint ranges at compile time, and lazy quantifiers (*?/+?/…, §11.1) on bytes/char via a per-stage Pike VM for leftmost-first match-end semantics (greedy stays pure-DFA).

The per-backend code-generation strategy — how each language represents the compiled DFA (nested arrays in dynamic targets, parallel transition/accept arrays in statically-typed targets, a struct + free functions over static const tables in C, a map threaded through per-state functions in Erlang, inner classes in GDScript) — is documented in the module-level doc comments of the respective fsm_<lang>.rs generators in the framepiler source, not in this RFC.

A small number of constructs remain unimplemented in every backend and emit a clear, uniform error (never a silent miscompile): interior (non-edge) anchors and word boundaries, \b/\B on the char/token alphabets (which need a Unicode word classification — §11.6), lazy quantifiers on the token alphabet or in a stage with embedding actions (§11.1), a Mode C stage used as a | alternative selector, and a | alternative with elements preceding its first stage. These are the remaining deferrals of §6.5 and the composition restrictions of §8, surfaced at compile time. (Edge ^/$/\A/\z and, on bytes, edge \b/\B are supported — extracted to position requirements the matcher enforces.)

@@fsm has no Graphviz emitter; the graphviz target applies to @@system only.


Validation Tests

This section specifies the conformance test suite for @@fsm. Each test has a stable identifier (FSM-TEST-NNN), a code sample, and a structured description of expected behavior.

Test entry conventions:

  • Expected outcome — does the fsm compile, does construction succeed, what verdict is produced.
  • Diagnostic location (for error tests) — where in the source the framepiler should anchor its error report.
  • Diagnostic content (for error tests) — what information the error message must convey.
  • Recovery hint (for error tests) — the direction the user should be pointed toward to fix the issue.
  • Fatal (for error tests) — whether compilation halts at this error or continues to find more.
  • Example diagnostic text — non-normative sample rendering of the error message. Implementers may vary the exact wording, but the location and content requirements are normative.
  • Behavior (for success tests) — observable instance state after construction.
  • What this test verifies — the spec properties under test.

Construction and structure (§3)

FSM-TEST-001 — Minimal fsm compiles

Context: The smoke test: the minimum viable @@fsm. Pins down that auto-promotion of the input parameter and the bare-expression-as-return both work without any explicit domain: or actions: block.

@@fsm M(text: bytes) : bool = false { /a/ true }

Expected outcome: Compiles. Construction with various inputs yields the expected verdicts.

Behavior:

  • Assert(@@M("a").accepted == true)
  • Assert(@@M("a").return_value == true)
  • Assert(@@M("a").cursor == 1)
  • Assert(@@M("b").accepted == false)
  • Assert(@@M("b").return_value == false)
  • Assert(@@M("b").reject_position == 0)
  • Assert(@@M("").accepted == false)
  • Assert(@@M("").reject_position == 0)

What this test verifies:

  • The minimum viable fsm header form parses (input parameter, return type, default).
  • A single-stage match with a bare-expression return compiles to a working recognizer.
  • The auto-domain-promotion of the text parameter works without an explicit domain: block.
  • The boolean accepted/reject_position fields populate correctly in both success and failure cases.

FSM-TEST-002 — Missing return type rejected

Context: Guards the mandatory-typing commitment: every @@fsm declares its return type explicitly. The parser must recognize the absent : type annotation and report it cleanly rather than failing further downstream.

@@fsm M(text: bytes) = false { /a/ true }

Expected outcome: Compilation fails with E705.

Diagnostic location: The = token following the parameter list, where the return type annotation : type should appear.

Diagnostic content:

  • States that an @@fsm declaration requires a return type before the default value.
  • Names the construct kind (@@fsm M).

Recovery hint: Suggest inserting : <type> between the parameter list and the = default clause.

Fatal: Yes.

Example diagnostic text (non-normative):

error[E705]: @@fsm declaration is missing return type
  @@fsm M(text: bytes) = false { /a/ true }
                       ^ expected `:` followed by a return type
help: add a return type annotation before the default value:
      @@fsm M(text: bytes) : bool = false { /a/ true }

FSM-TEST-003 — Missing default rejected

Context: Companion to FSM-TEST-002. The default value is also mandatory; the spec does not infer one even for types like bool where the conventional default would be obvious.

@@fsm M(text: bytes) : bool { /a/ true }

Expected outcome: Compilation fails with E705.

Diagnostic location: The opening { of the body, where the = default_expr clause should have appeared.

Diagnostic content:

  • States that an @@fsm declaration requires a mandatory default value for the return type.
  • Names the declared return type (bool).

Recovery hint: Suggest a sensible default for the declared type (e.g., false for bool, 0 for int, "" for string).

Fatal: Yes.

Example diagnostic text (non-normative):

error[E705]: @@fsm declaration is missing the mandatory default value
  @@fsm M(text: bytes) : bool { /a/ true }
                              ^ expected `= <default>` before the body
help: every @@fsm return slot requires an explicit default to define
      the value returned on failure paths. For `bool`, write `= false`.

FSM-TEST-004 — Implicit start state with one match

Context: Tests that a body containing only a match (no explicit state label) is valid; the match is the start state’s match.

@@fsm M(text: bytes) : int = 0 { /[0-9]/ to_int(@@:matched) }

Expected outcome: Compiles. Construction produces expected results.

Behavior:

  • Assert(@@M("7").accepted == true)
  • Assert(@@M("7").return_value == 7)
  • Assert(@@M("a").accepted == false)
  • Assert(@@M("a").reject_position == 0)

What this test verifies:

  • A body with no $label: declaration treats its match as the start state’s match.
  • @@:matched returns the bytes consumed by the immediately-preceding stage.
  • The built-in to_int converts a bytes-slice to int.

FSM-TEST-005 — Auto-domain field accessible as self.text

Context: Pins down that the input parameter auto-promotes to a domain field accessible as self.<name>. Specifically: self.text is the full input buffer, distinct from @@:matched (the immediately-preceding stage’s bytes).

@@fsm M(text: bytes) : int = 0 {
    /[0-9]+/ len(self.text)
}

Expected outcome: Compiles.

Behavior:

  • Assert(@@M("123").return_value == 3)len(self.text) returns the length of the full input, not just the matched portion.
  • Assert(@@M("123456789").return_value == 9).

What this test verifies:

  • The first parameter is auto-promoted to a domain field of the same name.
  • The auto-generated field is accessible via self.<name> throughout the body.
  • The built-in len() operates on the alphabet’s slice type.

FSM-TEST-006 — Explicit labeled state with transition

Context: Tests multi-state navigation: a labeled start state, a static transition on success, a labeled error state. Numeric labels ($0) are valid identifiers, not magic names.

@@fsm M(text: bytes) : int = 0 {
    $0: /[a-z]/ -> $digits : -> $error
    $digits: .n/[0-9]+/ to_int($digits.n)
    $error: -1
}

Expected outcome: Compiles.

Behavior:

  • Assert(@@M("x42").accepted == true) — first stage matches x, transitions to $digits, which matches 42. The terminal stage’s bare expression assigns to_int($digits.n) == 42 to @@:return.
  • Assert(@@M("x42").return_value == 42).
  • Assert(@@M("X").accepted == false)X doesn’t match /[a-z]/; transitions to $error.
  • Assert(@@M("X").return_value == -1).
  • Assert(@@M("3").accepted == false) — first stage /[a-z]/ fails on a digit; transitions to $error.
  • Assert(@@M("3").return_value == -1).

What this test verifies:

  • Numeric labels ($0:) are valid identifiers.
  • Static transition targets work in both success and failure branches.
  • A terminal state can have a bare-expression match.
  • to_int operates correctly on a regex-constrained numeric capture.

FSM-TEST-007 — Stage label and capture

Context: Tests the named-stage capture mechanism: .x/regex/ captures the matched bytes under the label x, addressable as $state.x from within the same state.

@@fsm M(text: bytes) : bytes = "" {
    $main: .x/[0-9]+/ $main.x
}

Expected outcome: Compiles.

Behavior:

  • Assert(@@M("123").return_value == "123"). The stage .x/[0-9]+/ captures "123"; the bare expression $main.x reads it.
  • Assert(@@M("123abc").return_value == "123")
  • Assert(m.cursor == 3)
  • Assert(@@M("abc").accepted == false)
  • Assert(m.reject_position == 0)
  • Assert(m.return_value == "") (declared default).

What this test verifies:

  • A labeled stage is addressable as $state.label from within the enclosing state.
  • Stage capture is the bytes consumed by the stage’s regex match.

FSM-TEST-008 — Unlabeled state cannot be referenced

Context: Guards the design that there is no implicit $0 magic name. A user who writes $0.x referring to an unlabeled state gets a clear error rather than a phantom successful compile against a synthesized label.

@@fsm M(text: bytes) : bytes = "" {
    .x/[0-9]+/ $0.x
}

Expected outcome: Compilation fails with E704.

Diagnostic location: The expression $0.x in the bare-expression position.

Diagnostic content:

  • States that a stage-capture reference requires the enclosing state to carry an explicit label.
  • Names the absent label ($0).
  • Optionally names the referenced stage label (.x).

Recovery hint: Suggest declaring an explicit $0: (or other identifier) label on the state, or referring to the capture by a different means.

Fatal: Yes.

Example diagnostic text (non-normative):

error[E704]: stage-capture reference to unlabeled state
  .x/[0-9]+/ $0.x
             ^^^^ refers to a state named `$0`, but the enclosing
                  state has no declared label
help: add a label to the state to make its captures referenceable:
      $0: .x/[0-9]+/ $0.x

FSM-TEST-009 — State-arg sigil rejected in fsm header

Context: Guards the v0.1 design decision that @@fsm does not support state-arg or enter-arg parameters (no compartments → nothing to receive them). Writers familiar with @@system’s three-sigil taxonomy should not assume it carries over.

@@fsm M($(state_arg: int), text: bytes) : bool = false { /a/ true }

Expected outcome: Compilation fails with E701.

Diagnostic location: The $( sigil opening in the parameter list.

Diagnostic content:

  • States that @@fsm does not support $(...) state-arg or $>(...) enter-arg parameter forms.
  • Explains briefly that @@fsm has no compartments to receive these.

Recovery hint: Suggest converting state-arg or enter-arg parameters to bare parameters (which auto-promote to domain).

Fatal: Yes.

Example diagnostic text (non-normative):

error[E701]: `$()` parameter sigil is not allowed in @@fsm
  @@fsm M($(state_arg: int), text: bytes) : bool = false { /a/ true }
          ^^^^^^^^^^^^^^^^^^ @@fsm has no compartments; state-args
                              have no destination in this construct
help: declare the parameter as a bare (domain) parameter instead:
      @@fsm M(state_arg: int, text: bytes) : bool = false { /a/ true }

FSM-TEST-010 — Input parameter type validation

Context: Pins down that the input parameter type is restricted to bytes, char, or token. The framepiler does not synthesize alphabet handling for arbitrary types.

@@fsm M(text: float) : bool = false { /a/ true }

Expected outcome: Compilation fails with E713.

Diagnostic location: The type annotation float of the first parameter.

Diagnostic content:

  • States that the first parameter of an @@fsm must be one of bytes, char, or token.
  • Names the declared type (float).
  • Names the parameter (text).

Recovery hint: Suggest the three valid alphabet types and what each is for (bytes for byte streams, char for Unicode, token for token streams).

Fatal: Yes.

Example diagnostic text (non-normative):

error[E713]: invalid input parameter type
  @@fsm M(text: float) : bool = false { /a/ true }
                ^^^^^ the input parameter (first positional) must be
                       `bytes`, `char`, or `token`; got `float`
help: pick the alphabet appropriate for your recognizer:
      - `bytes`  for byte streams (HTTP, binary protocols)
      - `char`   for Unicode text
      - `token`  for token streams (parser front-ends)

FSM-TEST-011 — Explicit override of parameter-derived domain field

Context: Tests that an explicit domain: field can override the auto-promotion’s verbatim copy, useful when the writer wants the field to derive from the parameter rather than equal it.

@@fsm M(text: bytes, initial: int = 0) : int = 0 {
    /[0-9]/ to_int(@@:matched) + self.initial
    domain:
        initial: int = initial * 2
}

Expected outcome: Compiles.

Behavior:

  • Assert(@@M("5", 10).return_value == 25)self.initial was overridden to initial * 2 == 20; the bare expression adds matched digit (5) + 20 = 25.

What this test verifies:

  • An explicit domain: declaration of a parameter-named field with a matching type overrides the auto-generated name = name initializer.
  • The explicit initializer expression has access to the bare parameter name.
  • The explicit field is then accessed throughout the body as self.<name> per Frame convention.

FSM-TEST-012 — Type mismatch in explicit redeclaration

Context: Guards consistency: an explicit domain field redeclaring a parameter must have a matching type. A writer who accidentally declares text: int = 5 when the parameter is text: bytes gets a clean error.

@@fsm M(text: bytes) : bool = false {
    /a/ true
    domain:
        text: int = 5
}

Expected outcome: Compilation fails with E707.

Diagnostic location: The text: int = 5 declaration in the domain block.

Diagnostic content:

  • States that a domain field redeclaring a parameter name must have a matching type.
  • Names the parameter’s type (bytes).
  • Names the conflicting domain declaration type (int).

Recovery hint: Suggest either matching the parameter’s type, or renaming the domain field.

Fatal: Yes.

Example diagnostic text (non-normative):

error[E707]: explicit domain field type mismatches parameter
  @@fsm M(text: bytes) ...
                ^^^^^ parameter `text` has type `bytes`
  ...
      domain:
          text: int = 5
          ^^^^^^^^^ but domain redeclares `text` with type `int`
help: either match the parameter's type (`text: bytes = ...`),
      or rename the domain field to avoid the collision.

FSM-TEST-013 — Two consecutive unlabeled states rejected

Context: Guards the rule that only the first state in a body may be unlabeled. Two unlabeled states would have no syntactic separator; the parser cannot distinguish where one ends and the next begins.

@@fsm M(text: bytes) : bool = false {
    /a/ -> $next : -> $error
    /b/ true              // second unlabeled state
    $error: false
}

Expected outcome: Compilation fails with E704.

Diagnostic location: The opening / of the second unlabeled state’s first stage.

Diagnostic content:

  • States that only the first state in an fsm body may be unlabeled.
  • Notes the position of the prior unlabeled state.
  • Indicates that the parser cannot determine state boundaries without labels.

Recovery hint: Suggest adding a $label: to the second state.

Fatal: Yes.

Example diagnostic text (non-normative):

error[E704]: second unlabeled state in fsm body
  /a/ -> $next : -> $error
                            <-- first unlabeled state (start state)
  /b/ true
  ^^^ this match begins a second state, but it has no `$label:`
help: only the first state may be unlabeled. Add a label to disambiguate:
      $second: /b/ true

FSM-TEST-014 — Numeric label valid

Context: Tests numeric labels ($0, $1) as valid state identifiers — they are not reserved or magic, just identifiers that happen to be numeric. Also exercises self-loop transitions (failure-branch routing back to the same state).

@@fsm M(text: bytes) : bool = false {
    $0: /a/ -> $1 : -> $0
    $1: /b/ true
}

Expected outcome: Compiles.

Behavior:

  • Assert(@@M("ab").accepted == true) — first stage matches a, transitions to $1, which matches b.
  • Assert(@@M("ab").return_value == true).

What this test verifies:

  • Numeric labels ($0, $1) parse as valid state identifiers.
  • A failure_branch can target the enclosing state itself (self-loop syntax is admitted; semantics of unbounded self-loops on non-consuming failure are out of scope for this test).

Block ordering (§3.3)

FSM-TEST-020 — Canonical order compiles

Context: Smoke test for the canonical block order: states → actions → domain. All three section types in their canonical order should parse cleanly.

@@fsm M(text: bytes) : int = 0 {
    /[0-9]/ to_int(@@:matched)

    actions:
        helper(): int { 42 }

    domain:
        n: int = 0
}

Expected outcome: Compiles.

Behavior:

  • Assert(@@M("5").return_value == 5).

What this test verifies:

  • The canonical block order (states → actions → domain) is accepted.
  • A fully-loaded fsm with all three section types parses correctly.

FSM-TEST-021 — Domain before states rejected

Context: Guards the canonical order: domain: must follow states. A writer who puts domain: first gets a clear error rather than a confusing parse failure.

@@fsm M(text: bytes) : int = 0 {
    domain:
        n: int = 0
    /[0-9]/ to_int(@@:matched)
}

Expected outcome: Compilation fails with E710.

Diagnostic location: The domain: keyword.

Diagnostic content:

  • States that domain: must follow all state declarations.
  • Names the canonical order: states → actions → domain.

Recovery hint: Suggest moving the domain: block to after the states.

Fatal: Yes.

Example diagnostic text (non-normative):

error[E710]: block out of canonical order
  domain:
  ^^^^^^^ `domain:` must follow all state declarations
help: the canonical block order is:
      <states>  →  actions  →  domain
      Move the `domain:` block to after the state declarations.

FSM-TEST-022 — Actions before states rejected

Context: Companion to FSM-TEST-021: same canonical-order rule, applied to actions:.

@@fsm M(text: bytes) : int = 0 {
    actions:
        helper(): int { 1 }
    /[0-9]/ helper()
}

Expected outcome: Compilation fails with E710.

Diagnostic location: The actions: keyword.

Diagnostic content:

  • States that actions: must follow all state declarations.

Recovery hint: Move the actions: block to after the states.

Fatal: Yes.


FSM-TEST-023 — Duplicate domain block rejected

Context: Each optional block may appear at most once. Splitting domain declarations across two blocks is rejected.

@@fsm M(text: bytes) : int = 0 {
    /[0-9]/ to_int(@@:matched)
    domain:
        a: int = 0
    domain:
        b: int = 0
}

Expected outcome: Compilation fails with E711.

Diagnostic location: The keyword of the second domain: block.

Diagnostic content:

  • States that each optional block (domain, actions) may appear at most once.
  • Notes the position of the first occurrence.

Recovery hint: Suggest combining the two blocks into one.

Fatal: Yes.

Example diagnostic text (non-normative):

error[E711]: duplicate `domain:` block
  domain:                  <-- first declaration here
      a: int = 0
  domain:
  ^^^^^^^ second `domain:` block; only one allowed per fsm
help: combine fields into a single block:
      domain:
          a: int = 0
          b: int = 0

Frame statement syntax (§3.6)

FSM-TEST-030 — Multi-statement block with semicolons

Context: Tests the standard ;-separated multi-statement form within an action block.

@@fsm M(text: bytes) : int = 0 {
    /[0-9]/ { self.count = self.count + 1; self.flag = true }
    self.count
    domain:
        count: int = 0
        flag: bool = false
}

Expected outcome: Compiles.

Behavior:

  • Assert(@@M("5").return_value == 1)
  • Assert(m.flag == true)
  • Assert(m.count == 1)

What this test verifies:

  • Multiple statements in a single action block, separated by ;, parse correctly.
  • Domain fields are mutable from within action blocks.

FSM-TEST-031 — Multi-statement block whitespace-separated

Context: Companion to FSM-TEST-030: same logic but whitespace-separated instead of ;-separated. Both forms are valid per the whitespace-agnostic statement syntax rule.

@@fsm M(text: bytes) : int = 0 {
    /[0-9]/ {
        self.count = self.count + 1
        self.flag = true
    }
    self.count
    domain:
        count: int = 0
        flag: bool = false
}

Expected outcome: Compiles, semantically equivalent to FSM-TEST-030.

Behavior:

  • Assert(@@M("5").return_value == 1)
  • Assert(m.flag == true)
  • Assert(m.count == 1)

What this test verifies:

  • Whitespace (including newlines) can separate statements in lieu of ;.
  • The whitespace-agnostic statement separator works.

FSM-TEST-032 — If/else statement

Context: Tests the standard if/else form: condition unparenthesized, blocks brace-delimited.

@@fsm M(text: bytes) : int = 0 {
    /[0-9]/ {
        if to_int(@@:matched) > 5 {
            self.flag = true
        } else {
            self.flag = false
        }
    }
    to_int(@@:matched)
    domain:
        flag: bool = false
}

Expected outcome: Compiles.

Behavior:

  • Assert(@@M("7").return_value == 7)
  • Assert(m.flag == true)
  • Assert(@@M("3").return_value == 3)
  • Assert(m.flag == false)

What this test verifies:

  • The if ... else form with brace blocks parses.
  • Conditions are evaluated unparenthesized.
  • The bare-expression return continues to work after an action block.

FSM-TEST-033 — Bare name does not refer to domain

Context: Pins down that bare identifiers do not refer to domain fields outside of initializer expressions. A writer accustomed to languages without explicit self/this prefixes will hit this error early; the diagnostic should suggest the self. form.

@@fsm M(text: bytes) : int = 0 {
    /[0-9]/ { count = count + 1 }
    self.count
    domain:
        count: int = 0
}

Expected outcome: Compilation fails with E703.

Diagnostic location: The bare identifier count in count = count + 1.

Diagnostic content:

  • States that bare names do not refer to domain fields outside of initializer expressions.
  • Notes that domain access requires self. prefix.
  • Names the apparent intent (count looks like the domain field self.count).

Recovery hint: Suggest self.count = self.count + 1.

Fatal: Yes.

Example diagnostic text (non-normative):

error[E703]: undeclared name `count`
  /[0-9]/ { count = count + 1 }
            ^^^^^ no name `count` is in scope here

note: there is a domain field `self.count`, but domain access
      requires the `self.` prefix outside of initializer expressions.
help: write `self.count = self.count + 1`

Typing and variable scope (§4.1, §4.2)

FSM-TEST-100 — Undeclared variable read

Context: Tests that reading an undeclared domain field is caught at compile time.

@@fsm M(text: bytes) : int = 0 { /a/ @@:return = self.undeclared }

Expected outcome: Compilation fails with E703.

Diagnostic location: The self.undeclared reference.

Diagnostic content:

  • States that no domain field named undeclared is declared.
  • Optionally lists known domain fields (including the auto-generated text).

Recovery hint: Suggest either declaring the field in the domain: block or correcting the name.

Fatal: Yes.

Example diagnostic text (non-normative):

error[E703]: read of undeclared domain field `undeclared`
  /a/ @@:return = self.undeclared
                  ^^^^^^^^^^^^^^^ no domain field by this name

note: declared domain fields: text (bytes, from parameter)
help: declare the field in a `domain:` block, or check for typos.

FSM-TEST-101 — Undeclared variable write

Context: Companion to FSM-TEST-100: the write side of the same property.

@@fsm M(text: bytes) : int = 0 { /a/ { self.undeclared = 5 } }

Expected outcome: Compilation fails with E704.

Diagnostic location: The self.undeclared = 5 assignment.

Diagnostic content:

  • States that no domain field named undeclared is declared.
  • Indicates that writing to undeclared fields is not permitted.

Recovery hint: Suggest declaring the field with appropriate type and initializer in domain:.

Fatal: Yes.


FSM-TEST-102 — Type mismatch on bare-expression @@:return

Context: Tests that the bare-expression-as-return form respects the declared return type — a string expression in an int-returning fsm is rejected at compile time, not silently coerced.

@@fsm M(text: bytes) : int = 0 { /a/ "string" }

Expected outcome: Compilation fails with E706.

Diagnostic location: The string literal "string" in the bare-expression position.

Diagnostic content:

  • Names the declared return type (int).
  • Names the type of the offending expression (string).
  • Names the assignment context (@@:return via implicit-tail-assignment).

Recovery hint: Suggest changing the return type, the expression, or making the assignment explicit so the intent is clear.

Fatal: Yes.

Example diagnostic text (non-normative):

error[E706]: type mismatch in implicit return assignment
  @@fsm M(text: bytes) : int = 0 { /a/ "string" }
                          ---            ^^^^^^^^
                          |              found `string`
                          declared return type is `int`
help: the bare expression at the cascade tail assigns to @@:return;
      provide an `int` value, or change the return type declaration.

FSM-TEST-103 — Domain field initializer references parameter

Context: Tests that a domain field’s initializer can reference a constructor parameter by bare name. Parameters are in scope only within initializer expressions, per Frame convention; after that, the same value is accessed via self.<name>.

@@fsm M(text: bytes, initial: int = 0) : int = 0 {
    /[0-9]/ { self.count = self.count + 1 }
    self.count
    domain:
        count: int = initial
}

Expected outcome: Compiles.

Behavior:

  • Assert(@@M("5", 10).return_value == 11)count initialized from initial (10), incremented once by the action.
  • Assert(@@M("55", 0).return_value == 2).

What this test verifies:

  • Parameter bare-names are accessible as initializer expressions in the domain: block.
  • A domain field can derive its initial value from a parameter without auto-promotion.

FSM-TEST-104 — Domain variable missing initializer

Context: Tests that every domain field requires an initializer; type alone is insufficient. This is the strict-defaults commitment that guards against uninitialized return paths.

@@fsm M(text: bytes) : int = 0 {
    /a/ 1
    domain:
        count: int
}

Expected outcome: Compilation fails with E705.

Diagnostic location: The count: int declaration in the domain block.

Diagnostic content:

  • States that every domain field requires a default initializer.
  • Names the field with the missing initializer.

Recovery hint: Suggest adding = <default> for the field.

Fatal: Yes.

Example diagnostic text (non-normative):

error[E705]: domain field `count` is missing its initializer
  domain:
      count: int
      ^^^^^^^^^^ every domain field requires `= <default>`
help: every variable in `@@fsm` must have an explicit default to ensure
      that no path returns an uninitialized value. For `int`, write `= 0`.

FSM-TEST-105 — Parameter accessible as self.name in body

Context: Tests that additional parameters auto-promote alongside the input parameter; multiple parameter values are all accessible via self.<name>.

@@fsm M(text: bytes, threshold: int = 10) : bool = false {
    /[0-9]+/ to_int(@@:matched) > self.threshold
}

Expected outcome: Compiles.

Behavior:

  • Assert(@@M("100", 5).return_value == true) (100 > 5).
  • Assert(@@M("10", 50).return_value == false) (10 > 50 is false).

What this test verifies:

  • Additional parameters beyond the input are auto-promoted to domain fields.
  • They are accessible via self.<name> in match bodies.
  • The promoted field’s value matches the constructor argument.

Actions section (§3.7)

FSM-TEST-120 — Action callable from match

Context: Smoke test for the actions: block: a private helper function callable from match contexts.

@@fsm M(text: bytes) : int = 0 {
    /[0-9]+/ parse_int(@@:matched)

    actions:
        parse_int(s: bytes): int { to_int(s) }
}

Expected outcome: Compiles.

Behavior:

  • Assert(@@M("42").return_value == 42).

What this test verifies:

  • A declared action is callable from a bare-expression context.
  • Action return values propagate to @@:return via the implicit-tail-assignment.
  • Actions can accept parameters and return values.

FSM-TEST-121 — Action with domain access

Context: Pins down that declared actions can read and write domain fields, persisting state across stage boundaries.

@@fsm M(text: bytes) : int = 0 {
    /[0-9]/ { increment() }
    self.count

    actions:
        increment() { self.count = self.count + 1 }

    domain:
        count: int = 0
}

Expected outcome: Compiles.

Behavior:

  • Assert(@@M("5").return_value == 1) — single digit consumed, action runs once.

What this test verifies:

  • Actions can read and write domain fields via self.<name>.
  • Side effects of action calls persist into the bare-expression return.

FSM-TEST-122 — Transition in action rejected

Context: Guards the actions: constraint that no transition statements may appear in action bodies. Transitions are state-machine concerns; actions are stateless helpers.

@@fsm M(text: bytes) : int = 0 {
    /a/ { do_jump() }

    actions:
        do_jump() { -> $other }

    $other: 1
}

Expected outcome: Compilation fails with E712.

Diagnostic location: The -> $other transition statement inside the action body.

Diagnostic content:

  • States that transitions are not permitted in actions: bodies.
  • Notes the constraint that actions are state-machine-independent helpers.

Recovery hint: Suggest issuing the transition from the calling match’s transition clause instead.

Fatal: Yes.

Example diagnostic text (non-normative):

error[E712]: transition statement in actions: body
  actions:
      do_jump() { -> $other }
                  ^^^^^^^^^ transitions are not allowed in actions
help: actions are state-machine-independent helpers; they cannot
      initiate transitions. Issue the transition from the match's
      success or failure branch instead.

FSM-TEST-123 — Action callable from embedding action

Context: Tests that declared actions are callable from inside embedding action bodies (>{...}, ${...}, etc.), not only from plain {...} action blocks. This makes embedding actions composable rather than requiring code duplication.

@@fsm M(text: bytes) : int = 0 {
    /[0-9]+/  ${ tally() }
    self.count

    actions:
        tally() { self.count = self.count + 1 }

    domain:
        count: int = 0
}

Expected outcome: Compiles.

Behavior:

  • Assert(@@M("123").return_value == 3) — three digits consumed, embedding ${...} fires three times, each invoking tally().

What this test verifies:

  • Declared actions can be called from embedding action bodies (not just plain action blocks).
  • The ${...} operator fires once per DFA transition within the stage.

Match and exhaustiveness (§4.3)

FSM-TEST-200 — Failable match without failure_branch rejected

Context: Guards the exhaustiveness rule: a match whose stages can fail must declare a failure_branch. The framepiler proves statically which stages can fail; uncovered failure paths are an error, not a runtime surprise.

@@fsm M(text: bytes) : bool = false {
    /foo/ -> $accept
    $accept: true
}

Expected outcome: Compilation fails with E701.

Diagnostic location: The transition clause -> $accept (specifically the absence of : -> $error).

Diagnostic content:

  • States that a match with a fallible stage (regex that does not accept the empty string) requires a failure_branch.
  • Notes which stage was deemed fallible (here: /foo/).

Recovery hint: Suggest adding : -> $error (or equivalent) to handle the failure case.

Fatal: Yes.

Example diagnostic text (non-normative):

error[E701]: match can fail but has no failure_branch
  /foo/ -> $accept
        ^^^^^^^^^^ success branch only; stage `/foo/` can fail
help: add a failure_branch to handle the rejection case:
      /foo/ -> $accept : -> $error

FSM-TEST-201 — Unfailable match without failure_branch allowed

Context: Pins down two related properties. (1) The framepiler permits a match to omit its failure_branch only when the match is provably non-failing. (2) /a*/ (zero or more as) is provably non-failing because it accepts the empty string at any position. A reader who wrote /a*/ when they meant /a+/ (one or more) will be surprised that this fsm accepts inputs like "b" without consuming them; that’s not a bug but the consequence of * accepting empty. See FSM-TEST-1002 for the general rule that trailing unconsumed input does not prevent acceptance, and FSM-TEST-1005 for another zero-consumption case.

@@fsm M(text: bytes) : bool = false {
    /a*/ -> $accept
    $accept: true
}

Expected outcome: Compiles.

Behavior:

  • Assert(@@M("").accepted == true). Empty input; /a*/ matches empty; recognition reaches $accept.
  • Assert(@@M("a").accepted == true). /a*/ matches "a"; cursor advances to 1; reaches $accept.
  • Assert(@@M("aaa").accepted == true). /a*/ matches "aaa"; cursor advances to 3; reaches $accept.
  • Assert(@@M("b").accepted == true)
  • Assert(m.cursor == 0). /a*/ matches zero as at position 0 (the empty string is a valid match for the * quantifier); the stage succeeds without advancing the cursor; recognition reaches $accept and returns true. The unconsumed "b" is left in place — see FSM-TEST-1002 for the general rule that trailing unmatched input does not prevent acceptance.

What this test verifies:

  • A stage whose regex accepts the empty string is provably non-failing.
  • The framepiler permits omitted failure_branch in such cases.

FSM-TEST-202 — Explicit failure branch

Context: Tests the explicit failure_branch form: success and failure both name target states. Demonstrates the RE2 recognizer rule pinned in §5.3 — accepted answers “is the input in the recognized language?”, decided by the terminating event. Reaching $accept by completing the /foo/ match accepts; reaching $error via the failure_branch (no further successful match) does not — the input was not recognized, even though $error sets a clean return_value.

@@fsm M(text: bytes) : bool = false {
    /foo/ -> $accept : -> $error
    $accept: true
    $error: false
}

Expected outcome: Compiles.

Behavior:

  • Assert(@@M("foo").accepted == true)/foo/ matches; transitions to $accept via the success branch; the terminating event is a successful match.
  • Assert(@@M("foo").return_value == true).
  • Assert(@@M("bar").accepted == false)/foo/ fails; the failure_branch routes to the terminal $error with no further successful match, so the input is not in the recognized language.
  • Assert(@@M("bar").return_value == false) — the $error state’s bare expression assigned false to @@:return; return_value is independent of accepted.

What this test verifies:

  • The : -> $error syntax parses correctly.
  • Failure routing transfers control to the named state and sets its return_value.
  • accepted follows the RE2 recognizer model: it is true only when the terminating event is a successful match completion, not merely because a terminal state was reached.

FSM-TEST-203 — Terminal match without transition

Context: Tests the implicit-terminal match form: a single match with neither success nor failure branch. Reaching the end of the match’s elements is acceptance; failing any stage is rejection.

@@fsm M(text: bytes) : int = 0 {
    /[0-9]+/ to_int(@@:matched)
}

Expected outcome: Compiles.

Behavior:

  • Assert(@@M("42").return_value == 42)
  • Assert(m.accepted == true)

What this test verifies:

  • An implicit-terminal match (no success_branch, no failure_branch) is permitted.
  • On success, the bare-expression assigns to @@:return and recognition terminates with accepted == true.
  • On failure, recognition terminates with accepted == false and the default @@:return value.

Alphabets (§6.1)

FSM-TEST-250 — Byte alphabet

Context: Smoke test for the byte alphabet (the most common case): regex character classes match byte values.

@@fsm M(buf: bytes) : bool = false { /[\x00-\x7F]+/ true }

Expected outcome: Compiles.

Behavior:

  • Assert(@@M(b"hello").return_value == true) (all bytes in ASCII range).
  • Assert(@@M(b"\xFF").accepted == false) (0xFF is outside the matched class).

What this test verifies:

  • The byte alphabet supports the \xNN hex escape syntax in character classes.
  • The framepiler compiles a DFA over the byte alphabet (0–255).

FSM-TEST-251 — Char alphabet

Context: Smoke test for the char alphabet: regex character classes match Unicode code points.

@@fsm M(text: char) : bool = false { /[a-zA-Z]+/ true }

Expected outcome: Compiles.

Behavior:

  • Assert(@@M("hello").return_value == true).
  • Assert(@@M("hello!").return_value == true) if the alphabet permits the longer match per RE2 greedy semantics; the trailing ! is left unconsumed.
  • Assert(@@M("!").accepted == false)
  • Assert(m.reject_position == 0)

What this test verifies:

  • The char alphabet supports ASCII character classes natively.
  • The DFA executor handles Unicode-aware iteration over the input.

FSM-TEST-252 — Char alphabet rejects byte escape

Context: Guards the rule that byte-level escapes (\xNN) are nonsensical in the char alphabet.

@@fsm M(text: char) : bool = false { /\xNN/ true }

Expected outcome: Compilation fails with E722.

Diagnostic location: The \x escape in the regex.

Diagnostic content:

  • States that \xNN is a byte-alphabet escape and is invalid in char alphabet contexts.
  • Notes the input parameter type (char).

Recovery hint: Suggest \u{NNNN} for Unicode code-point escapes.

Fatal: Yes.

Example diagnostic text (non-normative):

error[E722]: invalid regex syntax for `char` alphabet
  /\xNN/ true
   ^^^^ `\xNN` byte escape is not meaningful in the char alphabet
help: use the Unicode escape form instead:
      /\u{4E2D}/  -- matches Chinese character 中

FSM-TEST-253 — Token alphabet

Context: Smoke test for the token alphabet: regex identifiers refer to token kinds rather than character literals.

@@fsm M(toks: token) : bool = false { /IDENT LPAREN RPAREN/ true }

Expected outcome: Compiles.

Behavior:

  • Assert(@@M([IDENT, LPAREN, RPAREN]).return_value == true) (assuming a token sequence matching the pattern).
  • Assert(@@M([IDENT, IDENT]).accepted == false).

What this test verifies:

  • The token alphabet treats bare identifiers as token-kind references.
  • Concatenation of token kinds compiles to a DFA over token kinds.

FSM-TEST-254 — Token alphabet rejects character class

Context: Guards the rule that character classes are nonsensical in the token alphabet — there are no ‘characters’ to range over.

@@fsm M(toks: token) : bool = false { /[a-z]/ true }

Expected outcome: Compilation fails with E722.

Diagnostic location: The character class [a-z].

Diagnostic content:

  • States that character classes are not meaningful in the token alphabet.
  • Notes that token alphabet uses token-kind identifiers.

Recovery hint: Suggest listing the specific token kinds the match should accept.

Fatal: Yes.


Regex dialect (§6)

FSM-TEST-300 — Backreference rejected

Context: Guards a fundamental commitment: backreferences make the recognized language non-regular and are forbidden in @@fsm.

@@fsm M(text: bytes) : bool = false { /(.)\1/ true }

Expected outcome: Compilation fails with E720.

Diagnostic location: The backreference \1.

Diagnostic content:

  • States that backreferences are non-regular and cannot be compiled to a DFA.
  • Notes that recognizing such patterns requires runtime state beyond a DFA and is out of scope for @@fsm.

Recovery hint: Suggest expressing the pattern without backreferences if it is actually regular; otherwise the recognizer needs to be implemented outside @@fsm (typically in host code invoked from @@system).

Fatal: Yes.

Example diagnostic text (non-normative):

error[E720]: backreference `\1` is non-regular
  /(.)\1/ true
       ^^ backreferences require runtime state beyond a DFA
help: the language `{XX : X is any byte}` is not regular. If your
      pattern is genuinely context-sensitive, implement the
      recognizer in host code; if it isn't, you may be able to
      rewrite without the backreference.

FSM-TEST-301 — Recursion rejected

Context: Companion to FSM-TEST-300: same fundamental commitment, this time targeting recursion constructs in regex.

@@fsm M(text: bytes) : bool = false { /a(?R)?b/ true }

Expected outcome: Compilation fails with E720.

Diagnostic location: The recursion construct (?R).

Diagnostic content:

  • States that recursion in regex is non-regular and not supported.

Recovery hint: Suggest restructuring the pattern non-recursively if possible; otherwise implement the recognizer in host code.

Fatal: Yes.


FSM-TEST-302 — Lookahead rejected

Context: Tests that lookahead constructs are forbidden in v0.1. The v0.1 regex dialect excludes lookahead to keep DFA sizes bounded across the codegen targets.

@@fsm M(text: bytes) : bool = false { /foo(?=bar)/ true }

Expected outcome: Compilation fails with E720.

Diagnostic location: The lookahead construct (?=bar).

Diagnostic content:

  • States that fixed-width lookaround is not supported in the v0.1 regex dialect.
  • Identifies the offending construct.

Recovery hint: Suggest restructuring the pattern to avoid lookahead — for example, by capturing the lookahead’s content as a separate stage that the writer subsequently inspects.

Fatal: Yes.

Example diagnostic text (non-normative):

error[E720]: lookahead not supported in v0.1 regex dialect
  /foo(?=bar)/ true
      ^^^^^^^ fixed-width lookahead is not part of the v0.1 dialect
help: restructure the pattern to inspect the trailing content
      as a separate stage:
      .head/foo/ .tail/bar/ ...

FSM-TEST-303 — Character class compiles

Context: Smoke test for character-class concatenation with quantifiers — the classic identifier-recognizer pattern.

@@fsm M(text: bytes) : bool = false { /[a-zA-Z_][a-zA-Z0-9_]*/ true }

Expected outcome: Compiles.

Behavior:

  • Assert(@@M("foo").return_value == true) (matches the identifier-pattern).
  • Assert(@@M("foo_123").return_value == true).
  • Assert(@@M("123").accepted == false) (digits cannot start an identifier).

What this test verifies:

  • Multi-class concatenation with quantifiers compiles correctly.
  • The DFA distinguishes class membership in different positions.

FSM-TEST-304 — Alternation precedence

Context: Pins down operator precedence: alternation | is loosest, so foo|bar baz parses as foo | (bar baz). Important for users coming from languages with different precedence rules.

@@fsm M(text: bytes) : bool = false { /foo|bar baz/ true }

Expected outcome: Compiles.

Behavior:

  • Assert(@@M("foo").return_value == true) — alternation is loose; this parses as foo | (bar baz).
  • Assert(@@M("bar baz").return_value == true).
  • Assert(@@M("foo baz").accepted == true)
  • Assert(m.cursor == 3)foo matched; ` baz` left unconsumed.

What this test verifies:

  • Alternation has lower precedence than concatenation per RE2 semantics.
  • The framepiler does not require parenthesization to disambiguate.

FSM-TEST-305 — Bounded repetition

Context: Tests bounded repetition; specifically, an exact-count repetition {n}.

@@fsm M(text: bytes) : bool = false { /[0-9]{4}/ true }

Expected outcome: Compiles.

Behavior:

  • Assert(@@M("1234").return_value == true).
  • Assert(@@M("123").accepted == false)
  • Assert(m.reject_position == 3) (failed after consuming 3 of 4 required digits).
  • Assert(@@M("12345").return_value == true)
  • Assert(m.cursor == 4)

What this test verifies:

  • Bounded repetition {n} compiles to a DFA with exactly the required number of states.
  • Partial matches fail; the cursor reflects where matching stopped.

FSM-TEST-306 — Greedy quantifier semantics

Context: Pins down greedy-quantifier semantics under the pure-DFA engine. The match is longest-match within the regex, with the trailing literal anchoring the right edge. Lazy quantifiers (*?, +?, ??) are now supported in v0.2 via a Pike VM (§6.5, §11.1); see FSM-TEST-306b for the lazy/leftmost-first behavior.

@@fsm Greedy(text: bytes) : bytes = "" { /a.*b/ @@:matched }

Expected outcome: Compiles.

Behavior:

  • On "aXbXb": Greedy.return_value == "aXbXb", m.cursor == 5.* consumes maximally; the engine accepts the longest match that still allows the trailing literal b to match.
  • On "ab": Greedy.return_value == "ab", m.cursor == 2.
  • On "aXXX" (no trailing b): Greedy.accepted == false.

What this test verifies:

  • The greedy * quantifier under v0.1’s pure-DFA engine produces longest-match semantics anchored by the surrounding pattern.

FSM-TEST-306b — Lazy quantifier (v0.2, Pike VM)

Context: Companion to FSM-TEST-306. Lazy quantifiers were E720 in v0.1; v0.2 supports them on bytes/char via a Pike VM (§6.5, §11.1), giving leftmost-first match-end semantics. /.*?,/ stops at the first comma where greedy /.*,/ takes the last; the mixed /a*?b+/ keeps b+ greedy.

@@fsm First(text: bytes) : int = 0 { /.*?,/ @@:cursor }
@@fsm Mixed(text: bytes) : int = 0 { /a*?b+/ @@:cursor }

Expected outcome: Compiles (bytes/char). On the token alphabet, or in a stage with embedding actions, lazy is rejected with E720.

Behavior:

  • Assert(First("ab,cd,ef").cursor == 3) — lazy .*? stops at the first comma (“ab,”).
  • Assert(Mixed("aabbb").cursor == 5)a*? is minimal (consumes “aa” so b+ can start), b+ is greedy (“bbb”), so the match is “aabbb”, not the leftmost-shortest “aab”.

What this test verifies:

  • A lazy quantifier compiles to a Pike program and yields leftmost-first / Perl semantics, not longest-match.
  • Per-quantifier laziness: a lazy quantifier followed by a greedy one keeps the greedy one maximal.

FSM-TEST-307 — Unicode class requires the opt-in

Context: Tests that Unicode general-category classes (\p{...}) are admitted only behind the per-fsm opt-in @@[allow(unicode_classes)] (§6.7, §11.6). Without the attribute the construct is rejected, so a writer must consciously enable the (regular but table-heavy) feature; with it, \p{...} resolves to codepoint ranges on the char alphabet.

@@fsm M(text: char) : bool = false { /\p{L}+/ true }

Expected outcome: Compilation fails with E720 (no @@[allow(unicode_classes)]). Adding the attribute makes it compile; on a non-char alphabet it is E722 regardless.

Diagnostic location: The Unicode class \p{L}.

Diagnostic content:

  • States that Unicode classes require the @@[allow(unicode_classes)] opt-in.
  • Identifies the offending construct.

Recovery hint: Suggest adding @@[allow(unicode_classes)] to the fsm, or — if the input is ASCII-only — an ASCII-equivalent class (e.g., [a-zA-Z]).

Fatal: Yes.

Example diagnostic text (non-normative):

error[E720]: Unicode class \p{L} requires the `@@[allow(unicode_classes)]` attribute
  /\p{L}+/ true
   ^^^^^ Unicode general-category classes are opt-in (RFC-0042 §11.6)
help: add `@@[allow(unicode_classes)]` to the fsm, or use an ASCII-equivalent
      class if your input is ASCII-only: /[a-zA-Z]+/

FSM-TEST-309 — Escaped slash literal

Context: Tests that \/ is the canonical escape for a literal slash inside a regex. Important since / is also the slot delimiter.

@@fsm M(text: bytes) : bool = false { /a\/b/ true }

Expected outcome: Compiles.

Behavior:

  • Assert(@@M("a/b").return_value == true).
  • Assert(@@M("ab").accepted == false).

What this test verifies:

  • \/ is the canonical escape for a literal slash in the regex.
  • The slash delimiter is not confused with a literal / inside the regex.

FSM-TEST-310 — Empty regex rejected (revised — framec#163)

Context: An empty regex has no clear semantic. However, the spelling // is lexically a line comment inside @@fsm (§3.5 / RFC-0043), so an empty regex cannot be written in source — the case below is a comment that swallows the closing }:

@@fsm M(text: bytes) : bool = false { // true }

Expected outcome: E001 (unterminated @@fsm block) whose message notes that // begins a line comment and an empty regex cannot be written as //. E723 is engine-internal: it guards the regex compile API (defense in depth, unit-tested in fsm_regex) and is unreachable from source by construction.

  • Notes that a stage matching zero input would consume nothing and produce ambiguity.

Recovery hint: Suggest either deleting the empty match (if intent was a no-op), or providing a regex that matches at least the empty string explicitly (e.g., /(?:)/ — but this is also excluded; or /a*/ if matching nothing-or-more-a’s is desired).

Fatal: Yes.


FSM-TEST-311 — DFA size limit

Context: Tests the DFA-size limit: regexes that compile to too many states are rejected at compile time. Important for predictable resource usage in constrained environments.

@@fsm @@[max_dfa_states(100)] M(text: bytes) : bool = false { /.{1000}foo/ true }

Expected outcome: Compilation fails with E721.

Diagnostic location: The regex .{1000}foo.

Diagnostic content:

  • Names the DFA size constraint (max_dfa_states(100)).
  • Reports the computed DFA size (substantially exceeds 100).
  • Identifies the construct(s) responsible for the explosion (the bounded repetition .{1000}).

Recovery hint: Suggest either increasing the limit, decomposing the regex into multiple stages, or using a different approach.

Fatal: Yes.

Example diagnostic text (non-normative):

error[E721]: DFA size exceeds configured limit
  @@fsm @@[max_dfa_states(100)] M(...) : ... { /.{1000}foo/ true }
                                                ^^^^^^^^^^^ DFA would
                                                have ~1003 states
help: the bounded repetition `.{1000}` requires N+1 states per regex
      to track position. Options:
      - increase the limit: @@[max_dfa_states(1100)]
      - decompose into separate stages: /.{500}/ /.{500}/ /foo/

FSM-TEST-312 — Anchors

Context: Tests that the start-of-input anchor ^ matches only at position 0, not elsewhere in the input.

@@fsm M(text: bytes) : bool = false { /^foo/ true }

Expected outcome: Compiles.

Behavior:

  • Assert(@@M("foo").return_value == true).
  • Assert(@@M("xfoo").accepted == false)
  • Assert(m.reject_position == 0)^ anchor fails when cursor is at position 0 but the next byte isn’t f.
  • Assert(@@M("").accepted == false).

What this test verifies:

  • The start-of-input anchor ^ matches only when the cursor is at position 0.
  • Anchor failure produces a rejection at the cursor position.

FSM-TEST-313 — Interior anchor (Pike VM)

Context: An interior anchor — one not at a pattern edge — cannot be extracted to a position guard around a DFA match, so the stage routes to the Pike VM where the anchor compiles to a zero-width Assert evaluated against the live cursor (§6.6, §11.1). /a$b/ places $ (input end) mid-pattern, which can never hold between two elements.

@@fsm M(text: bytes) : bool = false { /a$b/ true }

Expected outcome: Compiles (routes to the Pike VM, not an “unsupported anchor” error).

Behavior:

  • Assert(@@M("ab").accepted == false) — the interior $ is unsatisfiable.

What this test verifies:

  • An interior anchor compiles to a Pike program carrying an Assert, not E722.
  • The VM evaluates the zero-width assertion correctly (interior input-end never holds).

FSM-TEST-314 — Word boundary on the char alphabet (Pike VM)

Context: \b/\B on the char alphabet need the Unicode \w word set, so they route to the Pike VM with a word-character table rather than the bytes edge-guard path (§6.6, §6.7). /\bcat\b/ matches a free-standing cat.

@@fsm M(text: char) : bool = false { /\bcat\b/ true }

Expected outcome: Compiles (Pike VM, Unicode \w word table).

Behavior:

  • Assert(@@M("cat").accepted == true) — boundaries hold at both input edges.
  • Assert(@@M("cats").accepted == false) — the trailing \b fails between t and s.

What this test verifies:

  • \b/\B work on the char alphabet via the Pike VM’s word-boundary assertion.
  • The word table uses the Unicode \w set sourced at compile time.

FSM-TEST-315 — Inline flags ((?i), (?s), (?m))

Context: Inline flags (§6.5) are baked into the AST at parse time: (?i) case-folds, (?s) makes . match \n, (?m) makes ^/$ line-relative (routed to the Pike VM). Scoped groups (?ims:…)/(?-i:…) apply or clear flags within the group.

@@fsm M(text: bytes) : bool = false { /(?i)cat/ true }

Expected outcome: Compiles (bytes/char).

Behavior:

  • Assert(@@M("CAT").accepted == true) and Assert(@@M("Cat").accepted == true)(?i) folds case.
  • For /(?s)a.b/, Assert(@@M("a\nb").accepted == true)(?s) lets . match \n.
  • For /(?m)a$/, Assert(@@M("a\nb").accepted == true)(?m) $ matches before a \n.
  • For /(?i:ab)c/, Assert(@@M("ABc").accepted == true) and Assert(@@M("ABC").accepted == false) — the scope ends at the group.

What this test verifies:

  • (?i)/(?s)/(?m) parse and produce the correct match semantics.
  • Scoped (?i:…) confines a flag to its group; the surrounding pattern is unaffected.

Transitions and targets (§3.5.4, §4.4)

FSM-TEST-400 — Static transition

Context: Smoke test for static transitions: a multi-state fsm with named state targets in both success and failure branches.

@@fsm M(text: bytes) : bool = false {
    /a/ -> $next : -> $error
    $next: /b/ true : -> $error
    $error: false
}

Expected outcome: Compiles.

Behavior:

  • Assert(@@M("ab").return_value == true).
  • Assert(@@M("ax").return_value == false) (first stage succeeds, second fails, transitions to $error).
  • Assert(@@M("x").return_value == false).

What this test verifies:

  • Static state references in transition clauses resolve correctly.
  • Both branches (success and failure) work for both initial and intermediate states.

FSM-TEST-401 — Stage-address transition target

Context: Tests stage-address transition targets: a transition can name a specific labeled stage within a state, re-entering at that stage rather than the state’s first stage.

@@fsm M(text: bytes) : bool = false {
    $0: .start/a/ /b/ /c/ true : -> $error
    $other: /x/ -> $0.start : -> $error
    $error: false
}

Expected outcome: Compiles.

Behavior:

  • Assert(@@M("abc").return_value == true).
  • Assert(@@M("xabc").return_value == true)x matches $other, transitions back to $0.start, then matches a, b, c.

What this test verifies:

  • A transition target may name a state and a labeled stage within it.
  • Re-entering a state at a specific stage skips earlier stages of that state.

FSM-TEST-402 — Conditional target

Context: Tests conditional transition targets — a runtime-chosen target from a statically-enumerable set. The set is {$zero, $one}; the framepiler can verify CFI properties despite the runtime selection.

@@fsm M(text: bytes, mode: int) : int = 0 {
    /[01]/ -> ( $zero when self.mode == 0, $one when self.mode == 1 ) : -> $error
    $zero: 0
    $one: 1
    $error: -1
}

Expected outcome: Compiles.

Behavior:

  • Assert(@@M("0", 0).return_value == 0).
  • Assert(@@M("1", 1).return_value == 1).
  • Assert(@@M("0", 2).return_value == -1) — neither condition matches; failure branch fires.

What this test verifies:

  • Conditional targets with multiple alternatives are statically enumerable.
  • Conditions evaluate in source order; first true wins.
  • Falling through all conditions triggers the failure branch.

FSM-TEST-403 — Reference to undeclared state

Context: Tests that transition targets referring to undeclared states fail at compile time, not at runtime.

@@fsm M(text: bytes) : bool = false {
    /a/ -> $undeclared : -> $error
    $error: false
}

Expected outcome: Compilation fails with E731.

Diagnostic location: The state reference $undeclared in the success branch.

Diagnostic content:

  • States that no state with the label $undeclared is declared in the fsm.
  • Optionally lists the available state labels.

Recovery hint: Suggest declaring the state, or correcting the reference.

Fatal: Yes.

Example diagnostic text (non-normative):

error[E731]: reference to undeclared state `$undeclared`
  /a/ -> $undeclared : -> $error
         ^^^^^^^^^^^ no state by this name

note: declared states: <implicit start>, $error
help: add a labeled state `$undeclared:` somewhere in the body,
      or correct the reference if the name is a typo.

FSM-TEST-404 — Reference to undeclared stage

Context: Companion to FSM-TEST-403: stage references to undeclared labels also fail at compile time.

@@fsm M(text: bytes) : bool = false {
    $0: /a/ -> $0.nosuch : -> $error
    $error: false
}

Expected outcome: Compilation fails with E732.

Diagnostic location: The stage reference $0.nosuch.

Diagnostic content:

  • States that no stage labeled .nosuch exists in $0.
  • Optionally lists $0’s stage labels (here, none, since the stage is unlabeled).

Recovery hint: Suggest labeling the target stage, or correcting the name.

Fatal: Yes.


FSM-TEST-405 — Conditional with no matching condition warns

Context: Tests the warning case for conditional targets: a single alternative leaves no fallback. The compile proceeds (it’s a warning, not an error) but the writer is told that values outside the matched alternative fall to the failure branch silently.

@@fsm M(text: bytes, mode: int) : int = 0 {
    /[01]/ -> ( $zero when self.mode == 0 ) : -> $error
    $zero: 0
    $error: -1
}

Expected outcome: Compiles with W701.

Diagnostic location: The conditional target with a single alternative.

Diagnostic content (warning):

  • Notes that the conditional has only one branch.
  • Warns that values of mode other than 0 will silently fall to the failure branch.

Recovery hint: Suggest either adding a default alternative or documenting the intent.

Fatal: No (warning).

Behavior:

  • Assert(@@M("0", 0).return_value == 0).
  • Assert(@@M("0", 1).return_value == -1) (falls to failure).

FSM-TEST-406 — Missing when guard on cond_alt rejected

Context: Guards the rule that every cond_alt in a conditional_target must carry a when guard. A bare target without when is rejected.

@@fsm M(text: bytes, mode: int) : int = 0 {
    /[01]/ -> ( $zero when self.mode == 0, $one ) : -> $error
    $zero: 0
    $one: 1
    $error: -1
}

Expected outcome: Compilation fails with E715.

Diagnostic location: The $one reference in the conditional_target (the alternative missing the when guard).

Diagnostic content:

  • States that every alternative in a conditional_target must include a when guard.
  • Names the offending alternative.
  • Notes that an unconditional fallback should be expressed via the failure_branch.

Recovery hint: Suggest adding a when clause, or moving the unconditional alternative to the failure_branch.

Fatal: Yes.

Example diagnostic text (non-normative):

error[E715]: conditional_target alternative missing `when` guard
  /[01]/ -> ( $zero when self.mode == 0, $one ) : -> $error
                                          ^^^^ no `when` predicate
help: every alternative in a conditional_target requires a `when` guard.
      For an unconditional fallback, use the failure_branch:
      /[01]/ -> ( $zero when self.mode == 0 ) : -> $one

FSM-TEST-407 — Constant-true when guard warns

Context: Tests W705: a constant-true when guard adds no semantic content and should be either removed or moved to the failure_branch.

@@fsm M(text: bytes) : int = 0 {
    /[01]/ -> ( $always when true ) : -> $error
    $always: 1
    $error: -1
}

Expected outcome: Compiles with W705.

Diagnostic location: The when true predicate.

Diagnostic content (warning):

  • States that a constant-true predicate adds no semantic content.
  • Suggests dropping the guard (and the surrounding conditional_target if it becomes single-alternative).

Recovery hint: Suggest rewriting as /[01]/ -> $always : -> $error, or moving $always to the failure_branch if appropriate.

Fatal: No (warning only).

Behavior:

  • Assert(@@M("0").return_value == 1).
  • Assert(@@M("x").return_value == -1). The recognizer compiles and runs; the warning does not block compilation.

Runtime semantics (§5)

FSM-TEST-500 — Construction with full match

Context: Smoke test for runtime semantics: a successful match populates all instance fields correctly. The auto-promoted input parameter is also preserved on the instance.

@@fsm RecognizeFoo(text: bytes) : bool = false { /foo/ true }
m = @@RecognizeFoo("foo")

Expected outcome: Compiles. Construction succeeds.

Behavior:

  • Assert(m.accepted == true).
  • Assert(m.return_value == true).
  • Assert(m.cursor == 3).
  • Assert(m.reject_position == 0).
  • Assert(m.text == "foo") (auto-promoted parameter accessible).

What this test verifies:

  • Construction with a full-match input populates accepted, return_value, and cursor consistently.
  • The auto-promoted input parameter is preserved on the instance.

FSM-TEST-501 — Construction with no match

Context: Companion to FSM-TEST-500: a failed match populates accepted = false, reject_position at the failure point, and leaves return_value at the declared default.

m = @@RecognizeFoo("bar")

Expected outcome: Construction succeeds; recognition fails.

Behavior:

  • Assert(m.accepted == false).
  • Assert(m.return_value == false) (declared default).
  • Assert(m.cursor == 0) — no input was consumed (regex failed at position 0).
  • Assert(m.reject_position == 0).

What this test verifies:

  • Failure leaves @@:return at its declared default.
  • reject_position matches the cursor at the moment of failure.

FSM-TEST-502 — Cursor advances on match

Context: Tests that @@:cursor returns the current cursor position during match execution. Specifically, the cursor reflects how much input was consumed by stages.

@@fsm M(text: bytes) : int = 0 { /[a-z]+/ @@:cursor }
m = @@M("hello")

Expected outcome: Compiles.

Behavior:

  • Assert(m.return_value == 5).
  • Assert(m.cursor == 5).
  • Assert(m.accepted == true).

What this test verifies:

  • @@:cursor returns the current cursor position during action execution.
  • The cursor reflects total input consumed.

FSM-TEST-503 — Stage capture exposes matched bytes

Context: Tests stage capture: a labeled stage’s capture contains the bytes the regex consumed, accessible via $state.label.

@@fsm M(text: bytes) : bytes = "" {
    $main: .word/[a-z]+/ $main.word
}
m = @@M("hello123")

Expected outcome: Compiles.

Behavior:

  • Assert(m.return_value == "hello").
  • Assert(m.cursor == 5).
  • Assert(m.accepted == true).

What this test verifies:

  • A labeled stage’s capture contains the bytes consumed by its regex.
  • $state.label references resolve to the slice type of the alphabet (here bytes).

**FSM-TEST-504 — Input parameter accessible as self.**

Context: Tests that the auto-promoted input parameter is the full input buffer, distinct from @@:matched which is just the immediately-preceding stage’s bytes.

@@fsm M(text: bytes) : int = 0 { /[a-z]+/ len(self.text) }
m = @@M("hello")

Expected outcome: Compiles.

Behavior:

  • Assert(m.return_value == 5).
  • The len(self.text) call sees the full input (5 bytes), not just what was matched.

What this test verifies:

  • The auto-promoted input parameter is accessible via self.<name> throughout the body.
  • The built-in len() operates on the input slice.

Embedding actions (§3.5.5)

FSM-TEST-600 — Entry and per-element actions

Context: Tests embedding action firing rules: >{...} fires once when the regex’s DFA enters its start state; ${...} fires for each consumed element.

@@fsm M(text: bytes) : int = 0 {
    /[0-9]+/  >{ self.count = self.count + 100 }  ${ self.count = self.count + 1 }
    self.count

    domain:
        count: int = 0
}
m = @@M("123")

Expected outcome: Compiles.

Behavior:

  • Assert(m.return_value == 103).
  • The >{...} action fires once (start of stage), adding 100.
  • The ${...} action fires three times (one per consumed digit), adding 1 each time.
  • Total: 0 + 100 + 1 + 1 + 1 = 103.

What this test verifies:

  • >{...} fires exactly once per stage.
  • ${...} fires per consumed input element.
  • Multiple embedding actions on a single stage compose without interfering.

FSM-TEST-601 — Final-state action

Context: Tests that @{...} fires when the regex’s DFA enters an accepting state. For a regex like (foo|bar), that’s once per successful match.

@@fsm M(text: bytes) : int = 0 {
    /(foo|bar)/  @{ self.flag = 1 }
    self.flag

    domain:
        flag: int = 0
}
m = @@M("foo")

Expected outcome: Compiles.

Behavior:

  • Assert(m.return_value == 1).
  • The @{...} action fires when the DFA enters an accepting state, which happens once at the end of "foo" (or "bar").

What this test verifies:

  • @{...} fires when an accepting state is reached.
  • It does not refire on prefix or partial matches.

FSM-TEST-602 — EOF action

Context: Tests @eof{...} firing: when input ends while a stage is mid-match, the EOF action fires before the failure branch resolves.

@@fsm M(text: bytes) : int = 0 {
    /foo/  @eof{ self.eof_seen = 1 }  -> $accept : -> $reject
    $accept: 1
    $reject: self.eof_seen

    domain:
        eof_seen: int = 0
}
m = @@M("fo")

Expected outcome: Compiles.

Behavior:

  • Assert(m.return_value == 1) — the input ended mid-match (after "fo"), so @eof{...} fired, setting eof_seen = 1. The stage didn’t complete, so the failure branch fires, leading to $reject, which returns self.eof_seen == 1.

What this test verifies:

  • @eof{...} fires when the input ends before the stage’s regex completes.
  • The action body can observe and modify domain state at the moment of input exhaustion.

FSM-TEST-603 — Leave-final action

Context: Tests %{...} firing: when the DFA leaves its last accepting state. For /[0-9]+/, this fires at the position where digits stop, not at any digit-position before that.

@@fsm M(text: bytes) : int = 0 {
    /[0-9]+/  %{ self.end_pos = @@:cursor }
    self.end_pos

    domain:
        end_pos: int = 0
}
m = @@M("42x")

Expected outcome: Compiles.

Behavior:

  • Assert(m.return_value == 2)/[0-9]+/ matches "42"; at the third position, x cannot extend the match, so the DFA leaves its accepting state. %{...} fires at that moment, recording cursor == 2.

What this test verifies:

  • %{...} fires when the DFA leaves its last accepting state.
  • It captures the position at the end of the matched region, not the position of the failing character.

Composition (§8)

FSM-TEST-700 — Mode C composition

Context: Smoke test for Mode C composition: an outer fsm references an inner fsm via /@FsmName/. In v0.1, the inner fsm runs as a call-out at the stage position (constructed, executed to completion, cursor advanced); true DFA integration is deferred to v0.2.

@@fsm Digit(input: char) : int = 0 {
    /[0-9]/ to_int(@@:matched)
}

@@fsm Pair(input: char) : (int, int) = (0, 0) {
    $p: .a/@Digit/  /,/  .b/@Digit/
        ($p.a.return_value, $p.b.return_value)
}

Expected outcome: Both compile.

Behavior:

  • Assert(@@Pair("3,7").return_value == (3, 7)).
  • Assert(@@Pair("3,7").accepted == true).
  • Assert(@@Pair("3-7").accepted == false) (, doesn’t match).

What this test verifies:

  • The /@FsmName/ syntax invokes another fsm at the stage position.
  • $state.label.return_value extracts the inner fsm’s return value via chained-dot access.
  • In v0.1, the inner fsm runs as a call-out subroutine; observable semantics are identical to the v0.2 inline-DFA form.

FSM-TEST-701 — Mode C bytes-and-return

Context: Tests that Mode C exposes both the matched bytes ($state.label) and the inner fsm’s return value ($state.label.return_value). Both addressing forms work simultaneously.

@@fsm Digit(input: char) : int = 0 {
    /[0-9]/ to_int(@@:matched)
}

@@fsm Both(input: char) : (string, int) = ("", 0) {
    $b: .d/@Digit/
        ($b.d, $b.d.return_value)
}

Expected outcome: Both compile.

Behavior:

  • Assert(@@Both("5").return_value == ("5", 5)).

What this test verifies:

  • $state.label exposes the matched elements (here, the string "5").
  • $state.label.return_value exposes the inner fsm’s return value (here, the int 5).
  • Both are accessible from a single Mode C stage.

FSM-TEST-702 — Mode C type mismatch

Context: Tests that the inner fsm’s return type must be compatible with the outer fsm’s usage context.

@@fsm Word(text: bytes) : bytes = "" { /[a-z]+/ @@:matched }
@@fsm Wrong(text: bytes) : int = 0 {
    $w: /@Word/ $w.0.return_value
}

Expected outcome: Compilation fails with E706.

Diagnostic location: The bare expression $w.0.return_value (which has type bytes) in a context expecting int.

Diagnostic content:

  • Names the inner fsm’s return type (bytes).
  • Names the outer fsm’s declared return type (int).
  • Names the mismatch site.

Recovery hint: Suggest converting the inner return value, or adjusting the outer return type.

Fatal: Yes.


FSM-TEST-703 — Mode C alphabet mismatch

Context: Tests that inner and outer fsms must declare matching alphabets — a bytes-alphabet fsm cannot reference a char-alphabet fsm.

@@fsm A(text: bytes) : bool = false { /a/ true }
@@fsm B(input: char) : bool = false { /@A/ true }

Expected outcome: Compilation fails with E731.

Diagnostic location: The /@A/ reference.

Diagnostic content:

  • Names the outer fsm’s alphabet (char).
  • Names the inner fsm’s alphabet (bytes).
  • States that Mode C composition requires alphabet compatibility.

Recovery hint: Suggest matching the alphabets (changing one of the two declarations).

Fatal: Yes.


FSM-TEST-704 — Mode C dynamic dispatch rejected

Context: Tests that the referenced fsm in /@FsmName/ must be statically resolvable. Dynamic dispatch is not supported in v0.1.

@@fsm M(text: bytes, which: FsmRef) : int = 0 {
    $m: /@which/  $m.0.return_value
}

Expected outcome: Compilation fails with E732.

Diagnostic location: The /@which/ reference.

Diagnostic content:

  • States that the inner fsm in Mode C must be statically named (not a runtime value).
  • Notes that which is a runtime variable, not a static fsm name.

Recovery hint: Suggest using a conditional target with multiple /@FsmName/ alternatives (each statically named), or rethinking the architecture to use Mode A composition from @@system.

Fatal: Yes.


Edge cases

FSM-TEST-1000 — Empty input

Context: Tests the empty-input case. With no input bytes available, regexes that require at least one element (like /foo/) cannot match; rejection is reported at position 0.

m = @@RecognizeFoo("")

Expected outcome: Construction succeeds; recognition rejects.

Behavior:

  • Assert(m.accepted == false).
  • Assert(m.reject_position == 0).
  • Assert(m.return_value == false).
  • Assert(m.cursor == 0).

What this test verifies:

  • Empty input produces a clean rejection at position 0.
  • The default @@:return value is preserved.

FSM-TEST-1001 — Input exactly matching

Context: Tests the exact-match case: input consumed entirely, recognition succeeds, cursor at end of input.

m = @@RecognizeFoo("foo")

Expected outcome: Recognition succeeds.

Behavior:

  • Assert(m.accepted == true).
  • Assert(m.cursor == 3) (exactly the input length).
  • Assert(m.return_value == true).

What this test verifies:

  • Input that exactly matches consumes the full buffer.

FSM-TEST-1002 — Input longer than match

Context: Pins down @@fsm’s anchored-prefix-match semantics: input longer than the match is acceptable. The fsm accepts when recognition reaches a terminal state, regardless of whether all input is consumed. Important to contrast with ‘full input match’ semantics used by some languages; if the writer wants the latter, they must anchor with $ or \z in the regex.

m = @@RecognizeFoo("foobar")

Expected outcome: Recognition succeeds after consuming "foo".

Behavior:

  • Assert(m.accepted == true).
  • Assert(m.cursor == 3).
  • Assert(m.return_value == true).

What this test verifies:

  • Trailing input beyond the match is left unconsumed.
  • The fsm does not require the input to be fully consumed for acceptance.

FSM-TEST-1003 — Input strictly prefix of match

Context: Tests the prefix-of-match case: input ends mid-match. Rejection is reported at the cursor position where the regex was waiting for more.

m = @@RecognizeFoo("fo")

Expected outcome: Recognition rejects at end-of-input.

Behavior:

  • Assert(m.accepted == false).
  • Assert(m.reject_position == 2) (cursor at end of input when match was incomplete).
  • Assert(m.return_value == false).

What this test verifies:

  • A prefix of a valid match is not itself accepted.
  • Rejection at end-of-input fires @eof actions for the current stage (if any).

FSM-TEST-1004 — Anchored match with leading non-match

Context: Tests that the start-of-input anchor ^ produces rejection at position 0 when the next character doesn’t match. The anchor itself fails; no input is consumed.

@@fsm M(text: bytes) : bool = false { /^foo/ true }
m = @@M("xfoo")

Expected outcome: Recognition rejects.

Behavior:

  • Assert(m.accepted == false).
  • Assert(m.reject_position == 0) — the ^ anchor fails immediately at position 0 because the next byte is x, not f.

What this test verifies:

  • The start-of-input anchor ^ matches only at position 0.
  • Anchor failure produces rejection at the cursor’s current position.

FSM-TEST-1005 — Zero-length match

Context: Pins down zero-length matches: /a*/ against "bbb" matches the empty string at position 0 and succeeds without advancing the cursor. Companion to FSM-TEST-201 — the same behavior framed as ‘zero-consumption is acceptance.’

@@fsm M(text: bytes) : int = 0 { /a*/ @@:cursor }
m = @@M("bbb")

Expected outcome: Recognition succeeds with zero consumption.

Behavior:

  • Assert(m.accepted == true).
  • Assert(m.return_value == 0) (cursor remained at 0).
  • Assert(m.cursor == 0).

What this test verifies:

  • /a*/ accepts the empty string at position 0 (matches zero as).
  • The remaining input is left unconsumed; this is success per the spec.

FSM-TEST-1006 — @@:matched before any stage completes

Context: Tests the @@:matched probe before any stage has completed in the current match. The result is the empty slice of the alphabet’s type, not the field’s declared default value.

@@fsm M(text: bytes) : bytes = "default" {
    { self.saw_initial = @@:matched }
    /foo/
    @@:matched

    domain:
        saw_initial: bytes = "placeholder"
}
m = @@M("foo")

Expected outcome: Compiles.

Behavior:

  • Assert(m.return_value == "foo") (final @@:matched reads the bytes consumed by /foo/).
  • Assert(m.saw_initial == "") — the action block runs before any stage; @@:matched is the empty slice.

What this test verifies:

  • @@:matched returns the empty slice of the alphabet’s type when no stage has yet completed.
  • The empty slice is distinct from the field’s declared default; the read returns “” (empty), overwriting the placeholder.

Additional diagnostic coverage

These tests exercise diagnostic codes not covered by the section-organized tests above.

FSM-TEST-1100 — Malformed declaration (missing fsm name)

Context: Tests a general structural error: a malformed @@fsm declaration missing the construct name. The error is anchored to the position where the name should appear.

@@fsm (text: bytes) : bool = false { /a/ true }

Expected outcome: Compilation fails with E700.

Diagnostic location: The position between the @@fsm keyword and the opening ( where a name should appear.

Diagnostic content:

  • States that an @@fsm declaration requires a name (identifier) between the keyword and the parameter list.
  • Identifies the construct (@@fsm).

Recovery hint: Suggest inserting an identifier (e.g., M) as the fsm name.

Fatal: Yes.

Example diagnostic text (non-normative):

error[E700]: @@fsm declaration is missing the name
  @@fsm (text: bytes) : bool = false { /a/ true }
        ^ expected an identifier between `@@fsm` and `(`
help: provide a name for the fsm:
      @@fsm Recognize(text: bytes) : bool = false { /a/ true }

FSM-TEST-1101 — Malformed declaration (missing body braces)

Context: Companion to FSM-TEST-1100: a different malformed declaration, this time missing the body braces.

@@fsm M(text: bytes) : bool = false /a/ true

Expected outcome: Compilation fails with E700.

Diagnostic location: The position after the default value where the opening { should appear.

Diagnostic content:

  • States that an @@fsm body must be enclosed in {}.
  • Identifies the construct.

Recovery hint: Suggest wrapping the body in { ... }.

Fatal: Yes.


FSM-TEST-1102 — Stage label collision within a state

Context: Tests detection of duplicate stage labels within a single state.

@@fsm M(text: bytes) : bytes = "" {
    $main: .x/[0-9]+/ .x/[a-z]+/ $main.x
}

Expected outcome: Compilation fails with E730.

Diagnostic location: The second .x label.

Diagnostic content:

  • States that two stages within the same state cannot share a label.
  • Names the duplicated label (.x).
  • Notes the position of the first occurrence.

Recovery hint: Suggest renaming one of the stages, or referring to the stages by position ($main.0, $main.1) if labels aren’t needed.

Fatal: Yes.

Example diagnostic text (non-normative):

error[E730]: stage label `.x` is used twice within state `$main`
  $main: .x/[0-9]+/ .x/[a-z]+/ $main.x
         ^^         ^^ second occurrence
         |
         first occurrence

help: stage labels within a state must be unique. Rename one of them:
      $main: .digits/[0-9]+/ .letters/[a-z]+/ ...

FSM-TEST-1103 — Unused parameter warning

Context: Tests the unused-parameter warning. The parameter is auto-promoted but never referenced; the framepiler suggests removal or use.

@@fsm M(text: bytes, unused: int = 0) : bool = false {
    /a/ true
}

Expected outcome: Compiles with W702.

Diagnostic location: The parameter unused: int.

Diagnostic content:

  • Notes that the parameter (and its auto-promoted domain field) is never referenced.
  • Names the parameter (unused).

Recovery hint: Suggest either using the parameter, removing it, or prefixing the name with _ to acknowledge intentional non-use (a common convention in similar languages; whether Frame adopts this convention is implementation-defined).

Fatal: No (warning only).

Behavior:

  • Assert(@@M("a", 5).accepted == true)
  • Assert(@@M("a", 5).return_value == true). The parameter is bound but ignored; the warning does not prevent compilation.

Example diagnostic text (non-normative):

warning[W702]: parameter `unused` is never referenced
  @@fsm M(text: bytes, unused: int = 0) : bool = false {
                       ^^^^^^^^^^^^^^^^ auto-promoted to `self.unused`
                                         but never read or written

help: remove the parameter, reference it via `self.unused`, or rename
      to `_unused` to mark as intentionally unused.

FSM-TEST-1104 — Unused domain variable warning

Context: Tests the unused-domain-variable warning, distinct from W702 (unused parameter): this is an explicit domain declaration that’s never read or written.

@@fsm M(text: bytes) : bool = false {
    /a/ true

    domain:
        unused: int = 0
}

Expected outcome: Compiles with W703.

Diagnostic location: The domain field declaration unused: int = 0.

Diagnostic content:

  • Notes that the explicitly declared domain field is never referenced.
  • Names the field (unused).
  • Distinguishes from W702: this is an explicit domain declaration, not a parameter auto-promotion.

Recovery hint: Suggest removing the declaration, or using it in the body.

Fatal: No (warning only).


FSM-TEST-1105 — DFA size approaching limit warning

Context: Tests the DFA-size-approaching-limit warning: a recognizer whose DFA size is at or above some threshold of the configured limit gets a heads-up, even though it still compiles.

@@fsm @@[max_dfa_states(50)] M(text: bytes) : bool = false { /.{40}foo/ true }

Expected outcome: Compiles with W704.

Diagnostic location: The regex /.{40}foo/ (or the configured @@[max_dfa_states(50)] attribute).

Diagnostic content:

  • Reports the computed DFA size.
  • Reports the configured limit.
  • Reports the percentage of limit used (a value at or above some threshold — implementation-defined, but commonly ≥75%).

Recovery hint: Suggest either raising the limit or refactoring the regex to require fewer states.

Fatal: No (warning only).

Example diagnostic text (non-normative):

warning[W704]: DFA size approaching configured limit
  @@fsm @@[max_dfa_states(50)] M(...) : ... { /.{40}foo/ true }
                                                ^^^^^^^^^ DFA uses
                                                ~43 of 50 states
                                                (86% of limit)

help: this fsm is close to its configured DFA size limit. Future
      additions may push it over. Consider raising the limit:
      @@[max_dfa_states(100)]