Skip to content

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

json
{
  "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:

json
{
  "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:

json
{ "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:

kindFieldsMeaning
stringvaluestring literal; simple ${a.b.c} fragments stay raw and interpolate at runtime
numbervalue, integerinteger: true for Integer, else Double
booleanvalue
nonethe missing optional value (NoneValue.INSTANCE in Java)
varnameroot identifier only — never dotted
proptarget (Expr), nameone property hop; chains nest left
typenametype literal, right side of is a
binaryop, left, rightsee operator list below
unaryop (NOT | NEGATE | EXISTS), operand
callname, argsuser function or builtin
all_playersonline-player collection (sugar for the builtin)
listitemslist 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:

json
{
  "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

kindFields
sendmessage, target (Expr or null = sender)
broadcastmessage
teleportentity, target
halt
cancel_event
ifcondition, then (Stmt), else (Stmt or null; else-if chains nest here)
blockstatements
assigntarget (string, plain variable), value
set_proptarget (Expr), name, valueset a.b.c to v has target = a.b, name = "c"
loopcount, var (string or null), body
whilecondition, body
foreachvar, limit (Expr or null — the first N of cap), iterable, body
callname, args
returnvalue (Expr or null)

Example — set thing.point.x to 9.5:

json
{
  "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

kindFields
waitamount (Expr), unit ("ticks" | "seconds" | "millis")
spawncall: { "name", "args" }
async_blockbody (Stmt array)
json
{
  "line": 34, "col": 9,
  "kind": "spawn",
  "call": { "name": "farewell", "args": [ { "kind": "string", "value": "swofty", "line": 34, "col": 24 } ] }
}

GUI navigation

kindFields
open_gui / replace_guiname, target (Expr), init (object of state-key → Expr)
close_gui / gui_backtarget

Scoreboard / tablist / bossbar / titles

kindFields
show_scoreboardname, target
hide_scoreboard, update_scoreboardtarget
show_tablistname, target
hide_tablisttarget
set_tablist_partpart ("header" | "footer"), value, target
show_bossbar, hide_bossbarname, target
set_bossbar_partname, part ("progress" | "text"), value, target
titletitle, subtitle (or null), target, fade_in/stay/fade_out (ticks or null)
clear_titletarget
actionbartext, target, duration (ticks or null)
belownametext, target
set_belowname_scorevalue, target
clear_belownametarget

Restricted-DSL statements

Only inside scoreboard lines and tablist columns bodies:

kindFields
linetext
blank
entrytext, skin
fillskin

skin is { "kind": "builtin", "name": "green" }, { "kind": "player", "expr": Expr }, or { "kind": "custom", "texture": Expr, "signature": Expr }.

Commands

json
{
  "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):

json
{
  "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

json
{
  "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

json
{
  "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:

json
{
  "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

json
{
  "name": "main",
  "title": Expr,
  "update": { "kind": "ticks", "value": 4 },
  "numbers": "hidden",
  "lines": [Stmt...]
}
json
{
  "name": "lobby",
  "update": { "kind": "ticks", "value": 60 },
  "header": Expr | null,
  "footer": Expr | null,
  "columns": [ { "line": 6, "col": 5, "body": [Stmt...] } ]
}
json
{
  "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

json
{
  "line": 1, "col": 1,
  "auth": { "kind": "offline", "secret": null },
  "host": "0.0.0.0",
  "port": 25565,
  "brand": "SwoftLang",
  "motd": "<green>A SwoftLang server"
}

auth.kindoffline | 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:

json
{ "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.