Introduction
WCL (Wil’s Configuration Language) is a statically-typed, block-structured configuration language designed for expressive, validated, and maintainable configuration. It combines schemas, decorators, macros, data tables, a query engine, partial declarations, and a full Language Server Protocol (LSP) implementation into a single cohesive system.
What is WCL?
WCL is a configuration language built around named blocks of typed key-value attributes. Unlike general-purpose data formats, WCL is evaluated: it supports expressions, environment variable references, built-in functions, and cross-block references. Schemas enforce the shape of your data at validation time, and the entire pipeline from parsing to schema validation is designed to produce clear, actionable diagnostics.
Core features:
- Block-structured: configuration is organized into named, typed blocks
- Static typing with schemas: declare the expected shape of any block type and validate against it
- Decorators: attach metadata or behavior to blocks and attributes with
@decoratorsyntax - Macros: define reusable configuration fragments with function-style and attribute-style macros
- Data tables: declare tabular data inline and query it
- Partial declarations: split a block definition across multiple files or sites with
@partial - Query engine: select and filter blocks using a path-based query syntax
- Full LSP: diagnostics, hover, go-to-definition, completions, formatting, and more
Why WCL Instead of JSON, YAML, TOML, or HCL?
| Feature | JSON | YAML | TOML | HCL | WCL |
|---|---|---|---|---|---|
| Comments | No | Yes | Yes | Yes | Yes (doc comments too) |
| Expressions | No | No | No | Yes | Yes |
| Schemas / validation | No | No | No | Partial | Yes (first-class) |
| Macros / reuse | No | Anchors | No | Modules | Yes |
| Decorators | No | No | No | No | Yes |
| Data tables | No | No | No | No | Yes |
| Query engine | No | No | No | No | Yes |
| LSP | No | Limited | Limited | Yes | Yes |
| Static types | No | No | No | No | Yes |
JSON and TOML are simple but offer no reuse or validation. YAML is expressive but notorious for surprising parse behavior. HCL is powerful but tightly coupled to HashiCorp tooling. WCL is designed as a standalone, tool-agnostic configuration layer with a full validation and evaluation pipeline.
Quick Example
The following defines a production web server configuration with an enforcing schema:
/// Production web server configuration
server web-prod {
host = "0.0.0.0"
port = 8080
workers = max(4, 2)
@sensitive
api_key = "sk-secret-key"
}
schema "server" {
host: string @optional
port: int
workers: int
api_key: string
}
Key things to notice:
server web-prod { ... }is a block of typeserverwith IDweb-prod- The
schema "server"block automatically validates everyserverblock by matching the name workers = max(4, 2)is an evaluated expression using a built-in function@sensitiveis a decorator that can be handled by tooling (e.g., to redact the value from output)- Schema fields use colon syntax (
port: int) to declare expected types
Where to Go Next
Ready to start using WCL? Head to Getting Started to install the CLI and write your first configuration file.
Installation
Prerequisites
WCL requires a Rust toolchain. If you do not have Rust installed, get it from rustup.rs:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
After installation, ensure cargo and rustc are on your PATH:
rustc --version
cargo --version
Install the CLI
From the root of the WCL repository, install the wcl binary directly to ~/.cargo/bin/:
cargo install --path crates/wcl
Cargo will build the binary in release mode and place it at ~/.cargo/bin/wcl. As long as ~/.cargo/bin is on your PATH (the default after running rustup), the wcl command is immediately available.
Building from Source
To build the entire workspace without installing:
cargo build --workspace
The debug binary will be at target/debug/wcl. For a release build:
cargo build --workspace --release
The release binary will be at target/release/wcl.
To run the full test suite:
cargo test --workspace
Verify the Installation
wcl --version
You should see output like:
wcl 0.1.0
Uninstalling
cargo uninstall wcl
This removes the wcl binary from ~/.cargo/bin/.
Your First Configuration File
This page walks through creating a WCL file, evaluating it, adding a schema, and validating it.
Create a File
Create a file called hello.wcl with a simple server block:
server web {
host = "localhost"
port = 3000
workers = 2
}
A block in WCL has a type (server), an optional ID (web), and a body containing attribute assignments.
Evaluate It
The eval command runs the WCL pipeline and outputs the result as JSON:
wcl eval hello.wcl
Output:
{
"server": {
"web": {
"host": "localhost",
"port": 3000,
"workers": 2
}
}
}
WCL evaluates expressions, resolves references, and expands macros before producing output. For a simple file like this, the output mirrors the input structure.
Add a Schema
Extend hello.wcl to include a schema:
server web {
host = "localhost"
port = 3000
workers = 2
}
schema "server" {
host : string
port : int
workers : int
}
The schema block declares the expected type for each attribute. WCL matches schemas to blocks automatically by block type name — a schema named "server" validates all server blocks.
Validate It
wcl validate hello.wcl
If the configuration is valid, the command exits with code 0 and no output. If there is a type mismatch or a missing required field, you will see a diagnostic:
error[E071]: type mismatch for field `port`: expected int, got string
--> hello.wcl:3:12
|
3 | port = "3000"
| ^^^^^^ expected int
JSON Output with a Schema
Validation does not change the JSON output — wcl eval hello.wcl produces the same JSON structure as before. The schema is purely a validation constraint.
Key Concepts
- Blocks: the primary unit of configuration. A block has a type, an optional ID, and a set of attributes.
- Attributes: key-value pairs inside a block. Values can be literals, expressions, function calls, or references to other values.
- Schemas: declare the expected type (and optionally constraints) for each attribute in a block. Schemas are matched to blocks automatically by block type name.
- Decorators:
@nameor@name(args)annotations on blocks or attributes. Built-in decorators include@sensitive,@optional,@partial, and more.
From here, explore the CLI Quickstart to learn about the other commands available, or jump into the language reference to learn about expressions, macros, and the query engine.
CLI Quickstart
The wcl binary is the primary interface to the WCL toolchain. This page gives a quick tour of the most commonly used commands.
Evaluate to JSON
wcl eval config.wcl
Runs the full WCL pipeline (parse, macro expansion, evaluation, schema validation) and prints the result as JSON to stdout. Use --pretty for indented output (the default) or --compact for a single line.
Validate
wcl validate config.wcl
Runs the pipeline including schema validation and reports any diagnostics. Exits with a non-zero status code if there are errors. Useful as a pre-commit check or in CI.
Format
wcl fmt config.wcl
Prints the formatted version of the file to stdout. To write the result back in place:
wcl fmt config.wcl --write
To check whether a file is already formatted (useful in CI):
wcl fmt config.wcl --check
Query
wcl query config.wcl "server | .port > 8000"
Runs a query against the evaluated document and prints matching blocks as JSON. The query syntax selects blocks by type, filters by attribute values, and supports chaining. Some examples:
# All server blocks
wcl query config.wcl "server"
# Server blocks where port is greater than 8000
wcl query config.wcl "server | .port > 8000"
# A specific block by ID
wcl query config.wcl "server#web-prod"
Convert
Convert between WCL and other formats:
# JSON to WCL
wcl convert data.json --to wcl
# WCL to YAML
wcl convert config.wcl --to yaml
# WCL to JSON
wcl convert config.wcl --to json
Supported target formats: wcl, json, yaml, toml.
Set a Value
Update a single attribute value in a WCL file:
wcl set config.wcl "server#web.port" "9090"
The path syntax is type#id.attribute. The file is updated in place.
Add a Block
wcl add config.wcl "server new-svc"
Appends a new empty block of the given type and ID to the file. You can also pipe in a block body:
wcl add config.wcl "server new-svc" --body '{ port = 8081 }'
Remove a Block
wcl remove config.wcl "server#old-svc"
Removes the block with type server and ID old-svc from the file.
Inspect
Inspect internal representations for debugging or tooling development:
# Print the AST as pretty-printed text
wcl inspect --ast config.wcl
# Print the evaluated scope as JSON
wcl inspect --scope config.wcl
# Print all macros collected from the file
wcl inspect --macros config.wcl
Start the Language Server
wcl lsp
Starts the WCL Language Server over stdio. This is normally invoked automatically by your editor extension or LSP client, not manually. See Editor Setup for configuration instructions.
Editor Setup
WCL ships a language server (wcl lsp) that implements the Language Server Protocol. Any LSP-capable editor can use it. This page covers setup for VS Code, Neovim, and Helix.
LSP Features
- Real-time diagnostics (parse errors, type mismatches, schema violations)
- Hover documentation for blocks, attributes, and schema definitions
- Go-to-definition for block references and macro uses
- Completions for attribute names, block types, schema names, and decorator names
- Semantic token highlighting
- Signature help for macro and built-in function calls
- Find references
- Document formatting (
wcl fmtintegration)
VS Code
A bundled VS Code extension is located in editors/vscode/ in the repository. It handles syntax highlighting, LSP integration, and file association for .wcl files.
Install with just (if you have just installed):
just install-vscode
Install manually:
cd editors/vscode && npm install
ln -sfn "$(pwd)" ~/.vscode/extensions/wil.wcl-0.1.0
The symlink approach means changes to the extension source are picked up immediately without reinstalling. Restart VS Code (or run “Developer: Reload Window”) after linking.
The extension automatically starts wcl lsp when a .wcl file is opened. Make sure wcl is on your PATH (i.e., installed via cargo install --path crates/wcl).
Neovim
Add the following to your Neovim configuration (e.g., in an ftplugin/wcl.lua or your main init.lua):
vim.lsp.start({
name = "wcl",
cmd = { "wcl", "lsp" },
root_dir = vim.fs.dirname(vim.fs.find({ ".git" }, { upward = true })[1]),
filetypes = { "wcl" },
})
You will also want to register the .wcl filetype so Neovim recognizes it:
vim.filetype.add({
extension = {
wcl = "wcl",
},
})
If you use nvim-lspconfig, a custom server entry works the same way — pass cmd = { "wcl", "lsp" } and set filetypes = { "wcl" }.
Helix
Add the following to your ~/.config/helix/languages.toml:
[[language]]
name = "wcl"
scope = "source.wcl"
file-types = ["wcl"]
language-servers = ["wcl-lsp"]
[language-server.wcl-lsp]
command = "wcl"
args = ["lsp"]
Helix will start wcl lsp automatically when a .wcl file is opened. Run hx --health wcl to verify the language server is detected correctly.
Other Editors
Any editor with LSP support can use the WCL language server. The server communicates over stdio and is started with:
wcl lsp
Refer to your editor’s LSP documentation for how to register a custom language server with that command.
Syntax Highlighting
WCL provides syntax highlighting definitions for a wide range of editors, tools, and platforms. These are distributed as standalone files in the extras/ directory of the repository.
Overview
| Package | Format | Unlocks |
|---|---|---|
| Tree-sitter Highlight Queries | .scm | Neovim, Helix, Zed, GitHub |
| Sublime Syntax | .sublime-syntax | Sublime Text, Syntect, bat |
| TextMate Grammar | .tmLanguage.json | VS Code, Shiki, Monaco |
| highlight.js | .js | mdbook, any highlight.js site |
| Pygments Lexer | .py | Pygments, Chroma, Hugo |
Tree-sitter Highlight Queries
The tree-sitter grammar (extras/tree-sitter-wcl/) provides the most accurate highlighting since it uses a full parse tree rather than regex heuristics. The query files are:
| File | Location | Purpose |
|---|---|---|
highlights.scm | extras/tree-sitter-wcl/queries/ | Core syntax highlighting |
locals.scm | extras/highlight-queries/ | Scope-aware variable highlighting |
textobjects.scm | extras/highlight-queries/ | Structural text objects (select block, function, etc.) |
injections.scm | extras/highlight-queries/ | String interpolation injection |
Neovim
Register the WCL filetype and copy the query files:
mkdir -p ~/.config/nvim/queries/wcl
cp extras/tree-sitter-wcl/queries/highlights.scm ~/.config/nvim/queries/wcl/
cp extras/highlight-queries/*.scm ~/.config/nvim/queries/wcl/
Add filetype detection in your init.lua:
vim.filetype.add({ extension = { wcl = "wcl" } })
If you use nvim-treesitter, you can register WCL as a custom parser. The query files above will then provide highlighting, text objects (with nvim-treesitter-textobjects), and scope-aware local variable highlights automatically.
Helix
Copy the query files into the Helix runtime directory:
mkdir -p ~/.config/helix/runtime/queries/wcl
cp extras/tree-sitter-wcl/queries/highlights.scm ~/.config/helix/runtime/queries/wcl/
cp extras/highlight-queries/textobjects.scm ~/.config/helix/runtime/queries/wcl/
cp extras/highlight-queries/injections.scm ~/.config/helix/runtime/queries/wcl/
Then add the language configuration to ~/.config/helix/languages.toml (see Editor Setup for the full LSP config):
[[language]]
name = "wcl"
scope = "source.wcl"
file-types = ["wcl"]
[[grammar]]
name = "wcl"
source = { path = "/path/to/extras/tree-sitter-wcl" }
Zed
Zed supports tree-sitter grammars natively. Place the query files in an extension directory:
languages/wcl/
highlights.scm
injections.scm
GitHub
GitHub uses tree-sitter for syntax highlighting in repositories. Once tree-sitter-wcl is published and registered with github-linguist, .wcl files will be highlighted automatically on GitHub.
VS Code / Shiki
The VS Code extension (editors/vscode/) includes a TextMate grammar (wcl.tmLanguage.json) that provides syntax highlighting. See Editor Setup for installation.
The TextMate grammar is generated from the canonical Sublime Syntax definition:
just build vscode-syntax
This same wcl.tmLanguage.json file can be used with Shiki (used by VitePress, Astro, and other static site generators):
import { createHighlighter } from 'shiki';
import wclGrammar from './wcl.tmLanguage.json';
const highlighter = await createHighlighter({
langs: [
{
id: 'wcl',
scopeName: 'source.wcl',
...wclGrammar,
},
],
themes: ['github-dark'],
});
const html = highlighter.codeToHtml(code, { lang: 'wcl' });
Sublime Text / Syntect / bat
The Sublime Syntax definition (extras/sublime-syntax/WCL.sublime-syntax) is the canonical regex-based syntax file. It supports nested block comments, string interpolation, heredocs, and all WCL keywords and types.
Sublime Text
cp extras/sublime-syntax/WCL.sublime-syntax \
~/.config/sublime-text/Packages/User/
bat
bat uses Syntect internally and can load custom .sublime-syntax files:
mkdir -p "$(bat --config-dir)/syntaxes"
cp extras/sublime-syntax/WCL.sublime-syntax "$(bat --config-dir)/syntaxes/"
bat cache --build
Then bat file.wcl will use WCL highlighting.
Syntect (Rust)
If you’re building a Rust tool that uses Syntect for highlighting, load the syntax definition at build time:
#![allow(unused)]
fn main() {
use syntect::parsing::SyntaxSet;
let mut builder = SyntaxSet::load_defaults_newlines().into_builder();
builder.add_from_folder("path/to/extras/sublime-syntax/", true)?;
let ss = builder.build();
}
highlight.js / mdbook
The highlight.js grammar (extras/highlightjs/wcl.js) provides syntax highlighting for any site using highlight.js, including mdbook (which uses highlight.js by default).
mdbook
To add WCL highlighting to an mdbook project:
- Copy the grammar file:
mkdir -p docs/book/theme
cp extras/highlightjs/wcl.js docs/book/theme/
- Register it in
book.toml:
[output.html]
additional-js = ["theme/wcl.js"]
- Add a registration snippet. Create
theme/highlight-wcl.js:
if (typeof hljs !== 'undefined') {
hljs.registerLanguage('wcl', function(hljs) {
// The module exports a default function
return wcl(hljs);
});
}
And add it to additional-js:
[output.html]
additional-js = ["theme/wcl.js", "theme/highlight-wcl.js"]
- Use
wclas the language in fenced code blocks:
```wcl
server web-prod {
host = "0.0.0.0"
port = 8080
}
```
Standalone highlight.js
import hljs from 'highlight.js/lib/core';
import wcl from './wcl.js';
hljs.registerLanguage('wcl', wcl);
hljs.highlightAll();
Pygments / Chroma / Hugo
The Pygments lexer (extras/pygments/wcl_lexer.py) provides syntax highlighting for Python-based tools and can be converted for use with Go-based tools.
Pygments
Use directly with the -x flag:
pygmentize -l extras/pygments/wcl_lexer.py:WclLexer -x -f html input.wcl
Or install as a plugin by adding to your package’s entry points:
# In setup.py or pyproject.toml
[project.entry-points."pygments.lexers"]
wcl = "wcl_lexer:WclLexer"
Then pygmentize -l wcl input.wcl works directly.
Chroma / Hugo
Chroma is the Go syntax highlighter used by Hugo. Chroma can import Pygments-style lexers. To add WCL support to a Hugo site:
- Convert the Pygments lexer to a Chroma Go implementation (see the Chroma contributing guide)
- Or use the
chromaCLI to test directly:
chroma --lexer pygments --filename input.wcl < input.wcl
What’s Highlighted
All highlighting definitions cover the same WCL syntax elements:
| Element | Examples |
|---|---|
| Keywords | if, else, for, in, let, macro, schema, table, import, export |
| Declaration keywords | declare, validation, decorator_schema, partial |
| Transform keywords | inject, set, remove, when, check, message, target |
| Built-in types | string, int, float, bool, any, identifier, list, map, set, union, ref |
| Built-in functions | query, has, import_table, import_raw |
| Constants | true, false, null |
| Numbers | Integers, floats, hex (0xFF), octal (0o77), binary (0b101) |
| Strings | Double-quoted with ${interpolation} and \escape sequences |
| Heredocs | <<EOF ... EOF |
| Decorators | @optional, @deprecated(reason = "...") |
| Comments | //, /* */, /// (doc comments) |
| Operators | +, -, *, /, %, ==, !=, &&, ` |
The tree-sitter queries additionally provide context-aware highlighting (distinguishing block types from identifiers, function calls from variables, parameters from local bindings, etc.) which regex-based highlighters cannot fully replicate.
Using WCL from C and C++
WCL provides a C static library (libwcl_ffi.a / wcl_ffi.lib) and a header (wcl.h) that expose the full 11-phase WCL pipeline. All complex values cross the boundary as JSON strings — parse them with your preferred JSON library (e.g. cJSON, nlohmann/json, jansson).
Prebuilt libraries are available for:
| Platform | Architecture | Library |
|---|---|---|
| Linux | x86_64 | lib/linux_amd64/libwcl_ffi.a |
| Linux | aarch64 | lib/linux_arm64/libwcl_ffi.a |
| macOS | x86_64 | lib/darwin_amd64/libwcl_ffi.a |
| macOS | arm64 | lib/darwin_arm64/libwcl_ffi.a |
| Windows | x86_64 | lib/windows_amd64/wcl_ffi.lib |
Using the Prebuilt Package
Download and extract the wcl-ffi.tar.gz archive. It contains everything you need:
wcl-ffi/
CMakeLists.txt # CMake config (creates wcl::wcl target)
include/wcl.h # C header
lib/
linux_amd64/libwcl_ffi.a
linux_arm64/libwcl_ffi.a
darwin_amd64/libwcl_ffi.a
darwin_arm64/libwcl_ffi.a
windows_amd64/wcl_ffi.lib
CMake
Add the extracted directory as a subdirectory in your project:
cmake_minimum_required(VERSION 3.14)
project(myapp LANGUAGES C)
add_subdirectory(path/to/wcl-ffi)
add_executable(myapp main.c)
target_link_libraries(myapp PRIVATE wcl::wcl)
The wcl::wcl target automatically handles the include path, selects the correct library for your platform, and links the required system dependencies.
Build:
cmake -B build
cmake --build build
Without CMake (Linux)
gcc -o myapp main.c \
-Ipath/to/wcl-ffi/include \
-Lpath/to/wcl-ffi/lib/linux_amd64 \
-lwcl_ffi -lm -ldl -lpthread
Without CMake (macOS)
gcc -o myapp main.c \
-Ipath/to/wcl-ffi/include \
-Lpath/to/wcl-ffi/lib/darwin_arm64 \
-lwcl_ffi -lm -ldl -lpthread -framework Security
Without CMake (Windows / MSVC)
cl /Fe:myapp.exe main.c \
/I path\to\wcl-ffi\include \
path\to\wcl-ffi\lib\windows_amd64\wcl_ffi.lib \
ws2_32.lib bcrypt.lib userenv.lib
Building from Source
If you need to build the library yourself (requires a Rust toolchain):
# Native platform only
cargo build -p wcl_ffi --release
# Output: target/release/libwcl_ffi.a (or .lib on Windows)
# All platforms (requires cargo-zigbuild + zig)
just build ffi-all
# Package into an archive
just pack ffi
When building from the source tree, the CMake file also searches target/release/ for the library, so you can use add_subdirectory directly on crates/wcl_ffi/:
add_subdirectory(path/to/wcl/crates/wcl_ffi)
target_link_libraries(myapp PRIVATE wcl::wcl)
You can override the library location with -DWCL_LIB_DIR:
cmake -B build -DWCL_LIB_DIR=/custom/path
C API Reference
All functions use null-terminated C strings. Strings returned by wcl_ffi_* functions are heap-allocated and must be freed with wcl_ffi_string_free(). Documents must be freed with wcl_ffi_document_free().
#include "wcl.h"
Parsing
// Parse a source string. options_json may be NULL for defaults.
WclDocument *wcl_ffi_parse(const char *source, const char *options_json);
// Parse a file. Returns NULL on I/O error (check wcl_ffi_last_error()).
WclDocument *wcl_ffi_parse_file(const char *path, const char *options_json);
// Free a document. Safe to call with NULL.
void wcl_ffi_document_free(WclDocument *doc);
Accessing Values
// Evaluated values as a JSON object string. Caller frees.
char *wcl_ffi_document_values(const WclDocument *doc);
// Check for errors.
bool wcl_ffi_document_has_errors(const WclDocument *doc);
// Error diagnostics as a JSON array string. Caller frees.
char *wcl_ffi_document_errors(const WclDocument *doc);
// All diagnostics as a JSON array string. Caller frees.
char *wcl_ffi_document_diagnostics(const WclDocument *doc);
Queries and Blocks
// Execute a query. Returns {"ok": ...} or {"error": "..."}. Caller frees.
char *wcl_ffi_document_query(const WclDocument *doc, const char *query);
// All blocks as a JSON array. Caller frees.
char *wcl_ffi_document_blocks(const WclDocument *doc);
// Blocks of a specific type. Caller frees.
char *wcl_ffi_document_blocks_of_type(const WclDocument *doc, const char *kind);
Custom Functions
// Callback signature: receives JSON args, returns JSON result (malloc'd).
// Return NULL on error, or prefix with "ERR:" for an error message.
typedef char *(*WclCallbackFn)(void *ctx, const char *args_json);
// Parse with custom functions.
WclDocument *wcl_ffi_parse_with_functions(
const char *source,
const char *options_json,
const char *const *func_names, // array of function name strings
const WclCallbackFn *func_callbacks, // array of callback pointers
const uintptr_t *func_contexts, // array of context pointers
uintptr_t func_count
);
Utilities
// Free a string returned by any wcl_ffi_* function. Safe with NULL.
void wcl_ffi_string_free(char *s);
// Last error message from a failed call. NULL if none. Caller frees.
char *wcl_ffi_last_error(void);
// List installed libraries. Returns JSON. Caller frees.
char *wcl_ffi_list_libraries(void);
Parsing a WCL String
#include <stdio.h>
#include "wcl.h"
int main(void) {
WclDocument *doc = wcl_ffi_parse(
"server web-prod {\n"
" host = \"0.0.0.0\"\n"
" port = 8080\n"
"}\n",
NULL
);
if (wcl_ffi_document_has_errors(doc)) {
char *errors = wcl_ffi_document_errors(doc);
fprintf(stderr, "Errors: %s\n", errors);
wcl_ffi_string_free(errors);
} else {
char *values = wcl_ffi_document_values(doc);
printf("Values: %s\n", values);
wcl_ffi_string_free(values);
}
wcl_ffi_document_free(doc);
return 0;
}
Parsing a WCL File
#include <stdio.h>
#include "wcl.h"
int main(void) {
WclDocument *doc = wcl_ffi_parse_file("config/main.wcl", NULL);
if (!doc) {
char *err = wcl_ffi_last_error();
fprintf(stderr, "Failed to open file: %s\n", err ? err : "unknown");
wcl_ffi_string_free(err);
return 1;
}
char *values = wcl_ffi_document_values(doc);
printf("%s\n", values);
wcl_ffi_string_free(values);
wcl_ffi_document_free(doc);
return 0;
}
Running Queries
WclDocument *doc = wcl_ffi_parse(
"server svc-api { port = 8080 }\n"
"server svc-admin { port = 9090 }\n",
NULL
);
char *result = wcl_ffi_document_query(doc, "server | .port");
printf("Ports: %s\n", result); // {"ok":[8080,9090]}
wcl_ffi_string_free(result);
wcl_ffi_document_free(doc);
Working with Blocks
WclDocument *doc = wcl_ffi_parse(
"server web { port = 80 }\n"
"database main { port = 5432 }\n",
NULL
);
// All blocks
char *blocks = wcl_ffi_document_blocks(doc);
printf("All blocks: %s\n", blocks);
wcl_ffi_string_free(blocks);
// Blocks of a specific type
char *servers = wcl_ffi_document_blocks_of_type(doc, "server");
printf("Servers: %s\n", servers);
wcl_ffi_string_free(servers);
wcl_ffi_document_free(doc);
Custom Functions
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "wcl.h"
// Callback: double the first argument.
// Receives JSON array of args, returns JSON result (must be malloc'd).
char *double_fn(void *ctx, const char *args_json) {
(void)ctx;
// Minimal parsing: args_json is e.g. "[21]"
int n = 0;
sscanf(args_json, "[%d]", &n);
char *result = malloc(32);
snprintf(result, 32, "%d", n * 2);
return result;
}
int main(void) {
const char *names[] = {"double"};
WclCallbackFn callbacks[] = {double_fn};
uintptr_t contexts[] = {0};
WclDocument *doc = wcl_ffi_parse_with_functions(
"result = double(21)",
NULL,
names, callbacks, contexts, 1
);
char *values = wcl_ffi_document_values(doc);
printf("%s\n", values); // {"result":42}
wcl_ffi_string_free(values);
wcl_ffi_document_free(doc);
return 0;
}
Parse Options
Options are passed as a JSON string:
WclDocument *doc = wcl_ffi_parse(source,
"{"
" \"rootDir\": \"./config\","
" \"allowImports\": false,"
" \"maxImportDepth\": 32,"
" \"maxMacroDepth\": 64,"
" \"maxLoopDepth\": 32,"
" \"maxIterations\": 10000"
"}"
);
Pass NULL for default options. When processing untrusted input, disable imports:
WclDocument *doc = wcl_ffi_parse(untrusted, "{\"allowImports\": false}");
Error Handling
The document collects all diagnostics from every pipeline phase. Each diagnostic in the JSON array has severity, message, and an optional code:
WclDocument *doc = wcl_ffi_parse(
"schema \"server\" { port: int }\n"
"server web { port = \"bad\" }\n",
NULL
);
if (wcl_ffi_document_has_errors(doc)) {
char *diags = wcl_ffi_document_diagnostics(doc);
// diags is a JSON array:
// [{"severity":"error","message":"...","code":"E071"}]
printf("Diagnostics: %s\n", diags);
wcl_ffi_string_free(diags);
}
wcl_ffi_document_free(doc);
C++ Usage
The header is plain C and works directly from C++:
extern "C" {
#include "wcl.h"
}
All the examples above work identically in C++. The CMake target and compiler flags are the same — just compile your .cpp files instead of .c.
Complete Example
#include <stdio.h>
#include "wcl.h"
int main(void) {
WclDocument *doc = wcl_ffi_parse(
"schema \"server\" {\n"
" port: int\n"
" host: string @optional\n"
"}\n"
"\n"
"server svc-api {\n"
" port = 8080\n"
" host = \"api.internal\"\n"
"}\n"
"\n"
"server svc-admin {\n"
" port = 9090\n"
" host = \"admin.internal\"\n"
"}\n",
NULL
);
// 1. Check for errors
if (wcl_ffi_document_has_errors(doc)) {
char *errors = wcl_ffi_document_errors(doc);
fprintf(stderr, "Errors: %s\n", errors);
wcl_ffi_string_free(errors);
wcl_ffi_document_free(doc);
return 1;
}
// 2. Get evaluated values
char *values = wcl_ffi_document_values(doc);
printf("Values: %s\n", values);
wcl_ffi_string_free(values);
// 3. Query for all server ports
char *ports = wcl_ffi_document_query(doc, "server | .port");
printf("Ports: %s\n", ports);
wcl_ffi_string_free(ports);
// 4. Get server blocks
char *servers = wcl_ffi_document_blocks_of_type(doc, "server");
printf("Servers: %s\n", servers);
wcl_ffi_string_free(servers);
// 5. All diagnostics
char *diags = wcl_ffi_document_diagnostics(doc);
printf("Diagnostics: %s\n", diags);
wcl_ffi_string_free(diags);
wcl_ffi_document_free(doc);
return 0;
}
Using WCL as a Rust Library
WCL can be embedded directly into Rust programs. The wcl facade crate provides the full parsing pipeline, evaluated values, queries, and serde deserialization.
Adding the Dependency
Add wcl as a path dependency (or git dependency) in your Cargo.toml:
[dependencies]
wcl = { path = "../wcl" }
The wcl crate re-exports everything you need.
Parsing a WCL String
Use wcl::parse() to run the full 11-phase pipeline and get a Document:
#![allow(unused)]
fn main() {
use wcl::{parse, ParseOptions};
let source = r#"
server web-prod {
host = "0.0.0.0"
port = 8080
debug = false
}
"#;
let doc = parse(source, ParseOptions::default());
// Check for errors
if doc.has_errors() {
for diag in doc.errors() {
eprintln!("error: {}", diag.message);
}
} else {
println!("Document parsed successfully");
}
}
Parsing a WCL File
Read the file yourself and pass the content to parse(). Set root_dir so that imports resolve correctly:
#![allow(unused)]
fn main() {
use wcl::{parse, ParseOptions};
use std::path::PathBuf;
let path = PathBuf::from("config/main.wcl");
let source = std::fs::read_to_string(&path).expect("read file");
let options = ParseOptions {
root_dir: path.parent().unwrap().to_path_buf(),
..Default::default()
};
let doc = parse(&source, options);
assert!(!doc.has_errors());
}
Accessing Evaluated Values
After parsing, doc.values contains an ordered map of all evaluated top-level attributes and blocks:
#![allow(unused)]
fn main() {
use wcl::{parse, ParseOptions, Value};
let doc = parse(r#"
name = "my-app"
port = 8080
tags = ["web", "prod"]
"#, ParseOptions::default());
// Access scalar values
if let Some(Value::String(name)) = doc.values.get("name") {
println!("name: {}", name);
}
if let Some(Value::Int(port)) = doc.values.get("port") {
println!("port: {}", port);
}
// Access list values
if let Some(Value::List(tags)) = doc.values.get("tags") {
for tag in tags {
println!("tag: {}", tag);
}
}
}
Working with Blocks
Blocks are stored as Value::BlockRef in the values map. You can also use the convenience methods on Document:
#![allow(unused)]
fn main() {
use wcl::{parse, ParseOptions, Value};
let doc = parse(r#"
server web-prod {
host = "0.0.0.0"
port = 8080
}
server web-staging {
host = "staging.internal"
port = 8081
}
"#, ParseOptions::default());
// Get all blocks as resolved BlockRef values
let servers = doc.blocks_of_type_resolved("server");
for server in &servers {
println!("server id={:?}", server.id);
if let Some(Value::Int(port)) = server.get("port") {
println!(" port: {}", port);
}
if let Some(Value::String(host)) = server.get("host") {
println!(" host: {}", host);
}
}
// Check decorators
let all_blocks = doc.blocks();
for block in &all_blocks {
if block.has_decorator("deprecated") {
let dec = block.decorator("deprecated").unwrap();
println!("{} is deprecated: {:?}", block.kind, dec.args);
}
}
}
Working with Tables
Tables evaluate to Value::List(Vec<Value::Map>) — a list of row maps where each row maps column names to cell values:
#![allow(unused)]
fn main() {
use wcl::{parse, ParseOptions};
use wcl::eval::value::Value;
let doc = parse(r#"
table users {
name : string
age : int
| "alice" | 25 |
| "bob" | 30 |
}
"#, ParseOptions::default());
if let Some(Value::List(rows)) = doc.values.get("users") {
for row in rows {
if let Value::Map(cols) = row {
println!("{}: {}", cols["name"], cols["age"]);
}
}
}
// Output:
// alice: 25
// bob: 30
}
Tables inside blocks appear in the BlockRef.attributes map:
#![allow(unused)]
fn main() {
if let Some(Value::BlockRef(br)) = doc.values.get("main") {
if let Some(Value::List(rows)) = br.attributes.get("users") {
println!("Table has {} rows", rows.len());
}
}
}
Running Queries
The Document::query() method accepts the same query syntax as the CLI:
#![allow(unused)]
fn main() {
use wcl::{parse, ParseOptions, Value};
let doc = parse(r#"
server svc-api {
port = 8080
env = "prod"
}
server svc-admin {
port = 9090
env = "prod"
}
server svc-debug {
port = 3000
env = "dev"
}
"#, ParseOptions::default());
// Select all server blocks
let all = doc.query("server").unwrap();
// Filter by attribute
let prod = doc.query(r#"server | .env == "prod""#).unwrap();
// Project a single attribute
let ports = doc.query("server | .port").unwrap();
if let Value::List(port_list) = ports {
println!("ports: {:?}", port_list);
// [Int(8080), Int(9090), Int(3000)]
}
// Filter and project
let prod_ports = doc.query(r#"server | .env == "prod" | .port"#).unwrap();
// Select by ID
let api = doc.query("server#svc-api").unwrap();
}
Deserializing into Rust Structs
With from_str
The simplest approach deserializes a WCL string directly into a Rust type via serde:
#![allow(unused)]
fn main() {
use wcl::from_str;
use serde::Deserialize;
#[derive(Deserialize, Debug)]
struct AppConfig {
name: String,
port: i64,
debug: bool,
}
let config: AppConfig = from_str(r#"
name = "my-app"
port = 8080
debug = false
"#).expect("parse error");
println!("{:?}", config);
}
Deserializing from Value
If you already have a parsed Document, you can deserialize individual values using from_value:
#![allow(unused)]
fn main() {
use wcl::{from_value, Value};
use serde::Deserialize;
#[derive(Deserialize, Debug)]
struct ServerConfig {
port: i64,
host: String,
}
// Assume `val` is a Value::Map obtained from doc.values or a BlockRef
let mut map = indexmap::IndexMap::new();
map.insert("port".to_string(), Value::Int(8080));
map.insert("host".to_string(), Value::String("localhost".to_string()));
let config: ServerConfig = from_value(Value::Map(map)).unwrap();
println!("{:?}", config);
}
Custom Functions
You can register custom Rust functions that are callable from WCL expressions. This lets host applications extend WCL with domain-specific logic:
#![allow(unused)]
fn main() {
use wcl::{parse, ParseOptions, FunctionRegistry, FunctionSignature, Value};
use std::sync::Arc;
let mut opts = ParseOptions::default();
// Register a custom function
opts.functions.functions.insert(
"double".into(),
Arc::new(|args: &[Value]| {
match args.first() {
Some(Value::Int(n)) => Ok(Value::Int(n * 2)),
_ => Err("expected int".into()),
}
}),
);
// Optionally add a signature for LSP support (completions, signature help)
opts.functions.signatures.push(FunctionSignature {
name: "double".into(),
params: vec!["n: int".into()],
return_type: "int".into(),
doc: "Double a number".into(),
});
let doc = parse("result = double(21)", opts);
assert_eq!(doc.values.get("result"), Some(&Value::Int(42)));
}
You can also use FunctionRegistry::register() to add both the function and its signature at once:
#![allow(unused)]
fn main() {
use wcl::{FunctionRegistry, FunctionSignature, Value};
use std::sync::Arc;
let mut registry = FunctionRegistry::new();
registry.register(
"greet",
Arc::new(|args: &[Value]| {
match args.first() {
Some(Value::String(s)) => Ok(Value::String(format!("Hello, {}!", s))),
_ => Err("expected string".into()),
}
}),
FunctionSignature {
name: "greet".into(),
params: vec!["name: string".into()],
return_type: "string".into(),
doc: "Greet someone".into(),
},
);
}
Library Files
Create .wcl library files manually and place them in ~/.local/share/wcl/lib/. Use wcl::library::list_libraries() to list installed libraries. See the Libraries guide for details.
Parse Options
ParseOptions controls the pipeline behavior:
#![allow(unused)]
fn main() {
use wcl::{ParseOptions, ConflictMode};
use std::path::PathBuf;
let options = ParseOptions {
// Root directory for import path jail checking
root_dir: PathBuf::from("./config"),
// Maximum depth for nested imports (default: 32)
max_import_depth: 32,
// Set to false for untrusted input to forbid all imports
allow_imports: true,
// How to handle duplicate attributes in partial merges
// Strict = error on duplicates, LastWins = later value wins
merge_conflict_mode: ConflictMode::Strict,
// Maximum macro expansion depth (default: 64)
max_macro_depth: 64,
// Maximum for-loop nesting depth (default: 32)
max_loop_depth: 32,
// Maximum total iterations across all for loops (default: 10,000)
max_iterations: 10_000,
// Custom functions (builtins are always included)
functions: FunctionRegistry::default(),
};
}
When processing untrusted WCL input (e.g., from an API), disable imports to prevent file system access:
#![allow(unused)]
fn main() {
use wcl::ParseOptions;
let options = ParseOptions {
allow_imports: false,
..Default::default()
};
}
Error Handling
The Document collects all diagnostics from every pipeline phase. Each Diagnostic includes a message, severity, source span, and optional error code:
#![allow(unused)]
fn main() {
use wcl::{parse, ParseOptions};
let doc = parse(r#"
server web {
port = "not_a_number"
}
schema "server" {
port: int
}
"#, ParseOptions::default());
for diag in &doc.diagnostics {
let severity = if diag.is_error() { "ERROR" } else { "WARN" };
let code = diag.code.as_deref().unwrap_or("----");
eprintln!("[{}] {}: {}", severity, code, diag.message);
}
}
Complete Example
Putting it all together – parse a configuration file, validate it, query it, and extract values:
use wcl::{parse, ParseOptions, Value};
use std::path::PathBuf;
fn main() {
let source = r#"
schema "server" {
port: int
host: string @optional
}
server svc-api {
port = 8080
host = "api.internal"
}
server svc-admin {
port = 9090
host = "admin.internal"
}
"#;
let doc = parse(source, ParseOptions::default());
// 1. Check for errors
if doc.has_errors() {
for e in doc.errors() {
eprintln!("{}", e.message);
}
std::process::exit(1);
}
// 2. Query for all server ports
let ports = doc.query("server | .port").unwrap();
println!("All ports: {}", ports);
// 3. Iterate resolved blocks
for server in doc.blocks_of_type_resolved("server") {
let id = server.id.as_deref().unwrap_or("(no id)");
let port = server.get("port").unwrap();
let host = server.get("host").unwrap();
println!("{}: {}:{}", id, host, port);
}
}
Using WCL as a Python Library
WCL has native Python bindings via PyO3. The pywcl package provides the full 11-phase parsing pipeline with Pythonic types — values come back as native dict, list, int, str, etc.
Installation
Install from PyPI:
pip install pywcl
Or install from source (requires a Rust toolchain and maturin):
cd bindings/python
pip install -e .
Parsing a WCL String
Use wcl.parse() to run the full pipeline and get a Document:
import wcl
doc = wcl.parse("""
server web-prod {
host = "0.0.0.0"
port = 8080
debug = false
}
""")
if doc.has_errors:
for e in doc.errors:
print(f"error: {e.message}")
else:
print("Document parsed successfully")
Parsing a WCL File
parse_file() reads and parses a file. It automatically sets the root directory to the file’s parent so imports resolve correctly:
doc = wcl.parse_file("config/main.wcl")
if doc.has_errors:
for e in doc.errors:
print(f"error: {e.message}")
Raises IOError if the file doesn’t exist.
Accessing Evaluated Values
After parsing, doc.values is a Python dict with all evaluated top-level attributes and blocks. Values are converted to native Python types:
doc = wcl.parse("""
name = "my-app"
port = 8080
tags = ["web", "prod"]
debug = false
""")
print(doc.values["name"]) # "my-app" (str)
print(doc.values["port"]) # 8080 (int)
print(doc.values["tags"]) # ["web", "prod"] (list)
print(doc.values["debug"]) # False (bool)
WCL types map to Python types as follows:
| WCL Type | Python Type |
|---|---|
string | str |
int | int |
float | float |
bool | bool |
null | None |
list | list |
map | dict |
set | set (or list if items are unhashable) |
Working with Blocks
Use blocks() and blocks_of_type() to access parsed blocks with resolved attributes:
doc = wcl.parse("""
server web-prod {
host = "0.0.0.0"
port = 8080
}
server web-staging {
host = "staging.internal"
port = 8081
}
database main-db {
host = "db.internal"
port = 5432
}
""")
# Get all blocks
blocks = doc.blocks()
print(f"Total blocks: {len(blocks)}") # 3
# Get blocks of a specific type
servers = doc.blocks_of_type("server")
for s in servers:
print(f"server id={s.id} host={s.get('host')} port={s.get('port')}")
Each BlockRef has the following properties:
block.kind # str — block type name (e.g. "server")
block.id # str | None — inline ID (e.g. "web-prod")
block.attributes # dict — evaluated attribute values (includes _args if inline args present)
block.children # list[BlockRef] — nested child blocks
block.decorators # list[Decorator] — decorators on this block
And these methods:
block.get("port") # attribute value, or None if missing
block.has_decorator("deprecated") # True/False
Working with Tables
Tables evaluate to a list of row dicts. Each row is a dict mapping column names to cell values:
doc = wcl.parse("""
table users {
name : string
age : int
| "alice" | 25 |
| "bob" | 30 |
}
""")
users = doc.values["users"]
print(users)
# [{"name": "alice", "age": 25}, {"name": "bob", "age": 30}]
# Access individual rows
print(users[0]["name"]) # "alice"
print(users[1]["age"]) # 30
Tables inside blocks appear in the block’s attributes:
doc = wcl.parse("""
service main {
table config {
key : string
value : int
| "port" | 8080 |
}
}
""")
block = doc.blocks_of_type("service")[0]
print(block.get("config")) # [{"key": "port", "value": 8080}]
Running Queries
doc.query() accepts the same query syntax as the wcl query CLI command:
doc = wcl.parse("""
server svc-api {
port = 8080
env = "prod"
}
server svc-admin {
port = 9090
env = "prod"
}
server svc-debug {
port = 3000
env = "dev"
}
""")
# Select all server blocks
all_servers = doc.query("server")
# Filter by attribute
prod = doc.query('server | .env == "prod"')
# Project a single attribute
ports = doc.query("server | .port")
print(ports) # [8080, 9090, 3000]
# Filter and project
prod_ports = doc.query('server | .env == "prod" | .port')
print(prod_ports) # [8080, 9090]
# Filter by comparison
high_ports = doc.query("server | .port > 8500")
print(high_ports) # [BlockRef for svc-admin, BlockRef for svc-debug]
Raises ValueError if the query is invalid.
Custom Functions
Register Python functions callable from WCL expressions by passing a functions dict:
def double(args):
return args[0] * 2
def greet(args):
return f"Hello, {args[0]}!"
doc = wcl.parse("""
result = double(21)
message = greet("World")
""", functions={"double": double, "greet": greet})
print(doc.values["result"]) # 42
print(doc.values["message"]) # "Hello, World!"
Functions receive a single args list with native Python values and should return a native Python value. Errors propagate as diagnostics:
def safe_div(args):
if args[1] == 0:
raise ValueError("division by zero")
return args[0] / args[1]
doc = wcl.parse("result = safe_div(10, 0)", functions={"safe_div": safe_div})
assert doc.has_errors # The ValueError becomes a diagnostic
Functions can return any supported type:
def make_list(args):
return [1, 2, 3]
def is_even(args):
return args[0] % 2 == 0
def noop(args):
return None
Custom functions also work in control flow expressions:
def items(args):
return [1, 2, 3]
doc = wcl.parse(
"for item in items() { entry { value = item } }",
functions={"items": items},
)
Parse Options
All options are passed as keyword arguments to parse():
doc = wcl.parse(source,
root_dir="./config", # root directory for import resolution
allow_imports=True, # enable/disable imports (default: True)
max_import_depth=32, # max nested import depth (default: 32)
max_macro_depth=64, # max macro expansion depth (default: 64)
max_loop_depth=32, # max for-loop nesting (default: 32)
max_iterations=10000, # max total loop iterations (default: 10,000)
functions={"my_fn": my_fn}, # custom functions
)
When processing untrusted input, disable imports to prevent file system access:
doc = wcl.parse(untrusted_input, allow_imports=False)
Library Files
Create .wcl library files manually and place them in ~/.local/share/wcl/lib/. Use wcl.list_libraries() to list installed libraries. See the Libraries guide for details.
Error Handling
The Document collects all diagnostics from every pipeline phase. Each Diagnostic has a severity, message, and optional error code:
doc = wcl.parse("""
server web {
port = "not_a_number"
}
schema "server" {
port: int
}
""")
# Check for errors
if doc.has_errors:
for e in doc.errors:
code = f"[{e.code}] " if e.code else ""
print(f"{e.severity}: {code}{e.message}")
# All diagnostics (errors + warnings)
for d in doc.diagnostics:
print(f"{d.severity}: {d.message}")
The Diagnostic type:
d.severity # "error", "warning", "info", or "hint"
d.message # str — the diagnostic message
d.code # str | None — e.g. "E071" for type mismatch
repr(d) # "Diagnostic(error: [E071] type mismatch: ...)"
Use doc.has_errors (bool) as a quick check, doc.errors for only errors, and doc.diagnostics for everything including warnings.
Complete Example
Putting it all together — parse a configuration, validate it, query it, and extract values:
import wcl
doc = wcl.parse("""
schema "server" {
port: int
host: string @optional
}
server svc-api {
port = 8080
host = "api.internal"
}
server svc-admin {
port = 9090
host = "admin.internal"
}
""")
# 1. Check for errors
if doc.has_errors:
for e in doc.errors:
print(f"{e.severity}: {e.message}")
exit(1)
# 2. Query for all server ports
ports = doc.query("server | .port")
print(f"All ports: {ports}") # [8080, 9090]
# 3. Iterate resolved blocks
for server in doc.blocks_of_type("server"):
id = server.id or "(no id)"
host = server.get("host")
port = server.get("port")
print(f"{id}: {host}:{port}")
# 4. Custom functions
def double(args):
return args[0] * 2
doc2 = wcl.parse("result = double(21)", functions={"double": double})
print(f"result = {doc2.values['result']}") # 42
Building from Source
# Install development dependencies
cd wcl_python
python -m venv .venv
source .venv/bin/activate
pip install maturin pytest
# Build and install in development mode
maturin develop
# Run tests
pytest tests/ -v
# Or via just
just test-python
This requires the Rust toolchain and Python 3.8+.
Using WCL as a Go Library
WCL can be embedded into Go programs via the wcl_go package. It uses an embedded WASM module with wazero (a pure Go, zero-dependency WebAssembly runtime), so you get the full 11-phase WCL pipeline without needing a Rust toolchain or C compiler.
Adding the Dependency
Add the module to your project:
go get github.com/wiltaylor/wcl/bindings/go
Then import it:
import wcl "github.com/wiltaylor/wcl/bindings/go"
No CGo or C compiler required — the WASM module is embedded directly in the Go binary.
Parsing a WCL String
Use wcl.Parse() to run the full pipeline and get a Document:
package main
import (
"fmt"
"log"
wcl "github.com/wiltaylor/wcl/bindings/go"
)
func main() {
doc, err := wcl.Parse(`
server web-prod {
host = "0.0.0.0"
port = 8080
debug = false
}
`, nil)
if err != nil {
log.Fatal(err)
}
defer doc.Close()
if doc.HasErrors() {
errs, _ := doc.Errors()
for _, e := range errs {
fmt.Printf("error: %s\n", e.Message)
}
} else {
fmt.Println("Document parsed successfully")
}
}
Always call doc.Close() when you’re done with a document. A finalizer is set as a safety net, but explicit cleanup is preferred.
Parsing a WCL File
ParseFile reads and parses a file. It automatically sets RootDir to the file’s parent directory so imports resolve correctly:
doc, err := wcl.ParseFile("config/main.wcl", nil)
if err != nil {
log.Fatal(err)
}
defer doc.Close()
Note: File reading happens on the Go side; the WASM module does not have direct filesystem access. Imports within parsed files that reference other files on disk will not resolve in the WASM environment.
Accessing Evaluated Values
After parsing, Values() returns an ordered map of all evaluated top-level attributes and blocks:
doc, _ := wcl.Parse(`
name = "my-app"
port = 8080
tags = ["web", "prod"]
`, nil)
defer doc.Close()
values, err := doc.Values()
if err != nil {
log.Fatal(err)
}
fmt.Println(values["name"]) // "my-app"
fmt.Println(values["port"]) // 8080 (float64 — JSON numbers)
fmt.Println(values["tags"]) // ["web", "prod"]
Type note: Values cross the WASM boundary as JSON, so numbers arrive as
float64, strings asstring, booleans asbool, lists as[]any, and maps asmap[string]any.
Working with Blocks
Use Blocks() and BlocksOfType() to access parsed blocks with their resolved attributes:
doc, _ := wcl.Parse(`
server web-prod {
host = "0.0.0.0"
port = 8080
}
server web-staging {
host = "staging.internal"
port = 8081
}
database main-db {
host = "db.internal"
port = 5432
}
`, nil)
defer doc.Close()
// Get all blocks
blocks, _ := doc.Blocks()
fmt.Printf("Total blocks: %d\n", len(blocks)) // 3
// Get blocks of a specific type
servers, _ := doc.BlocksOfType("server")
for _, s := range servers {
fmt.Printf("server id=%v host=%v port=%v\n",
s.ID, s.Attributes["host"], s.Attributes["port"])
}
Each BlockRef has the following fields:
type BlockRef struct {
Kind string // block type name (e.g. "server")
ID *string // inline ID (e.g. "web-prod"), nil if none
Attributes map[string]any // evaluated attribute values (includes _args if inline args present)
Children []BlockRef // nested child blocks
Decorators []Decorator // decorators applied to this block
}
Working with Tables
Tables evaluate to a slice of row maps ([]map[string]interface{}). Each row is a map from column name to cell value:
doc, _ := wcl.Parse(`
table users {
name : string
age : int
| "alice" | 25 |
| "bob" | 30 |
}
`)
users := doc.Values["users"].([]interface{})
row0 := users[0].(map[string]interface{})
fmt.Println(row0["name"]) // "alice"
fmt.Println(row0["age"]) // 25
Tables inside blocks appear in the block’s attributes map.
Running Queries
Query() accepts the same query syntax as the wcl query CLI command:
doc, _ := wcl.Parse(`
server svc-api {
port = 8080
env = "prod"
}
server svc-admin {
port = 9090
env = "prod"
}
server svc-debug {
port = 3000
env = "dev"
}
`, nil)
defer doc.Close()
// Select all server blocks
all, _ := doc.Query("server")
// Filter by attribute
prod, _ := doc.Query(`server | .env == "prod"`)
// Project a single attribute
ports, _ := doc.Query("server | .port")
fmt.Println(ports) // [8080, 9090, 3000]
// Filter and project
prodPorts, _ := doc.Query(`server | .env == "prod" | .port`)
fmt.Println(prodPorts) // [8080, 9090]
// Select by ID
api, _ := doc.Query("server#svc-api")
Custom Functions
You can register Go functions that are callable from WCL expressions. This lets your application extend WCL with domain-specific logic:
opts := &wcl.ParseOptions{
Functions: map[string]func([]any) (any, error){
"double": func(args []any) (any, error) {
n, ok := args[0].(float64)
if !ok {
return nil, fmt.Errorf("expected number")
}
return n * 2, nil
},
"greet": func(args []any) (any, error) {
name, ok := args[0].(string)
if !ok {
return nil, fmt.Errorf("expected string")
}
return fmt.Sprintf("Hello, %s!", name), nil
},
},
}
doc, err := wcl.Parse(`
result = double(21)
message = greet("World")
`, opts)
if err != nil {
log.Fatal(err)
}
defer doc.Close()
values, _ := doc.Values()
fmt.Println(values["result"]) // 42
fmt.Println(values["message"]) // "Hello, World!"
Arguments and return values are serialized as JSON across the WASM boundary. Numbers are float64, strings are string, lists are []any, maps are map[string]any, booleans are bool, and nil maps to null.
Return an error to signal a function failure:
"safe_div": func(args []any) (any, error) {
a, b := args[0].(float64), args[1].(float64)
if b == 0 {
return nil, fmt.Errorf("division by zero")
}
return a / b, nil
},
Parse Options
ParseOptions controls the parser behavior:
allowImports := true
opts := &wcl.ParseOptions{
// Root directory for import path resolution
RootDir: "./config",
// Whether imports are allowed (pointer for optional; nil = default true)
AllowImports: &allowImports,
// Maximum depth for nested imports (default: 32)
MaxImportDepth: 32,
// Maximum macro expansion depth (default: 64)
MaxMacroDepth: 64,
// Maximum for-loop nesting depth (default: 32)
MaxLoopDepth: 32,
// Maximum total iterations across all for loops (default: 10,000)
MaxIterations: 10000,
// Custom functions callable from WCL expressions
Functions: map[string]func([]any) (any, error){ ... },
}
doc, err := wcl.Parse(source, opts)
When processing untrusted WCL input, disable imports to prevent file system access:
noImports := false
doc, err := wcl.Parse(untrustedInput, &wcl.ParseOptions{
AllowImports: &noImports,
})
Pass nil for default options:
doc, err := wcl.Parse(source, nil)
Error Handling
The Document collects all diagnostics from every pipeline phase. Each Diagnostic includes a severity, message, and optional error code:
doc, _ := wcl.Parse(`
server web {
port = "not_a_number"
}
schema "server" {
port: int
}
`, nil)
defer doc.Close()
diags, _ := doc.Diagnostics()
for _, d := range diags {
code := ""
if d.Code != nil {
code = "[" + *d.Code + "] "
}
fmt.Printf("%s: %s%s\n", d.Severity, code, d.Message)
}
The Diagnostic type:
type Diagnostic struct {
Severity string // "error", "warning", "info", "hint"
Message string
Code *string // e.g. "E071" for type mismatch, nil if no code
}
Thread Safety
Documents are safe to use from multiple goroutines. All methods acquire a read lock internally:
doc, _ := wcl.Parse("x = 42", nil)
defer doc.Close()
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
values, _ := doc.Values()
fmt.Println(values["x"])
}()
}
wg.Wait()
Complete Example
Putting it all together — parse a configuration, validate it, query it, and extract values:
package main
import (
"fmt"
"log"
wcl "github.com/wiltaylor/wcl/bindings/go"
)
func main() {
doc, err := wcl.Parse(`
schema "server" {
port: int
host: string @optional
}
server svc-api {
port = 8080
host = "api.internal"
}
server svc-admin {
port = 9090
host = "admin.internal"
}
`, nil)
if err != nil {
log.Fatal(err)
}
defer doc.Close()
// 1. Check for errors
if doc.HasErrors() {
errs, _ := doc.Errors()
for _, e := range errs {
log.Printf("%s: %s", e.Severity, e.Message)
}
log.Fatal("validation failed")
}
// 2. Query for all server ports
ports, _ := doc.Query("server | .port")
fmt.Println("All ports:", ports)
// 3. Iterate resolved blocks
servers, _ := doc.BlocksOfType("server")
for _, s := range servers {
id := "(no id)"
if s.ID != nil {
id = *s.ID
}
fmt.Printf("%s: %v:%v\n", id, s.Attributes["host"], s.Attributes["port"])
}
}
Building from Source
If you want to rebuild the WASM module from the Rust source (e.g., after modifying the WCL codebase), run:
# Using just (recommended)
just build go
# Or via go generate (requires Rust toolchain)
cd bindings/go && go generate ./...
Using WCL in JavaScript / TypeScript
WCL runs in the browser and Node.js via WebAssembly. The wcl-wasm package provides parse, parseValues, and query functions with full TypeScript types.
Installation
npm install wcl-wasm
Initialization
The WASM module must be initialized once before use:
Browser / Deno:
import { init, parse, parseValues, query } from "wcl-wasm";
await init();
Node.js (ESM):
import { init, parse, parseValues, query } from "wcl-wasm";
import { readFileSync } from "node:fs";
await init(readFileSync(new URL("../node_modules/wcl-wasm/pkg/wcl_wasm_bg.wasm", import.meta.url)));
Node.js (CommonJS):
const { init, parse, parseValues, query } = require("wcl-wasm");
const fs = require("node:fs");
const path = require("node:path");
async function main() {
await init(fs.readFileSync(path.join(__dirname, "../node_modules/wcl-wasm/pkg/wcl_wasm_bg.wasm")));
const doc = parse('x = 42');
console.log(doc.values);
}
main();
Calling any function before init() throws an error.
Parsing a WCL String
parse() runs the full 11-phase pipeline and returns a document object:
import { init, parse } from "wcl-wasm";
await init();
const doc = parse(`
server web-prod {
host = "0.0.0.0"
port = 8080
debug = false
}
`);
if (doc.hasErrors) {
for (const d of doc.diagnostics) {
console.error(`${d.severity}: ${d.message}`);
}
} else {
console.log("Parsed successfully");
console.log(doc.values);
}
The returned WclDocument has this shape:
interface WclDocument {
values: Record<string, any>; // evaluated top-level values
hasErrors: boolean; // true if any errors occurred
diagnostics: WclDiagnostic[]; // all diagnostics
}
interface WclDiagnostic {
severity: "error" | "warning";
message: string;
code?: string; // e.g. "E071" for type mismatch
}
Getting Just the Values
parseValues() returns only the evaluated values and throws on errors:
try {
const values = parseValues(`
name = "my-app"
port = 8080
tags = ["web", "prod"]
`);
console.log(values.name); // "my-app"
console.log(values.port); // 8080
console.log(values.tags); // ["web", "prod"]
} catch (e) {
console.error("Parse error:", e);
}
This is the simplest way to use WCL when you just want the config values and don’t need diagnostics.
Working with Blocks
Block values appear as objects with kind, id, attributes, and children:
const doc = parse(`
server web-prod {
host = "0.0.0.0"
port = 8080
}
server web-staging {
host = "staging.internal"
port = 8081
}
`);
// Blocks appear as values keyed by their type
// Use queries for structured access (see below)
Working with Tables
Tables evaluate to an array of row objects. Each row is an object mapping column names to cell values:
const doc = parse(`
table users {
name : string
age : int
| "alice" | 25 |
| "bob" | 30 |
}
`);
console.log(doc.values.users);
// [{ name: "alice", age: 25 }, { name: "bob", age: 30 }]
console.log(doc.values.users[0].name); // "alice"
Tables inside blocks appear in the block’s attributes:
const doc = parse(`
service main {
table config {
key : string
value : int
| "port" | 8080 |
}
}
`);
// Access via the block's values
console.log(doc.values.main.attributes.config);
// [{ key: "port", value: 8080 }]
Running Queries
query() parses a WCL string and executes a query in one call:
const result = query(`
server svc-api {
port = 8080
env = "prod"
}
server svc-admin {
port = 9090
env = "prod"
}
server svc-debug {
port = 3000
env = "dev"
}
`, "server | .port");
console.log(result); // [8080, 9090, 3000]
More query examples:
// Select all server blocks
query(source, "server");
// Filter by attribute
query(source, 'server | .env == "prod"');
// Filter and project
query(source, 'server | .env == "prod" | .port');
// → [8080, 9090]
// Select by ID
query(source, "server#svc-api");
Throws if there are parse errors or if the query is invalid.
Custom Functions
Register custom JavaScript functions that are callable from WCL expressions:
const values = parseValues(`
result = double(21)
message = greet("World")
`, {
functions: {
double: (n: number) => n * 2,
greet: (name: string) => `Hello, ${name}!`,
},
});
console.log(values.result); // 42
console.log(values.message); // "Hello, World!"
Functions receive native JavaScript values and should return native values. Errors can be thrown normally:
const values = parseValues("result = safe_div(10, 0)", {
functions: {
safe_div: (a: number, b: number) => {
if (b === 0) throw new Error("division by zero");
return a / b;
},
},
});
In-Memory Files for Imports
Provide files as a map for import resolution without filesystem access (useful in browsers):
const doc = parse('import "utils.wcl"\nresult = base_port + 1', {
files: {
"utils.wcl": "base_port = 8080",
},
});
console.log(doc.values.result); // 8081
Custom Import Resolver
For more control over import resolution, provide a synchronous callback:
const doc = parse('import "config.wcl"', {
importResolver: (path: string) => {
if (path.endsWith("config.wcl")) {
return 'port = 8080';
}
return null; // file not found
},
});
If both files and importResolver are provided, files takes precedence.
Parse Options
All options are optional:
interface ParseOptions {
rootDir?: string; // root directory for import resolution (default: ".")
allowImports?: boolean; // enable/disable imports (default: true)
maxImportDepth?: number; // max nested import depth (default: 32)
maxMacroDepth?: number; // max macro expansion depth (default: 64)
maxLoopDepth?: number; // max for-loop nesting (default: 32)
maxIterations?: number; // max total loop iterations (default: 10000)
importResolver?: (path: string) => string | null;
files?: Record<string, string>;
functions?: Record<string, (...args: any[]) => any>;
}
When processing untrusted input, disable imports:
const doc = parse(untrustedInput, { allowImports: false });
Error Handling
Check hasErrors and inspect diagnostics:
const doc = parse(`
server web {
port = "not_a_number"
}
schema "server" {
port: int
}
`);
if (doc.hasErrors) {
for (const d of doc.diagnostics) {
const code = d.code ? `[${d.code}] ` : "";
console.error(`${d.severity}: ${code}${d.message}`);
}
}
parseValues() and query() throw on errors instead of returning them.
Complete Example
import { init, parse, parseValues, query } from "wcl-wasm";
await init();
// Simple value extraction
const config = parseValues(`
app_name = "my-service"
port = 8080
debug = false
`);
console.log(`Starting ${config.app_name} on port ${config.port}`);
// Full document with validation
const doc = parse(`
schema "server" {
port: int
host: string @optional
}
server svc-api {
port = 8080
host = "api.internal"
}
server svc-admin {
port = 9090
host = "admin.internal"
}
`);
if (doc.hasErrors) {
doc.diagnostics.forEach(d => console.error(d.message));
process.exit(1);
}
// Query for all ports
const ports = query(doc_source, "server | .port");
console.log("All ports:", ports); // [8080, 9090]
// Custom functions
const result = parseValues("doubled = double(21)", {
functions: {
double: (n) => n * 2,
},
});
console.log(result.doubled); // 42
Building from Source
To rebuild the WASM package from the Rust source:
# Build the WASM package
just build-wasm
# Run WASM tests
just test-wasm
This requires wasm-pack and the Rust toolchain.
Using WCL as a .NET Library
WCL can be embedded into .NET programs via the WclLang NuGet package. It uses a native shared library (P/Invoke) under the hood, so you get the full 11-phase WCL pipeline without needing a Rust toolchain.
Adding the Dependency
Install from NuGet:
dotnet add package WclLang
Or add a project reference for development from source:
<ProjectReference Include="../wcl_dotnet/src/Wcl/Wcl.csproj" />
The library targets netstandard2.1 and works with .NET Core 3.0+ and .NET 5+.
Note: This package uses P/Invoke with a native shared library (
libwcl_ffi.so/.dylib/.dll). The native library must be present in theruntimes/{rid}/native/directory or alongside your application binary.
Parsing a WCL String
Use WclParser.Parse() to run the full pipeline and get a WclDocument:
using Wcl;
using var doc = WclParser.Parse(@"
server web-prod {
host = ""0.0.0.0""
port = 8080
debug = false
}
");
if (doc.HasErrors())
{
foreach (var diag in doc.Errors())
{
Console.Error.WriteLine($"error: {diag.Message}");
}
}
else
{
Console.WriteLine("Document parsed successfully");
}
WclDocument implements IDisposable and should be disposed when no longer needed. A finalizer is set as a safety net, but explicit disposal (via using or Dispose()) is preferred.
Parsing a WCL File
ParseFile reads and parses a file. It automatically sets RootDir to the file’s parent directory so imports resolve correctly:
using Wcl;
using var doc = WclParser.ParseFile("config/main.wcl");
To override parse options:
var options = new ParseOptions
{
RootDir = "./config"
};
using var doc = WclParser.ParseFile("config/main.wcl", options);
Accessing Evaluated Values
After parsing, doc.Values is an OrderedMap<string, WclValue> containing all evaluated top-level attributes and blocks:
using Wcl;
using Wcl.Eval;
using var doc = WclParser.Parse(@"
name = ""my-app""
port = 8080
tags = [""web"", ""prod""]
");
// Access scalar values
if (doc.Values.TryGetValue("name", out var name))
Console.WriteLine($"name: {name.AsString()}");
if (doc.Values.TryGetValue("port", out var port))
Console.WriteLine($"port: {port.AsInt()}");
// Access list values
if (doc.Values.TryGetValue("tags", out var tags))
{
foreach (var tag in tags.AsList())
Console.WriteLine($"tag: {tag.AsString()}");
}
WclValue is a sealed type with these value kinds:
| Kind | Factory | Accessor |
|---|---|---|
String | WclValue.NewString("...") | .AsString() |
Int | WclValue.NewInt(42) | .AsInt() |
Float | WclValue.NewFloat(3.14) | .AsFloat() |
Bool | WclValue.NewBool(true) | .AsBool() |
Null | WclValue.Null | .IsNull |
List | WclValue.NewList(...) | .AsList() |
Map | WclValue.NewMap(...) | .AsMap() |
Set | WclValue.NewSet(...) | .AsSet() |
BlockRef | WclValue.NewBlockRef(...) | .AsBlockRef() |
Safe accessors like .TryAsString() return null instead of throwing on type mismatch.
Working with Blocks
Use Blocks() and BlocksOfType() to access blocks with their resolved attribute values:
using var doc = WclParser.Parse(@"
server web-prod {
host = ""0.0.0.0""
port = 8080
}
server web-staging {
host = ""staging.internal""
port = 8081
}
database main-db {
host = ""db.internal""
port = 5432
}
");
// Get all blocks as resolved BlockRefs
var blocks = doc.Blocks();
Console.WriteLine($"Total blocks: {blocks.Count}"); // 3
// Get blocks of a specific type
var servers = doc.BlocksOfType("server");
foreach (var s in servers)
{
Console.WriteLine($"server id={s.Id} host={s.Get("host")} port={s.Get("port")}");
}
Each BlockRef provides:
public class BlockRef
{
public string Kind { get; }
public string? Id { get; }
public List<string> Labels { get; }
public OrderedMap<string, WclValue> Attributes { get; }
public List<BlockRef> Children { get; }
public List<DecoratorValue> Decorators { get; }
public WclValue? Get(string key); // safe attribute access
public bool HasDecorator(string name);
public DecoratorValue? GetDecorator(string name);
}
Working with Tables
Tables evaluate to a list of row dictionaries. Each row is a Dictionary<string, object> mapping column names to cell values:
var doc = Wcl.Parse(@"
table users {
name : string
age : int
| ""alice"" | 25 |
| ""bob"" | 30 |
}
");
var users = (List<object>)doc.Values["users"];
var row0 = (Dictionary<string, object>)users[0];
Console.WriteLine(row0["name"]); // "alice"
Console.WriteLine(row0["age"]); // 25
Tables inside blocks appear in the block’s Attributes dictionary.
Running Queries
Query() accepts the same query syntax as the wcl query CLI command:
using var doc = WclParser.Parse(@"
server svc-api {
port = 8080
env = ""prod""
}
server svc-admin {
port = 9090
env = ""prod""
}
server svc-debug {
port = 3000
env = ""dev""
}
");
// Select all server blocks
var all = doc.Query("server");
// Filter by attribute
var prod = doc.Query(@"server | .env == ""prod""");
// Project a single attribute
var ports = doc.Query("server | .port");
// → List [8080, 9090, 3000]
// Filter and project
var prodPorts = doc.Query(@"server | .env == ""prod"" | .port");
// → List [8080, 9090]
// Select by ID
var api = doc.Query("server#svc-api");
Custom Functions
You can register C# functions that are callable from WCL expressions. This lets your application extend WCL with domain-specific logic:
using Wcl;
using Wcl.Eval;
var opts = new ParseOptions
{
Functions = new Dictionary<string, Func<WclValue[], WclValue>>
{
["double"] = args => WclValue.NewInt(args[0].AsInt() * 2),
["greet"] = args => WclValue.NewString($"Hello, {args[0].AsString()}!"),
}
};
using var doc = WclParser.Parse(@"
result = double(21)
message = greet(""World"")
", opts);
Console.WriteLine(doc.Values["result"].AsInt()); // 42
Console.WriteLine(doc.Values["message"].AsString()); // "Hello, World!"
Arguments and return values are serialized as JSON across the FFI boundary. Functions receive WclValue[] arguments and must return a WclValue. Use the factory methods to create return values.
To signal a function failure, throw an exception:
["safe_div"] = args =>
{
var a = args[0].AsFloat();
var b = args[1].AsFloat();
if (b == 0) throw new Exception("division by zero");
return WclValue.NewFloat(a / b);
}
Deserializing into C# Types
With FromString<T>
Deserialize a WCL string directly into a C# type:
using Wcl;
public class AppConfig
{
public string Name { get; set; }
public long Port { get; set; }
public bool Debug { get; set; }
}
var config = WclParser.FromString<AppConfig>(@"
name = ""my-app""
port = 8080
debug = false
");
Console.WriteLine($"{config.Name} on port {config.Port}");
FromString<T> throws if there are parse errors.
Serializing to WCL
Convert a C# object back to WCL text:
var config = new AppConfig { Name = "my-app", Port = 8080, Debug = false };
var wcl = WclParser.ToString(config);
// name = "my-app"
// port = 8080
// debug = false
var pretty = WclParser.ToStringPretty(config);
// Same but with indentation for nested structures
Parse Options
ParseOptions controls the pipeline behavior. All fields are nullable — only set values are sent to the engine:
var options = new ParseOptions
{
// Root directory for import path resolution
RootDir = "./config",
// Whether imports are allowed (default: true)
AllowImports = true,
// Maximum depth for nested imports (default: 32)
MaxImportDepth = 32,
// Maximum macro expansion depth (default: 64)
MaxMacroDepth = 64,
// Maximum for-loop nesting depth (default: 32)
MaxLoopDepth = 32,
// Maximum total iterations across all for loops (default: 10,000)
MaxIterations = 10000,
// Custom functions callable from WCL expressions
Functions = new Dictionary<string, Func<WclValue[], WclValue>> { ... },
};
When processing untrusted input, disable imports to prevent file system access:
var options = new ParseOptions { AllowImports = false };
using var doc = WclParser.Parse(untrustedInput, options);
Pass null for default options:
using var doc = WclParser.Parse(source, null);
Library Files
Create .wcl library files manually and place them in ~/.local/share/wcl/lib/. Use LibraryManager.List() to list installed libraries. See the Libraries guide for details.
Error Handling
The WclDocument collects all diagnostics from every pipeline phase. Each Diagnostic includes a severity, message, and optional error code:
using var doc = WclParser.Parse(@"
server web {
port = ""not_a_number""
}
schema ""server"" {
port: int
}
");
foreach (var diag in doc.Diagnostics)
{
var severity = diag.IsError ? "ERROR" : "WARN";
var code = diag.Code ?? "----";
Console.Error.WriteLine($"[{severity}] {code}: {diag.Message}");
}
The Diagnostic type:
public class Diagnostic
{
public string Severity { get; } // "error", "warning", "info", "hint"
public string Message { get; }
public string? Code { get; } // e.g. "E071" for type mismatch
public bool IsError { get; }
}
Thread Safety
Documents are safe to use from multiple threads. All methods acquire a lock internally, and values are cached after first access:
using var doc = WclParser.Parse("x = 42");
var tasks = Enumerable.Range(0, 10).Select(_ => Task.Run(() =>
{
var values = doc.Values;
Console.WriteLine(values["x"].AsInt()); // 42
}));
await Task.WhenAll(tasks);
Complete Example
Putting it all together — parse a configuration, validate it, query it, and extract values:
using Wcl;
using Wcl.Eval;
using var doc = WclParser.Parse(@"
schema ""server"" {
port: int
host: string @optional
}
server svc-api {
port = 8080
host = ""api.internal""
}
server svc-admin {
port = 9090
host = ""admin.internal""
}
");
// 1. Check for errors
if (doc.HasErrors())
{
foreach (var e in doc.Errors())
Console.Error.WriteLine(e.Message);
Environment.Exit(1);
}
// 2. Query for all server ports
var ports = doc.Query("server | .port");
Console.WriteLine($"All ports: {ports}");
// 3. Iterate resolved blocks
foreach (var server in doc.BlocksOfType("server"))
{
var id = server.Id ?? "(no id)";
var host = server.Get("host");
var port = server.Get("port");
Console.WriteLine($"{id}: {host}:{port}");
}
// 4. Custom functions
var opts = new ParseOptions
{
Functions = new Dictionary<string, Func<WclValue[], WclValue>>
{
["double"] = args => WclValue.NewInt(args[0].AsInt() * 2)
}
};
using var doc2 = WclParser.Parse("result = double(21)", opts);
Console.WriteLine($"result = {doc2.Values["result"].AsInt()}"); // 42
Building from Source
# Build the native library and .NET project
just build dotnet
# Run .NET tests
just test dotnet
This requires the .NET SDK (6.0+) and a Rust toolchain (for building the native library).
Using WCL as a JVM Library
WCL can be embedded into Java, Kotlin, Scala, and other JVM programs via the wcl Maven package. It uses Chicory (a pure-Java WASM runtime) under the hood, so you get the full 11-phase WCL pipeline with no native dependencies.
Adding the Dependency
Gradle (Kotlin DSL)
dependencies {
implementation("io.github.wiltaylor:wcl:0.1.0")
}
Gradle (Groovy)
dependencies {
implementation 'io.github.wiltaylor:wcl:0.1.0'
}
Maven
<dependency>
<groupId>io.github.wiltaylor</groupId>
<artifactId>wcl</artifactId>
<version>0.1.0</version>
</dependency>
The library requires Java 17+.
Parsing a WCL String
Use Wcl.parse() to run the full pipeline and get a WclDocument:
import io.github.wiltaylor.wcl.Wcl;
try (var doc = Wcl.parse("""
server web-prod {
host = "0.0.0.0"
port = 8080
debug = false
}
""")) {
if (doc.hasErrors()) {
for (var diag : doc.getErrors()) {
System.err.println("error: " + diag.message());
}
} else {
System.out.println("Document parsed successfully");
}
}
WclDocument implements AutoCloseable and should be closed when no longer needed (via try-with-resources or close()).
Parsing a WCL File
parseFile reads and parses a file. It automatically sets rootDir to the file’s parent directory so imports resolve correctly:
try (var doc = Wcl.parseFile("config/main.wcl")) {
// ...
}
To override parse options:
var options = new ParseOptions().rootDir("./config");
try (var doc = Wcl.parseFile("config/main.wcl", options)) {
// ...
}
Accessing Evaluated Values
After parsing, doc.getValues() returns a LinkedHashMap<String, WclValue> containing all evaluated top-level attributes and blocks:
import io.github.wiltaylor.wcl.Wcl;
import io.github.wiltaylor.wcl.eval.WclValue;
try (var doc = Wcl.parse("""
name = "my-app"
port = 8080
tags = ["web", "prod"]
""")) {
var values = doc.getValues();
System.out.println("name: " + values.get("name").asString());
System.out.println("port: " + values.get("port").asInt());
for (var tag : values.get("tags").asList()) {
System.out.println("tag: " + tag.asString());
}
}
WclValue is a tagged union with these value kinds:
| Kind | Factory | Accessor |
|---|---|---|
STRING | WclValue.ofString("...") | .asString() |
INT | WclValue.ofInt(42) | .asInt() |
FLOAT | WclValue.ofFloat(3.14) | .asFloat() |
BOOL | WclValue.ofBool(true) | .asBool() |
NULL | WclValue.NULL | .isNull() |
LIST | WclValue.ofList(...) | .asList() |
MAP | WclValue.ofMap(...) | .asMap() |
SET | WclValue.ofSet(...) | .asSet() |
BLOCK_REF | WclValue.ofBlockRef(...) | .asBlockRef() |
Safe accessors like .tryAsString() return Optional instead of throwing on type mismatch.
Working with Blocks
Use getBlocks() and getBlocksOfType() to access blocks with their resolved attribute values:
try (var doc = Wcl.parse("""
server web-prod {
host = "0.0.0.0"
port = 8080
}
server web-staging {
host = "staging.internal"
port = 8081
}
database main-db {
host = "db.internal"
port = 5432
}
""")) {
var blocks = doc.getBlocks();
System.out.println("Total blocks: " + blocks.size()); // 3
var servers = doc.getBlocksOfType("server");
for (var s : servers) {
System.out.printf("%s: %s:%s%n", s.getId(), s.get("host"), s.get("port"));
}
}
Each BlockRef provides:
getKind()- block type namegetId()- optional block identifiergetAttributes()- resolved attribute map (includes_argsif inline args are present)getChildren()- nested child blocksgetDecorators()- attached decoratorsget(key)- safe attribute access (returnsnullif missing)hasDecorator(name)/getDecorator(name)- decorator access
Running Queries
query() accepts the same query syntax as the wcl query CLI command:
try (var doc = Wcl.parse("""
server svc-api { port = 8080, env = "prod" }
server svc-admin { port = 9090, env = "prod" }
server svc-debug { port = 3000, env = "dev" }
""")) {
var all = doc.query("server");
var prod = doc.query("server | .env == \"prod\"");
var ports = doc.query("server | .port");
var api = doc.query("server#svc-api");
}
Custom Functions
Register Java functions that are callable from WCL expressions:
import io.github.wiltaylor.wcl.Wcl;
import io.github.wiltaylor.wcl.ParseOptions;
import io.github.wiltaylor.wcl.eval.WclValue;
var functions = Map.<String, java.util.function.Function<WclValue[], WclValue>>of(
"double", args -> WclValue.ofInt(args[0].asInt() * 2),
"greet", args -> WclValue.ofString("Hello, " + args[0].asString() + "!")
);
var opts = new ParseOptions().functions(functions);
try (var doc = Wcl.parse("""
result = double(21)
message = greet("World")
""", opts)) {
System.out.println(doc.getValues().get("result").asInt()); // 42
System.out.println(doc.getValues().get("message").asString()); // Hello, World!
}
Arguments and return values are serialized as JSON across the WASM boundary. Functions receive WclValue[] arguments and must return a WclValue.
Deserializing into Java Types
With fromString
Deserialize a WCL string directly into a Java type:
import io.github.wiltaylor.wcl.Wcl;
import java.util.Map;
var config = Wcl.fromString("""
name = "my-app"
port = 8080
debug = false
""", Map.class);
For POJOs, fields are matched by snake_case conversion:
public class AppConfig {
public String name;
public long port;
public boolean debug;
}
var config = Wcl.fromString("name = \"my-app\"\nport = 8080\ndebug = false", AppConfig.class);
Serializing to WCL
Convert a Java object back to WCL text:
var wcl = Wcl.toString(config);
// name = "my-app"
// port = 8080
// debug = false
var pretty = Wcl.toStringPretty(config);
Parse Options
ParseOptions uses a fluent builder pattern. All fields are optional:
var options = new ParseOptions()
.rootDir("./config")
.allowImports(true)
.maxImportDepth(32)
.maxMacroDepth(64)
.maxLoopDepth(32)
.maxIterations(10000)
.functions(Map.of("double", args -> WclValue.ofInt(args[0].asInt() * 2)));
When processing untrusted input, disable imports to prevent file system access:
var options = new ParseOptions().allowImports(false);
try (var doc = Wcl.parse(untrustedInput, options)) { ... }
Library Files
Create .wcl library files manually and place them in ~/.local/share/wcl/lib/. Use LibraryManager.list() to list installed libraries. See the Libraries guide for details.
Error Handling
The WclDocument collects all diagnostics from every pipeline phase. Each Diagnostic includes a severity, message, and optional error code:
try (var doc = Wcl.parse("""
server web {
port = "not_a_number"
}
schema "server" {
port: int
}
""")) {
for (var diag : doc.getDiagnostics()) {
var severity = diag.isError() ? "ERROR" : "WARN";
var code = diag.code() != null ? diag.code() : "----";
System.err.printf("[%s] %s: %s%n", severity, code, diag.message());
}
}
The Diagnostic record:
public record Diagnostic(String severity, String message, String code) {
public boolean isError();
}
Thread Safety
Documents are safe to use from multiple threads. All methods acquire a lock internally, and values are cached after first access:
try (var doc = Wcl.parse("x = 42")) {
var threads = new Thread[10];
for (int i = 0; i < 10; i++) {
threads[i] = new Thread(() -> {
var values = doc.getValues();
System.out.println(values.get("x").asInt()); // 42
});
threads[i].start();
}
for (var t : threads) t.join();
}
Complete Example
import io.github.wiltaylor.wcl.Wcl;
import io.github.wiltaylor.wcl.ParseOptions;
import io.github.wiltaylor.wcl.eval.WclValue;
import java.util.Map;
public class Example {
public static void main(String[] args) {
// 1. Parse with schema validation
try (var doc = Wcl.parse("""
schema "server" {
port: int
host: string @optional
}
server svc-api {
port = 8080
host = "api.internal"
}
server svc-admin {
port = 9090
host = "admin.internal"
}
""")) {
if (doc.hasErrors()) {
doc.getErrors().forEach(e -> System.err.println(e.message()));
return;
}
// 2. Query for all server ports
var ports = doc.query("server | .port");
System.out.println("All ports: " + ports);
// 3. Iterate resolved blocks
for (var server : doc.getBlocksOfType("server")) {
System.out.printf("%s: %s:%s%n",
server.getId(), server.get("host"), server.get("port"));
}
}
// 4. Custom functions
var opts = new ParseOptions().functions(Map.of(
"double", a -> WclValue.ofInt(a[0].asInt() * 2)
));
try (var doc2 = Wcl.parse("result = double(21)", opts)) {
System.out.println("result = " + doc2.getValues().get("result").asInt()); // 42
}
}
}
Building from Source
# Build the WASM module and Java project
just build jvm
# Run JVM tests
just test jvm
This requires Java 17+ and a Rust toolchain (for building the WASM module).
Using WCL as a Zig Library
WCL can be embedded into Zig programs via the wcl package. It uses a prebuilt static library under the hood, so you get the full 11-phase WCL pipeline without needing a Rust toolchain.
Adding the Dependency
Add wcl to your build.zig.zon:
.dependencies = .{
.wcl = .{
.url = "https://github.com/wiltaylor/wcl/archive/refs/heads/main.tar.gz",
.hash = "...",
},
},
Then in your build.zig, import and use the module:
const wcl_dep = b.dependency("wcl", .{
.target = target,
.optimize = optimize,
});
// Add to your executable or library module
exe.root_module.addImport("wcl", wcl_dep.module("wcl"));
Note: This package links a statically compiled Rust library via the C ABI. Prebuilt libraries are provided for Linux (x86_64, aarch64), macOS (x86_64, aarch64), and Windows (x86_64).
Parsing a WCL String
Use wcl.parse() to run the full pipeline and get a Document:
const std = @import("std");
const wcl = @import("wcl");
pub fn main() !void {
var gpa: std.heap.GeneralPurposeAllocator(.{}) = .init;
defer _ = gpa.deinit();
const allocator = gpa.allocator();
var doc = try wcl.parse(allocator,
\\server web-prod {
\\ host = "0.0.0.0"
\\ port = 8080
\\ debug = false
\\}
, null);
defer doc.deinit();
if (doc.hasErrors()) {
var errs = try doc.errors(allocator);
defer errs.deinit();
for (errs.value.array.items) |item| {
const msg = item.object.get("message").?.string;
std.debug.print("error: {s}\n", .{msg});
}
} else {
std.debug.print("Document parsed successfully\n", .{});
}
}
Always call doc.deinit() when you’re done with a document. This releases the underlying Rust resources.
Parsing a WCL File
parseFile reads and parses a file. It automatically sets root_dir to the file’s parent directory so imports resolve correctly:
var doc = try wcl.parseFile(allocator, "config/main.wcl", null);
defer doc.deinit();
Accessing Evaluated Values
After parsing, values() returns a parsed std.json.Value containing all evaluated top-level attributes and blocks:
var doc = try wcl.parse(allocator,
\\name = "my-app"
\\port = 8080
\\tags = ["web", "prod"]
, null);
defer doc.deinit();
var vals = try doc.values(allocator);
defer vals.deinit();
const obj = vals.value.object;
const name = obj.get("name").?.string; // "my-app"
const port = obj.get("port").?.integer; // 8080
You can also get the raw JSON string with valuesRaw():
const raw = try doc.valuesRaw(allocator);
defer allocator.free(raw);
Type mapping: Values cross the FFI boundary as JSON. In the
std.json.Valueunion: strings are.string, integers are.integer(i64), floats are.float(f64), booleans are.bool, arrays are.array, objects are.object, and null is.null.
Working with Blocks
Use blocks() and blocksOfType() to access parsed blocks with their resolved attributes:
var doc = try wcl.parse(allocator,
\\server web-prod {
\\ host = "0.0.0.0"
\\ port = 8080
\\}
\\server web-staging {
\\ host = "staging.internal"
\\ port = 8081
\\}
\\database main-db {
\\ host = "db.internal"
\\ port = 5432
\\}
, null);
defer doc.deinit();
// Get all blocks
var all = try doc.blocks(allocator);
defer all.deinit();
std.debug.print("Total blocks: {d}\n", .{all.value.array.items.len}); // 3
// Get blocks of a specific type
var servers = try doc.blocksOfType(allocator, "server");
defer servers.deinit();
for (servers.value.array.items) |block| {
const obj = block.object;
const id = if (obj.get("id")) |v| v.string else "(no id)";
const attrs = obj.get("attributes").?.object;
std.debug.print("server id={s} host={s}\n", .{ id, attrs.get("host").?.string });
}
Each block in the JSON array has the following fields: kind (string), id (string or null), attributes (object, includes _args if inline args are present), children (array), and decorators (array).
Running Queries
query() accepts the same query syntax as the wcl query CLI command:
var doc = try wcl.parse(allocator,
\\server svc-api {
\\ port = 8080
\\ env = "prod"
\\}
\\server svc-admin {
\\ port = 9090
\\ env = "prod"
\\}
\\server svc-debug {
\\ port = 3000
\\ env = "dev"
\\}
, null);
defer doc.deinit();
// Select all server ports
var result = try doc.query(allocator, "server | .port");
defer result.deinit();
// The result is {"ok": <value>} — access via .object.get("ok")
const ports = result.value.object.get("ok").?;
// ports.array.items contains [8080, 9090, 3000]
Query syntax supports filtering (server | .env == "prod"), projection (server | .port), and selection by ID (server#svc-api).
Custom Functions
You can register Zig functions that are callable from WCL expressions using parseWithFunctions:
const std = @import("std");
const wcl = @import("wcl");
const json = std.json;
const Allocator = std.mem.Allocator;
fn doubleImpl(_: Allocator, args: json.Value) !json.Value {
const n = args.array.items[0].integer;
return json.Value{ .integer = n * 2 };
}
pub fn main() !void {
var gpa: std.heap.GeneralPurposeAllocator(.{}) = .init;
defer _ = gpa.deinit();
const allocator = gpa.allocator();
var functions = std.StringHashMap(
*const fn (Allocator, json.Value) anyerror!json.Value,
).init(allocator);
defer functions.deinit();
try functions.put("double", &doubleImpl);
var doc = try wcl.parseWithFunctions(allocator,
\\result = double(21)
, null, functions);
defer doc.deinit();
var vals = try doc.values(allocator);
defer vals.deinit();
// vals.value.object.get("result").?.integer == 42
}
Arguments arrive as a std.json.Value (the full JSON array of arguments). Return a json.Value for the result, or return an error to signal failure.
Parse Options
ParseOptions controls the parser behavior:
var doc = try wcl.parse(allocator, source, wcl.ParseOptions{
// Root directory for import path resolution
.root_dir = "./config",
// Whether imports are allowed (null = default true)
.allow_imports = false,
// Maximum depth for nested imports (default: 32)
.max_import_depth = 32,
// Maximum macro expansion depth (default: 64)
.max_macro_depth = 64,
// Maximum for-loop nesting depth (default: 32)
.max_loop_depth = 32,
// Maximum total iterations across all for loops (default: 10,000)
.max_iterations = 10000,
});
When processing untrusted WCL input, disable imports to prevent file system access:
var doc = try wcl.parse(allocator, untrusted_input, wcl.ParseOptions{
.allow_imports = false,
});
Pass null for default options:
var doc = try wcl.parse(allocator, source, null);
Library Files
Create .wcl library files manually and place them in ~/.local/share/wcl/lib/. Use wcl.listLibraries() to list installed libraries. See the Libraries guide for details.
Error Handling
The Document collects all diagnostics from every pipeline phase. Use diagnostics() to get all diagnostics or errors() to get only errors:
var doc = try wcl.parse(allocator,
\\server web {
\\ port = "not_a_number"
\\}
\\schema "server" {
\\ port: int
\\}
, null);
defer doc.deinit();
var diags = try doc.diagnostics(allocator);
defer diags.deinit();
for (diags.value.array.items) |item| {
const obj = item.object;
const severity = obj.get("severity").?.string;
const message = obj.get("message").?.string;
const code = if (obj.get("code")) |v| switch (v) {
.string => |s| s,
else => "",
} else "";
std.debug.print("{s}: [{s}] {s}\n", .{ severity, code, message });
}
WCL API functions return Zig error unions. The WclError error set includes:
| Error | Meaning |
|---|---|
ParseFailed | Source string parsing returned null |
ParseFileFailed | File parsing returned null (I/O error or invalid path) |
QueryFailed | Query execution returned an error |
LibraryListFailed | Library listing failed |
DocumentClosed | Operation on a closed document |
Complete Example
Putting it all together – parse a configuration, validate it, query it, and extract values:
const std = @import("std");
const wcl = @import("wcl");
pub fn main() !void {
var gpa: std.heap.GeneralPurposeAllocator(.{}) = .init;
defer _ = gpa.deinit();
const allocator = gpa.allocator();
var doc = try wcl.parse(allocator,
\\schema "server" {
\\ port: int
\\ host: string @optional
\\}
\\
\\server svc-api {
\\ port = 8080
\\ host = "api.internal"
\\}
\\
\\server svc-admin {
\\ port = 9090
\\ host = "admin.internal"
\\}
, null);
defer doc.deinit();
// 1. Check for errors
if (doc.hasErrors()) {
var errs = try doc.errors(allocator);
defer errs.deinit();
for (errs.value.array.items) |item| {
const msg = item.object.get("message").?.string;
std.debug.print("error: {s}\n", .{msg});
}
return error.ValidationFailed;
}
// 2. Query for all server ports
var ports = try doc.query(allocator, "server | .port");
defer ports.deinit();
const ok = ports.value.object.get("ok").?;
std.debug.print("All ports: ", .{});
for (ok.array.items) |p| {
std.debug.print("{d} ", .{p.integer});
}
std.debug.print("\n", .{});
// 3. Iterate resolved blocks
var servers = try doc.blocksOfType(allocator, "server");
defer servers.deinit();
for (servers.value.array.items) |block| {
const obj = block.object;
const id = if (obj.get("id")) |v| v.string else "(no id)";
const attrs = obj.get("attributes").?.object;
std.debug.print("{s}: {s}:{d}\n", .{
id,
attrs.get("host").?.string,
attrs.get("port").?.integer,
});
}
}
Building from Source
If you want to rebuild the static library from the Rust source (e.g., after modifying the WCL codebase), run:
# Using just (recommended)
just build zig # native platform only
just build zig-all # all platforms (requires cargo-zigbuild + zig)
Using WCL as a Ruby Library
WCL has Ruby bindings powered by a WASM module and the wasmtime runtime. The wcl gem provides the full 11-phase parsing pipeline with native Ruby types — values come back as Hash, Array, Integer, String, etc.
Installation
Install from RubyGems:
gem install wcl
Or add to your Gemfile:
gem "wcl"
Parsing a WCL String
Use Wcl.parse() to run the full pipeline and get a Document:
require "wcl"
doc = Wcl.parse(<<~WCL)
server web-prod {
host = "0.0.0.0"
port = 8080
debug = false
}
WCL
if doc.has_errors?
doc.errors.each { |e| puts "error: #{e.message}" }
else
puts "Document parsed successfully"
end
Parsing a WCL File
Wcl.parse_file() reads and parses a file. It automatically sets the root directory to the file’s parent so imports resolve correctly:
doc = Wcl.parse_file("config/main.wcl")
if doc.has_errors?
doc.errors.each { |e| puts "error: #{e.message}" }
end
Raises IOError if the file doesn’t exist.
Accessing Evaluated Values
After parsing, doc.values is a Ruby Hash with all evaluated top-level attributes and blocks. Values are converted to native Ruby types:
doc = Wcl.parse(<<~WCL)
name = "my-app"
port = 8080
tags = ["web", "prod"]
debug = false
WCL
puts doc.values["name"] # "my-app" (String)
puts doc.values["port"] # 8080 (Integer)
puts doc.values["tags"] # ["web", "prod"] (Array)
puts doc.values["debug"] # false (FalseClass)
WCL types map to Ruby types as follows:
| WCL Type | Ruby Type |
|---|---|
string | String |
int | Integer |
float | Float |
bool | true / false |
null | nil |
list | Array |
map | Hash |
set | Set (or Array if items are unhashable) |
Working with Blocks
Use blocks and blocks_of_type to access parsed blocks with resolved attributes:
doc = Wcl.parse(<<~WCL)
server web-prod {
host = "0.0.0.0"
port = 8080
}
server web-staging {
host = "staging.internal"
port = 8081
}
database main-db {
host = "db.internal"
port = 5432
}
WCL
# Get all blocks
blocks = doc.blocks
puts "Total blocks: #{blocks.size}" # 3
# Get blocks of a specific type
servers = doc.blocks_of_type("server")
servers.each do |s|
puts "server id=#{s.id} host=#{s.get('host')} port=#{s.get('port')}"
end
Each BlockRef has the following properties:
block.kind # String — block type name (e.g. "server")
block.id # String or nil — inline ID (e.g. "web-prod")
block.attributes # Hash — evaluated attribute values (includes _args if inline args present)
block.children # Array<BlockRef> — nested child blocks
block.decorators # Array<Decorator> — decorators on this block
And these methods:
block.get("port") # attribute value, or nil if missing
block["port"] # same as get
block.has_decorator?("deprecated") # true/false
Running Queries
doc.query() accepts the same query syntax as the wcl query CLI command:
doc = Wcl.parse(<<~WCL)
server svc-api {
port = 8080
env = "prod"
}
server svc-admin {
port = 9090
env = "prod"
}
server svc-debug {
port = 3000
env = "dev"
}
WCL
# Select all server blocks
all_servers = doc.query("server")
# Filter by attribute
prod = doc.query('server | .env == "prod"')
# Project a single attribute
ports = doc.query("server | .port")
puts ports.inspect # [8080, 9090, 3000]
# Filter and project
prod_ports = doc.query('server | .env == "prod" | .port')
puts prod_ports.inspect # [8080, 9090]
# Filter by comparison
high_ports = doc.query("server | .port > 8500")
Raises Wcl::ValueError if the query is invalid.
Custom Functions
Register Ruby functions callable from WCL expressions by passing a functions hash:
double = ->(args) { args[0] * 2 }
greet = ->(args) { "Hello, #{args[0]}!" }
doc = Wcl.parse(<<~WCL, functions: { "double" => double, "greet" => greet })
result = double(21)
message = greet("World")
WCL
puts doc.values["result"] # 42
puts doc.values["message"] # "Hello, World!"
Functions receive a single args array with native Ruby values and should return a native Ruby value. Errors propagate as diagnostics:
safe_div = ->(args) {
raise "division by zero" if args[1] == 0
args[0].to_f / args[1]
}
doc = Wcl.parse('result = safe_div(10, 0)', functions: { "safe_div" => safe_div })
puts doc.has_errors? # true — the error becomes a diagnostic
Functions can return any supported type:
make_list = ->(_args) { [1, 2, 3] }
is_even = ->(args) { args[0] % 2 == 0 }
noop = ->(_args) { nil }
Custom functions also work in control flow expressions:
items = ->(_args) { [1, 2, 3] }
doc = Wcl.parse(
"for item in items() { entry { value = item } }",
functions: { "items" => items }
)
Parse Options
All options are passed as keyword arguments to Wcl.parse:
doc = Wcl.parse(source,
root_dir: "./config", # root directory for import resolution
allow_imports: true, # enable/disable imports (default: true)
max_import_depth: 32, # max nested import depth (default: 32)
max_macro_depth: 64, # max macro expansion depth (default: 64)
max_loop_depth: 32, # max for-loop nesting (default: 32)
max_iterations: 10000, # max total loop iterations (default: 10,000)
functions: { "my_fn" => my_fn } # custom functions
)
When processing untrusted input, disable imports to prevent file system access:
doc = Wcl.parse(untrusted_input, allow_imports: false)
Library Files
Create .wcl library files manually and place them in ~/.local/share/wcl/lib/. See the Libraries guide for details.
Error Handling
The Document collects all diagnostics from every pipeline phase. Each Diagnostic has a severity, message, and optional error code:
doc = Wcl.parse(<<~WCL)
server web {
port = "not_a_number"
}
schema "server" {
port: int
}
WCL
# Check for errors
if doc.has_errors?
doc.errors.each do |e|
code = e.code ? "[#{e.code}] " : ""
puts "#{e.severity}: #{code}#{e.message}"
end
end
# All diagnostics (errors + warnings)
doc.diagnostics.each { |d| puts "#{d.severity}: #{d.message}" }
The Diagnostic type:
d.severity # "error", "warning", "info", or "hint"
d.message # String — the diagnostic message
d.code # String or nil — e.g. "E071" for type mismatch
d.error? # true if severity is "error"
d.warning? # true if severity is "warning"
d.inspect # "#<Wcl::Diagnostic(error: [E071] type mismatch: ...)>"
Use doc.has_errors? as a quick check, doc.errors for only errors, and doc.diagnostics for everything including warnings.
Complete Example
Putting it all together — parse a configuration, validate it, query it, and extract values:
require "wcl"
doc = Wcl.parse(<<~WCL)
schema "server" {
port: int
host: string @optional
}
server svc-api {
port = 8080
host = "api.internal"
}
server svc-admin {
port = 9090
host = "admin.internal"
}
WCL
# 1. Check for errors
if doc.has_errors?
doc.errors.each { |e| puts "#{e.severity}: #{e.message}" }
exit 1
end
# 2. Query for all server ports
ports = doc.query("server | .port")
puts "All ports: #{ports.inspect}" # [8080, 9090]
# 3. Iterate resolved blocks
doc.blocks_of_type("server").each do |server|
id = server.id || "(no id)"
host = server.get("host")
port = server.get("port")
puts "#{id}: #{host}:#{port}"
end
# 4. Custom functions
double = ->(args) { args[0] * 2 }
doc2 = Wcl.parse("result = double(21)", functions: { "double" => double })
puts "result = #{doc2.values['result']}" # 42
Building from Source
# Build WASM module and copy to gem
just build ruby-wasm
# Install dependencies
cd bindings/ruby
bundle install
# Run tests
bundle exec rake test
# Build gem
gem build wcl.gemspec
# Or via just
just test ruby
This requires the Rust toolchain (with wasm32-wasip1 target) and Ruby 3.1+.
Basic Syntax
WCL (Wil’s Configuration Language) is a block-structured configuration language. Every WCL file is composed of two kinds of top-level declarations: attributes and blocks. Both may be nested to arbitrary depth inside block bodies.
Attributes
An attribute binds a name to a value using =:
name = "acme-app"
port = 8080
debug = false
The left-hand side must be a valid identifier (letters, digits, and underscores — no hyphens). The right-hand side is any expression. See Attributes for the full set of value types.
Blocks
A block groups related attributes and nested blocks under a type name:
server {
host = "0.0.0.0"
port = 8080
}
Blocks may carry an inline ID (written with hyphens) and inline arguments:
server web-1 8080 "production" {
host = "0.0.0.0"
port = 443
}
See Blocks for the complete syntax including decorators, partials, and reserved block types.
Nesting
Block bodies can contain other blocks, creating a tree:
application {
name = "my-service"
version = "1.0.0"
database {
host = "db.internal"
port = 5432
pool {
min = 2
max = 10
}
}
server {
host = "0.0.0.0"
port = 8080
}
}
There is no practical limit on nesting depth.
A Complete Minimal File
// Service configuration
service #api {
name = "api-gateway"
version = "2.1.0"
enabled = true
listen {
host = "0.0.0.0"
port = 8080
}
}
Comments
WCL supports line comments (// ...), block comments (/* ... */), and doc comments (/// ...). See Comments for details.
What’s Next
- Attributes — value types, expressions, and duplicate rules
- Blocks — IDs, inline arguments, decorators, partials, reserved types
- Data Types — the full primitive and composite type system
- Expressions — operators, precedence, function calls, lambdas
Attributes
An attribute is a named value binding inside a block or at module scope. It is the primary way to attach data to a configuration node.
Syntax
name = expression
The name must be a valid identifier: it may contain ASCII letters, digits, and underscores, and must not start with a digit. Hyphens are not allowed in attribute names (they are only permitted in inline block IDs).
// Valid attribute names
host = "localhost"
port_number = 3000
max_retries = 5
_internal = true
// Invalid — hyphens are not allowed
// max-retries = 5 // parse error
Duplicate Attributes
Declaring the same attribute name twice within the same block is an error:
server {
port = 8080
port = 9090 // error: duplicate attribute "port"
}
Value Types
The right-hand side of an attribute can be any WCL expression.
Literals
string_val = "hello"
int_val = 42
float_val = 3.14
bool_val = true
null_val = null
Variable References
let base_port = 8000
service {
port = base_port
alt_port = base_port + 1
}
Arithmetic Expressions
timeout = 30 * 1000 // milliseconds
buffer_size = 4 * 1024 * 1024 // 4 MiB
half_port = base_port / 2
remainder = total % batch_size
Function Calls
name_upper = upper("my-service")
tag_count = len(tags)
checksum = sha256(payload)
combined = concat("prefix-", name)
String Interpolation
greeting = "Hello, ${user_name}!"
url = "https://${host}:${port}/api/v${version}"
See String Interpolation for full details.
Ternary Expressions
mode = debug ? "verbose" : "quiet"
timeout = is_production ? 5000 : 30000
Queries
Query expressions select blocks from the current scope and return lists or single values:
all_servers = query server
prod_servers = query server where env == "production"
first_host = (query server)[0].host
See Query Engine for the complete query syntax.
Refs
A ref creates a typed reference to another block by its inline ID:
database #primary {
host = "db1.internal"
port = 5432
}
connection {
target = ref(db-primary)
}
Lists and Maps
ports = [8080, 8081, 8082]
labels = ["web", "api", "public"]
env_vars = { HOST: "0.0.0.0", PORT: "8080" }
Comparison and Logical Expressions
is_valid = port > 0 && port < 65536
is_dev = env == "development" || env == "dev"
is_enabled = !disabled
matches = name =~ "^api-"
Summary
| Value Kind | Example |
|---|---|
| String literal | "hello" |
| Integer literal | 42, 0xFF, 0b1010 |
| Float literal | 3.14, 1.5e-3 |
| Boolean literal | true, false |
| Null literal | null |
| Variable reference | base_port |
| Arithmetic | base_port + 1 |
| Comparison/logical | port > 0 && port < 65536 |
| Ternary | debug ? "verbose" : "quiet" |
| Function call | upper("hello") |
| String interpolation | "http://${host}:${port}" |
| Query | query(service | .port) |
| Ref | ref(svc-api) |
| List | [1, 2, 3] |
| Map | { key: "value" } |
Blocks
A block is the primary structural unit of a WCL file. Blocks group attributes and nested blocks under a named type, and can carry optional identity and metadata.
Full Syntax
[decorators] [partial] type [inline-id] [inline-args...] { body }
Every component except type and { body } is optional.
Components
Block Type
The block type is a plain identifier that names the kind of configuration node. Types are user-defined unless they are one of the reserved names listed below.
server { }
database { }
endpoint { }
my_custom_type { }
Inline ID
An inline ID uniquely identifies a block within its scope. It may contain letters, digits, and hyphens (unlike attribute names, which forbid hyphens):
server web-1 { }
database primary-db { }
endpoint get-users { }
Inline IDs must be unique per block type within their scope. The only exception is partial blocks, which share an ID with the block they extend (see Partial Declarations).
Inline Arguments
Inline arguments are positional values placed between the inline ID (or block type) and the opening {. They accept any primary expression: integers, floats, strings, booleans, null, and lists.
server web 8080 "prod" { }
endpoint api true 3 { }
service worker [1, 2, 3] { }
Without a schema, inline args are collected into a synthetic _args list attribute:
server web 8080 "prod" {
host = "localhost"
}
// Evaluates to: { _args: [8080, "prod"], host: "localhost" }
With a schema, fields decorated with @inline(N) map positional args to named attributes:
schema "server" {
port: int @inline(0)
env: string @inline(1)
host: string
}
server web 8080 "prod" {
host = "localhost"
}
// Evaluates to: { port: 8080, env: "prod", host: "localhost" }
Any args not mapped by @inline remain in _args.
Decorators
Decorators are annotations that modify or validate a block. They are placed before the block type, one per line, each starting with @:
@deprecated
@env("production")
server legacy { }
@required
server primary { }
See Decorators for the full decorator system.
The partial Keyword
The partial keyword marks a block as a partial declaration, meaning it will be merged into another block with the same type and ID. This allows spreading a block’s definition across multiple files:
partial server web-1 {
host = "0.0.0.0"
}
partial server web-1 {
port = 8080
}
See Partial Declarations for merge semantics.
Body
The body is a { }-delimited sequence of attributes, nested blocks, let bindings, for loops, and if/else expressions.
Reserved Block Types
The following block types have special semantics and are handled by the WCL pipeline:
| Type | Purpose |
|---|---|
schema | Defines a schema for validating user blocks |
decorator_schema | Defines the parameter schema for a decorator |
table | Tabular data with typed columns |
validation | Inline validation assertions |
macro | Defines a reusable macro (function or attribute form) |
Examples
Minimal Block
server { }
Block with Attributes
server {
host = "0.0.0.0"
port = 8080
}
Block with Inline ID
server web-1 {
host = "0.0.0.0"
port = 8080
}
Block with Inline Arguments
server 8080 "production" {
host = "prod.example.com"
}
Block with Inline ID and Arguments
server web-1 8080 "production" {
host = "prod.example.com"
}
Decorated Block
@env("production")
server primary {
host = "prod.example.com"
port = 443
}
Nested Blocks
application my-app {
name = "my-app"
version = "1.0.0"
server {
host = "0.0.0.0"
port = 8080
tls {
cert = "/etc/certs/server.crt"
key = "/etc/certs/server.key"
}
}
database primary {
host = "db.internal"
port = 5432
}
}
Multiple Sibling Blocks of the Same Type
server web-1 {
host = "10.0.0.1"
port = 8080
}
server web-2 {
host = "10.0.0.2"
port = 8080
}
server web-3 {
host = "10.0.0.3"
port = 8080
}
Blocks without IDs in the same scope are not required to be unique by the evaluator, but schemas may impose additional uniqueness constraints.
Comments
WCL has three comment forms. All comments are preserved in the AST, enabling round-trip formatting and IDE tooling.
Line Comments
A line comment starts with // and extends to the end of the line:
// This is a line comment.
port = 8080 // inline comment after a value
Block Comments
A block comment is delimited by /* and */. Block comments are nestable, so you can comment out a region of code that already contains block comments:
/*
This entire section is commented out.
server {
/* nested block comment */
port = 8080
}
*/
Nesting depth is tracked accurately, so only the outermost */ closes the comment:
/* outer /* inner */ still in outer */ // now outside
Doc Comments
A doc comment starts with /// and attaches to the declaration that immediately follows it:
/// The primary web server.
/// Listens on all interfaces.
server #web-1 {
/// The TCP port to bind.
port = 8080
}
Doc comments are used by the WCL language server to populate hover documentation in editors that support the LSP. Multiple consecutive /// lines are merged into a single doc string.
Comment Attachment
The parser classifies comments into three categories based on their position relative to declarations:
| Category | Position | Example |
|---|---|---|
| Leading | One or more comment lines immediately before a declaration | // comment\nport = 8080 |
| Trailing | A comment on the same line as a declaration, after the value | port = 8080 // comment |
| Floating | A comment separated from any declaration by blank lines | A comment in the middle of a block |
Floating comments are associated with the surrounding block rather than a specific declaration.
Round-Trip Preservation
All three comment categories — leading, trailing, and floating — are stored in the AST. The WCL formatter (wcl fmt) reads these nodes and restores them in their original positions, so formatting a file never discards comments.
Blank Line Preservation
The parser records blank lines between declarations. The formatter uses this information to maintain vertical spacing, preventing it from collapsing logically grouped sections together.
// Database settings
host = "db.internal"
port = 5432
// Connection pool settings
pool_min = 2
pool_max = 10
After formatting, the blank line between the two groups is preserved.
Disabling Linting with Comments
Some diagnostics can be suppressed inline using the @allow decorator on a block. For example, to suppress a shadowing warning on a specific block:
@allow(shadowing)
server {
let port = 8080
}
See Built-in Decorators for the full list of suppressible diagnostics.
Data Types
WCL has a rich type system covering primitive scalars, composite collections, and a set of special types used internally by the schema and query systems.
Primitive Types
string
String values are written with double quotes. All standard escape sequences are supported:
| Escape | Meaning |
|---|---|
\n | Newline |
\t | Tab |
\r | Carriage return |
\\ | Literal backslash |
\" | Literal double quote |
\0 | Null byte |
\uXXXX | Unicode code point |
greeting = "Hello, world!"
escaped = "Line one\nLine two"
quoted = "He said \"hello\""
unicode = "caf\u00E9"
Heredocs
For multi-line strings, WCL supports heredoc syntax. The delimiter must appear alone on its line.
Standard heredoc (preserves leading whitespace):
message = <<EOF
Dear user,
Welcome to WCL.
EOF
Indented heredoc (<<-): strips the leading whitespace common to all lines, based on the closing delimiter’s indentation:
message = <<-EOF
Dear user,
Welcome to WCL.
EOF
Raw heredoc (<<'EOF'): disables ${...} interpolation. The content is taken exactly as-is:
template = <<'EOF'
Use ${variable} syntax in your templates.
EOF
int
Integer literals support several bases and underscore separators for readability:
decimal = 1000000
hex = 0xFF
octal = 0o755
binary = 0b1010_1010
separated = 1_000_000
negative = -42
float
Floating-point literals support decimal and scientific notation:
pi = 3.14159
small = 1.5e-3
large = 2.998e8
negative = -0.5
bool
enabled = true
disabled = false
null
optional_field = null
identifier
Bare identifiers (without quotes) are used in certain contexts such as schema type names and query selectors. They are distinct from strings at the type level.
Composite Types
list(T)
An ordered collection of values of type T. List literals use [...] with comma-separated elements. Trailing commas are allowed.
ports = [8080, 8081, 8082]
names = ["alice", "bob", "carol",] // trailing comma OK
mixed = [1, "two", true] // list(any)
nested = [[1, 2], [3, 4]] // list(list(int))
map(K, V)
An ordered map from keys of type K to values of type V. Map literals use { key: value, key2: value2 } syntax:
env_vars = {
HOST: "0.0.0.0",
PORT: "8080",
DEBUG: "false",
}
scores = {
alice: 95,
bob: 87,
}
Map keys are identifiers in literal syntax. When a key needs to be a computed value or contain special characters, use the map() built-in function.
set(T)
An unordered collection of unique values of type T. Sets are produced by the set() function and certain query operations; there is no dedicated set literal syntax.
unique_ports = set([8080, 8080, 9090]) // {8080, 9090}
Special Types
ref(schema)
A typed reference to another block by its inline ID. Used to create cross-block relationships validated by the schema system:
database db-primary { host = "db.internal" }
connection {
target = ref(db-primary)
}
any
Accepts any value regardless of type. Used in schemas to opt out of type checking for a specific field, and returned by queries that mix types.
union(T1, T2, ...)
Accepts a value that matches any of the listed types. Declared in schemas:
schema "flex_port" {
port: union(int, string)
}
function (internal)
The type of lambda expressions and built-in functions. It cannot be stored in an attribute or returned from the evaluator — it exists only during evaluation for use in higher-order functions like map(), filter(), and sort_by().
Type Coercion
WCL is strictly typed. Implicit coercions are intentionally minimal:
| From | To | When |
|---|---|---|
int | float | In arithmetic expressions involving floats |
All other conversions require explicit function calls:
| Function | Converts to | Example |
|---|---|---|
to_string(v) | string | to_string(42) → "42" |
to_int(v) | int | to_int("42") → 42 |
to_float(v) | float | to_float("3.14") → 3.14 |
to_bool(v) | bool | to_bool(0) → false |
Attempting an invalid coercion (for example to_int("hello")) produces a runtime error.
Type Names in Schemas
When writing schema definitions, type names use the following syntax:
schema "server_config" {
host: string
port: int
enabled: bool
tags: list(string)
meta: map(string, any)
addr: union(string, null)
}
See Schemas for the complete schema language.
Expressions
Expressions appear on the right-hand side of attribute bindings, inside let declarations, in query where clauses, in decorator arguments, and in lambda bodies. WCL expressions are eagerly evaluated after dependency-order resolution.
Operator Precedence
The table below lists all operators from lowest to highest precedence. Operators on the same row have equal precedence and are left-associative unless noted.
| Precedence | Operator(s) | Description | Associativity |
|---|---|---|---|
| 1 (lowest) | ? : | Ternary conditional | Right |
| 2 | || | Logical OR | Left |
| 3 | && | Logical AND | Left |
| 4 | ! | Logical NOT (unary) | Right (prefix) |
| 5 | == != < > <= >= =~ | Comparison / regex | Left |
| 6 | + - | Additive | Left |
| 7 | * / % | Multiplicative | Left |
| 8 | - (unary) | Negation | Right (prefix) |
| 9 (highest) | () [] . calls | Grouping, index, member, call | Left |
Use parentheses to override the default precedence.
Arithmetic
The +, -, *, /, and % operators work on numeric types. When one operand is an int and the other is a float, the int is promoted to float.
sum = 10 + 3 // 13
diff = 10 - 3 // 7
product = 10 * 3 // 30
quotient = 10 / 3 // 3 (integer division)
float_q = 10 / 3.0 // 3.333...
remainder = 10 % 3 // 1
The + operator also concatenates strings:
greeting = "Hello, " + "world!" // "Hello, world!"
Division by zero is a runtime error.
Comparison
Comparison operators return a bool.
a == b // equal
a != b // not equal
a < b // less than
a > b // greater than
a <= b // less than or equal
a >= b // greater than or equal
The =~ operator tests whether the left operand (a string) matches the right operand (a regex literal string):
is_api = name =~ "^api-"
is_version = tag =~ "^v[0-9]+"
Logical
a && b // true if both are true (short-circuits: b not evaluated if a is false)
a || b // true if either is true (short-circuits: b not evaluated if a is true)
!a // true if a is false
Short-circuit evaluation means the right operand of && and || is only evaluated when necessary.
Ternary
condition ? then_value : else_value
Both branches must produce values, but only the selected branch is evaluated:
mode = debug ? "verbose" : "quiet"
timeout = is_production ? 5000 : 500
endpoint = use_tls ? "https://${host}" : "http://${host}"
Ternaries can be chained, though deeply nested ternaries reduce readability:
level = score >= 90 ? "A"
: score >= 80 ? "B"
: score >= 70 ? "C"
: "F"
Member Access
Use . to access an attribute of a block value:
db_host = config.database.host
version = app.meta.version
Member access chains are resolved left-to-right. Accessing a missing key is a runtime error unless the field is declared optional in a schema.
Index Access
Lists are zero-indexed. Maps accept string or identifier keys.
first_port = ports[0]
last_port = ports[len(ports) - 1]
debug_flag = env_vars["DEBUG"]
Out-of-bounds list access is a runtime error.
Function Calls
Built-in and macro-defined functions are called with standard name(args...) syntax:
upper_name = upper("hello") // "HELLO"
count = len([1, 2, 3]) // 3
hex_sum = to_string(0xFF + 1) // "256"
joined = join(", ", ["a","b"]) // "a, b"
See the Functions section for all built-in functions.
Lambda Expressions
Lambdas are anonymous functions used with higher-order functions like map(), filter(), and sort_by().
Single-parameter shorthand (no parentheses needed):
doubled = map([1, 2, 3], x => x * 2) // [2, 4, 6]
Multi-parameter lambda:
products = map(pairs, (a, b) => a * b)
Block lambdas allow multiple let bindings before the final expression:
result = map(items, x => {
let scaled = x * factor
let clamped = min(scaled, 100)
clamped
})
Inside a block lambda, let bindings are local to the lambda body. The last expression is the return value.
Lambdas are not values that can be stored in attributes. They exist only as arguments to higher-order functions.
Grouping
Parentheses override precedence in the usual way:
val = (a + b) * c
neg = -(x + y)
Query Expressions
The query keyword selects blocks and returns a list:
servers = query server
prod_servers = query server where env == "production"
See Query Engine for the full query language.
String Interpolation
String interpolation lets you embed expressions directly inside string literals. The interpolated value is converted to a string and spliced into the surrounding text at runtime.
Syntax
Use ${expression} inside a double-quoted string or a heredoc:
name = "api-gateway"
port = 8080
url = "http://${name}:${port}/health"
// → "http://api-gateway:8080/health"
The expression inside ${...} can be any valid WCL expression.
Supported Expression Types
Variable References
let env = "production"
label = "env:${env}"
// → "env:production"
Arithmetic
let base = 8000
addr = "port ${base + 80} is in use"
// → "port 8080 is in use"
Function Calls
name = "my-service"
display = "Service: ${upper(name)}"
// → "Service: MY-SERVICE"
items = ["a", "b", "c"]
summary = "Items: ${join(", ", items)}"
// → "Items: a, b, c"
Ternary Expressions
debug = true
mode = "Running in ${debug ? "debug" : "release"} mode"
// → "Running in debug mode"
Member Access
config {
version = "2.1.0"
}
banner = "WCL config v${config.version}"
// → "WCL config v2.1.0"
Nested Interpolation
Interpolations can be nested when the inner expression itself contains a string with interpolation:
let prefix = "api"
let version = 2
path = "/v${version}/${prefix}-${to_string(version * 10)}"
// → "/v2/api-20"
Type Coercion in Interpolation
When an interpolated expression evaluates to a non-string type, it is automatically converted:
| Type | Conversion rule | Example result |
|---|---|---|
string | Used as-is | "hello" → hello |
int | Decimal representation | 42 → 42 |
float | Decimal representation | 3.14 → 3.14 |
bool | true or false | true → true |
null | The literal string "null" | null → null |
identifier | The identifier’s name | foo → foo |
list | Runtime error — not auto-converted | |
map | Runtime error — not auto-converted | |
function | Runtime error — not auto-converted |
To embed a list or map in a string, use an explicit conversion function such as join() or to_string().
Escaping
To include a literal ${ in a string without triggering interpolation, escape the dollar sign with a backslash:
template = "Use \${variable} in your templates."
// → "Use ${variable} in your templates."
Interpolation in Heredocs
Standard and indented heredocs support interpolation. Raw heredocs (using single-quoted delimiters) do not.
Standard heredoc with interpolation:
let host = "db.internal"
let port = 5432
dsn = <<EOF
postgresql://${host}:${port}/mydb
EOF
Indented heredoc with interpolation:
let name = "my-service"
let version = "1.0.0"
banner = <<-EOF
Service: ${name}
Version: ${version}
EOF
Raw heredoc (interpolation disabled):
example = <<'EOF'
Use ${variable} to embed values at runtime.
EOF
// → " Use ${variable} to embed values at runtime.\n"
Practical Examples
Building URLs
let scheme = "https"
let host = "api.example.com"
let version = 2
base_url = "${scheme}://${host}/v${version}"
health_check = "${base_url}/health"
Log Format Strings
let service = "auth"
let level = "INFO"
log_prefix = "[${upper(level)}] ${service}:"
Configuration File Paths
let app_name = "my-app"
let env = "production"
config_path = "/etc/${app_name}/${env}/config.yaml"
log_path = "/var/log/${app_name}/${env}.log"
Dynamic Labels
let region = "us-east-1"
let zone = "a"
availability_zone = "${region}${zone}" // "us-east-1a"
resource_tag = "zone:${region}-${zone}" // "zone:us-east-1-a"
Variables and Scoping
WCL provides let bindings for named local values. Unlike block attributes, let bindings are private to their scope and are erased before serialization.
let Bindings
A let binding assigns a name to an expression. It is visible within the rest of the enclosing scope:
let base_port = 8000
server #web-1 {
port = base_port // 8000
}
server #web-2 {
port = base_port + 1 // 8001
}
let bindings are not included in the evaluated output or in serialized JSON/TOML/YAML. They exist purely to reduce repetition.
External Variable Overrides
WCL documents can be parameterized with external variables injected at parse time. External variables override any let binding of the same name, allowing a document to define defaults that the caller can replace.
CLI
Use the --var flag (repeatable) on eval or validate:
wcl eval --var PORT=8080 --var DEBUG=true config.wcl
wcl validate --var PORT=8080 config.wcl
Values are auto-parsed: bare numbers become int/float, true/false become bool, null becomes null, quoted strings become string, and JSON arrays/objects are supported. An unquoted string that doesn’t match any of the above is treated as a string.
Rust API
#![allow(unused)]
fn main() {
let mut opts = ParseOptions::default();
opts.variables.insert("PORT".into(), Value::Int(8080));
let doc = wcl::parse(source, opts);
}
Python
doc = wcl.parse(source, variables={"PORT": 8080, "DEBUG": True})
JavaScript (WASM)
const doc = parse(source, { variables: { PORT: 8080, DEBUG: true } });
Go
doc, err := wcl.Parse(source, &wcl.ParseOptions{
Variables: map[string]any{"PORT": 8080, "DEBUG": true},
})
Ruby
doc = Wcl.parse(source, variables: { "PORT" => 8080, "DEBUG" => true })
.NET
var doc = WclParser.Parse(source, new ParseOptions {
Variables = new Dictionary<string, object> { ["PORT"] = 8080 }
});
Zig
Pass variables as a JSON object string:
var doc = try wcl.parse(allocator, source, .{
.variables_json = "{\"PORT\":8080}",
});
Override Semantics
External variables override document let bindings of the same name. This lets a document define sensible defaults while still being fully parameterizable:
let port = 8080 // default, overridden if --var port=... is set
let host = "localhost" // default
server {
port = port
host = host
}
wcl eval --var port=9090 config.wcl
# → { "server": { ... "port": 9090, "host": "localhost" } }
External variables are also available in control flow expressions:
let regions = ["us"] // default
for region in regions {
server { name = region }
}
wcl eval --var 'regions=["us","eu","ap"]' config.wcl
# → produces 3 server blocks
export let Bindings
An export let binding works like let but makes the name available to files that import this module:
// config/defaults.wcl
export let default_timeout = 5000
export let default_retries = 3
// app.wcl
import "config/defaults.wcl"
service {
timeout = default_timeout // 5000
retries = default_retries // 3
}
Like plain let bindings, exported bindings are erased before serialization — they are not present in the output document.
Re-exporting Names
An export name statement re-exports a name that was imported from another module, making it available to the importer’s importers:
// lib/net.wcl
export let port = 8080
// lib/index.wcl
import "lib/net.wcl"
export port // re-export to callers of lib/index.wcl
// app.wcl
import "lib/index.wcl"
service {
port = port // 8080 — reached through re-export chain
}
Partial Let Bindings
A partial let binding declares a list value that can be split across multiple declarations — including across multiple files. All fragments with the same name are concatenated into a single list.
partial let tags = ["api", "public"]
This is useful when different modules each contribute to a shared collection without having to know about one another.
Multi-file example
// base.wcl
partial let allowed_origins = ["https://example.com"]
// admin.wcl
import "base.wcl"
partial let allowed_origins = ["https://admin.example.com"]
// The merged value used anywhere in scope:
// allowed_origins → ["https://example.com", "https://admin.example.com"]
// (or the reverse — order is not guaranteed)
Rules
- Values must be lists. A
partial letwhose value is not a list is an error (E038). - Cannot mix partial and non-partial. Declaring
let x = ...andpartial let x = ...with the same name in the same merged scope is an error (E039). - Merge order is not guaranteed. Fragments from different files or scopes may be concatenated in any order. Do not write code that depends on the position of elements within the merged list.
Like plain let bindings, partial bindings are erased before serialization and do not appear in the output document.
Scope Model
WCL uses lexical scoping with three scope kinds:
| Scope kind | Created by | Contains |
|---|---|---|
| Module scope | Each .wcl file | Top-level let, export let, blocks, attributes |
| Block scope | Each { } block body | let bindings, nested blocks, attributes |
| Macro scope | Each macro expansion | Macro parameters, local bindings |
Scopes form a chain. A name is resolved by walking the chain from innermost to outermost until a binding is found.
Name Resolution Order
Given a reference x inside a block:
- Look for
xas aletbinding in the current block scope. - Look for
xas an attribute in the current block scope. - Walk up to the enclosing scope and repeat.
- Check module-level
letandexport letbindings. - Check imported names.
- If not found, report an unresolved reference error.
Evaluation Order
WCL does not evaluate declarations in the order they appear. Instead, the evaluator performs a dependency-based topological sort: each name is evaluated after all names it depends on. This means you can reference a name before its declaration:
full_url = "${scheme}://${host}:${port}" // declared before its parts
let scheme = "https"
let host = "api.example.com"
let port = 443
Circular references are detected and reported as errors:
let a = b + 1 // error: cyclic reference: a → b → a
let b = a - 1
Shadowing
A let binding in an inner scope may shadow a name from an outer scope. This produces a warning by default:
let timeout = 5000
service {
let timeout = 1000 // warning: shadows outer binding "timeout"
request_timeout = timeout
}
To suppress the warning for a specific block, use the @allow(shadowing) decorator:
let timeout = 5000
@allow(shadowing)
service {
let timeout = 1000 // no warning
request_timeout = timeout
}
Unused Variable Warnings
A let binding that is declared but never referenced produces an unused-variable warning:
let unused = "hello" // warning: unused variable "unused"
To suppress the warning, prefix the name with an underscore:
let _unused = "hello" // no warning
Comparison: let vs export let vs Attribute
| Feature | let | export let | Attribute |
|---|---|---|---|
| Visible in current scope | Yes | Yes | Yes |
| Visible to importers | No | Yes | No |
| Appears in serialized output | No | No | Yes |
Can be query-selected | No | No | Yes |
| Subject to schema validation | No | No | Yes |
Can be ref-erenced | No | No | Yes (block-level) |
Example: Shared Constants
// shared/network.wcl
export let internal_domain = "svc.cluster.local"
export let default_port = 8080
// services/api.wcl
import "shared/network.wcl"
let service_name = "api-gateway"
server #primary {
host = "${service_name}.${internal_domain}"
port = default_port
}
server #secondary {
host = "${service_name}-2.${internal_domain}"
port = default_port + 1
}
After evaluation the let bindings and export let bindings are stripped; only host and port attributes appear in the output.
Control Flow
WCL provides two declarative control flow structures: for loops and if/else conditionals. Unlike imperative languages, these constructs do not execute at runtime — they expand into concrete blocks and attributes during the control flow expansion phase of the pipeline, before evaluation.
This design keeps WCL configs purely declarative: the final evaluated document contains no loops or conditionals, only the concrete values they produced.
Structures
- For Loops — iterate over lists, ranges, or query results to generate repeated blocks or values.
- If/Else Conditionals — conditionally include blocks, attributes, or children based on boolean expressions.
Expansion Phase
Both constructs are processed during phase 5: control flow expansion, after macro expansion and before partial merge and evaluation. At the end of this phase, the AST contains only concrete nodes.
Limits
To prevent runaway configs and protect tooling performance, WCL enforces the following hard limits:
| Limit | Default |
|---|---|
| Maximum nesting depth (loops + conditionals combined) | 32 |
| Maximum iterations per single for loop | 1,000 |
| Maximum total iterations across all for loops | 10,000 |
Exceeding any of these limits is a compile-time error. The limits exist to keep configs analyzable and prevent accidental exponential expansion.
Composition
For loops and if/else can be freely composed:
for env in ["staging", "prod"] {
service web-${env} {
replicas: if env == "prod" { 3 } else { 1 }
}
}
See the individual pages for full syntax, scoping rules, and examples.
For Loops
For loops let you generate repeated blocks, attributes, or values by iterating over a collection. They expand during the control flow phase into concrete nodes — by the time evaluation runs, all loops have already been unrolled.
Syntax
Basic form
for item in expression {
// body: any WCL statements
}
With index
for item, index in expression {
// index is 0-based
}
expression must evaluate to a list. Ranges, query results, and literal lists are all valid.
Iterating Over Lists
let regions = ["us-east-1", "eu-west-1", "ap-southeast-1"]
for region in regions {
deployment deploy-${region} {
region: region
replicas: 2
}
}
Iterating Over Ranges
Use the range(start, end) built-in to produce a list of integers:
for i in range(0, 5) {
worker worker-${i} {
id: i
}
}
range(0, 5) produces [0, 1, 2, 3, 4] (end is exclusive).
Using the Index
for name, i in ["alpha", "beta", "gamma"] {
shard shard-${i} {
label: name
position: i
}
}
Iterating Over Map Keys
Use keys() to iterate over the keys of a map:
let limits = { cpu: "500m", memory: "256Mi" }
for key in keys(limits) {
resource_limit lim-${key} {
name: key
value: limits[key]
}
}
Iterating Over Query Results
Query results are lists of blocks, so they can be used directly as the loop source:
for svc in query(service | where has(@public)) {
ingress ingress-${svc.name} {
target: svc.name
port: svc.port
}
}
Identifier Interpolation in Inline IDs
When a block is declared inside a for loop, its inline ID can interpolate loop variables using ${variable} syntax:
for name in ["web", "api", "worker"] {
service svc-${name} {
image: "app/${name}:latest"
}
}
This expands to three separate service blocks with IDs svc-web, svc-api, and svc-worker. Interpolation is resolved at expansion time, not evaluation time, so the resulting IDs are static strings in the evaluated document.
Multiple variables and arbitrary expressions are supported inside ${}:
for env in ["staging", "prod"] {
for tier in ["web", "db"] {
group ${env}-${tier} {
label: "${env}/${tier}"
}
}
}
Iterating Over Tables
Tables evaluate to lists of row maps, so they work directly as for-loop sources. This is especially powerful when combined with let-bound import_table():
let users = import_table("users.csv")
for row in users {
service ${row.name}-svc {
role = row.role
}
}
Inline tables also work:
table ports {
name : string
port : int
| "web" | 8080 |
| "api" | 9090 |
}
for row in ports {
server ${row.name} {
listen_port = row.port
}
}
Table manipulation functions like filter(), find(), and insert_row() can be used to transform table data before iteration:
let data = import_table("users.csv")
let admins = filter(data, (r) => r.role == "admin")
for row in admins {
admin_service ${row.name} {
level = "elevated"
}
}
Nested For Loops
For loops can be nested up to the global nesting depth limit (default 32):
for region in regions {
for zone in zones {
node node-${region}-${zone} {
region: region
zone: zone
}
}
}
The total iterations across all loops combined must not exceed 10,000, and each individual loop must not exceed 1,000 iterations.
For Loops Inside Blocks
A for loop can appear inside a block body to generate multiple child blocks or repeated attributes:
cluster main {
for region in regions {
node node-${region} {
region: region
}
}
}
Composition with Macros
For loops can appear inside macro bodies and macros can be called inside for loops:
macro base_service(name, port) {
service ${name} {
port: port
health_check: "/${name}/health"
}
}
for svc in services {
@base_service(svc.name, svc.port)
}
Scoping
Each iteration of a for loop creates a new child scope. The loop variable is bound in that scope and shadows any outer variable with the same name without producing a warning. Variables defined inside the loop body are not visible outside the loop.
let name = "outer"
for name in ["a", "b", "c"] {
// name refers to the iteration variable here, not "outer"
item x-${name} { label: name }
}
// name is "outer" again here
Empty Lists
Iterating over an empty list produces zero iterations and no output — it is not an error:
for item in [] {
// never expanded
}
Limits
| Limit | Default |
|---|---|
| Iterations per loop | 1,000 |
| Total iterations across all loops | 10,000 |
| Maximum nesting depth | 32 |
Exceeding any limit is a compile-time error.
If/Else Conditionals
If/else conditionals let you include or exclude blocks, attributes, and children based on boolean expressions. Like for loops, they are declarative: the conditional is fully resolved during the control flow expansion phase, and only the matching branch appears in the evaluated document.
Syntax
if expression {
// branch body
} else if expression {
// branch body
} else {
// fallback body
}
Any number of else if clauses may be chained. The else clause is optional. All branches are syntactically identical to block bodies and can contain any WCL statements.
Conditions Must Be Boolean
WCL does not perform implicit coercion on condition expressions. The expression must evaluate to a bool. Passing a non-bool value is a compile-time error:
let count = 5
// Correct
if count > 0 { ... }
// Error: count is an int, not a bool
if count { ... }
Conditionals Inside Block Bodies
The most common use of if/else is to selectively include attributes or child blocks inside a block body:
service api {
port: 8080
if env == "prod" {
replicas: 3
tls: true
} else {
replicas: 1
tls: false
}
}
Inline Attribute Values
if/else can appear as an expression to select between two values:
service api {
replicas: if env == "prod" { 3 } else { 1 }
log_level: if debug { "debug" } else { "info" }
}
When used as an expression, each branch must contain exactly one value expression.
Using Queries as Conditions
The result of has() or a boolean query expression can drive a conditional:
for svc in query(service) {
if has(svc, "port") {
ingress ingress-${svc.name} {
target: svc.name
port: svc.port
}
}
}
if has_decorator(myblock, "public") {
expose myblock {
external: true
}
}
Chained Conditions
let tier = "gold"
config limits {
max_requests: if tier == "gold" {
10000
} else if tier == "silver" {
5000
} else {
1000
}
}
Composition with For Loops
if/else and for loops compose freely. A conditional can appear inside a for loop body, and a for loop can appear inside a conditional branch:
for env in ["staging", "prod"] {
service web-${env} {
replicas: if env == "prod" { 3 } else { 1 }
if env == "prod" {
alerts {
pagerduty: true
}
}
}
}
if enable_workers {
for i in range(0, worker_count) {
worker worker-${i} {
id: i
}
}
}
Discarded Branches Are Not Validated
Only the matching branch is included in the expanded AST. The other branches are discarded before evaluation and schema validation. This means a discarded branch can reference names or produce structures that would otherwise be invalid, as long as the condition that guards it is false:
if false {
// This block is never evaluated, so undefined_var is not an error
item x { value: undefined_var }
}
Use this carefully — relying on discarded branches to suppress errors can make configs harder to understand.
Nesting Depth
If/else conditionals count toward the global nesting depth limit (default 32), shared with for loops.
Expansion Phase
Conditionals are expanded during phase 5: control flow expansion, after macro expansion. At the end of this phase the AST contains only the winning branch’s content and no if/else nodes remain.
Functions
WCL provides 50+ built-in functions covering strings, math, collections, higher-order operations, aggregation, hashing, encoding, type coercion, block references, and document introspection. All built-ins are pure, deterministic, and have no side effects — given the same inputs they always return the same output, and they never modify state.
Functions are called with parentheses:
let s = upper("hello") // "HELLO"
let n = max(1, 2, 3) // 3
let xs = filter([1,2,3], x => x > 1) // [2, 3]
Built-in Categories
| Category | Page | Functions |
|---|---|---|
| String | String Functions | upper, lower, trim, trim_prefix, trim_suffix, replace, split, join, starts_with, ends_with, contains, length, substr, format, regex_match, regex_capture |
| Math | Math Functions | abs, min, max, floor, ceil, round, sqrt, pow |
| Collection | Collection Functions | len, keys, values, flatten, concat, distinct, sort, reverse, contains, index_of, range, zip |
| Higher-Order | Higher-Order Functions | map, filter, every, some, reduce |
| Aggregate | Aggregate Functions | sum, avg, min_of, max_of, count |
| Hash & Encoding | Hash & Encoding Functions | sha256, base64_encode, base64_decode, json_encode |
| Type Coercion | Type Coercion Functions | to_string, to_int, to_float, to_bool, type_of |
| Reference & Query | Reference & Query Functions | ref, query, has, has_decorator, is_imported, has_schema |
User-Defined Functions
WCL does not have a fn keyword. Instead, user-defined functions are lambdas stored in let bindings:
let double = x => x * 2
let add = (x, y) => x + y
let greet = name => "Hello, " + name + "!"
Single-parameter lambdas omit the parentheses. Multi-parameter lambdas use (a, b, ...). The body is a single expression — lambdas do not have block bodies.
Once bound, they are called exactly like built-ins:
let result = double(21) // 42
let sum = add(10, 32) // 42
let msg = greet("world") // "Hello, world!"
Lambdas can be passed to higher-order functions:
let evens = filter([1,2,3,4,5], x => x % 2 == 0) // [2, 4]
let doubled = map([1,2,3], double) // [2, 4, 6]
Limitations
- No recursion — a lambda cannot call itself.
- No mutation — lambdas are pure expressions; they cannot modify outer state.
- No variadic arguments — arity is fixed at definition.
- No default parameters — all parameters must be supplied at the call site.
Exporting Functions
Use export let to make a lambda available to other files that import this one:
// utils.wcl
export let clamp = (val, lo, hi) => max(lo, min(hi, val))
export let percent = (n, total) => (n / total) * 100.0
// main.wcl
import "utils.wcl"
config display {
value: clamp(input, 0, 100)
pct: percent(input, 200)
}
Custom Functions (Rust Host)
When using WCL as a Rust library, host programs can register custom fn(&[Value]) -> Result<Value, String> functions that are callable from WCL expressions. This allows domain-specific logic to be exposed to configuration files.
Custom functions are registered via ParseOptions.functions (a FunctionRegistry). See the Rust Library Usage guide for details and examples.
Function Declarations (declare)
Library files can include declare statements that describe functions provided by the host application:
declare my_fn(input: string, count: int) -> string
This serves two purposes:
- The LSP uses declarations for completions and signature help
- If a declared function is called but not registered by the host, a clear error is produced: “function ‘X’ is declared in library but not registered by host application”
Purity Guarantee
Because all functions (built-in and user-defined) are pure and the language has no I/O or mutation, a WCL document always evaluates to the same result given the same inputs. This makes configs safe to cache, diff, and analyze statically.
String Functions
WCL’s string functions operate on string values and return strings, booleans, lists, or integers depending on the operation. All are pure and side-effect free.
Reference
| Function | Signature | Description |
|---|---|---|
upper | upper(s: string) -> string | Convert to uppercase |
lower | lower(s: string) -> string | Convert to lowercase |
trim | trim(s: string) -> string | Remove leading and trailing whitespace |
trim_prefix | trim_prefix(s: string, prefix: string) -> string | Remove prefix if present |
trim_suffix | trim_suffix(s: string, suffix: string) -> string | Remove suffix if present |
replace | replace(s: string, from: string, to: string) -> string | Replace all occurrences of from with to |
split | split(s: string, sep: string) -> list | Split on separator, returning a list of strings |
join | join(list: list, sep: string) -> string | Join a list of strings with a separator |
starts_with | starts_with(s: string, prefix: string) -> bool | True if s starts with prefix |
ends_with | ends_with(s: string, suffix: string) -> bool | True if s ends with suffix |
contains | contains(s: string, sub: string) -> bool | True if s contains sub |
length | length(s: string) -> int | Number of characters (Unicode code points) |
substr | substr(s: string, start: int, end: int) -> string | Substring from start (inclusive) to end (exclusive) |
format | format(template: string, ...args) -> string | Format string with {} placeholders |
regex_match | regex_match(s: string, pattern: string) -> bool | True if s matches the regex pattern |
regex_capture | regex_capture(s: string, pattern: string) -> list | List of capture groups from the first match |
Examples
upper / lower
let name = "Hello World"
let up = upper(name) // "HELLO WORLD"
let lo = lower(name) // "hello world"
trim / trim_prefix / trim_suffix
let padded = " hello "
let clean = trim(padded) // "hello"
let path = trim_prefix("/api/v1/users", "/api") // "/v1/users"
let file = trim_suffix("report.csv", ".csv") // "report"
replace
let msg = replace("foo bar foo", "foo", "baz") // "baz bar baz"
split / join
let parts = split("a,b,c", ",") // ["a", "b", "c"]
let rejoined = join(parts, " | ") // "a | b | c"
starts_with / ends_with / contains
let url = "https://example.com/api"
let secure = starts_with(url, "https") // true
let is_api = ends_with(url, "/api") // true
let has_ex = contains(url, "example") // true
length / substr
let s = "abcdef"
let n = length(s) // 6
let sub = substr(s, 1, 4) // "bcd"
format
let msg = format("Hello, {}! You have {} messages.", "Alice", 3)
// "Hello, Alice! You have 3 messages."
Placeholders are filled left to right. Each {} consumes one argument.
regex_match
let valid = regex_match("[email protected]", "^[\\w.]+@[\\w.]+\\.[a-z]{2,}$")
// true
regex_capture
let groups = regex_capture("2024-03-15", "(\\d{4})-(\\d{2})-(\\d{2})")
// ["2024", "03", "15"]
Returns an empty list if there is no match. The list contains only the capture groups, not the full match.
String Interpolation
In addition to these functions, WCL supports ${} interpolation inside string literals and block IDs:
let env = "prod"
let tag = "deploy-${env}" // "deploy-prod"
Interpolation converts any value to its string representation automatically.
Math Functions
WCL’s math functions operate on int and float values. Functions that accept either type promote integers to floats where needed and are noted below.
Reference
| Function | Signature | Description |
|---|---|---|
abs | abs(n: int|float) -> int|float | Absolute value; preserves input type |
min | min(a: int|float, b: int|float) -> int|float | Smaller of two values |
max | max(a: int|float, b: int|float) -> int|float | Larger of two values |
floor | floor(n: float) -> int | Round down to nearest integer |
ceil | ceil(n: float) -> int | Round up to nearest integer |
round | round(n: float) -> int | Round to nearest integer (half-up) |
sqrt | sqrt(n: int|float) -> float | Square root; always returns float |
pow | pow(base: int|float, exp: int|float) -> float | Raise base to the power of exp; always returns float |
Examples
abs
let a = abs(-42) // 42
let b = abs(-3.14) // 3.14
let c = abs(7) // 7
min / max
let lo = min(10, 3) // 3
let hi = max(10, 3) // 10
// Combine with variables
let clamped = max(0, min(100, input_value))
floor / ceil / round
let f = 3.7
let down = floor(f) // 3
let up = ceil(f) // 4
let near = round(f) // 4
let g = 3.2
let down2 = floor(g) // 3
let up2 = ceil(g) // 4
let near2 = round(g) // 3
sqrt
let root = sqrt(16) // 4.0
let root2 = sqrt(2) // 1.4142135623730951
pow
let squared = pow(4, 2) // 16.0
let cubed = pow(2, 10) // 1024.0
let frac = pow(8, 0.333) // ~2.0
Integer Arithmetic
Standard arithmetic operators work on integers without calling functions:
let sum = 10 + 3 // 13
let diff = 10 - 3 // 7
let prod = 10 * 3 // 30
let quot = 10 / 3 // 3 (integer division)
let rem = 10 % 3 // 1
For floating-point division, ensure at least one operand is a float:
let ratio = 10.0 / 3 // 3.3333...
Combining Math with Collections
For aggregate operations over lists (sum, average, min/max of a list), see Aggregate Functions. For per-element operations, use Higher-Order Functions:
let values = [1, 4, 9, 16]
let roots = map(values, x => sqrt(x)) // [1.0, 2.0, 3.0, 4.0]
Collection Functions
WCL’s collection functions work with lists and maps. They are pure — none mutate their input; all return new values.
Reference
| Function | Signature | Description |
|---|---|---|
len | len(coll: list|map|string) -> int | Number of elements (or characters for strings) |
keys | keys(m: map) -> list | Ordered list of a map’s keys |
values | values(m: map) -> list | Ordered list of a map’s values |
flatten | flatten(list: list) -> list | Recursively flatten nested lists one level |
concat | concat(a: list, b: list) -> list | Concatenate two lists |
distinct | distinct(list: list) -> list | Remove duplicate values, preserving first occurrence order |
sort | sort(list: list) -> list | Sort in ascending order (strings lexicographic, numbers numeric) |
reverse | reverse(list: list) -> list | Reverse the order of a list |
contains | contains(list: list, value) -> bool | True if value is in the list |
index_of | index_of(list: list, value) -> int | Zero-based index of first occurrence, or -1 if not found |
range | range(start: int, end: int) -> list | List of integers from start (inclusive) to end (exclusive) |
zip | zip(a: list, b: list) -> list | List of [a_i, b_i] pairs; length is the shorter of the two |
Examples
len
let n1 = len([1, 2, 3]) // 3
let n2 = len({a: 1, b: 2}) // 2
let n3 = len("hello") // 5
keys / values
let config = {host: "localhost", port: 5432, db: "main"}
let k = keys(config) // ["host", "port", "db"]
let v = values(config) // ["localhost", 5432, "main"]
flatten
let nested = [[1, 2], [3, [4, 5]], [6]]
let flat = flatten(nested) // [1, 2, 3, [4, 5], 6]
flatten goes one level deep. Call it again for deeper nesting.
concat
let a = [1, 2, 3]
let b = [4, 5, 6]
let c = concat(a, b) // [1, 2, 3, 4, 5, 6]
distinct
let dupes = ["a", "b", "a", "c", "b"]
let uniq = distinct(dupes) // ["a", "b", "c"]
sort
let nums = sort([3, 1, 4, 1, 5, 9, 2])
// [1, 1, 2, 3, 4, 5, 9]
let words = sort(["banana", "apple", "cherry"])
// ["apple", "banana", "cherry"]
reverse
let rev = reverse([1, 2, 3, 4]) // [4, 3, 2, 1]
contains
let present = contains([10, 20, 30], 20) // true
let absent = contains(["a", "b"], "c") // false
index_of
let i = index_of(["x", "y", "z"], "y") // 1
let j = index_of(["x", "y", "z"], "w") // -1
range
let r = range(0, 5) // [0, 1, 2, 3, 4]
let r2 = range(3, 7) // [3, 4, 5, 6]
range is most often used as the source of a for loop:
for i in range(0, 3) {
item item-${i} { position: i }
}
zip
let names = ["web", "api", "worker"]
let ports = [80, 8080, 9000]
let pairs = zip(names, ports)
// [["web", 80], ["api", 8080], ["worker", 9000]]
If the lists have different lengths, the result has the length of the shorter list.
Combining Collection Functions
Collection functions compose naturally:
let items = [3, 1, 4, 1, 5, 9, 2, 6, 5]
let result = reverse(sort(distinct(items)))
// [9, 6, 5, 4, 3, 2, 1]
For transforming or filtering list elements, see Higher-Order Functions. For numeric aggregation over lists, see Aggregate Functions.
Higher-Order Functions
Higher-order functions accept a list and a lambda (anonymous function) and apply the lambda to elements. They are the primary tool for transforming and testing collections in WCL.
Lambdas use the => syntax:
// Single parameter — no parentheses needed
x => x * 2
// Multiple parameters
(acc, x) => acc + x
Lambdas can also reference named functions from let bindings:
let double = x => x * 2
let doubled = map([1, 2, 3], double) // [2, 4, 6]
Reference
| Function | Signature | Description |
|---|---|---|
map | map(list: list, fn: lambda) -> list | Apply fn to each element; return list of results |
filter | filter(list: list, fn: lambda) -> list | Keep elements for which fn returns true |
every | every(list: list, fn: lambda) -> bool | True if fn returns true for all elements |
some | some(list: list, fn: lambda) -> bool | True if fn returns true for at least one element |
reduce | reduce(list: list, fn: lambda, initial) -> any | Fold list into a single value using fn(accumulator, element) |
map
Transform each element of a list:
let nums = [1, 2, 3, 4, 5]
let squared = map(nums, x => x * x) // [1, 4, 9, 16, 25]
let strs = map(nums, x => to_string(x)) // ["1", "2", "3", "4", "5"]
Map over strings extracted from blocks:
let services = query(service)
let names = map(services, s => s.name) // ["web", "api", "worker"]
filter
Keep only elements that satisfy a predicate:
let nums = [1, 2, 3, 4, 5, 6]
let evens = filter(nums, x => x % 2 == 0) // [2, 4, 6]
let large = filter(nums, x => x > 3) // [4, 5, 6]
Filter blocks by attribute values:
let prod_svcs = filter(query(service), s => s.env == "prod")
every
Check that all elements satisfy a predicate:
let all_positive = every([1, 2, 3], x => x > 0) // true
let all_even = every([2, 4, 6], x => x % 2 == 0) // true
let all_small = every([1, 2, 100], x => x < 10) // false
Short-circuits on the first false result.
some
Check that at least one element satisfies a predicate:
let has_large = some([1, 2, 3, 100], x => x > 50) // true
let has_neg = some([1, 2, 3], x => x < 0) // false
Short-circuits on the first true result.
reduce
Fold a list into a single accumulated value:
let nums = [1, 2, 3, 4, 5]
let total = reduce(nums, (acc, x) => acc + x, 0) // 15
let product = reduce(nums, (acc, x) => acc * x, 1) // 120
Build a string from a list:
let words = ["hello", "world", "wcl"]
let sentence = reduce(words, (acc, w) => acc + " " + w, "")
// " hello world wcl" (note the leading space from the empty initial)
Build a map from a list of pairs:
let pairs = [["a", 1], ["b", 2], ["c", 3]]
let m = reduce(pairs, (acc, p) => merge(acc, {[p[0]]: p[1]}), {})
// {a: 1, b: 2, c: 3}
Composing Higher-Order Functions
Higher-order functions compose naturally:
let nums = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
// Sum of squares of even numbers
let result = reduce(
map(filter(nums, x => x % 2 == 0), x => x * x),
(acc, x) => acc + x,
0
)
// filter -> [2, 4, 6, 8, 10]
// map -> [4, 16, 36, 64, 100]
// reduce -> 220
Using Named Lambdas
Store predicates and transformers in let bindings for reuse:
let is_prod = s => s.env == "prod"
let get_port = s => s.port
let prod_services = filter(query(service), is_prod)
let prod_ports = map(prod_services, get_port)
Exported lambdas (via export let) can be shared across files the same way as data.
Aggregate Functions
Aggregate functions reduce a list of numbers to a single scalar value. They are most useful for summarizing configuration data — computing totals, averages, and extremes across a collection of blocks or values.
Reference
| Function | Signature | Description |
|---|---|---|
sum | sum(list: list) -> int|float | Sum of all elements |
avg | avg(list: list) -> float | Arithmetic mean of all elements |
min_of | min_of(list: list) -> int|float | Smallest element |
max_of | max_of(list: list) -> int|float | Largest element |
count | count(list: list, fn: lambda) -> int | Number of elements for which fn returns true |
Note:
minandmaxtake two scalar arguments and compare them directly.min_ofandmax_oftake a list and find the extreme value within it. See Math Functions formin/max.
Examples
sum
let totals = [10, 25, 30, 15]
let total = sum(totals) // 80
Sum an attribute across all matching blocks:
let all_replicas = map(query(service), s => s.replicas)
let replica_count = sum(all_replicas)
avg
let scores = [80, 90, 70, 100, 85]
let mean = avg(scores) // 85.0
avg always returns a float, even for integer input lists.
min_of / max_of
let latencies = [120, 45, 200, 88, 33]
let fastest = min_of(latencies) // 33
let slowest = max_of(latencies) // 200
Find the service with the highest replica count:
let counts = map(query(service), s => s.replicas)
let max_replicas = max_of(counts)
count
Count elements that satisfy a predicate:
let nums = [1, 2, 3, 4, 5, 6, 7, 8]
let even_count = count(nums, x => x % 2 == 0) // 4
let large_count = count(nums, x => x > 5) // 3
Count matching blocks:
let prod_count = count(query(service), s => s.env == "prod")
Combining Aggregates with Higher-Order Functions
Aggregate functions work naturally after map and filter:
// Average port number across all production services
let prod_ports = map(
filter(query(service), s => s.env == "prod"),
s => s.port
)
let avg_port = avg(prod_ports)
// Total memory requested across all workers with > 2 replicas
let high_replica_workers = filter(query(worker), w => w.replicas > 2)
let memory_values = map(high_replica_workers, w => w.memory_mb)
let total_memory = sum(memory_values)
Validation Use Case
Aggregates are useful inside validate blocks to enforce fleet-wide constraints:
validate {
let total = sum(map(query(service), s => s.replicas))
assert total <= 50 : "Total replica count must not exceed 50"
}
Hash & Encoding Functions
WCL provides functions for hashing strings and encoding/decoding data. These are pure, deterministic functions useful for generating stable identifiers, verifying content, and interoperating with systems that expect encoded data.
Reference
| Function | Signature | Description |
|---|---|---|
sha256 | sha256(s: string) -> string | Hex-encoded SHA-256 digest of the UTF-8 bytes of s |
base64_encode | base64_encode(s: string) -> string | Standard Base64 encoding of the UTF-8 bytes of s |
base64_decode | base64_decode(s: string) -> string | Decode a Base64-encoded string back to UTF-8 |
json_encode | json_encode(value: any) -> string | Serialize any WCL value to a JSON string |
Examples
sha256
let digest = sha256("hello world")
// "b94d27b9934d3e08a52e52d7da7dabfac484efe04294e576b35568b3f5d8d4a5"
// (truncated for illustration)
Use sha256 to generate stable, content-derived identifiers:
let artifact_id = sha256(format("{}-{}-{}", name, version, arch))
artifact ${artifact_id} {
name: name
version: version
}
base64_encode / base64_decode
let encoded = base64_encode("user:password") // "dXNlcjpwYXNzd29yZA=="
let decoded = base64_decode(encoded) // "user:password"
Encode a config value for embedding in an environment variable:
config app {
db_url_b64: base64_encode("postgres://host:5432/mydb")
}
json_encode
json_encode converts any WCL value to its JSON representation:
let obj = {name: "web", port: 8080, tags: ["api", "public"]}
let json = json_encode(obj)
// "{\"name\":\"web\",\"port\":8080,\"tags\":[\"api\",\"public\"]}"
Scalar values:
let s = json_encode("hello") // "\"hello\""
let n = json_encode(42) // "42"
let b = json_encode(true) // "true"
let l = json_encode([1, 2, 3]) // "[1,2,3]"
json_encode is particularly useful when you need to embed structured data as a string in another system’s configuration format:
service api {
env_vars: {
APP_CONFIG: json_encode({
timeout: 30
retries: 3
endpoints: ["primary", "fallback"]
})
}
}
Notes
sha256always produces lowercase hex output.base64_encodeuses standard Base64 with padding (=). It does not produce URL-safe Base64.base64_decodereturns astring. If the decoded bytes are not valid UTF-8, an error is raised.json_encodeserializes WCL maps with keys in insertion order.
Type Coercion Functions
WCL is strongly typed and does not coerce values implicitly. When you need to convert between types, use these explicit coercion functions. The type_of function lets you inspect a value’s type at evaluation time.
Reference
| Function | Signature | Description |
|---|---|---|
to_string | to_string(value: any) -> string | Convert any value to its string representation |
to_int | to_int(value: string|float|bool) -> int | Parse or truncate a value to integer |
to_float | to_float(value: string|int|bool) -> float | Parse or promote a value to float |
to_bool | to_bool(value: string|int) -> bool | Parse a value to boolean |
type_of | type_of(value: any) -> string | Return a string naming the value’s type |
Examples
to_string
Converts any WCL value to a string. This is equivalent to placing a value inside ${} interpolation.
let s1 = to_string(42) // "42"
let s2 = to_string(3.14) // "3.14"
let s3 = to_string(true) // "true"
let s4 = to_string([1, 2]) // "[1, 2]"
Useful when building strings from computed values:
let label = "replicas-" + to_string(replica_count)
to_int
Converts strings, floats, and booleans to integers.
let n1 = to_int("42") // 42
let n2 = to_int(3.9) // 3 (truncates toward zero)
let n3 = to_int(true) // 1
let n4 = to_int(false) // 0
Parsing a string that is not a valid integer is a runtime error:
let bad = to_int("hello") // error
to_float
Converts strings, integers, and booleans to floats.
let f1 = to_float("3.14") // 3.14
let f2 = to_float(10) // 10.0
let f3 = to_float(true) // 1.0
let f4 = to_float(false) // 0.0
to_bool
Converts strings and integers to booleans.
For strings: "true" → true, "false" → false. Any other string is a runtime error.
For integers: 0 → false, any non-zero → true.
let b1 = to_bool("true") // true
let b2 = to_bool("false") // false
let b3 = to_bool(1) // true
let b4 = to_bool(0) // false
type_of
Returns a lowercase string naming the type of the given value.
| Value type | type_of result |
|---|---|
string | "string" |
int | "int" |
float | "float" |
bool | "bool" |
list | "list" |
map | "map" |
| block reference | "block" |
null | "null" |
let t1 = type_of("hello") // "string"
let t2 = type_of(42) // "int"
let t3 = type_of(3.14) // "float"
let t4 = type_of(true) // "bool"
let t5 = type_of([1, 2, 3]) // "list"
let t6 = type_of({a: 1}) // "map"
Branching on type:
let describe = v => if type_of(v) == "int" {
"integer: " + to_string(v)
} else if type_of(v) == "string" {
"string: " + v
} else {
"other: " + to_string(v)
}
No Implicit Coercion
WCL never coerces types automatically. The following are all errors:
let bad1 = "count: " + 5 // error: cannot add string and int
let bad2 = 1 + true // error: cannot add int and bool
let bad3 = if 1 { "yes" } // error: condition must be bool
Always use an explicit coercion function when mixing types.
Reference & Query Functions
WCL provides six functions for inspecting the document and its environment: ref() for resolving a block by ID, query() for selecting sets of blocks using a pipeline, has() for checking whether a block has a named attribute or child, has_decorator() for checking whether a block carries a decorator, is_imported() for testing whether a file was imported, and has_schema() for testing whether a schema is declared. These are the bridge between the functional expression layer and the block-oriented document model.
The query engine is covered in depth in the Query Engine chapter. This page focuses on the function call syntax and common patterns.
Reference
| Function | Signature | Description |
|---|---|---|
ref | ref(id: string) -> block | Resolve a block by its ID; error if not found |
query | query(pipeline) -> list | Execute a query pipeline; return matching blocks |
has | has(block, name: string) -> bool | True if block has an attribute or child block named name |
has_decorator | has_decorator(block, name: string) -> bool | True if block carries the decorator @name |
is_imported | is_imported(path: string) -> bool | True if the given file path was imported |
has_schema | has_schema(name: string) -> bool | True if a schema with the given name is declared |
ref
ref(id) looks up a block by its inline ID and returns it as a value. Attribute access on the returned block uses dot notation.
service web {
port: 8080
image: "nginx:latest"
}
config proxy {
upstream_port: ref("web").port // 8080
upstream_image: ref("web").image // "nginx:latest"
}
If the ID does not exist, ref raises a compile-time error. ref can resolve any block type, not just service.
database primary {
host: "db.internal"
port: 5432
}
service api {
db_host: ref("primary").host
db_port: ref("primary").port
}
query
query(pipeline) runs a query pipeline against the document and returns a list of matching blocks. The pipeline syntax is described fully in the Query Engine chapter.
Basic form — select all blocks of a given type:
let all_services = query(service)
let all_workers = query(worker)
With a filter:
let prod_services = query(service | where env == "prod")
With multiple pipeline stages:
let names = query(service | where env == "prod" | select name)
Query results are lists. Use collection and higher-order functions on them:
let ports = map(query(service), s => s.port)
let total_replicas = sum(map(query(service), s => s.replicas))
Use query results in for loops:
for svc in query(service | where has(@public)) {
ingress ingress-${svc.name} {
target: svc.name
port: svc.port
}
}
has
has(block, name) tests whether a block contains a named attribute or child block. It returns false rather than erroring when the name is absent, making it safe to use in conditionals and filters.
service api {
port: 8080
// no "tls" attribute
}
service secure {
port: 443
tls: true
}
let has_tls = has(ref("secure"), "tls") // true
let api_tls = has(ref("api"), "tls") // false
Use in a filter to find blocks that have a particular attribute:
let tls_services = filter(query(service), s => has(s, "tls"))
Use in a conditional to selectively generate configuration:
for svc in query(service) {
if has(svc, "port") {
health_check check-${svc.name} {
url: "http://${svc.name}:${svc.port}/health"
}
}
}
has_decorator
has_decorator(block, name) tests whether a block was annotated with the decorator @name. The name is passed without the @ prefix.
@public
service web {
port: 80
}
service internal-api {
port: 8080
}
let is_public = has_decorator(ref("web"), "public") // true
let api_public = has_decorator(ref("internal-api"), "public") // false
Filter to all publicly exposed services:
let public_services = filter(query(service), s => has_decorator(s, "public"))
Generate ingress rules only for @public blocks:
for svc in query(service) {
if has_decorator(svc, "public") {
ingress ingress-${svc.name} {
host: "${svc.name}.example.com"
port: svc.port
}
}
}
is_imported
is_imported(path) returns true if the given file path was imported into the current document. The path is resolved relative to the project root. This is useful for writing conditional configuration that activates only when an optional module is present.
let has_auth = is_imported("./auth.wcl")
if has_auth {
config security {
auth_enabled: true
}
}
is_imported returns false for paths that were not imported — it never errors.
has_schema
has_schema(name) returns true if a schema with the given name is declared in the document, including schemas brought in via imports. This lets configuration react to what schema definitions are available at evaluation time.
let needs_schema = has_schema("service")
if needs_schema {
config validation_enabled {
strict: true
}
}
Like is_imported, has_schema returns false rather than erroring when the name is not found.
// Guard generated blocks behind schema availability
let has_svc_schema = has_schema("service")
let has_db_schema = has_schema("database")
Combining ref, query, has, has_decorator, is_imported, and has_schema
These six functions compose with the rest of WCL’s expression language:
// All services that have a port attribute and are marked @public
let exposed = filter(
query(service | where has(@public)),
s => has(s, "port")
)
// Generate a summary block
config fleet_summary {
total_services: len(query(service))
public_count: count(query(service), s => has_decorator(s, "public"))
total_replicas: sum(map(query(service), s => s.replicas))
primary_db_host: ref("primary").host
}
For advanced query pipeline syntax including multi-stage pipelines, recursive queries, and the select and order_by stages, see the Query Engine chapter.
Schemas
Schemas define the expected shape of blocks — their fields, types, and constraints. When a schema exists with the same name as a block type, WCL automatically validates every block of that type against it.
Syntax
schema "service" {
port: int @required
region: string @required
env: string @default("production")
tags: list @optional
replicas: int @validate(min = 1, max = 100)
}
A schema body is a list of field declarations. Each field has a name, a type, and zero or more decorators.
Matching Blocks to Schemas
A schema named "service" is automatically applied to every service block in the document:
service "api" {
port = 8080
region = "us-east-1"
env = "staging"
replicas = 3
}
Matching is done by block type name. There is no explicit @schema annotation needed on the block.
Field Types
The following primitive types are available for schema fields:
| Type | Description |
|---|---|
string | A string value |
int | An integer value |
float | A floating-point value |
bool | A boolean value |
list | A list of values |
map | A key-value map |
any | Accepts any value type |
symbol | A symbol literal (e.g. :GET) |
Field Decorators
@required
Fields are required by default. You may add @required explicitly for clarity:
schema "database" {
host: string @required
port: int @required
}
@optional
Marks a field as not required. If the field is absent from the block, no error is raised:
schema "service" {
debug_port: int @optional
}
@default(value)
Provides a default value used when the field is absent. Implies @optional:
schema "service" {
env: string @default("production")
replicas: int @default(1)
}
@validate(…)
Attaches constraints to a field’s value:
schema "service" {
port: int @validate(min = 1, max = 65535)
env: string @validate(one_of = ["development", "staging", "production"])
name: string @validate(pattern = "^[a-z][a-z0-9-]*$")
replicas: int @validate(min = 1, max = 100, custom_msg = "replicas must be between 1 and 100")
}
Available constraint arguments:
| Argument | Applies to | Description |
|---|---|---|
min | int, float | Minimum value (inclusive) |
max | int, float | Maximum value (inclusive) |
pattern | string | Regex pattern the value must match |
one_of | string, int | Value must be one of the listed options |
custom_msg | any | Custom error message on violation |
Cross-References with @ref
Use @ref("schema_name") on an identifier field to require that the referenced value points to a valid block of the named type:
schema "deployment" {
service_id: string @ref("service")
region_id: string @ref("region")
}
When a deployment block has service_id = "api", WCL verifies that a service "api" block exists in the document.
ID Naming Conventions with @id_pattern
Use @id_pattern("glob") on a schema’s identifier field to enforce naming conventions on block IDs:
schema "service" @id_pattern("svc-*") {
port: int
}
Any service block whose ID does not match the glob svc-* will produce a validation error.
Nested Schema References with ref()
Use ref("other_schema") as a field type to require that the field’s value conforms to another schema:
schema "address" {
street: string
city: string
zip: string @validate(pattern = "^[0-9]{5}$")
}
schema "contact" {
name: string
address: ref("address")
}
Open vs Closed Schemas
By default schemas are closed: any attribute present in a block but not declared in the schema produces an error (error code E072).
Add the @open decorator to allow extra attributes:
schema "service" @open {
port: int
region: string
}
An open schema validates all declared fields but silently permits additional attributes not listed in the schema.
Validation Timing
Schema validation runs at phase 9 of the WCL pipeline, after:
- Import resolution
- Macro expansion
- Control flow expansion (for/if)
- Partial merging
- Scope construction and evaluation
This means schema validation sees the fully resolved document. Computed values, macro-generated blocks, and merged partials are all validated.
Accumulative Error Reporting
Schema validation is accumulative. All violations across all blocks are collected before reporting, so you see every error in a single pass rather than stopping at the first failure.
Composition: ref() and Partials
WCL schemas do not support inheritance. Instead, use two composition mechanisms:
ref("schema")— reference another schema as a field type.- Partials — share common attribute groups across blocks and merge them before validation runs.
schema "base_service" {
port: int
region: string
}
schema "web_service" {
base: ref("base_service")
domain: string
tls: bool @default(true)
}
Full Example
schema "service" @id_pattern("svc-*") {
port: int @required @validate(min = 1, max = 65535)
region: string @required @validate(one_of = ["us-east-1", "eu-west-1", "ap-south-1"])
env: string @default("production") @validate(one_of = ["development", "staging", "production"])
tags: list @optional
replicas: int @default(1) @validate(min = 1, max = 100)
}
service "svc-api" {
port = 8080
region = "us-east-1"
env = "staging"
tags = ["web", "critical"]
replicas = 3
}
service "svc-worker" {
port = 9090
region = "eu-west-1"
}
The svc-worker block inherits env = "production" and replicas = 1 from the schema defaults.
Per-Child Cardinality with @child
Use @child("kind", min=N, max=N) to enforce how many children of a given kind a block must/may have:
schema "server" {
@child("endpoint", min=1, max=10)
@child("config", max=1)
port: int
host: string
}
min— error if fewer children of that kind exist (E097)max— error if more children of that kind exist (E098)@child("kind")with no min/max just adds the kind to the allowed children set (like@children)@childentries merge into the@childrenconstraint automatically
Self-Nesting with max_depth
Use @child("kind", max_depth=N) to allow a block to contain itself, up to a depth limit:
schema "menu" {
@child("menu", max_depth=3)
label: string
}
menu top {
label = "File"
menu sub {
label = "Open"
menu deep {
label = "Recent" // depth 3 — allowed
// menu too-deep { ... } // ERROR E099: exceeds max depth
}
}
}
Union Field Types
Use union(t1, t2, ...) to declare that a field accepts any of the listed types:
schema "config" {
value: union(string, int, bool)
}
config a { value = "hello" }
config b { value = 42 }
config c { value = true }
Tagged Variant Schemas
Use @tagged("field") and variant "value" { ... } to define schemas where required fields depend on a discriminator value:
@tagged("style")
schema "api" {
style: string
version: string @optional
@children(["resource"])
variant "rest" {
base_path: string
}
@children(["gql_query", "gql_mutation"])
variant "graphql" {
schema_path: string @optional
}
}
api rest-api {
style = "rest"
base_path = "/api/v1"
}
api gql-api {
style = "graphql"
}
- Common fields (outside variants) apply to all blocks
- When the tag field matches a variant, that variant’s fields are also validated
- When no variant matches, only common fields are validated
- Variant
@children/@childdecorators override the base schema’s containment for that variant - Variant fields are accepted by closed schemas even when not in the active variant
Symbols
Symbol literals are lightweight, identifier-like values prefixed with a colon. They are useful when a field represents a fixed set of named options rather than arbitrary strings.
Symbol Literals
A symbol literal is written as a colon followed by an identifier:
endpoint list_users {
method = :GET
path = "/users"
}
Symbol values are distinct from strings. :GET is not the same as "GET".
Symbol Sets
A symbol_set declaration defines a named group of valid symbols:
symbol_set http_method {
:GET
:POST
:PUT
:PATCH
:DELETE
:HEAD
:OPTIONS
}
Value Mappings
Each member of a symbol set can optionally map to a string value using =. This controls how the symbol serializes to JSON:
symbol_set curl_option {
:unix_socket = "unix-socket"
:compressed = "compressed"
:verbose = "verbose"
}
Without an explicit mapping, a symbol serializes to its name as a string (e.g. :GET becomes "GET" in JSON output).
Using @symbol_set in Schemas
Use the symbol type and the @symbol_set decorator to constrain a field to members of a declared set:
schema "endpoint" {
method: symbol @symbol_set("http_method")
path: string
}
If a block provides a symbol value that is not a member of the referenced set, error E100 is raised. If the named set does not exist, error E101 is raised.
The Special “all” Set
Use the set name "all" to accept any symbol value without restricting to a specific set:
schema "tag" {
kind: symbol @symbol_set("all")
}
tag important {
kind = :priority // any symbol is accepted
}
This is useful when you want the symbol type for its semantics (not a free-form string) but do not want to enumerate every valid value.
JSON Serialization
Symbols serialize to JSON as strings:
- A symbol with no value mapping serializes to its identifier name:
:GETbecomes"GET". - A symbol with a value mapping serializes to the mapped string:
:unix_socket = "unix-socket"becomes"unix-socket".
symbol_set http_method { :GET :POST }
endpoint example {
method = :GET
}
// JSON output:
// { "endpoint": { "example": { "method": "GET" } } }
Symbol Error Codes
| Code | Meaning |
|---|---|
| E100 | Symbol value not in declared symbol_set |
| E101 | Referenced symbol_set does not exist |
| E102 | Duplicate symbol_set name |
| E103 | Duplicate symbol within a symbol_set |
Error Codes
| Code | Meaning |
|---|---|
| E001 | Duplicate schema name |
| E030 | Duplicate block ID |
| E070 | Missing required field |
| E071 | Type mismatch |
| E072 | Unknown attribute in closed schema |
| E073 | min/max constraint violation |
| E074 | Pattern constraint violation |
| E075 | one_of constraint violation |
| E076 | @ref target not found |
| E077 | @id_pattern mismatch |
| E080 | Validation block failure |
| E092 | Inline columns defined when schema is applied |
| E095 | Child not allowed by parent’s @children list |
| E096 | Item not allowed by its own @parent list |
| E097 | Child count below @child minimum |
| E098 | Child count above @child maximum |
| E099 | Self-nesting exceeds @child max_depth |
| E100 | Symbol value not in declared symbol_set |
| E101 | Referenced symbol_set does not exist |
| E102 | Duplicate symbol_set name |
| E103 | Duplicate symbol within a symbol_set |
Block & Table Containment
Use @children and @parent decorators on schemas to constrain which blocks and tables can nest inside which others.
@children — restrict what a block may contain
@children(["endpoint", "table:user_row"])
schema "service" {
name: string
}
service "api" {
name = "my api"
endpoint health { path = "/health" } // allowed
table users : user_row { | "Alice" | } // allowed
// logger { level = "info" } // ERROR E095
}
Use @children([]) to create a leaf block that cannot contain any children.
@parent — restrict where a block may appear
@parent(["service", "_root"])
schema "endpoint" {
path: string
}
The special name "_root" refers to the document’s top level. Use a schema named "_root" with @children to constrain what appears at the top level:
@children(["service", "config"])
schema "_root" {}
Table containment
Define virtual schemas named "table" or "table:X" to constrain table placement:
@parent(["data"])
schema "table:user_row" {}
@parent(["_root"])
schema "table" {}
See Built-in Decorators for full details.
Applying Schemas to Tables
You can apply a schema to a table using the colon syntax or the @schema decorator:
schema "user_row" {
name : string
age : int
}
# Colon syntax
table users : user_row {
| "Alice" | 30 |
}
# Decorator syntax
@schema("user_row")
table contacts {
| "Bob" | 25 |
}
# With CSV import
table imported : user_row = import_table("users.csv")
When a schema is applied, inline column declarations are not allowed (E092).
Decorators
Decorators attach metadata and behavioral hints to blocks, attributes, tables, and schema fields. They are the primary extension mechanism in WCL — used for schema constraints, documentation, macro transforms, and custom tooling.
Syntax
Decorators are written with a leading @ followed by a name and an optional argument list:
@name
@name(positional_arg)
@name(key = value)
@name(positional_arg, key = value)
Multiple decorators can be stacked on the same target:
port = 8080 @required @validate(min = 1, max = 65535) @doc("The port this service listens on")
Arguments
Decorator arguments are full WCL expressions. They can reference variables, use arithmetic, or call built-in functions:
let max_port = 65535
port = 8080 @validate(min = 1, max = max_port)
Arguments may be positional, named, or a mix of both. When a decorator accepts a single primary argument, positional form is the most concise:
env = "production" @default("development")
Named arguments are used when providing multiple values or for clarity:
@deprecated(message = "use new_field instead", since = "2.0")
Targets
Decorators can be placed on:
| Target | Example |
|---|---|
| Attribute | port = 8080 @required |
| Block | service "api" @deprecated("use v2") { ... } |
| Table | table#hosts @open { ... } |
| Schema field | port: int @validate(min = 1, max = 65535) |
| Schema itself | schema "service" @open { ... } |
| Partial block | partial service @partial_requires([port]) { } |
Stacking
Any number of decorators can appear on a single target. They are evaluated in declaration order:
schema "endpoint" {
url: string @required
@validate(pattern = "^https://")
@doc("Must be a full HTTPS URL")
}
Built-in vs Custom Decorators
WCL ships with a set of built-in decorators covering common needs. You can also define your own using decorator schemas, which let you validate arguments and restrict which targets a decorator may appear on.
- See Built-in Decorators for a full reference of the decorators provided by WCL.
- See Decorator Schemas for how to define and validate custom decorators.
Built-in Decorators
WCL provides a set of built-in decorators for schema validation, documentation, macro transforms, and configuration semantics.
Reference Table
| Decorator | Targets | Arguments | Description |
|---|---|---|---|
@optional | schema fields | none | Field is not required |
@required | schema fields | none | Field must be present (default for schema fields) |
@default(value) | schema fields | value: any | Default value when field is absent |
@sensitive | attributes | redact_in_logs: bool = true | Marks value as sensitive; redacted in log output |
@deprecated | blocks, attributes | message: string, since: string (optional) | Warns when this item is used |
@validate(...) | attributes, schema fields | min, max, pattern, one_of, custom_msg | Value constraints |
@doc(text) | any | text: string | Inline documentation for the decorated item |
@example { } | decorator schemas, schemas | block body | Embedded usage example |
@allow(rule) | let bindings, attributes | rule: string | Suppresses a specific warning |
@id_pattern(glob) | schemas | glob: string | Enforces naming convention on block IDs |
@ref(schema) | schema identifier fields | schema: string | Requires value to reference an existing block of that type |
@partial_requires | partial blocks | fields: list of strings | Declares expected merge dependencies |
@merge_order(n) | partial blocks | n: int | Explicit ordering for partial merges |
@open | schemas | none | Allows extra attributes not declared in the schema |
@child(kind, ...) | schemas | kind: string, min/max/max_depth: int (optional) | Per-child cardinality and depth constraints |
@tagged(field) | schemas | field: string | Names the discriminator field for tagged variant schemas |
@children(kinds) | schemas | kinds: list of strings | Restricts which child blocks/tables may appear inside |
@parent(kinds) | schemas | kinds: list of strings | Restricts which parent blocks may contain this block/table |
@symbol_set(name) | schema fields | name: string | Constrains a symbol-typed field to members of the named symbol set |
@inline(N) | schema fields | N: int | Maps positional inline arg at index N to this named field |
@text | schema fields | none | Marks a content: string field as the text block target |
@merge_strategy(s) | partial blocks | s: string | Controls how partial merges resolve conflicts |
@optional
Marks a schema field as not required. If the field is absent from a block, no error is raised.
schema "service" {
debug_port: int @optional
log_level: string @optional
}
@required
Marks a schema field as required. This is the default for all schema fields, but can be written explicitly for clarity.
schema "service" {
port: int @required
region: string @required
}
@default(value)
Provides a fallback value when the field is absent from the block. A field with @default is implicitly optional.
schema "service" {
env: string @default("production")
replicas: int @default(1)
tls: bool @default(true)
}
The default value must be a valid WCL expression and must match the declared field type.
@sensitive
Marks an attribute’s value as sensitive. Tools and log output should redact this value.
database "primary" {
host = "db.internal"
password = "s3cr3t" @sensitive
api_key = "change-me" @sensitive(redact_in_logs = true)
}
The optional redact_in_logs argument defaults to true.
@deprecated
Indicates that a block or attribute is deprecated. A warning is emitted when it is used.
service "legacy-api" @deprecated(message = "Use service 'api-v2' instead", since = "3.0") {
port = 8080
}
On an attribute:
schema "service" {
workers: int @deprecated(message = "Use 'replicas' instead")
replicas: int @default(1)
}
since is optional and accepts a version string.
@validate(…)
Attaches value constraints to an attribute or schema field. Multiple constraint arguments can be combined.
schema "endpoint" {
port: int @validate(min = 1, max = 65535)
env: string @validate(one_of = ["development", "staging", "production"])
slug: string @validate(pattern = "^[a-z0-9-]+$")
timeout: int @validate(min = 1, max = 300, custom_msg = "timeout must be between 1 and 300 seconds")
}
| Argument | Applies to | Description |
|---|---|---|
min | int, float | Minimum value (inclusive) |
max | int, float | Maximum value (inclusive) |
pattern | string | Regular expression the value must fully match |
one_of | string, int | Value must be one of the given options |
custom_msg | any | Custom message emitted on constraint violation |
@doc(text)
Attaches a documentation string to any declaration. Used by tooling and the language server to provide hover documentation.
schema "service" {
port: int @required @doc("The TCP port this service listens on.")
env: string @default("production") @doc("Deployment environment name.")
}
service "api" @doc("Main API service for the frontend.") {
port = 8080
}
@example
Embeds a usage example directly inside a decorator_schema or schema declaration. Used by documentation generators and IDE tooling.
decorator_schema "rate_limit" {
target = [attribute]
requests: int
window_ms: int @default(1000)
@example {
calls_per_second = 100 @rate_limit(requests = 100, window_ms = 1000)
}
}
@allow(rule)
Suppresses a specific warning on a let binding or attribute. Use this when a warning is expected and intentional.
let _unused = compute_value() @allow("unused_binding")
service "api" {
legacy_flag = true @allow("deprecated_field")
}
The rule argument is a string identifying the warning to suppress.
@id_pattern(glob)
Enforces a naming convention on block IDs for a schema. Any block whose ID does not match the glob pattern produces a validation error (E077).
schema "service" @id_pattern("svc-*") {
port: int
}
service "svc-api" { port = 8080 } // valid
service "api" { port = 8080 } // error E077: ID does not match "svc-*"
@ref(schema)
Applied to an identifier field in a schema. Requires that the field’s value matches the ID of an existing block of the named type.
schema "deployment" {
service_id: string @ref("service")
}
service "api" { port = 8080 }
deployment "d1" {
service_id = "api" // valid: service "api" exists
}
deployment "d2" {
service_id = "missing" // error E076: no service "missing" found
}
@partial_requires(fields)
Declares that a partial block expects certain fields to be present after merging. This documents and enforces merge dependencies.
partial service @partial_requires(["port", "region"]) {
env = "production"
replicas = 1
}
If a block that includes this partial does not provide the listed fields either directly or through another partial, a validation error is raised.
@merge_order(n)
Sets an explicit integer priority for partial merge ordering. Partials with lower n are merged first. Without this decorator, merge order follows declaration order.
partial service @merge_order(1) {
env = "production"
}
partial service @merge_order(2) {
env = "staging" // this wins because it merges later
}
@child(kind, min=N, max=N, max_depth=N)
Constrains how many children of a specific kind a block may have. The kind argument is the first positional string; min, max, and max_depth are optional named integer arguments.
@child("endpoint", min=1, max=10)
@child("config", max=1)
schema "server" {
host: string
}
Each @child entry automatically adds its kind to the schema’s allowed children set (as if it were also listed in @children). This means @child("endpoint") with no bounds is equivalent to including "endpoint" in a @children list.
Use max_depth for self-nesting blocks:
@child("menu", max_depth=3)
schema "menu" {
label: string
}
| Error | Condition |
|---|---|
| E097 | Child count below minimum |
| E098 | Child count above maximum |
| E099 | Self-nesting exceeds max_depth |
@tagged(field)
Names the discriminator field for tagged variant schemas. Used together with variant blocks inside the schema body.
@tagged("style")
schema "api" {
style: string
version: string @optional
variant "rest" {
base_path: string
}
variant "graphql" {
schema_path: string @optional
}
}
When a block’s tag field matches a variant’s value, that variant’s fields and containment constraints are also validated. When no variant matches, only the common fields are checked. See Schemas — Tagged Variant Schemas for full details.
@children(kinds)
Restricts which child blocks and tables may appear inside blocks of a given schema. The argument is a list of allowed block kind names and table identifiers.
@children(["endpoint", "middleware", "table:user_row"])
schema "service" {
name: string
}
service "api" {
endpoint health { path = "/health" } // allowed
middleware auth { priority = 1 } // allowed
table users : user_row { | "Alice" | } // allowed (table:user_row)
// logger { level = "info" } // ERROR E095: not in children list
}
Special names in the children list:
| Entry | Meaning |
|---|---|
"table" | Allows anonymous tables (no schema ref) |
"table:X" | Allows tables with schema_ref = X |
An empty list @children([]) forbids all child blocks and tables, making the schema a leaf:
@children([])
schema "leaf_node" {
value: string
}
You can also constrain what appears at the document root by defining a schema named "_root":
@children(["service", "config"])
schema "_root" {}
service main { port = 8080 } // allowed
database primary { host = "db" } // ERROR E095: not in _root children list
@parent(kinds)
Restricts where a block may appear. The argument is a list of allowed parent block kinds. Use "_root" to allow the block at the document root.
@parent(["service", "_root"])
schema "endpoint" {
path: string
}
service "api" {
endpoint health { path = "/health" } // allowed: parent is "service"
}
endpoint standalone { path = "/ping" } // allowed: parent is _root
If a block appears inside a parent not in its @parent list, error E096 is emitted.
@symbol_set(name)
Constrains a symbol-typed schema field so that only members of the named symbol set are accepted. The argument is the name of a symbol_set declaration.
symbol_set http_method {
:GET
:POST
:PUT
:DELETE
}
schema "endpoint" {
method: symbol @symbol_set("http_method")
path: string
}
endpoint list_users {
method = :GET // valid: :GET is in http_method
path = "/users"
}
endpoint bad {
method = :PATCH // error E100: :PATCH is not in symbol_set "http_method"
path = "/items"
}
Use the special set name "all" to accept any symbol value without restricting to a specific set:
schema "tag" {
kind: symbol @symbol_set("all")
}
| Error | Condition |
|---|---|
| E100 | Symbol value not in the declared symbol set |
| E101 | Referenced symbol set does not exist |
@inline(N)
Maps the Nth positional inline argument (0-based) to a named schema field. Without @inline, inline args are collected into a synthetic _args list. With @inline, they map to proper named attributes.
schema "server" {
port: int @inline(0)
env: string @inline(1)
host: string
}
server web 8080 "prod" {
host = "localhost"
}
// Evaluates to: { port: 8080, env: "prod", host: "localhost" }
Any positional args not covered by an @inline mapping remain in _args. See Blocks — Inline Arguments for full details.
@text
Marks a schema field as the text content target for text block syntax. The field must be named content and have type string. A schema may have at most one @text field.
When a schema has a @text field, blocks of that type can use text block syntax — a heredoc or string literal in place of a { body }:
schema "readme" {
content: string @text
}
// Heredoc syntax
readme my-doc <<EOF
# Hello World
This is the content of the readme.
EOF
// String literal syntax
readme short-doc "Simple one-line content"
// String interpolation works too
let name = "World"
readme greeting "Hello ${name}!"
The text content is assigned to the content field:
{
"my-doc": { "content": "# Hello World\nThis is the content of the readme.\n" }
}
| Error | Condition |
|---|---|
| E093 | Block uses text block syntax but its schema has no @text field |
| E094 | @text field validation errors (wrong name or type) |
@merge_strategy(strategy)
Controls how attribute conflicts are resolved during partial merges. See Partial Declarations for details.
Constraining table placement
To constrain where tables may appear, define a virtual schema with the "table" or "table:X" name:
# Tables with schema_ref "user_row" may only appear inside "data" blocks
@parent(["data"])
schema "table:user_row" {}
# Anonymous tables may only appear at the root
@parent(["_root"])
schema "table" {}
Combined constraints
Both @children and @parent are checked independently. If both are violated on the same item, both E095 and E096 are emitted. If neither decorator is present on a schema, nesting is unrestricted (backwards compatible).
Decorator Schemas
Decorator schemas let you define custom decorators with typed parameters, target restrictions, and constraints. Once defined, WCL validates every use of the decorator against its schema — checking argument types, required parameters, and applicable targets.
Syntax
decorator_schema "name" {
target = [block, attribute, table, schema]
param_name: type
param_name: type @optional
param_name: type @default(value)
}
The target field is a list of one or more of: block, attribute, table, schema. It controls where the decorator may legally appear.
Example: Custom @rate_limit Decorator
decorator_schema "rate_limit" {
target = [attribute, block]
requests: int
window_ms: int @default(1000)
burst: int @optional
}
This decorator can now be used on attributes and blocks:
service "api" {
calls_per_second = 100 @rate_limit(requests = 100, window_ms = 500)
upload_endpoint = "/upload" @rate_limit(requests = 10)
}
Applying @rate_limit to a schema field or table would produce a validation error because schema and table are not listed in target.
Parameters
Each parameter is declared as a field with a name, type, and optional decorators.
Required Parameters
Parameters without @optional or @default must be supplied at every call site:
decorator_schema "retry" {
target = [block]
max_attempts: int
backoff_ms: int
}
service "api" @retry(max_attempts = 3, backoff_ms = 200) { ... }
// error: missing required parameter backoff_ms would be caught if omitted
Optional Parameters
decorator_schema "cache" {
target = [attribute, block]
ttl_ms: int
key: string @optional
}
Parameters with Defaults
decorator_schema "timeout" {
target = [block, attribute]
seconds: int
on_timeout: string @default("error")
}
Positional Parameter Mapping
The first non-optional parameter of a decorator schema is the positional parameter. When the decorator is called with a single bare argument (no key =), the value is mapped to this parameter:
decorator_schema "doc" {
target = [block, attribute, schema]
text: string
}
Both forms below are equivalent:
port = 8080 @doc("The service port")
port = 8080 @doc(text = "The service port")
Constraints
Use @constraint on the decorator_schema block itself to express relationships between parameters:
decorator_schema "validate_range" @constraint(requires = ["min", "max"]) {
target = [attribute, schema]
min: int @optional
max: int @optional
}
Available constraint kinds:
| Kind | Description |
|---|---|
any_of | At least one of the listed parameters must be provided |
all_of | All listed parameters must be provided together |
one_of | Exactly one of the listed parameters must be provided |
requires | If this decorator is present, the listed params are required |
decorator_schema "alert" @constraint(any_of = ["email", "pagerduty", "slack"]) {
target = [block]
email: string @optional
pagerduty: string @optional
slack: string @optional
}
Validation
When a decorator is used in a document, WCL validates:
- Name match — A
decorator_schemawith the decorator’s name must exist. - Valid target — The decorated item’s kind must be listed in
target. - Required parameters — All non-optional, non-default parameters must be supplied.
- Type checking — Each argument’s value must match the declared parameter type.
- Constraints — Any
@constraintconditions are checked against the supplied arguments.
Errors from decorator schema validation are included in accumulative error reporting alongside schema validation errors.
Embedded Examples with @example
You can embed a usage example directly in a decorator_schema using the @example decorator on the body:
decorator_schema "rate_limit" {
target = [attribute, block]
requests: int
window_ms: int @default(1000)
@example {
service "api" @rate_limit(requests = 500, window_ms = 1000) {
port = 8080
}
}
}
The @example body is not evaluated as live configuration; it is stored as documentation metadata.
Full Example
decorator_schema "slo" {
target = [block]
availability: float
latency_p99: int @optional
error_budget: float @default(0.001)
@example {
service "payments" @slo(availability = 0.999, latency_p99 = 200) {
port = 443
}
}
}
service "payments" @slo(availability = 0.999, latency_p99 = 200) {
port = 443
}
service "internal-tools" @slo(availability = 0.99) {
port = 8080
}
WCL will validate that every @slo use targets a block, provides availability, and that availability and error_budget are floats.
Macros
Macros are WCL’s code reuse mechanism. They let you define named templates that expand into blocks and attributes at the point of use — eliminating repetition without sacrificing explicitness in the final resolved document.
WCL has two distinct macro types with different invocation styles and capabilities:
Function Macros
Function macros are reusable templates invoked at statement level, like a function call. They expand into one or more blocks or attributes in place.
macro service_endpoint(name, port, env = "production") {
service name {
port = port
env = env
region = "us-east-1"
}
}
service_endpoint("api", 8080)
service_endpoint("worker", 9090, env = "staging")
See Function Macros for full syntax and examples.
Attribute Macros
Attribute macros are invoked as decorators on a block and transform that block. They can inject child content, set or remove attributes, and apply conditional changes based on the block’s own properties.
macro @with_monitoring(alert_channel = "ops") {
inject {
metrics_port = 9100
health_path = "/healthz"
}
set {
monitoring_channel = alert_channel
}
}
service "api" @with_monitoring(alert_channel = "sre") {
port = 8080
}
See Attribute Macros for full syntax and examples.
Parameters
Both macro types support typed parameters with optional defaults:
macro deploy(name: string, replicas: int = 1, env: string = "production") {
deployment name {
replicas = replicas
env = env
}
}
Type annotations are optional. When provided, WCL checks argument types at expansion time.
Hygiene
Variables defined inside a macro with let are scoped to the macro definition site and do not leak into the caller’s scope:
macro make_service(name, port) {
let internal_host = "internal." + name + ".svc"
service name {
port = port
host = internal_host
}
}
make_service("api", 8080)
// internal_host is NOT visible here
This prevents macros from accidentally shadowing or polluting the surrounding scope.
Composition
Macros can call other macros. A function macro can invoke another function macro; an attribute macro can invoke function macros from within its body:
macro base_service(name, port) {
service name {
port = port
region = "us-east-1"
}
}
macro web_service(name, port, domain) {
base_service(name, port)
dns_record name {
cname = domain
}
}
web_service("api", 8080, "api.example.com")
No Recursion
Direct and indirect recursion in macros is detected and rejected at expansion time. WCL does not support self-referential macro expansion.
macro bad(n) {
bad(n) // error: recursive macro call detected
}
Expansion Depth Limit
Macro expansion has a maximum depth of 64. Chains of macro calls that exceed this depth produce an expansion error. This prevents runaway expansion from deeply nested composition.
Further Reading
- Function Macros — definition syntax, parameters, invocation, examples
- Attribute Macros — transform operations, the
selfreference, examples
Function Macros
Function macros are named templates that expand into blocks, attributes, or other declarations at statement level. They are the primary way to eliminate repetitive block definitions in WCL.
Definition
macro name(param1, param2, param3 = default_value) {
// body: blocks, attributes, let bindings, other macro calls
}
The macro keyword is followed by the macro name, a parameter list, and a body enclosed in { }.
Parameters
Parameters are positional by default. Each parameter may optionally have:
- A type annotation:
name: string - A default value:
name = "default"orname: string = "default"
Parameters with defaults are optional at the call site. Parameters without defaults are required.
macro service_endpoint(
name: string,
port: int,
region: string = "us-east-1",
env: string = "production",
replicas: int = 1
) {
service name {
port = port
region = region
env = env
replicas = replicas
}
}
Invocation
Function macros are called at statement level — the same positions where you can write a block or let binding. They are not valid as expression values.
service_endpoint("api", 8080)
service_endpoint("worker", 9090, region = "eu-west-1")
service_endpoint("batch", 7070, env = "staging", replicas = 4)
Arguments can be positional, named, or mixed. Named arguments may appear in any order and can follow positional ones:
service_endpoint("api", 8080, env = "staging", replicas = 2)
Body
The body of a function macro can contain:
- Block declarations — the most common use case
- Attribute assignments — when expanding into attribute context
letbindings — scoped to the macro, not visible outside- Other macro calls — for composition
macro health_check(service_name, path = "/healthz", interval_s = 30) {
let check_id = service_name + "-health"
monitor check_id {
target = service_name
path = path
interval = interval_s
timeout = 5
}
alert check_id + "-alert" {
monitor = check_id
channel = "ops"
}
}
health_check("api")
health_check("worker", path = "/health", interval_s = 60)
This expands to two monitor blocks and two alert blocks.
Hygiene
let bindings defined inside a macro are scoped to the macro. They are resolved at the definition site, not the call site. This means:
- The macro cannot accidentally read variables from the caller’s scope (unless passed as arguments).
- The macro’s internal variables do not leak into the caller’s scope.
let prefix = "global"
macro make_db(name) {
let prefix = "db" // shadows outer "prefix" inside this macro only
let full_name = prefix + "-" + name
database full_name {
host = "db.internal"
}
}
make_db("primary") // expands to database "db-primary" { ... }
// "prefix" here is still "global" — the macro did not mutate it
Composition
Function macros can call other function macros. This lets you build complex templates from simpler ones:
macro base_service(name, port) {
service name {
port = port
region = "us-east-1"
env = "production"
}
}
macro web_service(name, port, domain, tls = true) {
base_service(name, port)
dns_record name {
cname = domain
tls = tls
}
health_check(name)
}
macro health_check(name) {
monitor name + "-check" {
target = name
path = "/healthz"
}
}
web_service("api", 8080, "api.example.com")
web_service("dashboard", 3000, "dash.example.com", tls = false)
Full Example
macro service_endpoint(
name: string,
port: int,
env: string = "production",
replicas: int = 1,
region: string = "us-east-1"
) {
service name {
port = port
env = env
replicas = replicas
region = region
}
}
macro health_check(name, path = "/healthz", interval_s = 30) {
monitor name + "-health" {
target = name
path = path
interval = interval_s
}
}
macro monitored_service(name, port, env = "production") {
service_endpoint(name, port, env = env)
health_check(name)
}
monitored_service("api", 8080)
monitored_service("worker", 9090, env = "staging")
monitored_service("batch", 7070, env = "development")
This expands to three service blocks and three monitor blocks, each correctly parameterized.
Attribute Macros
Attribute macros are invoked as decorators on a block and transform the block in place. Rather than generating new top-level content, they modify the block they are attached to — injecting child content, setting or removing attributes, and applying conditional changes.
Definition
macro @name(param1, param2 = default) {
// transform operations
}
The macro keyword is followed by @name (with the leading @) to indicate this is an attribute macro, then a parameter list and a body.
Invocation
Attribute macros are called by placing them as a decorator on a block declaration:
service "api" @with_monitoring(alert_channel = "sre") {
port = 8080
}
The macro receives the block as its implicit self target and executes its transform operations against it.
Transform Operations
inject
Adds child content to the block. All declarations inside inject are merged into the block’s body:
macro @with_defaults() {
inject {
region = "us-east-1"
env = "production"
replicas = 1
}
}
service "api" @with_defaults() {
port = 8080
}
// result: service "api" { port = 8080, region = "us-east-1", env = "production", replicas = 1 }
Injected values do not overwrite attributes already present on the block. Use set for that.
set
Sets or overwrites attributes on the block. Unlike inject, set will replace an existing value:
macro @force_tls() {
set {
tls = true
protocol = "https"
}
}
service "api" @force_tls() {
port = 8080
protocol = "http" // will be overwritten to "https"
}
remove [targets]
Removes attributes, child blocks, or tables from the block. Each target in the list uses a different syntax to specify what to remove:
| Syntax | Removes |
|---|---|
name | Attribute with that name |
kind#id | Child block of kind with inline ID id |
kind#* | All child blocks of kind |
kind[n] | The nth child block of kind (0-based) |
table#id | Table with inline ID id |
table#* | All tables |
table[n] | The nth table (0-based) |
macro @strip_debug() {
remove [debug_port, verbose_logging, trace_id]
}
service "api" @strip_debug() {
port = 8080
debug_port = 9999
verbose_logging = true
}
// result: service "api" { port = 8080 }
Removing child blocks and tables:
macro @secure() {
remove [endpoint#debug, table#metrics]
}
@secure()
service main {
port = 8080
endpoint health { path = "/health" }
endpoint debug { path = "/debug" }
table metrics {
key : string
| "requests" |
}
}
// result: endpoint debug and table metrics are removed
Wildcard and index removal:
macro @clean() {
remove [endpoint#*, table[0]]
}
when condition
Applies a set of transform operations only when condition is true. The condition is a WCL boolean expression and may reference self properties:
macro @environment_defaults(env) {
when env == "production" {
set {
replicas = 3
tls = true
}
}
when env == "development" {
set {
replicas = 1
tls = false
}
}
inject {
env = env
}
}
service "api" @environment_defaults(env = "production") {
port = 8080
}
update selector
Applies transform directives to child blocks or row operations to tables. The selector identifies which child or table to target.
Updating child blocks
macro @secure() {
update endpoint#health {
set { tls = true }
}
update endpoint { // targets ALL endpoint children
inject { auth = true }
}
update endpoint[0] { // targets first endpoint by index
set { primary = true }
}
}
The body of a block update contains the same directives as the top-level macro body (inject, set, remove, when, update).
Updating tables (row operations)
When the selector targets a table (table#id or table[n]), the body contains table directives instead of transform directives:
| Directive | Effect |
|---|---|
inject_rows { | val | ... | } | Append rows to the table |
remove_rows where <expr> | Remove rows where condition is true |
update_rows where <expr> { set { col = val } } | Update cells in matching rows |
clear_rows | Remove all data rows (columns preserved) |
macro @filter_guests() {
update table#users {
remove_rows where role == "guest"
inject_rows {
| "admin" | "admin" |
}
}
}
@filter_guests()
service main {
table users {
name : string
role : string
| "alice" | "admin" |
| "bob" | "guest" |
}
}
// result: bob/guest row removed, admin/admin row added
Row conditions reference column names directly. Only literal comparisons are supported at macro expansion time (==, !=, >, <, >=, <=, &&, ||).
Composition with when
update directives can be nested inside when blocks for conditional transforms:
macro @env_config(env) {
when env == "production" {
update endpoint#health {
set { tls = true }
}
update table#config {
remove_rows where key == "debug"
}
}
}
The self Reference
Inside an attribute macro body, self refers to the block the macro is applied to. It exposes the block’s properties as readable values for use in conditions and injected content.
| Expression | Returns |
|---|---|
self.name | The block’s type name (e.g. "service") |
self.kind | The block’s kind string |
self.id | The block’s ID label (e.g. "api") |
self.attr(name) | The value of the named attribute |
self.has(name) | true if the attribute exists on the block |
self.args | List of inline argument values on the block |
self.decorators | List of decorator names applied to the block |
self is only available in attribute macros. It is not defined in function macro bodies.
Examples Using self
Conditional injection based on an existing attribute:
macro @with_monitoring(alert_channel = "ops") {
inject {
metrics_port = 9100
health_path = "/healthz"
}
set {
monitoring_channel = alert_channel
}
when self.has("debug_port") {
inject {
debug_monitoring = true
}
}
}
Using self.id in injected values:
macro @with_log_config() {
inject {
log_prefix = self.name + "/" + self.id
}
}
service "api" @with_log_config() {
port = 8080
}
// result: service "api" { port = 8080, log_prefix = "service/api" }
Branching on block type:
macro @common_tags(team) {
set {
team = team
}
when self.name == "service" {
inject {
service_mesh = true
}
}
when self.name == "job" {
inject {
retry_policy = "exponential"
}
}
}
service "api" @common_tags(team = "platform") {
port = 8080
}
job "nightly-backup" @common_tags(team = "data") {
schedule = "0 2 * * *"
}
Full Example: @with_monitoring
macro @with_monitoring(
alert_channel: string = "ops",
metrics_port: int = 9100,
health_path: string = "/healthz"
) {
inject {
metrics_port = metrics_port
health_path = health_path
}
set {
monitoring_channel = alert_channel
}
when self.has("env") {
when self.attr("env") == "production" {
set {
alert_severity = "critical"
}
}
}
}
service "api" @with_monitoring(alert_channel = "sre") {
port = 8080
env = "production"
}
service "internal-tools" @with_monitoring() {
port = 3000
env = "staging"
}
After expansion, service "api" will have metrics_port = 9100, health_path = "/healthz", monitoring_channel = "sre", and alert_severity = "critical" merged in. service "internal-tools" will have the same fields except alert_severity is absent (its env is not "production").
Data Tables
WCL’s table construct provides structured, typed tabular data inside your configuration. Tables are first-class values: they can be validated, queried, and deserialized just like any other block.
Basic Syntax
table id {
column_name : type
another_col : type
| value1 | value2 |
| value3 | value4 |
}
The block contains two sections in order: column declarations followed by rows. Column declarations must appear before any row.
Column Declarations
Each column is declared as name : type. The supported types are the same primitive types used elsewhere in WCL (string, int, float, bool).
Columns accept the following decorators:
| Decorator | Purpose |
|---|---|
@validate(expr) | Constraint expression applied to every cell in this column |
@doc("text") | Human-readable description of the column |
@sensitive | Marks column values as sensitive (redacted in output) |
@default(value) | Fallback value when a row omits this column |
table user_roles {
username : string @doc("Login name")
role : string @validate(one_of(["admin", "viewer", "editor"]))
max_items : int @default(100)
api_key : string @sensitive
| "alice" | "admin" | 500 | "key-abc" |
| "bob" | "viewer" | | "key-xyz" |
}
Row Syntax
Rows are written as pipe-delimited expressions:
| expr1 | expr2 | expr3 |
Each cell is a full WCL expression, so you can reference variables, call built-in functions, and perform arithmetic:
let base_port = 8000
table services {
name : string
port : int
| "auth" | base_port + 1 |
| "gateway" | base_port + 2 |
| "metrics" | base_port + 3 |
}
The number of values in every row must exactly match the number of declared columns. A mismatch is a parse error.
Each cell value is type-checked against its column’s declared type. A type mismatch produces a validation error.
Inline IDs
Tables require an inline ID:
table perms_main {
role : string
resource : string
action : string
allow : bool
| "admin" | "users" | "delete" | true |
| "viewer" | "users" | "read" | true |
| "viewer" | "users" | "write" | false |
}
The ID perms_main can then be used in @ref decorators and query selectors such as table#perms_main.
Schema Reference
You can apply an existing schema to a table instead of declaring columns inline. This is useful when multiple tables share the same structure.
Colon syntax
schema "user_row" {
name : string
age : int
}
table users : user_row {
| "Alice" | 30 |
| "Bob" | 25 |
}
Decorator syntax
@schema("user_row")
table users {
| "Alice" | 30 |
| "Bob" | 25 |
}
When a schema is applied, you cannot also declare inline columns. Doing so produces error E092.
Loading Tables from CSV
Use import_table("path.csv") to load a CSV file as a table value.
let acl = import_table("./acl.csv")
Options
import_table accepts named arguments for fine-grained control:
| Parameter | Type | Default | Description |
|---|---|---|---|
separator | string | "," | Field separator character |
headers | bool | true | Whether the first row contains column headers |
columns | list | — | Explicit column names (overrides headers) |
# Tab-separated (legacy positional syntax still works)
let tsv = import_table("./data.tsv", "\t")
# Named separator argument
let tsv = import_table("./data.tsv", separator="\t")
# No header row — columns are named "0", "1", ...
let raw = import_table("./data.csv", headers=false)
# No header row with explicit column names
let data = import_table("./data.csv", headers=false, columns=["name", "age"])
Table assignment syntax
You can populate a table directly from a CSV file using assignment syntax:
table users = import_table("data.csv")
Combine with a schema reference to validate imported data:
table users : user_row = import_table("data.csv")
The first row of the CSV is treated as the column header by default. All cell values are imported as strings; apply a schema if you need typed validation.
import_table follows the same path rules as import: relative paths only, resolved from the importing file, jailed to the project root.
Let-bound Tables
You can assign an import_table call to a let binding. The table data becomes a list of row maps that can be used in expressions, for loops, and function calls:
let data = import_table("users.csv")
// Iterate over rows
for row in data {
service ${row.name}-svc {
role = row.role
}
}
Let-bound tables are not included in the serialized output (like all let bindings), but their data is available for use in expressions and control flow.
Table Manipulation Functions
WCL provides built-in functions for working with table data (lists of row maps):
find(table, key, value)
Returns the first row where key equals value, or null if not found:
let data = import_table("users.csv")
let admin = find(data, "role", "admin")
admin_name = admin.name
filter(table, predicate)
Returns all rows matching the predicate (a lambda):
let data = import_table("users.csv")
let admins = filter(data, (r) => r.role == "admin")
admin_count = len(admins)
insert_row(table, row)
Returns a new list with the given row map appended:
let data = import_table("users.csv")
let extended = insert_row(data, {name = "charlie", role = "viewer"})
These functions work on any list of maps, not just import_table results.
Evaluation
Tables are evaluated into a list of row maps. Each row becomes a map from column name to cell value. For example:
table users {
name : string
age : int
| "alice" | 25 |
| "bob" | 30 |
}
evaluates to a value equivalent to:
[
{"name": "alice", "age": 25},
{"name": "bob", "age": 30}
]
Cell expressions are fully evaluated, so references, function calls, and arithmetic all work:
let base = 100
table config {
key : string
value : int
| "port" | base + 80 |
| "debug" | 0 |
}
// config evaluates to [{"key": "port", "value": 180}, {"key": "debug", "value": 0}]
Tables inside blocks appear in the block’s attributes map, keyed by the table’s inline ID. Tables at the top level appear as top-level values.
Deserialization
When deserializing a document into Rust types, a table maps to Vec<T> where T is a struct whose fields correspond to the column names:
#![allow(unused)]
fn main() {
#[derive(Deserialize)]
struct PermRow {
role: String,
resource: String,
action: String,
allow: bool,
}
let rows: Vec<PermRow> = doc.get_table("permissions")?;
}
Querying Tables
Use query() to filter rows. The selector table#id targets a specific table; filters then match on column values:
validation "no-admin-deletes-on-prod" {
let dangerous = query(table#permissions | .role == "viewer" | .allow == true | .action == "delete")
check = len(dangerous) == 0
message = "viewers must not have delete permission"
}
The full query pipeline syntax is described in the Query Engine chapter. Key points for tables:
.col == val— exact match on a column value.col =~ "pattern"— regex match.col > val— numeric comparisonhas(.col)— column exists and is non-null- Append
| .colat the end to project a single column as a list of values
Example: Permissions Table
table perms_main {
role : string @doc("Subject role")
resource : string @doc("Target resource type")
action : string @validate(one_of(["read", "write", "delete"]))
allow : bool @doc("Whether the action is permitted")
| "admin" | "users" | "read" | true |
| "admin" | "users" | "write" | true |
| "admin" | "users" | "delete" | true |
| "editor" | "posts" | "read" | true |
| "editor" | "posts" | "write" | true |
| "editor" | "posts" | "delete" | false |
| "viewer" | "posts" | "read" | true |
| "viewer" | "posts" | "write" | false |
| "viewer" | "posts" | "delete" | false |
}
Fetch all actions allowed for editor:
let editor_allowed = query(table#perms_main | .role == "editor" | .allow == true | .action)
Count total denied rules:
let denied_count = len(query(table#perms_main | .allow == false))
Imports
Imports let you split a WCL configuration across multiple files and compose them at load time.
Basic Syntax
import "./relative/path.wcl"
import is a top-level statement only — it cannot appear inside a block or expression. The path must be a string literal.
What Gets Merged
When a file is imported, the following items from the imported file are merged into the importing scope:
- Blocks (services, configs, etc.)
- Attributes defined at the top level
- Schemas and decorator schemas
- Macro definitions
- Tables
- Validation blocks
- Exported variables (
letbindings declared withexport)
Private bindings are not merged. A plain let binding is file-private and is never visible to the importing file. Use export let to make a variable available across the import boundary:
// shared.wcl
export let environment = "production"
export let base_port = 9000
// main.wcl
import "./shared.wcl"
service "api" {
env = environment // "production"
port = base_port + 1 // 9001
}
Library Imports
In addition to relative path imports, WCL supports well-known library imports using angle-bracket syntax:
import <myapp.wcl>
Library files are searched in these directories (in order):
- User library:
$XDG_DATA_HOME/wcl/lib/(default:~/.local/share/wcl/lib/) - System library: each dir in
$XDG_DATA_DIRS+/wcl/lib/(default:/usr/local/share/wcl/lib/,/usr/share/wcl/lib/)
Library imports skip the jail check since they are intentionally located outside the project root. Relative imports inside library files (e.g., import "./helper.wcl") also skip the jail check, so library files can freely compose helper files within their own directory. All other rules (import-once, depth limit, recursive resolution) still apply.
Library files can contain schemas, declare stubs for host-registered functions, and any other WCL content:
// ~/.local/share/wcl/lib/myapp.wcl
schema "server_config" {
port: int
host: string @optional
}
declare my_custom_fn(input: string) -> string
You can add custom library search paths using the --lib-path CLI flag or the lib_paths option in ParseOptions. Use --no-default-lib-paths to disable the default XDG/system paths entirely.
See the Libraries guide for how to create and manage library files.
Path Rules
- Relative paths only (for quoted imports). Absolute paths and URLs are not accepted.
- Paths are resolved relative to the file containing the
importstatement. - All resolved paths must remain inside the project root directory. Attempts to escape via
../../../are rejected. - Symlinks that point outside the root are not followed.
- Remote imports (HTTP/HTTPS) are not supported.
Import-Once Semantics
WCL deduplicates imports by canonical path. If two files both import a third file, that third file is processed exactly once and its contents are merged a single time. Import graphs are therefore safe regardless of how many paths lead to the same file.
Circular Imports
Circular import chains are detected and produce a clear error rather than looping infinitely. Restructure your files to break the cycle, typically by extracting shared definitions into a dedicated file that neither file in the cycle imports.
Depth Limit
The default maximum import depth is 32. This prevents runaway chains in generated or adversarial inputs. The limit can be raised programmatically when constructing the pipeline options, but the default is appropriate for all normal configurations.
Merge Conflicts
Different item kinds have different conflict rules:
| Item | Conflict behavior |
|---|---|
| Blocks with distinct IDs | Merged without conflict |
| Blocks sharing an ID | Requires partial — see Partial Declarations |
| Duplicate schema name | Error (E001) |
| Duplicate decorator schema name | Error |
| Duplicate top-level attribute | Error |
| Macros with the same name | Error |
If you need to compose fragments of the same block across files, declare every fragment as partial and let the merge phase assemble them.
Importing Non-WCL Files
Raw Text: import_raw
import_raw("path") reads an arbitrary file and returns its contents as a string value. This is useful for embedding certificates, SQL, or other text assets:
service "tls-frontend" {
cert = import_raw("./certs/server.pem")
key = import_raw("./certs/server.key")
}
The same path rules apply: relative only, jailed to the root.
CSV Data: import_table
import_table("path.csv") loads a CSV file as a table value:
let acl_rows = import_table("./acl.csv")
let tsv_rows = import_table("./data.tsv", "\t")
Named arguments provide fine-grained control:
# Custom separator
let tsv = import_table("./data.tsv", separator="\t")
# Skip the header row
let raw = import_table("./data.csv", headers=false)
# Explicit column names
let data = import_table("./data.csv", headers=false, columns=["name", "age"])
| Parameter | Type | Default | Description |
|---|---|---|---|
separator | string | "," | Field separator character |
headers | bool | true | Whether the first row contains column headers |
columns | list | — | Explicit column names (overrides headers) |
Tables can be populated directly from CSV using assignment syntax:
table users : user_row = import_table("data.csv")
See the Data Tables chapter for full details.
Security: allow_imports
When processing untrusted WCL input you can disable all file-loading operations by setting the allow_imports pipeline option to false. With this option disabled, any import, import_raw, or import_table statement produces an error rather than reading from disk. This is recommended for any context where the WCL source is not fully trusted.
#![allow(unused)]
fn main() {
let opts = PipelineOptions {
allow_imports: false,
..Default::default()
};
let doc = Document::from_str_with_options(src, opts)?;
}
Example: Multi-File Layout
config/
main.wcl
shared/
constants.wcl
schemas.wcl
services/
auth.wcl
gateway.wcl
// main.wcl
import "./shared/constants.wcl"
import "./shared/schemas.wcl"
import "./services/auth.wcl"
import "./services/gateway.wcl"
// shared/constants.wcl
export let region = "us-east-1"
export let log_level = "info"
export let base_domain = "example.internal"
// services/auth.wcl
import "../shared/constants.wcl"
service svc-auth "auth-service" {
region = region
domain = "auth." + base_domain
log = log_level
}
Because constants.wcl is imported by both main.wcl and auth.wcl, it is evaluated once. The export let bindings are available wherever the file is imported.
Glob Imports
A glob pattern can be used in place of a literal path to import multiple files at once:
import "./schemas/*.wcl"
import "./modules/**/*.wcl"
*matches any file name within a single directory.**matches any number of directory segments (recursive).- Matched files are processed in alphabetical order by resolved path, ensuring deterministic merge order regardless of filesystem traversal order.
- If the pattern matches no files, error E016 is reported. Use an optional import (see below) to suppress this.
Each matched file is subject to the same path rules as a regular import: relative paths only, jailed to the project root, import-once deduplication, and depth limit enforcement.
// Import every schema defined under the schemas/ directory
import "./schemas/**/*.wcl"
// Import all service definitions from a flat directory
import "./services/*.wcl"
Optional Imports
Prefix the import keyword with ? to make the import silently succeed when the target does not exist:
import? "./local-overrides.wcl"
If local-overrides.wcl is absent the statement is a no-op. If the file exists, it is imported normally.
Optional imports compose with glob patterns:
import? "./env/*.wcl"
When glob and optional are combined, a pattern that matches no files is not an error — the statement is silently skipped. This is useful for environment-specific overlay directories that may not exist in every deployment.
Security errors are always reported. A path that would escape the project root, exceed the depth limit, or violate another security constraint produces an error even when ? is present. Optional only suppresses “file not found” and “no glob matches”; it does not suppress policy violations.
WCL Libraries
WCL supports well-known library files that can be shared across projects. Libraries are installed in standard XDG directories and imported using angle-bracket syntax.
Importing a Library
import <myapp.wcl>
This searches for myapp.wcl in the library search paths:
- User library:
$XDG_DATA_HOME/wcl/lib/(default:~/.local/share/wcl/lib/) - System library: dirs in
$XDG_DATA_DIRS+/wcl/lib/(default:/usr/local/share/wcl/lib/,/usr/share/wcl/lib/)
The first match is used. Library imports skip the project root jail check. Relative imports inside library files also skip the jail check, so libraries can freely import helper files in their own directory.
Custom Search Paths
You can prepend extra directories to the library search path using the CLI --lib-path flag (repeatable). These are searched before the default XDG/system paths:
wcl eval main.wcl --lib-path ./my-libs --lib-path /opt/wcl-libs
To disable the default paths entirely (only use --lib-path directories):
wcl eval main.wcl --lib-path ./my-libs --no-default-lib-paths
Programmatically, set lib_paths and no_default_lib_paths on ParseOptions.
Library File Contents
A library file is a normal WCL file that can contain:
- Schemas – type definitions for blocks
- Function declarations (
declare) – stubs for host-registered functions - Decorator schemas – custom decorator definitions
- Exported variables – shared constants
Example library file:
// ~/.local/share/wcl/lib/myapp.wcl
schema "server_config" {
port: int
host: string @optional
@validate(min = 1, max = 65535)
port: int
}
declare transform(input: string) -> string
declare validate_config(config: any) -> bool
Function Declarations
The declare keyword creates a function stub:
declare fn_name(param1: type1, param2: type2) -> return_type
Declarations tell the LSP about functions that will be provided by the host application at runtime. If a declared function is called but not registered, a helpful error is produced.
Creating Library Files
Create .wcl files manually and place them in the user library directory (~/.local/share/wcl/lib/ on Linux/macOS). For example:
mkdir -p ~/.local/share/wcl/lib
cat > ~/.local/share/wcl/lib/myapp.wcl << 'EOF'
schema "config" {
port: int
host: string @optional
}
declare transform(input: string) -> string
EOF
To list installed libraries from Rust:
#![allow(unused)]
fn main() {
for lib in wcl::library::list_libraries().unwrap() {
println!("{}", lib.display());
}
}
LSP Support
The LSP automatically provides:
- Completions for functions declared in imported libraries
- Signature help with parameter names and types from
declarestatements - Go-to-definition for library imports (jumps to the library file)
- Diagnostics if a declared function is not registered by the host
Partial Declarations
Partials allow a single logical block to be defined across multiple fragments — in the same file or spread across imported files. All fragments are merged into one complete block before evaluation proceeds.
Basic Syntax
partial service svc-api "api-service" {
port = 8080
}
partial service svc-api "api-service" {
env = "production"
}
After merging, the result is equivalent to:
service svc-api "api-service" {
port = 8080
env = "production"
}
Rules:
- Every fragment sharing a type and ID must be marked
partial. A non-partial block with the same type/ID as a partial fragment is an error. - All fragments must have identical inline IDs.
- The merged block is placed at the position of the first fragment encountered.
Attribute Merge Rules
By default, WCL uses strict conflict mode: if two fragments both define the same attribute, the merge fails with an error. This is the safest default — it catches accidental duplication.
partial service svc-api "api-service" { port = 8080 }
partial service svc-api "api-service" { port = 9090 } // Error: duplicate attribute 'port'
Last-wins mode relaxes this: the last fragment’s value for a given attribute wins. Enable it by applying @merge_strategy("last_wins") to the fragments, or by configuring ConflictMode::LastWins programmatically:
partial service svc-api "api-service" @merge_strategy("last_wins") {
port = 8080
timeout = 30
}
partial service svc-api "api-service" @merge_strategy("last_wins") {
port = 9090 // overrides the first fragment's value
}
Child Block Merging
Child blocks nested inside partial fragments are merged recursively:
- Child blocks with an ID are matched by (type, ID) and merged by the same rules.
- Child blocks without an ID are appended in order.
partial service svc-api "api-service" {
endpoint ep-health "/health" {
method = "GET"
}
}
partial service svc-api "api-service" {
endpoint ep-health "/health" {
timeout = 5
}
endpoint "/metrics" {
method = "GET"
}
}
Merged result has ep-health with both method and timeout, plus the anonymous /metrics endpoint appended.
Decorator Merging
Decorators from all fragments are combined. Duplicate decorator names are deduplicated — if the same decorator appears on multiple fragments, it is included once. The order follows fragment order.
partial service svc-api "api-service" @doc("Main API") { ... }
partial service svc-api "api-service" @validate { ... }
// Merged block has both @doc and @validate
Explicit Ordering: @merge_order
By default, fragments are merged in the order they are encountered (depth-first import order, then source order). Use @merge_order(n) to assign an explicit integer priority. Lower numbers sort first.
partial service svc-api "api-service" @merge_order(10) {
port = 8080
}
partial service svc-api "api-service" @merge_order(1) {
// This fragment is applied first despite appearing second
log_level = "debug"
}
This is useful when the merge order would otherwise depend on import order, which can be fragile.
Documenting Dependencies: @partial_requires
@partial_requires(["field1", "field2"]) is a documentation decorator that declares which attributes a fragment expects another fragment to supply. It has no effect on merge behaviour but is surfaced by the LSP and validation tooling to help detect incomplete configurations:
partial service svc-api "api-service" @partial_requires(["port", "env"]) {
// This fragment uses port and env but does not define them.
// Another fragment must supply them.
healthcheck_url = "http://localhost:" + str(port) + "/health"
}
If the merged block does not contain all fields listed in @partial_requires, a warning is emitted.
Cross-File Composition
The most common use of partials is assembling a block from fragments in separate files:
services/
api-base.wcl — core attributes
api-tls.wcl — TLS configuration
api-observability.wcl — metrics and tracing
// api-base.wcl
partial service svc-api "api-service" {
port = 8443
env = "production"
workers = 4
}
// api-tls.wcl
partial service svc-api "api-service" {
tls {
cert = import_raw("./certs/server.pem")
key = import_raw("./certs/server.key")
}
}
// api-observability.wcl
partial service svc-api "api-service" {
metrics {
path = "/metrics"
port = 9090
}
tracing {
endpoint = "http://jaeger:14268/api/traces"
sampling = 0.1
}
}
// main.wcl
import "./services/api-base.wcl"
import "./services/api-tls.wcl"
import "./services/api-observability.wcl"
The final document contains a single, fully merged svc-api block.
ConflictMode Reference
| Mode | Behaviour on duplicate attribute |
|---|---|
ConflictMode::Strict (default) | Error — duplicate attributes are forbidden |
ConflictMode::LastWins | The value from the last fragment in merge order is kept |
Conflict mode is applied per-merge operation. When using the Rust API, pass ConflictMode to the merge phase options. When using decorators, @merge_strategy("last_wins") activates last-wins mode on that block.
Query Engine
The query engine lets you search, filter, and project over all blocks in a document using a concise pipeline syntax. Queries can appear anywhere an expression is expected — inside let bindings, attribute values, and validation blocks.
Pipeline Syntax
query(selector | filter | filter | ... | projection?)
A pipeline is a selector followed by zero or more filter steps and an optional trailing projection. Each step is separated by |.
Selectors
Selectors determine the initial set of blocks the pipeline operates on.
| Selector | Matches |
|---|---|
service | All blocks of kind service |
service#svc-auth | The block of kind service with ID svc-auth |
config.server.listener | A nested attribute path — the listener attribute inside server inside config |
..health_check | Recursive descent — all blocks named health_check at any depth |
. | The document root |
* | All top-level blocks |
table#id | The table with the given ID |
Examples
// All services
let all_services = query(service)
// A specific service by ID
let auth = query(service#svc-auth)
// All tables
let all_tables = query(table.*)
// Every health_check block anywhere in the document
let checks = query(..health_check)
Filters
Filters narrow the result set. Multiple filters are AND-combined — every filter in the chain must match.
Attribute Comparison
query(service | .port > 8080)
query(service | .env == "production")
query(service | .env != "staging")
query(service | .workers >= 4)
Regex Match
query(service | .name =~ "^api-")
query(config | .region =~ "us-.*")
Existence Check: has
// Blocks that have an 'auth' attribute
query(service | has(.auth))
// Blocks that carry a specific decorator
query(service | has(@sensitive))
query(service | has(@validate))
Decorator Argument Filtering
Filter on the arguments of a decorator:
// Services whose @validate decorator has min > 0
query(service | @validate.min > 0)
// Blocks with @retry where attempts >= 3
query(service | @retry.attempts >= 3)
Compound Filters
Chain multiple filters to AND them together:
query(service | .env == "production" | .port > 8000 | has(@tls))
Projections
A projection extracts a single attribute value from each matched block, producing a list of values instead of a list of block references. The projection step must be the last step in the pipeline.
// List of port numbers from all production services
let prod_ports = query(service | .env == "production" | .port)
// List of names from all services
let names = query(service | .name)
Without a projection, query() returns a list(block_ref). With a projection, it returns a list(value).
Result Types
| Pipeline | Return type |
|---|---|
query(service) | list(block_ref) |
query(service | .port) | list(value) (the projected attribute values) |
Aggregate Functions
Aggregate functions operate on query results:
| Function | Description |
|---|---|
len(query(...)) | Number of matched items |
sum(query(... | .attr)) | Sum of numeric projection |
avg(query(... | .attr)) | Average of numeric projection |
distinct(query(... | .attr)) | Deduplicated list of projected values |
let service_count = len(query(service))
let total_workers = sum(query(service | .workers))
let avg_port = avg(query(service | .port))
let all_envs = distinct(query(service | .env))
Using Queries in Validation
validation "all-prod-services-have-tls" {
let prod = query(service | .env == "production")
check = every(prod, s => has(s.tls))
message = "all production services must have TLS configured"
}
validation "unique-ports" {
let ports = query(service | .port)
check = len(ports) == len(distinct(ports))
message = "each service must use a unique port"
}
CLI Usage
The wcl query subcommand runs a pipeline against a file from the command line:
wcl query file.wcl 'service'
wcl query file.wcl 'service | .env == "prod" | .port'
wcl query file.wcl 'service | has(@tls)'
Output Formats
| Flag | Output |
|---|---|
--format json | JSON array |
--format text | One item per line (default) |
--format csv | Comma-separated values |
--format wcl | WCL block representation |
Additional Flags
| Flag | Description |
|---|---|
--count | Print the number of results instead of the results themselves |
--recursive | Descend into imported files |
Examples
# Count production services
wcl query --count infra.wcl 'service | .env == "production"'
# Export port list as JSON
wcl query --format json infra.wcl 'service | .env == "prod" | .port'
# Find all services without TLS
wcl query infra.wcl 'service | !has(@tls)'
# Recursively find all health_check blocks across imports
wcl query --recursive infra.wcl '..health_check'
Validation Blocks
Validation blocks let you encode invariants about your configuration that go beyond per-field type and constraint checking. They run after all other pipeline phases — including imports, macro expansion, partial merging, and schema validation — so they can reason about the fully assembled document.
Syntax
validation "name" {
let intermediate = some_expr
check = bool_expression
message = "human-readable error text"
}
checkmust evaluate to abool. If it isfalse, a validation error (code E080) is emitted with the text frommessage.messageis a string expression and may use interpolation.letbindings inside the block are local — they are computed beforecheckis evaluated and are not visible outside the block.- Multiple
letbindings are allowed; they are evaluated in order.
Execution Order
Validation blocks run in phase 11, after:
- Parsing
- Macro collection
- Import resolution
- Macro expansion
- Control flow expansion
- Partial merge
- Scope construction and evaluation
- Decorator validation
- Schema validation
- ID uniqueness checks
- Document validation ← validation blocks run here
This means query() calls inside a validation block see the complete, merged, evaluated document.
Warnings
Decorate a validation block with @warning to downgrade a failure from an error to a warning. The configuration is still accepted; the diagnostic is reported with warning severity.
validation "prefer-tls" @warning {
let non_tls = query(service | !has(@tls))
check = len(non_tls) == 0
message = "some services are not using TLS (non-fatal)"
}
Documentation
@doc adds a human-readable description surfaced by the LSP and tooling:
validation "unique-ports" @doc("Each service must listen on a distinct port") {
let ports = query(service | .port)
check = len(ports) == len(distinct(ports))
message = "duplicate port assignments detected"
}
Local Bindings for Intermediate Computation
Use let bindings to break complex checks into readable steps:
validation "prod-services-have-health-checks" {
let prod = query(service | .env == "production")
let prod_with_hc = query(service | .env == "production" | has(.health_check))
check = len(prod) == len(prod_with_hc)
message = "every production service must define a health_check block"
}
Common Patterns
Unique Ports
validation "unique-service-ports" {
let ports = query(service | .port)
check = len(ports) == len(distinct(ports))
message = "each service must use a unique port number"
}
TLS Coverage
validation "prod-tls-required" {
let prod_no_tls = query(service | .env == "production" | !has(@tls))
check = len(prod_no_tls) == 0
message = "all production services must be decorated with @tls"
}
Service Existence Checks
validation "gateway-references-valid-services" {
let gateway_upstreams = query(gateway | .upstream)
let service_names = query(service | .name)
check = every(gateway_upstreams, u => contains(service_names, u))
message = "gateway.upstream references a service that does not exist"
}
Universal Quantifier with every
validation "all-services-have-env" {
let services = query(service)
check = every(services, s => has(s.env))
message = "every service must declare an env attribute"
}
Cross-Table Integrity
validation "permission-roles-are-defined" {
let perm_roles = distinct(query(table."permissions" | .role))
let defined_roles = query(table."roles" | .name)
check = every(perm_roles, r => contains(defined_roles, r))
message = "permissions table references a role not present in the roles table"
}
Counting and Thresholds
validation "minimum-replica-count" @warning {
let under_replicated = query(service | .env == "production" | .replicas < 2)
check = len(under_replicated) == 0
message = "production services should run at least 2 replicas"
}
Multiple Validation Blocks
You may define as many validation blocks as you need. Each is evaluated independently; all failures are collected and reported together.
validation "unique-ports" { ... }
validation "prod-tls-required" { ... }
validation "all-services-have-env" { ... }
Error Code
Validation block failures are reported under error code E080. Schema-level constraint failures (from @validate in a schema) use E073–E075. Both kinds appear in the same diagnostic output.
CLI Overview
The wcl CLI provides tools for working with WCL documents — parsing, validating, evaluating, formatting, querying, and converting configuration files.
Installation
cargo install wcl
Usage
wcl <subcommand> [options] [args]
Subcommands
| Subcommand | Description |
|---|---|
validate | Parse and validate a WCL document through all pipeline phases |
eval | Evaluate a document and print the resolved output |
fmt | Format a WCL document |
query | Execute a query expression against a document |
inspect | Inspect the AST, HIR, scopes, or dependency graph |
convert | Convert between WCL and other formats (JSON, YAML, TOML) |
set | Set an attribute value by path |
add | Add a new block to a document |
remove | Remove a block or attribute by path |
lsp | Start the WCL language server |
Help
wcl --help
wcl <subcommand> --help
Global Flags
| Flag | Description |
|---|---|
--help, -h | Print help information |
--version, -V | Print version information |
Exit Codes
| Code | Meaning |
|---|---|
0 | Success |
1 | Validation or evaluation error |
2 | Usage / argument error |
wcl validate
Parse and validate a WCL document through all pipeline phases.
Usage
wcl validate <file> [options]
Options
| Flag | Description |
|---|---|
--strict | Treat warnings as errors |
--schema <file> | Load an additional external schema file |
--var KEY=VALUE | Set an external variable (may be repeated) |
Description
wcl validate runs the document through the full 11-phase pipeline:
- Parse
- Macro collection
- Import resolution
- Macro expansion
- Control flow expansion
- Partial merge
- Scope construction and evaluation
- Decorator validation
- Schema validation
- ID uniqueness
- Document validation
All diagnostics (errors and warnings) are printed to stderr. If any errors are produced, the command exits with a non-zero status code.
Exit Codes
| Code | Meaning |
|---|---|
0 | Document is valid |
1 | One or more errors (or warnings with --strict) |
2 | Argument error |
Examples
Validate a file:
wcl validate config.wcl
Validate strictly (warnings are errors):
wcl validate --strict config.wcl
Validate against an external schema:
wcl validate --schema schemas/service.wcl config.wcl
Validate with external variables:
wcl validate --var PORT=8080 config.wcl
Diagnostic Output
error[E070]: missing required field `port`
--> config.wcl:12:3
|
12 | service svc-api {
| ^^^^^^^^^^^^^^^ missing field `port`
|
= required by schema `ServiceSchema`
wcl eval
Evaluate a WCL document and print the fully resolved output.
Usage
wcl eval <file> [options]
Options
| Flag | Description |
|---|---|
--format <fmt> | Output format: json, yaml, or toml (default: json) |
--var KEY=VALUE | Set an external variable (may be repeated) |
Description
wcl eval runs the full evaluation pipeline and serializes the resulting document to the requested format. All macros are expanded, all expressions evaluated, all imports merged, and all partial blocks resolved before output is produced.
The output represents the final, fully-resolved state of the document — suitable for consumption by tools that do not understand WCL natively.
Examples
Evaluate and print as JSON (default):
wcl eval config.wcl
Evaluate and print as YAML:
wcl eval config.wcl --format yaml
Evaluate and print as TOML:
wcl eval config.wcl --format toml
Override variables:
wcl eval --var PORT=8080 --var DEBUG=true config.wcl
Pipe output to another tool:
wcl eval config.wcl | jq '.service'
Example Output
Given:
let base_port = 8000
service svc-api {
port = base_port + 80
host = "localhost"
}
Running wcl eval config.wcl produces:
{
"service": {
"svc-api": {
"port": 8080,
"host": "localhost"
}
}
}
wcl fmt
Format a WCL document according to the standard style.
Usage
wcl fmt <file> [options]
Options
| Flag | Description |
|---|---|
--write | Format the file in place instead of printing to stdout |
--check | Check whether the file is already formatted; exit non-zero if not |
Description
wcl fmt applies canonical formatting to a WCL document. By default, it prints the formatted output to stdout, leaving the source file unchanged.
The formatter preserves:
- All comments (line, block, and doc comments)
- Blank line grouping within blocks
- The logical structure and ordering of the document
The formatter normalises:
- Indentation (2 spaces per level)
- Spacing around operators and delimiters
- Trailing commas in lists and maps
- Consistent quote style for string literals
Exit Codes
| Code | Meaning |
|---|---|
0 | Success (or file is already formatted when using --check) |
1 | File would be reformatted (only with --check) |
2 | Argument or parse error |
Examples
Print formatted output to stdout:
wcl fmt config.wcl
Format file in place:
wcl fmt --write config.wcl
Check formatting in CI (no changes written):
wcl fmt --check config.wcl
Check all WCL files in a project:
find . -name '*.wcl' | xargs wcl fmt --check
wcl query
Execute a query expression against a WCL document.
Usage
wcl query <file> <query> [options]
wcl query --recursive <dir> <query> [options]
Options
| Flag | Description |
|---|---|
--format <fmt> | Output format: text, json, csv, or wcl (default: text) |
--count | Print the number of matching results instead of the results themselves |
--recursive | Search recursively across all .wcl files in a directory |
Description
wcl query evaluates a query pipeline against the resolved document and prints matching results. The query syntax is the same as the inline query(...) expression in WCL source.
A query pipeline is a selector followed by zero or more filters separated by |.
Selectors
| Syntax | Selects |
|---|---|
service | All blocks of type service |
service#svc-api | Block with type service and ID svc-api |
..service | All service blocks at any depth |
* | All top-level items |
. | The root document |
Filters
| Syntax | Meaning |
|---|---|
.port | Has attribute port |
.port == 8080 | Attribute port equals 8080 |
.name =~ "api.*" | Attribute name matches regex |
has(.port) | Has attribute port |
has(@deprecated) | Has decorator @deprecated |
@tag.env == "prod" | Decorator @tag has named arg env equal to "prod" |
Examples
Select all services:
wcl query config.wcl 'service'
Select a specific block by ID:
wcl query config.wcl 'service#svc-api'
Filter by attribute value:
wcl query config.wcl 'service | .port == 8080'
Filter by regex match:
wcl query config.wcl 'service | .name =~ ".*-api"'
Count matching blocks:
wcl query config.wcl 'service' --count
Output as JSON:
wcl query config.wcl 'service | .port > 1024' --format json
Query recursively across a directory:
wcl query --recursive ./configs 'service | has(@deprecated)'
Filter by decorator argument:
wcl query config.wcl 'service | @tag.env == "prod"'
wcl inspect
Inspect the internal representation of a WCL document.
Usage
wcl inspect <file> [options]
Options
| Flag | Description |
|---|---|
--ast | Print the raw Abstract Syntax Tree |
--hir | Print the resolved High-level Intermediate Representation |
--scopes | Print the scope tree |
--deps | Print the dependency graph |
Description
wcl inspect exposes the internal structure that WCL builds when processing a document. It is primarily useful for debugging WCL documents, understanding how the pipeline transforms source, and for tooling authors.
Multiple flags may be combined. If no flag is given, --hir is used by default.
--ast
Prints the raw parse tree produced by the parser, before any macro expansion, import resolution, or evaluation. Spans and token positions are included.
--hir
Prints the fully resolved document after all pipeline phases complete. This corresponds to the same data that wcl eval serializes, but in WCL’s internal tree format rather than a target serialization format.
--scopes
Prints the scope tree showing each scope, the names it defines, and their resolved values. Useful for understanding name resolution and spotting shadowing.
--deps
Prints the dependency graph between attributes and let bindings. Shows which names each expression depends on, and the topological evaluation order.
Examples
Inspect the AST of a file:
wcl inspect config.wcl --ast
Inspect the resolved HIR:
wcl inspect config.wcl --hir
View the scope tree:
wcl inspect config.wcl --scopes
View the dependency graph:
wcl inspect config.wcl --deps
Combine multiple views:
wcl inspect config.wcl --scopes --deps
wcl convert
Convert between WCL and other configuration formats.
Usage
wcl convert <file> --to <format>
wcl convert <file> --from <format>
Options
| Flag | Description |
|---|---|
--to <format> | Convert WCL to the target format: json, yaml, or toml |
--from <format> | Convert the source format to WCL: json |
Description
wcl convert handles bidirectional conversion between WCL and common configuration formats.
WCL to another format (--to): the document is fully evaluated through the pipeline and the resolved output is serialized. The result is equivalent to wcl eval --format <format>.
Another format to WCL (--from): the input file is parsed as the given format and a WCL document is generated that represents the same data. The generated WCL uses plain attribute assignments and blocks — no macros, expressions, or schemas are introduced.
Output is written to stdout. Redirect to a file to save the result.
Supported Formats
| Format | To WCL (--from) | From WCL (--to) |
|---|---|---|
| JSON | Yes | Yes |
| YAML | No | Yes |
| TOML | No | Yes |
Examples
Convert WCL to JSON:
wcl convert config.wcl --to json
Convert WCL to YAML:
wcl convert config.wcl --to yaml
Convert WCL to TOML:
wcl convert config.wcl --to toml
Convert JSON to WCL:
wcl convert config.json --from json
Save the result to a file:
wcl convert config.wcl --to json > config.json
wcl convert config.json --from json > config.wcl
wcl set
Set an attribute value by path in a WCL document.
Usage
wcl set <file> <path> <value>
Description
wcl set edits a WCL document by locating the attribute identified by <path> and replacing its value with <value>. The modified document is written back to the file in place. Formatting and comments in the rest of the file are preserved.
The value is parsed as a WCL expression, so string values must be quoted.
Path Syntax
Paths use dot notation to navigate through the document structure:
| Path | Meaning |
|---|---|
service#svc-api.port | Attribute port inside block service with ID svc-api |
database#primary.host | Attribute host inside block database with ID primary |
service#svc-api.tls.enabled | Nested attribute access |
The type#id portion identifies the block. The .attribute portion names the attribute within that block.
Examples
Set an integer value:
wcl set config.wcl service#svc-api.port 9090
Set a string value:
wcl set config.wcl service#svc-api.host '"0.0.0.0"'
Set a boolean value:
wcl set config.wcl service#svc-api.tls.enabled true
Set a list value:
wcl set config.wcl service#svc-api.tags '["prod", "api"]'
Notes
- The path must refer to an existing attribute. To add a new attribute, use
wcl addor edit the file directly. - String values must include their quotes: use
'"value"'in shell to pass a quoted string. - The file is modified in place; no backup is created automatically.
wcl add
Add a new block to a WCL document.
Usage
wcl add <file> <block_spec> [options]
Options
| Flag | Description |
|---|---|
--file-auto | Automatically determine the best placement for the new block |
Description
wcl add appends a new empty block to the specified WCL document. The block is inserted and the file is written back in place. You can then use wcl set to populate attributes, or edit the file directly.
The <block_spec> argument is a quoted string describing the block type and optional ID, matching WCL block declaration syntax.
Block Spec Syntax
| Spec | Result |
|---|---|
"service svc-new" | service svc-new { } |
"database primary" | database primary { } |
"config" | config { } (no ID) |
Examples
Add a new service block:
wcl add config.wcl "service svc-new"
Add a database block with auto-placement:
wcl add config.wcl "database replica" --file-auto
Add a block with a label (quoted string in spec):
wcl add config.wcl 'endpoint svc-api "v2"'
Notes
- By default, the new block is appended at the end of the document.
- With
--file-auto, the CLI attempts to place the new block near existing blocks of the same type. - The new block is empty; use
wcl setor direct editing to populate its attributes. - The block spec must be quoted in the shell to avoid splitting on spaces.
wcl remove
Remove a block or attribute from a WCL document.
Usage
wcl remove <file> <path>
Description
wcl remove deletes a block or attribute identified by <path> from the given document. The file is modified in place. The surrounding content, including comments and blank lines adjacent to the removed item, is cleaned up.
Path Syntax
The path syntax determines whether a block or an attribute is removed.
| Path | What is removed |
|---|---|
service#svc-old | The entire service block with ID svc-old |
service#svc-api.debug | The debug attribute inside service#svc-api |
database#primary.port | The port attribute inside database#primary |
A path with no trailing .attribute removes the whole block. A path with a trailing .attribute removes only that attribute from the block.
Examples
Remove an entire block:
wcl remove config.wcl service#svc-old
Remove a single attribute from a block:
wcl remove config.wcl service#svc-api.debug
Remove a nested attribute:
wcl remove config.wcl database#primary.tls.cert_path
Notes
- Removing a block removes all of its contents, including nested blocks and attributes.
- If the path does not exist in the document, an error is reported and the file is not modified.
- The file is modified in place; no backup is created automatically.
- To remove a partial block, all partial declarations sharing the same type and ID must be addressed individually.
wcl lsp
Start the WCL Language Server Protocol (LSP) server.
Usage
wcl lsp [options]
Options
| Flag | Description |
|---|---|
--tcp <addr> | Listen on a TCP address instead of stdio (e.g. 127.0.0.1:9257) |
Description
wcl lsp starts the WCL language server, which implements the Language Server Protocol. Editors and IDEs connect to it to receive language intelligence features for WCL documents.
By default, the server communicates over stdio, which is the standard transport for most editor integrations (VS Code, Neovim, Helix, etc.). The --tcp flag enables a TCP transport useful for debugging or unconventional editor setups.
Features
| Feature | Description |
|---|---|
| Diagnostics | Real-time errors and warnings as you type, sourced from the full pipeline |
| Hover | Type information and documentation for identifiers, blocks, and schema fields |
| Go to definition | Jump to where a name, macro, schema, or imported identifier is defined |
| Completions | Context-aware completions for identifiers, attribute names, block types, and decorators |
| Semantic tokens | Syntax highlighting based on semantic meaning (not just token type) |
| Signature help | Parameter hints when calling macros or functions |
| Find references | Locate all uses of a definition across the document |
| Formatting | Full-document formatting via wcl fmt |
Editor Integration
VS Code
Install the wcl-vscode extension. It starts wcl lsp automatically.
Neovim (nvim-lspconfig)
require('lspconfig').wcl.setup({
cmd = { 'wcl', 'lsp' },
filetypes = { 'wcl' },
root_dir = require('lspconfig.util').root_pattern('.git', '*.wcl'),
})
Helix
Add to languages.toml:
[[language]]
name = "wcl"
language-servers = ["wcl-lsp"]
[language-server.wcl-lsp]
command = "wcl"
args = ["lsp"]
Examples
Start in stdio mode (used by editors automatically):
wcl lsp
Start on a TCP port for debugging:
wcl lsp --tcp 127.0.0.1:9257
Syntax Reference
This page is a concise formal summary of WCL syntax. The complete EBNF grammar is in the EBNF Grammar appendix.
Document Structure
A WCL document is a sequence of top-level items. Order matters for readability but not for evaluation (declarations are dependency-ordered automatically).
import "other.wcl"
let base = 8000
service svc-api {
port = base + 80
}
Top-level items:
importdeclarationsexportdeclarations- Attributes (
name = expr) - Blocks (
type [id] [inline-args...] { body }) - Tables
- Let bindings (
let name = expr) - Macro definitions
- Macro calls
- For loops
- Conditionals
- Validation blocks
- Schemas and decorator schemas
- Symbol set declarations
- Comments
Attributes
port = 8080
host = "localhost"
enabled = true
ratio = 1.5
nothing = null
tag = #prod
An attribute is an identifier followed by = and an expression.
Blocks
service svc-api {
port = 8080
}
service svc-api 8080 "production" {
port = 9090
}
Syntax: [decorators] [partial] type [id] [inline-args...] { body }
type— bare identifier (the block kind)id— identifier literal (may contain hyphens); used for unique identificationinline-args— zero or more positional expressions (int, float, string, bool, null, list) mapped to named fields via@inline(N)in a schema, or collected into_args
Let Bindings
let max_conns = 100
let dsn = "postgres://localhost/${db_name}"
Let bindings are module-scoped. They are not included in the serialized output.
Imports
import "base.wcl"
import "schemas/service.wcl"
Imports must appear at the top of the file. The imported document’s contents are merged into the current document before evaluation.
Control Flow
For Loops
let ports = [8080, 8081, 8082]
for i, port in ports {
service "svc-${i}" {
port = port
}
}
Conditionals
if env == "prod" {
replicas = 3
} else if env == "staging" {
replicas = 2
} else {
replicas = 1
}
Tables
table routes {
path: string
method: string
handler: string
| "/health" | "GET" | "health_handler" |
| "/users" | "GET" | "list_users" |
| "/users" | "POST" | "create_user" |
}
Tables declare typed columns followed by row data using |-delimited syntax.
Schemas
schema "ServiceSchema" {
port : int
host : string
@required
name : string
@min(1) @max(65535)
port : int
}
Schemas define the expected shape and constraints for blocks.
Decorator Schemas
decorator_schema "tag" {
target = [block, attribute]
env : string
tier : string
}
Decorator schemas define the structure of custom decorators.
Decorators
@deprecated
@tag(env = "prod", tier = "critical")
@partial_requires(["port", "host"])
service svc-api {
port = 8080
}
Decorators appear immediately before the item they annotate. They accept positional and named arguments.
Macros
Function Macro Definition
macro service_defaults(port: int, host: string = "localhost") {
port = port
host = host
enabled = true
}
Attribute Macro Definition
macro @with_logging(level: string = "info") {
inject {
log_level = level
}
}
Macro Call
service svc-api {
service_defaults(8080)
}
Expressions
| Category | Examples |
|---|---|
| Literals | 42, 3.14, "hello", true, false, null, #my-id |
| Arithmetic | a + b, a - b, a * b, a / b, a % b |
| Comparison | a == b, a != b, a < b, a >= b, a =~ "pattern" |
| Logical | a && b, a || b, !a |
| Ternary | cond ? then_val : else_val |
| String interpolation | "host: ${host}:${port}" |
| List | [1, 2, 3] |
| Map | { key: "value" } |
| Field access | obj.field |
| Index | list[0], map["key"] |
| Query | query(service | .port > 1024) |
| Ref | ref(svc-api) |
| Lambda | x => x * 2, (a, b) => a + b |
Comments
// Line comment
/*
Block comment
(nestable)
*/
/// Doc comment — attached to the next item
service svc-api {
port = 8080
}
Type System
WCL has a rich static type system used in schema definitions, macro parameter declarations, table column declarations, and type expressions.
Primitive Types
| Type | Keyword | Description | Example literals |
|---|---|---|---|
| String | string | UTF-8 text, supports interpolation and heredocs | "hello", "port: ${p}" |
| Integer | int | 64-bit signed integer (decimal, hex, octal, binary) | 42, 0xFF, 0o77, 0b1010 |
| Float | float | 64-bit IEEE 754 double | 3.14, 1.0e-3 |
| Boolean | bool | true or false | true, false |
| Null | null | Absence of a value | null |
| Identifier | identifier | An identifier literal (may contain hyphens) | #svc-api, #my-resource |
Composite Types
| Type | Syntax | Description | Example |
|---|---|---|---|
| List | list(T) | Ordered sequence of values of type T | [1, 2, 3] |
| Map | map(K, V) | Key-value pairs; keys are type K, values type V | { a: 1, b: 2 } |
| Set | set(T) | Unordered collection of unique values of type T | [1, 2, 3] |
Special Types
| Type | Syntax | Description |
|---|---|---|
| Any | any | Accepts a value of any type; opts out of type checking |
| Union | union(T1, T2, ...) | A value that may be any one of the listed types |
| Ref | ref("SchemaName") | A reference to a block conforming to the named schema |
| Function | — | First-class lambda values; not directly nameable in type expressions |
Type Expressions
Type expressions appear in schema fields, macro parameters, and table column declarations:
schema "Config" {
port : int
host : string
tags : list(string)
meta : map(string, any)
env : union(string, null)
service : ref("ServiceSchema")
}
macro connect(host: string, port: int = 5432) {
host = host
port = port
}
table routes {
path: string
method: string
active: bool
| "/api" | "GET" | true |
}
Type Coercion
WCL does not implicitly coerce between types. Type mismatches produce an error at evaluation time (E050) or schema validation time (E071). Explicit conversions are performed via built-in functions.
Exceptions:
- Integer literals are accepted where
floatis expected (widening is safe). nullis accepted for anyunion(T, null)type.anyaccepts all values without checking.
Serde Type Mapping
When serializing to JSON, YAML, or TOML, WCL types map as follows:
| WCL Type | JSON | YAML | TOML |
|---|---|---|---|
string | string | string | string |
int | number (integer) | int | integer |
float | number (float) | float | float |
bool | boolean | bool | boolean |
null | null | null | not representable (omitted) |
identifier | string (bare name) | string | string |
list(T) | array | sequence | array |
map(K, V) | object | mapping | table |
set(T) | array (deduplicated) | sequence | array |
ref(...) | resolved value | resolved value | resolved value |
any | native JSON value | native | native |
Block structure is serialized as nested objects keyed by block type, then by block ID (if present).
Expression Evaluation
This page describes how WCL evaluates expressions: operator precedence, type rules, short-circuit behavior, and error conditions.
Operator Precedence
Operators are listed from lowest to highest precedence:
| Level | Operators | Associativity |
|---|---|---|
| 1 | ?: (ternary) | Right |
| 2 | || (logical or) | Left |
| 3 | && (logical and) | Left |
| 4 | ==, != | Left |
| 5 | <, >, <=, >=, =~ | Left |
| 6 | +, - | Left |
| 7 | *, /, % | Left |
| 8 | ! (unary not), - (unary negation) | Right (prefix) |
| 9 | .field, [index], (call) (postfix) | Left |
Ternary Expression
condition ? then_value : else_value
The condition must evaluate to bool. Only the selected branch is evaluated.
Logical Operators
| Operator | Types | Result |
|---|---|---|
a || b | bool, bool | bool |
a && b | bool, bool | bool |
!a | bool | bool |
Short-circuit evaluation: || does not evaluate the right operand if the left is true. && does not evaluate the right operand if the left is false.
Equality Operators
| Operator | Types | Result |
|---|---|---|
a == b | any matching types | bool |
a != b | any matching types | bool |
Equality is deep structural equality for lists and maps. Comparing values of different types always returns false for == and true for != (no implicit coercion).
Comparison Operators
| Operator | Types | Result |
|---|---|---|
a < b | int, float, string | bool |
a > b | int, float, string | bool |
a <= b | int, float, string | bool |
a >= b | int, float, string | bool |
a =~ b | string, string | bool |
The =~ operator matches the left operand against the right operand as a regular expression (RE2 syntax). Returns true if there is any match.
Comparing across incompatible types (e.g., int with string) produces error E050.
Arithmetic Operators
| Operator | Types | Result | Notes |
|---|---|---|---|
a + b | int, int | int | |
a + b | float, float | float | |
a + b | string, string | string | Concatenation |
a + b | list, list | list | Concatenation |
a - b | int, int | int | |
a - b | float, float | float | |
a * b | int, int | int | |
a * b | float, float | float | |
a / b | int, int | int | Integer division; error E051 if b == 0 |
a / b | float, float | float | IEEE 754; b == 0.0 produces infinity |
a % b | int, int | int | Remainder; error E051 if b == 0 |
-a | int | int | Unary negation |
-a | float | float | Unary negation |
Field Access and Indexing
obj.field // access named field of a map or block
list[0] // index into a list (0-based)
map["key"] // index into a map by string key
Out-of-bounds list indexing produces error E054. Accessing a missing map key returns null.
Function Calls
value.to_string()
list.map(x => x * 2)
string.split(",")
Postfix call syntax is used for built-in functions and lambda application. Calling an unknown function produces error E052.
String Interpolation
let msg = "Hello, ${name}! Port is ${port + 1}."
Interpolated expressions (${...}) are evaluated and converted to their string representation before concatenation. Any expression type is allowed inside ${}.
Query Expressions
let services = query(service | .port > 1024)
Query expressions run the pipeline query engine against the current document scope and return a list of matching resolved values. Queries are evaluated after scope construction is complete.
Ref Expressions
let api = ref(svc-api)
A ref expression resolves to the block or value with the given identifier. If no matching identifier is found, error E053 is produced.
Lambda Expressions
let double = x => x * 2
let add = (a, b) => a + b
let clamp = (v, lo, hi) => v < lo ? lo : (v > hi ? hi : v)
Lambdas capture their lexical scope. They are first-class values and can be passed to built-in higher-order functions.
Error Conditions
| Code | Condition |
|---|---|
| E050 | Type mismatch in operator or function call |
| E051 | Division or modulo by zero |
| E052 | Call to unknown function |
| E053 | ref() target identifier not found |
| E054 | List index out of bounds |
| E040 | Reference to undefined name |
| E041 | Cyclic dependency between names |
Scoping Rules
This page describes how WCL constructs scopes, resolves names, and orders evaluation.
Scope Kinds
Module Scope
The top level of a WCL document forms the module scope. It contains:
- All top-level
letbindings - All top-level attributes
- All block declarations (accessible by type and ID)
- All imported names (merged from
importdirectives) - All exported names
Module scope is the root of the scope tree.
Block Scope
Each block (service, database, etc.) creates a child scope. Block scopes:
- Inherit all names from the enclosing module scope (or parent block scope)
- Define their own attributes and let bindings, which are local to the block
- Can reference names from any enclosing scope
Blocks can be nested. Inner blocks have access to all names in all enclosing scopes.
let base_port = 8000
service svc-api {
// base_port is visible here from module scope
port = base_port + 80
let path_prefix = "/api"
endpoint health {
// both base_port and path_prefix are visible here
path = path_prefix + "/health"
}
}
Macro Scope
When a macro is called, a new scope is created for the macro body. This scope:
- Contains the macro parameters bound to the call arguments
- Does not inherit from the call site scope
- Has read access to the module scope at the point of definition
Macro expansion happens before scope construction for the main document, so macro-generated items are treated as if they were written directly in the source.
Name Resolution Algorithm
When an identifier x is referenced in an expression:
- Search the current (innermost) scope for a binding named
x. - If not found, walk up to the parent scope and repeat.
- Continue until the module scope is reached.
- If still not found, produce error E040 (undefined reference).
First match wins. The search is purely lexical (static scoping).
Shadowing
A name in an inner scope may shadow a name with the same identifier in an outer scope. This is permitted but produces warning W001 to alert the author.
let port = 8080
service svc-api {
let port = 9090 // W001: shadows outer `port`
exposed_port = port // resolves to 9090
}
Dependency-Ordered Evaluation
Within a scope, WCL does not require declarations to appear in evaluation order. Instead:
- The evaluator builds a dependency graph by inspecting which names each expression references.
- The graph is topologically sorted.
- Expressions are evaluated in dependency order.
This means forward references are fully supported:
service svc-api {
base_url = "http://${host}:${port}"
port = 8080
host = "localhost"
}
base_url is evaluated after host and port regardless of their textual order.
If a cycle exists in the dependency graph, error E041 is produced for all names involved in the cycle.
Import Merging and Scope
import directives are resolved before scope construction. The imported document’s top-level items are merged into the current module scope. Imported names are visible throughout the entire importing document, including in items that textually precede the import declaration.
Name conflicts between an imported document and the importing document are resolved in favour of the importing document (local definitions win).
Export Visibility
export let name = expr and export name make names available to documents that import this file. Exported names are part of the module’s public interface.
Exports are only permitted at the top level. Exporting a name from inside a block produces error E036.
Duplicate export names produce error E034. Exporting an undefined name produces error E035.
Unused Variables
A let binding that is defined but never referenced produces warning W002. This warning is suppressed for exported names, since they may be consumed by other documents.
Evaluation Pipeline
WCL processes a document through eleven sequential phases. Each phase transforms or validates the document before passing it to the next. Understanding the pipeline is useful when interpreting error messages and when reasoning about what is possible at each stage.
Overview
Source text
│
▼
1. Parse → AST
│
▼
2. Macro collection → MacroRegistry
│
▼
3. Import resolution → merged AST
│
▼
4. Macro expansion → expanded AST
│
▼
5. Control flow → flattened AST
│
▼
6. Partial merge → unified AST
│
▼
7. Scope construction → ScopeArena + dependency graph
│
▼
8. Expression eval → resolved values
│
▼
9. Decorator valid. → (errors or ok)
│
▼
10. Schema validation → (errors or ok)
│
▼
11. Document valid. → final Document
Phase 1: Parse
Input: raw source text Output: Abstract Syntax Tree (AST)
The lexer tokenizes the source, and the parser produces an unambiguous AST. This phase handles:
- All syntactic constructs: blocks, attributes, let bindings, expressions, macros, schemas, tables, etc.
- Comments and trivia (preserved for formatting)
- Span information for every node (for diagnostics and LSP)
Errors here are E001 (syntax error), E002 (unexpected token), E003 (unterminated string).
Rationale: parsing first gives a clean representation to work with. No evaluation can occur until syntax is confirmed valid.
Phase 2: Macro Collection
Input: AST Output: MacroRegistry
All macro definitions (macro name(...)) are collected and registered before any expansion occurs. Both function macros and attribute macros are collected.
Rationale: macros must be fully registered before expansion so that call sites that appear before the definition in textual order can still be expanded. This enables any-order macro definitions, consistent with WCL’s general any-order philosophy.
Phase 3: Import Resolution
Input: AST with import directives
Output: merged AST
Each import "path" directive is resolved to a file, parsed, and its top-level items are merged into the importing document’s AST. Imports are processed recursively. The same file is not re-parsed if it has already been imported (import deduplication).
Errors: E010 (file not found), E011 (path escapes jail), E013 (remote import), E014 (max depth exceeded).
Rationale: imports are resolved before macro expansion so that macros imported from other files are available during expansion.
Phase 4: Macro Expansion
Input: merged AST + MacroRegistry Output: expanded AST (no macro calls remain)
Every macro call site is replaced with the expanded body of the macro, with parameters substituted. Attribute macros (@macro_name) are applied to their annotated items, injecting, setting, or removing attributes per their transform body.
Errors: E020 (undefined macro), E021 (recursive expansion), E022 (max depth exceeded), E023 (wrong macro kind), E024 (parameter type mismatch).
Rationale: macro expansion must happen before control flow so that macros can generate for loops and if blocks, and before scope construction so that generated items participate in normal scoping.
Phase 5: Control Flow Expansion
Input: expanded AST
Output: flattened AST (no for/if constructs remain)
for loops are unrolled: the loop body is repeated once per element in the iterable, with the loop variable substituted. if/else chains are evaluated and only the selected branch is retained.
Errors: E025 (non-list iterable), E026 (non-bool condition), E027 (invalid expanded identifier), E028 (max iteration count), E029 (max nesting depth).
Rationale: control flow must be resolved before partial merge and scope construction so that all generated blocks and attributes are known as concrete AST nodes before name resolution.
Phase 6: Partial Merge
Input: flattened AST Output: unified AST (partial blocks merged)
Blocks declared as partial with the same type and ID are merged into a single block. Attributes from each partial declaration are combined. The order of attributes follows declaration order across partials.
Errors: E030 (duplicate non-partial ID), E031 (attribute conflict), E032 (kind mismatch), E033 (mixed partial/non-partial). Warning W003 (label mismatch).
Rationale: partial merging happens after control flow (which can generate partial blocks) and before scope construction so that the merged block is the single entity that participates in scope and evaluation.
Phase 7: Scope Construction
Input: unified AST Output: ScopeArena (scope tree) + dependency graph
A scope tree is built: one module scope plus one child scope per block. For each scope, all defined names are recorded. A dependency graph is constructed by inspecting which names each expression references.
Errors: E040 (undefined reference), E041 (cyclic dependency), E034/E035/E036 (export errors). Warning W001 (shadowing), W002 (unused variable).
Rationale: scope construction is separated from evaluation so that the full dependency graph can be built before any expression is evaluated, enabling correct topological ordering.
Phase 8: Expression Evaluation
Input: ScopeArena + dependency graph Output: resolved values for all attributes and let bindings
Expressions are evaluated in topological order (dependencies before dependents) within each scope. Query expressions (query(...)) are evaluated against the partially-resolved document. Ref expressions (ref(id)) are resolved to their target values.
Errors: E050 (type error), E051 (division by zero), E052 (unknown function), E053 (ref not found), E054 (index out of bounds).
Rationale: topo-sorted evaluation ensures each expression is evaluated only after its dependencies are known, without requiring the author to declare them in order.
Phase 9: Decorator Validation
Input: resolved document + decorator schemas Output: (errors or ok)
Each decorator applied to a block, attribute, table, or schema is validated against its decorator schema (if one is registered). Required parameters are checked, types are verified, and constraint rules are applied.
Errors: E060 (unknown decorator), E061 (invalid target), E062 (missing required parameter), E063 (parameter type mismatch), E064 (constraint violation).
Rationale: decorator validation happens after full evaluation so that decorator argument values (which may be expressions) are fully resolved before checking.
Phase 10: Schema Validation
Input: resolved document + schema definitions Output: (errors or ok)
Every block that is annotated with a schema reference is validated against that schema. Field presence, types, and constraints (min, max, pattern, one_of, ref) are checked.
Errors: E070 (missing required field), E071 (type mismatch), E072 (unknown attribute in closed schema), E073 (constraint violation), E074 (ref target not found).
Rationale: schema validation is the final structural check. It happens after evaluation because schema constraints may involve evaluated expressions (e.g., computed field values).
Phase 11: Document Validation
Input: resolved document + validation blocks Output: final Document
Each validation block is executed: its check expression is evaluated in the context of the resolved document, and if it returns false, the message expression is evaluated and reported as an error (E080).
Rationale: document-level validation is the last phase because it can express cross-cutting invariants over the fully-evaluated document — things that cannot be expressed in per-block schemas alone.
EBNF Grammar
This is the complete EBNF grammar for WCL.
Notation:
=— rule definition|— alternation{ ... }— zero or more repetitions[ ... ]— zero or one (optional)( ... )— grouping"..."— literal terminalUPPER— named terminal
(* ===== Top-level ===== *)
document = trivia { doc_item } trivia ;
doc_item = import_decl | export_decl | body_item ;
body = { body_item } ;
body_item = attribute
| block
| table
| let_binding
| macro_def
| macro_call
| for_loop
| conditional
| validation
| schema
| decorator_schema
| symbol_set_decl
| declare_stmt
| comment ;
(* ===== Import Directives (top-level only) ===== *)
import_decl = "import" ["?"] (STRING_LIT | library_import) ;
library_import = "<" IDENT { "." IDENT } ".wcl" ">" ;
(* ===== Export Declarations (top-level only) ===== *)
export_decl = "export" "let" IDENT "=" expression
| "export" IDENT ;
(* ===== Attributes ===== *)
attribute = { decorator } IDENT "=" expression ;
(* ===== Blocks ===== *)
block = { decorator } [ "partial" ] IDENT [ IDENTIFIER_LIT ]
{ inline_arg } ( "{" body "}" | text_content ) ;
inline_arg = INT_LIT | FLOAT_LIT | STRING_LIT | BOOL_LIT | NULL_LIT
| symbol_lit | IDENT | list_literal ;
text_content = STRING_LIT | heredoc ;
(* ===== Let Bindings ===== *)
let_binding = { decorator } ["partial"] "let" IDENT "=" expression ;
(* ===== Control Flow ===== *)
for_loop = "for" IDENT [ "," IDENT ] "in" expression "{" body "}" ;
conditional = "if" expression "{" body "}" [ else_branch ] ;
else_branch = "else" ( conditional | "{" body "}" ) ;
(* ===== Tables ===== *)
table = { decorator } [ "partial" ] "table" IDENTIFIER_LIT
[ ":" IDENT ] ( "{" table_body "}" | "=" expression ) ;
table_body = { column_decl } { table_row } ;
column_decl = { decorator } IDENT ":" type_expr ;
table_row = "|" expression { "|" expression } "|" ;
(* ===== Schemas ===== *)
schema = { decorator } "schema" STRING_LIT "{" { schema_field | schema_variant } "}" ;
schema_variant = { decorator } "variant" STRING_LIT "{" { schema_field } "}" ;
schema_field = { decorator } IDENT "=" type_expr { decorator } ;
(* ===== Decorator Schemas ===== *)
decorator_schema = { decorator } "decorator_schema" STRING_LIT
"{" decorator_schema_body "}" ;
decorator_schema_body = "target" "=" "[" target_list "]" { schema_field } ;
target_list = target { "," target } ;
target = "block" | "attribute" | "table" | "schema" ;
(* ===== Symbol Sets ===== *)
symbol_set_decl = "symbol_set" IDENT "{" { symbol_set_member } "}" ;
symbol_set_member = symbol_lit [ "=" STRING_LIT ] ;
symbol_lit = ":" IDENT ;
(* ===== Decorators ===== *)
decorator = "@" IDENT [ "(" decorator_args ")" ] ;
decorator_args = positional_args [ "," named_args ]
| named_args ;
positional_args = expression { "," expression } ;
named_args = named_arg { "," named_arg } ;
named_arg = IDENT "=" expression ;
(* ===== Macros ===== *)
macro_def = { decorator } "macro" ( func_macro_def | attr_macro_def ) ;
func_macro_def = IDENT "(" param_list ")" "{" body "}" ;
attr_macro_def = "@" IDENT "(" param_list ")" "{" transform_body "}" ;
param_list = [ param { "," param } ] ;
param = IDENT [ ":" type_expr ] [ "=" expression ] ;
macro_call = IDENT "(" [ arg_list ] ")" ;
arg_list = expression { "," expression } [ "," named_args ] ;
(* ===== Transform Body (attribute macros) ===== *)
transform_body = { transform_directive } ;
transform_directive = inject_block | set_block | remove_block | when_block ;
inject_block = "inject" "{" body "}" ;
set_block = "set" "{" { attribute } "}" ;
remove_block = "remove" "[" ident_list "]" ;
ident_list = IDENT { "," IDENT } [ "," ] ;
when_block = "when" expression "{" { transform_directive } "}" ;
(* ===== Declare Statements ===== *)
declare_stmt = "declare" IDENT "(" [ declare_params ] ")" [ "->" type_expr ] ;
declare_params = declare_param { "," declare_param } ;
declare_param = IDENT ":" type_expr ;
(* ===== Validation ===== *)
validation = { decorator } "validation" STRING_LIT "{"
{ let_binding } "check" "=" expression
"message" "=" expression "}" ;
(* ===== Types ===== *)
type_expr = "string" | "int" | "float" | "bool" | "null"
| "identifier" | "symbol" | "any"
| "list" "(" type_expr ")"
| "map" "(" type_expr "," type_expr ")"
| "set" "(" type_expr ")"
| "ref" "(" STRING_LIT ")"
| "union" "(" type_expr { "," type_expr } ")" ;
(* ===== Expressions ===== *)
expression = ternary_expr ;
ternary_expr = or_expr [ "?" expression ":" expression ] ;
or_expr = and_expr { "||" and_expr } ;
and_expr = equality_expr { "&&" equality_expr } ;
equality_expr = comparison_expr { ( "==" | "!=" ) comparison_expr } ;
comparison_expr = additive_expr { ( "<" | ">" | "<=" | ">=" | "=~" )
additive_expr } ;
additive_expr = multiplicative_expr { ( "+" | "-" ) multiplicative_expr } ;
multiplicative_expr = unary_expr { ( "*" | "/" | "%" ) unary_expr } ;
unary_expr = ( "!" | "-" ) unary_expr | postfix_expr ;
postfix_expr = primary_expr { ( "." IDENT | "[" expression "]"
| "(" [ arg_list ] ")" ) } ;
primary_expr = INT_LIT | FLOAT_LIT | STRING_LIT | BOOL_LIT | NULL_LIT
| IDENTIFIER_LIT | IDENT | symbol_lit
| list_literal | map_literal
| "(" expression ")"
| query_expr
| import_util_expr
| ref_expr
| lambda_expr ;
(* ===== Special Expressions ===== *)
query_expr = "query" "(" pipeline ")" ;
pipeline = selector { "|" filter } ;
selector = [ ".." ] IDENT [ "#" IDENTIFIER_LIT ]
{ "." ( IDENT | STRING_LIT ) }
| "." | "*" ;
filter = "." IDENT [ ( "==" | "!=" | "<" | ">" | "<=" | ">="
| "=~" ) expression ]
| "has" "(" ( "." IDENT | "@" IDENT ) ")"
| "@" IDENT "." IDENT ( "==" | "!=" | "<" | ">" | "<="
| ">=" ) expression ;
import_util_expr = "import_table" "(" STRING_LIT { "," import_table_arg } ")"
| "import_raw" "(" STRING_LIT ")" ;
import_table_arg = STRING_LIT | IDENT "=" expression ;
ref_expr = "ref" "(" IDENTIFIER_LIT ")" ;
lambda_expr = lambda_params "=>" ( expression | block_expr ) ;
lambda_params = IDENT
| "(" [ IDENT { "," IDENT } ] ")" ;
block_expr = "{" { let_binding } expression "}" ;
(* ===== Collections ===== *)
list_literal = "[" [ expression { "," expression } [ "," ] ] "]" ;
map_literal = "{" [ map_entry { map_entry } ] "}" ;
map_entry = ( IDENT | STRING_LIT ) "=" expression ;
(* ===== Trivia ===== *)
trivia = { whitespace | comment } ;
comment = line_comment | block_comment | doc_comment ;
line_comment = "//" { any_char } newline ;
block_comment = "/*" { any_char | block_comment } "*/" ;
doc_comment = "///" { any_char } newline ;
(* ===== Terminals ===== *)
IDENT = ( letter | "_" ) { letter | digit | "_" } ;
IDENTIFIER_LIT = ( letter | "_" ) { letter | digit | "_" | "-" } ;
STRING_LIT = '"' { string_char | escape_seq | interpolation } '"'
| heredoc ;
INT_LIT = digit { digit | "_" }
| "0x" hex_digit { hex_digit | "_" }
| "0o" oct_digit { oct_digit | "_" }
| "0b" bin_digit { bin_digit | "_" } ;
FLOAT_LIT = digit { digit } "." digit { digit }
[ ( "e" | "E" ) [ "+" | "-" ] digit { digit } ] ;
BOOL_LIT = "true" | "false" ;
NULL_LIT = "null" ;
interpolation = "${" expression "}" ;
escape_seq = "\\" ( '"' | "\\" | "n" | "r" | "t"
| "u" hex4 | "U" hex8 ) ;
heredoc = "<<" [ "-" ] marker newline { any_char } newline marker
| "<<'" marker "'" newline { any_char } newline marker ;
Error Codes
This page lists all diagnostic codes produced by the WCL pipeline, grouped by phase.
Errors
| Code | Phase | Description |
|---|---|---|
| E001 | Parse | Syntax error |
| E002 | Parse | Unexpected token |
| E003 | Parse | Unterminated string |
| E010 | Import | File not found |
| E011 | Import | Jail escape (path outside root) |
| E013 | Import | Remote import forbidden |
| E014 | Import | Max import depth exceeded |
| E015 | Import | Library not found in search paths |
| E016 | Import | Glob pattern matched no files (non-optional import) |
| E020 | Macro | Undefined macro |
| E021 | Macro | Recursive macro expansion |
| E022 | Macro | Max expansion depth exceeded |
| E023 | Macro | Wrong macro kind |
| E024 | Macro | Parameter type mismatch |
| E025 | Control Flow | For-loop iterable is not a list |
| E026 | Control Flow | If/else condition is not bool |
| E027 | Control Flow | Invalid expanded identifier |
| E028 | Control Flow | Max iteration count exceeded |
| E029 | Control Flow | Max nesting depth exceeded |
| E030 | Merge | Duplicate ID (non-partial) |
| E031 | Merge | Attribute conflict in partial merge |
| E032 | Merge | Kind mismatch in partial merge |
| E033 | Merge | Mixed partial/non-partial |
| E034 | Export | Duplicate exported variable name |
| E035 | Export | Re-export of undefined name |
| E036 | Export | Export inside block |
| E038 | Merge | Partial let binding value must be a list |
| E039 | Merge | Let binding declared as both partial and non-partial with same name |
| E040 | Scope | Undefined reference |
| E041 | Scope | Cyclic dependency |
| E050 | Eval | Type error in expression |
| E051 | Eval | Division by zero |
| E052 | Eval | Unknown function |
| E053 | Eval | Declared-but-unregistered function (from declare in library) |
| E054 | Eval | Index out of bounds |
| E060 | Decorator | Unknown decorator |
| E061 | Decorator | Invalid target |
| E062 | Decorator | Missing required parameter |
| E063 | Decorator | Parameter type mismatch |
| E064 | Decorator | Constraint violation |
| E070 | Schema | Missing required field |
| E071 | Schema | Attribute type mismatch |
| E072 | Schema | Unknown attribute (closed schema) |
| E073 | Schema | Validate constraint violation |
| E074 | Schema | Ref target not found |
| E080 | Validation | Document validation failed |
| E090 | Table | @table_index references nonexistent column |
| E091 | Table | Duplicate value in unique table index |
| E092 | Table | Inline columns defined when schema is applied |
| E093 | Schema | Block uses text block syntax but schema has no @text field |
| E094 | Schema | @text field validation errors (wrong name or type) |
| E095 | Schema | Child not allowed by parent’s @children constraint |
| E096 | Schema | Item not allowed by its own @parent constraint |
| E097 | Schema | Child count below @child minimum |
| E098 | Schema | Child count above @child maximum |
| E099 | Schema | Self-nesting exceeds @child max_depth |
| E100 | Schema | Symbol value not in declared symbol_set |
| E101 | Schema | Referenced symbol_set does not exist |
| E102 | Parse | Duplicate symbol_set name |
| E103 | Parse | Duplicate symbol within a symbol_set |
Warnings
| Code | Phase | Description |
|---|---|---|
| W001 | Scope | Shadowing warning |
| W002 | Scope | Unused variable |
| W003 | Merge | Inline args mismatch in partial merge |
Diagnostic Output Format
WCL diagnostics use a Rust-style format with source spans:
error[E070]: missing required field `port`
--> config.wcl:12:3
|
12 | service svc-api {
| ^^^^^^^^^^^^^^^ missing field `port`
|
= required by schema `ServiceSchema`
warning[W001]: `port` shadows outer binding
--> config.wcl:18:7
|
18 | let port = 9090
| ^^^^ shadows binding at config.wcl:3:5
Each diagnostic includes:
- Severity —
errororwarning - Code — e.g.
[E070] - Message — human-readable description
- Location — file path, line, and column (
file:line:col) - Source snippet — the relevant source lines with a caret pointing to the problem
- Notes — optional additional context (prefixed with
=)
Using --strict
Running wcl validate --strict promotes all warnings to errors. This is useful in CI pipelines where zero warnings are required.
wcl validate --strict config.wcl
echo $? # 0 if no errors or warnings; 1 if any
Comparison with Other Formats
This page compares WCL with JSON, YAML, TOML, and HCL across key features.
Feature Matrix
| Feature | JSON | YAML | TOML | HCL | WCL |
|---|---|---|---|---|---|
| Comments | No | Yes | Yes | Yes | Yes — line (//), block (/* */), doc (///); preserved by formatter |
| String interpolation | No | No | No | Yes | Yes — "host: ${host}:${port}" |
| Type system | Implicit (6 types) | Implicit (dynamic) | Explicit primitives | Limited | Full — primitives, composites, union, ref, any |
| Schemas | External (JSON Schema) | No | No | No | Built-in — first-class schema declarations with constraints |
| Variables / let bindings | No | Anchors only | No | Limited | Yes — let name = expr |
| Expressions | No | No | No | Limited (templates) | Yes — full arithmetic, logic, ternary, regex, lambdas |
| Macros | No | No | No | No | Yes — function macros and attribute macros |
| Imports | No | No | No | Handled by Terraform | Yes — import "file.wcl" built-in |
| Query engine | No (external: jq) | No | No | No | Yes — built-in query(selector | filters) |
| Tables | Arrays of objects | Sequences | Array of tables | No | Yes — typed column declarations with row syntax |
| Control flow | No | No | No | for_each (Terraform) | Yes — for loops and if/else |
| Partial declarations | No | No | No | No | Yes — partial blocks merged across files |
| Decorators | No | No | No | No | Yes — @decorator(args) with schemas |
| Validation blocks | External | No | No | No | Built-in — validation blocks with check and message |
| LSP support | Via plugins | Via plugins | Via plugins | Via plugins | Yes — first-class wcl lsp with full feature set |
| Formatting | External (prettier) | External | External | External | Yes — wcl fmt built-in |
| Bidirectional conversion | — | Partial | Partial | No | Yes — wcl convert to/from JSON/YAML/TOML |
Comments
JSON has no comment syntax at all. Tools like jsonc or json5 add comments as extensions, but they are not part of the standard.
YAML supports # line comments.
TOML supports # line comments.
HCL supports //, #, and /* */ comments.
WCL supports three comment forms: // line comments, /* */ block comments (nestable), and /// doc comments which are attached to the following declaration. All comment forms are preserved by wcl fmt.
Schemas and Validation
JSON Schema is a separate specification applied externally. It is powerful but disconnected from the data file itself.
YAML, TOML, and HCL have no built-in schema mechanism.
WCL schemas are declared directly in WCL source and matched to blocks automatically by block type name. They support type constraints, required/optional fields, min/max, pattern matching, one_of, and cross-references between block types.
schema "ServiceSchema" {
@required
name : string
@min(1) @max(65535)
port : int
host : string
}
service svc-api {
name = "api"
port = 8080
host = "localhost"
}
Expressions and Variables
JSON and TOML are purely declarative — all values must be literals.
YAML supports anchors and aliases for value reuse, but no arithmetic or logic.
HCL (as used in Terraform) supports template expressions and some built-in functions.
WCL supports full expression evaluation: arithmetic, string interpolation, comparisons, logical operators, ternary expressions, list/map operations, regex matching, lambdas, and a built-in query engine.
Partial Declarations
WCL’s partial blocks are unique among configuration formats. A block can be declared in multiple files or multiple places in the same file with partial, and the pieces are merged before evaluation:
// base.wcl
partial service svc-api {
host = "localhost"
}
// overrides.wcl
import "base.wcl"
partial service svc-api {
port = 9090
}
// Result: service svc-api { host = "localhost", port = 9090 }
This enables layered configuration composition without inheritance hierarchies or external merge tools.
Decorators
WCL decorators attach metadata and behaviour to declarations. They are validated against decorator_schema definitions, making them typed and self-documenting:
decorator_schema "tag" {
target = [block, attribute]
env : string
tier : string
}
@tag(env = "prod", tier = "critical")
service svc-payments {
port = 8443
}
No other mainstream configuration format has an equivalent mechanism.
When to Use Each Format
| Format | Best suited for |
|---|---|
| JSON | Machine-generated config, REST APIs, interoperability |
| YAML | Kubernetes manifests, CI pipelines, human-edited simple config |
| TOML | Application config files (Cargo.toml, pyproject.toml) |
| HCL | Terraform infrastructure definitions |
| WCL | Complex configuration with shared logic, schemas, cross-file composition, and tooling |