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

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+.