Data and Dates
Four config formats that behave the same way, one that behaves differently on purpose, dates, and the handful of things Lua’s standard library leaves out.
json, yaml, toml
wattle.json.parse(source)
wattle.json.read(path)
wattle.yaml and wattle.toml have the same two. All three return plain Lua tables by default. Pass true as a second argument to get a document that supports JSONPath query and get instead:
local config = wattle.json.read("config.json") -- a table
local doc = wattle.json.read("config.json", true) -- a document
local hosts = doc:query("$.servers[*].hostname")
A null arrives as nil, which means an explicit null and a missing key look identical. Lua tables cannot hold a nil anyway, so there was never a way to tell them apart that did not involve a sentinel you could not test for.
kdl
wattle.kdl.parse(source)
wattle.kdl.read(path)
KDL gets its own shape because a KDL node is not a key and a value. It has a name, positional arguments, named properties, and children, and flattening all four into a table loses three of them.
title "Files and Assets"
order 4
author name="Collectedly" year=2026
tags {
tag "reference"
tag "api"
}
local doc = wattle.kdl.read("page.kdl")
doc.title -- "Files and Assets"
doc.order -- 4
doc.author.name -- "Collectedly"
doc.tags.tag -- "api", the last one wins as a key
local tags = doc:query_all("tags > tag")
tags[1].args[1] -- "reference"
A document indexes by top-level node name, and a node indexes like a table: node[1] is the first argument, node.key is a property or a child node. A node also carries name, args, children and properties. query returns the first match and query_all returns every match, using a subset of KQL.
Indexing a document unwraps a node the same way to_table() does, so a node with one argument and nothing else gives you the argument. Values keep the type they were written as: "42" in quotes stays a string, #true is a boolean, and #null is nil.
date
local d = wattle.date.now()
local d = wattle.date.parse("2026-08-17T09:30:00Z")
Dates compare with < and sort without a comparator, which is most of what a build script wants from a date:
table.sort(posts, function(a, b) return b.date < a.date end)
format(fmt, tz) takes an optional IANA timezone. Also add_days, add_hours, add_minutes, add_seconds, timestamp and to_rfc3339, and the fields year, month, day, hour, minute and second.
_
clone merge split trim truncate aspect_ratio uuid5 dump
clone is a deep copy. merge takes any number of tables with later keys winning, so _.merge(defaults, opts) reads the way it should. split takes a literal separator rather than a pattern, because a separator that is sometimes a pattern is a bug waiting for the first filename with a dot in it.
truncate(s, n, ending) never cuts a word in half, so the result is usually shorter than you asked for. aspect_ratio("16:9", {h = 1080}) returns 1920 and 1080. uuid5(namespace, name) gives a stable identifier, which is what a feed entry needs and what a random one cannot be. dump(value) pretty-prints anything, including parsed documents, and is the fastest way to find out what shape something came back as, rather than the shape you assumed.