scripting / scripting-5Markdown ↓

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]              --> nil

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

  1. Reading an undefined global is an error. It doesn't give nil, so typos fail loudly.
  2. There are no iterators, coroutines, redefinable operators, io or os. If you reach for one, this guide lists the alternative, or the thing genuinely can't be done.
  3. 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).
  4. Only what §8 lists exists. The standard library is a closed set: next, string.gmatch and math.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.sub and string.byte count 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=" .. 42 gives "n=42"); any other type is an error. Escapes \128 to \255 aren't allowed in string literals, because strings are always valid text.
  • Truthiness: nil and false are false; everything else, including 0 and "", is true. and and or return 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, even nil <= nil.
  • No automatic conversion in arithmetic: "42" + 0 is an error. Convert with tonumber (§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  --> 50

2. 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  --> 5

Scope 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 local

3. 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  --> 5

4. 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 = nil

The rules in full:

  1. Return lists. The last expression in a return passes on every value: return f(args) and return 0, f(args) both pass all of f'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.
  2. A call in the last position of a local declaration or assignment fills the remaining variables, padding with nil and dropping extras.
  3. A call in the last position of an argument list (including pcall's) or a table constructor expands too: g(f()) passes every value of f, and {f()} holds them all.
  4. 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 not f(x, ...).
  5. 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.
  • pairs follows 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 + Decimal is a Decimal, and Decimal < Decimal is a boolean, so local t: Decimal = price * qty checks.
  • An integer works alongside a decimal (price * 3). A floating-point number doesn't: price + 0.1 is 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, require doesn'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 require gives 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).
  • require is 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"), not require(name)), the checker reads the module's own source to learn what it exports. So require("tax").aply is caught as a typo, and a chain such as require("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() is string.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) is t.scale(t, 4).
  • On anything else it is an error. A missing method is a notCallable error.
  • The receiver is evaluated exactly once. The argument list is required: a:b on 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)             --> 12

8. 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)  --> true

string (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) or nil. It uses patterns unless plain is true.
  • match(s, pat [, init]) gives the captures as separate values (or the whole match when the pattern has none), or nil, as in Lua.
  • captures(s, pat [, init]) gives an array of the captures (position captures as integers), or {wholeMatch} if the pattern has none, or nil.
  • matchall(s, pat) gives an array of every matching piece of text. It replaces Lua's gmatch, because a generic for accepts only pairs and ipairs (§5).
  • gsub(s, pat, repl [, n]) gives (result, count). repl can be a string (%0 is the whole match, %1–%9 the captures and %% a literal %), a table looked up by the first capture, or a function called for each match (returning the replacement, or nil/false to keep the original). Because gsub returns 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 = 2

Limits worth knowing:

  • string.format supports %d %i %u %c %o %x %X %e %E %f %g %G %q %s %% with the - + space # 0 flags; width and precision are at most 99. %s writes the value as tostring would. 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)  --> 40

time 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()                              --> 10

uuid: 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")                          --> false

The 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   -- fine

Under --!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")  --> 10

The 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 = 9

Names 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" })  --> 4

A 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 integer

Enums 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 values

A 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 runs

Growing 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 })  --> 4

The 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() and assert, and mistakes in values: typeMismatch, unknownGlobal, notCallable, invalidArgument, arithmeticOverflow, moduleNotFound, moduleError and 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, hostResourceLimitExceeded and cancelledByHost. 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 all pairs, inspect and 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 invalidArgument error 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 method

12. 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 success ok is true and res is the value; on a failure you can recover from, ok is false and res is the error. If you take only one value (local ok = orders.fetch(...)), you get just the true or false.
  • Each call has its own time limit. If a call takes too long, it is a hostCallTimeout error that you can catch with pcall and 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.

  1. No code from text (load and similar). require exists only when the app provides modules, and finds only what the app provides; it can only be called, not stored or passed around.
  2. No io, os, debug or coroutines, and no goto. print exists 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.
  3. Metatables honour only __index, __newindex and __tostring (§5): setmetatable refuses the rest, so scripts can't redefine operators, and the app never runs a script's metatable function when it reads a table.
  4. Library tables are frozen.
  5. 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/__newindex chain, and running out of a limit can't be caught, nor can the app stopping the run (§10).
  6. ipairs and pairs are part of the loop syntax: a generic for accepts nothing else, and there are no first-class iterators. That's why there's no next; use table.isempty(t) to test for an empty table. gmatch is replaced by matchall, which returns an array.

Errors where standard Lua would carry on.

  1. Reading an undefined global is an error, rather than giving nil.
  2. Integer overflow is an error, rather than wrapping round.
  3. No automatic string-to-number conversion in arithmetic: use tonumber.
  4. Strings are UTF-8 text. String functions count bytes but never split a character, and \128–\255 escapes aren't allowed.
  5. No bitwise operators (& | ~ << >>): use the bit32 extra library.
  6. break outside a loop is an error, as are statements after return in a block.

Different, but predictable.

  1. ipairs fixes its number of passes when the loop starts; pairs order is defined (the array part, then map entries in the order added) and so is traversal of a table that changes (§5).
  2. Table holes are defined (§5).
  3. Error messages carry no file and line prefix: pcall gives exactly "boom", and the position goes to the app separately.
  4. string.upper and string.lower handle all letters, not just ASCII.
  5. Some math functions can differ in the last digit between platforms (exp, log, pow and the trigonometric functions; §8).
  6. tostring never shows addresses (tables are "table", functions "function"), and inspect is likewise repeatable and shows no identities.
  7. Method calls work without metatables (§7): strings find methods in the string library, and tables by field lookup.
  8. 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 then is an error if someGlobal was never defined. Use a local with a default.
  • Using pcall to survive a limit. Running out of a limit can't be caught, by design, so pcall won't rescue a runaway loop.
  • Reaching for gmatch, next or math.random. None exist; §8 lists the alternatives where there is one.
  • Writing a & b or a << n. The bitwise operators aren't part of the language; use the bit32 library, 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 the true or false, 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