The Document Graph
Every other concept in Stof sits on top of one structure: a document is a graph of named nodes, and each node holds any number of data components — fields, functions, or richer data like images and PDFs. Get comfortable with this shape and the rest of the language stops feeling like syntax to memorize and starts feeling like consequences of one idea.
Nodes and Data Components
A node is what most languages would call an object. It doesn't hold values directly — it holds pointers to data components, which are the actual fields and functions. This distinction matters because it's what makes the graph flat: internally, a document is one list of nodes and one list of data, connected by pointers, not a tree of nested copies. Moving a node, or handing a function a reference to one, never copies the subtree underneath it.
Creating Child Nodes
The simplest way to create a child node is to write one — a nested object literal as a field's value becomes a child node automatically:
server: {
port: 8080
address: "localhost"
fn url() -> str {
`http://${self.address}:${self.port}`
}
}
#[main]
fn main() {
pln(self.server.url());
}server is a real node here, not a nested map — it has its own path, its own identity, and its own self inside url(). From inside a function, you can also create nodes programmatically: new {} for an anonymous node, or new {} on target to place it under a specific parent instead of the current one.
Navigating: self, super, root
Three keywords move you through the graph from inside a function:
self— the node the function lives onsuper— its parent noderoot— the document root, regardless of how deep you are
name: "Stof"
team: {
name: "Runtime"
fn whoami() -> str {
`${self.name}, part of ${super.name}`
}
}
#[main]
fn main() {
pln(self.team.whoami());
}Inside whoami(), self is the team node — so self.name is "Runtime". super is team's parent, the document root — so super.name is "Stof". Neither of those required knowing the document's shape in advance; they're relative to wherever the function happens to be called from.
Absolute Paths
self & super are relative — they depend on where a function lives. Sometimes you want to reach a specific node regardless of who's calling: an absolute dot-path, starting from a document root (root is the main doc root name, but there can be many), does that.
Lang: {
Fields: {
MyType: {
list items: []
}
}
}
fn addItem(value: str) {
root.Lang.Fields.MyType.items.push_back(value);
}
#[main]
fn main() {
self.addItem('hello');
self.addItem('world');
pln(root.Lang.Fields.MyType.items);
}addItem doesn't need to live anywhere near MyType to reach it — the absolute path works the same regardless of where the function was called from. This is the same mechanism the prototype <TypeName> shorthand uses under the hood — it's really just a path shortcut to a type's definition node, covered later in Prototypes & Schemas.
Inspecting the Graph at Runtime
Because the graph is real data, not a hidden implementation detail, a document can ask questions about or manipulate its own shape at runtime:
team: {
name: "Runtime"
engineer: {
name: "Ada"
}
}
#[main]
fn main() {
pln(self.team.name());
pln(self.team.path());
pln(self.team.is_root());
pln(self.team.engineer.is_parent(self.team));
}name(), path(), id(), parent(), root(), is_root(), children(), is_parent(other), and others are all obj type built-ins on every node — useful for anything that needs to reason about the document generically, like a validator that walks the whole graph rather than one written for a specific shape.
Moving Nodes & Fields
Because the graph is flat pointers rather than nested copies, reparenting a node is cheap — obj.move(new_parent) updates the pointer, nothing underneath it gets copied or rewritten:
archive: {}
active: {
task: {
title: "Ship the docs"
}
}
#[main]
fn main() {
// Node is independent from field - this moves the node only (new parent)
self.active.task.move(self.archive);
pln(stringify('json'));
// Field is a separate data component - this works like 'mv' and can rename
self.move_field('self.active.task', 'self.archive.task');
pln(stringify('json'));
}task and everything nested inside it move in one pointer update, regardless of how large that subtree is — the same property that makes the graph cheap to navigate makes it cheap to reshape.