The SwiftLua Language Guide
This is the full reference for writing SwiftLua scripts. The language is a deliberate subset of Lua with defined behaviour; it is not "just Lua". Where Lua leaves behaviour unspecified, SwiftLua pins it down, and where a Lua feature would get in the way of safety or repeatable results, SwiftLua limits it or leaves it out. If you know Lua, read Differences from Lua first. New to scripting, or coming from JavaScript or TypeScript? Start with Getting started and come back here for the precise rules.
Scripts run inside an app, in a sandbox: a script can use exactly the values, functions and modules the app provides, and nothing else. Every run has limits (on instructions, call depth, memory, table sizes and string sizes) and stops at the same point every time one runs out.
In the examples, --> shows what a line produces, and --> error: … shows that it
stops with an error saying so.
Safe navigation (dialect extension)
Use ?.field or ?[key] when a receiver may be nil:
local response = {data = {me = {user = {email = "a@b"}}}}
response.data?.me.user?.email --> "a@b"
local optionalList: any = nil
optionalList?[1] --> nilOnly nil short-circuits. The receiver is evaluated once; if it is nil, the
result is nil and the rest of that continuous access chain is skipped, including
computed keys, method lookups, calls and their arguments. For a non-nil receiver,
ordinary indexing and metatable behaviour apply. false is not treated as nil.
Errors from an executed lookup or call still propagate.
Each ? guards its own receiver. With a?.b.c, nil a skips both accesses,
but a present a whose b is nil still errors at .c. Use a?.b?.c if both
may be absent. Parentheses end the chain: (a?.b).c performs .c even when
a?.b returned nil. Binary operators also end the chain; a?.b + 1 still
attempts the addition, while a?.b or fallback uses the usual Lua fallback rule
(which also replaces false).
The checker checks accesses against the non-nil receiver type and includes nil
in the result when the receiver may be absent. Unknown fields and invalid index
types remain errors. Safe chains cannot be assignment targets and always produce
exactly one value, including when they end with a function call. A chain ending
in a call may stand alone as a statement, for example receiver?.notify().
There is no ?() safe-call operator, ?: safe-method operator, or ?? operator.
Each continuous chain allows at most 32 safe guards (the CoCoCo app may set a lower
parser nesting limit). This syntax is shared by every engine of this dialect; stock Lua and
Luau do not accept it. Optional type annotations such as string? are unchanged.
Key points
If you take four things from this guide, take these:
- Reading an undefined global is an error. It doesn't give
nil, so typos fail loudly. - There are no iterators, coroutines, redefinable operators,
iooros. If you reach for one, this guide lists the alternative, or the thing genuinely can't be done. - Multiple returns follow Lua 5.1. A call in the last position of a list expands to
all its values; write
(f())to keep just one (§4). - Only what §8 lists exists. The standard library is a closed set:
next,string.gmatchandmath.random, among others, aren't there. Don't assume a name is present because it's standard elsewhere.
1. Values and types
nil, booleans, integers (64-bit, checked), numbers (64-bit floating point), strings
(immutable Unicode text), tables and functions. There is no userdata type and there are
no coroutines; metatables are limited (§5).
- Integers and numbers. Integer literals stay integers, and arithmetic between
integers stays whole except for
/and^, which always give a number. Integer overflow is an error; it never wraps round.//rounds down (towards negative infinity),%takes the sign of the divisor, and whole-number//or%by zero is an error. Floating-point division by zero gives infinity (1/0 == math.huge). - Strings are Unicode text.
#s,string.len,string.subandstring.bytecount UTF-8 bytes, and an operation that would split a character in the middle is an error rather than producing broken text...joins strings and turns integers and numbers into text ("n=" .. 42gives"n=42"); any other type is an error. Escapes\128to\255aren't allowed in string literals, because strings are always valid text. - Truthiness:
nilandfalseare false; everything else, including0and"", is true.andandorreturn one of their operands and stop as soon as the answer is known. - Equality works across the two numeric forms (
1 == 1.0), and values of different types are simply unequal, never an error. Ordering (<,<=) needs two numbers or two strings (compared byte by byte); anything else is an error, evennil <= nil. - No automatic conversion in arithmetic:
"42" + 0is an error. Convert withtonumber(§8).
7 // 2 --> 3
-7 // 2 --> -4
-7 % 3 --> 2
7 / 2 --> 3.5
1 == 1.0 --> true
1 == "1" --> false
"n=" .. 42 --> "n=42"
0 and "yes" or "no" --> "yes"
math.maxinteger + 1 --> error: overflow
(tonumber("42") or 0) + 8 --> 502. Variables and scope
local x = 1 -- a local, visible to the end of its block
local a, b = 1, 2 -- several at once
x = 5 -- assignment
x --> 5Scope follows position, exactly as in Lua: in local a = a + 1 the right-hand side sees
the outer a. Reading a global that doesn't exist is an error (unknownGlobal);
Lua would quietly give nil, but SwiftLua treats it as the bug it usually is. In the
default strict mode, creating a global is reported before the script runs, so declare
locals instead:
g = 10 -- the checker reports this: declare it with local3. Statements
| Form | Notes |
|---|---|
if … then … elseif … else … end |
as in Lua |
while cond do … end |
as in Lua |
repeat … until cond |
the body runs at least once; the condition can see the body's locals |
for i = a, b [, step] do … end |
counting loop; both bounds included. A step of 0 is an error and the bounds must be numbers. If any of start, limit or step is a floating-point number, the loop variable is one on every pass; it is an integer only when all three are integers |
for k, v in ipairs(t) do … end |
the array part, in order (see §5) |
for k, v in pairs(t) do … end |
the whole table, in a defined order (see §5) |
break |
leaves the innermost loop; outside a loop it is an error |
do … end |
an explicit block, for scope |
return … |
see §4: several values are allowed, up to a limit |
local function f(…) … end |
a local function that can call itself |
function f(…) … end |
a global function |
There is no goto, no continue, and no compound assignment such as +=.
The loop variable of a counting for is a fresh copy on each pass, so assigning to it
inside the body doesn't change how many times the loop runs.
function t.m() end and function a.b:m() end are shorthand for assigning a field; the
: form adds a hidden first parameter called self. Dotted paths can be as long as you
need. Both are exactly the same as t.m = function(…) … end.
local tries = 0
repeat
local done = tries >= 2
tries = tries + 1
until done -- the condition sees the body's local
tries --> 3
local passes = 0
for i = 1, 3 do i = 100; passes = passes + 1 end
passes --> 3
local out = {}
for i = 1, 3.0 do out[#out + 1] = tostring(i) end -- a float bound gives float values
string.join(" ", out) --> "1.0 2.0 3.0"
local counter = { n = 0 }
function counter:bump(by) self.n = self.n + by end
counter:bump(5)
counter.n --> 54. Functions and multiple returns
Functions are ordinary values, and they capture the variables around them (closures).
Missing arguments arrive as nil and extra ones are dropped, as in Lua.
Multiple returns follow Lua 5.1: a call, method call or ... in the last
position of a list expands to all of its values; anywhere else it gives just its first
value.
local function divmod(a, b) return a // b, a % b end
local q, r = divmod(17, 5) --> q = 3, r = 2
q, r = divmod(9, 2) --> q = 4, r = 1
local x, y = divmod(9, 2), 7 --> x = 4, y = 7
local a, b, c = divmod(9, 2) --> a = 4, b = 1, c = nilThe rules in full:
- Return lists. The last expression in a
returnpasses on every value:return f(args)andreturn 0, f(args)both pass all off's results, limited by how many the caller takes.return f(args)is also a proper tail call, so tail recursion runs in constant stack depth. Parentheses cut the list to one value, as in Lua:return (f())returns exactly one. - A call in the last position of a
localdeclaration or assignment fills the remaining variables, padding withniland dropping extras. - A call in the last position of an argument list (including
pcall's) or a table constructor expands too:g(f())passes every value off, and{f()}holds them all. - Each expansion carries at most 250 values (an app can set a different limit), and so
does a whole argument list that one expands into; more is an error you can catch. So a
function already holding 250 extra arguments can pass
...on alone, but notf(x, ...). - The value your script returns to the app, and the value a module exports (§6), are
single values: the first, if there are several. A function from the app normally
returns one value, but some return a result and an error (
local res, err = f(...)) or several values. Functions that wait on the outside world return(ok, res)instead (§12).
A function from the app that returns a result and an error works like pcall, except
the app decides what the error says:
local function total(text)
local qty, err = parseQuantity(text)
if not qty then return "failed: " .. tostring(err) end
return qty * 5
end
total("4") --> 20
total("x") --> "failed: not a quantity"Variable arguments (...). A function whose parameter list ends in ... takes any
number of extra arguments. Inside it, ... is those arguments: select("#", ...) counts
them, select(n, ...) gives them from position n, and {...} or table.unpack moves
them around. ... follows the same rules as a call:
local function log(fmt, ...) return string.format(fmt, ...) end
log("%d/%d", 3, 4) --> "3/4"
local function count(...) return select("#", ...) end
count("a", nil, "c") --> 3
local function firstTwo(...)
local a, b = ... -- last position: fills two, padded with nil
return a, b
end
local p, q = firstTwo(1) --> p = 1, q = nil
local function pack(...) return {...} end
pack(1, 2, 3) --> {1, 2, 3}A ... that isn't last (for example {..., x} or local a, b = ..., 7) gives one
value, exactly like a call that isn't last. In the main script, ... holds any
arguments the app passes when it runs the script; most apps use inputs instead, so it is
usually empty.
5. Tables
A table has two parts: a dense array part (keys 1…n, with #t == n) and a map
part for every other key. A floating-point key with a whole value is the same key as
the integer (t[1.0] is t[1]), and nil or NaN keys are errors.
Holes are defined (Lua leaves them unspecified): setting the last element to nil
removes it; setting one in the middle to nil ends the array part there and moves the
rest into the map part; and writing at #t + 1 appends, pulling in any map entries that
continue the sequence.
local t = { "a", "b", "c" }
t[3] = nil
#t --> 2
t[#t + 1] = "z"
t --> {"a", "b", "z"}Iteration is defined and repeatable. A generic for accepts exactly two forms,
ipairs(expr) and pairs(expr), written directly in the loop. You can't pass iterators
around, and redefining ipairs or pairs makes the loop an error rather than a
different call.
ipairs(t)walks the array part in index order. The number of passes is fixed when the loop starts (in standard Lua it is live), so changing the array inside the loop doesn't change how many times it runs.pairs(t)walks the array part in index order and then the map part in the order keys were added. Lua's order is unspecified; SwiftLua's is the same on every run and every platform.pairsfollows the table as it changes, by position: adding entries during the loop extends it, and removing one shifts the later entries. (Lua forbids adding during traversal; SwiftLua defines what happens instead.)
local keys = ""
for k in pairs({ 10, 20, zebra = 1, apple = 2 }) do
keys = keys .. tostring(k) .. " "
end
keys --> "1 2 zebra apple "Table constructors ({1, 2, x = 3, [k] = v}) accept positional, named and computed
fields, evaluated left to right.
Limited metatables. setmetatable(t, mt) and getmetatable(t) exist and honour
three fields. __index (a table, or a function (t, k)) answers reads of keys t
doesn't have, following a chain of tables; __newindex (a table, or a function
(t, k, v)) receives writes to keys t doesn't have; and __tostring gives the
text for tostring, print and inspect. A chain can be at most 8 tables long: longer,
or circular, is an error. rawget(t, k) and rawset(t, k, v) bypass both hooks.
pairs, #, table.size, the standard library and the values handed back to the app
all see a table's own entries only. setmetatable refuses every other field
(__eq, __call, __gc, __mode, __metatable, __len, __concat and the operator
fields), so scripts can't redefine operators. Frozen tables can't have a metatable.
Operators do work on some types the app provides, such as Decimal
(price * qty + shipping), but that is a separate mechanism, not a metatable, so writing
an __add is refused even then. See "Sealed types" below.
Metatables are exactly enough for the usual class pattern:
local Account = {}
Account.__index = Account
function Account.new(owner)
local self = setmetatable({}, Account)
self.owner = owner
self.balance = 0
return self
end
function Account:deposit(amount)
self.balance = self.balance + amount
return self.balance
end
local acct = Account.new("Ada")
acct:deposit(50) --> 50
acct:deposit(25) --> 75
local Point = {}
Point.__index = Point
Point.__tostring = function(p) return "<Point " .. p.x .. "," .. p.y .. ">" end
tostring(setmetatable({ x = 1, y = 2 }, Point)) --> "<Point 1,2>"Sealed types (operators)
Some types come from the app, sealed: only the app can create one, and the app decides
which operators it supports. Decimal, from the decimal library, is the main example:
local price, qty, shipping = decimal.new("19.99"), 3, decimal.new("4.95")
local total = price * qty + shipping -- a Decimal: exact, no floating-point rounding
tostring(total) --> "64.92"
total > 0 --> true
decimal.new("1") == decimal.new("1.000") --> true- The checker knows the result types:
Decimal + Decimalis aDecimal, andDecimal < Decimalis aboolean, solocal t: Decimal = price * qtychecks. - An integer works alongside a decimal (
price * 3). A floating-point number doesn't:price + 0.1is an error, because a binary fraction has no exact decimal value. That refusal is the point. - Comparison is by value, as the last line above shows.
Three things this is not. It isn't metatables: setmetatable refuses an __add
whenever it is written. It isn't something a script can declare: only the app can seal a
type. And it can't be forged: a sealed value carries a hidden mark that pairs, rawget
and returned values never show and no script can set, so {units = 10, scale = 0} is not
a decimal, however decimal-like it looks.
6. Modules and require
Where the app provides modules, require loads one while your script runs:
local tax = require("tax") -- a literal name
local rules = require("rules-" .. region) -- names can be worked out, too
if 100 >= rules.threshold then
return tax.apply(100)
end
return 100- Names are looked up only by the app. There is no file system, no search path and
no way to turn text into code. If the app provides no modules,
requiredoesn't exist. - A module's top level runs as part of your run, within your limits. Its one return
value (usually a table of functions) is what
requiregives you. - Each module loads once per run: requiring the same name again returns the same value. Separate runs load separately, so a module's state never carries over between runs.
- A module that requires itself, directly or in a circle, is an error (
moduleError), as is a name the app doesn't know (moduleNotFound). There is a limit on how many modules one run can load. - Modules share the run's globals, as in Lua, so a module that changes a global changes it for the script that required it (and the checker reports creating one, as anywhere).
requireis always called directly with one argument. Storing it (local r = require) isn't supported.- Loading may take a moment. Some apps fetch modules from storage or over the
network. Your
require("x")looks and behaves the same either way: the run simply waits for the module (see §12). - Checked across modules. When a module is named literally (
require("tax"), notrequire(name)), the checker reads the module's own source to learn what it exports. Sorequire("tax").aplyis caught as a typo, and a chain such asrequire("client").new(key):method()is checked all the way through. Names that are worked out at run time aren't checked.
Some apps also offer a loader under a name of their own, for example to load a saved
script by name. It behaves exactly like require, including loading each name once per
run, and returns the module itself, so its functions are callable. A literal name is
checked just like require("name"), and a module's exported types (export type) can be
named as mod.Name.
7. Method calls
receiver:name(args) works without metatables:
- On a string, the method comes from the string library:
s:upper()isstring.upper(s), and("%d!"):format(7)works. - On a table, it looks up the field and passes the table as the first argument:
t:scale(4)ist.scale(t, 4). - On anything else it is an error. A missing method is a
notCallableerror. - The receiver is evaluated exactly once. The argument list is required:
a:bon its own is an error, as in Lua.
("hello"):upper() --> "HELLO"
("%d!"):format(7) --> "7!"
local shape = { size = 3, scale = function(self, by) return self.size * by end }
shape:scale(4) --> 128. The standard library
Only what is listed here exists, and every library table is frozen (writing to it is an
error). All the functions give the same results everywhere: there are no locale
settings, and no clock or randomness except where the app provides them. time.now()
exists only when the app provides a clock, and uuid.v4/uuid.v7 only when it provides
a source of randomness (see "time" and "uuid" below). The other time and UUID functions
always give the same result for the same input.
Basic: assert(v [, message]); error([value [, level]]) (any value; level 2
blames the caller, 0 adds no position); type(v); tostring(v); tonumber(v [, base])
(reads a number from text, or passes a number through, and gives nil otherwise; it
never fails, so it works as a check; leading zeros and surrounding spaces are accepted,
base can be 2–36, and a 0x prefix reads hexadecimal; note that arithmetic still does
not convert strings); inspect(v); pcall(f, …) and xpcall(f, handler) (§10);
select("#" | n, …); unpack(t [, i [, j]]) (also table.unpack); rawequal(a, b);
_VERSION; and setmetatable, getmetatable, rawget, rawset (§5).
tonumber("42") --> 42
tonumber(" 7 ") --> 7
tonumber("ff", 16) --> 255
tonumber("abc") --> nil
type(tonumber) --> "function"print(…) exists only when the app allows it; otherwise the name doesn't exist. It
writes any number of values as tostring would, separated by tabs, with no memory
addresses. The app decides where the output goes (a run log, for example), and the
amount of output is limited: going over the limit ends the run (logLimitExceeded) and
can't be caught. Trailing nil arguments are dropped; nils in the middle print as
nil.
inspect(v) shows a value's structure, for debugging. Tables appear as
{1, 2, key = value}: the array part first, then map entries in pairs order, with
names written bare and other keys in brackets. Strings are double-quoted with \n-style
escapes, nesting stops at depth 4 (<deep>) and loops are marked <cycle>. The format
is exact and the same on every platform, and never shows addresses or identities.
inspect({ 1, 2, name = "Ada", ["two words"] = true }) --> '{1, 2, name = "Ada", ["two words"] = true}'
inspect("line\nbreak") --> '"line\\nbreak"'math: floor, ceil, round (halves away from zero) and trunc (towards zero)
all return integers and are errors on overflow; round(x, places) rounds to 0–12
decimal places and returns a number; abs, min and max (any number of values),
sqrt; clamp(x, lo, hi) (reversed bounds are an error); sign(x) (−1, 0 or 1; NaN is
an error); lerp(a, b, t); approx(a, b, epsilon) (NaN is never approximately
anything); isinteger(x) (true for integers, and for numbers that are exactly whole);
fmod, modf, frexp, ldexp, deg and rad; exp, log(x [, base]), log10,
pow, sin, cos, tan, asin, acos, atan(y [, x]), atan2, sinh, cosh
and tanh; and the constants pi, huge, maxinteger and mininteger.
fmod to rad give exactly the same result everywhere. exp, log, pow and the
trigonometric and hyperbolic functions can differ in the last digit between platforms,
so compare their results with math.approx rather than ==. There is no
math.random: scripts give the same result every time they run.
math.round(2.5) --> 3
math.round(-2.5) --> -3
math.round(3.14159, 2) --> 3.14
math.clamp(15, 0, 10) --> 10
math.isinteger(4.0) --> true
math.max(3, 9, 4, 1) --> 9
math.fmod(7, 3) --> 1
math.deg(math.pi) --> 180.0
math.approx(math.sin(math.pi / 2), 1, 1e-12) --> truestring (counting bytes; see §1): len(s), upper(s), lower(s),
sub(s, i [, j]) (Lua's index rules; negative positions count from the end),
rep(s, n [, sep]), find(s, pattern [, init [, plain]]) (patterns: see below),
byte(s [, i [, j]]), char(…), format(fmt, …), split(s, sep) (the separator is
plain text and can't be empty; empty fields are kept), join(sep, list)
(table.concat with the separator first; elements must be strings or numbers),
isempty(s), lines(s) (splits on \n and drops one trailing \r per line, so Windows
line endings work; a final newline doesn't add an empty last line, and "" gives no
lines), trim, ltrim and rtrim (ASCII whitespace), startswith, endswith and
contains(s, part), replace(s, old, new [, count]) (plain text, never patterns; an
empty old is an error), padleft and padright(s, width [, fill]) (a single fill
character) and reverse(s) (reverses by character, the same on every platform).
upper and lower change the case of all letters, not just ASCII ones.
string.split("a,,b", ",") --> {"a", "", "b"}
string.join("-", { 1, 2, 3 }) --> "1-2-3"
string.lines("one\r\ntwo\n") --> {"one", "two"}
string.padleft("7", 3, "0") --> "007"
(" hi "):trim() --> "hi"
string.upper("straße é") --> "STRASSE É"
string.replace("a.b.c", ".", "/") --> "a/b/c"String patterns. SwiftLua supports the full Lua 5.4 pattern language: the
classes %a %d %s %w %l %u %p %c %x and their uppercase opposites, ., sets [..] and
[^..] with ranges, the repeats * + - ?, the anchors ^ and $, captures ()
(including position captures), the back-references %1–%9, %b and %f. Matching is
metered: a pattern that would take an extreme amount of backtracking ends the run
with patternComplexityExceeded, which pcall can't catch, while a badly formed
pattern is an ordinary error you can catch. The pattern functions:
find(s, pat [, init [, plain]])gives(start, end, captures…)(1-based, both ends included) ornil. It uses patterns unlessplainistrue.match(s, pat [, init])gives the captures as separate values (or the whole match when the pattern has none), ornil, as in Lua.captures(s, pat [, init])gives an array of the captures (position captures as integers), or{wholeMatch}if the pattern has none, ornil.matchall(s, pat)gives an array of every matching piece of text. It replaces Lua'sgmatch, because a genericforaccepts onlypairsandipairs(§5).gsub(s, pat, repl [, n])gives(result, count).replcan be a string (%0is the whole match,%1–%9the captures and%%a literal%), a table looked up by the first capture, or a function called for each match (returning the replacement, ornil/falseto keep the original). Becausegsubreturns two values, wrap it in parentheses,return (string.gsub(s, " ", "-")), when you want only the text.
string.find("order-1042", "%d+") --> 7
string.match("order-1042", "(%a+)-(%d+)") --> "order"
string.matchall("a1 b22 c333", "%d+") --> {"1", "22", "333"}
local out, n = string.gsub("a-b-c", "-", "+") --> out = "a+b+c", n = 2Limits worth knowing:
string.formatsupports%d %i %u %c %o %x %X %e %E %f %g %G %q %s %%with the- + space # 0flags; width and precision are at most 99.%swrites the value astostringwould. Anything else is an error.
string.format("%05.1f|%-4s|%x", 3.14159, "ab", 255) --> "003.1|ab |ff"
string.format("%d-%d-%d-%d-%d", 1, 2, 3, 4, 5) --> "1-2-3-4-5"table: queries and slices: insert(t [, pos], v) (appends, or inserts at pos and
moves later elements up); remove(t [, pos]) (removes the last element, or the one at
pos, moving the rest down, and returns it; a pos out of range is an
invalidArgument error); concat(t [, sep]); keys(t), values(t) and items(t)
(arrays in pairs order; items gives {key = k, value = v} records, and values
holds the same tables rather than copies); indexof(t, v) (the position in the array
part, or nil); contains(t, v) (anywhere in the table); has(t, k) (the key has a
non-nil value; false counts as present); size(t) (all entries, where #t counts
only the array part); isempty(t); slice(t, i [, j]) (Lua's index rules);
reverse(t) (a new, reversed array); take(t, n) and drop(t, n) (split the array
part by count; counts below zero count as zero); append(t, v1 [, …v4]) (adds to the
array part; nil arguments are skipped); clear(t) (removes every entry in place); and
flatten(t) (one level; every element must be a table).
local list = { "a", "b", "c", "d" }
table.slice(list, 2, 3) --> {"b", "c"}
table.indexof(list, "c") --> 3
table.take(list, 2) --> {"a", "b"}
table.size({ 1, 2, x = 3 }) --> 3
table.flatten({ { 1, 2 }, { 3 } }) --> {1, 2, 3}table: copying and merging: clone(t) (a shallow copy of the table's own entries;
metatables aren't copied); deepcopy(t) (copies all the way down; shared tables and
loops are kept, metatables aren't copied); merge(a, b) (a new table, where b's
entries win); extend(dst, src) (copies src into dst and returns dst); and
defaults(t, d) (fills in only the keys that are nil in t; an existing false
stays).
table.merge({ a = 1, b = 2 }, { b = 3 }) --> {a = 1, b = 3}
table.defaults({ a = false }, { a = 1, b = 2 }) --> {a = false, b = 2}table: functions that take a callback work on the array part, in order. Your
callback runs as ordinary script code, with the usual limits and error rules, and
receives (value, index); you can leave out parameters you don't need, so
function(v) works everywhere, and so does passing a built-in such as tostring
directly. map(t, f) and collect(t, f) (two names, one
operation); filter(t, f); find(t, test) and findindex(t, test) (a function is
used as a test, and anything else is compared for equality; nil when absent);
reduce(t, f, initial) (combines left to right; f receives (acc, value, index));
each(t, f); any(t, test) and all(t, test); count(t [, test]) (the length, or the
number that pass); partition(t, test) (returns one table, with the passing
elements at [1] and the rest at [2]); group_by(t, keyfn) (a map of arrays: keys in
the order first seen, each group in input order); tomap(t, keyfn, valuefn) (later
elements win a key clash); unique(t) and unique_by(t, keyfn) (the first occurrence
wins, order kept); and sort(t [, less]), which sorts t in place and returns
nothing, as Lua's does,
while sorted(t [, less]) and sort_by(t, keyfn [, less]) return a new sorted
array. All three are stable: equal elements keep their order. Without a comparison
function, the elements must be all numbers or all strings (a mix is an error); a
comparison function receives (a, b) (the derived keys, for sort_by) and returns true
when a comes first. A callback that itself calls one of these functions can nest only
so deep: very deep nesting is a callDepthExceeded error.
local nums = { 5, 3, 8, 1 }
table.map(nums, function(v) return v * 2 end) --> {10, 6, 16, 2}
table.filter(nums, function(v) return v > 2 end) --> {5, 3, 8}
table.reduce(nums, function(acc, v) return acc + v end, 0) --> 17
table.sorted(nums) --> {1, 3, 5, 8}
nums --> {5, 3, 8, 1}
table.partition(nums, function(v) return v % 2 == 1 end) --> {{5, 3, 1}, {8}}
table.map({ 1, 2 }, tostring) --> {"1", "2"}
table.sort(nums)
nums --> {1, 3, 5, 8}
local people = { { name = "Ada", team = "x" }, { name = "Bo", team = "y" }, { name = "Cy", team = "x" } }
local byTeam = table.group_by(people, function(p) return p.team end)
#byTeam.x --> 2
table.sort_by(people, function(p) return p.name end)[1].name --> "Ada"set: new(list) (duplicates collapse), has(s, v), add(s, v) and remove(s, v)
(change the set and return it), union(a, b), intersection(a, b) and
difference(a, b) (new sets), and tolist(s) (members in the order first added). A set
is a plain table mapping each member to true, and members can be any valid table key.
local seen = set.new({ "a", "b", "a" })
set.has(seen, "a") --> true
set.tolist(set.union(seen, set.new({ "c" }))) --> {"a", "b", "c"}Handy globals: default(v, fallback) (the fallback only when v is nil; false
passes through); coalesce(a [, …d]) (the first non-nil of up to 4); pipe(x, f1 [, …f4]) (applies the functions left to right; with none it returns x);
assertf(cond, fmt [, …3 values]) (assert with a string.format message, built only
when it fails); and typecheck(v, expected [, name]) (an error you can catch, naming the
problem, or returns v).
default(nil, 5) --> 5
default(false, 5) --> false
coalesce(nil, nil, "third") --> "third"
pipe(3, function(x) return x + 1 end, function(x) return x * 10 end) --> 40time gives instants and durations as values that can't be changed: each method
returns a new value, so calls chain. Only fixed offsets from UTC are supported (no
named time zones or daylight saving). Create an instant with
time.new({year=, month=, day=, hour=, minute=, second=, millis=, offset=}) (the
parentheses are required; missing parts take defaults and an impossible date is an
error), time.fromepoch(ms [, offsetMinutes]) or time.parse(isoString) (ISO 8601).
Durations come from time.period({days=, hours=, minutes=, seconds=, millis=}) or
time.millis, seconds, minutes, hours and days(n).
Instant methods: :epoch_millis(), :offset(), :year(), :month(), :day(),
:hour(), :minute(), :second(), :add(p) and :subtract(p) (giving an instant),
:diff(other) (giving a duration), :before, :after and :equals(other),
:start_of_minute, _hour, _day, _month and _year(), :to_offset(minutes),
:with({…}) and :format([fmt]) (the %Y %m %d %H %M %S %z %% codes; with no
format, ISO 8601). Duration methods: :millis(), :add(p) and :negate().
time.now() gives the time of the run, but only when the app provides a clock;
otherwise it doesn't exist.
local start = time.parse("2026-03-30T09:15:00Z")
start:add(time.days(3)):format("%Y-%m-%d") --> "2026-04-02"
start:start_of_month():format() --> "2026-03-01T00:00:00Z"
time.parse("2026-03-31T00:00:00Z"):diff(start):millis() --> 53100000
start:to_offset(60):hour() --> 10uuid: uuid.parse(s) (the canonical lowercase form, or nil), uuid.is_valid(s),
uuid.version(s) and uuid.nil_uuid() always exist. Creating new ones depends on the
app: uuid.v4() needs a source of randomness, and uuid.v7() needs randomness and a
clock, because it includes the time. Without them, the name doesn't exist.
uuid.parse("0190A5C8-8C3E-7B4A-9F21-3D6C2A1B0E9F") --> "0190a5c8-8c3e-7b4a-9f21-3d6c2a1b0e9f"
uuid.version("0190a5c8-8c3e-7b4a-9f21-3d6c2a1b0e9f") --> 7
uuid.is_valid("not-a-uuid") --> falseThe extra libraries (json, base64, hex, url, csv, decimal, stats,
template, bit32, hash, tpath, validate and text) exist only where the app
offers them; see Extra libraries. Don't assume they're there.
Not present: io, os, debug, package and coroutine; load, loadstring and
dofile; next, collectgarbage, _G, getfenv and setfenv; math.random and
string.gmatch; goto; and first-class iterators of any kind. require and print
exist only where the app provides them. The bitwise operators & | ~ << >> don't
exist: they are syntax errors, found before the script runs. Use the bit32 extra
library instead.
Some things are deliberately missing:
| Item | Why |
|---|---|
iter.* (lazy iterators) |
pairs and ipairs are part of the loop syntax (§5), so first-class iterators don't fit. Use the table functions that take a callback. |
fun.identity, constant, partial, compose, noop |
Each is a one-line Lua function; pipe covers composition. |
string.escape_pattern |
Not yet provided. |
class(base) |
It would need __call, which is refused. The class pattern in §5 does the job. |
serialize(value) |
Not yet provided: inspect is for people to read, not for loading back. |
module.new |
A module is a plain table and needs no helper (§6). |
require_optional(name) |
Use pcall(function() return require(name) end): a missing module is an error you can catch. |
reload(name) |
Modules load once per run by design, and nothing is kept between runs to reload (§6). |
path.* |
Not yet provided. |
| A bytes type | Binary data stays with the app, as objects (§11), and the app's functions work on it. Strings are always text. |
9. Type checking
Scripts are checked before they run. The checking mode goes on the first line:
--!strict(the default): unknown globals, unknown fields, wrong argument types or counts, and creating globals are errors. Local variables take their types from their first value, a local function's parameters from how they're used in arithmetic, and table literals are recognised as arrays or records.--!nonstrict: those findings become warnings; outright contradictions (calling a number) are still errors. So are an unknown field on a record whose fields are all known, and an unknown global that the script never assigns and CoCoCo does not provide, because neither can exist at run time.--!nocheck: no checking.--!strict-nil: strict, and reading an array or map element gives an optional type, so check it (if v then …) or give a default (v or fallback) before using it. Declared record fields don't count, because they are a promise.
--!strict-nil
local prices: {number} = { 10, 20 }
return prices[5] + 1 -- reported: prices[5] may be nil
--!strict-nil
local prices: {number} = { 10, 20 }
return (prices[5] or 0) + 1 -- fineUnder --!nonstrict, softer findings (a wrong argument type, a global read before the
script assigns it) are warnings rather than errors, which helps when adding types
gradually, while real contradictions stay errors: calling a number, an unknown field on a
known type, or a global the script never assigns and CoCoCo does not provide (such as
os.time()), because that read can only fail when it runs.
--!doc comments. A --!doc <description> line describes what code is for, for the
people and tools that read it. In a module, a --!doc at the top describes the module,
and one (or several lines of them, which join up) just above an exported function or
field (function M.fn() or M.fn = …) describes that function or field. It has no
effect when the script runs.
Type annotations. Local variables, parameters and return values can be annotated. This is a SwiftLua addition to Lua's syntax:
local total: number = 0
local names: {string} = {}
local maybe: integer? = nil
local rates: {[string]: number} = { GBP = 1.25 }
local function apply(amount: number, code: string): number
return amount * rates[code]
end
apply(8, "GBP") --> 10The type forms are: a name, T? (optional), {T} (array), {[K]: V} (map), {} (the
empty record), A | B (either), (T, …) -> R (function), (T) (grouping) and literal
types ("active", 42, true, nil), combined freely. A function may return several
values ((T) -> (A, B), () -> (), function f(): (A, B)), checked one by one:
local id: integer | string = 1
local s: "active" | "archived" = "active"
local lookup: {[string]: number} = {}
local maybe: Status | nil = nil -- the same as Status?
local double: (number) -> number = function(x) return x * 2 end
local callback: ((number) -> number)? = nil -- grouping for an optional function
local function minmax(xs: {number}): (number, number)
return math.min(xs[1], xs[2]), math.max(xs[1], xs[2])
end
double(21) --> 42
local lo, hi = minmax({ 9, 4 }) --> lo = 4, hi = 9Names other than the built-in ones (like Status above) are types the app provides. The
| and -> symbols exist only inside annotations; they aren't operators. -> binds
loosest: (number) -> string | nil is a function returning string | nil.
Naming your own types. Name a shape with type, including records:
type Line = { sku: string, qty: number }
type Order = { lines: {Line}, status: "open" | "closed" }
type Node = { value: number, next: Node? } -- a type can refer to itself
local function total(order: Order): number
local sum = 0
for _, line in ipairs(order.lines) do sum = sum + line.qty end
return sum
end
local chain: Node = { value = 1, next = { value = 2 } } -- optional fields can be left out
total({ lines = { { sku = "A", qty = 4 } }, status = "closed" }) --> 4A record can leave out its optional fields: a missing key is nil, and writing k = nil
stores nothing, so the two are the same.
A type name applies to the whole file from where it's declared and only affects
checking: the statement does nothing when the script runs. type is still the ordinary
function (type(x)); only the exact form type Name = … declares a type. Declaring a
name twice, or reusing the name of a type the app provides, is an error: the app's names
come first.
Casts. expr :: T tells the checker a type it can't work out itself:
type Order = { lines: {any}, status: "open" | "closed" }
local order = json.decode(payload) :: Order -- you know the shape; the checker doesn't
order.status --> "open"
local x: any = 2
1 + x :: number --> 3:: binds tightly: 1 + x :: number casts x, not the sum. A cast between unrelated
types (5 :: string) is an error: casts move between related types, they don't jump.
Casts only affect checking. When the script runs, the value is whatever it really is, and
every run-time rule and limit still applies, whatever a cast claims.
A cast is not a call: f() :: T in the last position gives one value. It does
not fill the remaining variables or return values the way a bare f() would (see the
rules in §4). An annotation is a promise: it takes priority over what the checker would
work out, values assigned must fit it, annotated parameters type the function body and
every call, and a return annotation applies to every return. Annotations only affect
checking.
The app's names are typed. Everything the app provides carries its real type into
checking. The app's functions are checked against their declared parameters, including
grouped ones (if the app declares orders.fetch(id: integer), then
orders.fetch("x") is reported, however deeply the names are grouped). Data the app
provides is typed by its actual shape (with a config value that has a field
retries, config.retires is reported as an unknown field, including in nested tables
and array elements). Objects, enums and modules from the app bring their types too, as
described in this section and in §6 and §11. An app can still declare a deliberately
looser type for a value whose shape varies from run to run. Some of the app's functions
are generic: a function declared as pick(items: {T}, chooser: (T) -> boolean): T
gives a result typed by what you passed, and the chooser's parameter takes its type from
the elements, exactly like the built-in table functions.
local ok, order = orders.fetch("x") -- reported: id must be an integerEnums from the app. When the app provides an enum, your script gets a frozen table of its values, each named after itself, and the enum's name is a type: any one of its values.
local s: Status = Status.active
s --> "active"
transition(s) --> "now active" -- the app's functions can require the type
s = Status.archived -- fine
s = "deleted" -- fine: the text is one of the values
local s: Status = "activ" -- reported, listing the allowed valuesA misspelled value (Status.actve) is an error, as is assigning anything that isn't one
of the values, including a plain string variable. The app also checks at run time,
even under --!nocheck (an invalidArgument error). Two rules keep enums ordinary:
enum values join, compare and take methods like the strings they are; and a local with
no annotation, set from a value (local x = Status.active), is a plain string, so
annotate it to keep the enum type.
--!nocheck
local s = "activ"
transition(s) -- stopped when the script runsGrowing a table. A local that starts as a table literal gains fields as you add them, so the usual way of building a table is fully typed:
local cfg = {} -- an empty record
cfg.retries = 3 -- cfg now has retries: number
cfg.label = "prod" -- and label: string
cfg.retries --> 3
local cfg = {}
cfg.retries = 3
return cfg.retires -- reported: an unknown field (a typo caught)local function newJob(name: string)
local job = { name = name, id = nil } -- id declared here, for later
job.dueAt = "tomorrow" -- still being built: the field is added
return job
end
local job = newJob("print") -- fixed from here on
job.id = "job-1" -- fine: id was declared
job.id --> "job-1"Writing a new field adds it; reading a field that isn't there is an error; and writing
an existing field must fit its type (m.weight = "heavy" into a number field is an
error). A declared type is enforced strictly; a type the checker worked out allows
widening between numbers, nil, and changes of table shape, just as a local can be
reassigned. Everywhere else the shape is fixed: an annotation is a promise
(local p: Point doesn't grow), a second name for the table (local u = t) sees the
shape at that point, and a function sees the table as it was when it was called, so a
table returned from a function is fixed for the caller. To fill a field in later, include
it when the table is built (id = nil, which lets the field hold any type) or annotate it
with a type that has an optional field; the error on the write says so. Array literals stay arrays: a table mixing an
array and fields needs a declared record or map type.
A record or array literal can still be used where a map type is expected: a record
{a = 1, b = 2} fits {[string]: number}, and an array {1, 2, 3} fits
{[integer]: number} (each field or element must fit the map's value type). So
table.merge({a = 1}, {b = 2}) and other functions that take maps check with plain
literals.
Typed standard library. The table functions that take callbacks are fully typed: the
callback's (value, index) parameters take their type from the elements (so
table.map(flags, function(v) return v + 1 end) is an error when flags is
{boolean}), results have real element types (map returns an array of what the
callback returns, filter and sort keep the input's element type, and find returns
an optional element, so check it), reduce carries the accumulator's type from its
starting value, and group_by and tomap build typed maps. Loop variables take their
type from the elements too: for _, v in ipairs(xs) gives v the element type of
xs. pcall(f, …) gives (boolean, R | string) where R is what f returns, so check
with type(v) before using it as R when R could itself be a string.
Narrowing. The checker follows your code. A condition refines types in the branches it guards, and the refinement holds through the shapes Lua code really uses:
local function describe(value: integer | string): string | integer
if type(value) == "string" then -- value is a string here
return value .. "!"
else
return value + 1 -- and an integer here
end
end
describe("hi") --> "hi!"
local function bump(maybe: number?): number
if maybe == nil then return 0 end -- an early return
return maybe + 1 -- maybe is not nil from here on
end
bump(nil) --> 0
local function add(first: number?, second: number?): number
if first ~= nil and second ~= nil then -- both are narrowed
return first + second
end
return 0
end
add(2, 3) --> 5
type Message = { kind: "add", amount: number } | { kind: "note", text: string }
local function handle(msg: Message): number | string
if msg.kind == "add" then -- testing a shared literal field
return msg.amount -- picks the matching record shape
else
return msg.text -- and the other branch gets the rest
end
end
handle({ kind = "add", amount = 4 }) --> 4The forms it recognises: truthiness (x, not x); comparisons with nil;
type(x) == "kind" (including "table" and "function"); equality with a literal or
enum value (an integer literal works against a number: 401 == 401.0); and and or
(the right-hand side knows what the left-hand side established, so
if errors and #errors > 0 works); and if <cond> then return/error end guards (whose
opposite holds for the rest of the block). Field paths (a.b.c) narrow; call results and
t[k] don't, so put them in a local first. The right-hand side of or knows the
left-hand side was false, so if res == nil or res.status ~= 401 then works too. Assigning to a narrowed variable ends the
narrowing: the assignment is checked against the variable's declared type, and the
narrowed view follows the new value. A field that exists on only some members of a union
is an error until you narrow, and indexing a value that might not be a table (a pcall
result, say) is an error until you check it. That's the checker telling you which check
to write.
pcall pairs. local ok, v = pcall(f) links the two: if ok then narrows v to
what f returns, and the other branch (or an if not ok then return end guard) narrows
v to the error message string. Assigning to either variable unlinks them.
Covering every case. Branches understand which enum values they cover, cumulatively:
each elseif and the else know which values earlier branches took:
local function label(s: Status): string
if s == Status.active then
return "live"
elseif s == Status.archived then
return "kept"
else
return "gone: " .. s -- s can only be "deleted" here, and the checker knows it
end
end
label(Status.deleted) --> "gone: deleted"A branch that can never run (a repeated case, an impossible type test) gets a
warning, and an if/elseif chain on an enum with no else and missing values gets
a warning naming the missing values. Both are only ever warnings: add the branch or
an else to clear them. Once every value is covered, a defensive else is still allowed
and raises nothing.
The checker helps you get things right early; the rules are enforced when the script runs regardless.
10. Errors and limits
error("message") and failed asserts raise errors, as does any mistake at run time
(a type mismatch, an overflow, an unknown global and so on). An error that isn't caught
ends the run, and the app receives it with the line it happened on and the calls that
led there.
pcall. pcall(f, …) calls f and returns true followed by all of f's
results, or false and the error: the value passed to error (any value), or the
message of a run-time error. xpcall(f, handler) passes the error to handler first.
Error messages don't start with a file and line: pcall gives exactly the text passed to
error, and the position goes to the app with the error instead. One rule keeps both
safe:
- Can be caught:
error()andassert, and mistakes in values:typeMismatch,unknownGlobal,notCallable,invalidArgument,arithmeticOverflow,moduleNotFound,moduleErrorand similar. - Can't be caught: running out of a limit, or the app stopping the run. These pass
straight through
pcall:instructionLimitExceeded,callDepthExceeded,memoryLimitExceeded,tableLimitExceeded,stringLimitExceeded,timeoutExceeded,logLimitExceeded,hostResourceLimitExceededandcancelledByHost. A script can never outlast its limits by catching them.
local ok, err = pcall(function() error({ code = 42 }) end)
ok --> false
type(err) == "table" and err.code --> 42
local _, plain = pcall(function() error("boom") end)
plain --> "boom"
local fine, why = xpcall(function() error("boom", 0) end, function(e) return "handled " .. e end)
fine --> false
why --> "handled boom"Every run has limits, set by the app: on instructions, call depth, memory, entries per
table, bytes per string, output, modules loaded and objects from the app (§11). Running
out of one ends the run with an error naming it (instructionLimitExceeded,
callDepthExceeded and so on) at a predictable point: the same script with the same
input stops at the same place every time, on every platform. Write scripts that do a
bounded amount of work: a loop over a large amount of data is stopped, not slowed down.
11. Objects from the app
An app can give you objects: a function creates one, and you call its methods with
:. Behind the object is something the app keeps for you, such as a document being
built or a connection.
local doc = document.new("Order 1042") -- the app's function creates the object
doc:line("2 x widget"):line("1 x gizmo") -- methods can chain, if the app allows
doc:render() --> "Order 1042\n2 x widget\n1 x gizmo"What an object is, precisely:
type(obj) == "table". The table is frozen (writing to it is an error) and holds an entry called__handle, a small sequence number, plus any fields the app chooses to show; that is allpairs,inspectand returned values ever see. There is no identity or address, ever: the same script produces the same numbers on every run.- The real object stays with the app, and you reach it only through its methods. Binary data works the same way: it reaches your script as objects, never as raw bytes.
- An object lasts for one run. Objects from an earlier run mean nothing, and the app releases them when the run ends, whether it succeeded or failed.
- Methods can take callbacks, and a callback can call methods on the same object while the outer method is still running. This is allowed, and limited like any nesting (the call-depth and other limits in §10 all apply inside).
- Errors are ordinary. A made-up or out-of-date object, an object of the wrong kind,
or a bad argument is an
invalidArgumenterror you can catch. Creating too many objects runs out of a limit, which can't be caught. - The checker knows objects' types: each kind of object has a type name (for example
local d: Document), unknown methods and wrong arguments are reported, and a method that chains returns the same type again. - Repeatable results depend on the app here: a method that reads a clock or the network gives different results on different runs, as any function from the app could.
local doc = document.new("Draft")
doc:lnie("typo") -- reported: no such method12. Functions that wait on the outside world
Some of the app's functions do real work outside your script: reading a database,
calling a web service. To your script they look like any other call, and each one
returns a pair, (ok, res):
local ok, order = orders.fetch(1042) -- waits while the app fetches the order
ok --> true
if ok and type(order) == "table" then
#order.lines --> 2
end
local found, err = orders.fetch(7)
found --> false
err --> "no such order"While the app works, your run pauses, then carries on exactly where it left off.
There is no await keyword and no callback. Things to know:
- Always
(ok, res). On successokistrueandresis the value; on a failure you can recover from,okisfalseandresis the error. If you take only one value (local ok = orders.fetch(...)), you get just thetrueorfalse. - Each call has its own time limit. If a call takes too long, it is a
hostCallTimeouterror that you can catch withpcalland recover from. Time spent waiting does not usually count towards your script's own time limit. - Which functions wait is up to the app. From your side, a function the app provides just works when you call it; there's nothing to switch on.
Differences from Lua
The complete list. Each is deliberate and described above; don't "fix" code towards standard Lua's behaviour.
Needed for safety.
- No code from text (
loadand similar).requireexists only when the app provides modules, and finds only what the app provides; it can only be called, not stored or passed around. - No
io,os,debugor coroutines, and nogoto.printexists only where the app allows it (§8), and its output is limited. Results are the same on every run unless the app provides a clock or randomness. - Metatables honour only
__index,__newindexand__tostring(§5):setmetatablerefuses the rest, so scripts can't redefine operators, and the app never runs a script's metatable function when it reads a table. - Library tables are frozen.
- Everything is limited: at most 250 values per expansion (§4), a single value where a
script returns to the app or a module exports, at most 8 tables in an
__index/__newindexchain, and running out of a limit can't be caught, nor can the app stopping the run (§10). ipairsandpairsare part of the loop syntax: a genericforaccepts nothing else, and there are no first-class iterators. That's why there's nonext; usetable.isempty(t)to test for an empty table.gmatchis replaced bymatchall, which returns an array.
Errors where standard Lua would carry on.
- Reading an undefined global is an error, rather than giving
nil. - Integer overflow is an error, rather than wrapping round.
- No automatic string-to-number conversion in arithmetic: use
tonumber. - Strings are UTF-8 text. String functions count bytes but never split a character,
and
\128–\255escapes aren't allowed. - No bitwise operators (
& | ~ << >>): use thebit32extra library. breakoutside a loop is an error, as are statements afterreturnin a block.
Different, but predictable.
ipairsfixes its number of passes when the loop starts;pairsorder is defined (the array part, then map entries in the order added) and so is traversal of a table that changes (§5).- Table holes are defined (§5).
- Error messages carry no file and line prefix:
pcallgives exactly"boom", and the position goes to the app separately. string.upperandstring.lowerhandle all letters, not just ASCII.- Some math functions can differ in the last digit between platforms (
exp,log,powand the trigonometric functions; §8). tostringnever shows addresses (tables are"table", functions"function"), andinspectis likewise repeatable and shows no identities.- Method calls work without metatables (§7): strings find methods in the string library, and tables by field lookup.
- String patterns (the full Lua 5.4 language) are metered: a pattern that needs extreme backtracking ends the run, and can't be caught.
Additions. Type annotations (local x: number, function f(a: integer): string) are
a SwiftLua addition that plain Lua would reject. The standard library's additions beyond
Lua 5.1 (inspect, the table, set, string and math helpers, and the handy globals) add
to Lua rather than change it. Two differ in shape from common Lua habits:
table.sort_by returns a new array, and table.partition returns one table holding both
halves. (table.sort sorts in place, as in Lua.)
Common mistakes
Each of these fails to check, stops with an error, or quietly does the wrong thing.
- Testing a global that may not exist.
if someGlobal thenis an error ifsomeGlobalwas never defined. Use a local with a default. - Using
pcallto survive a limit. Running out of a limit can't be caught, by design, sopcallwon't rescue a runaway loop. - Reaching for
gmatch,nextormath.random. None exist; §8 lists the alternatives where there is one. - Writing
a & bora << n. The bitwise operators aren't part of the language; use thebit32library, where the app offers it. - Writing to a library table (
string.trim = …). They are frozen, so it's an error. - Taking one value from a function that waits (
local res = orders.fetch(1)). You get thetrueorfalse, not the data (§12). - Working round a missing feature. If something genuinely can't be done here (coroutines, files, a library the app doesn't offer), say so rather than writing code that won't run.
Published platform manual · revision b30bd0d9e65d