Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

rust-plc is an open-source IEC 61131-3 Structured Text compiler toolchain built in Rust. It provides everything you need to write, analyze, and execute PLC programs:

  • Compiler — Parses Structured Text source code, performs semantic analysis with 30+ diagnostic checks, and compiles to register-based bytecode
  • Runtime — A bytecode VM that executes compiled programs in a PLC-style scan cycle loop
  • LSP Server — Full Language Server Protocol integration with 16 features: diagnostics, hover, go-to-definition, go-to-type-definition, completion, signature help, find all references, rename, document/workspace symbols, document highlight, folding, document links, semantic tokens, formatting, and code actions
  • DAP Debugger — Full Debug Adapter Protocol support with breakpoints, stepping, variable inspection, and scan-cycle-aware continue
  • Online Change — Hot-reload compiled programs without stopping the runtime, with automatic variable migration
  • Monitor Server — WebSocket-based live variable dashboard for real-time trend recording
  • CLI Toolst-cli provides check, compile, run, and debug commands for the terminal

What is Structured Text?

Structured Text (ST) is one of the five programming languages defined by the IEC 61131-3 standard for programmable logic controllers (PLCs). It’s a high-level, Pascal-like language used extensively in industrial automation:

PROGRAM TemperatureControl
VAR
    sensor_temp : REAL := 22.0;
    setpoint    : REAL := 50.0;
    heater_on   : BOOL := FALSE;
END_VAR
    IF sensor_temp < setpoint - 2.0 THEN
        heater_on := TRUE;
    ELSIF sensor_temp > setpoint + 2.0 THEN
        heater_on := FALSE;
    END_IF;
END_PROGRAM

Key Features

FeatureStatus
Full ST parser with error recovery
30+ semantic diagnostics (type errors, undeclared vars, etc.)
LSP server (16 features: diagnostics, hover, go-to-def, completion, references, rename, formatting, and more)
VSCode extension with syntax highlighting
Bytecode compiler
Runtime VM with scan cycle engine
Debugger (DAP)
Online change (hot reload)
Monitor server (WebSocket live dashboard)
LLVM native compilation🔜

Quick Example

# Check a file for errors
st-cli check program.st

# Compile and run for 1000 scan cycles
st-cli run program.st -n 1000

# Start the LSP server (used by VSCode)
st-cli serve

Architecture

The toolchain follows the same architecture as rust-analyzer: a Rust core process handles all the heavy lifting, while a thin TypeScript extension bridges to VSCode.

Source (.st) → Parser → AST → Semantics → IR → VM
                              ↓
                          LSP Server → VSCode

Continue to Installation to get started.

Installation

Prerequisites

  • Rust 1.85 or later (install via rustup)
  • Node.js 18+ (only needed for the VSCode extension)
  • C compiler (for building the tree-sitter parser — usually pre-installed on Linux/macOS)

Building from Source

# Clone the repository
git clone https://github.com/user/rust-plc.git
cd rust-plc

# Build the CLI tool
cargo build -p st-cli --release

# The binary is at:
./target/release/st-cli --help

Verify Installation

# Check version
st-cli help

# Parse and check a sample file
st-cli check playground/01_hello.st

# Run a program
st-cli run playground/01_hello.st

Expected output:

playground/01_hello.st: OK
Executed 1 cycle(s) in 8.5µs (avg 8.5µs/cycle, 16 instructions)

Installing the VSCode Extension

See VSCode Setup for detailed instructions.

Using the Devcontainer

The easiest way to get started is with the included devcontainer:

  1. Open the repository in VSCode
  2. Click “Reopen in Container” when prompted (or Ctrl+Shift+P → “Dev Containers: Reopen in Container”)
  3. Wait for the container to build and the post-create script to finish
  4. Open any .st file in the playground/ folder

The devcontainer automatically:

  • Installs Rust and Node.js
  • Builds st-cli
  • Installs the VSCode extension
  • Configures syntax highlighting and LSP

Quick Start

This guide walks you through writing and running your first Structured Text program.

1. Create a Program

Create a file called hello.st:

PROGRAM HelloWorld
VAR
    counter : INT := 0;
    running : BOOL := TRUE;
END_VAR
    IF running THEN
        counter := counter + 1;
        IF counter > 100 THEN
            running := FALSE;
        END_IF;
    END_IF;
END_PROGRAM

2. Check for Errors

st-cli check hello.st

Output:

hello.st: OK

If there are errors, you’ll see them with file, line, and column:

hello.st:5:10: error: undeclared variable 'x'
hello.st:8:8: error: condition must be BOOL, found 'INT'

3. Run the Program

# Run a single scan cycle
st-cli run hello.st

# Run 1000 scan cycles (like a real PLC)
st-cli run hello.st -n 1000

Output:

Executed 1000 cycle(s) in 1.74ms (avg 1.74µs/cycle, 16 instructions)

4. Write a Function

Functions compute and return a value:

FUNCTION Clamp : REAL
VAR_INPUT
    value : REAL;
    low   : REAL;
    high  : REAL;
END_VAR
    IF value < low THEN
        Clamp := low;
    ELSIF value > high THEN
        Clamp := high;
    ELSE
        Clamp := value;
    END_IF;
END_FUNCTION

PROGRAM Main
VAR
    sensor : REAL := 150.0;
    output : REAL := 0.0;
END_VAR
    output := Clamp(value := sensor, low := 0.0, high := 100.0);
END_PROGRAM

5. Write a Function Block

Function blocks maintain state between calls:

FUNCTION_BLOCK Counter
VAR_INPUT
    reset : BOOL;
END_VAR
VAR_OUTPUT
    count : INT;
END_VAR
VAR
    internal : INT := 0;
END_VAR
    IF reset THEN
        internal := 0;
    ELSE
        internal := internal + 1;
    END_IF;
    count := internal;
END_FUNCTION_BLOCK

PROGRAM Main
VAR
    cnt : Counter;
    value : INT := 0;
END_VAR
    cnt(reset := FALSE);
    value := cnt.count;
END_PROGRAM

6. Use Global Variables

Global variables persist across scan cycles:

VAR_GLOBAL
    total_cycles : INT;
END_VAR

PROGRAM Main
VAR
    local_var : INT := 0;
END_VAR
    total_cycles := total_cycles + 1;
    local_var := total_cycles;
END_PROGRAM
# After 1000 cycles, total_cycles = 1000
st-cli run program.st -n 1000

Next Steps

VSCode Setup

The rust-plc toolchain includes a VSCode extension that provides full IDE support for Structured Text.

Features

The language server provides 16 LSP features for a full IDE experience:

  • Diagnostics — Real-time errors and warnings as you type (30+ diagnostic codes)
  • Hover — Ctrl+hover shows type info, function signatures, and variable kinds
  • Go-to-definition — Ctrl+Click jumps to variable or POU declarations
  • Go-to-type-definition — Jumps to the TYPE, STRUCT, or FUNCTION_BLOCK declaration of a variable’s type
  • Completion — Auto-complete with keywords (snippets), variables, functions, struct fields (dot-trigger), FB members, and types
  • Signature help — Parameter hints on ( and , inside function and FB calls
  • Find all references — Shift+F12 finds all usages of a symbol (case-insensitive, whole-word)
  • Rename symbol — F2 renames across all occurrences in the file
  • Document symbols — Ctrl+Shift+O outline view with nested POUs and variables
  • Workspace symbols — Ctrl+T search for any POU or type across all open files
  • Document highlight — Cursor on an identifier highlights all occurrences instantly
  • Folding ranges — Collapse PROGRAM, FUNCTION, VAR, IF, FOR, WHILE, CASE, and comment blocks
  • Document links — File paths in comments (e.g., // see utils.st) become clickable links
  • Semantic tokens — 10 token types for rich, context-aware syntax highlighting
  • Formatting — Shift+Alt+F auto-indents the entire file
  • Code actions — Ctrl+. quick fix: declare undeclared variable as INT

Installation

  1. Open the rust-plc repository in VSCode
  2. Click “Reopen in Container” or run Dev Containers: Reopen in Container
  3. Everything is configured automatically

Option B: Extension Development Host

  1. Build the CLI and extension:

    cargo build -p st-cli
    cd editors/vscode && npm install && npm run compile
    
  2. Press F5 in VSCode (with the rust-plc repo open)

  3. Select “Launch Extension (playground)”

  4. A new VSCode window opens with the extension loaded and the playground/ folder open

Option C: Manual Installation

  1. Build st-cli:

    cargo build -p st-cli --release
    
  2. Package the extension:

    cd editors/vscode
    npm install
    npm run compile
    npx @vscode/vsce package --no-dependencies
    
  3. Install the .vsix:

    code --install-extension iec61131-st-0.1.0.vsix
    
  4. Configure the server path in VSCode settings:

    {
      "structured-text.serverPath": "/path/to/st-cli"
    }
    

Configuration

SettingDefaultDescription
structured-text.serverPathst-cliPath to the st-cli binary

File Associations

The extension automatically activates for files with these extensions:

  • .st — Structured Text
  • .scl — Structured Control Language (Siemens variant)

Troubleshooting

Extension not activating

  • Check that st-cli is built and accessible
  • Open the Output panel → select “Structured Text Language Server”
  • Verify the binary path in settings

No syntax highlighting

  • Reload the window: Ctrl+Shift+P → “Developer: Reload Window”
  • Check that the file is recognized as “Structured Text” (bottom-right status bar)

LSP errors

  • Build st-cli: cargo build -p st-cli
  • Check the Output panel for error messages from the language server

Next Steps

Once the extension is installed and working, see the complete walkthrough:

Editing, Running & Debugging in VSCode — step-by-step guide covering hover, completion, diagnostics, running programs, setting breakpoints, stepping through code, and inspecting variables.

Editing, Running & Debugging in VSCode

This is a complete walkthrough of writing, running, and debugging an IEC 61131-3 Structured Text program in Visual Studio Code using the rust-plc toolchain.


Prerequisites

Before starting, make sure you have:

  • rust-plc repository cloned and built (cargo build -p st-cli)
  • VSCode extension installed (see VSCode Setup)
  • Or simply use the Devcontainer — everything is pre-configured

Fastest way to start: Open the repository in VSCode and click “Reopen in Container”. After the container builds, everything is ready.


Step 1: Create a New ST Program

Open VSCode with the playground/ folder (or any folder with .st files).

Create a new file called my_program.st:

File → New File → Save as my_program.st

Paste this code:

(*
 * My first ST program — a simple counter with threshold detection.
 *)

FUNCTION IsAboveThreshold : BOOL
VAR_INPUT
    value : INT;
    threshold : INT;
END_VAR
    IsAboveThreshold := value > threshold;
END_FUNCTION

PROGRAM Main
VAR
    counter   : INT := 0;
    limit     : INT := 50;
    exceeded  : BOOL := FALSE;
    message   : INT := 0;
END_VAR
    counter := counter + 1;

    exceeded := IsAboveThreshold(value := counter, threshold := limit);

    IF exceeded THEN
        message := 1;
    ELSE
        message := 0;
    END_IF;

    IF counter >= 100 THEN
        counter := 0;
    END_IF;
END_PROGRAM

What you should see immediately

As soon as you save the file:

  1. Syntax highlighting — Keywords (PROGRAM, IF, THEN, END_IF) appear in a distinct color. Types (INT, BOOL) are highlighted differently. Comments are dimmed. String and numeric literals have their own colors.

  2. No red squiggles — If the code is correct, no error underlines appear. The Problems panel (View → Problems) should show no errors.

  3. Status bar — The bottom-right of VSCode shows Structured Text as the language mode.


Step 2: Explore Editor Features

Hover for Type Information

Hold Ctrl (or Cmd on macOS) and hover over any variable or function name:

  • Hover over counter → shows: counter : INT with Var kind
  • Hover over IsAboveThreshold → shows the function signature: FUNCTION(value: INT, threshold: INT) : BOOL
  • Hover over exceeded → shows: exceeded : BOOL

Go to Definition

Ctrl+Click (or Cmd+Click) on any identifier to jump to its declaration:

  • Click on IsAboveThreshold in the exceeded := line → jumps to the FUNCTION IsAboveThreshold declaration at the top
  • Click on counter in the IF counter >= 100 line → jumps to the VAR block where counter is declared
  • Click on limit → jumps to its declaration

Code Completion

Start typing inside the program body. Completion suggestions appear automatically:

  • Type cou → completion list shows counter, count (if any), and keywords starting with “COU”
  • Type IF → completion offers the IF...END_IF snippet template
  • After a struct variable, type . → field names appear (e.g., myStruct. shows x, y, value)

Snippet completions insert full control structures:

TriggerExpands to
IFIF ${condition} THEN ... END_IF;
FORFOR ${i} := ${1} TO ${10} DO ... END_FOR;
WHILEWHILE ${condition} DO ... END_WHILE;
CASECASE ${expression} OF ... END_CASE;
FUNCTIONFull function template with VAR_INPUT
FUNCTION_BLOCKFull FB template
PROGRAMFull program template

Go to Type Definition

Ctrl+Shift+Click (or use the right-click context menu → “Go to Type Definition”) on any variable to jump to the declaration of its type. This is especially useful with user-defined types:

  • Click on a variable of type MyStruct → jumps to the TYPE MyStruct : STRUCT ... END_STRUCT; END_TYPE declaration
  • Click on a variable of type MyFB → jumps to the FUNCTION_BLOCK MyFB declaration

This differs from Go-to-definition (which jumps to where the variable is declared) by jumping to where the variable’s type is declared instead.

Signature Help

When calling a function or function block, the editor shows parameter hints automatically:

  • Type IsAboveThreshold( → a tooltip appears showing (value: INT, threshold: INT) : BOOL with the first parameter highlighted
  • Type a , after the first argument → the tooltip advances to highlight the next parameter

This works for all FUNCTION and FUNCTION_BLOCK calls, showing parameter names and types as you type each argument.

Find All References

Press Shift+F12 (or right-click → “Find All References”) on any identifier to find every usage in the file:

  • On counter → shows all lines where counter is read or assigned
  • On IsAboveThreshold → shows the declaration and all call sites

The search is case-insensitive and matches whole words only, consistent with IEC 61131-3 semantics.

Rename Symbol

Press F2 on any variable or POU name to rename it across all occurrences in the file:

  • Place the cursor on counter → press F2 → type cycle_count → all occurrences of counter are renamed to cycle_count

The rename is case-insensitive and applies to all references, declarations, and usages simultaneously.

Document Symbols (Outline)

Press Ctrl+Shift+O to open the document symbol picker, or open the Outline panel (View → Open View → Outline):

▼ Main (PROGRAM)
    counter : Var : INT
    limit : Var : INT
    exceeded : Var : BOOL
    message : Var : INT
▼ IsAboveThreshold (FUNCTION : BOOL)
    value : VarInput : INT
    threshold : VarInput : INT

This shows all POUs and their variables in a navigable tree. Type in the picker to filter by name.

Workspace Symbols

Press Ctrl+T to search for any POU or type across all open files in the workspace. This is useful when working with multi-file projects:

  • Type Main → shows all PROGRAM/FUNCTION/FUNCTION_BLOCK declarations named “Main” across all .st files
  • Type Temp → finds any POU or type whose name contains “Temp”

Document Highlight

Place your cursor on any identifier and all other occurrences of that symbol in the file are instantly highlighted with a background color. This happens automatically with no keyboard shortcut needed:

  • Click on counter → every reference to counter in the file lights up
  • Click on exceeded → all usages are highlighted

This makes it easy to visually trace how a variable flows through your program.

Folding Ranges

Click the fold icons in the gutter (the small triangles next to line numbers) to collapse code blocks:

  • PROGRAM / FUNCTION / FUNCTION_BLOCK — collapse entire POU bodies
  • VAR / VAR_INPUT / VAR_OUTPUT — collapse variable declaration blocks
  • IF / FOR / WHILE / CASE — collapse control flow blocks
  • Comment blocks (* ... *) — collapse multi-line comments

You can also use Ctrl+Shift+[ to fold and Ctrl+Shift+] to unfold the block at the cursor.

File paths mentioned in comments become clickable links:

(* See utils.st for helper functions *)
// Reference: alarm_logic.st

Ctrl+Click on these paths to open the referenced file directly in the editor.

Formatting

Press Shift+Alt+F to auto-format the entire document. The formatter normalizes indentation to produce consistently readable code. You can also right-click and select “Format Document” from the context menu.

Code Actions (Quick Fixes)

When the LSP reports an undeclared variable, press Ctrl+. (or click the lightbulb icon) to see available quick fixes:

  • If you type new_var := 42; without declaring new_var, the diagnostics underline it. Press Ctrl+. and select the quick fix to automatically add new_var : INT; to the nearest VAR block.

Diagnostics (Error Detection)

Try introducing an error — change counter := counter + 1; to:

counter := counter + TRUE;

Immediately you’ll see:

  • A red squiggly underline under TRUE
  • The Problems panel shows: left operand of '+' must be numeric, found 'BOOL'
  • A red circle appears on the file tab and in the Explorer

Fix the error to clear the diagnostic.

Common diagnostics the LSP catches:

ErrorExample
Undeclared variablex := unknown_var;
Type mismatchint_var := TRUE;
Wrong condition typeIF int_var THEN (needs BOOL)
Missing parametersMyFunc() when params are required
Unused variablesVariable declared but never read
EXIT outside loopEXIT; in program body
Duplicate declarationsTwo variables with the same name

Step 3: Run the Program

From the Terminal

Open the integrated terminal (Ctrl+`) and run:

# Check for errors (no execution)
st-cli check my_program.st

# Run a single scan cycle
st-cli run my_program.st

# Run 1000 scan cycles (like a real PLC)
st-cli run my_program.st -n 1000

Expected output for 1000 cycles:

Executed 1000 cycle(s) in 1.2ms (avg 1.2µs/cycle, 28 instructions)

This tells you:

  • 1000 cycles were executed (like a PLC running for 1000 scans)
  • 1.2µs per cycle — the average execution time
  • 28 instructions — bytecode instructions per cycle

Understanding Scan Cycles

In a real PLC, programs execute in a continuous loop called the scan cycle:

┌─────────────┐
│ Read Inputs │ ← from sensors, switches
├─────────────┤
│ Execute     │ ← your ST program runs here
│ Program     │
├─────────────┤
│ Write       │ ← to motors, valves, lights
│ Outputs     │
└─────┬───────┘
      │ repeat
      └───────→ back to top

The -n 1000 flag simulates 1000 iterations of this loop. Global variables (VAR_GLOBAL) persist across cycles, so a counter increments each time.


Step 4: Debug the Program

Start a Debug Session

  1. Open my_program.st in the editor
  2. Set a breakpoint — click in the gutter (left margin) next to line counter := counter + 1;. A red dot appears.
  3. Press F5 or click Run → Start Debugging
  4. If prompted, select “Debug Current ST File”

What Happens

The debugger:

  1. Compiles my_program.st to bytecode
  2. Starts the VM paused on the first instruction
  3. Shows the Debug toolbar at the top of the editor:
  ▶ Continue  ⏭ Step Over  ⏬ Step Into  ⏫ Step Out  🔄 Restart  ⏹ Stop

The editor highlights the current line (typically the first executable statement) with a yellow background.

Debug Controls

ButtonKeyboardAction
▶ ContinueF5Run until next breakpoint (across scan cycles, up to 100,000)
⏭ Step OverF10Execute one statement, skip into function calls
⏬ Step IntoF11Execute one statement, enter function calls
⏫ Step OutShift+F11Run until current function returns
⏹ StopShift+F5End debug session

PLC-Specific Debug Toolbar Buttons

The VSCode extension adds 4 PLC-specific buttons to the debug toolbar:

ButtonAction
ForceForce a variable to a specific value (overrides program logic)
UnforceRemove the force override from a variable
List ForcedShow all currently forced variables and their values
Cycle InfoDisplay scan cycle statistics (count, timing)

You can also use these via the Debug Console by typing evaluate expressions:

force counter = 42
unforce counter
listForced
scanCycleInfo

Inspect Variables

While paused, look at the Variables panel on the left (Debug sidebar):

▼ Locals
    counter    0    INT
    limit      50   INT
    exceeded   FALSE BOOL
    message    0    INT
▼ Globals
    (empty — no VAR_GLOBAL in this program)

The values update as you step through the code.

Step Through Code

  1. Press F10 (Step Over) — the highlighted line advances to the next statement
  2. After stepping past counter := counter + 1;, check the Variables panel:
    • counter now shows 1
  3. Press F10 again — steps to the exceeded := IsAboveThreshold(...) line
  4. Press F11 (Step Into) — enters the IsAboveThreshold function body
  5. The Call Stack panel shows:
▼ PLC Scan Cycle
    IsAboveThreshold    line 10
    Main                line 24
  1. Press Shift+F11 (Step Out) — returns to Main
  2. Press F5 (Continue) — runs until the breakpoint is hit again (next scan cycle)

Watch Expressions

In the Watch panel, click + and type a variable name:

  • Type counter → shows the current value
  • Type exceeded → shows TRUE or FALSE

The watch panel evaluates variable names against the current scope (locals first, then globals).

Breakpoint Features

  • Toggle breakpoint: Click the gutter or press F9 on a line
  • Remove all breakpoints: Run → Remove All Breakpoints
  • Conditional breakpoints are not yet supported (future feature)

Debug a Program with Global Variables

Create counter_demo.st:

VAR_GLOBAL
    total_cycles : INT;
END_VAR

PROGRAM Main
VAR
    x : INT := 0;
END_VAR
    total_cycles := total_cycles + 1;
    x := total_cycles * 2;
END_PROGRAM

Debug this file and use Continue (F5) multiple times. Watch total_cycles increment in the Globals scope each time the program completes a cycle and restarts.


Step 5: Debug a Multi-POU Program

The debugger supports stepping into function calls across POUs.

Open playground/06_full_demo.st and set a breakpoint inside the CASE state OF block. Press F5 to start debugging:

  1. The program stops on entry
  2. Press F5 to continue — it hits your breakpoint
  3. Check the Variables panel to see all local variables and their current values
  4. Step through the state machine logic
  5. Use Step Into (F11) when a function like Clamp(...) is called to enter it

Call Stack Navigation

When stopped inside a nested function call, the Call Stack panel shows all active frames:

▼ PLC Scan Cycle
    Clamp              line 32    ← current position
    BottleFiller       line 112   ← caller

Click on BottleFiller in the call stack to view the caller’s local variables and source position.


Step 6: PLC Monitor Panel

The Monitor Panel provides a live dashboard for observing and controlling PLC variables in real time while the program runs.

Open the Monitor Panel

  1. Open the Command Palette: Ctrl+Shift+P (or Cmd+Shift+P on macOS)
  2. Type and select: “ST: Open PLC Monitor”
  3. A webview panel opens showing all variables with live values

The monitor connects to the runtime via a WebSocket server that streams variable state after each scan cycle.

Monitor Features

FeatureDescription
Live variablesAll global and program-local variables update in real time
Force variableRight-click a variable to override its value (useful for testing)
Unforce variableRemove the override and let the program control the value again
Trend recordingWatch how variable values change over time

Forcing Variables

Forcing is essential during commissioning and testing. When a variable is forced:

  • The forced value is written at the start of each scan cycle, overriding program logic
  • A visual indicator shows which variables are currently forced
  • Use “Unforce” to release the variable back to normal program control

Online Change via the Monitor

The monitor server’s WebSocket API also supports online change (hot-reload). When you modify and recompile a source file, you can push the new module to the running engine without stopping it:

  • Compatible changes (e.g., modified logic, same variable layout) are applied instantly
  • Variable values are automatically migrated to the new module
  • Incompatible changes (e.g., added/removed variables, changed types) require a full restart

This enables an iterative development workflow where you can edit program logic and see the effects immediately in the monitor panel, without losing runtime state.


Troubleshooting

“Failed to start ST language server”

  • Build the CLI: cargo build -p st-cli
  • Check the setting structured-text.serverPath points to the built binary

Breakpoints appear as gray circles (unverified)

  • The line may not correspond to any executable bytecode instruction
  • Try setting the breakpoint on an assignment or function call line instead of a VAR declaration or END_IF

No syntax highlighting

  • Check the status bar shows “Structured Text” (not “Plain Text”)
  • If not, click the language mode and select “Structured Text”
  • Reload the window: Ctrl+Shift+P → “Developer: Reload Window”

Debug session ends immediately

  • Ensure the file has a PROGRAM POU (not just functions/FBs)
  • Check the terminal for compilation errors

Variables show <unknown>

  • The variable may be out of scope
  • Step into the function where the variable is declared

Quick Reference

ActionShortcut / How
CLI
Check filest-cli check file.st
Run programst-cli run file.st -n 100
LSP Features
Hover for type infoCtrl+hover on identifier (Cmd+hover on macOS)
Go to definitionCtrl+Click on identifier (Cmd+Click on macOS)
Go to type definitionRight-click → Go to Type Definition
Code completionStart typing, or Ctrl+Space (Cmd+Space on macOS)
Signature helpType ( or , inside a function call
Find all referencesShift+F12
Rename symbolF2
Document symbols (outline)Ctrl+Shift+O (Cmd+Shift+O on macOS)
Workspace symbolsCtrl+T (Cmd+T on macOS)
Document highlightPlace cursor on identifier (automatic)
Fold blockCtrl+Shift+[ (Cmd+Option+[ on macOS)
Unfold blockCtrl+Shift+] (Cmd+Option+] on macOS)
Document linksCtrl+Click on file path in comment
Format documentShift+Alt+F (Shift+Option+F on macOS)
Code action (quick fix)Ctrl+. (Cmd+. on macOS)
Problems panelView → Problems
Debugging
Start debuggingOpen .st file → F5
Set breakpointClick gutter or F9
Step overF10
Step intoF11
Step outShift+F11
ContinueF5
Stop debuggingShift+F5
Force variableDebug toolbar button or force x = 42 in Debug Console
Unforce variableDebug toolbar button or unforce x in Debug Console
List forced variablesDebug toolbar button or listForced in Debug Console
Scan cycle infoDebug toolbar button or scanCycleInfo in Debug Console
Monitor
Open PLC MonitorCtrl+Shift+P → “ST: Open PLC Monitor”
Force variable (Monitor)Right-click variable in Monitor panel

Program Structure

IEC 61131-3 Structured Text organizes code into Program Organization Units (POUs). There are three kinds of POU: PROGRAM, FUNCTION, and FUNCTION_BLOCK. Each serves a distinct role in a well-structured automation project.

PROGRAM

A PROGRAM is the top-level entry point. Every ST file executed with st-cli run must contain at least one program. A program can declare local variables, call functions and function block instances, and perform I/O.

PROGRAM Main
  VAR
    counter : INT := 0;
    running : BOOL := TRUE;
  END_VAR

  IF running THEN
    counter := counter + 1;
  END_IF;
END_PROGRAM

Run it:

st-cli run main.st

A program’s variables retain their values between scan cycles. This is a key difference from functions, whose locals are re-initialized on every call.

FUNCTION

A FUNCTION is a stateless POU that computes a return value. It has no persistent local state – every invocation starts fresh. Functions are the right choice for pure computations such as unit conversions, math helpers, or validation checks.

FUNCTION Clamp : REAL
  VAR_INPUT
    value : REAL;
    low   : REAL;
    high  : REAL;
  END_VAR

  IF value < low THEN
    Clamp := low;
  ELSIF value > high THEN
    Clamp := high;
  ELSE
    Clamp := value;
  END_IF;
END_FUNCTION

The return value is assigned by writing to the function’s own name (Clamp := ...).

FUNCTION_BLOCK

A FUNCTION_BLOCK is a stateful POU. You create instances of a function block, and each instance maintains its own private copy of all internal variables across calls. This is the standard building block for timers, counters, PID controllers, and state machines.

FUNCTION_BLOCK PulseCounter
  VAR_INPUT
    pulse : BOOL;
  END_VAR
  VAR_OUTPUT
    count : INT;
  END_VAR
  VAR
    prev_pulse : BOOL := FALSE;
  END_VAR

  // Rising-edge detection
  IF pulse AND NOT prev_pulse THEN
    count := count + 1;
  END_IF;
  prev_pulse := pulse;
END_FUNCTION_BLOCK

Instantiate and use it inside a program:

PROGRAM Main
  VAR
    sensor_counter : PulseCounter;
    sensor_input   : BOOL;
  END_VAR

  sensor_counter(pulse := sensor_input);

  IF sensor_counter.count > 100 THEN
    // threshold reached
  END_IF;
END_PROGRAM

How POUs Relate

FeaturePROGRAMFUNCTIONFUNCTION_BLOCK
Has persistent stateYesNoYes (per instance)
Return valueNoYes (one)No (use outputs)
Can be instantiatedNo (singleton)No (called)Yes
Can call functionsYesYesYes
Can call FB instancesYesNoYes

Functions must remain side-effect-free in standard IEC 61131-3: they cannot instantiate function blocks or write to global variables. This compiler relaxes some of those rules, but keeping functions pure is strongly recommended.

POU Lifecycle

  1. Programs are instantiated once when the runtime starts. Their VAR sections are initialized at that point. On each scan cycle the program body executes, and variables persist until the next cycle.

  2. Functions are called, execute, and return. Local variables exist only for the duration of the call. No state carries over between invocations.

  3. Function blocks are instantiated as variables (typically inside a program or another function block). Their internal state is initialized when the instance is created and persists across every subsequent call. Each instance is independent.

Nesting and Composition

A program can instantiate multiple function blocks, and function blocks can instantiate other function blocks internally. This enables hierarchical composition:

FUNCTION_BLOCK MotorController
  VAR_INPUT
    enable   : BOOL;
    set_rpm  : REAL;
  END_VAR
  VAR_OUTPUT
    at_speed : BOOL;
  END_VAR
  VAR
    ramp : RampGenerator;
    pid  : PID_Controller;
  END_VAR

  ramp(target := set_rpm, enable := enable);
  pid(setpoint := ramp.output, enable := enable);
  at_speed := ABS(pid.error) < 5.0;
END_FUNCTION_BLOCK

Minimal Complete Example

FUNCTION DoubleIt : INT
  VAR_INPUT
    x : INT;
  END_VAR
  DoubleIt := x * 2;
END_FUNCTION

PROGRAM Main
  VAR
    result : INT;
  END_VAR

  result := DoubleIt(x := 10);
  // result is now 20
END_PROGRAM
st-cli run example.st

Programs, functions, and function blocks can all coexist in the same source file or be split across multiple files depending on project conventions.

Data Types

IEC 61131-3 defines a rich set of elementary data types designed for industrial automation. This chapter covers every supported type, the type hierarchy, and the literal formats used to write constant values in code.

Boolean

TypeSizeValues
BOOL1 bitTRUE, FALSE
VAR
  motor_on : BOOL := TRUE;
  fault    : BOOL := FALSE;
END_VAR

Integer Types

Signed and unsigned integers come in four widths:

SignedUnsignedSizeRange
SINTUSINT8-bit-128..127 / 0..255
INTUINT16-bit-32768..32767 / 0..65535
DINTUDINT32-bit-2^31..2^31-1 / 0..2^32-1
LINTULINT64-bit-2^63..2^63-1 / 0..2^64-1
VAR
  temperature : INT  := -40;
  rpm         : UINT := 3600;
  big_count   : LINT := 1_000_000_000;
END_VAR

Underscores in numeric literals are allowed for readability.

Floating-Point Types

TypeSizePrecision
REAL32-bit~7 decimal digits
LREAL64-bit~15 decimal digits
VAR
  setpoint : REAL  := 72.5;
  pi       : LREAL := 3.14159265358979;
END_VAR

Bit-String Types

These types are used for bitwise operations and direct bit access:

TypeSize
BYTE8-bit
WORD16-bit
DWORD32-bit
LWORD64-bit
VAR
  status_flags : WORD  := 16#00FF;
  mask         : DWORD := 2#11110000_11110000_11110000_11110000;
END_VAR

Bit-string types are not interchangeable with integers. Use explicit conversions when mixing arithmetic and bitwise operations.

Time and Duration Types

TypeRepresentsExample literal
TIMEDurationT#5s, T#1h30m
DATECalendar dateD#2024-01-15
TODTime of dayTOD#14:30:00
DTDate and time combinedDT#2024-01-15-14:30:00
VAR
  cycle_time  : TIME := T#100ms;
  start_date  : DATE := D#2024-01-15;
  shift_start : TOD  := TOD#06:00:00;
  timestamp   : DT   := DT#2024-01-15-08:00:00;
END_VAR

Caveat: The keyword DT is a reserved type name. If you name a variable dt, it will conflict with the DT keyword. Since keywords are case-insensitive, dt, Dt, and DT all refer to the type. Use descriptive names like date_time or my_dt instead.

TIME literal components

A TIME literal begins with T# or TIME# followed by one or more components:

  • d – days
  • h – hours
  • m – minutes
  • s – seconds
  • ms – milliseconds
  • us – microseconds
  • ns – nanoseconds
VAR
  short_delay : TIME := T#250ms;
  work_shift  : TIME := T#8h;
  precise     : TIME := T#1m30s500ms;
END_VAR

String Types

TypeEncodingDefault max length
STRINGSingle-byte80 characters
WSTRINGUTF-1680 characters
VAR
  name    : STRING      := 'Hello, PLC!';
  label   : STRING[200] := 'Extended length string';
  unicode : WSTRING     := "Wide string literal";
END_VAR

Single-byte strings use single quotes. Wide strings use double quotes.

Type Hierarchy

The IEC 61131-3 type system is organized hierarchically:

ANY
├── ANY_BIT
│   ├── BOOL
│   ├── BYTE
│   ├── WORD
│   ├── DWORD
│   └── LWORD
├── ANY_NUM
│   ├── ANY_INT
│   │   ├── ANY_SIGNED (SINT, INT, DINT, LINT)
│   │   └── ANY_UNSIGNED (USINT, UINT, UDINT, ULINT)
│   └── ANY_REAL (REAL, LREAL)
├── ANY_STRING (STRING, WSTRING)
├── ANY_DATE (DATE, TOD, DT)
└── ANY_DURATION (TIME)

The ANY_* groups are used in function signatures to accept a range of types. For example, an ADD function accepts ANY_NUM inputs.

Literal Formats

Integer literals

BasePrefixExampleDecimal value
Decimal(none)255255
Hex16#16#FF255
Octal8#8#7763
Binary2#2#101010
VAR
  dec_val : INT   := 100;
  hex_val : INT   := 16#64;
  oct_val : INT   := 8#144;
  bin_val : INT   := 2#01100100;
END_VAR
// All four variables hold the value 100.

Real literals

Real numbers use a decimal point and optional exponent:

VAR
  a : REAL := 3.14;
  b : REAL := 3.14e2;    // 314.0
  c : REAL := 1.0E-3;    // 0.001
  d : REAL := -2.5e+10;
END_VAR

Typed literals

A typed literal forces a specific type on a constant value:

VAR
  x : INT  := INT#42;
  y : REAL := REAL#3.14;
  z : BYTE := BYTE#16#FF;
  w : BOOL := BOOL#1;
END_VAR

This is especially useful in function calls where overload resolution needs a hint, or when assigning to a generic (ANY_NUM) parameter.

Implicit Conversions

Narrowing conversions (e.g., DINT to INT) are not implicit and require an explicit conversion function like DINT_TO_INT(). Widening conversions (e.g., INT to DINT) are generally safe and may be performed implicitly by the compiler.

VAR
  small : INT  := 42;
  big   : DINT;
  back  : INT;
END_VAR

big  := small;              // OK: widening, implicit
back := DINT_TO_INT(big);   // Required: narrowing, explicit

Variables

Variables in Structured Text are declared inside VAR blocks within a POU. The kind of VAR block determines visibility, direction, and lifetime. This chapter covers every variable section, qualifiers, initialization, and declaration syntax.

Variable Sections

VAR – Local Variables

Local variables are private to the POU. They persist across scan cycles in programs and function blocks, but are re-initialized on every call in functions.

PROGRAM Main
  VAR
    cycle_count : INT := 0;
    pressure    : REAL;
  END_VAR

  cycle_count := cycle_count + 1;
END_PROGRAM

VAR_INPUT – Input Parameters

Inputs are passed by value into the POU by the caller. The POU may read but should not write to them.

FUNCTION_BLOCK Heater
  VAR_INPUT
    enable   : BOOL;
    setpoint : REAL := 70.0;
  END_VAR

  IF enable THEN
    // regulate to setpoint
  END_IF;
END_FUNCTION_BLOCK

VAR_OUTPUT – Output Parameters

Outputs are values produced by the POU that the caller can read after invocation via dot notation on function block instances.

FUNCTION_BLOCK TemperatureSensor
  VAR_OUTPUT
    value : REAL;
    valid : BOOL;
  END_VAR

  value := ReadAnalogInput();
  valid := (value > -40.0) AND (value < 150.0);
END_FUNCTION_BLOCK

Reading outputs:

PROGRAM Main
  VAR
    sensor : TemperatureSensor;
  END_VAR

  sensor();
  IF sensor.valid THEN
    // use sensor.value
  END_IF;
END_PROGRAM

VAR_IN_OUT – Pass by Reference

VAR_IN_OUT parameters are passed by reference. The caller must supply a variable (not a literal), and any modification inside the POU is reflected in the caller’s variable.

FUNCTION_BLOCK Accumulator
  VAR_IN_OUT
    total : REAL;
  END_VAR
  VAR_INPUT
    increment : REAL;
  END_VAR

  total := total + increment;
END_FUNCTION_BLOCK
PROGRAM Main
  VAR
    running_sum : REAL := 0.0;
    acc         : Accumulator;
  END_VAR

  acc(total := running_sum, increment := 1.5);
  // running_sum is now 1.5
END_PROGRAM

VAR_GLOBAL – Global Variables

Global variables are declared at the top level (outside any POU or in a dedicated global block) and are visible to all POUs that reference them via VAR_EXTERNAL.

VAR_GLOBAL
  system_mode : INT := 0;
  alarm_active : BOOL := FALSE;
END_VAR

VAR_EXTERNAL – Referencing Globals

A POU uses VAR_EXTERNAL to access a previously declared global variable. The type must match exactly.

PROGRAM Main
  VAR_EXTERNAL
    system_mode : INT;
  END_VAR

  IF system_mode = 1 THEN
    // run mode
  END_IF;
END_PROGRAM

VAR_TEMP – Temporary Variables

Temporary variables exist only for one execution of the POU body. They are re-initialized on every scan cycle, even in programs and function blocks (unlike VAR, which persists).

PROGRAM Main
  VAR_TEMP
    scratch : INT;
  END_VAR

  scratch := HeavyComputation();
  // scratch is gone after this cycle's execution ends
END_PROGRAM

Qualifiers

Qualifiers appear after the VAR keyword (or its variant) and before the variable declarations.

CONSTANT

Declares read-only variables. The value must be set at declaration and cannot be changed.

VAR CONSTANT
  MAX_SPEED     : REAL := 1500.0;
  SENSOR_COUNT  : INT  := 8;
END_VAR

RETAIN

Retained variables survive a warm restart of the PLC. Their values are stored in non-volatile memory.

VAR RETAIN
  total_runtime : TIME := T#0s;
  boot_count    : DINT := 0;
END_VAR

PERSISTENT

Persistent variables survive both warm and cold restarts. They are only reset by an explicit user action.

VAR PERSISTENT
  machine_serial : STRING := '';
  calibration    : REAL   := 1.0;
END_VAR

Combining Qualifiers

Qualifiers can be combined:

VAR RETAIN PERSISTENT
  lifetime_hours : LREAL := 0.0;
END_VAR

Initialization

Every variable can have an initializer using :=. Without one, the variable is zero-initialized (0, FALSE, empty string, T#0s, etc.).

VAR
  a : INT;            // initialized to 0
  b : INT := 42;      // initialized to 42
  c : BOOL;           // initialized to FALSE
  d : STRING := 'OK'; // initialized to 'OK'
END_VAR

Structured initialization

Arrays and structs can be initialized with parenthesized lists:

VAR
  temps  : ARRAY[1..5] OF REAL := [20.0, 21.5, 22.0, 19.8, 20.5];
  origin : Point := (x := 0.0, y := 0.0);
END_VAR

Multiple Variables on One Line

Multiple variables of the same type can be declared on a single line, separated by commas:

VAR
  x, y, z    : REAL := 0.0;
  a, b       : INT;
  run, stop  : BOOL;
END_VAR

All variables on the line share the same type and initial value. If you need different initial values, use separate declarations.

Practical Example: State Machine Variables

PROGRAM ConveyorControl
  VAR
    state      : INT := 0;
    speed      : REAL := 0.0;
    item_count : DINT := 0;
  END_VAR
  VAR_INPUT
    start_btn  : BOOL;
    stop_btn   : BOOL;
    sensor     : BOOL;
  END_VAR
  VAR_OUTPUT
    motor_cmd  : REAL;
    running    : BOOL;
  END_VAR
  VAR CONSTANT
    MAX_SPEED  : REAL := 2.0;  // m/s
  END_VAR
  VAR RETAIN
    total_items : DINT := 0;
  END_VAR

  CASE state OF
    0: // Idle
      IF start_btn THEN
        state := 1;
      END_IF;
    1: // Running
      speed := MAX_SPEED;
      IF sensor THEN
        item_count := item_count + 1;
        total_items := total_items + 1;
      END_IF;
      IF stop_btn THEN
        state := 0;
        speed := 0.0;
      END_IF;
  END_CASE;

  motor_cmd := speed;
  running := (state = 1);
END_PROGRAM

Summary

SectionDirectionPersistencePassed by
VARLocalPer POU typeN/A
VAR_INPUTInN/AValue
VAR_OUTPUTOutN/AValue
VAR_IN_OUTIn/OutN/AReference
VAR_GLOBALGlobalProgram scopeN/A
VAR_EXTERNALGlobal refProgram scopeN/A
VAR_TEMPLocalSingle executionN/A

Expressions

Expressions in Structured Text combine variables, literals, operators, and function calls to produce values. This chapter covers operator precedence, grouping with parentheses, and using function calls within expressions.

Operator Precedence

Operators are listed from highest to lowest precedence. Operators on the same row have equal precedence and are evaluated left-to-right.

PrecedenceOperator(s)Description
1 (highest)**Exponentiation
2- (unary), NOTNegation, bitwise/logical NOT
3*, /, MODMultiplication, division, modulo
4+, -Addition, subtraction
5<, >, <=, >=Relational comparisons
6=, <>Equality, inequality
7AND, &Logical/bitwise AND
8XORLogical/bitwise XOR
9 (lowest)ORLogical/bitwise OR

When in doubt, use parentheses to make intent explicit.

Arithmetic Operators

Standard arithmetic works on numeric types (ANY_NUM):

VAR
  a, b, result : INT;
END_VAR

a := 10;
b := 3;

result := a + b;    // 13
result := a - b;    // 7
result := a * b;    // 30
result := a / b;    // 3 (integer division)
result := a MOD b;  // 1

Exponentiation

The ** operator raises the left operand to the power of the right operand:

VAR
  x : REAL;
END_VAR

x := 2.0 ** 10.0;   // 1024.0
x := 3.0 ** 0.5;    // square root of 3

Unary Negation

VAR
  speed : REAL := 50.0;
  reverse_speed : REAL;
END_VAR

reverse_speed := -speed;  // -50.0

Comparison Operators

Comparisons return BOOL:

VAR
  temp    : REAL := 85.0;
  too_hot : BOOL;
  in_range: BOOL;
END_VAR

too_hot  := temp > 100.0;
in_range := (temp >= 60.0) AND (temp <= 100.0);

The full set:

OperatorMeaning
=Equal to
<>Not equal to
<Less than
>Greater than
<=Less than or equal
>=Greater than or equal

Note that = is the equality comparison operator, not assignment. Assignment uses :=.

Logical and Bitwise Operators

AND, OR, XOR, and NOT operate on BOOL values (logical) or bit-string types (bitwise), depending on the operand types:

VAR
  a, b   : BOOL;
  result : BOOL;
END_VAR

result := a AND b;
result := a OR b;
result := a XOR b;
result := NOT a;

The & symbol is an alternative for AND:

result := a & b;  // same as a AND b

Bitwise example

VAR
  flags  : WORD := 16#FF00;
  mask   : WORD := 16#0F0F;
  masked : WORD;
END_VAR

masked := flags AND mask;  // 16#0F00

Parentheses

Parentheses override the default precedence:

VAR
  a : INT := 2;
  b : INT := 3;
  c : INT := 4;
  r : INT;
END_VAR

r := a + b * c;     // 14  (multiplication first)
r := (a + b) * c;   // 20  (addition first)

A practical example – checking a sensor range with debounce:

VAR
  pressure     : REAL;
  pump_active  : BOOL;
  override     : BOOL;
  should_run   : BOOL;
END_VAR

should_run := (pressure < 50.0 OR override) AND NOT pump_active;

Without parentheses this expression would bind differently due to AND having higher precedence than OR. Always parenthesize mixed AND/OR expressions.

Function Calls in Expressions

Functions that return a value can be used directly inside expressions:

FUNCTION ABS_REAL : REAL
  VAR_INPUT
    x : REAL;
  END_VAR
  IF x < 0.0 THEN
    ABS_REAL := -x;
  ELSE
    ABS_REAL := x;
  END_IF;
END_FUNCTION

PROGRAM Main
  VAR
    error    : REAL := -3.5;
    clamped  : REAL;
  END_VAR

  // Function call as part of a larger expression
  clamped := ABS_REAL(x := error) * 2.0 + 1.0;
  // result: 8.0
END_PROGRAM

Nested function calls are also valid:

VAR
  angle : REAL;
  dist  : REAL;
END_VAR

dist := SQRT(SIN(angle) ** 2.0 + COS(angle) ** 2.0);
// Always 1.0, but demonstrates nesting

Precedence in Practice

Consider a PID error calculation:

VAR
  setpoint     : REAL := 100.0;
  measured     : REAL := 95.0;
  deadband     : REAL := 2.0;
  error        : REAL;
  needs_action : BOOL;
END_VAR

error := setpoint - measured;
needs_action := error > deadband OR error < -deadband;

Because > and < bind tighter than OR, this evaluates as:

(error > deadband) OR (error < -deadband)

which is the intended behavior. Still, explicit parentheses make the intent clearer for anyone reading the code.

Common Pitfalls

Confusing = and :=. The single = is comparison, not assignment. Writing x = 5 in an IF condition compares; writing x := 5 assigns.

Integer division truncates. 7 / 2 yields 3, not 3.5. Use REAL operands for fractional results: 7.0 / 2.0 yields 3.5.

MOD with negative operands. The sign of the result follows the dividend: -7 MOD 3 yields -1.

NOT precedence. NOT a AND b means (NOT a) AND b, not NOT (a AND b). Parenthesize when in doubt.

Statements

Statements are the executable instructions that make up a POU body. Structured Text provides assignment, conditional branching, looping, and flow control. Every statement ends with a semicolon.

Assignment

The assignment operator is :=. The left side must be a variable; the right side is any expression of a compatible type.

VAR
  speed    : REAL;
  counter  : INT;
  running  : BOOL;
END_VAR

speed   := 1500.0;
counter := counter + 1;
running := speed > 0.0;

IF / ELSIF / ELSE / END_IF

The IF statement executes blocks conditionally. ELSIF and ELSE branches are optional.

IF temperature > 100.0 THEN
  alarm := TRUE;
  heater := FALSE;
ELSIF temperature < 60.0 THEN
  heater := TRUE;
ELSE
  // in range, maintain
  heater := heater;
END_IF;

Multiple ELSIF branches are allowed:

IF level = 0 THEN
  state_name := 'IDLE';
ELSIF level = 1 THEN
  state_name := 'LOW';
ELSIF level = 2 THEN
  state_name := 'MEDIUM';
ELSIF level = 3 THEN
  state_name := 'HIGH';
ELSE
  state_name := 'UNKNOWN';
END_IF;

For many discrete values, prefer CASE (below).

CASE / OF / END_CASE

The CASE statement selects among branches based on an integer or enumeration value.

CASE machine_state OF
  0:
    // Idle
    motor := FALSE;
    valve := FALSE;

  1:
    // Starting
    motor := TRUE;
    valve := FALSE;

  2:
    // Running
    motor := TRUE;
    valve := TRUE;

  3:
    // Stopping
    motor := FALSE;
    valve := TRUE;

ELSE
  // Unknown state, emergency stop
  motor := FALSE;
  valve := FALSE;
  alarm := TRUE;
END_CASE;

Multiple values per branch

A single branch can match several values using commas or ranges:

CASE error_code OF
  0:
    status := 'OK';

  1, 2, 3:
    status := 'WARNING';

  10..19:
    status := 'SENSOR_FAULT';

  20..29:
    status := 'COMM_FAULT';

ELSE
  status := 'UNKNOWN';
END_CASE;

FOR / TO / BY / DO / END_FOR

The FOR loop iterates a counter from a start value to an end value with an optional step.

VAR
  i     : INT;
  total : INT := 0;
  data  : ARRAY[1..10] OF INT;
END_VAR

FOR i := 1 TO 10 DO
  total := total + data[i];
END_FOR;

BY clause

The BY keyword specifies the step. It defaults to 1 if omitted.

// Count down from 10 to 0
FOR i := 10 TO 0 BY -1 DO
  // process in reverse
END_FOR;
// Step by 2 (even indices only)
FOR i := 0 TO 100 BY 2 DO
  even_sum := even_sum + i;
END_FOR;

The loop variable should not be modified inside the loop body. Doing so leads to undefined behavior in standard IEC 61131-3.

WHILE / DO / END_WHILE

The WHILE loop repeats as long as its condition is TRUE. The condition is checked before each iteration, so the body may never execute.

VAR
  pressure : REAL;
END_VAR

WHILE pressure < 100.0 DO
  pressure := pressure + ReadPressureIncrement();
END_WHILE;

A practical example – draining a buffer:

VAR
  buffer_count : INT;
END_VAR

WHILE buffer_count > 0 DO
  ProcessNextItem();
  buffer_count := buffer_count - 1;
END_WHILE;

REPEAT / UNTIL / END_REPEAT

The REPEAT loop is like WHILE but checks its condition after the body, so the body always executes at least once.

VAR
  attempts : INT := 0;
  success  : BOOL := FALSE;
END_VAR

REPEAT
  attempts := attempts + 1;
  success := TryConnect();
UNTIL success OR (attempts >= 3)
END_REPEAT;

Note: the condition follows UNTIL without a THEN or DO. The loop exits when the condition becomes TRUE.

RETURN

RETURN exits the current POU immediately. In a function, it returns whatever value has been assigned to the function name so far. In a program or function block, it ends the current scan cycle execution for that POU.

FUNCTION SafeDivide : REAL
  VAR_INPUT
    numerator   : REAL;
    denominator : REAL;
  END_VAR

  IF denominator = 0.0 THEN
    SafeDivide := 0.0;
    RETURN;
  END_IF;

  SafeDivide := numerator / denominator;
END_FUNCTION

EXIT

EXIT breaks out of the innermost FOR, WHILE, or REPEAT loop.

VAR
  i     : INT;
  found : INT := -1;
  data  : ARRAY[1..100] OF INT;
END_VAR

FOR i := 1 TO 100 DO
  IF data[i] = 42 THEN
    found := i;
    EXIT;
  END_IF;
END_FOR;
// If found <> -1, data[found] = 42

EXIT only affects the innermost loop. In nested loops, the outer loop continues:

VAR
  row, col : INT;
END_VAR

FOR row := 1 TO 10 DO
  FOR col := 1 TO 10 DO
    IF matrix[row, col] = 0 THEN
      EXIT;  // exits inner loop only
    END_IF;
  END_FOR;
  // continues with next row
END_FOR;

Empty Statement

A lone semicolon is a valid empty statement. It does nothing and can be useful as a placeholder:

IF condition THEN
  ;  // intentionally empty, to be implemented
END_IF;

Complete Example: Simple State Machine

PROGRAM BatchMixer
  VAR
    state     : INT := 0;
    timer     : INT := 0;
    fill_done : BOOL;
    mix_time  : INT := 100;
  END_VAR

  CASE state OF
    0: // IDLE
      IF start_cmd THEN
        state := 1;
        timer := 0;
      END_IF;

    1: // FILLING
      fill_valve := TRUE;
      IF fill_done THEN
        fill_valve := FALSE;
        state := 2;
        timer := 0;
      END_IF;

    2: // MIXING
      mixer_motor := TRUE;
      timer := timer + 1;
      IF timer >= mix_time THEN
        mixer_motor := FALSE;
        state := 3;
      END_IF;

    3: // DRAINING
      drain_valve := TRUE;
      IF level <= 0 THEN
        drain_valve := FALSE;
        state := 0;
      END_IF;

  ELSE
    // fault recovery
    state := 0;
  END_CASE;
END_PROGRAM
st-cli run batch_mixer.st

Functions

A FUNCTION is a stateless POU that accepts inputs, performs a computation, and returns a single value. Functions have no persistent state – local variables are initialized fresh on every call. This makes functions ideal for pure computations: conversions, math helpers, validation, and formatting.

Declaration

A function declaration specifies the function name and its return type after the colon:

FUNCTION FunctionName : ReturnType
  VAR_INPUT
    // input parameters
  END_VAR
  VAR
    // local variables
  END_VAR

  // body
END_FUNCTION

Return Value

The return value is assigned by writing to the function name itself:

FUNCTION CelsiusToFahrenheit : REAL
  VAR_INPUT
    celsius : REAL;
  END_VAR

  CelsiusToFahrenheit := celsius * 9.0 / 5.0 + 32.0;
END_FUNCTION

You may assign to the function name multiple times. The last assigned value before the function returns is the result:

FUNCTION Classify : INT
  VAR_INPUT
    value : REAL;
  END_VAR

  Classify := 0;  // default: nominal

  IF value > 100.0 THEN
    Classify := 2;  // high
  ELSIF value > 80.0 THEN
    Classify := 1;  // warning
  ELSIF value < 0.0 THEN
    Classify := -1; // underrange
  END_IF;
END_FUNCTION

Use RETURN to exit early after assigning the return value:

FUNCTION SafeSqrt : REAL
  VAR_INPUT
    x : REAL;
  END_VAR

  IF x < 0.0 THEN
    SafeSqrt := -1.0;
    RETURN;
  END_IF;

  SafeSqrt := SQRT(x);
END_FUNCTION

Calling with Named Arguments

Named (formal) argument syntax passes each parameter by name using :=:

PROGRAM Main
  VAR
    temp_f : REAL;
  END_VAR

  temp_f := CelsiusToFahrenheit(celsius := 100.0);
  // temp_f = 212.0
END_PROGRAM

Named arguments can appear in any order:

FUNCTION Clamp : REAL
  VAR_INPUT
    value : REAL;
    low   : REAL;
    high  : REAL;
  END_VAR

  IF value < low THEN
    Clamp := low;
  ELSIF value > high THEN
    Clamp := high;
  ELSE
    Clamp := value;
  END_IF;
END_FUNCTION

PROGRAM Main
  VAR
    result : REAL;
  END_VAR

  // Order does not matter with named arguments
  result := Clamp(high := 100.0, value := 150.0, low := 0.0);
  // result = 100.0
END_PROGRAM

Calling with Positional Arguments

Arguments can also be passed positionally, matching the declaration order of VAR_INPUT:

result := Clamp(150.0, 0.0, 100.0);
// value=150.0, low=0.0, high=100.0

Do not mix positional and named arguments in the same call. Use one style consistently.

Local Variables

Functions can declare local variables in a VAR block. These are initialized on every call and do not persist:

FUNCTION Average : REAL
  VAR_INPUT
    a, b, c : REAL;
  END_VAR
  VAR
    sum : REAL;
  END_VAR

  sum := a + b + c;
  Average := sum / 3.0;
END_FUNCTION

Functions Without Inputs

A function may have no inputs, though this is uncommon:

FUNCTION GetTimestamp : LINT
  GetTimestamp := ReadSystemClock();
END_FUNCTION

Recursive Functions

Functions may call themselves recursively, but be cautious of stack depth in resource-constrained PLC environments:

FUNCTION Factorial : LINT
  VAR_INPUT
    n : LINT;
  END_VAR

  IF n <= 1 THEN
    Factorial := 1;
  ELSE
    Factorial := n * Factorial(n := n - 1);
  END_IF;
END_FUNCTION

Practical Examples

Linear Interpolation

FUNCTION Lerp : REAL
  VAR_INPUT
    a : REAL;  // start value
    b : REAL;  // end value
    t : REAL;  // 0.0 to 1.0
  END_VAR

  Lerp := a + (b - a) * t;
END_FUNCTION

Scaling an Analog Input

A common PLC task is converting a raw ADC count to engineering units:

FUNCTION ScaleAnalog : REAL
  VAR_INPUT
    raw      : INT;    // 0..32767
    eng_low  : REAL;   // engineering low  (e.g., 0.0 PSI)
    eng_high : REAL;   // engineering high (e.g., 100.0 PSI)
  END_VAR
  VAR
    fraction : REAL;
  END_VAR

  fraction := INT_TO_REAL(raw) / 32767.0;
  ScaleAnalog := eng_low + fraction * (eng_high - eng_low);
END_FUNCTION

PROGRAM Main
  VAR
    raw_pressure : INT := 16384;
    pressure_psi : REAL;
  END_VAR

  pressure_psi := ScaleAnalog(
    raw := raw_pressure,
    eng_low := 0.0,
    eng_high := 100.0
  );
  // pressure_psi ~ 50.0
END_PROGRAM

Bitfield Check

FUNCTION IsBitSet : BOOL
  VAR_INPUT
    value : DWORD;
    bit   : INT;
  END_VAR

  IsBitSet := (value AND SHL(DWORD#1, bit)) <> 0;
END_FUNCTION

Restrictions

Standard IEC 61131-3 places the following restrictions on functions:

  • Functions must not instantiate function blocks.
  • Functions must not write to global variables.
  • Functions must not have side effects.

These restrictions ensure that a function’s output depends only on its inputs, making programs easier to reason about. This compiler may relax some of these constraints, but adhering to them is strongly recommended for portable, maintainable code.

Function Blocks

A FUNCTION_BLOCK is a stateful Program Organisation Unit. Unlike plain functions, function blocks retain their internal variables across calls, making them the primary building block for timers, counters, PID loops, state machines, and communication handlers.

Declaration

FUNCTION_BLOCK BlockName
  VAR_INPUT
    enable : BOOL;         (* supplied by caller *)
  END_VAR
  VAR_OUTPUT
    result : INT;          (* readable by caller after the call *)
  END_VAR
  VAR_IN_OUT
    shared : REAL;         (* passed by reference *)
  END_VAR
  VAR
    internal : INT := 0;   (* private persistent state *)
  END_VAR

  (* body *)
END_FUNCTION_BLOCK

Variable Sections

SectionDirectionSemantics
VAR_INPUTInCopied from caller at invocation
VAR_OUTPUTOutReadable by caller via dot notation
VAR_IN_OUTIn/OutPassed by reference; caller must supply a variable, not a literal
VARPrivateInternal state, invisible to caller

Instantiation

Function blocks are used by declaring instances as variables. Each instance owns an independent copy of all internal state.

PROGRAM Main
  VAR
    counter1 : UpCounter;
    counter2 : UpCounter;
  END_VAR

  counter1(increment := TRUE);
  counter2(increment := FALSE);
  (* counter1 and counter2 have completely separate state *)
END_PROGRAM

Calling with Named Parameters

Invoke an instance by writing its name followed by parenthesised named arguments using the := assignment syntax:

delay(IN := start_signal, PT := T#5s);

All VAR_INPUT parameters that have no default must be supplied. Parameters with defaults may be omitted.

Accessing Outputs via Dot Notation

After calling an instance, read its VAR_OUTPUT fields with instance.output:

delay(IN := start_signal, PT := T#5s);

IF delay.Q THEN
  (* 5 seconds have elapsed *)
END_IF;

elapsed := delay.ET;

You may also read outputs without calling first, which returns the value from the previous cycle.

State Persistence Across Calls

VAR and VAR_OUTPUT variables keep their values between calls. This is the key difference from a plain FUNCTION:

FUNCTION_BLOCK UpCounter
  VAR_INPUT
    increment : BOOL;
    reset     : BOOL;
  END_VAR
  VAR_OUTPUT
    count : INT := 0;
  END_VAR
  VAR
    prev_increment : BOOL := FALSE;
  END_VAR

  IF reset THEN
    count := 0;
  ELSIF increment AND NOT prev_increment THEN
    count := count + 1;   (* rising edge detection *)
  END_IF;

  prev_increment := increment;
END_FUNCTION_BLOCK

Each scan cycle picks up exactly where the last one left off.

Realistic Example: Timer-Like Block

FUNCTION_BLOCK CycleTimer
  VAR_INPUT
    IN : BOOL;
    PT : INT;        (* preset in scan cycles *)
  END_VAR
  VAR_OUTPUT
    Q  : BOOL;
    ET : INT := 0;
  END_VAR
  VAR
    running : BOOL := FALSE;
  END_VAR

  IF IN THEN
    IF NOT running THEN
      ET := 0;
      running := TRUE;
    END_IF;
    ET := ET + 1;
    Q := ET >= PT;
  ELSE
    running := FALSE;
    Q := FALSE;
    ET := 0;
  END_IF;
END_FUNCTION_BLOCK

PROGRAM Main
  VAR
    btn     : BOOL;
    tmr     : CycleTimer;
    motor   : BOOL;
  END_VAR

  tmr(IN := btn, PT := 100);
  motor := tmr.Q;
END_PROGRAM

Nesting Function Blocks

A function block can instantiate other function blocks in its VAR section:

FUNCTION_BLOCK Controller
  VAR
    filter  : LowPass;
    limiter : Clamp;
  END_VAR
  (* ... *)
  filter(input := raw_value, alpha := 0.1);
  limiter(value := filter.output, lo := 0.0, hi := 100.0);
  result := limiter.clamped;
END_FUNCTION_BLOCK

Summary

AspectFUNCTIONFUNCTION_BLOCK
State persistenceNoneYes, per instance
Return valueOne, via nameNone (use VAR_OUTPUT)
InstantiationCalled directlyDeclared as a variable
Use casesPure computationTimers, counters, state

Classes, Methods & Interfaces

IEC 61131-3 Third Edition introduces object-oriented extensions. A CLASS is a stateful Program Organisation Unit — like a FUNCTION_BLOCK — but with explicit methods, access control, inheritance, and interface implementation.

Class Declaration

CLASS ClassName
  VAR_INPUT
    setpoint : INT;            (* supplied by caller *)
  END_VAR
  VAR_OUTPUT
    output : INT;              (* readable via dot notation *)
  END_VAR
  VAR
    _internal : INT := 0;     (* private persistent state *)
  END_VAR

  METHOD MethodName : INT      (* methods define behavior *)
  VAR_INPUT
    param : INT;
  END_VAR
    MethodName := _internal + param;
  END_METHOD
END_CLASS

Instantiation

Classes are instantiated by declaring variables of the class type. Each instance has completely independent state.

PROGRAM Main
  VAR
    ctrl1 : MotorController;
    ctrl2 : MotorController;
  END_VAR

  ctrl1.Start();
  ctrl2.Stop();
  (* ctrl1 and ctrl2 have separate internal state *)
END_PROGRAM

Methods

Methods define the behavior of a class. They can accept parameters, return values, and access the class’s variables directly.

Void methods (no return value)

CLASS Counter
  VAR
    _count : INT := 0;
  END_VAR

  METHOD Increment
    _count := _count + 1;
  END_METHOD

  METHOD Reset
    _count := 0;
  END_METHOD
END_CLASS

Methods with return values

Name the return type after a colon. Assign the return value using the method name:

  METHOD GetCount : INT
    GetCount := _count;
  END_METHOD

  METHOD Add : INT
  VAR_INPUT
    a : INT;
    b : INT;
  END_VAR
    Add := a + b;
  END_METHOD

Calling methods

Use dot notation with named or positional arguments:

  counter.Increment();
  val := counter.GetCount();
  sum := counter.Add(a := 10, b := 20);

Access Specifiers

Control member visibility with PUBLIC, PRIVATE, PROTECTED, or INTERNAL:

CLASS Sensor
  VAR
    _raw : INT := 0;
  END_VAR

  PUBLIC METHOD GetValue : INT
    GetValue := _raw;
  END_METHOD

  PRIVATE METHOD Calibrate
    _raw := _raw + 1;
  END_METHOD

  PROTECTED METHOD InternalUpdate
    (* accessible within this class and subclasses *)
  END_METHOD
END_CLASS

If omitted, methods default to PUBLIC.

Interfaces

An INTERFACE declares a contract — a set of method signatures that implementing classes must provide.

INTERFACE IResettable
  METHOD Reset
  END_METHOD
END_INTERFACE

INTERFACE IControllable
  METHOD Enable
  END_METHOD
  METHOD Disable
  END_METHOD
END_INTERFACE

Implementing interfaces

Use IMPLEMENTS to declare that a class fulfils an interface. The compiler verifies that all required methods are present:

CLASS Motor IMPLEMENTS IControllable, IResettable
  VAR
    _running : BOOL := FALSE;
  END_VAR

  METHOD Enable
    _running := TRUE;
  END_METHOD

  METHOD Disable
    _running := FALSE;
  END_METHOD

  METHOD Reset
    _running := FALSE;
  END_METHOD
END_CLASS

Interface inheritance

Interfaces can extend other interfaces:

INTERFACE IFullCounter EXTENDS IResettable
  METHOD Increment
  END_METHOD
  METHOD GetCount : INT
  END_METHOD
END_INTERFACE

Inheritance

Use EXTENDS to create a subclass that inherits all variables and methods from a base class:

CLASS Base
  VAR
    _value : INT := 0;
  END_VAR

  METHOD GetValue : INT
    GetValue := _value;
  END_METHOD
END_CLASS

CLASS Derived EXTENDS Base
  VAR
    _extra : INT := 0;
  END_VAR

  METHOD GetSum : INT
    (* can access both inherited _value and own _extra *)
    GetSum := _value + _extra;
  END_METHOD
END_CLASS

Calling inherited methods

A derived instance can call methods defined in any ancestor class:

VAR d : Derived; END_VAR
  val := d.GetValue();    (* inherited from Base *)
  sum := d.GetSum();      (* defined in Derived *)

Overriding methods

Use OVERRIDE to replace a parent method’s behavior:

CLASS Base
  METHOD Process : INT
    Process := 0;
  END_METHOD
END_CLASS

CLASS Derived EXTENDS Base
  OVERRIDE METHOD Process : INT
    Process := 42;    (* replaces Base.Process *)
  END_METHOD
END_CLASS

The compiler checks that an overridden method actually exists in the base class.

Abstract Classes

An ABSTRACT class cannot be instantiated directly. It defines methods that subclasses must implement:

ABSTRACT CLASS Shape
  ABSTRACT METHOD Area : REAL
  END_METHOD

  METHOD Describe : INT
    Describe := 1;    (* concrete method — inherited as-is *)
  END_METHOD
END_CLASS

CLASS Rectangle EXTENDS Shape
  VAR
    _w : REAL := 0.0;
    _h : REAL := 0.0;
  END_VAR

  METHOD SetSize
  VAR_INPUT w : REAL; h : REAL; END_VAR
    _w := w;  _h := h;
  END_METHOD

  OVERRIDE METHOD Area : REAL
    Area := _w * _h;   (* must implement abstract method *)
  END_METHOD
END_CLASS

Abstract methods have no body — only the ABSTRACT METHOD ... END_METHOD signature.

Final Classes and Methods

FINAL prevents further extension or overriding:

FINAL CLASS Singleton
  (* this class cannot be extended *)
END_CLASS

CLASS Base
  FINAL METHOD Locked : INT
    Locked := 42;   (* this method cannot be overridden *)
  END_METHOD
END_CLASS

Properties

Properties provide getter/setter syntax for encapsulated field access:

CLASS Thermostat
  VAR
    _setpoint : INT := 20;
  END_VAR

  PROPERTY Setpoint : INT
    GET
      Setpoint := _setpoint;
    END_GET
    SET
      IF Setpoint >= 0 THEN
        _setpoint := Setpoint;
      END_IF;
    END_SET
  END_PROPERTY
END_CLASS

Read-only properties omit the SET block.

Pointers and Classes

Class methods can accept and use pointers to modify external state:

CLASS Logger
  VAR _count : INT := 0; END_VAR

  METHOD WriteCount
  VAR_INPUT target : REF_TO INT; END_VAR
    IF target <> NULL THEN
      target^ := _count;
    END_IF;
  END_METHOD

  METHOD Log
    _count := _count + 1;
  END_METHOD
END_CLASS

Multi-File Projects

Classes, interfaces, and functions can be split across files. The two-pass compiler resolves forward references automatically — file order does not matter:

project/
  interfaces/resettable.st     (* INTERFACE IResettable *)
  classes/sensor.st            (* CLASS Sensor IMPLEMENTS IResettable *)
  classes/controller.st        (* CLASS TempController EXTENDS BaseController *)
  utils/math.st                (* FUNCTION Clamp *)
  main.st                      (* PROGRAM Main — uses all of the above *)
  plc-project.yaml

See playground/oop_project/ for a complete multi-file example.

Realistic Example

A complete PID-like controller using classes, inheritance, and interfaces:

INTERFACE IResettable
  METHOD Reset
  END_METHOD
END_INTERFACE

CLASS BaseController IMPLEMENTS IResettable
  VAR
    _enabled : BOOL := FALSE;
    _output  : INT := 0;
  END_VAR

  METHOD Enable
    _enabled := TRUE;
  END_METHOD

  METHOD Disable
    _enabled := FALSE;
    _output := 0;
  END_METHOD

  METHOD GetOutput : INT
    GetOutput := _output;
  END_METHOD

  METHOD Reset
    _enabled := FALSE;
    _output := 0;
  END_METHOD
END_CLASS

CLASS TempController EXTENDS BaseController
  VAR
    _setpoint : INT := 50;
    _gain     : INT := 20;
  END_VAR

  METHOD Configure
  VAR_INPUT sp : INT; gain : INT; END_VAR
    _setpoint := sp;
    _gain := gain;
  END_METHOD

  METHOD Compute
  VAR_INPUT pv : INT; END_VAR
  VAR error : INT; END_VAR
    IF _enabled THEN
      error := _setpoint - pv;
      _output := (error * _gain) / 10;
      IF _output < 0 THEN _output := 0; END_IF;
      IF _output > 100 THEN _output := 100; END_IF;
    ELSE
      _output := 0;
    END_IF;
  END_METHOD
END_CLASS

PROGRAM Main
  VAR
    ctrl : TempController;
    simTemp : INT := 30;
  END_VAR

  ctrl.Configure(sp := 50, gain := 15);
  ctrl.Enable();
  ctrl.Compute(pv := simTemp);
  (* ctrl.GetOutput() returns the control action *)
END_PROGRAM

Comparison: CLASS vs FUNCTION_BLOCK

AspectFUNCTION_BLOCKCLASS
State persistenceYesYes
MethodsSingle body onlyNamed methods with access control
InheritanceNoEXTENDS single inheritance
InterfacesNoIMPLEMENTS one or more
Abstract/FinalNoABSTRACT, FINAL modifiers
PropertiesNoPROPERTY with GET/SET
Calling conventionfb(input := val);obj.Method(param := val);
Output accessfb.outputobj.Method() or obj.field

Playground Examples

  • playground/10_classes.st — basic OOP feature tour
  • playground/11_class_patterns.st — industrial patterns (state machines, alarm managers, 3-level inheritance)
  • playground/14_class_instances.st — lifecycle, composition, producer-consumer
  • playground/oop_project/ — multi-file project with classes across files
st-cli run playground/10_classes.st -n 10
st-cli run playground/oop_project/ -n 100

User-Defined Types

IEC 61131-3 allows you to define custom types using TYPE ... END_TYPE blocks. This includes structures, enumerations, arrays, subrange types, and type aliases. User-defined types improve readability, enforce constraints, and enable structured data modeling.

TYPE Block Syntax

All user-defined types are declared inside TYPE ... END_TYPE:

TYPE
  MyType : <type definition>;
END_TYPE

Multiple types can be declared in a single block.

Structures (STRUCT)

Structures group related variables into a single composite type:

TYPE
  MotorData : STRUCT
    speed    : REAL;
    current  : REAL;
    running  : BOOL;
    fault    : BOOL;
    op_hours : LREAL;
  END_STRUCT;
END_TYPE

Using structures

PROGRAM Main
  VAR
    pump_motor : MotorData;
    fan_motor  : MotorData;
  END_VAR

  pump_motor.speed := 1450.0;
  pump_motor.running := TRUE;

  IF pump_motor.fault THEN
    pump_motor.speed := 0.0;
    pump_motor.running := FALSE;
  END_IF;
END_PROGRAM

Nested structures

Structures can contain other structures:

TYPE
  Coordinate : STRUCT
    x : REAL;
    y : REAL;
    z : REAL;
  END_STRUCT;

  RobotArm : STRUCT
    position : Coordinate;
    target   : Coordinate;
    speed    : REAL;
    gripper  : BOOL;
  END_STRUCT;
END_TYPE

PROGRAM Main
  VAR
    arm : RobotArm;
  END_VAR

  arm.position.x := 100.0;
  arm.target.x := 200.0;
  arm.gripper := TRUE;
END_PROGRAM

Initialization

Structure variables can be initialized with named field values:

VAR
  origin : Coordinate := (x := 0.0, y := 0.0, z := 0.0);
  arm    : RobotArm := (speed := 50.0, gripper := FALSE);
END_VAR

Fields not explicitly initialized default to their zero value.

Enumerations

Enumerations define a type with a fixed set of named values:

TYPE
  MachineState : (IDLE, STARTING, RUNNING, STOPPING, FAULTED);
END_TYPE

Using enumerations

PROGRAM Main
  VAR
    state : MachineState := MachineState#IDLE;
  END_VAR

  CASE state OF
    MachineState#IDLE:
      IF start_button THEN
        state := MachineState#STARTING;
      END_IF;

    MachineState#STARTING:
      state := MachineState#RUNNING;

    MachineState#RUNNING:
      IF stop_button THEN
        state := MachineState#STOPPING;
      END_IF;

    MachineState#FAULTED:
      IF reset_button THEN
        state := MachineState#IDLE;
      END_IF;
  END_CASE;
END_PROGRAM

The qualified syntax MachineState#IDLE avoids ambiguity when multiple enumerations share a value name.

Enumerations with explicit values

You can assign integer values to enumeration members:

TYPE
  AlarmPriority : (
    NONE     := 0,
    LOW      := 1,
    MEDIUM   := 2,
    HIGH     := 3,
    CRITICAL := 4
  );
END_TYPE

This is useful when the enumeration must map to specific protocol codes, register values, or database entries.

TYPE
  CommStatus : (
    OK            := 0,
    TIMEOUT       := 16#01,
    CRC_ERROR     := 16#02,
    FRAME_ERROR   := 16#04,
    DISCONNECTED  := 16#FF
  );
END_TYPE

Arrays

Arrays are declared with index ranges using ARRAY[low..high] OF type:

TYPE
  TenReals : ARRAY[1..10] OF REAL;
  SensorArray : ARRAY[0..7] OF INT;
END_TYPE

Inline array declarations

Arrays can also be declared directly in variable sections without a separate TYPE:

VAR
  temperatures : ARRAY[1..8] OF REAL;
  error_log    : ARRAY[0..99] OF INT;
END_VAR

Multi-dimensional arrays

TYPE
  Matrix3x3 : ARRAY[1..3, 1..3] OF REAL;
END_TYPE

PROGRAM Main
  VAR
    transform : Matrix3x3;
  END_VAR

  transform[1, 1] := 1.0;
  transform[2, 2] := 1.0;
  transform[3, 3] := 1.0;
  // identity matrix diagonal
END_PROGRAM

Array initialization

VAR
  weights : ARRAY[1..5] OF REAL := [1.0, 2.0, 3.0, 4.0, 5.0];
  flags   : ARRAY[0..3] OF BOOL := [TRUE, FALSE, FALSE, TRUE];
END_VAR

Arrays of structures

TYPE
  SensorReading : STRUCT
    channel : INT;
    value   : REAL;
    valid   : BOOL;
  END_STRUCT;
END_TYPE

VAR
  readings : ARRAY[1..16] OF SensorReading;
END_VAR
FOR i := 1 TO 16 DO
  IF readings[i].valid THEN
    total := total + readings[i].value;
    count := count + 1;
  END_IF;
END_FOR;

Subrange Types

A subrange type restricts an integer type to a specific range of values:

TYPE
  Percentage : INT(0..100);
  DayOfWeek  : USINT(1..7);
  MotorRPM   : UINT(0..3600);
END_TYPE

Assigning a value outside the declared range is a runtime error:

VAR
  level : Percentage;
END_VAR

level := 50;   // OK
level := 150;  // runtime error: out of range

Subrange types are useful for self-documenting code and catching logic errors early.

Type Aliases

A type alias gives a new name to an existing type:

TYPE
  Temperature : REAL;
  Pressure    : REAL;
  FlowRate    : REAL;
  ErrorCode   : DINT;
END_TYPE

Aliases improve readability and allow you to change the underlying type in one place:

VAR
  tank_temp  : Temperature := 25.0;
  tank_press : Pressure := 101.3;
  flow       : FlowRate;
  error      : ErrorCode;
END_VAR

Note that aliases are structurally typed – a Temperature and a Pressure are both REAL and can be used interchangeably. They do not create distinct types for type-checking purposes.

Complete Example: Recipe System

TYPE
  IngredientUnit : (GRAMS, MILLILITERS, UNITS);

  Ingredient : STRUCT
    name     : STRING[50];
    amount   : REAL;
    unit     : IngredientUnit;
    added    : BOOL;
  END_STRUCT;

  Recipe : STRUCT
    name        : STRING[100];
    ingredients : ARRAY[1..10] OF Ingredient;
    step_count  : INT;
    mix_time    : TIME;
    temperature : REAL;
  END_STRUCT;

  BatchState : (
    WAITING  := 0,
    LOADING  := 1,
    MIXING   := 2,
    HEATING  := 3,
    COMPLETE := 4,
    ERROR    := 99
  );
END_TYPE

PROGRAM BatchController
  VAR
    recipe : Recipe;
    state  : BatchState := BatchState#WAITING;
    step   : INT := 1;
  END_VAR

  CASE state OF
    BatchState#WAITING:
      IF start_cmd THEN
        state := BatchState#LOADING;
        step := 1;
      END_IF;

    BatchState#LOADING:
      IF step <= recipe.step_count THEN
        IF recipe.ingredients[step].added THEN
          step := step + 1;
        END_IF;
      ELSE
        state := BatchState#MIXING;
      END_IF;

    BatchState#MIXING:
      mixer := TRUE;
      // transition after mix_time elapsed

    BatchState#COMPLETE:
      mixer := FALSE;
      heater := FALSE;
  END_CASE;
END_PROGRAM

Naming Caveat

When naming types or variables, be aware that all IEC 61131-3 keywords are case-insensitive. A type or variable named dt, Dt, or DT conflicts with the DT (Date and Time) keyword. Similarly, tod, int, or real as variable names will conflict with their respective keywords. Always use descriptive names that avoid keyword collisions:

  • Use date_time instead of dt
  • Use time_of_day instead of tod
  • Use count instead of int

Pointers & References

IEC 61131-3 supports typed pointers via REF_TO, the REF() function, and the ^ dereference operator. Pointers allow indirect access to variables — reading or writing a variable through a reference rather than by name.

Declaring a Pointer

Use REF_TO <type> to declare a pointer variable:

VAR
    x    : INT := 42;
    ptr  : REF_TO INT;     (* pointer to an INT variable *)
    bptr : REF_TO BOOL;    (* pointer to a BOOL variable *)
    rptr : REF_TO REAL;    (* pointer to a REAL variable *)
END_VAR

A pointer variable holds the address of another variable of the specified type. When first declared, a pointer is NULL (points to nothing).

Taking a Reference — REF()

Use the REF() function to get a pointer to a variable:

VAR
    x   : INT := 42;
    ptr : REF_TO INT;
END_VAR
    ptr := REF(x);    (* ptr now points to x *)

REF() works with both local and global variables:

VAR_GLOBAL
    g_sensor : REAL;
END_VAR

PROGRAM Main
VAR
    ptr : REF_TO REAL;
END_VAR
    ptr := REF(g_sensor);    (* pointer to a global variable *)
END_PROGRAM

Dereferencing — ^

The ^ operator reads or writes through a pointer:

Reading through a pointer

VAR
    x   : INT := 42;
    ptr : REF_TO INT;
    y   : INT;
END_VAR
    ptr := REF(x);
    y := ptr^;         (* y is now 42 — read x through ptr *)

Writing through a pointer

VAR
    x   : INT := 42;
    ptr : REF_TO INT;
END_VAR
    ptr := REF(x);
    ptr^ := 99;        (* x is now 99 — written through ptr *)

Read-modify-write

    ptr^ := ptr^ + 1;  (* increment x through the pointer *)

NULL Pointer

NULL is a built-in literal representing an empty pointer:

VAR
    ptr : REF_TO INT;
END_VAR
    ptr := NULL;        (* explicitly set to null *)

Default value: All REF_TO variables are initialized to NULL unless assigned.

Safe dereference: Dereferencing a NULL pointer returns the default value for the type (0 for INT, 0.0 for REAL, FALSE for BOOL). It does not crash the program.

VAR
    ptr : REF_TO INT;
    x   : INT;
END_VAR
    x := ptr^;          (* x = 0, because ptr is NULL *)

Passing Pointers to Functions

Pointers are especially useful for functions that need to modify their caller’s variables:

Swap function

FUNCTION SwapInt : INT
VAR_INPUT
    a : REF_TO INT;
    b : REF_TO INT;
END_VAR
VAR
    temp : INT;
END_VAR
    temp := a^;
    a^ := b^;
    b^ := temp;
    SwapInt := 0;
END_FUNCTION

PROGRAM Main
VAR
    x : INT := 10;
    y : INT := 20;
    dummy : INT;
END_VAR
    dummy := SwapInt(a := REF(x), b := REF(y));
    (* x is now 20, y is now 10 *)
END_PROGRAM

Increment via pointer

FUNCTION IncrementBy : INT
VAR_INPUT
    target : REF_TO INT;
    amount : INT;
END_VAR
    target^ := target^ + amount;
    IncrementBy := target^;
END_FUNCTION

Reassigning Pointers

A pointer can be reassigned to point to different variables:

VAR
    a   : INT := 10;
    b   : INT := 20;
    ptr : REF_TO INT;
END_VAR
    ptr := REF(a);
    ptr^ := ptr^ + 5;    (* a is now 15 *)

    ptr := REF(b);
    ptr^ := ptr^ * 2;    (* b is now 40 *)

Comparison with VAR_IN_OUT

Both pointers and VAR_IN_OUT allow indirect variable access:

FeatureVAR_IN_OUTREF_TO
SyntaxVAR_IN_OUT x : INT; END_VARptr : REF_TO INT;
AssignmentBound at call siteCan be reassigned at any time
NULLNever nullCan be NULL
FlexibilityFixed for the duration of the callCan point to different variables
Use caseFunction parametersDynamic data structures, callbacks

Restrictions

  • No pointer arithmetic — you cannot add or subtract from a pointer (unlike C)
  • Type-safeREF_TO INT can only point to INT variables
  • No nested pointersREF_TO REF_TO INT is not supported
  • No pointer comparison — comparing two pointers is not currently supported (use NULL comparison via <>)

Example: Playground

See playground/09_pointers.st for a complete example demonstrating:

  • Basic pointer read/write
  • Swap function using pointers
  • Increment via pointer
  • NULL pointer safety
st-cli run playground/09_pointers.st -n 10

Standard Library

The IEC 61131-3 standard library provides a set of reusable function blocks and functions commonly needed in PLC programming. The library is implemented as plain Structured Text source files in the stdlib/ directory and is automatically loaded by the compiler via builtin_stdlib() – all standard library functions and function blocks are available in every program without any import statements.

The library includes:

ModuleSource FileContents
Countersstdlib/counters.stCTU, CTD, CTUD
Edge Detectionstdlib/edge_detection.stR_TRIG, F_TRIG
Timersstdlib/timers.stTON, TOF, TP
Math & Selectionstdlib/math.stMAX, MIN, LIMIT, ABS, SEL
Type ConversionsCompiler intrinsics30+ TO functions (INT_TO_REAL, REAL_TO_INT, etc.)
Trig & Math IntrinsicsCompiler intrinsicsSQRT, SIN, COS, TAN, ASIN, ACOS, ATAN, LN, LOG, EXP
System TimeCompiler intrinsicSYSTEM_TIME()

Counters

Source: stdlib/counters.st

Counters are function blocks that track rising edges on their count inputs. Because they are function blocks (not functions), each instance retains its internal state across scan cycles.

CTU – Count Up

Increments CV on each rising edge of CU. Sets Q to TRUE when CV reaches or exceeds the preset value PV. The RESET input sets CV back to 0.

Inputs

NameTypeDescription
CUBOOLCount up – increments on rising edge
RESETBOOLReset counter to 0
PVINTPreset value – Q goes TRUE when CV >= PV

Outputs

NameTypeDescription
QBOOLTRUE when CV >= PV
CVINTCurrent counter value

Example

PROGRAM CounterExample
VAR
    my_counter : CTU;
    pulse : BOOL;
END_VAR
    my_counter(CU := pulse, RESET := FALSE, PV := 10);

    // my_counter.Q is TRUE after 10 rising edges on pulse
    // my_counter.CV holds the current count
END_PROGRAM

CTD – Count Down

Decrements CV on each rising edge of CD. The LOAD input sets CV to PV. Sets Q to TRUE when CV drops to 0 or below.

Inputs

NameTypeDescription
CDBOOLCount down – decrements on rising edge
LOADBOOLLoad preset value into CV
PVINTPreset value

Outputs

NameTypeDescription
QBOOLTRUE when CV <= 0
CVINTCurrent counter value

Example

PROGRAM CountdownExample
VAR
    parts_left : CTD;
    part_sensor : BOOL;
END_VAR
    parts_left(CD := part_sensor, LOAD := FALSE, PV := 100);

    // parts_left.Q goes TRUE when all 100 parts consumed
END_PROGRAM

CTUD – Count Up/Down

Combines up-counting and down-counting in a single block. RESET sets CV to 0; LOAD sets CV to PV. When neither reset nor load is active, rising edges on CU increment and rising edges on CD decrement.

Inputs

NameTypeDescription
CUBOOLCount up – increments on rising edge
CDBOOLCount down – decrements on rising edge
RESETBOOLReset counter to 0
LOADBOOLLoad preset value into CV
PVINTPreset value

Outputs

NameTypeDescription
QUBOOLTRUE when CV >= PV
QDBOOLTRUE when CV <= 0
CVINTCurrent counter value

Example

PROGRAM UpDownExample
VAR
    inventory : CTUD;
    item_in : BOOL;
    item_out : BOOL;
END_VAR
    inventory(CU := item_in, CD := item_out,
              RESET := FALSE, LOAD := FALSE, PV := 50);

    // inventory.QU = TRUE when stock is full (>= 50)
    // inventory.QD = TRUE when stock is empty (<= 0)
END_PROGRAM

Note: All counters detect rising edges internally. They only count on the FALSE-to-TRUE transition of the count input, not while it is held TRUE.


Edge Detection

Source: stdlib/edge_detection.st

Edge detection function blocks produce a single-cycle pulse when a signal changes state. They are the building blocks used internally by counters and timers, and are useful on their own for detecting button presses, sensor transitions, and other discrete events.

R_TRIG – Rising Edge

Q is TRUE for exactly one scan cycle when CLK transitions from FALSE to TRUE.

Inputs

NameTypeDescription
CLKBOOLSignal to monitor

Outputs

NameTypeDescription
QBOOLTRUE for one cycle on rising edge

F_TRIG – Falling Edge

Q is TRUE for exactly one scan cycle when CLK transitions from TRUE to FALSE.

Inputs

NameTypeDescription
CLKBOOLSignal to monitor

Outputs

NameTypeDescription
QBOOLTRUE for one cycle on falling edge

Example

PROGRAM EdgeExample
VAR
    start_btn_edge : R_TRIG;
    stop_btn_edge  : F_TRIG;
    start_button : BOOL;
    stop_button  : BOOL;
    motor_on     : BOOL;
END_VAR
    start_btn_edge(CLK := start_button);
    stop_btn_edge(CLK := stop_button);

    IF start_btn_edge.Q THEN
        motor_on := TRUE;   // Start on button press
    END_IF;
    IF stop_btn_edge.Q THEN
        motor_on := FALSE;  // Stop on button release
    END_IF;
END_PROGRAM

Timers

Source: stdlib/timers.st

Timers use real-time TIME values and the SYSTEM_TIME() intrinsic to measure wall-clock elapsed time. The preset value PT is a TIME type specified using TIME literals (e.g., T#5s, T#100ms, T#1m30s). This makes timers independent of scan cycle speed.

Note: The timer input is named IN1 (not IN) to avoid a keyword conflict in Structured Text.

All three timer blocks share the same input/output signature:

Inputs

NameTypeDescription
IN1BOOLTimer input
PTTIMEPreset time (e.g., T#5s, T#500ms)

Outputs

NameTypeDescription
QBOOLTimer output
ETTIMEElapsed time

TON – On-Delay Timer

Q goes TRUE after IN1 has been TRUE for at least the duration PT. When IN1 goes FALSE, both Q and ET reset immediately. Internally, the timer records the start time via SYSTEM_TIME() when IN1 first goes TRUE, and computes ET as the difference on each scan.

IN1:  _____|‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾|_____
ET:   T#0s  ... increasing ... T#0s
Q:    _____|          ‾‾‾‾‾‾‾|_____
              ^-- ET >= PT reached

TOF – Off-Delay Timer

Q goes TRUE immediately when IN1 goes TRUE. When IN1 goes FALSE, Q stays TRUE for the duration PT before turning FALSE.

IN1:  _____|‾‾‾‾‾|________________________
Q:    _____|‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾|___________
ET:   T#0s  T#0s  ... increasing ... T#0s
                          ^-- ET >= PT, Q goes FALSE

TP – Pulse Timer

On a rising edge of IN1, Q goes TRUE for exactly the duration PT, regardless of what IN1 does during the pulse. A new pulse cannot be triggered while the current one is active.

IN1:  _____|‾‾‾‾‾‾‾‾‾‾‾‾‾|________
Q:    _____|‾‾‾‾‾‾‾‾‾|____________
ET:   T#0s  ... increasing ... T#0s
                  ^-- ET >= PT, pulse ends

Example

PROGRAM TimerExample
VAR
    debounce : TON;
    raw_input : BOOL;
    clean_input : BOOL;
END_VAR
    // Debounce: require input to be stable for 5 seconds
    debounce(IN1 := raw_input, PT := T#5s);
    clean_input := debounce.Q;
END_PROGRAM

Math & Selection

Source: stdlib/math.st

Math functions are pure functions (not function blocks) – they have no internal state and return a value directly.

Integer Functions

FunctionSignatureDescription
MAX_INTMAX_INT(IN1: INT, IN2: INT) : INTReturns the larger of two values
MIN_INTMIN_INT(IN1: INT, IN2: INT) : INTReturns the smaller of two values
ABS_INTABS_INT(IN1: INT) : INTReturns the absolute value
LIMIT_INTLIMIT_INT(MN: INT, IN1: INT, MX: INT) : INTClamps IN1 to range [MN, MX]

REAL Functions

FunctionSignatureDescription
MAX_REALMAX_REAL(IN1: REAL, IN2: REAL) : REALReturns the larger of two values
MIN_REALMIN_REAL(IN1: REAL, IN2: REAL) : REALReturns the smaller of two values
ABS_REALABS_REAL(IN1: REAL) : REALReturns the absolute value
LIMIT_REALLIMIT_REAL(MN: REAL, IN1: REAL, MX: REAL) : REALClamps IN1 to range [MN, MX]

Selection

FunctionSignatureDescription
SELSEL(G: BOOL, IN0: INT, IN1: INT) : INTReturns IN0 when G=FALSE, IN1 when G=TRUE

Example

PROGRAM MathExample
VAR
    sensor_val : INT := -42;
    clamped : INT;
    bigger : INT;
    mode : BOOL := TRUE;
    chosen : INT;
END_VAR
    clamped := LIMIT_INT(MN := 0, IN1 := sensor_val, MX := 100);
    // clamped = 0 (sensor_val is below MN)

    bigger := MAX_INT(IN1 := 10, IN2 := 20);
    // bigger = 20

    chosen := SEL(G := mode, IN0 := 50, IN1 := 75);
    // chosen = 75 (mode is TRUE, so IN1 selected)
END_PROGRAM

Type Conversions

Type conversion functions are implemented as compiler intrinsics. The compiler recognizes *_TO_* function name patterns and emits ToInt, ToReal, or ToBool VM instructions directly, rather than calling a user-defined function. The file stdlib/conversions.st serves as documentation for the available conversions.

To REAL / LREAL

FunctionDescription
INT_TO_REALInteger to REAL
SINT_TO_REALShort integer to REAL
DINT_TO_REALDouble integer to REAL
LINT_TO_REALLong integer to REAL
UINT_TO_REALUnsigned integer to REAL
USINT_TO_REALUnsigned short integer to REAL
UDINT_TO_REALUnsigned double integer to REAL
ULINT_TO_REALUnsigned long integer to REAL
BOOL_TO_REALBoolean to REAL (FALSE=0.0, TRUE=1.0)
INT_TO_LREALInteger to LREAL
SINT_TO_LREALShort integer to LREAL
DINT_TO_LREALDouble integer to LREAL
LINT_TO_LREALLong integer to LREAL
REAL_TO_LREALREAL to LREAL

To INT / DINT / LINT / SINT

FunctionDescription
REAL_TO_INTREAL to integer (truncates)
LREAL_TO_INTLREAL to integer (truncates)
REAL_TO_DINTREAL to double integer
LREAL_TO_DINTLREAL to double integer
REAL_TO_LINTREAL to long integer
LREAL_TO_LINTLREAL to long integer
REAL_TO_SINTREAL to short integer
LREAL_TO_SINTLREAL to short integer
BOOL_TO_INTBoolean to integer (FALSE=0, TRUE=1)
BOOL_TO_DINTBoolean to double integer
BOOL_TO_LINTBoolean to long integer
UINT_TO_INTUnsigned integer to signed integer
UDINT_TO_DINTUnsigned double integer to signed
ULINT_TO_LINTUnsigned long integer to signed
INT_TO_DINTInteger to double integer
INT_TO_LINTInteger to long integer
DINT_TO_LINTDouble integer to long integer
SINT_TO_INTShort integer to integer
SINT_TO_DINTShort integer to double integer
SINT_TO_LINTShort integer to long integer

To BOOL

FunctionDescription
INT_TO_BOOLInteger to boolean (0=FALSE, nonzero=TRUE)
REAL_TO_BOOLREAL to boolean
DINT_TO_BOOLDouble integer to boolean
LINT_TO_BOOLLong integer to boolean

Example

PROGRAM ConversionExample
VAR
    flag : BOOL := TRUE;
    flag_as_int : INT;
    my_real : REAL;
    my_int : INT := 42;
    value_as_bool : BOOL;
END_VAR
    flag_as_int := BOOL_TO_INT(IN1 := flag);
    // flag_as_int = 1

    my_real := INT_TO_REAL(IN1 := my_int);
    // my_real = 42.0

    value_as_bool := INT_TO_BOOL(IN1 := my_int);
    // value_as_bool = TRUE
END_PROGRAM

Trigonometric & Math Intrinsics

These functions are VM intrinsic instructions – the compiler recognizes the function name and emits a dedicated bytecode instruction. They operate on REAL values.

FunctionSignatureDescription
SQRTSQRT(IN1: REAL) : REALSquare root
SINSIN(IN1: REAL) : REALSine (radians)
COSCOS(IN1: REAL) : REALCosine (radians)
TANTAN(IN1: REAL) : REALTangent (radians)
ASINASIN(IN1: REAL) : REALArc sine
ACOSACOS(IN1: REAL) : REALArc cosine
ATANATAN(IN1: REAL) : REALArc tangent
LNLN(IN1: REAL) : REALNatural logarithm
LOGLOG(IN1: REAL) : REALBase-10 logarithm
EXPEXP(IN1: REAL) : REALExponential (e^x)

Example

PROGRAM TrigExample
VAR
    angle : REAL := 1.5708;   // approx pi/2
    result : REAL;
    root : REAL;
END_VAR
    result := SIN(IN1 := angle);
    // result ~ 1.0

    root := SQRT(IN1 := 144.0);
    // root = 12.0
END_PROGRAM

System Time

SYSTEM_TIME() is a compiler intrinsic that returns the elapsed time in milliseconds since the engine started, as a TIME value. It is used internally by the standard library timers (TON, TOF, TP) and can also be called directly from user programs.

Example

PROGRAM TimestampExample
VAR
    now : TIME;
END_VAR
    now := SYSTEM_TIME();
    // now contains the elapsed time since engine start
END_PROGRAM

Creating Custom Modules

You can extend the standard library by adding your own .st files to the stdlib/ directory. Any FUNCTION or FUNCTION_BLOCK defined there will be automatically available in all programs.

Steps

  1. Create a new .st file in the stdlib/ directory (e.g., stdlib/my_blocks.st).
  2. Define your functions or function blocks using standard ST syntax.
  3. They are immediately available in all programs – no import needed.

Guidelines

  • Use FUNCTION_BLOCK when you need to retain state across scan cycles (e.g., filters, controllers, state machines).
  • Use FUNCTION for stateless computations (e.g., math, conversions, scaling).
  • Follow the naming conventions of the existing library: uppercase names for standard-style blocks, descriptive VAR_INPUT/VAR_OUTPUT names.
  • Add a comment header describing what the module provides.

Example

The file playground/08_custom_module.st demonstrates the pattern with three custom blocks:

  • Hysteresis – a function block that turns an output ON when an input exceeds a high threshold and OFF when it drops below a low threshold, preventing oscillation around a single setpoint.
  • Averager – a function block that computes a running average of input samples.
  • ScaleWithDeadBand – a function that applies a dead band and scale factor to a raw value.
// Define a custom function block
FUNCTION_BLOCK Hysteresis
VAR_INPUT
    input_val      : INT;
    high_threshold : INT;
    low_threshold  : INT;
END_VAR
VAR_OUTPUT
    output : BOOL;
END_VAR
    IF input_val >= high_threshold THEN
        output := TRUE;
    ELSIF input_val <= low_threshold THEN
        output := FALSE;
    END_IF;
    // Between thresholds: output holds previous state
END_FUNCTION_BLOCK

// Use it in a program
PROGRAM Main
VAR
    ctrl : Hysteresis;
    temperature : INT;
END_VAR
    ctrl(input_val := temperature,
         high_threshold := 60,
         low_threshold := 40);

    // ctrl.output is TRUE when temp >= 60,
    // FALSE when temp <= 40,
    // unchanged in between
END_PROGRAM

To make this available globally, move the FUNCTION_BLOCK Hysteresis definition into a file under stdlib/ (e.g., stdlib/controllers.st) and it will be loaded automatically for every program.

CLI Commands

The st-cli tool provides commands for checking, compiling, running, formatting, and serving Structured Text programs.

st-cli check

Parse and analyze source file(s), reporting all diagnostics.

st-cli check [path] [--json]

Path modes:

PathBehavior
st-cli checkAutodiscover .st files from current directory
st-cli check file.stCheck a single file
st-cli check dir/Autodiscover from directory
st-cli check plc-project.yamlUse project file configuration

Flags:

FlagDescription
--jsonOutput diagnostics as structured JSON (for CI integration)

Examples:

$ st-cli check program.st
program.st: OK

$ st-cli check broken.st
broken.st:5:10: error: undeclared variable 'x'
broken.st:8:8: warning: unused variable 'temp'

# Project mode
$ cd my_project/
$ st-cli check
Project 'MyProject': 4 source file(s)
  controllers/main.st
  types/data.st
  utils.st
  main.st
Project 'MyProject': OK

# JSON output for CI
$ st-cli check broken.st --json
[
  {
    "file": "broken.st",
    "line": 5,
    "column": 10,
    "severity": "error",
    "code": "UndeclaredVariable",
    "message": "undeclared variable 'x'"
  }
]

Exit codes:

  • 0 — No errors (warnings are OK)
  • 1 — One or more errors found

st-cli run

Compile and execute a Structured Text program.

st-cli run [path] [-n <cycles>]

Path modes:

PathBehavior
st-cli runAutodiscover from current directory, run first PROGRAM found
st-cli run file.stCompile and run a single file
st-cli run dir/Autodiscover from directory
st-cli run -n 1000Autodiscover + run 1000 scan cycles

Options:

FlagDefaultDescription
-n <cycles>1Number of scan cycles to execute

Examples:

# Single file
$ st-cli run program.st
Executed 1 cycle(s) in 8.5µs (avg 8.5µs/cycle, 16 instructions)

# 10,000 scan cycles
$ st-cli run program.st -n 10000
Executed 10000 cycle(s) in 17.4ms (avg 1.74µs/cycle, 16 instructions)

# Project mode
$ cd my_project/
$ st-cli run -n 100
Project 'MyProject': 5 source file(s)
Executed 100 cycle(s) in 1.8ms (avg 18µs/cycle, 112 instructions)

Pipeline:

  1. Discover source files (single file, directory, or project yaml)
  2. Parse all sources with stdlib merged via builtin_stdlib()
  3. Run semantic analysis — abort if errors
  4. Compile to bytecode (intrinsics emitted as single instructions)
  5. Execute in the VM for N cycles

PLC behavior:

  • PROGRAM locals persist across scan cycles (like a real PLC)
  • Global variables persist across scan cycles
  • FB instance state persists across calls
  • Timers use wall-clock time via SYSTEM_TIME()

st-cli compile

Compile a Structured Text source file to a bytecode file.

st-cli compile <file> -o <output>

Example:

$ st-cli compile program.st -o program.json
Compiled to program.json (78047 bytes)

The output is a JSON-serialized Module containing all compiled functions, global variables, type definitions, and source maps. This can be used for offline analysis or loaded by external tools.

Pipeline:

  1. Parse the source file with stdlib
  2. Run semantic analysis — abort if errors
  3. Compile to bytecode
  4. Serialize module as JSON to the output file

st-cli fmt

Format Structured Text source file(s) in place.

st-cli fmt [path]

Path modes:

PathBehavior
st-cli fmtFormat all .st files in current directory (autodiscover)
st-cli fmt file.stFormat a single file
st-cli fmt dir/Format all files in directory

Example:

$ st-cli fmt program.st
Formatted: program.st
Formatted 1 file(s)

# Format entire project
$ cd my_project/
$ st-cli fmt
Formatted: controllers/main.st
Formatted: utils.st
Formatted 2 file(s)

# Already formatted
$ st-cli fmt program.st
All 1 file(s) already formatted

The formatter normalizes indentation (4 spaces per level) for all ST block structures: PROGRAM, FUNCTION, VAR, IF, FOR, WHILE, CASE, STRUCT, TYPE, etc.

st-cli serve

Start the Language Server Protocol (LSP) server for editor integration.

st-cli serve

The server communicates over stdin/stdout using the JSON-RPC protocol. This is typically invoked by the VSCode extension, not directly by users.

Supported LSP capabilities (16 features):

FeatureProtocol Method
DiagnosticstextDocument/publishDiagnostics
HovertextDocument/hover
Go-to-definitiontextDocument/definition
Go-to-type-definitiontextDocument/typeDefinition
CompletiontextDocument/completion (triggers: .)
Signature helptextDocument/signatureHelp (triggers: (, ,)
Find referencestextDocument/references
RenametextDocument/rename
Document symbolstextDocument/documentSymbol
Workspace symbolsworkspace/symbol
Document highlighttextDocument/documentHighlight
Folding rangestextDocument/foldingRange
Document linkstextDocument/documentLink
Semantic tokenstextDocument/semanticTokens/full
FormattingtextDocument/formatting
Code actionstextDocument/codeAction

st-cli debug

Start a Debug Adapter Protocol (DAP) session for a Structured Text file.

st-cli debug <file>

This is typically invoked by the VSCode extension when you press F5, not called directly by users.

DAP capabilities:

CapabilityDescription
BreakpointsSet/clear breakpoints on executable lines
Step InStep into function/FB calls (F11)
Step OverStep over one statement (F10)
Step OutRun until current function returns (Shift+F11)
ContinueRun across scan cycles until a breakpoint is hit (F5)
Stack TraceView the full call stack including nested POU calls
ScopesInspect Locals and Globals scopes
VariablesView all variables with types and current values
EvaluateEvaluate variable names or PLC commands

PLC-specific debug commands (type in the Debug Console):

ExpressionDescription
force x = 42Force variable x to value 42
unforce xRemove force from variable x
listForcedList all forced variables
scanCycleInfoShow cycle statistics

Key behaviors:

  • Continue runs across scan cycles (up to 100,000) until a breakpoint hits
  • Step at end of cycle wraps to the next cycle
  • PROGRAM locals and FB state persist across cycles
  • 4 VSCode debug toolbar buttons: Force, Unforce, List Forced, Cycle Info

st-cli help

Show usage information.

$ st-cli help
st-cli: IEC 61131-3 Structured Text toolchain

Usage: st-cli <command> [options]

Commands:
  serve             Start the LSP server (stdio)
  check [path]      Parse and analyze, report diagnostics
  run [path] [-n N] Compile and execute (N cycles, default 1)
  compile <path> -o <output>  Compile to bytecode file
  fmt [path]        Format source file(s) in place
  debug <file>      Start DAP debug server (stdin/stdout)
  help              Show this help message

Flags:
  --json            Output diagnostics as JSON (for CI integration)

Path modes:
  (no path)         Use current directory as project root
  file.st           Single file mode
  directory/        Project mode (autodiscover .st files)
  plc-project.yaml  Explicit project file

Architecture Overview

rust-plc is an IEC 61131-3 Structured Text compiler and runtime written in Rust. The project follows the rust-analyzer model: a Rust core that implements parsing, analysis, compilation, and execution, paired with a thin TypeScript extension that wires the Language Server Protocol into VSCode.

High-Level Diagram

  +-----------+     +------------+     +--------------+     +---------+
  | st-grammar| --> | st-syntax  | --> | st-semantics | --> | st-ir   |
  | tree-sitter     | CST->AST   |     | scope + types|     | bytecode|
  | parser    |     | lower.rs   |     | diagnostics  |     | defns   |
  +-----------+     +------------+     +--------------+     +---------+
                                                                  |
  +-----------+     +------------+     +--------------+           |
  | st-cli    | --> | st-lsp     | --> | st-dap       |           v
  | commands  |     | lang server|     | debug adapter|     +-----------+
  |           |     | hover, diag|     | breakpoints  |     | st-compiler|
  +-----------+     +------------+     +--------------+     | AST -> IR |
                                                            +-----------+
                                                                  |
  +-----------+                                                   v
  | st-monitor| <-------------------------------------------+-----------+
  | WS live   |                                             | st-runtime|
  | dashboard |                                             | VM engine |
  +-----------+                                             +-----------+

  editors/vscode/   Thin TypeScript extension (launches st-cli serve)

The 10 Crates

CratePathPurpose
st-grammarcrates/st-grammarWraps the tree-sitter generated parser for Structured Text. Exposes language() and 70+ node-kind constants (kind::*). Incremental and error-recovering.
st-syntaxcrates/st-syntaxTyped AST definitions (ast.rs) and CST-to-AST lowering (lower.rs). Every AST node carries a TextRange for source-location mapping. Provides the one-call parse() convenience function.
st-semanticscrates/st-semanticsTwo-pass semantic analyzer. Pass 1 registers top-level names in the global scope; Pass 2 analyzes bodies. Includes the hierarchical scope model (scope.rs), semantic type system (types.rsTy enum with widening/coercion rules), and diagnostics. Recognizes compiler intrinsics (type conversions, trig/math functions, SYSTEM_TIME).
st-ircrates/st-irIntermediate representation: Module, Function, Instruction enum (48 variants), Value enum, MemoryLayout, VarSlot, and SourceLocation. Register-based design with u16 register indices and u32 label indices. Serializable with serde.
st-compilercrates/st-compilerCompiles a typed AST (SourceFile) into an IR Module. Two internal passes: register all POUs, then compile bodies. Emits register-based instructions with source-map entries for debugger integration. Handles intrinsic function detection (30+ type conversions, 10 trig/math functions, SYSTEM_TIME). Also contains builtin_stdlib() for multi-file compilation and analyze_change() / migrate_locals() for online change.
st-runtimecrates/st-runtimeBytecode VM (vm.rs) with fetch-decode-execute loop and scan-cycle engine (engine.rs). Provides CycleStats, watchdog timeout, configurable max call depth and instruction limits. Supports force/unforce variable overrides and FB instance state persistence.
st-lspcrates/st-lspLanguage Server Protocol implementation via tower-lsp. Per-document state with incremental re-parse on edits. Provides diagnostics, semantic tokens, completion, hover, and go-to-definition.
st-dapcrates/st-dapDebug Adapter Protocol server for online debugging: breakpoints, stepping, variable inspection, force/unforce via evaluate expressions (force x = 42, unforce x, listForced, scanCycleInfo), online change.
st-monitorcrates/st-monitorWebSocket-based live monitoring server. Streams variable values from the runtime to connected dashboards for real-time trend recording. Supports force/unforce and online change via JSON-RPC.
st-clicrates/st-cliCLI entry point. Commands: serve (start LSP on stdio), check <file> (parse + analyze, report diagnostics), run <file> [-n N] (compile and execute N scan cycles), debug <file> (start DAP session).

Standard Library and Multi-File Compilation

The standard library lives in the stdlib/ directory as plain .st files (counters, timers, edge detection, math). At compile time, builtin_stdlib() embeds all stdlib source files and parse_multi() merges the stdlib AST with the user’s AST. This means all standard library functions and function blocks are available in every program without import statements. Parse errors from stdlib files are suppressed – only user source errors are reported.

In addition to the stdlib-based functions, the compiler recognizes intrinsic functions by name:

  • 30+ type conversion functions (INT_TO_REAL, REAL_TO_INT, etc.)
  • 10 trig/math functions (SQRT, SIN, COS, TAN, ASIN, ACOS, ATAN, LN, LOG, EXP)
  • SYSTEM_TIME() – returns elapsed milliseconds since engine start

These compile to dedicated VM instructions rather than function calls.

Data Flow: Source to Execution

The end-to-end pipeline for st-cli run example.st:

  1. Read sourcest-cli reads the .st file into a String.
  2. Merge stdlibbuiltin_stdlib() provides the standard library source. parse_multi() parses both and merges the ASTs.
  3. Parsest_syntax::parse() creates a tree-sitter Parser, parses the source into a concrete syntax tree, then calls lower::lower() to produce a typed SourceFile AST plus any LowerErrors.
  4. Analyzest_semantics::analyze::analyze() builds a SymbolTable, resolves types, checks type compatibility, and collects Diagnostics. If any error-severity diagnostics exist, st-cli reports them and exits.
  5. Compilest_compiler::compile() walks the AST and emits an st_ir::Module containing Functions (with instructions, label maps, memory layouts, and source maps) plus global variable storage. Intrinsic functions are emitted as single VM instructions.
  6. Executest_runtime::Engine::new() instantiates a Vm from the module. engine.run() enters the scan-cycle loop, calling the named PROGRAM once per cycle and tracking CycleStats.

The VSCode Extension

The extension lives in editors/vscode/ and is intentionally thin:

  • Registers the structured-text language (.st, .scl files).
  • Provides TextMate grammar for syntax highlighting (syntaxes/structured-text.tmLanguage.json).
  • Launches st-cli serve as the language server subprocess.
  • Configurable server path via structured-text.serverPath.

All intelligence (diagnostics, completions, semantic tokens) is implemented in the Rust LSP crate, not in TypeScript. This keeps the extension simple and allows the same analysis to power both the CLI and the editor.

DAP Server (st-dap)

The Debug Adapter Protocol server enables interactive debugging of ST programs in VSCode. It sits between the editor and the VM, translating DAP requests (setBreakpoints, stepIn, continue, etc.) into VM control operations.

Key design decisions:

  • Scan-cycle-aware continue: When the user presses Continue, execution does not stop at the end of the current scan cycle. Instead, it runs across multiple cycles (up to 100,000) until a breakpoint is hit. This matches PLC debugging expectations.
  • Step at end of cycle: When stepping reaches the end of a scan cycle, the VM wraps to the next cycle instead of terminating.
  • PROGRAM local retention: The VM skips variable initialization on subsequent scan cycles (using body_start_pc) so that local variables retain their values, just like a real PLC.
  • FB instance persistence: Function block instance state is persisted across scan cycles via the fb_instances HashMap in the VM.
  • Force/unforce variables: The debugger supports PLC-specific evaluate expressions: force x = 42, unforce x, listForced, scanCycleInfo. The VSCode extension provides 4 debug toolbar buttons for these operations.
  • Source mapping: The compiler emits SourceLocation entries for every instruction, allowing the DAP server to map bytecode PCs back to source lines.

Online Change Manager

The online change system allows hot-reloading a modified program into a running engine without restarting. The pipeline is:

  1. engine.online_change(source) – Full pipeline: parse, analyze, compile, compare modules, migrate state, and atomic swap.
  2. analyze_change(old_module, new_module) – Compares two compiled modules and determines whether the change is compatible (same variable layout) or incompatible (structural changes requiring a full restart).
  3. migrate_locals(old_vm, new_module) – For compatible changes, copies local variable values from the old VM state into the new module’s memory layout, preserving runtime state by name and type.
  4. vm.swap_module(new_module) – Performs an atomic swap of the running module, replacing bytecode while the engine is between scan cycles.

See Online Change for full details.

Monitor Server (st-monitor)

The monitor server exposes a WebSocket interface for live variable observation and control. It runs alongside the scan-cycle engine and provides:

  • Real-time variable streaming – Connected clients receive variable values after each scan cycle.
  • Force/unforce variables – Override variable values from the dashboard, useful for testing and commissioning.
  • JSON-RPC protocol – All communication uses a simple JSON request/response format over WebSocket. Supported methods: subscribe, unsubscribe, read_variables, force_variable, unforce_variable, list_forced, online_change, status.
  • MonitorHandle API – A thread-safe handle that the engine uses to publish state and receive force commands without blocking the scan loop.
  • MonitorPanel webview – VSCode command “ST: Open PLC Monitor” opens a live variable table.

See Monitor Server for the full protocol reference.

Dependency Graph

st-cli
  |-- st-lsp
  |     |-- st-semantics
  |     |     |-- st-syntax
  |     |     |     |-- st-grammar
  |     |     |-- st-syntax (ast types)
  |     |-- st-grammar (incremental re-parse)
  |-- st-dap
  |     |-- st-runtime
  |     |-- st-compiler
  |     |-- st-ir
  |-- st-compiler
  |     |-- st-ir
  |     |-- st-syntax (ast types)
  |-- st-runtime
  |     |-- st-ir
  |-- st-monitor
  |     |-- st-runtime
  |     |-- st-ir
  |-- st-semantics
  |-- st-syntax

External dependencies are kept minimal: tree-sitter for parsing, tower-lsp + lsp-types for the language server, tokio for async, serde for IR serialization, and thiserror/anyhow for error handling.

Compiler Pipeline

This chapter describes the five stages that transform IEC 61131-3 Structured Text source code into executable bytecode.

Stage 1: Tree-Sitter Parsing

Crate: st-grammar (crates/st-grammar/src/lib.rs)

The grammar crate wraps a tree-sitter parser generated from a custom Structured Text grammar definition. Key properties:

  • Incremental – The LSP passes the previous tree into parser.parse(source, Some(&old_tree)) so only the edited region is re-parsed. This keeps keystroke latency low.
  • Error-recovering – Syntax errors produce ERROR nodes in the CST but do not prevent the rest of the tree from being built. The test test_error_recovery in st-grammar validates this: a broken x := ; statement still yields a valid source_file root.
  • 70+ named node kinds – Defined as string constants in st_grammar::kind (e.g. PROGRAM_DECLARATION, IF_STATEMENT, ADDITIVE_EXPRESSION). These are the glue between the C tree-sitter parser and the Rust lowering code.
#![allow(unused)]
fn main() {
// crates/st-grammar/src/lib.rs
pub fn language() -> Language {
    unsafe { Language::from_raw(tree_sitter_structured_text()) }
}
}

Stage 2: CST-to-AST Lowering

Crate: st-syntax (crates/st-syntax/src/lower.rs)

The lower() function walks the tree-sitter concrete syntax tree and produces typed Rust AST nodes defined in crates/st-syntax/src/ast.rs.

tree-sitter::Tree + &str  -->  lower::lower()  -->  LowerResult {
                                                       source_file: SourceFile,
                                                       errors: Vec<LowerError>,
                                                     }

Design choices:

  • Every AST node carries a TextRange { start, end } (byte offsets). This enables precise diagnostic locations and source-map generation later.
  • CST ERROR nodes are collected into LowerErrors but do not halt construction. Valid subtrees still produce AST nodes.
  • The top-level SourceFile contains a Vec<TopLevelItem> with variants: Program, Function, FunctionBlock, TypeDeclaration, GlobalVarDeclaration.
  • The convenience function st_syntax::parse(source) creates a parser, parses, and lowers in one call.

Stage 2.5: Multi-File Compilation

Before semantic analysis, the compiler supports multi-file compilation via parse_multi(). The standard library is embedded at compile time through builtin_stdlib(), which concatenates all stdlib/*.st files into a single source string. The parser merges the stdlib AST with the user’s AST, and only reports errors from the user source (stdlib parse errors are suppressed).

This means all standard library functions (counters, timers, edge detection, math) are available in every program without import statements.

Stage 3: Semantic Analysis

Crate: st-semantics (crates/st-semantics/src/analyze.rs)

Semantic analysis runs in two passes over the AST:

Pass 1 – Register Top-Level Names

#![allow(unused)]
fn main() {
// analyze.rs
for item in &sf.items {
    self.register_top_level(item);
}
}

This pass inserts every PROGRAM, FUNCTION, FUNCTION_BLOCK, TYPE declaration, and global VAR block into the global scope of the SymbolTable. Forward references between POUs are therefore resolved correctly.

Pass 2 – Analyze Bodies

#![allow(unused)]
fn main() {
for item in &sf.items {
    self.analyze_top_level(item);
}
}

For each POU, the analyzer:

  1. Creates a child scope (global -> POU).
  2. Registers local variables from VAR / VAR_INPUT / VAR_OUTPUT blocks.
  3. Walks the statement list, resolving every variable reference through the scope chain (SymbolTable::resolve() walks from current scope up to global).
  4. Type-checks expressions using the rules in types.rs.

Intrinsic Function Recognition

The semantic analyzer recognizes compiler intrinsic functions by name. These include:

  • Type conversions (30+): INT_TO_REAL, REAL_TO_INT, BOOL_TO_INT, etc. The compiler recognizes *_TO_* patterns and emits ToInt, ToReal, or ToBool instructions directly.
  • Trig/math functions (10): SQRT, SIN, COS, TAN, ASIN, ACOS, ATAN, LN, LOG, EXP. Each compiles to a dedicated VM instruction.
  • SYSTEM_TIME(): Compiles to the SystemTime VM instruction, returning elapsed milliseconds since engine start as a TIME value.

These intrinsics bypass normal function call resolution and are emitted as single VM instructions for maximum efficiency.

Scope Chain

SymbolTable
  scopes[0]  "global"    -- types, POUs, global vars
  scopes[1]  "Main"      -- locals of PROGRAM Main (parent = 0)
  scopes[2]  "Counter"   -- locals of FB Counter   (parent = 0)
  ...

resolve(scope_id, name) walks parent links until it finds a match or reaches the root. Names are case-insensitive (uppercased for lookup).

Type Checking

The semantic type system (crates/st-semantics/src/types.rs) defines Ty:

VariantDescription
Elementary(e)BOOL, SINT..ULINT, REAL, LREAL, BYTE..LWORD, TIME..LDT
Array { ranges, element_type }Multi-dimensional arrays
String { wide, max_len }STRING / WSTRING
Struct { name, fields }Named struct with Vec<FieldDef>
Enum { name, variants }Enumeration
Subrange { name, base, lower, upper }Constrained integer range
FunctionBlock { name }FB instance type
Alias { name, target }Type alias (resolved transparently)
Void / UnknownPrograms (no return) / unresolved

Widening rules (can_coerce): implicit coercion is allowed when the source type has a lower numeric_rank than the target. The ranking is: SINT(1) < USINT(2) < INT(3) < UINT(4) < DINT(5) < UDINT(6) < LINT(7) < ULINT(8) < REAL(9) < LREAL(10). Enum-to-integer coercion is also permitted.

Common type (common_type): for binary operations, the operand with the higher rank is selected. If no common type exists the analyzer emits an error diagnostic.

After both passes, check_unused() scans the symbol table for variables that were declared but never read, emitting warnings.

Stage 4: IR Compilation

Crate: st-compiler (crates/st-compiler/src/compile.rs)

The compiler translates the AST into register-based bytecode stored in an st_ir::Module.

Two-Pass Compilation

  1. Register POUs – Create empty Function entries so that cross-function Call instructions can resolve indices.
  2. Compile bodies – A per-function FunctionCompiler allocates registers, labels, and local variable slots, then emits instructions.

Register Allocation

Registers are allocated linearly (alloc_reg() increments a counter). Each expression evaluation returns the Reg holding its result. There is no register reuse or optimization pass; the register file is sized to next_reg at the end of compilation.

Instruction Set Summary

CategoryInstructions
Register opsNop, LoadConst(dst, val), Move(dst, src)
Variable accessLoadLocal(dst, slot), StoreLocal(slot, src), LoadGlobal(dst, slot), StoreGlobal(slot, src)
ArithmeticAdd, Sub, Mul, Div, Mod, Pow, Neg
ComparisonCmpEq, CmpNe, CmpLt, CmpGt, CmpLe, CmpGe
Logic/bitwiseAnd, Or, Xor, Not
Math intrinsicsSqrt, Sin, Cos, Tan, Asin, Acos, Atan, Ln, Log, Exp
SystemSystemTime
ConversionToInt, ToReal, ToBool
Control flowJump(label), JumpIf(reg, label), JumpIfNot(reg, label)
CallsCall { func_index, dst, args }, CallFb { instance_slot, func_index, args }, Ret(reg), RetVoid
Aggregate accessLoadArray, StoreArray, LoadField, StoreField

Total: 48 instruction variants.

Source Map Generation

Every emit() / emit_sourced() call appends a SourceLocation { byte_offset, byte_end } parallel to the instruction vector. The runtime and debugger use this mapping to translate instruction indices back to source positions.

Stage 5: Module Output

The final Module contains:

#![allow(unused)]
fn main() {
pub struct Module {
    pub functions: Vec<Function>,   // compiled POUs
    pub globals: MemoryLayout,      // global variable slots
    pub type_defs: Vec<TypeDef>,    // struct/enum/array defs for runtime
}
}

Each Function carries its name, PouKind (Function / FunctionBlock / Program), register count, instruction vector, label-position map, local MemoryLayout, and source map. The module is serializable via serde for potential caching or transport.

Pipeline Summary

  .st source
      |
      v
  [tree-sitter]  incremental, error-recovering parse
      |
      v
  CST (tree_sitter::Tree)
      |
      v
  [lower.rs]  walk CST nodes, produce typed AST
      |
      v
  AST (SourceFile)
      |
      v
  [builtin_stdlib() + parse_multi()]  merge stdlib AST with user AST
      |
      v
  [analyze.rs]  pass 1: register names, pass 2: analyze bodies
      |
      v
  SymbolTable + Vec<Diagnostic>
      |
      v
  [compile.rs]  register POUs, then compile to bytecode (intrinsics emitted inline)
      |
      v
  Module { functions, globals, type_defs }
      |
      v
  [vm.rs / engine.rs]  fetch-decode-execute in scan cycles

Bytecode VM

The runtime virtual machine lives in crates/st-runtime. It has two main components: the VM (vm.rs) that executes bytecode, and the Engine (engine.rs) that drives the PLC scan-cycle loop.

Register-Based Architecture

The VM uses a register-based IR rather than a stack-based one. Each function call allocates a flat array of registers (Vec<Value>) sized to Function::register_count. The compiler assigns temporaries to registers during code generation.

Value Types

Every register and variable slot holds a Value:

#![allow(unused)]
fn main() {
pub enum Value {
    Bool(bool),
    Int(i64),       // covers SINT through LINT
    UInt(u64),      // covers USINT through ULINT
    Real(f64),      // covers REAL and LREAL
    String(String),
    Time(i64),      // nanoseconds
    Void,
}
}

All IEC integer widths widen to i64/u64; all floats become f64. This keeps the instruction set small – one Add, not eight width-specific variants.

Time Arithmetic

The Value::Time(i64) variant stores time durations in nanoseconds. Time values participate in standard arithmetic operations:

  • Addition/Subtraction: TIME + TIME and TIME - TIME produce TIME results. This is used by the standard library timers to compute elapsed time (e.g., ET := SYSTEM_TIME() - start_time).
  • Comparison: TIME >= TIME, TIME < TIME, etc. are used by timers to check if the preset has been reached (e.g., ET >= PT).
  • TIME literals (T#5s, T#100ms, T#1m30s) compile to LoadConst with a Value::Time(...) constant.

Call Frames

Each function invocation pushes a CallFrame:

#![allow(unused)]
fn main() {
struct CallFrame {
    func_index: u16,
    registers: Vec<Value>,     // sized to Function::register_count
    locals: Vec<Value>,        // one per VarSlot in the function's MemoryLayout
    pc: usize,                 // program counter (instruction index)
    return_reg: Option<Reg>,   // where to store return value in the caller
}
}

Registers are per-frame and never shared between frames. Local variables are separate from registers: locals correspond to declared VAR slots, while registers hold intermediate expression results.

Variable Storage

StorageInstructionsLifetime
Locals (CallFrame::locals)LoadLocal / StoreLocalOne function invocation
Globals (Vm::globals)LoadGlobal / StoreGlobalEntire VM lifetime (persists across scan cycles)

Slots are addressed by u16 indices into the corresponding MemoryLayout.

Force / Unforce Variables

The VM supports forcing variable values, which overrides the normal program-computed value with a fixed value set by the debugger or monitor:

#![allow(unused)]
fn main() {
pub fn force_variable(&mut self, name: &str, value: Value);
pub fn unforce_variable(&mut self, name: &str);
}

When a variable is forced:

  • LoadLocal and LoadGlobal instructions check the force table first. If the variable is forced, the forced value is returned instead of the actual stored value.
  • The forced value persists across scan cycles until explicitly unforced.
  • Force/unforce is accessible from both the DAP debugger (via evaluate expressions like force x = 42, unforce x) and the monitor server (via WebSocket force_variable / unforce_variable requests).

The VM maintains a HashMap of forced variables (by name) that is consulted during variable load operations.

FB Instance State

Function block instances maintain persistent state across scan cycles via the fb_instances HashMap in the VM. When a CallFb instruction executes, the VM looks up (or creates) the instance state for that particular instance slot, ensuring that internal variables (like edge detection prev flags or timer start_time values) are preserved between calls.

Fetch-Decode-Execute Loop

The core loop in Vm::execute():

  1. If the call stack is empty, return Value::Void.
  2. Read the current frame’s pc and func_index.
  3. If pc >= instructions.len(), perform an implicit return (pop frame).
  4. Clone the instruction at pc and advance pc by one.
  5. Increment instruction_count; check against max_instructions.
  6. Match on the Instruction variant and execute it.

The PC advances before execution so jump instructions simply overwrite frame.pc without off-by-one issues. Instructions are cloned out of the function vector to avoid borrow conflicts with the mutable call stack.

Arithmetic with Int/Real Dispatch

Binary arithmetic dispatches on operand types at runtime:

#![allow(unused)]
fn main() {
fn arith_op(&self, l: Reg, r: Reg,
            int_op: impl Fn(i64, i64) -> i64,
            real_op: impl Fn(f64, f64) -> f64) -> Value {
    match (lv, rv) {
        (Value::Real(_), _) | (_, Value::Real(_)) =>
            Value::Real(real_op(lv.as_real(), rv.as_real())),
        _ =>
            Value::Int(int_op(lv.as_int(), rv.as_int())),
    }
}
}

If either operand is Real, both promote to f64. Otherwise the operation stays in i64. Comparisons follow the same pattern through cmp_op(). Division by zero on integer operands returns VmError::DivisionByZero.

Intrinsic Instructions

The VM executes several intrinsic instructions that map directly to native operations:

Math Intrinsics

Sqrt, Sin, Cos, Tan, Asin, Acos, Atan, Ln, Log, Exp – each takes a source register, converts its value to f64, applies the corresponding Rust f64 method, and stores the result as Value::Real.

SystemTime

SystemTime(dst) writes the current elapsed time since engine start into the destination register as a Value::Time(...). This is the foundation for the real-time timers in the standard library.

Type Conversions

ToInt(dst, src), ToReal(dst, src), ToBool(dst, src) convert between value types. These are emitted by the compiler for the 30+ *_TO_* intrinsic functions.

Control Flow via Labels

Labels are u32 indices into Function::label_positions, which maps each label to an instruction index. The compiler allocates labels with alloc_label() and resolves them with place_label().

Three jump instructions exist:

  • Jump(label) – unconditional.
  • JumpIf(reg, label) – jump when register is truthy.
  • JumpIfNot(reg, label) – jump when register is falsy.

A WHILE loop compiles to:

  place_label(loop_start)
  <condition -> reg>
  JumpIfNot(reg, exit_label)
  <body>
  Jump(loop_start)
  place_label(exit_label)

FOR and REPEAT loops follow analogous patterns.

Function Calls

Call { func_index, dst, args } performs:

  1. Check depth against max_call_depth (default 256).
  2. Allocate a new CallFrame with default-initialised locals.
  3. Copy arguments: each (param_slot, arg_reg) pair writes the caller’s register into the callee’s local slot.
  4. Set return_reg on the caller’s frame.
  5. Push the new frame; execution continues in the callee.

Ret(reg) pops the frame and writes the value into the caller’s return_reg. RetVoid pops without writing (used by PROGRAMs and FUNCTION_BLOCKs). CallFb is the variant for function block instances, carrying an additional instance_slot.

Safety Limits

LimitDefaultError
max_call_depth256VmError::StackOverflow
max_instructions10,000,000VmError::ExecutionLimit

Division by zero produces VmError::DivisionByZero. Invalid function or label indices produce VmError::InvalidFunction and VmError::InvalidLabel.

Scan Cycle Engine

The Engine (engine.rs) wraps a Vm and drives it cyclically:

#![allow(unused)]
fn main() {
pub fn run_one_cycle(&mut self) -> Result<Duration, VmError> {
    self.vm.reset_instruction_count();
    self.vm.scan_cycle(&self.program_name)?;
    // check watchdog, update stats
}
}

CycleStats

#![allow(unused)]
fn main() {
pub struct CycleStats {
    pub cycle_count: u64,
    pub last_cycle_time: Duration,
    pub min_cycle_time: Duration,
    pub max_cycle_time: Duration,
    pub total_time: Duration,
}
}

avg_cycle_time() returns total_time / cycle_count.

Watchdog

If EngineConfig::watchdog_timeout is set and a single cycle exceeds that duration, the engine aborts with VmError::ExecutionLimit.

Configuration

#![allow(unused)]
fn main() {
pub struct EngineConfig {
    pub cycle_time: Option<Duration>,      // None = fast as possible
    pub max_cycles: u64,                   // 0 = unlimited
    pub vm_config: VmConfig,
    pub watchdog_timeout: Option<Duration>,
}
}

PROGRAM Local Retention

The VM uses body_start_pc to skip variable initialization on subsequent scan cycles. This means PROGRAM locals retain their values across cycles, matching real PLC behavior. The same mechanism is used after online change to preserve migrated variable values.

Variable Access Between Cycles

#![allow(unused)]
fn main() {
engine.vm().get_global("counter")                       // read
engine.vm_mut().set_global("counter", Value::Int(0))    // write
}

This is the foundation for st-monitor (live variable streaming) and st-dap (debug adapter protocol).

Online Change

The engine supports hot-reloading via engine.online_change(source), which performs the full pipeline: parse, analyze, compile, compare modules via analyze_change(), migrate state via migrate_locals(), and atomically swap via vm.swap_module(). See Online Change for full details.

Online Change

Online change (hot-reload) allows you to modify a running PLC program’s logic without stopping the scan-cycle engine. The system analyzes compatibility between the old and new compiled modules, migrates variable state, and performs an atomic swap.

Overview

The simplest way to perform an online change is via the high-level API:

#![allow(unused)]
fn main() {
engine.online_change(new_source)?;
}

This runs the full pipeline: parse, analyze, compile, compare modules, migrate state, and atomic swap. Under the hood, the pipeline consists of three steps:

  Old Module + New Source
        |
        v
  ┌─────────────────────┐
  │  analyze_change()    │ ── Compare old and new modules
  │  → Compatible?       │    for structural equivalence
  └──────────┬──────────┘
             │ yes
             v
  ┌────────��────────────┐
  │  migrate_locals()    │ ── Copy variable values from old
  │  → State preserved   │    VM state into new module layout
  └──────────���───────��──┘
             │
             v
  ┌─────���───────────────┐
  │  vm.swap_module()    │ ── Atomic swap of the module in
  │  → Engine updated    │    the running engine
  └─────────────────────┘

If the change is incompatible, the system reports the reason and the caller must perform a full restart instead.

Compatibility Analysis

analyze_change(old_module, new_module) compares two st_ir::Module values and returns a ChangeAnalysis:

#![allow(unused)]
fn main() {
pub fn analyze_change(old: &Module, new: &Module) -> ChangeAnalysis;
}

What is Compatible

The following changes can be applied online without stopping the engine:

ChangeExampleWhy it works
Modified program body logicChanged an IF condition or assignmentSame variable layout, only bytecode changes
Reordered statementsMoved assignments aroundSame variable layout
Changed literal valueslimit := 50 to limit := 100Same variable layout
Added/removed comments(* new comment *)No effect on compiled output
Modified function bodiesChanged internal computationFunctions are stateless

What is Incompatible

These changes require a full restart:

ChangeExampleWhy it fails
Added a variableNew VAR counter2 : INT; END_VARMemory layout changed
Removed a variableDeleted a VAR declarationMemory layout changed
Changed a variable’s typecounter : INT to counter : DINTValue size changed
Renamed a variablecounter to cntName-based migration cannot match
Added/removed a POUNew FUNCTION or FUNCTION_BLOCKModule structure changed
Changed function signaturesAdded a parameter to a functionCall sites would be invalid

Variable Migration

When a change is compatible, migrate_locals(old_vm, new_module) copies variable values from the old VM’s memory into the new module’s memory layout:

#![allow(unused)]
fn main() {
pub fn migrate_locals(
    old_vm: &Vm,
    new_module: &mut Module,
) -> Result<MigrationReport, MigrationError>;
}

The migration is name-and-type-based: a variable in the new module receives the old value only if a variable with the same name and same type exists in the old module. This ensures type safety during the swap.

Migration Report

The migration returns a report listing:

  • Migrated – Variables whose values were copied successfully
  • Defaulted – Variables that exist only in the new module (initialized to defaults)
  • Dropped – Variables that exist only in the old module (values discarded)

Atomic Swap

vm.swap_module(new_module) performs the actual replacement:

  1. The engine finishes the current scan cycle (never interrupts mid-cycle)
  2. The old module is swapped out and the new module is installed
  3. The next scan cycle executes the new bytecode
  4. Program locals that were migrated retain their values

The use of body_start_pc is critical: it causes the VM to skip the variable initialization preamble on the next cycle, preserving the migrated values. This is the same mechanism used for normal scan-cycle local retention in PROGRAMs.

Code Example: Hot-Reload Workflow

Original program

PROGRAM Main
VAR
    counter : INT := 0;
    limit   : INT := 50;
    active  : BOOL := FALSE;
END_VAR
    counter := counter + 1;
    IF counter > limit THEN
        active := TRUE;
    END_IF;
END_PROGRAM

Modified program (compatible change)

PROGRAM Main
VAR
    counter : INT := 0;
    limit   : INT := 50;
    active  : BOOL := FALSE;
END_VAR
    counter := counter + 2;         (* changed: increment by 2 *)
    IF counter > limit THEN
        active := TRUE;
        counter := 0;              (* added: reset on overflow *)
    END_IF;
END_PROGRAM

This change is compatible because:

  • The variable declarations are identical (same names, same types, same order)
  • Only the program body logic changed

After online change:

  • counter retains its current runtime value (e.g., 37)
  • limit retains its value (50)
  • active retains its value (FALSE or TRUE depending on state)
  • The new logic takes effect on the very next scan cycle

Incompatible change example

PROGRAM Main
VAR
    counter : DINT := 0;           (* changed type: INT → DINT *)
    limit   : INT := 50;
    active  : BOOL := FALSE;
    log     : INT := 0;            (* added new variable *)
END_VAR
    (* ... *)
END_PROGRAM

This change is incompatible because:

  • counter changed type from INT to DINT
  • A new variable log was added

The system reports the incompatibility and the program must be fully restarted.

Integration with Monitor Server

Online change can be triggered through the monitor server’s WebSocket API using the onlineChange request type. This allows the VSCode monitor panel (or any WebSocket client) to push new source code to the running engine. See Monitor Server for the protocol details.

Monitor Server

The monitor server (st-monitor crate) provides a WebSocket-based interface for live variable observation, forced variable control, and online change triggering. It runs alongside the scan-cycle engine and communicates using a JSON-RPC protocol.

Architecture

  ┌──────────────┐        ┌───────────────┐        ┌──────────────┐
  │ VSCode       │  WS    │ st-monitor    │        │ st-runtime   │
  │ MonitorPanel │◄──────►│ WebSocket     │◄──────►│ Engine       │
  │ (webview)    │        │ Server        │        │ (scan loop)  │
  └──────────────┘        └───────────────┘        └──────────────┘
                                │
                          MonitorHandle
                          (thread-safe)

The MonitorHandle is the bridge between the WebSocket server and the engine. It is designed to be non-blocking: the engine publishes variable state into the handle after each scan cycle, and the server reads it when clients request updates. Force commands flow in the reverse direction.

Protocol Reference

All messages use JSON over WebSocket. Requests are tagged by method name with optional params. Responses are tagged by type.

Request Format

Requests use serde’s tag-content encoding:

{
  "method": "<method_name>",
  "params": { ... }
}

Response Types

The server sends back one of four message types:

TypeDescription
responseSuccess/failure response to a request
variableUpdatePushed variable value update for subscribers
cycleInfoScan cycle statistics
errorError message

Response format:

{
  "type": "response",
  "id": null,
  "success": true,
  "data": { ... }
}

Error format:

{
  "type": "error",
  "message": "description of the error"
}

Request Types

The monitor server supports 8 request types:


1. subscribe

Subscribe to live variable updates. After subscribing, the server pushes variableUpdate messages after each scan cycle (or at the specified interval).

Request:

{
  "method": "subscribe",
  "params": {
    "variables": ["Main.counter", "Main.limit"],
    "interval_ms": 0
  }
}
ParameterTypeDescription
variablesstring[]Variable names to subscribe to
interval_msu64Update interval in milliseconds (0 = every cycle)

After subscribing, the server sends push notifications:

{
  "type": "variableUpdate",
  "cycle": 1042,
  "variables": [
    { "name": "Main.counter", "value": "37", "type": "INT" },
    { "name": "Main.limit", "value": "50", "type": "INT" }
  ]
}

2. unsubscribe

Stop receiving updates for specific variables.

Request:

{
  "method": "unsubscribe",
  "params": {
    "variables": ["Main.counter"]
  }
}

3. read

Read current values of specific variables (polling mode).

Request:

{
  "method": "read",
  "params": {
    "variables": ["Main.counter", "Main.limit"]
  }
}

Response:

{
  "type": "response",
  "success": true,
  "data": {
    "variables": [
      { "name": "Main.counter", "value": "37", "type": "INT" },
      { "name": "Main.limit", "value": "50", "type": "INT" }
    ]
  }
}

4. write

Write a value to a variable.

Request:

{
  "method": "write",
  "params": {
    "variable": "Main.counter",
    "value": 100
  }
}

5. force

Override a variable’s value. The forced value is written at the start of each scan cycle, overriding whatever the program logic computes.

Request:

{
  "method": "force",
  "params": {
    "variable": "Main.counter",
    "value": 100
  }
}

6. unforce

Remove the force override from a variable, returning it to normal program control.

Request:

{
  "method": "unforce",
  "params": {
    "variable": "Main.counter"
  }
}

7. getCycleInfo

Get scan cycle statistics. This method takes no parameters.

Request:

{
  "method": "getCycleInfo"
}

Response:

{
  "type": "cycleInfo",
  "cycle_count": 1042,
  "last_cycle_us": 150,
  "min_cycle_us": 120,
  "max_cycle_us": 450,
  "avg_cycle_us": 165
}

8. onlineChange

Push new source code to the running engine for hot-reload. The server performs the full pipeline: parse, analyze, compile, compatibility analysis, variable migration, and atomic swap.

Request:

{
  "method": "onlineChange",
  "params": {
    "source": "PROGRAM Main\nVAR\n  counter : INT := 0;\nEND_VAR\n  counter := counter + 2;\nEND_PROGRAM"
  }
}

Response (success):

{
  "type": "response",
  "success": true,
  "data": {
    "status": "applied"
  }
}

Response (incompatible):

{
  "type": "error",
  "message": "incompatible change: variable 'counter' type changed from INT to DINT"
}

See Online Change for details on compatibility rules.

MonitorHandle API

The MonitorHandle is the Rust API used internally by the engine to communicate with the monitor server. It is Send + Sync and designed for zero-copy operation where possible.

#![allow(unused)]
fn main() {
pub struct MonitorHandle { /* ... */ }

impl MonitorHandle {
    /// Publish the current variable state after a scan cycle completes.
    /// Called by the engine at the end of each cycle.
    pub fn publish_state(&self, cycle: u64, variables: &VariableSnapshot);

    /// Check for pending force commands from connected clients.
    /// Called by the engine at the start of each cycle.
    pub fn poll_forces(&self) -> Vec<ForceCommand>;

    /// Check for a pending online change request.
    /// Returns the new module if one is queued.
    pub fn poll_online_change(&self) -> Option<Module>;

    /// Report the result of an online change back to the requesting client.
    pub fn report_change_result(&self, result: Result<MigrationReport, String>);
}
}

Integration with the Engine

The engine integrates with the monitor handle in its scan loop:

  loop {
      // 1. Apply any forced variables
      for cmd in handle.poll_forces() {
          vm.force_variable(cmd.name, cmd.value);
      }

      // 2. Check for online change
      if let Some(new_module) = handle.poll_online_change() {
          let result = apply_online_change(&mut vm, new_module);
          handle.report_change_result(result);
      }

      // 3. Execute one scan cycle
      vm.run_cycle();

      // 4. Publish state to subscribers
      handle.publish_state(cycle_count, &vm.snapshot());

      cycle_count += 1;
  }

VSCode MonitorPanel

The VSCode extension includes a MonitorPanel webview that connects to the monitor server. Open it via:

Command Palette (Ctrl+Shift+P) -> “ST: Open PLC Monitor”

The panel provides:

  • A variable table showing live values, types, and forced status
  • Right-click context menu to force/unforce variables
  • Visual indicators for forced variables
  • Cycle counter showing the current scan cycle number

The panel automatically connects to the monitor server when the engine is running and reconnects if the connection is lost.

Development Setup

This guide covers how to build, run, and develop the rust-plc project.

Prerequisites

  • Rust 1.85+ (edition 2024). Install via rustup.
  • Node.js LTS (for the VSCode extension). The devcontainer installs this automatically.
  • C compiler (for building tree-sitter). Usually available by default on Linux/macOS; on Windows use MSVC.

Clone and Build

git clone <repository-url>
cd rust-plc

# Build the CLI (and all dependencies)
cargo build -p st-cli

# Build the entire workspace
cargo build --workspace

The primary binary is st-cli, which serves as both the command-line tool and the LSP server process.

Run Tests

# Run all tests across every crate
cargo test --workspace

# Run tests for a specific crate
cargo test -p st-grammar
cargo test -p st-semantics

# Run a single test by name
cargo test -p st-runtime test_arithmetic

Project Structure

rust-plc/
  Cargo.toml                  Workspace root (10 members)
  crates/
    st-grammar/               Tree-sitter parser wrapper
      src/lib.rs              language(), kind constants, grammar tests
    st-syntax/                AST definitions + CST-to-AST lowering
      src/ast.rs              Typed AST nodes (SourceFile, Statement, Expr...)
      src/lower.rs            Tree-sitter walk -> AST construction
      src/lib.rs              parse() convenience function
      tests/
        lower_tests.rs        AST lowering tests
        coverage_gaps.rs      Additional coverage tests
    st-semantics/             Semantic analysis
      src/analyze.rs          Two-pass analyzer
      src/scope.rs            Hierarchical symbol table
      src/types.rs            Ty enum, coercion rules, numeric ranking
      src/diagnostic.rs       Diagnostic codes and severities
      src/lib.rs              check() convenience function
      tests/
        end_to_end_tests.rs   Full parse-analyze round trips
        type_tests.rs         Type checking
        scope_tests.rs        Scope resolution
        call_tests.rs         Function/FB call validation
        control_flow_tests.rs IF/FOR/WHILE/CASE checks
        struct_array_tests.rs UDT tests
        warning_tests.rs      Unused variable warnings etc.
        coverage_gaps.rs      Additional coverage
        test_helpers.rs       Shared test utilities
    st-ir/                    Intermediate representation
      src/lib.rs              Module, Function, Instruction, Value, MemoryLayout
    st-compiler/              AST -> IR compilation
      src/compile.rs          ModuleCompiler + FunctionCompiler
      src/lib.rs              compile() public API
      tests/
        compile_tests.rs      Compilation tests
    st-runtime/               Bytecode VM + scan-cycle engine
      src/vm.rs               Vm, CallFrame, fetch-decode-execute loop
      src/engine.rs           Engine, CycleStats, watchdog
      src/lib.rs              Public re-exports
      tests/
        vm_tests.rs           VM execution tests
    st-lsp/                   Language Server Protocol
      src/server.rs           tower-lsp Backend implementation
      src/document.rs         Per-document state (tree, AST, analysis)
      src/completion.rs       Completion provider
      src/semantic_tokens.rs  Semantic token encoding
      src/lib.rs              run_stdio()
      tests/
        lsp_integration.rs    Subprocess-based LSP tests (13 tests)
        unit_tests.rs         In-process LSP tests (41 tests)
    st-dap/                   Debug Adapter Protocol (placeholder)
      src/lib.rs
    st-monitor/               WebSocket live monitoring (placeholder)
      src/lib.rs
    st-cli/                   CLI entry point
      src/main.rs             serve, check, run commands
  editors/
    vscode/                   VSCode extension
      package.json            Extension manifest
      src/extension.ts        Thin client: launches st-cli serve
      syntaxes/               TextMate grammar for highlighting
      language-configuration.json
  docs/                       mdBook documentation (you are here)
  .devcontainer/              Development container definition
  playground/                 Example .st files for testing

Using the Devcontainer

The project includes a devcontainer configuration in .devcontainer/:

  • Dockerfile builds an image with Rust and Node.js.
  • devcontainer.json configures VSCode with rust-analyzer and ST file associations.
  • post-create.sh runs after container creation to install dependencies.

To use it:

  1. Install the “Dev Containers” extension in VSCode.
  2. Open the project folder.
  3. VSCode will prompt “Reopen in Container” – accept.
  4. The container will build and configure itself automatically.

Launching the Extension Development Host

To test the VSCode extension with the LSP server:

  1. Open the project in VSCode.
  2. Make sure the Rust binary is built: cargo build -p st-cli.
  3. Open the editors/vscode/ folder in VSCode (or use the workspace).
  4. Press F5 to launch the Extension Development Host.
  5. In the new VSCode window, open a .st file. The extension will launch st-cli serve and connect to it over stdio.

The server path is configured via structured-text.serverPath in settings. The devcontainer sets this to ${workspaceFolder}/target/debug/st-cli.

Useful Commands

# Type check without running
cargo build --workspace 2>&1 | head -20

# Check a Structured Text file for errors
cargo run -p st-cli -- check playground/example.st

# Run a Structured Text program for 10 scan cycles
cargo run -p st-cli -- run playground/example.st -n 10

# Start the LSP server manually (for debugging)
cargo run -p st-cli -- serve

# Format all code
cargo fmt --all

# Run clippy lints
cargo clippy --workspace

# Build in release mode
cargo build --workspace --release

IDE Support

  • CLion / RustRover – Open the workspace Cargo.toml. The IDE will index all 10 crates automatically.
  • VSCode with rust-analyzer – Same; open the root folder. The devcontainer pre-configures this.
  • The workspace uses resolver = "3" (Rust 2024 edition) so all crates share a single dependency graph.

Testing

This chapter covers the testing strategy, how tests are organized, how to run them, and how to add new tests.

Overview

The workspace contains 483 tests across all crates. Every crate with non-trivial logic has its own test suite. Tests range from unit tests (individual functions) to integration tests (full parse-analyze-compile-run round trips).

# Run the entire test suite
cargo test --workspace

Test Distribution by Crate

CrateTest file(s)CountWhat is tested
st-grammarsrc/lib.rs (inline)11Parser loads, minimal programs, FBs, functions, types, control flow, expressions, literals, comments, error recovery, incremental parse
st-syntaxtests/lower_tests.rs21CST-to-AST lowering for all node types
st-syntaxtests/coverage_gaps.rs58Additional lowering edge cases
st-semanticstests/end_to_end_tests.rs17Full parse-and-analyze round trips
st-semanticstests/scope_tests.rs22Scope creation, resolution, shadowing
st-semanticstests/type_tests.rs38Type coercion, common_type, numeric ranking
st-semanticstests/control_flow_tests.rs16IF/FOR/WHILE/REPEAT/CASE semantics
st-semanticstests/call_tests.rs13Function/FB call argument checking
st-semanticstests/struct_array_tests.rs11Struct field access, array indexing, UDTs
st-semanticstests/warning_tests.rs10Unused variables, write-without-read
st-semanticstests/coverage_gaps.rs44Edge cases for additional coverage
st-lsptests/lsp_integration.rs13Subprocess LSP lifecycle (init, open, diagnostics, shutdown)
st-lsptests/unit_tests.rs41In-process tests for completion, semantic tokens, document sync
st-compilertests/compile_tests.rs35AST-to-IR compilation for all statement/expression types
st-runtimetests/vm_tests.rs42VM execution: arithmetic, control flow, calls, limits, cycles, intrinsics
st-runtimetests/stdlib_tests.rs16Standard library integration: counters, timers, edge detection, math
st-runtimetests/online_change_tests.rs10Engine-level online change: apply, preserve state, reject incompatible
st-runtimesrc/online_change.rs (inline)11analyze_change compatibility, migrate_locals state preservation
st-runtimesrc/debug.rs (inline)9Debug-mode VM helpers
st-daptests/dap_integration.rs26DAP protocol: breakpoints, stepping, continue across cycles, variables, evaluate, force/unforce
st-monitortests/monitor_tests.rs4WebSocket protocol: connect, subscribe, variable streaming, force/unforce

Test Patterns

Grammar Tests (st-grammar)

Grammar tests are inline in crates/st-grammar/src/lib.rs. They verify that tree-sitter can parse various ST constructs and produce the expected node structure:

#![allow(unused)]
fn main() {
#[test]
fn test_parse_minimal_program() {
    let mut parser = tree_sitter::Parser::new();
    parser.set_language(&language()).unwrap();
    let tree = parser.parse(source, None).unwrap();
    assert!(!tree.root_node().has_error());
    assert_eq!(program.kind(), kind::PROGRAM_DECLARATION);
}
}

The test_error_recovery test confirms that broken syntax still produces a tree, and test_incremental_parse validates that re-parsing after an edit uses the old tree for efficiency.

Semantic Tests (st-semantics)

Semantic tests follow a consistent pattern using a test_helpers module:

  1. Write a complete ST source string.
  2. Call the parse-and-analyze pipeline.
  3. Assert the expected diagnostics (errors/warnings) by code and/or message.
  4. Or assert that zero diagnostics are produced (valid program).
#![allow(unused)]
fn main() {
#[test]
fn test_undeclared_variable() {
    let source = r#"
PROGRAM Main
VAR END_VAR
    x := 1;   // x is not declared
END_PROGRAM
"#;
    let result = check(source);
    assert!(result.diagnostics.iter().any(|d|
        d.message.contains("undeclared")
    ));
}
}

The test files are split by domain: scope resolution, type checking, control flow validation, function calls, struct/array access, and warnings.

LSP Tests (st-lsp)

LSP tests come in two flavors:

  • Subprocess tests (lsp_integration.rs, 13 tests): Launch st-cli serve as a child process, send JSON-RPC messages over stdio, and verify responses. These test the full end-to-end LSP protocol including initialization, textDocument/didOpen, textDocument/publishDiagnostics, and shutdown.

  • In-process tests (unit_tests.rs, 41 tests): Directly instantiate the Backend and call its methods, testing completion results, semantic token encoding, and document management without process overhead.

Compiler Tests (st-compiler)

Compiler tests in tests/compile_tests.rs parse ST source, compile it to a Module, and verify the resulting IR structure:

  • Correct number of functions in the module.
  • Expected instruction sequences for arithmetic, control flow, and calls.
  • Proper local/global variable slot allocation.
  • Source map entries present for sourced instructions.

Runtime/VM Tests (st-runtime)

VM tests in tests/vm_tests.rs compile and execute ST programs, then inspect the VM state:

#![allow(unused)]
fn main() {
#[test]
fn test_for_loop() {
    let module = compile_source("PROGRAM Main VAR x:INT; i:INT; END_VAR ...");
    let mut vm = Vm::new(module, VmConfig::default());
    vm.run("Main").unwrap();
    assert_eq!(vm.get_global("x"), Some(&Value::Int(55)));
}
}

These tests cover arithmetic operations, comparison, logic, control flow (IF/FOR/WHILE/REPEAT/CASE), function calls with return values, FB instance calls, safety limits (stack overflow, execution limit), division by zero, scan cycle execution through the Engine, and intrinsic functions (trig, math, conversions, SYSTEM_TIME).

Standard Library Tests (st-runtime)

The tests/stdlib_tests.rs file tests the standard library function blocks end-to-end: counters (CTU, CTD, CTUD) counting on rising edges, timers (TON, TOF, TP) with TIME values and SYSTEM_TIME(), edge detection (R_TRIG, F_TRIG), and math functions (MAX_INT, MIN_INT, ABS_INT, LIMIT_INT, etc.).

DAP Tests (st-dap)

DAP integration tests in tests/dap_integration.rs test the full debug protocol including breakpoints, stepping, continue across scan cycles, variable inspection, and PLC-specific extensions: force x = 42, unforce x, listForced, and scanCycleInfo.

Running Individual Test Suites

# Grammar tests only
cargo test -p st-grammar

# All semantic tests
cargo test -p st-semantics

# Only scope-related semantic tests
cargo test -p st-semantics --test scope_tests

# Only LSP integration tests (these are slower due to subprocess)
cargo test -p st-lsp --test lsp_integration

# Compiler tests
cargo test -p st-compiler

# VM tests
cargo test -p st-runtime

# Standard library tests
cargo test -p st-runtime --test stdlib_tests

# DAP debugger integration tests
cargo test -p st-dap

# Monitor server tests
cargo test -p st-monitor

# Online change tests (engine-level)
cargo test -p st-runtime --test online_change_tests

Code Coverage

The project uses cargo-llvm-cov for coverage reporting:

# Install (one-time)
cargo install cargo-llvm-cov

# Generate an HTML coverage report
cargo llvm-cov --workspace --html

# Generate a summary to the terminal
cargo llvm-cov --workspace

# Open the HTML report
open target/llvm-cov/html/index.html

Current Coverage

Overall workspace coverage is approximately 87%, with core logic crates achieving higher:

CrateApproximate Coverage
st-grammar~95%
st-syntax (lower.rs)~92%
st-semantics (analyze.rs)~95%
st-semantics (types.rs)~98%
st-semantics (scope.rs)~96%
st-ir~90%
st-compiler~88%
st-runtime (vm.rs)~91%
st-runtime (engine.rs)~85%
st-lsp~78%
st-cli~65%
st-dap~82%
st-monitor~80%

The coverage_gaps.rs files in st-syntax and st-semantics were added specifically to close coverage holes on edge cases and error paths.

Adding New Tests

Adding a Semantic Test

  1. Identify the appropriate test file (or create one in crates/st-semantics/tests/).
  2. Write a complete ST source snippet.
  3. Call st_semantics::check(&source) or the test helper functions.
  4. Assert on the diagnostics.
#![allow(unused)]
fn main() {
#[test]
fn test_my_new_check() {
    let source = r#"
PROGRAM Main
VAR
    x : INT;
END_VAR
    x := 3.14;  // should warn about implicit narrowing
END_PROGRAM
"#;
    let result = st_semantics::check(source);
    assert!(result.diagnostics.iter().any(|d|
        d.message.contains("type mismatch")
    ));
}
}

Adding a Compiler/VM Test

  1. Write the ST source.
  2. Parse with st_syntax::parse().
  3. Compile with st_compiler::compile().
  4. Execute with Vm::new() + vm.run().
  5. Inspect globals or the return value.

Adding a Grammar Test

Add an inline #[test] in crates/st-grammar/src/lib.rs that parses a new construct and asserts the CST structure.

Continuous Integration

All tests run on every push. The CI pipeline:

  1. cargo fmt --check – formatting.
  2. cargo clippy --workspace – lints.
  3. cargo test --workspace – all 483 tests.
  4. cargo llvm-cov --workspace – coverage report (optional).