JSON AST
Internals — you don't need this page to write scripts
This page documents the compiler↔runtime wire format. It exists for people debugging the toolchain, building editor tooling, or embedding the runtime. Nothing here is part of the script-facing language; the schema can grow additive keys at any time. If you're writing .sw files, the cheatsheet is the page you want.
swoftc compiles a .sw file into one JSON document — schema swoftlang-ast, version 2. The Java runtime deserializes it and walks the tree. There is no bytecode, no codegen, no reflection over scripts: every construct on this page maps to one Java class in the executor, and evaluation is plain recursive interpretation.
That split is the whole architecture: OCaml owns parsing and every compile-time guarantee (types, options, async coloring, caps); Java trusts the JSON it is handed. Anything the schema cannot express, the runtime cannot do.
All examples on this page are real swoftc output from the scripts in scripts/.
Top level
{
"format": "swoftlang-ast",
"version": 2,
"commands": [],
"events": [],
"functions": [],
"guis": [],
"scoreboards": [],
"tablists": [],
"bossbars": [],
"server": null
}One document per .sw file. Every array is always present (possibly empty); server is an object or null.
Positions everywhere
Every node — statements, expressions, declarations, arguments — carries "line" and "col" pointing at its source position. Runtime errors cite them, so a coercion failure at 3 a.m. still says myscript.sw:14:9.
Persistence adds two top-level keys (additive, no version bump): "storage" (backend + flush cadence, or null) and "persistents" (declarations with name, optional subject type, type, default). It also adds the persist_get expression and persist_set statement kinds — see the cheatsheet.
Phase 5/6 keys are additive too, present only when used: "items", "mobs", "packet_listeners", "apis", "schedulers", and "module_vars". Exported declarations carry "exported": true.
Module bundles
When a script imports modules (or several entry files are compiled together), the top level becomes a bundle instead — the same per-file keys, one object per module in dependency order:
{
"format": "swoftlang-ast",
"version": 2,
"modules": [
{ "name": "holograms", "path": "addons/holograms.sw", "entry": false,
"module_vars": [], "functions": [], "commands": [], "events": [] },
{ "name": "lobby", "path": "scripts/lobby.sw", "entry": true,
"functions": [], "commands": [], "events": [] }
]
}The Java loader merges modules with collision checks — a duplicate exported symbol across two modules is a load error naming both. A module imported by several entries appears (and loads) once.
DataType
Types appear in command arguments and function parameters:
{ "base": "STRING" }
{ "base": "EITHER", "subtypes": [ { "base": "PLAYER" }, { "base": "LOCATION" } ] }
{ "base": "OPTIONAL", "subtypes": [ { "base": "PLAYER" } ] }
{ "base": "LIST", "subtypes": [ { "base": "STRING" } ] }base is one of STRING, INTEGER, DOUBLE, BOOLEAN, PLAYER, LOCATION, WORLD, ITEM, EITHER, OPTIONAL, LIST, or UNKNOWN (unrecognized type name — emitted with a compile warning). Only the last three composite forms carry subtypes.
Expressions
Every expression is { "line", "col", "kind", ... }. The executor's evaluateExpression switches on kind:
| kind | Fields | Meaning |
|---|---|---|
string | value | string literal; simple ${a.b.c} fragments stay raw and interpolate at runtime |
number | value, integer | integer: true for Integer, else Double |
boolean | value | |
none | — | the missing optional value (NoneValue.INSTANCE in Java) |
var | name | root identifier only — never dotted |
prop | target (Expr), name | one property hop; chains nest left |
type | name | type literal, right side of is a |
binary | op, left, right | see operator list below |
unary | op (NOT | NEGATE | EXISTS), operand | |
call | name, args | user function or builtin |
all_players | — | online-player collection (sugar for the builtin) |
list | items | list literal |
Binary op values: EQUALS, NOT_EQUALS, LESS_THAN, GREATER_THAN, LESS_EQUALS, GREATER_EQUALS, AND, OR, OR_ELSE (otherwise), IS_TYPE, IS_NOT_TYPE, CONTAINS, CONCATENATE, ADD, SUBTRACT, MULTIPLY, DIVIDE, MODULO.
this.location.y compiles to nested prop nodes over a root var:
{
"kind": "prop", "name": "y",
"target": {
"kind": "prop", "name": "location",
"target": { "kind": "var", "name": "this", "line": 3, "col": 12 },
"line": 3, "col": 17
},
"line": 3, "col": 26
}Complex interpolation ("${1 + 2}") is desugared by the compiler into CONCATENATE chains of real expressions — the runtime only ever string-interpolates simple dotted paths.
Statements
Every statement is { "line", "col", "kind", ... }; bodies are either a single statement (if branches, loop bodies — usually a block) or arrays (handler bodies).
Core
| kind | Fields |
|---|---|
send | message, target (Expr or null = sender) |
broadcast | message |
teleport | entity, target |
halt | — |
cancel_event | — |
if | condition, then (Stmt), else (Stmt or null; else-if chains nest here) |
block | statements |
assign | target (string, plain variable), value |
set_prop | target (Expr), name, value — set a.b.c to v has target = a.b, name = "c" |
loop | count, var (string or null), body |
while | condition, body |
foreach | var, limit (Expr or null — the first N of cap), iterable, body |
call | name, args |
return | value (Expr or null) |
Example — set thing.point.x to 9.5:
{
"kind": "set_prop",
"name": "x",
"target": {
"kind": "prop", "name": "point",
"target": { "kind": "var", "name": "thing", "line": 15, "col": 13 },
"line": 15, "col": 19
},
"value": { "kind": "number", "value": 9.5, "integer": false, "line": 15, "col": 27 },
"line": 15, "col": 19
}Async
| kind | Fields |
|---|---|
wait | amount (Expr), unit ("ticks" | "seconds" | "millis") |
spawn | call: { "name", "args" } |
async_block | body (Stmt array) |
{
"line": 34, "col": 9,
"kind": "spawn",
"call": { "name": "farewell", "args": [ { "kind": "string", "value": "swofty", "line": 34, "col": 24 } ] }
}GUI navigation
| kind | Fields |
|---|---|
open_gui / replace_gui | name, target (Expr), init (object of state-key → Expr) |
close_gui / gui_back | target |
Scoreboard / tablist / bossbar / titles
| kind | Fields |
|---|---|
show_scoreboard | name, target |
hide_scoreboard, update_scoreboard | target |
show_tablist | name, target |
hide_tablist | target |
set_tablist_part | part ("header" | "footer"), value, target |
show_bossbar, hide_bossbar | name, target |
set_bossbar_part | name, part ("progress" | "text"), value, target |
title | title, subtitle (or null), target, fade_in/stay/fade_out (ticks or null) |
clear_title | target |
actionbar | text, target, duration (ticks or null) |
belowname | text, target |
set_belowname_score | value, target |
clear_belowname | target |
Restricted-DSL statements
Only inside scoreboard lines and tablist columns bodies:
| kind | Fields |
|---|---|
line | text |
blank | — |
entry | text, skin |
fill | skin |
skin is { "kind": "builtin", "name": "green" }, { "kind": "player", "expr": Expr }, or { "kind": "custom", "texture": Expr, "signature": Expr }.
Commands
{
"line": 10, "col": 1,
"names": [ "tp", "teleport" ],
"permission": "swoftlang.teleport",
"description": "Teleport a player to coordinates or another player",
"arguments": [
{ "line": 16, "col": 9, "name": "player",
"type": { "base": "PLAYER" }, "default": "sender" },
{ "line": 17, "col": 9, "name": "target",
"type": { "base": "EITHER",
"subtypes": [ { "base": "PLAYER" }, { "base": "LOCATION" } ] },
"default": null }
],
"execute": { "async": false, "statements": [ ... ] }
}An alias group is one entry with several names; the loader registers one Minestom command per name, all sharing the same execute block. default is the raw one-token default as a string, or null. execute is null for a body-less command.
Execute blocks everywhere have the shape { "async": bool, "statements": [Stmt...] } — async: true means the runtime detaches the whole body onto a virtual thread.
Events
Each receiver method lowers to one event entry. receiver is the receiver type it was declared under (Player, Mob, …), name is the canonical engine event the method dispatches on, and params are the method's binder names (their types are fixed by the receiver table):
{
"line": 8, "col": 5,
"name": "PlayerChat",
"receiver": "Player",
"priority": 0,
"params": ["message"],
"execute": { "async": false, "statements": [ ... ] }
}Raw-packet handlers from a Packet { } block lower to packet_listeners entries instead, each with the packet class name and an execute body.
Functions
{
"line": 12, "col": 1,
"name": "add",
"async": false,
"params": [
{ "line": 12, "col": 14, "name": "a", "type": { "base": "INTEGER" } },
{ "line": 12, "col": 22, "name": "b", "type": { "base": "INTEGER" } }
],
"body": [ ... ]
}type is null for untyped parameters. Return types are inferred at compile time and not serialized — the runtime discovers the value when a return unwinds.
GUIs
{
"line": 1, "col": 1,
"name": "storage",
"rows": 6,
"title": { "kind": "string", "value": "Storage (page ${state.page + 1})", ... },
"state": { "page": Expr, "enabled": Expr, "items": Expr },
"fill": ItemSpec | Expr | null,
"border": ItemSpec | Expr | null,
"slots": [
{
"slots": [ 4 ],
"item": ItemSpec,
"click_handlers": [ { "filter": "right", "body": [Stmt...] },
{ "filter": "any", "body": [Stmt...] } ],
"refresh": 20
}
],
"editable": [ { "slots": [ 28, 29, ... ], "on_change": [Stmt...] | null } ],
"paginate": {
"source": Expr,
"slots": [ 10, 11, 12, 13, 14, 15, 16, 19, ... ],
"render": ItemSpec,
"on_click": [Stmt...] | null,
"prev_slot": 45,
"next_slot": 53
} | null,
"refresh": 100 | null,
"on_open": [Stmt...] | null,
"on_close": [Stmt...] | null,
"on_click_fallback": [Stmt...] | null
}ItemSpec is six nullable expression fields:
{
"material": Expr | null,
"skull": Expr | null,
"name": Expr | null,
"lore": [Expr...] | null,
"amount": Expr | null,
"glint": Expr | null
}Slot lists, ranges, and grid(a, b) are expanded to explicit integer arrays at compile time (note the slots array above skipping 17 and 18 — the border columns of a grid(10, 27)). All refresh values and durations are already converted to ticks.
Scoreboards, tablists, bossbars
{
"name": "main",
"title": Expr,
"update": { "kind": "ticks", "value": 4 },
"numbers": "hidden",
"lines": [Stmt...]
}{
"name": "lobby",
"update": { "kind": "ticks", "value": 60 },
"header": Expr | null,
"footer": Expr | null,
"columns": [ { "line": 6, "col": 5, "body": [Stmt...] } ]
}{
"name": "objective",
"text": Expr,
"progress": Expr,
"color": "yellow",
"style": "progress",
"update": { "kind": "ticks", "value": 30 }
}update is { "kind": "ticks", "value": n } or { "kind": "manual" }. The lines / columns bodies contain only the restricted-DSL statement kinds plus if, loop, and foreach — the compiler guarantees it, the runtime just executes them with a line/entry sink.
Server
{
"line": 1, "col": 1,
"auth": { "kind": "offline", "secret": null },
"host": "0.0.0.0",
"port": 25565,
"brand": "SwoftLang",
"motd": "<green>A SwoftLang server"
}auth.kind ∈ offline | mojang | velocity | bungeecord; secret is non-null only for velocity. Host and port defaults are baked in by the compiler.
Compile errors
On failure, swoftc writes a machine-readable object to stdout (human diagnostics go to stderr) and exits 1:
{ "error": { "message": "event 'on_join' is not cancellable", "line": 3, "col": 9 } }See the CLI reference for the full contract.
Consuming the AST
The Java loader (net.swofty.compiler.SwoftJsonLoader) is a straight Gson walk: each kind constructs the matching representation class, and ASTExecutor interprets the tree. If you want to target SwoftLang from other tooling, emit this schema — the runtime cannot tell the difference. The one thing you lose by skipping swoftc is the entire compile-time story: nothing at the JSON layer checks types, arity, colors, or caps.