The WCL language · reference
The CLI
wcl is one binary with eleven commands. Nine read or rewrite a document: parse, check, eval (aliased get), set, fmt, diff, init, repl and lsp. Two sit under wcl wdoc: build renders a document to a website, a PDF or a folder of Markdown depending on --type, and serve runs a dev server. This chapter covers all of them: what each one takes, every flag it accepts, and what it prints.
The chapter also covers the two things every command shares. Each returns the same exit code for the same kind of failure, and each opens a document exactly the way the next one does.
One binary, one document model
Most commands below name a <file> (lsp names none, init names a template and a destination, and repl's is optional). Where a command names one, that file is the entry document: wcl parses it, follows its imports, and works on the result. You configure nothing outside the file. There is no project file, no lockfile, no wcl.toml.
Every command that opens a document opens it the same way. Disk imports resolve relative to the importing file, and system imports (import <wdoc.wcl>) resolve against the embedded wdoc standard library. That is why wcl check validates a wdoc document as thoroughly as wcl wdoc build does. The schemas are in scope for both.
The wdoc library is always in scope
The CLI does not read a document's imports to decide whether it is "a wdoc document". Every document it opens carries the wdoc registry and the wdoc environment. The cost is that wdoc's own builtins (page_metadata, __wdoc_slot) resolve in any file. The benefit is that a wdoc page's schema violations surface under plain wcl check.
Two flags work before any command:
$ wcl --version
wcl 0.33.2-alpha
$ wcl --help
WCL command-line interface
Usage: wcl <COMMAND>
...
wcl <command> --help prints the same detail for one command, including every flag this chapter lists.
Exit codes
wcl has five exit codes, and each one means the same thing in every command. A script can branch on them without reading any output:
| Code | Name | Means |
|---|---|---|
| 0 | OK | The command did what it was asked. |
| 1 | Parse | The source did not parse. Also reported when the entry file cannot be read at all. |
| 2 | Schema | The document parsed, but it violates its own schema. |
| 3 | Eval | Evaluation failed: an unresolved name, a type mismatch, a path that resolves to nothing, a refused block. |
| 4 | I/O | The document was good. Writing the output, copying an asset or reaching the disk was not. |
Diagnostics go to stderr and results go to stdout. So wcl get f.wcl path > value.txt captures the value and leaves the error text on the terminal.
wcl parse
Parses a file, forces every field, and prints the whole evaluated document tree.
$ wcl parse <file>
| Argument | Meaning |
|---|---|
| <file> | Path to a WCL source file. |
Use it to see what a document became rather than what you typed. Take the inventory from Documents, fields and blocks:
$ wcl parse inventory.wcl
...
owner = "platform"
service web {
port = 8080u32
region = "us-east-1"
}
service db {
port = 5432u32
region = "us-east-1"
}
The ... stands for real output. parse prints everything in scope. That includes the language's own decorator declarations (@block, @children, @inline, …) and the std unit types, because the document can see those declarations too. Your own items follow them, in source order. To print only the data, pipe the output through tail.
parse forces the document, so it is also the blunt way to make every lazy field run. If a field would raise an evaluation error, parse raises it here. How a document evaluates covers what forcing means and what the profiler measures.
wcl check
Parses a file and validates it against its own schema. On success it prints one word.
$ wcl check <file> [--json]
| Argument | Meaning |
|---|---|
| <file> | Path to a WCL source file, or - to read from stdin. Relative imports then resolve against the current directory. |
| --json | Emit a JSON result object on stdout instead of human-readable diagnostics. Exit codes do not change. |
$ wcl check inventory.wcl
OK
$ echo $?
0
A violation names the field and the schema, points at the source, and sets exit 2:
$ wcl check sch.wcl
wcl::eval::schema_violation
× top-level field 'zone' is not declared by @document schema 'C'
sch.wcl: 1 schema violation
$ echo $?
2
--json is the form a CI job or an editor wants. The object always carries the same four keys, so a consumer never has to branch on success:
$ wcl check sch.wcl --json
{
"errors": [
{
"code": "wcl::eval::schema_violation",
"length": 10,
"message": "top-level field 'zone' is not declared by @document schema 'C'",
"offset": 45
}
],
"file": "sch.wcl",
"ok": false,
"warnings": []
}
offset and length are byte positions into the file. An editor needs them to underline the span. A clean file gives "ok": true and two empty lists.
check is the whole gate
wcl check runs the parser and the schema validator, not only the parser. Run it in a pre-commit hook, in CI, and any time you want to know a file is good without printing it. It also takes - in place of a path, so cat generated.wcl | wcl check - validates a document that was never written to disk.
wcl eval and wcl get
Resolves one dotted path out of a document and prints the value. get is an alias for eval. They are the same command, and get reads better.
$ wcl get <file> <path> [--json]
$ wcl eval <file> <path> [--json]
| Argument | Meaning |
|---|---|
| <file> | Path to a WCL source file. |
| <path> | Dotted path, resolved from the document root. |
| --json | Print the value as JSON instead of the WCL display form. A function value cannot be serialized and becomes null. |
Reading a path
A path walks the same tree wcl parse prints: a field name, a gather field, a block label, a nested block, a field on it.
$ wcl get inventory.wcl owner
"platform"
$ wcl get inventory.wcl services.web.port
8080u32
$ wcl get inventory.wcl services.web.region
"us-east-1"
The output is the WCL display form, not JSON: a string keeps its quotes, and a number keeps its type suffix. That is deliberate. 8080u32 tells you the declared type as well as the value. A bare 8080 would not.
Only what the document model exposes is addressable. A let item is not, because it is not document data, and a table row is not, because it has no name and no label. Documents, fields and blocks covers why.
When the path is wrong
A path that resolves to nothing exits 3 and offers the nearest name it can see:
$ wcl get inventory.wcl services.web.prot
no such path: services.web.prot
did you mean: service?
$ echo $?
3
A path that resolves to a block rather than to a value is also an error, because a block is not a leaf:
$ wcl get inventory.wcl services.web --json
wcl::eval::not_a_leaf
× cannot evaluate block as a leaf value
Address a field inside the block instead. To get the subtree, use wcl parse.
Feeding another program
--json is the form to pipe. It emits the value as ordinary JSON, so jq and every other tool reads it without knowing that WCL exists:
$ wcl get inventory.wcl services.web.port --json
8080
$ PORT=$(wcl get inventory.wcl services.web.port --json)
Note what JSON drops: the u32 suffix, and the unit on a value such as 512MiB (which was already the number 536870912 by then). JSON has one number type. When the type matters to the consumer, read the WCL form. When only the number matters, read the JSON.
Inspecting a document you did not write
Three commands answer three different questions. wcl check asks is this file valid? wcl parse asks what is in it? wcl get asks what is this one value? Start with parse piped through a pager to find the path you want, then use get for the value. WCL_PROFILE=1 in the environment answers a fourth question, why is it slow? It makes wcl print the evaluation call tree as JSON on stderr. It is a debug switch for wcl itself rather than a flag, so it stays out of --help. 1 and true turn it on. 0, false and an unset variable turn it off. Any other value is an error.
wcl set
Replaces the value of one field, in place, on disk.
$ wcl set <file> <path> <value>
| Argument | Meaning |
|---|---|
| <file> | Entry document. Imports are followed. |
| <path> | Dotted path to the field whose value is replaced. |
| <value> | The new value, written as a WCL expression. |
$ wcl set inventory.wcl services.web.port 9090u32
updated services.web.port in inventory.wcl
set is the edit path, not the evaluation path. It works on the syntax tree, so the file keeps its comments, its blank-line groupings and its layout. Only the one expression changes. How a document evaluates covers the split.
set parses the value as an expression, so the shell and WCL both want a say in the quoting. A string needs quotes that survive the shell:
$ wcl set inventory.wcl owner '"infrastructure"'
$ wcl set inventory.wcl services.web.port 9090u32
$ wcl set site.wcl accent :gold
$ wcl set site.wcl tags '[:a, :b]'
Two behaviours are worth knowing before you script set.
set follows imports. When <path> resolves through an import, set edits the file that actually declares the field, not necessarily the file you named. The behaviour is usually what you want and occasionally a surprise. The message names the file it wrote.
set does not validate. It re-parses its own output before writing atomically, so it cannot leave a broken file behind. It does not run the schema. set writes the value anyway, even when it violates a @min or puts a utf8 where the schema declares a u32. Follow a scripted set with a wcl check:
$ wcl set inventory.wcl services.web.port 9090u32 && wcl check inventory.wcl
updated services.web.port in inventory.wcl
OK
A path that names nothing exits 3 and changes no file:
$ wcl set inventory.wcl services.web.nope 1u32
no such path: services.web.nope
did you mean: service?
wcl fmt
Re-emits a file in canonical form.
$ wcl fmt <file> [--in-place] [--indent N] [--no-trailing-comma]
| Argument | Default | Meaning |
|---|---|---|
| <file> | — | Path to a WCL source file, or - to read stdin and write to stdout. |
| --in-place | off | Overwrite the file, atomically. Without this flag, the formatted source goes to stdout and the file stays as it is. |
| --indent N | 2 | Spaces per indentation level. |
| --no-trailing-comma | off | Drop the trailing comma the formatter puts after every match arm. The parser accepts either form. |
The formatter normalizes what has no meaning and keeps what does. It rewrites indentation, brace style, number radix, string-delimiter choice and spacing. Comments survive, blank-line groupings survive up to one blank line, and the formatter never touches item order.
$ cat ugly.wcl
owner="x"
@document
type C { owner: utf8 }
$ wcl fmt ugly.wcl
owner = "x"
@document
type C {
owner: utf8
}
One choice it does rewrite is the comment marker: // becomes #. Both forms parse. The canonical form is #.
Checking format in CI
There is no --check flag. Compare the formatter's output against the file instead. The whole recipe needs nothing but diff:
$ wcl fmt config.wcl | diff -u config.wcl - && echo "formatted"
formatted
diff exits non-zero on any difference, so the && chain fails the job, and the unified diff shows the reviewer exactly what --in-place would have done. To check a whole tree, drive the recipe with find:
$ find . -name '*.wcl' -exec sh -c 'wcl fmt "$1" | diff -u "$1" -' _ {} \; && echo "all formatted"
Pair fmt with wcl check in the same job. fmt says the file is tidy. check says it is valid. Neither implies the other: a badly formatted file can be valid, and a beautifully formatted one can violate every constraint its schema declares.
Formatting is not validation
wcl fmt parses. It does not evaluate and it does not run the schema, so it formats a document whose fields cannot resolve. A gate that runs only fmt proves nothing about the data. Run check too.
wcl diff
Compares two documents and prints what changed.
$ wcl diff <old> <new>
| Argument | Meaning |
|---|---|
| <old> | The base document: a path, or a <rev>:<path> git specifier. |
| <new> | The new document: a path, or a <rev>:<path> git specifier. |
wcl diff is not diff(1). It compares the evaluated documents, with imports resolved, so it reports changes in the data rather than in the text. Each top-level block is an entity keyed kind:label. diff reports a nested field edit by path, and recurses into lists by index.
$ wcl diff inventory.wcl inv2.wcl
# wcl diff inventory.wcl -> inv2.wcl — generated
modified "service:web" {
field "port" {
kind = :changed
old = 8080u32
new = 9090u32
}
}
The output is itself WCL, and that is the whole point: a change set is a document, so anything that reads WCL reads a diff and wcl parse reads it back. There is no second output format, because a consumer that wants the diff structurally already parses WCL. That is how it came to have two documents to compare. One caveat: a change set carries no schema of its own, so wcl check on one reports modified as an undeclared block kind. To validate a change set, declare modified, removed, added and field yourself.
A formatting-only edit produces no diff at all, because the values did not change. Neither does a comment edit, a reordering, or a rewrite that moves a field into an import. That property is what makes wcl diff useful in review.
Reading a revision out of git
Either side may be a <rev>:<path> specifier. wcl materializes that revision into a temporary tree, so the old document's imports resolve from that same revision rather than from your working copy:
$ wcl diff HEAD~1:config.wcl config.wcl
$ wcl diff main:a.wcl feature:a.wcl
$ wcl diff v1.2.0:schema/main.wcl schema/main.wcl
Upgrading a document
The revision form is what makes wcl diff an upgrade tool and not only a review tool. When a library you import gains fields, changes a default or renames a kind, the question is never "what did the text do". It is "what did my data become". Ask that directly:
$ wcl diff v0.4.0:main.wcl main.wcl
Both sides evaluate against their own imports, so the answer folds in everything the upgrade changed underneath you: a new default that now fills a field you never wrote, a computed value that moved because a builtin changed, a block that gathers somewhere else now. An empty diff after a library bump is a real result: the data did not change, whatever the release notes say.
The working order for an upgrade is three commands. Bump the import. Run wcl check to find what no longer validates, then fix the violations it reports. Then run wcl diff <old-rev>:main.wcl main.wcl to see what moved silently. The third step is the one people skip, and it is the one that catches a default you did not know you relied on.
wcl init
Scaffolds a project folder from a WCL template.
$ wcl init <template> [dest] [-D key=value]... [--defaults] [--force]
$ wcl init --list
| Argument | Meaning |
|---|---|
| <template> | A built-in name, a user template name, or a path to a template .wcl file (or a folder holding template.wcl). Optional with --list. |
| [dest] | Destination directory. Defaults to the answered name property, then to the template name. |
| -D key=value | Answer one property inline. Repeatable. Highest precedence. |
| --defaults | Never prompt: use each property's default, and fail on a property that has none. |
| --force | Write into the destination even if it exists and is not empty. |
| --list | List the templates and exit. |
Start with --list. It shows the built-in templates, your own templates, and the directory those user templates live in:
$ wcl init --list
Built-in templates:
minimal
page
book
website
presentation
User templates (/home/you/.local/share/wcl/templates):
(none — add one as <that dir>/<name>/template.wcl)
Usage: wcl init <template> [dest] (<template> may also be a path to a .wcl file or a folder containing template.wcl)
Then scaffold. In a script, use --defaults to make the run non-interactive:
$ wcl init book ./handbook -D name=handbook --defaults
Created ./handbook from template 'book'
main.wcl
schema/main.wcl
data/main.wcl
wdoc/main.wcl
Without --defaults and without a -D, init prompts for each property the template declares and shows its default. For a non-interactive run, repeat -D once per property:
$ wcl init ./my-template.wcl ./out -D name=out -D author="A. Dev" --defaults
Where an answer comes from
init looks in three sources, in this precedence order. The first one that has an answer wins:
- -D key=value. An answer given on the command line.
- The interactive prompt. --defaults skips it entirely.
- The property's default. A property with no default is an error under --defaults.
Template resolution has its own order: built-in name, then user template, then disk path. A built-in name therefore shadows a user template of the same name. Give your own template a distinct name, or pass a path.
A template is itself a WCL document. It opts in with import <scaffold.wcl>, then declares property blocks for the questions and file and folder blocks for what to generate. The angle brackets name an embedded library, not a file on disk. Namespaces and imports covers that import form.
Two rules that catch every template author
A generated file's content must use the interpolating heredoc, $<<TAG. A plain <<TAG is literal, so ${answer("name")} lands in the output word for word. And a property instance sets its fields with = (prompt = "Project name"), because it is a block instance and not a type declaration.
wcl repl
A read-eval-print loop for WCL expressions.
$ wcl repl [file]
With no argument it evaluates self-contained expressions: arithmetic, string operations, builtin calls. With a file, identifiers also resolve against that document's top-level fields:
$ wcl repl inventory.wcl
wcl> owner
"platform"
wcl> len(services)
2
wcl> :quit
:quit or :q exits, and so does EOF (Ctrl-D). There is no history and no line editing. The repl reads lines, and that is exactly what makes it scriptable. A multi-line expression continues automatically while brackets are unbalanced, and the prompt changes to ... .
The exit code depends on whether a human was watching. An interactive session always exits 0: you saw the error and recovered from it. A piped session reports the worst thing that happened. It exits 3 if any evaluation failed, else 1 if any parse failed, so a script can detect the failure:
$ printf '1 + 2\nlen([1,2,3])\n' | wcl repl
3
3
$ echo $?
0
$ printf '1 +* 2\n' | wcl repl
parse error: wcl::parse
× expected value, found '*'
╭─[<repl>:1:4]
1 │ 1 +* 2
· ┬
· ╰── expected value
╰────
$ echo $?
1
wcl lsp
Runs the WCL language server.
$ wcl lsp [--tcp host:port] [--log file]
| Argument | Meaning |
|---|---|
| --tcp <addr> | Listen on host:port instead of using stdio. Each connection is an independent LSP session. |
| --log <file> | Write tracing log lines to this file. |
The default transport is stdio, and that is what an editor expects. You normally never type this command. You configure your editor to spawn it. --tcp 127.0.0.1:9257 is for attaching a debug client by hand.
--log takes a file and only a file. The server never logs to stderr, because that would corrupt the stdio LSP stream.
The server provides diagnostics, formatting, document symbols, workspace symbol search, go-to-definition and find-references across files, hover, completion, signature help, semantic tokens and schema-violation code actions. An open buffer shadows the copy on disk, so a cross-file lookup sees your unsaved edit.
wcl wdoc build
Renders every page in a document. --type picks the renderer: a website, a folder of Markdown, or a PDF.
$ wcl wdoc build <file> --out <dir> [--type html|markdown|pdf] [--site NAME] [--page-size a4|letter]
| Argument | Default | Meaning |
|---|---|---|
| <file> | — | The entry document. |
| --out <dir> | — | Output directory. Created if missing. Required. |
| --type | html | Which renderer to run: html, markdown (alias md) or pdf. |
| --site <name> | every site | Build only this site, flat at <out>. The effect of omitting this flag depends on --type. See below. |
| --page-size | a4 | a4 or letter. --type pdf only. Passing it with another type is an error. |
$ wcl wdoc build main.wcl --out _site
wrote 2 pages
$ wcl wdoc build main.wcl --out _md --type markdown
wrote 2 pages
$ wcl wdoc build main.wcl --out _pdf --type pdf
wrote 1 pdf
Two things stay per-type rather than unified, because the renderers genuinely differ. The count means pages for html and markdown, but sites for pdf, which writes one file per site. So the same document can report wrote 3 pages and wrote 2 pdfs. Omitting --site renders every site into its own <out>/<name>/ subdirectory for html and markdown, and html also writes a chooser index. pdf names the output after the site, and falls back to the source file's stem when the document declares no site block. A single-site document is unaffected either way.
- html. One .html per page plus shared assets. The default, and what serve serves.
- markdown. One .md per page, plus the standalone .svg files the Markdown references for diagrams, terminals and wireframes. Aimed at AI and text consumers: zoomable diagrams render as plain SVG, equations stay LaTeX, and the renderer skips videos.
- pdf. One paginated .pdf per site. Pure Rust: no browser, no external tools.
$ wcl wdoc build main.wcl --out _md --type markdown
$ find _md -type f
_md/index.md
_md/one.md
_md/_wdoc/one-diagram-1.svg
Output targets covers what lands in <out> for each type, what --site does to the layout, what a PDF can and cannot carry, and the rule that build never wipes the directory it writes into.
wcl wdoc serve
Runs a local dev server over the same build.
$ wcl wdoc serve <file> [--addr ADDR] [--out DIR] [--site NAME]
| Argument | Default | Meaning |
|---|---|---|
| <file> | — | The entry document. |
| --addr | 127.0.0.1:8080 | Bind address, or auto to take the first free port near 8080. |
| --out | a temp directory | Output directory. serve removes the temp directory on shutdown. |
| --site | every site | Serve only this site, at /. Without this flag, serve serves each site under /<name>/, with a chooser at /. |
$ wcl wdoc serve main.wcl --out _serve
rendered 2 pages
serving http://127.0.0.1:8080 (source: main.wcl, out: _serve)
auto-rebuild is off — press Enter here to rebuild after edits
The last line is the surprising part, and it is deliberate: the watcher accumulates changes and rebuilds only when you ask. Output targets covers the loop, the two triggers, and what an incremental rebuild really re-renders.
Where to go next
- Documents, fields and blocks. The tree parse, get and set walk.
- How a document evaluates. The evaluate-and-edit split behind get and set, and the error model behind the exit codes.
- Namespaces and imports. How the imports every command follows resolve.
- Builtins. Every function an expression in a repl session or a set value can call.
- Output targets. The three wcl wdoc build output types in depth.
- Documents, pages and sites. What a document must declare before wcl wdoc build has anything to render.