Language reference

Control Flow

Describe decisions, repetition, and sequences in your Livt designs.

Control flow determines which work a design performs, when it repeats that work, and how it moves between steps. Livt provides familiar conditions, loops, and early exits, together with states for making a sequence explicit. The right structure makes both the intended result and the conditions for progress clear to the reader.

The execution context matters. A context-free helper describes combinational work, while a scheduled function or process can perform work over time. Neither the number of source lines nor the number of loop iterations alone establishes a cycle count. See Execution Models for the scheduling rules and Contexts for clock domains and synchronised access.

This page follows a small packet example: classify a command, examine payload bytes, and publish a captured value through a request-and-completion sequence.

Make decisions

An expression produces a value; a statement uses values to perform an action. In if (length > 0), the comparison produces the boolean that chooses a branch. Assignments update values, and return finishes the current function. Local bindings belong to their enclosing scope; component fields hold state shared through the component. See Types and Expressions for choosing between var, let, and const.

Use boolean expressions for conditions. For a logic signal, write the comparison explicitly, such as valid == 0b1. This states which signal value enables the branch and avoids confusing a hardware signal with a boolean decision.

Choose an ordered branch

An if/elif/else chain is useful when conditions overlap and their order matters. The following function classifies a length as empty or invalid, short, or long:

livt
public fn ClassifyLength(length: int) int
{
    if (length <= 0) {
        return 0
    }
    elif (length <= 16) {
        return 1
    }
    else {
        return 2
    }
}

Only the first matching branch runs. A length of zero therefore returns zero even though it would also satisfy length <= 16. A length of sixteen returns one; seventeen returns two. Livt uses elif for a chained condition; write that keyword rather than else if. An else handles everything left over and can be omitted when no action is needed for that case.

Handle special cases early

A guard clause returns from a function as soon as a special case is known. It keeps the remaining work at the main indentation level, which helps when validating a packet before processing its contents.

livt
public fn IsValidLength(length: int) bool
{
    if (length <= 0) {
        return false
    }
    return length <= 255
}

Here a nonpositive length is rejected immediately. The remaining expression checks the upper bound. Returning early finishes this function; it does not stop other processes or components from running.

Select between alternatives

Use match when one value selects between distinct alternatives. An enum makes the possible choices readable without relying on a numeric encoding. For this example, PacketCommand contains Ignore, Read, and Write.

livt
enum PacketCommand
{
    Ignore
    Read
    Write
}

public fn NeedsPayload(command: PacketCommand) bool
{
    match command
    {
        PacketCommand.Ignore, PacketCommand.Read => {
            return false
        }
        PacketCommand.Write => {
            return true
        }
    }
}

The selector is evaluated once, and exactly one arm runs. Commas group alternatives that share an action. Each arm has its own braced block and local scope; there is no fallthrough and no need for break between arms. A match is a statement, so this function returns from its arms rather than assigning the match itself to a variable.

Unlike an ordered condition chain, earlier match arms have no priority over later ones. Alternatives must be distinct. An enum match must cover every member or end with a _ fallback. Listing all members makes a newly added command visible as an incomplete match, so its behaviour must be considered explicitly. Boolean matches follow the same coverage rule for true and false.

incomplete enum match diagnostic in VS Code
An omitted enum member produces a diagnostic.

Handle unrecognised commands

A byte received from outside the design can contain more values than the protocol defines. Numeric and logic matches require a final fallback, even when the listed alternatives appear to cover the expected input. Here unrecognised command bytes become PacketCommand.Ignore:

livt
public fn Decode(command: byte) PacketCommand
{
    match command
    {
        0x01 => { return PacketCommand.Read }
        0x02 => { return PacketCommand.Write }
        _ => { return PacketCommand.Ignore }
    }
}

Repeat work

Choose a loop according to what controls progress: a stopping condition, an index, or the elements of a collection. In each case, make it clear what changes between iterations and how the loop finishes. These choices describe the work; their timing still depends on the execution context and operations in the body.

Stop at a condition

A while loop checks its condition before each iteration and can run zero times. This function counts the bytes before the first zero, stopping at the array boundary if no zero occurs:

livt
public fn CountPrefix(payload: in byte[]) int
{
    var index = 0
    while (index < payload.Length()) {
        if (payload[index] == 0x00) {
            break
        }
        index++
    }
    return index
}

The length check protects the indexed access. The index advances for each nonzero byte, while break exits immediately when a terminator is found. If the first byte is zero, the result is zero; if none is zero, the result equals the array length. A loop that waits for an external condition needs a deliberate waiting strategy rather than an assumption that repeatedly reading a value will advance time.

Keep index-based work together

A for loop places initialization, the continuation condition, and the update in one header. It is useful when an index is part of the calculation or when the same range must be visited predictably.

livt
public fn Sum(payload: in byte[]) int
{
    var total = 0
    for (var index = 0; index < payload.Length(); index++) {
        total += payload[index] as int
    }
    return total
}

The index starts at zero, the condition is checked before the body, and the increment follows each completed iteration. The explicit cast makes the addition use integer arithmetic. A fixed array extent gives a finite amount of work, but it does not promise that the loop is unrolled or that each iteration takes one clock cycle.

Visit elements directly

When the position is unimportant, foreach expresses the same accumulation more directly. The element type is explicit here so that the byte-to-integer conversion is easy to follow:

livt
public fn SumElements(payload: in byte[]) int
{
    var total = 0
    foreach (var value: byte in payload) {
        total += value as int
    }
    return total
}

For an array, this visits its elements without a manually maintained index. Cursor-based iteration is useful when a component supplies a sequence through an iterator contract instead. Such a cursor supplies HasNext() and Next(); initialise or reset it according to its contract before traversal. A cursor holds a position, so overlapping traversals need separate cursor instances. Reusing one sequentially requires restoring its position before the next pass. The Livt core library provides the iterator interfaces.

Control the next step

Early exits make a loop’s exceptions explicit. break finishes the nearest enclosing loop; continue skips the rest of its current iteration. Neither returns from the surrounding function. In this packet format, zero bytes are padding and 0xFF ends the meaningful payload:

livt
public fn SumUntilEnd(payload: in byte[]) int
{
    var total = 0
    for (var index = 0; index < payload.Length(); index++) {
        if (payload[index] == 0xFF) {
            break
        }
        if (payload[index] == 0x00) {
            continue
        }
        total += payload[index] as int
    }
    return total
}

For [0x02, 0x00, 0x03, 0xFF, 0x09], the result is five: padding is skipped, and the final nine is never processed. In a for loop, continue still runs the update before checking the condition again. In a while loop, it returns directly to the condition, so an update placed after the continue would be skipped. A break inside a match arm still exits the enclosing loop.

Restart a process

Outside a loop, continue has a process-specific role: it yields and restarts the process on the next cycle. A process can use this to check for available work and try again later when none is ready. Inside a loop in that process, continue still belongs to the loop. This distinction is important when reviewing polling code.

A process has no return value. Publish results through its component’s fields or interfaces, and use a clear request/completion contract so another component knows when those results are valid. Use a named waiting state when the wait belongs to a larger sequence, as in the next example.

Describe sequences over time

Some behaviour has distinct stages: accept a request, capture its data, publish a result, and wait for the requester to acknowledge completion. States make these stages visible. Start with ordinary control flow and introduce explicit states where grouping or a transition is part of the design’s meaning.

Group related updates

Ordinary braces establish a block and local scope; braces alone do not establish a cycle boundary. An anonymous state groups ordinary non-suspending updates into one step. The test starts a transfer by publishing its input and request together:

livt
state {
    transfer.input = 0x3C
    transfer.start = true
}

Scheduled calls, waits, and context crossings can suspend execution inside a state. A state is therefore not an atomic transaction around arbitrary code. Keep related updates together, and keep payload values stable for the interval required by the receiving protocol. The sync marker for cross-context access is explained in Contexts.

Name the stages of a transfer

The following component accepts one byte per request. The requester supplies input and raises start, then holds the request until done is observed. After reading output, it lowers start and waits for done to clear before making another request.

livt
namespace Examples.ControlFlow

component PacketTransfer
{
    public start: bool = false
    public input: byte = 0x00
    public output: byte = 0x00
    public done: bool = false
    captured: byte = 0x00

    process Main()
    {
        state Idle {
            if (start) {
                captured = input
                goto Publish
            }
            else { goto Idle }
        }
        state Publish {
            output = captured
            done = true
            goto WaitForRelease
        }
        state WaitForRelease {
            if (start) { goto WaitForRelease }
            else {
                done = false
                goto Idle
            }
        }
    }
}

Idle captures the input only when start is true. Publish presents the captured byte and sets done together. WaitForRelease keeps that result available while start remains high, then clears done and returns to Idle. The private captured field carries the accepted value between stages; later changes to input do not replace the published result during the same request.

packet transfer state transitions
Transitions show the request-and-completion protocol, not an exact end-to-end cycle count.

A goto names a local state, not an arbitrary source line or a state in another component. Keep each transition explicit in its branch. Named states can be used in scheduled functions as well as processes; a function can return after its sequence, while a process continues its ongoing behaviour. A local declared within a state is initialized when that declaration executes. Use fields to make values retained across these stages clear.

named packet transfer states in VS Code
Read each state together with its outgoing conditions and the values it publishes.

Check the behaviour

Tests should cover the decisions that matter, not just one successful input. For the packet examples on this page, check values on both sides of the short-packet boundary, every command enum member, an unrecognised command byte, a terminator at the first position, and a payload with no terminator. Include padding and an early loop exit as separate cases.

Use assert to express the expected result in simulation. For example, assert rules.ClassifyLength(16) == 1 documents the inclusive boundary. Assertions detect a violated expectation; they do not replace runtime validation or a hardware error response in the design.

Wait deliberately and bound the check

For a sequence, test the handshake as well as the final data. After issuing a request, the example waits for completion with a bounded number of polls and asserts that completion actually occurred:

livt
var attempts = 0
while (!transfer.done && attempts < 20) {
    state {}
    attempts++
}
assert transfer.done == true
assert transfer.output == 0x3C

The empty state provides an explicit step that lets the design advance. Twenty is a polling limit, not a claim of twenty elapsed cycles: the surrounding scheduled work also matters. The assertion after the loop distinguishes completion from exhausting the limit. For longer waits, the simulation helpers described in Testing can express intent more clearly.

To check the complete handshake, change the input while completion is held and assert that the output remains stable. Then release the request and perform a second transfer. This verifies the return path to Idle as well as the first result. Add these checks to your project’s test component and run it from Livt Tests or Test Explorer. See Testing for creating and running a test component.

four passing Control Flow tests in VS Code
The tests cover decisions, command matching, payload iteration, and repeated transfers.

Choose a suitable structure

Choose the construct that exposes the reason for the next action. An ordered condition chain expresses priority; a match expresses distinct alternatives. A loop expresses repetition, while a named state makes a stage and its outgoing transitions visible. These structures can be combined without turning every helper into a state machine.

When the design needs to…Use…
Check overlapping conditions in orderif, elif, and else.
Select an action from one valuematch, with explicit coverage.
Repeat until a condition changeswhile, with clear progress and termination.
Visit indexed positions or collection elementsfor or foreach.
Publish related non-suspending updates togetherAn anonymous state.
Wait and move between named stagesNamed states with explicit transitions.

A short loop can still describe substantial hardware or scheduled work. Likewise, match selection does not promise a particular circuit delay, and a state diagram alone does not prove interface timing. Keep calculations bounded where appropriate, distinguish a process restart from a loop continue, and test waits and return paths. Use Execution Models to reason about scheduling and Testing to check observable behaviour.