Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 @decorator syntax
  • 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?

FeatureJSONYAMLTOMLHCLWCL
CommentsNoYesYesYesYes (doc comments too)
ExpressionsNoNoNoYesYes
Schemas / validationNoNoNoPartialYes (first-class)
Macros / reuseNoAnchorsNoModulesYes
DecoratorsNoNoNoNoYes
Data tablesNoNoNoNoYes
Query engineNoNoNoNoYes
LSPNoLimitedLimitedYesYes
Static typesNoNoNoNoYes

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 type server with ID web-prod
  • The schema "server" block automatically validates every server block by matching the name
  • workers = max(4, 2) is an evaluated expression using a built-in function
  • @sensitive is 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: @name or @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 fmt integration)

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

PackageFormatUnlocks
Tree-sitter Highlight Queries.scmNeovim, Helix, Zed, GitHub
Sublime Syntax.sublime-syntaxSublime Text, Syntect, bat
TextMate Grammar.tmLanguage.jsonVS Code, Shiki, Monaco
highlight.js.jsmdbook, any highlight.js site
Pygments Lexer.pyPygments, 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:

FileLocationPurpose
highlights.scmextras/tree-sitter-wcl/queries/Core syntax highlighting
locals.scmextras/highlight-queries/Scope-aware variable highlighting
textobjects.scmextras/highlight-queries/Structural text objects (select block, function, etc.)
injections.scmextras/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:

  1. Copy the grammar file:
mkdir -p docs/book/theme
cp extras/highlightjs/wcl.js docs/book/theme/
  1. Register it in book.toml:
[output.html]
additional-js = ["theme/wcl.js"]
  1. 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"]
  1. Use wcl as 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:

  1. Convert the Pygments lexer to a Chroma Go implementation (see the Chroma contributing guide)
  2. Or use the chroma CLI to test directly:
chroma --lexer pygments --filename input.wcl < input.wcl

What’s Highlighted

All highlighting definitions cover the same WCL syntax elements:

ElementExamples
Keywordsif, else, for, in, let, macro, schema, table, import, export
Declaration keywordsdeclare, validation, decorator_schema, partial
Transform keywordsinject, set, remove, when, check, message, target
Built-in typesstring, int, float, bool, any, identifier, list, map, set, union, ref
Built-in functionsquery, has, import_table, import_raw
Constantstrue, false, null
NumbersIntegers, floats, hex (0xFF), octal (0o77), binary (0b101)
StringsDouble-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:

PlatformArchitectureLibrary
Linuxx86_64lib/linux_amd64/libwcl_ffi.a
Linuxaarch64lib/linux_arm64/libwcl_ffi.a
macOSx86_64lib/darwin_amd64/libwcl_ffi.a
macOSarm64lib/darwin_arm64/libwcl_ffi.a
Windowsx86_64lib/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 TypePython Type
stringstr
intint
floatfloat
boolbool
nullNone
listlist
mapdict
setset (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 as string, booleans as bool, lists as []any, and maps as map[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 the runtimes/{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:

KindFactoryAccessor
StringWclValue.NewString("...").AsString()
IntWclValue.NewInt(42).AsInt()
FloatWclValue.NewFloat(3.14).AsFloat()
BoolWclValue.NewBool(true).AsBool()
NullWclValue.Null.IsNull
ListWclValue.NewList(...).AsList()
MapWclValue.NewMap(...).AsMap()
SetWclValue.NewSet(...).AsSet()
BlockRefWclValue.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:

KindFactoryAccessor
STRINGWclValue.ofString("...").asString()
INTWclValue.ofInt(42).asInt()
FLOATWclValue.ofFloat(3.14).asFloat()
BOOLWclValue.ofBool(true).asBool()
NULLWclValue.NULL.isNull()
LISTWclValue.ofList(...).asList()
MAPWclValue.ofMap(...).asMap()
SETWclValue.ofSet(...).asSet()
BLOCK_REFWclValue.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 name
  • getId() - optional block identifier
  • getAttributes() - resolved attribute map (includes _args if inline args are present)
  • getChildren() - nested child blocks
  • getDecorators() - attached decorators
  • get(key) - safe attribute access (returns null if 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.Value union: 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:

ErrorMeaning
ParseFailedSource string parsing returned null
ParseFileFailedFile parsing returned null (I/O error or invalid path)
QueryFailedQuery execution returned an error
LibraryListFailedLibrary listing failed
DocumentClosedOperation 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 TypeRuby Type
stringString
intInteger
floatFloat
booltrue / false
nullnil
listArray
mapHash
setSet (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 KindExample
String literal"hello"
Integer literal42, 0xFF, 0b1010
Float literal3.14, 1.5e-3
Boolean literaltrue, false
Null literalnull
Variable referencebase_port
Arithmeticbase_port + 1
Comparison/logicalport > 0 && port < 65536
Ternarydebug ? "verbose" : "quiet"
Function callupper("hello")
String interpolation"http://${host}:${port}"
Queryquery(service | .port)
Refref(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:

TypePurpose
schemaDefines a schema for validating user blocks
decorator_schemaDefines the parameter schema for a decorator
tableTabular data with typed columns
validationInline validation assertions
macroDefines 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:

CategoryPositionExample
LeadingOne or more comment lines immediately before a declaration// comment\nport = 8080
TrailingA comment on the same line as a declaration, after the valueport = 8080 // comment
FloatingA comment separated from any declaration by blank linesA 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:

EscapeMeaning
\nNewline
\tTab
\rCarriage return
\\Literal backslash
\"Literal double quote
\0Null byte
\uXXXXUnicode 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:

FromToWhen
intfloatIn arithmetic expressions involving floats

All other conversions require explicit function calls:

FunctionConverts toExample
to_string(v)stringto_string(42)"42"
to_int(v)intto_int("42")42
to_float(v)floatto_float("3.14")3.14
to_bool(v)boolto_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.

PrecedenceOperator(s)DescriptionAssociativity
1 (lowest)? :Ternary conditionalRight
2||Logical ORLeft
3&&Logical ANDLeft
4!Logical NOT (unary)Right (prefix)
5== != < > <= >= =~Comparison / regexLeft
6+ -AdditiveLeft
7* / %MultiplicativeLeft
8- (unary)NegationRight (prefix)
9 (highest)() [] . callsGrouping, index, member, callLeft

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:

TypeConversion ruleExample result
stringUsed as-is"hello"hello
intDecimal representation4242
floatDecimal representation3.143.14
booltrue or falsetruetrue
nullThe literal string "null"nullnull
identifierThe identifier’s namefoofoo
listRuntime error — not auto-converted
mapRuntime error — not auto-converted
functionRuntime 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 let whose value is not a list is an error (E038).
  • Cannot mix partial and non-partial. Declaring let x = ... and partial 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 kindCreated byContains
Module scopeEach .wcl fileTop-level let, export let, blocks, attributes
Block scopeEach { } block bodylet bindings, nested blocks, attributes
Macro scopeEach macro expansionMacro 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:

  1. Look for x as a let binding in the current block scope.
  2. Look for x as an attribute in the current block scope.
  3. Walk up to the enclosing scope and repeat.
  4. Check module-level let and export let bindings.
  5. Check imported names.
  6. 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

Featureletexport letAttribute
Visible in current scopeYesYesYes
Visible to importersNoYesNo
Appears in serialized outputNoNoYes
Can be query-selectedNoNoYes
Subject to schema validationNoNoYes
Can be ref-erencedNoNoYes (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:

LimitDefault
Maximum nesting depth (loops + conditionals combined)32
Maximum iterations per single for loop1,000
Maximum total iterations across all for loops10,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

LimitDefault
Iterations per loop1,000
Total iterations across all loops10,000
Maximum nesting depth32

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

CategoryPageFunctions
StringString Functionsupper, lower, trim, trim_prefix, trim_suffix, replace, split, join, starts_with, ends_with, contains, length, substr, format, regex_match, regex_capture
MathMath Functionsabs, min, max, floor, ceil, round, sqrt, pow
CollectionCollection Functionslen, keys, values, flatten, concat, distinct, sort, reverse, contains, index_of, range, zip
Higher-OrderHigher-Order Functionsmap, filter, every, some, reduce
AggregateAggregate Functionssum, avg, min_of, max_of, count
Hash & EncodingHash & Encoding Functionssha256, base64_encode, base64_decode, json_encode
Type CoercionType Coercion Functionsto_string, to_int, to_float, to_bool, type_of
Reference & QueryReference & Query Functionsref, 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:

  1. The LSP uses declarations for completions and signature help
  2. 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

FunctionSignatureDescription
upperupper(s: string) -> stringConvert to uppercase
lowerlower(s: string) -> stringConvert to lowercase
trimtrim(s: string) -> stringRemove leading and trailing whitespace
trim_prefixtrim_prefix(s: string, prefix: string) -> stringRemove prefix if present
trim_suffixtrim_suffix(s: string, suffix: string) -> stringRemove suffix if present
replacereplace(s: string, from: string, to: string) -> stringReplace all occurrences of from with to
splitsplit(s: string, sep: string) -> listSplit on separator, returning a list of strings
joinjoin(list: list, sep: string) -> stringJoin a list of strings with a separator
starts_withstarts_with(s: string, prefix: string) -> boolTrue if s starts with prefix
ends_withends_with(s: string, suffix: string) -> boolTrue if s ends with suffix
containscontains(s: string, sub: string) -> boolTrue if s contains sub
lengthlength(s: string) -> intNumber of characters (Unicode code points)
substrsubstr(s: string, start: int, end: int) -> stringSubstring from start (inclusive) to end (exclusive)
formatformat(template: string, ...args) -> stringFormat string with {} placeholders
regex_matchregex_match(s: string, pattern: string) -> boolTrue if s matches the regex pattern
regex_captureregex_capture(s: string, pattern: string) -> listList 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

FunctionSignatureDescription
absabs(n: int|float) -> int|floatAbsolute value; preserves input type
minmin(a: int|float, b: int|float) -> int|floatSmaller of two values
maxmax(a: int|float, b: int|float) -> int|floatLarger of two values
floorfloor(n: float) -> intRound down to nearest integer
ceilceil(n: float) -> intRound up to nearest integer
roundround(n: float) -> intRound to nearest integer (half-up)
sqrtsqrt(n: int|float) -> floatSquare root; always returns float
powpow(base: int|float, exp: int|float) -> floatRaise 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

FunctionSignatureDescription
lenlen(coll: list|map|string) -> intNumber of elements (or characters for strings)
keyskeys(m: map) -> listOrdered list of a map’s keys
valuesvalues(m: map) -> listOrdered list of a map’s values
flattenflatten(list: list) -> listRecursively flatten nested lists one level
concatconcat(a: list, b: list) -> listConcatenate two lists
distinctdistinct(list: list) -> listRemove duplicate values, preserving first occurrence order
sortsort(list: list) -> listSort in ascending order (strings lexicographic, numbers numeric)
reversereverse(list: list) -> listReverse the order of a list
containscontains(list: list, value) -> boolTrue if value is in the list
index_ofindex_of(list: list, value) -> intZero-based index of first occurrence, or -1 if not found
rangerange(start: int, end: int) -> listList of integers from start (inclusive) to end (exclusive)
zipzip(a: list, b: list) -> listList 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

FunctionSignatureDescription
mapmap(list: list, fn: lambda) -> listApply fn to each element; return list of results
filterfilter(list: list, fn: lambda) -> listKeep elements for which fn returns true
everyevery(list: list, fn: lambda) -> boolTrue if fn returns true for all elements
somesome(list: list, fn: lambda) -> boolTrue if fn returns true for at least one element
reducereduce(list: list, fn: lambda, initial) -> anyFold 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

FunctionSignatureDescription
sumsum(list: list) -> int|floatSum of all elements
avgavg(list: list) -> floatArithmetic mean of all elements
min_ofmin_of(list: list) -> int|floatSmallest element
max_ofmax_of(list: list) -> int|floatLargest element
countcount(list: list, fn: lambda) -> intNumber of elements for which fn returns true

Note: min and max take two scalar arguments and compare them directly. min_of and max_of take a list and find the extreme value within it. See Math Functions for min/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

FunctionSignatureDescription
sha256sha256(s: string) -> stringHex-encoded SHA-256 digest of the UTF-8 bytes of s
base64_encodebase64_encode(s: string) -> stringStandard Base64 encoding of the UTF-8 bytes of s
base64_decodebase64_decode(s: string) -> stringDecode a Base64-encoded string back to UTF-8
json_encodejson_encode(value: any) -> stringSerialize 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

  • sha256 always produces lowercase hex output.
  • base64_encode uses standard Base64 with padding (=). It does not produce URL-safe Base64.
  • base64_decode returns a string. If the decoded bytes are not valid UTF-8, an error is raised.
  • json_encode serializes 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

FunctionSignatureDescription
to_stringto_string(value: any) -> stringConvert any value to its string representation
to_intto_int(value: string|float|bool) -> intParse or truncate a value to integer
to_floatto_float(value: string|int|bool) -> floatParse or promote a value to float
to_boolto_bool(value: string|int) -> boolParse a value to boolean
type_oftype_of(value: any) -> stringReturn 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: 0false, 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 typetype_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

FunctionSignatureDescription
refref(id: string) -> blockResolve a block by its ID; error if not found
queryquery(pipeline) -> listExecute a query pipeline; return matching blocks
hashas(block, name: string) -> boolTrue if block has an attribute or child block named name
has_decoratorhas_decorator(block, name: string) -> boolTrue if block carries the decorator @name
is_importedis_imported(path: string) -> boolTrue if the given file path was imported
has_schemahas_schema(name: string) -> boolTrue 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:

TypeDescription
stringA string value
intAn integer value
floatA floating-point value
boolA boolean value
listA list of values
mapA key-value map
anyAccepts any value type
symbolA 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:

ArgumentApplies toDescription
minint, floatMinimum value (inclusive)
maxint, floatMaximum value (inclusive)
patternstringRegex pattern the value must match
one_ofstring, intValue must be one of the listed options
custom_msganyCustom 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:

  1. Import resolution
  2. Macro expansion
  3. Control flow expansion (for/if)
  4. Partial merging
  5. 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)
  • @child entries merge into the @children constraint 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/@child decorators 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: :GET becomes "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

CodeMeaning
E100Symbol value not in declared symbol_set
E101Referenced symbol_set does not exist
E102Duplicate symbol_set name
E103Duplicate symbol within a symbol_set

Error Codes

CodeMeaning
E001Duplicate schema name
E030Duplicate block ID
E070Missing required field
E071Type mismatch
E072Unknown attribute in closed schema
E073min/max constraint violation
E074Pattern constraint violation
E075one_of constraint violation
E076@ref target not found
E077@id_pattern mismatch
E080Validation block failure
E092Inline columns defined when schema is applied
E095Child not allowed by parent’s @children list
E096Item not allowed by its own @parent list
E097Child count below @child minimum
E098Child count above @child maximum
E099Self-nesting exceeds @child max_depth
E100Symbol value not in declared symbol_set
E101Referenced symbol_set does not exist
E102Duplicate symbol_set name
E103Duplicate 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:

TargetExample
Attributeport = 8080 @required
Blockservice "api" @deprecated("use v2") { ... }
Tabletable#hosts @open { ... }
Schema fieldport: int @validate(min = 1, max = 65535)
Schema itselfschema "service" @open { ... }
Partial blockpartial 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.

Built-in Decorators

WCL provides a set of built-in decorators for schema validation, documentation, macro transforms, and configuration semantics.

Reference Table

DecoratorTargetsArgumentsDescription
@optionalschema fieldsnoneField is not required
@requiredschema fieldsnoneField must be present (default for schema fields)
@default(value)schema fieldsvalue: anyDefault value when field is absent
@sensitiveattributesredact_in_logs: bool = trueMarks value as sensitive; redacted in log output
@deprecatedblocks, attributesmessage: string, since: string (optional)Warns when this item is used
@validate(...)attributes, schema fieldsmin, max, pattern, one_of, custom_msgValue constraints
@doc(text)anytext: stringInline documentation for the decorated item
@example { }decorator schemas, schemasblock bodyEmbedded usage example
@allow(rule)let bindings, attributesrule: stringSuppresses a specific warning
@id_pattern(glob)schemasglob: stringEnforces naming convention on block IDs
@ref(schema)schema identifier fieldsschema: stringRequires value to reference an existing block of that type
@partial_requirespartial blocksfields: list of stringsDeclares expected merge dependencies
@merge_order(n)partial blocksn: intExplicit ordering for partial merges
@openschemasnoneAllows extra attributes not declared in the schema
@child(kind, ...)schemaskind: string, min/max/max_depth: int (optional)Per-child cardinality and depth constraints
@tagged(field)schemasfield: stringNames the discriminator field for tagged variant schemas
@children(kinds)schemaskinds: list of stringsRestricts which child blocks/tables may appear inside
@parent(kinds)schemaskinds: list of stringsRestricts which parent blocks may contain this block/table
@symbol_set(name)schema fieldsname: stringConstrains a symbol-typed field to members of the named symbol set
@inline(N)schema fieldsN: intMaps positional inline arg at index N to this named field
@textschema fieldsnoneMarks a content: string field as the text block target
@merge_strategy(s)partial blockss: stringControls 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")
}
ArgumentApplies toDescription
minint, floatMinimum value (inclusive)
maxint, floatMaximum value (inclusive)
patternstringRegular expression the value must fully match
one_ofstring, intValue must be one of the given options
custom_msganyCustom 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
}
ErrorCondition
E097Child count below minimum
E098Child count above maximum
E099Self-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:

EntryMeaning
"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")
}
ErrorCondition
E100Symbol value not in the declared symbol set
E101Referenced 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" }
}
ErrorCondition
E093Block 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:

KindDescription
any_ofAt least one of the listed parameters must be provided
all_ofAll listed parameters must be provided together
one_ofExactly one of the listed parameters must be provided
requiresIf 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:

  1. Name match — A decorator_schema with the decorator’s name must exist.
  2. Valid target — The decorated item’s kind must be listed in target.
  3. Required parameters — All non-optional, non-default parameters must be supplied.
  4. Type checking — Each argument’s value must match the declared parameter type.
  5. Constraints — Any @constraint conditions 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

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" or name: 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
  • let bindings — 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:

SyntaxRemoves
nameAttribute with that name
kind#idChild block of kind with inline ID id
kind#*All child blocks of kind
kind[n]The nth child block of kind (0-based)
table#idTable 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:

DirectiveEffect
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_rowsRemove 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.

ExpressionReturns
self.nameThe block’s type name (e.g. "service")
self.kindThe block’s kind string
self.idThe 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.argsList of inline argument values on the block
self.decoratorsList 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:

DecoratorPurpose
@validate(expr)Constraint expression applied to every cell in this column
@doc("text")Human-readable description of the column
@sensitiveMarks 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:

ParameterTypeDefaultDescription
separatorstring","Field separator character
headersbooltrueWhether the first row contains column headers
columnslistExplicit 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 comparison
  • has(.col) — column exists and is non-null
  • Append | .col at 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 (let bindings declared with export)

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):

  1. User library: $XDG_DATA_HOME/wcl/lib/ (default: ~/.local/share/wcl/lib/)
  2. 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 import statement.
  • 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:

ItemConflict behavior
Blocks with distinct IDsMerged without conflict
Blocks sharing an IDRequires partial — see Partial Declarations
Duplicate schema nameError (E001)
Duplicate decorator schema nameError
Duplicate top-level attributeError
Macros with the same nameError

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"])
ParameterTypeDefaultDescription
separatorstring","Field separator character
headersbooltrueWhether the first row contains column headers
columnslistExplicit 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:

  1. User library: $XDG_DATA_HOME/wcl/lib/ (default: ~/.local/share/wcl/lib/)
  2. 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 declare statements
  • 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

ModeBehaviour on duplicate attribute
ConflictMode::Strict (default)Error — duplicate attributes are forbidden
ConflictMode::LastWinsThe 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.

SelectorMatches
serviceAll blocks of kind service
service#svc-authThe block of kind service with ID svc-auth
config.server.listenerA nested attribute path — the listener attribute inside server inside config
..health_checkRecursive descent — all blocks named health_check at any depth
.The document root
*All top-level blocks
table#idThe 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

PipelineReturn type
query(service)list(block_ref)
query(service | .port)list(value) (the projected attribute values)

Aggregate Functions

Aggregate functions operate on query results:

FunctionDescription
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

FlagOutput
--format jsonJSON array
--format textOne item per line (default)
--format csvComma-separated values
--format wclWCL block representation

Additional Flags

FlagDescription
--countPrint the number of results instead of the results themselves
--recursiveDescend 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"
}
  • check must evaluate to a bool. If it is false, a validation error (code E080) is emitted with the text from message.
  • message is a string expression and may use interpolation.
  • let bindings inside the block are local — they are computed before check is evaluated and are not visible outside the block.
  • Multiple let bindings are allowed; they are evaluated in order.

Execution Order

Validation blocks run in phase 11, after:

  1. Parsing
  2. Macro collection
  3. Import resolution
  4. Macro expansion
  5. Control flow expansion
  6. Partial merge
  7. Scope construction and evaluation
  8. Decorator validation
  9. Schema validation
  10. ID uniqueness checks
  11. 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

SubcommandDescription
validateParse and validate a WCL document through all pipeline phases
evalEvaluate a document and print the resolved output
fmtFormat a WCL document
queryExecute a query expression against a document
inspectInspect the AST, HIR, scopes, or dependency graph
convertConvert between WCL and other formats (JSON, YAML, TOML)
setSet an attribute value by path
addAdd a new block to a document
removeRemove a block or attribute by path
lspStart the WCL language server

Help

wcl --help
wcl <subcommand> --help

Global Flags

FlagDescription
--help, -hPrint help information
--version, -VPrint version information

Exit Codes

CodeMeaning
0Success
1Validation or evaluation error
2Usage / argument error

wcl validate

Parse and validate a WCL document through all pipeline phases.

Usage

wcl validate <file> [options]

Options

FlagDescription
--strictTreat warnings as errors
--schema <file>Load an additional external schema file
--var KEY=VALUESet an external variable (may be repeated)

Description

wcl validate runs the document through the full 11-phase pipeline:

  1. Parse
  2. Macro collection
  3. Import resolution
  4. Macro expansion
  5. Control flow expansion
  6. Partial merge
  7. Scope construction and evaluation
  8. Decorator validation
  9. Schema validation
  10. ID uniqueness
  11. 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

CodeMeaning
0Document is valid
1One or more errors (or warnings with --strict)
2Argument 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

FlagDescription
--format <fmt>Output format: json, yaml, or toml (default: json)
--var KEY=VALUESet 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

FlagDescription
--writeFormat the file in place instead of printing to stdout
--checkCheck 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

CodeMeaning
0Success (or file is already formatted when using --check)
1File would be reformatted (only with --check)
2Argument 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

FlagDescription
--format <fmt>Output format: text, json, csv, or wcl (default: text)
--countPrint the number of matching results instead of the results themselves
--recursiveSearch 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

SyntaxSelects
serviceAll blocks of type service
service#svc-apiBlock with type service and ID svc-api
..serviceAll service blocks at any depth
*All top-level items
.The root document

Filters

SyntaxMeaning
.portHas attribute port
.port == 8080Attribute 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

FlagDescription
--astPrint the raw Abstract Syntax Tree
--hirPrint the resolved High-level Intermediate Representation
--scopesPrint the scope tree
--depsPrint 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

FlagDescription
--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

FormatTo WCL (--from)From WCL (--to)
JSONYesYes
YAMLNoYes
TOMLNoYes

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:

PathMeaning
service#svc-api.portAttribute port inside block service with ID svc-api
database#primary.hostAttribute host inside block database with ID primary
service#svc-api.tls.enabledNested 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 add or 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

FlagDescription
--file-autoAutomatically 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

SpecResult
"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 set or 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.

PathWhat is removed
service#svc-oldThe entire service block with ID svc-old
service#svc-api.debugThe debug attribute inside service#svc-api
database#primary.portThe 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

FlagDescription
--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

FeatureDescription
DiagnosticsReal-time errors and warnings as you type, sourced from the full pipeline
HoverType information and documentation for identifiers, blocks, and schema fields
Go to definitionJump to where a name, macro, schema, or imported identifier is defined
CompletionsContext-aware completions for identifiers, attribute names, block types, and decorators
Semantic tokensSyntax highlighting based on semantic meaning (not just token type)
Signature helpParameter hints when calling macros or functions
Find referencesLocate all uses of a definition across the document
FormattingFull-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:

  • import declarations
  • export declarations
  • 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 identification
  • inline-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

CategoryExamples
Literals42, 3.14, "hello", true, false, null, #my-id
Arithmetica + b, a - b, a * b, a / b, a % b
Comparisona == b, a != b, a < b, a >= b, a =~ "pattern"
Logicala && b, a || b, !a
Ternarycond ? then_val : else_val
String interpolation"host: ${host}:${port}"
List[1, 2, 3]
Map{ key: "value" }
Field accessobj.field
Indexlist[0], map["key"]
Queryquery(service | .port > 1024)
Refref(svc-api)
Lambdax => 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

TypeKeywordDescriptionExample literals
StringstringUTF-8 text, supports interpolation and heredocs"hello", "port: ${p}"
Integerint64-bit signed integer (decimal, hex, octal, binary)42, 0xFF, 0o77, 0b1010
Floatfloat64-bit IEEE 754 double3.14, 1.0e-3
Booleanbooltrue or falsetrue, false
NullnullAbsence of a valuenull
IdentifieridentifierAn identifier literal (may contain hyphens)#svc-api, #my-resource

Composite Types

TypeSyntaxDescriptionExample
Listlist(T)Ordered sequence of values of type T[1, 2, 3]
Mapmap(K, V)Key-value pairs; keys are type K, values type V{ a: 1, b: 2 }
Setset(T)Unordered collection of unique values of type T[1, 2, 3]

Special Types

TypeSyntaxDescription
AnyanyAccepts a value of any type; opts out of type checking
Unionunion(T1, T2, ...)A value that may be any one of the listed types
Refref("SchemaName")A reference to a block conforming to the named schema
FunctionFirst-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 float is expected (widening is safe).
  • null is accepted for any union(T, null) type.
  • any accepts all values without checking.

Serde Type Mapping

When serializing to JSON, YAML, or TOML, WCL types map as follows:

WCL TypeJSONYAMLTOML
stringstringstringstring
intnumber (integer)intinteger
floatnumber (float)floatfloat
boolbooleanboolboolean
nullnullnullnot representable (omitted)
identifierstring (bare name)stringstring
list(T)arraysequencearray
map(K, V)objectmappingtable
set(T)array (deduplicated)sequencearray
ref(...)resolved valueresolved valueresolved value
anynative JSON valuenativenative

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:

LevelOperatorsAssociativity
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

OperatorTypesResult
a || bbool, boolbool
a && bbool, boolbool
!aboolbool

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

OperatorTypesResult
a == bany matching typesbool
a != bany matching typesbool

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

OperatorTypesResult
a < bint, float, stringbool
a > bint, float, stringbool
a <= bint, float, stringbool
a >= bint, float, stringbool
a =~ bstring, stringbool

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

OperatorTypesResultNotes
a + bint, intint
a + bfloat, floatfloat
a + bstring, stringstringConcatenation
a + blist, listlistConcatenation
a - bint, intint
a - bfloat, floatfloat
a * bint, intint
a * bfloat, floatfloat
a / bint, intintInteger division; error E051 if b == 0
a / bfloat, floatfloatIEEE 754; b == 0.0 produces infinity
a % bint, intintRemainder; error E051 if b == 0
-aintintUnary negation
-afloatfloatUnary 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

CodeCondition
E050Type mismatch in operator or function call
E051Division or modulo by zero
E052Call to unknown function
E053ref() target identifier not found
E054List index out of bounds
E040Reference to undefined name
E041Cyclic 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 let bindings
  • All top-level attributes
  • All block declarations (accessible by type and ID)
  • All imported names (merged from import directives)
  • 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:

  1. Search the current (innermost) scope for a binding named x.
  2. If not found, walk up to the parent scope and repeat.
  3. Continue until the module scope is reached.
  4. 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:

  1. The evaluator builds a dependency graph by inspecting which names each expression references.
  2. The graph is topologically sorted.
  3. 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 terminal
  • UPPER — 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

CodePhaseDescription
E001ParseSyntax error
E002ParseUnexpected token
E003ParseUnterminated string
E010ImportFile not found
E011ImportJail escape (path outside root)
E013ImportRemote import forbidden
E014ImportMax import depth exceeded
E015ImportLibrary not found in search paths
E016ImportGlob pattern matched no files (non-optional import)
E020MacroUndefined macro
E021MacroRecursive macro expansion
E022MacroMax expansion depth exceeded
E023MacroWrong macro kind
E024MacroParameter type mismatch
E025Control FlowFor-loop iterable is not a list
E026Control FlowIf/else condition is not bool
E027Control FlowInvalid expanded identifier
E028Control FlowMax iteration count exceeded
E029Control FlowMax nesting depth exceeded
E030MergeDuplicate ID (non-partial)
E031MergeAttribute conflict in partial merge
E032MergeKind mismatch in partial merge
E033MergeMixed partial/non-partial
E034ExportDuplicate exported variable name
E035ExportRe-export of undefined name
E036ExportExport inside block
E038MergePartial let binding value must be a list
E039MergeLet binding declared as both partial and non-partial with same name
E040ScopeUndefined reference
E041ScopeCyclic dependency
E050EvalType error in expression
E051EvalDivision by zero
E052EvalUnknown function
E053EvalDeclared-but-unregistered function (from declare in library)
E054EvalIndex out of bounds
E060DecoratorUnknown decorator
E061DecoratorInvalid target
E062DecoratorMissing required parameter
E063DecoratorParameter type mismatch
E064DecoratorConstraint violation
E070SchemaMissing required field
E071SchemaAttribute type mismatch
E072SchemaUnknown attribute (closed schema)
E073SchemaValidate constraint violation
E074SchemaRef target not found
E080ValidationDocument validation failed
E090Table@table_index references nonexistent column
E091TableDuplicate value in unique table index
E092TableInline columns defined when schema is applied
E093SchemaBlock uses text block syntax but schema has no @text field
E094Schema@text field validation errors (wrong name or type)
E095SchemaChild not allowed by parent’s @children constraint
E096SchemaItem not allowed by its own @parent constraint
E097SchemaChild count below @child minimum
E098SchemaChild count above @child maximum
E099SchemaSelf-nesting exceeds @child max_depth
E100SchemaSymbol value not in declared symbol_set
E101SchemaReferenced symbol_set does not exist
E102ParseDuplicate symbol_set name
E103ParseDuplicate symbol within a symbol_set

Warnings

CodePhaseDescription
W001ScopeShadowing warning
W002ScopeUnused variable
W003MergeInline 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:

  • Severityerror or warning
  • 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

FeatureJSONYAMLTOMLHCLWCL
CommentsNoYesYesYesYes — line (//), block (/* */), doc (///); preserved by formatter
String interpolationNoNoNoYesYes — "host: ${host}:${port}"
Type systemImplicit (6 types)Implicit (dynamic)Explicit primitivesLimitedFull — primitives, composites, union, ref, any
SchemasExternal (JSON Schema)NoNoNoBuilt-in — first-class schema declarations with constraints
Variables / let bindingsNoAnchors onlyNoLimitedYes — let name = expr
ExpressionsNoNoNoLimited (templates)Yes — full arithmetic, logic, ternary, regex, lambdas
MacrosNoNoNoNoYes — function macros and attribute macros
ImportsNoNoNoHandled by TerraformYes — import "file.wcl" built-in
Query engineNo (external: jq)NoNoNoYes — built-in query(selector | filters)
TablesArrays of objectsSequencesArray of tablesNoYes — typed column declarations with row syntax
Control flowNoNoNofor_each (Terraform)Yes — for loops and if/else
Partial declarationsNoNoNoNoYes — partial blocks merged across files
DecoratorsNoNoNoNoYes — @decorator(args) with schemas
Validation blocksExternalNoNoNoBuilt-in — validation blocks with check and message
LSP supportVia pluginsVia pluginsVia pluginsVia pluginsYes — first-class wcl lsp with full feature set
FormattingExternal (prettier)ExternalExternalExternalYes — wcl fmt built-in
Bidirectional conversionPartialPartialNoYes — 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

FormatBest suited for
JSONMachine-generated config, REST APIs, interoperability
YAMLKubernetes manifests, CI pipelines, human-edited simple config
TOMLApplication config files (Cargo.toml, pyproject.toml)
HCLTerraform infrastructure definitions
WCLComplex configuration with shared logic, schemas, cross-file composition, and tooling