Tiri is a Lua-based scripting language built on top of LuaJIT. Lua was chosen as our preferred programming language due to its extensive popularity amongst game developers, a testament to its low overhead, speed and lightweight processing when compared to common scripting languages. We have heavily customised our Lua implementation for full integration with Kōtuku, as well as extending it with many new language features that are often requested by Lua developers. This approach is known as a 'hard fork', meaning we do not maintain compatibility with Lua as a language.
Tiri is fully interoperable with Kōtuku's C++ APIs due to a requirement that all interfaces are fully described. You can therefore read our online API documentation with confidence that the interfaces are universal, irrespective of language.
This manual expands on the information in the existing Lua Reference Manual, and covers features that are exclusive to Tiri. The official Lua 5.1 Reference Manual is required reading if you are unfamiliar with the language, and a working knowledge of Lua is assumed from this point onward.
Tiri was designed with the following goals in mind:
For more information on the usage of available classes and modules, please refer to the Kōtuku API at www.kotuku.dev.
For general information on the syntax provided by Lua, please read the following online manuals:
To run a Tiri script, use the Origo executable:
origo myfile.tiri
Named arguments can be passed to the Tiri script by following the script location with a series of variable values:
origo myfile.tiri name='John' surname=Bloggs
To debug a Tiri script, use the --log-api parameter.
For further information on available options, execute Origo with the --help parameter.
Tiri files can use the file extension .lua or .tiri for identification. Ideally, scripts should start with the comment -- $TIRI near the start of the document so that they can be correctly identified by the Tiri parser.
A number of extensions have been added (and some features removed) to Lua 5.2 in order to add value to the Tiri language. This section examines the major changes in regards to the published Lua Reference Manual.
The Package and OS libraries normally found in Lua are removed as they duplicate features already found in Kōtuku's Script, File and Time classes. The introspective debug library is also removed for the purpose of reducing size.
The following Lua/LuaJIT features are unsupported or modified:
~= not equal operator; replaced with !=== equality operator; replaced with isgoto and ::label:: statements; superceded by continue, break, deferbit.* library; replaced with native bitwise operatorsselect(); replaced with result masks [_*]collectgarbage(); replaced with processing.collect()newproxy(); removed without replacementloadstring(); replaced with load()dofile(); replaced with loadFile()pcall() and xpcall(); replaced with try … exceptmath.fmod(); removed because % provides truncating remainder directlymath.pow(); removed because ** provides exponentiation directlygetfenv(), setfenv(), gcinfo(), table.maxn(), unpack(); removedstring.gsub(), string.match(), string.gmatch(); removedstring.substr(); deprecated compatibility alias of string.sub()io.* interfaces now throw exceptions on errorglobal keyword for globalsfor i = 0, 8 do; replaced with range loops such as
for i in {0 into 8} do# on a table that has ever been addressed with a non-numeric key returns nil rather than the array-part length;
see The Length Operator%; replaced with truncating remainder, whose non-zero result has the dividend's signThe % operator computes the remainder after division truncated towards zero. For finite operands it is equivalent
to Dividend - trunc(Dividend / Divisor) * Divisor, and a non-zero result has the same sign as the dividend:
-7 % 3 -- -1
7 % -3 -- 1%= applies the same semantics. Tiri does not provide math.fmod() because % supplies this operation directly.
Code that needs a non-negative wrap for a positive divisor can normalise a negative result by adding the divisor.
Tiri positional spans use zero-based inclusive starts and exclusive stops, written [Start, Stop). Their length is
Stop - Start; an equal start and stop selects nothing; and Stop may equal the string, table or array length.
Negative string bounds are resolved from the end before the exclusive-stop rule is applied. Interfaces that use a
Count parameter retain count semantics and do not use this convention.
The core span interfaces are string.byte(), string.sub(), table.concat(), table.move(), array.concat(),
array.fill() and array.indexOf(). Their explicit Stop parameters follow this rule; for example,
table.concat(values, ',', 1, 3) joins elements at indexes 1 and 2, and table.move(values, 1, 3, 0) copies those two
elements.
For code written against the former inclusive-stop interfaces, use the following breaking-change conversions:
| Former call | Half-open call |
|---|---|
text.sub(-5, -1) |
text.sub(-5) |
text.byte(0, 0) |
text.byte(0, 1) |
table.concat(values, ',', 1, 3) |
table.concat(values, ',', 1, 4) |
table.move(values, 1, 3, 0) |
table.move(values, 1, 4, 0) |
array.concat(',', nil, 1, 3) |
array.concat(',', nil, 1, 4) |
The positional array.fill() form formerly accepted a count as its final argument. Convert the count to an exclusive
stop by adding it to the starting index:
| Former count call | Half-open call |
|---|---|
values.fill(Value, Start, Count) |
values.fill(Value, Start, Start + Count) |
Tiri supports C-style bitwise operators on 32-bit integers:
~x bitwise NOTa & b bitwise ANDa | b bitwise ORa ^ b bitwise XORResults follow two's-complement 32-bit integer behaviour.
Bitwise operators do not coerce strings to numbers (matching Tiri arithmetic). A string operand, including a numeric string such as '12', raises a type error; convert it explicitly with tonumber(), e.g. tonumber('12') & 5. This applies equally to the underlying bit.* library functions.
Compound assignment variants are not supported: &=, |=, ^= (bitwise) do not exist. Use x = x & y and x = x | y.
Examples:
-- Bitwise NOT, shifts left by 2
result = ~a << 2
-- Bitwise AND, then shifts right logical by 1
result = (a & b) >> 1
-- Masks out the lower 8 bits
result = a & 0xFFThe has operator tests whether a bitwise flag is set:
a has b is equivalent to a & b != 0The result is always a boolean. Both operands must be numeric.
Examples:
permissions = PERMIT_READ | PERMIT_WRITE
if permissions has PERMIT_READ then
print('Readable')
endWhen both operands are constants, the expression is folded at compile time.
Tiri adds infix bitshift operators for convenience:
<< left shift>> right shiftPrecedence and associativity:
1 + 1 << 3 evaluates as 1 + (1 << 3) producing 9.8 >> 1 + 1 produces 2.x << y << z parses as (x << y) << z.Tiri supports Unicode alternatives for some multi-character ASCII operators as well as Unicode arithmetic operators. These provide a cleaner visual appearance in editors with good Unicode font support.
| Unicode | ASCII | Description |
|---|---|---|
≠ |
!= |
Not equal |
≈ |
None | Approximate equality |
≤ |
<= |
Less than or equal |
≥ |
>= |
Greater than or equal |
« |
<< |
Left shift |
» |
>> |
Right shift |
‥ |
.. |
Concatenation |
⁇ |
?? |
Null coalescing (if-empty) |
▷ |
: |
Ternary separator |
| Unicode | ASCII | Description |
|---|---|---|
× |
* |
Multiplication |
÷ |
/ |
Division |
↑ |
** |
Exponentiation (right-associative) |
Examples:
-- Comparison and logical operators
x = 5
x++ -- increment
name = user ⁇ 'Anonymous' -- null coalescing
max = a ≥ b ? a : b -- comparison with ternary
msg = 'Hello' ‥ ' World' -- concatenation
flags = 1 « 4 -- left shift
-- Arithmetic operators
result = 10 × 5 ÷ 2 -- 25 (multiplication and division)
area = radius × radius × PI -- circle areaBoth ASCII and Unicode forms can be mixed freely in the same source file:
x = 5 * 3 × 2 ÷ 1 / 2 -- All forms work togetherArguments passed to the Tiri script can be accessed via the arg() function. In the following example either 'width' is returned or 1024 otherwise:
width = arg('width', 1024)
All arguments are managed as strings regardless of the type of the value.
Tiri adds C-style compound assignment operators for convenience:
+=, -=, *=, /=, %= on numeric values..= for string concatenationRHS behaviour:
Errors and types:
The ..= operator appends to an existing string:
s = 'a'
s ..= 'bc' -- s is 'abc'
s ..= tostring(1) -- s is 'abc1'Semantics mirror s = s .. rhs. The left-hand side is evaluated once and the RHS uses only its first return value.
Tiri supports Python-style f-strings for embedding expressions directly within string literals. F-strings are prefixed with f and use curly braces {} to delimit expressions.
Basic Syntax:
name = "World"
greeting = f"Hello {name}" -- "Hello World"
a = 10
b = 20
result = f'{a} + {b} = {a + b}' -- '10 + 20 = 30'Both single and double quotes are supported.
Expression Support:
Any valid Tiri expression can be used inside the braces:
-- Arithmetic
f"Result: {1 + 2 * 3}" -- "Result: 7"
-- Function calls
f"Upper: {string.upper('hello')}" -- "Upper: HELLO"
-- Table field access
user = {name = "Alice", age = 30}
f"{user.name} is {user.age}" -- "Alice is 30"
-- Method calls
f"Name: {obj.getName()}"
-- Nested tables in expressions
f"Point: {point.x}, {point.y}"
-- Nil handling with ??
f"Your name is {user.name ?? 'unknown'}"It is strongly recommended that nil handling is always employed with the ?? operator if the data originates from outside the script.
Automatic Type Conversion:
All interpolated expressions are automatically wrapped in tostring(), ensuring proper conversion of any value type:
f"{nil}" -- "nil"
f"{true}" -- "true"
f"{42}" -- "42"
f"{3.14}" -- "3.14"Escaping Braces:
To include literal braces in the output, double them:
f"Use {{braces}} for interpolation" -- "Use {braces} for interpolation"Restrictions:
f[[...]] is invalid{} or whitespace-only expressions { } are syntax errorsTiri supports a postfix increment operator for convenience:
counter++
obj.field++
t[i]++Notes:
x++ expression itself is unspecified and should not be relied upon in expressions.=> provides concise anonymous function syntax. Single-expression bodies are implicitly returned; multi-statement bodies use do ... end with an explicit return.
value => value * 2(left, right) => left + right() => 42function(...) when needed.Examples:
double = n => n * 2
adder = (a, b) => a + b
on_click = () => do
print('Clicked')
return true
end
numbers = {1 to 10} |> map(i => i * 3) |> filter(i => i > 10)Arrow bodies bind loosely, so the expression after => extends as far right as possible.
Tiri adds a continue statement for all loop forms:
for {1 to 10} do
if i % 2 is 0 then continue end
-- odd numbers only
end
while cond do
if skip() then continue end
work()
end
repeat
if not ready() then continue end
until donecontinue skips the remainder of the current loop body and advances to the next iteration. In repeat … until, it jumps to the condition check.
The <close> attribute marks local variables for automatic cleanup via the __close metamethod when scope exits. This is more optimal than manually calling the garbage collector.
resource <close> = acquire_resource()
-- resource.__close(resource, nil) called automatically when scope endsThe bare form is available only when every declared name is new. If a name already resolves to a local, parameter,
upvalue, global, environment value, or registered constant, use an explicit local to document intentional shadowing:
local resource <close> = acquire_replacement_resource()For a table, the __close metamethod receives the error value (nil for normal exit, the error object during error
unwinding); the table being closed is available as &&. Native non-table values retain their documented receiver
argument.
For a native Kōtuku object, <close> provides deterministic ownership cleanup. It invokes the same destructive
operation as resource.free() when the scope exits:
do
local resource <close>:obj = obj.new('time')
resource.hour = 9
endThis also applies to a detached wrapper returned by obj.find(): closing any obj wrapper terminates the underlying
object and makes every other wrapper stale. Use an ordinary unannotated reference when object lifetime should remain
under normal ownership and garbage-collection rules.
with object do ... end only acquires and releases a temporary object lock; it never frees the object. It is safe to
use a with block inside an outer object <close> scope when both coordinated access and deterministic termination
are needed.
Native arrays have no intrinsic close operation. Applying <close> to an array<obj> does not close, free, or
otherwise transfer ownership of its elements; explicitly free or close elements when aggregate ownership is intended.
Execution order:
defer blocks (both in LIFO order)return, break, continue, and error unwindingError propagation:
When an error is thrown and caught by try, the __close handler receives the error as its second argument, enabling proper error-aware cleanup.
Primitive values:
Values without metatables (nil, false, strings, numbers) are safely ignored.
Error handling:
For cleanup that needs to know about errors, check the second argument:
try
file <close> = open_file()
risky_operation() -- If this throws, file.__close still runs
endThe <view> attribute makes the first object array-field read in a local variable initialiser return a read-only,
non-owning view instead of copying the field:
local pixels <view> = bitmap.dataThe local keyword is optional only for a new name. Use local pixels <view> = ... when intentionally shadowing an
existing binding; a bare attributed declaration cannot shadow a local, parameter, upvalue, global, environment value,
or registered constant.
Only primitive array fields can be viewed. String, object, pointer and struct arrays raise ERR_FieldTypeMismatch
because their Tiri representation requires conversion or managed references. A view declaration requires exactly one
local name and an initialiser, and cannot be combined with <const> or <close>. If the initialiser produces a plain
value or an existing Tiri array without reading an object array field, <view> is a benign no-op.
The native buffer is always borrowed without copying. A resource-backed field is automatically pinned until the Tiri view is collected, so its allocation remains live if its owner is destroyed or asks Core to free it. Pinning protects the allocation lifetime only: it does not serialise access or prevent native code from mutating the buffer. The Tiri view is read-only, but reads observe native mutations.
A non-resource field is unmanaged. The client must retain the source object and must not destroy it, resize or replace the field, or otherwise reallocate or invalidate its buffer while the view may be accessed. Tiri does not retain the owner or detect invalid unmanaged access.
Object field reads are locked while the view is acquired, but later array access occurs without the object lock. Use
with object do ... end when coherent access must be coordinated with other users of the object.
View mode is one-shot: only the first object array-field read performed while evaluating the initialiser becomes a view. Later reads in the same expression use the normal copying behaviour.
Module authors opt a primitive array field into automatic view pinning by combining FD_RESOURCE with FD_ARRAY or
FD_VECTOR. This promises that the returned span starts at the address of a live Core resource, remains valid under
the object lock until PinResource() succeeds, and is replaced or destroyed through FreeResource() for that same
resource. Pinned storage must not be retargeted with TrackResource().
Fields that cannot satisfy every part of this contract must omit FD_RESOURCE. Their views still succeed, but remain
unmanaged; Tiri never inspects allocation metadata for such fields.
The <const> attribute marks local or global variables as constant, preventing reassignment after initialisation. Local
bindings are enforced during compilation. Global constness is also stored with the runtime environment, so separately
compiled code and indirect environment writes cannot reassign the binding.
Syntax:
local max_size <const> = 100
local prefix <const>:str = "test_"
global DEBUG_MODE <const> = trueA bare <const> declaration is also supported for a new local, for example max_size <const> = 100. If any name in
the declaration already resolves to a local, parameter, upvalue, global, environment value, or registered constant,
the declaration must begin with local to make the shadowing explicit.
The <const> attribute can appear before or after a type annotation:
local value <const>:num = 42 -- Attribute before type
local value:num <const> = 42 -- Type before attribute (also valid)Initialiser requirement:
Const variables must be initialised at declaration. Declaring a const without an initialiser is a compile-time error:
local x <const> -- Error: const 'x' requires an initialiserReassignment prevention:
Attempting to reassign a const variable produces a compile-time error:
local x <const> = 1
x = 2 -- Error: cannot assign to const local 'x'
global CONFIG <const> = {}
CONFIG = {} -- Error: cannot assign to const global 'CONFIG'Global constness cannot be removed by redeclaration, including an explicit :any declaration. It also applies to
environment aliases, _G stores, rawset() and host global mutation APIs. These runtime mutation routes raise an error
before changing the value.
Table contents are mutable:
The <const> attribute protects the variable binding, not the contents of the value. Table and object contents can still be modified:
local data <const> = { x = 1, y = 2 }
data.x = 999 -- Valid: modifying table contents
data.z = 3 -- Valid: adding new fields
data = {} -- Error: cannot reassign the bindingMultiple declarations:
In multiple variable declarations, each variable can independently have the <const> attribute:
local a <const>, b, c <const> = 1, 2, 3
-- a and c are const, b is mutable
b = 20 -- Valid
a = 10 -- Error: cannot assign to const local 'a'Scope behaviour:
Const variables follow normal scoping rules. A const in an outer scope can be shadowed by a new variable in an inner scope:
local x <const> = 1
do
local x = 2 -- Valid: shadows outer const
x = 3 -- Valid: inner x is not const
end
-- Outer x is still 1 and still constEnum declarations generate a group of compile-time numeric constants from a shared prefix. Their use is favoured over constant variable declarations, as the parser can replace enum references with their numeric value for efficiency. The enum prefix is used only to build the generated names; it does not create a runtime table, variable, or value.
Syntax:
[global] enum PREFIX {
MEMBER = 0,
NEXT_MEMBER,
}Each generated constant is named PREFIX_MEMBER and is substituted by the parser as a numeric constant. For example:
enum HTTP_METHOD {
GET = 0,
POST,
PUT,
DELETE,
}
assert(HTTP_METHOD_GET is 0)
assert(HTTP_METHOD_PUT is 2)The bare and global forms are equivalent:
enum MODE { FAST, SLOW }
global enum ERR { OKAY = 0, FAIL }If the first member has no explicit value, it starts at 0. Later members without explicit values increment from the
previous member value:
enum SAMPLE {
FIRST, -- SAMPLE_FIRST = 0
SECOND, -- SAMPLE_SECOND = 1
CUSTOM = 10, -- SAMPLE_CUSTOM = 10
AFTER, -- SAMPLE_AFTER = 11
}Explicit values must be integer literals. Signed decimal and hexadecimal forms are accepted:
enum MASK {
NONE = 0x0,
READ = 0x1,
WRITE = 0x2,
ALL = 0x3,
}Enum declarations must appear at the top-level scope of a script or imported file. They are not permitted inside
functions or statement blocks, and local enum is invalid.
Enum prefixes and member names must use uppercase identifier style. The declaration must contain at least one member
and cannot use a prefix type annotation, <const>, or <close> attribute. The enum word is reserved and cannot be
used as an identifier.
Generated enum constants are immutable registry-backed constants. They can be shadowed by an explicit local,
function parameter, or loop variable of the same name, but cannot be reassigned with plain assignment or redeclared as
globals.
The safe navigation operator (?.) provides null-safe access to object fields, methods, and indexes.
obj?.field -- Safe field access
obj?.method() -- Safe method call
obj?[key] -- Safe index access
obj?.a?.b?.c -- ChainingIf the object is nil, the safe navigation operator returns nil without attempting to access the field/method/index. This prevents "attempt to index a nil value" errors.
Important: The safe navigation operator only checks for nil. Other falsey values like false, 0, or "" are treated as valid objects and field access proceeds normally.
-- Safe field access
user = nil
name = user?.name -- Returns nil instead of error
user2 = { name = "Alice" }
name2 = user2?.name -- Returns "Alice"
-- Chaining
city = user?.profile?.address?.city -- Returns nil if any level is nil
-- With default values using if-empty operator
displayName = user?.name ?? "Guest" -- "Guest" if user or name is nil
-- Safe method calls
result = obj?.calculate() -- Returns nil if obj or calculate() is nil
-- Multiple return values preserved
a, b = obj?.getTwoValues() -- Both a and b will be nil if obj or getTwoValues() is nil
-- Safe index access
value = table?[key] -- Returns nil if table is nilTiri maintains a dynamic table context for contextual entity calls and using blocks. Prefix a field name with &
to access that field on the current context:
&value = 10
print(&value)Use && when the table itself is required as a value:
function update(Target:table!, Value:num!)
Target.value = Value
end
local control = entity {
value = 0,
setValue = function(Value:num!)
update(&&, Value)
end
}&& has type table!. It is the same table used as the base of &field, so &&.value and &value select the same
field. Normal suffixes are supported, including &&[key], &&?.field and &&.method().
The current context is dynamic rather than a lexically captured self. A nested contextual call temporarily exposes
its receiver and restores the previous context when it returns. Ordinary synchronous function calls inherit their
caller's context. Extracting a function from an entity does not bind the function to that entity. At script root,
the current context is the global environment _G.
FUNCTION CallbacksWhen a function is converted to a native FUNCTION callback, Tiri captures the active context table at the conversion
point. This applies to function-valued object fields and to func arguments passed to native object actions, methods
and module functions. The callback later runs with that table as its current context, even after the originating
using block or contextual method has returned:
local slider = entity { value = 0 }
using slider do
viewport.dragCallback = function(Viewport, X, Y)
&value = X
update_value(&&, X)
end
endThe capture is limited to the native FUNCTION bridge. Ordinary functions remain dynamically scoped and do not
capture a context merely because they were declared or stored inside a contextual block. Callback systems that retain
raw Lua registry references, including processing.delayedCall() and object.subscribe(), also remain unbound and
begin in the state root context.
Table metamethod handlers are ordinary functions. When a table dispatch selects a callable handler, the runtime makes the selected receiver available as the current context for the duration of that call. The receiver is not included in the handler's visible arguments:
local colour_mt = {
__tostring = function():str
return f'colour:{&name}'
end,
__eq = function(Other:table!):bool
return &id is Other.id
end
}The selected table remains active through nested calls and cleanup, and the previous context is restored on return or
error. Use &field for receiver fields and && when the table itself is needed. Operands, keys, values and explicit
arguments are evaluated under the caller's context before dispatch enters the receiver context.
Context capture belongs to the dispatch event, not the function value. Extracting a handler produces an ordinary function; a direct call inherits the caller's current context and treats every supplied value as an ordinary argument:
local render = colour_mt.__tostring
local direct = render() -- Ordinary call; no metamethod dispatch.
local text = tostring(colour) -- Dispatch call; colour is available as &&.The visible table-handler arguments are:
| Handler | Visible arguments |
|---|---|
__index |
Key |
__newindex |
Key, Value |
__call |
Original call arguments |
__tostring, __len, __unm, __iter, __pairs, __ipairs, __clear, __gc |
None |
__close |
Error |
__eq |
Other |
__contains |
Value |
__add, __sub, __mul, __div, __mod, __pow, __concat, __lt, __le |
Other, LhsDispatch |
Binary dispatch is receiver-first: the operand whose metatable supplied the handler is exposed through &&, the
other operand is visible as Other, and LhsDispatch is true when the receiver was the left operand of the effective
operation. This lets a handler preserve non-commutative right-provider semantics:
__sub = function(Other, LhsDispatch:bool)
if LhsDispatch then return &value - Other end
return Other - &value
endFor arithmetic and concatenation, LhsDispatch describes the source operand order. Ordered comparisons can swap
operands to implement > and >=, or when __le falls back to a reversed __lt; their flag describes that effective
pair. A handler can therefore reconstruct the relation it is evaluating without relying on the source spelling.
Only table receivers use this receiver-elided ABI. Native object, structure, array and userdata handlers retain their documented receiver argument unless that type explicitly says otherwise. Avoid sharing one handler between a table metatable and a non-table metatable because the calling conventions differ.
Adjacent && is reserved for current-context materialisation; logical conjunction remains and. When bitwise AND
has an &field expression as its right operand, separate the operators with whitespace:
local masked = flags & &maskTiri supports optional type annotations on function parameters to surface static analysis diagnostics during parsing. Annotations follow the parameter name after a colon and constrain the expected argument type.
function process(Path:str, Count:num, Options:table)
-- Path must be a string
-- Count must be a number
-- Options must be a table
endUntyped parameters omit the annotation:
function mixed(Untyped, Typed:bool): <any,bool>
return Untyped, Typed
end| Name | Notes |
|---|---|
any |
Accepts any type |
nil |
Explicit nil |
num |
Numeric values |
int |
32-bit integer representation; floating numbers are truncated toward zero |
str |
Text strings |
bool |
Boolean values |
table |
Tables and dictionaries |
func |
Callable values |
array<Element> |
Native arrays with the declared member type |
func |
Functions and other values accepted by normal call dispatch |
struct<Name> |
Native structures with exactly the named layout |
obj |
Kōtuku objects |
range |
Range userdata with the registered range metatable |
userdata |
Ordinary full userdata and light userdata; specialised ranges are excluded |
Unknown type names raise diagnostics during parsing with the UnknownTypeName error code.
An array annotation must include its member type. For example, Value:array<str> accepts string arrays and rejects
array<int>. Use array<any> when an API deliberately accepts native arrays with different member types. A bare
:array annotation is invalid.
Tiri employs a lazy type inference system that commits local variables and globals declared with global to a
specific type upon their first meaningful assignment. This approach balances the convenience of dynamic typing with
the safety and performance benefits of static type knowledge.
Unlike traditional static type systems that require explicit annotations, sticky types work transparently:
nil value acts as a placeholder that does not commit the type.array<Element> because the member type is part of the binding contract.any type annotation opts out of type fixing for variables that genuinely need variant behaviour.Benefits:
When a local variable or declared global is assigned a non-nil value, its type becomes fixed to that value's type:
local count = 0 -- count is fixed to 'number'
count = 10 -- Valid
count = "ten" -- Error: cannot assign 'string' to variable of type 'number'
name = "Alice" -- name is fixed to 'string'
name = "Bob" -- Valid
name = 42 -- Error: cannot assign 'number' to variable of type 'string'
global glItems = {} -- glItems is fixed to 'table'
glItems = { a = 1 } -- Valid
glItems = "list" -- Error: cannot assign 'string' to variable of type 'table'This behaviour is automatic and requires no additional syntax. The compiler infers the type from the first assignment
and enforces consistency thereafter. Declared global contracts persist in the script environment, so code compiled
later through facilities such as exec() observes the same sticky type.
Global contracts are enforced at the environment itself, not merely at the assignment syntax. Every route that writes
to the global environment — plain assignment, _G.name, _G['name'], computed _G[key], an alias of _G,
rawset(_G, ...) and host API stores — passes through the same runtime policy check. A rejected store raises before
any mutation occurs, leaving both the previous value and the declared contract intact. Assigning nil through any of
these routes clears the value without clearing the sticky type.
Type annotations can be added to local and global declarations using the :type syntax. This is useful for
pre-declaring variables or documenting intent:
local limit: num -- pre-declared as number, starts as nil
limit = 100 -- Valid
limit = "high" -- Error: cannot assign 'string' to variable of type 'number'
local message: str = "" -- pre-declared as string with initial value
message = "hello" -- Valid
message = nil -- Clears the value (type remains 'str')
message = 42 -- Error: cannot assign 'number' to variable of type 'string'Explicit annotations also catch type mismatches in the initial value:
local count: num = "text" -- Error: cannot assign 'string' to variable of type 'number'The supported type names are: any, nil, bool, num, int, str, table,
array<Element>, func (or function), struct, obj, range and userdata.
The type() function uses canonical runtime names by default, so type(true) returns "bool" and type('text')
returns "string". A metatable can provide a non-empty string in its raw __name field to replace that displayed
name. The field is descriptive only: it does not affect annotations, type tests, equality, conversion, inheritance, or
native API checks.
local Vector = { __name = 'Vector' }
Vector.__index = Vector
local value = setmetatable({ x = 3, y = 4 }, Vector)
assert(type(value) is 'Vector')
assert(rawtype(value) is 'table')
assert(value is <table>)__name is read directly from the value's metatable. A field placed directly on the value is ignored, __index is
not followed, and a function in __name is never called. Missing, empty, and non-string values fall back to the
built-in name. Runtime diagnostics use the same display-name lookup for the actual value being described.
Use rawtype(Value) when code needs the stable internal type name. It uses annotation vocabulary and never honours a
user-supplied __name, follows __index, invokes a metamethod, or resolves a thunk. It identifies a range by
comparing its metatable with the registered Tiri.range metatable, but does not read a name from that metatable.
| Value | rawtype() result |
|---|---|
nil |
nil |
false, true |
bool |
| Any number | num |
| String | str |
| Table | table |
| Tiri, C or fast function | func |
| Native array | array<Element> |
| Struct array with a resolved layout | array<struct<Name>> |
| Kōtuku object with class metadata | obj<Class> |
| Kōtuku object without class metadata | obj |
| Struct with a resolved layout | struct<Name> |
| Struct without a resolved layout | struct |
| Range container | range |
| Thunk container | thunk |
| Full or light userdata | userdata |
array<char> reports array<byte> because both spellings use the same byte storage. An array whose members are
objects reports array<obj>, matching its annotation spelling, while a standalone object with class metadata reports
obj<Class>. Recursive identities are retained in full, so an array of integer arrays reports
array<array<int>>. thunk is descriptive only: it is not a valid type annotation.
type() and rawtype() deliberately answer different questions:
| Expression | type() |
rawtype() |
|---|---|---|
1 |
number |
num |
A table with __name = 'Vector' |
Vector |
table |
array<int16> { } |
array |
array<int16> |
A Vector object |
object |
obj<Vector> |
{0 to 2} |
range |
range |
A declared num thunk |
number |
thunk |
Use a type test such as Value is <array> when only the category matters. Compare a complete rawtype() result only
when exact internal member, class or layout identity is required.
Array annotations retain the exact public member identity recursively. Consequently, array<int> and array<float>
are different binding types even though both expose numeric elements, and array<array<int>> rejects an
array<array<str>>. array<any> is a member wildcard, while array<array> accepts inner arrays of any storage type.
array<array<any>> provides the same wildcard behaviour explicitly. nil may clear an array binding without clearing
its member contract.
Tiri's numeric values use either a 32-bit integer representation or a floating-point representation. The num
annotation accepts both representations, while int guarantees that a value has the integer representation after the
annotated boundary:
local positive:int = 5.7 -- 5
local negative:int = -5.7 -- -5
function index(Value:int!):int!
return Value
endint is the one coercing type contract. An integer value passes unchanged, and a floating num is truncated toward
zero. This applies to local and global stores, function arguments and results, including calls made through aliases or
callbacks. Non-numeric values still raise the normal catchable contract error. An int satisfies num without
conversion; a num satisfies int after truncation. int! adds the usual non-null requirement.
The conversion deliberately uses the runtime's existing lax number-to-index operation. Values outside the signed
32-bit range, NaN and infinities do not raise a range error, and their resulting integer value is platform-dependent.
Consequently, int is a representation constraint, not input range validation.
Arithmetic does not preserve the int type: / produces a floating result, and +, - and * may leave the integer
representation after overflow. Assigning or returning such a result through an int annotation truncates it again at
that boundary.
Use the any type annotation to preserve traditional dynamic typing for variables that genuinely need to hold different types:
variant: any = 0 -- Explicitly variant, all further assignments are valid
variant = "now a string"
variant = { key = true }
variant = nil
variant = load_json()
global glVariant:any = 'text'
glVariant = 42 -- Valid because the global explicitly opts out of sticky typingThe any type is the escape hatch when you need flexibility. For a global, :any must be present when the binding's
contract is first established. It establishes an immutable variant contract; it does not reset or weaken an existing
concrete contract, and a later concrete declaration cannot replace it.
A mutable global may be redeclared only with the same complete contract. For example, repeating :str is valid, but
changing :str to :num or :any is not. Array member identity, recursive array identity, structure layout, object
class, null policy and constness are part of this comparison. A failed redeclaration leaves both the current value and
the established contract unchanged.
An any source does not weaken a sticky destination. Its actual value is checked against the destination contract at
runtime:
global glName = 'Alice'
local dynamic:any = 42
glName = dynamic -- Runtime error: the sticky global expects strUsing any has two disadvantages: it disables the type safety benefits for that variable, and the JIT compiler has
fewer optimisation opportunities when dealing with variant types.
The nil value has special status in the type system, allowing variables to be cleared to their empty state regardless of their type.
Uninitialised variables: Variables declared without an initial value start as nil with no type commitment. The type is fixed when the first non-nil value is assigned:
local result -- result is nil (type uncommitted)
result = nil -- still uncommitted
result = calculate() -- result is now fixed to whatever calculate() returns
result = "fallback" -- Error if calculate() returned a non-string typeNil as a clear operation: Once a variable has a fixed type, assigning nil clears the value but preserves the type constraint. The next non-nil assignment must still match the fixed type:
name = "Alice" -- Assign a string to new variable 'name'
name = nil -- Clear the value
name = "Bob" -- Valid
name = 42 -- Error: Cannot assign 'number' to variable of type 'string'This design allows variables to represent "optional" or "nullable" values naturally without requiring a separate nullable type annotation, while still enforcing type consistency for non-nil values.
Function parameter type annotations serve as guards that validate incoming arguments. Unlike local variable type fixing (which is lazy), parameter annotations are checked at call-time:
function greet(Name: str, Times: num)
for i in {1 into Times} do
print("Hello, " .. Name)
end
end
greet("World", 3) -- OK
greet(42, 3) -- ERROR: expected 'string' for parameter 'Name'Parameter annotations are particularly important for public APIs and library functions where input validation is critical. They provide documentation and runtime safety at the function boundary.
These guards execute inside the called function, so direct calls, aliases, table-held functions, imported functions
and native callbacks enforce the same contract. Checks are exact and do not convert values except for the documented
numeric num-to-int representation coercion: a numeric string does not satisfy num, and a number does not satisfy
str. func includes values accepted through normal callable dispatch.
Named structure annotations require the exact named layout, and range accepts only range userdata. The userdata
type accepts ordinary full userdata, such as compiled regex values, and light userdata exposed by native bindings. A
range remains a distinct type and does not satisfy userdata.
function retain_proxy(Proxy:userdata):userdata
return Proxy
end
local proxy:userdata = retain_proxy(regex.new(''))By default, nil satisfies every parameter annotation. This includes omitted arguments, which arrive as nil.
Append ! to the type to require a value:
function load_profile(Name:str!):table!
-- Name cannot be nil or omitted, and the function must return a table.
return profiles[Name]
endA required parameter rejects both an explicit nil and an omitted argument. The suffix narrows acceptance without
coercing values, and violations raise the normal catchable contract diagnostic. nil! is contradictory and is
rejected during compilation. Required annotations are supported only on function parameters and return values; they
are not permitted on local, global or upvalue declarations.
Mixing typed and untyped parameters:
function process(Data, Count: num, Validate: bool)
-- Data accepts any type (no annotation)
-- Count must be a number
-- Validate must be a boolean
endUntyped parameters remain fully dynamic and accept any value, preserving flexibility where needed.
Tiri supports optional return type declarations on functions to enable compile-time validation of returned values. Additionally, more optimal code can be generated through the narrowing of return types. Return types are declared after the parameter list using a colon followed by the type (or a tuple of types for multiple returns).
Explicit return declarations are also runtime contracts. Dynamically produced results are checked before they leave
the function, after normal cleanup has run. A fixed declaration constrains the maximum result count: missing declared
positions are treated as nil, while additional undeclared results are truncated. Because nil satisfies contracts
by default, a missing result is accepted. For a variadic declaration, the final declared type applies to and preserves
every additional result.
Appending ! makes an individual result required. A required result rejects an explicit nil and a missing result;
the failure is catchable in the same way as any other return-contract mismatch. In a variadic declaration, ! on the
final declared type also applies to every additional result governed by that suffix:
function identifiers():<num!, ...>
return 10, 20, 30
endSingle return type:
function calculate_area(Radius: num): num
return math.pi * Radius * Radius
end
function get_name(): str
return "Alice"
endMultiple return types:
Functions that return multiple values can declare each type in angle brackets:
function divide(A: num, B: num):<num, num>
return math.floor(A / B), A % B -- quotient and remainder
end
function parse_header(Line: str):<str, str, num>
-- Returns name, value, and position
return name, value, pos
endVariadic return types:
When the last return value can repeat (e.g., returning variable numbers of values), use ... after the last type:
function get_values():<num, ...>
return 1, 2, 3, 4, 5 -- First is num, rest are also num
endWhen no explicit return type is declared, Tiri infers each result position from the function's return statements in
lexical source order using a "first-wins" rule:
nil value establishes the expected type for its position.nil is always permitted; it does not establish a type.any or otherwise unknown expression cannot prove compatibility and requires an explicit result declaration.function get_status(Code: num)
if Code < 0 then return nil end -- nil is allowed
if Code is 0 then return "OK" end -- Establishes type as 'str'
return "Error" -- Must also be 'str'
endWhen a dynamic expression is expected to satisfy a fixed result type, a concrete declaration installs the runtime contract needed to validate it at the function boundary:
local current:any = "ready"
function get_status():str
return current -- Accepted during compilation and checked as 'str' when executed
endWithout :str, the dynamic return is a compile error. Declaring :any instead deliberately permits variant result
types and does not install a concrete result contract.
Type mismatch errors:
function broken()
if condition then
return "text" -- Establishes type as 'str'
end
return 42 -- Error: inconsistent return type, expected 'str', got 'num'
endany Return TypeUse any to opt out of return type checking for functions that genuinely return different types:
function json_decode(Text: str):<any, num>
-- Returns decoded value (could be table, string, number, bool, or nil)
-- and the position after parsing
return decoded_value, pos
end
function dynamic_result(Mode: str): any
if Mode is "number" then return 42 end
if Mode is "string" then return "hello" end
return { key = "value" }
endRecursive functions (functions that call themselves) require explicit return type declarations. This is because the type inference cannot determine the return type before analysing the function body, which contains the recursive call:
-- Error: recursive function 'factorial' must have explicit return type declaration
function factorial(N: num)
if N <= 1 then return 1 end
return N * factorial(N - 1)
end
-- Correct: explicit return type declared
function factorial(N: num): num
if N <= 1 then return 1 end
return N * factorial(N - 1)
endThis requirement also applies to mutually recursive functions (function A calls function B, which calls function A).
Declaring return types provides several advantages:
any.function get_count(): num
return 42
end
result = get_count() -- 'result' is automatically inferred as 'num'
result = "text" -- Error: cannot assign 'str' to variable of type 'num'An explicitly annotated global is checked whenever it is written. A typed local retains its contract when captured as
an upvalue, so writes from nested functions are checked as well. Inferred mutable globals remain advisory because
their type can be changed by separately compiled or dynamically loaded code. Use any explicitly, or inherit a
sticky any type, when a binding intentionally accepts values of unrelated types.
Tiri enforces local-by-default variable scoping, a significant departure from standard Lua where undeclared variables are implicitly global. This design prevents accidental pollution of the global namespace and catches common programming errors at parse time.
Variables and functions are local by default. Any assignment to an undeclared variable creates a new local in the current scope:
counter = 0 -- Creates local 'counter'
name = "Alice" -- Creates local 'name'
bare_var -- Invalid, results in an error from the parser
a, b, c -- Invalid: use 'local a, b, c' or an assignment instead
function example() -- Function is local
total = 100 -- Creates local 'total' in function scope
counter += 1 -- Modifies 'counter' in the outer scope
endThe local keyword remains available for explicit declarations and is required when initialising multiple variables on one line:
local a, b, c = 1, 2, 3 -- Multiple locals on one line
local config -- Explicit nil initialisationTo create or access global variables, use the global keyword. It is recommended that global declarations appear before any reference to the variable:
global DEBUG_MODE = true
global APP_VERSION
global function configure() -- Global function is accessible in the parent context
APP_VERSION <const> = "1.0" -- Assigns to the global and makes it immutable
global LATE_CREATION = true -- Valid, but won't exist until this function runs
global DEBUG_MODE = false -- Modifies the original global without shadowing it
local DEBUG_MODE = true -- Valid; this local shadows the global within this scope
print(DEBUG_MODE) -- Prints local 'true'
endIt is recommended that global variables follow our naming conventions, which are UPPER_CASE for constants and glCamelCase for mutable globals. Exceptions may apply - but sticking to conventions helps with code readability.
Global Functions:
Declaring a function with the global keyword allows the parent scope to access it. This feature allows library scripts to expose functions and variables to the caller.
Global Declaration Rules:
global declarations must precede any use of the variable namelocalglobal keyword can appear at any scope level, but the variable becomes globally visiblenil value establishes an immutable type contract. An
initial :any establishes an equally immutable variant contract. Equivalent mutable redeclarations are permitted;
incompatible redeclarations fail without changing the value or contract. Assigning nil clears only the value.exec() observes the same environment contract. A failed redeclaration in
that code, or in a catchable try path, cannot migrate the policy seen by later direct, _G, alias, rawset() or
host API stores.tostring, table, obj, io and mod cannot be
overridden with global declarations or any environment write, including _G.name, computed _G[key], aliases of
_G and rawset(_G, ...). Protection is enforced at runtime as well as during compilation. Use an explicit local
if you need a scoped shadow. mSys is not a global at all; see the module declaration.ERR_Okay, KEY_SPACE, and generated enum members cannot be
assigned or redeclared with global. Use an explicit local, function parameter, or loop variable if a scoped shadow is
required.An extern declaration authorises reads of globals supplied by another compiled file or the embedding host. It emits
no runtime declaration and does not create or initialise the named values:
extern render_callback, shared_stateExtern declarations must precede the reads they authorise. Their names remain available to nested functions and are
limited to the current parse. Each external symbol must be listed explicitly; the extern * wildcard is invalid.
Tiri uses dot syntax for registered built-in instance methods. An immediate, ungrouped call on a receiver whose type is known at compile time invokes the canonical native function and supplies the receiver as its hidden first argument:
values = array<int> { 1, 2 }
values.push(3) -- Equivalent to array.push(values, 3)
text = " hello "
assert(text.trim().upper() is "HELLO")This special treatment applies only in call position and only to registered methods on arrays, strings, tables, ranges, structures and eligible objects. When the receiver type is not statically proved, Tiri performs the same method check at runtime before falling back to an ordinary field call.
Trusted native libraries can also mark a full-userdata metatable as method-compatible. Callable fields on such a metatable use the same explicit-receiver ABI as built-in methods. File handles opt into this contract, so their native methods support direct dot calls without registering every file operation as a canonical built-in:
file = io.open('temp:example.txt', 'w')
file.write('content')
file.close()The opt-in applies to the complete native metatable and cannot be enabled from Tiri source. Other userdata, ordinary tables and callable fields remain unchanged.
Reading, assigning, grouping or computing a method member retains ordinary, unbound field semantics. Pass the receiver explicitly when invoking an extracted or escaped native method:
write = file.write
write(file, 'one')
(file.write)(file, 'two')
file['write'](file, 'three')Ordinary callable table fields do not receive an implicit argument. Declare and pass an explicit receiver when the function needs one:
account = { balance = 10 }
function account.deposit(Account:table, Amount:num)
Account.balance += Amount
end
account.deposit(account, 5)
assert(account.balance is 15)Colon calls, safe-colon calls and colon-qualified declarations are syntax errors. Use value.member(args) for a
registered built-in or method-compatible native value. For an ordinary callable field, add and pass an explicit
receiver as shown above. Safe method calls use value?.member(args). Compiled chunks using the preceding bytecode
format are rejected and must be recompiled.
Several language constructs are lowered by the compiler directly to their canonical native implementation. The implementation is bound at compile time, so it cannot be redirected by reassigning the public namespace field that shares its name. This applies to:
{Start to Stop} and {Start into Stop}, and range slicing Value[{Range}];Value in Range;array<Type>, array<Type> { ... } and array<Type, Size>;struct<Name> { ... };obj<Class> { ... }; and&, |, ^, ~, << and >>.Explicit calls through the public namespace keep ordinary field-lookup semantics and remain rebindable. The two
forms are therefore independent: rebinding range, array, struct, obj or bit affects only explicit calls,
never the corresponding syntax.
local range = (() => "shadowed") -- Rebinds the explicit constructor only
assert(range() is "shadowed") -- Explicit call observes the replacement
values = {0 to 4} -- Literal syntax still builds a real range
assert(type(values) is "range")
local bit = { band = (() => 0) } -- Rebinds the explicit bit namespace only
assert(bit.band(6, 3) is 0) -- Explicit call observes the replacement
assert((6 & 3) is 2) -- Operator syntax still computes the real resultrange.slice, array.of, struct.new, bit.band and the other public functions remain fully documented, callable
and rebindable for explicit use. Only the compiler-generated form bypasses the mutable namespace field.
The blank identifier _ allows you to explicitly ignore values in assignments and loop variables:
-- Ignore error from function call
file, _ := openFile("data.txt")
-- Ignore multiple return values
_, _, result := getValues()
-- Loop without index
for _, value in ipairs(items) do
print(value)
end
-- Works with pairs() too
for _, v in pairs(table) do
process(v)
end
-- Multiple positions
local x, _, y = 1, 2, 3 -- x=1, y=3Notes:
local x = _ is an error)The pipe operator (|>) provides a functional programming style for chaining function calls. It passes the result of the left-hand side expression as the first argument(s) to the right-hand side function call.
Basic Syntax:
result = expression |> function_call()The expression on the left is evaluated first, and its result is prepended to the arguments of the function call on the right. This allows for readable left-to-right data flow instead of deeply nested function calls.
Multi-Value Support:
When the left-hand side returns multiple values (e.g., from a function call), all values are forwarded as arguments:
local function get_bounds()
return 10, 20
end
local result = get_bounds() |> math.max() -- math.max(10, 20) = 20Result Limiting:
Use |N> syntax to limit the number of return values forwarded from the left-hand side:
local function get_many()
return 1, 2, 3, 4, 5
end
local result = get_many() |2> math.max() -- math.max(1, 2) = 2Chaining:
Pipes can be chained for multi-step transformations:
local function double(x) return x * 2 end
local function square(x) return x * x end
local result = 3 |> double() |> square() -- square(double(3)) = 36Real-World Examples:
-- Data transformation pipeline
local function load_config(path)
return [*_]obj.new('file', { path = path, flags = '!READ' })
end
local function parse_json(file)
local content = [_*]file.acRead()
return json.decode(content)
end
local function validate(config)
assert(config.version, "Missing version")
return config
end
local config = "config.json" |> load_config() |> parse_json() |> validate()-- Processing user input with additional arguments
local function clamp(value, min, max)
return math.max(min, math.min(max, value))
end
local safe_value = tonumber(arg('value', '50')) |> clamp(0, 100)Notes:
Callback() to pass the value, or forEach(Callback) to traverse it.and, or) but lower than comparison operatorsa |> b() |> c() evaluates as a |> (b() |> c())obj |> obj.method() (not obj |> method())forEach()Use forEach(Target, Callback) when a pipeline needs to visit an iterable. It returns the original target, so it can
be used directly in a pipe chain:
values |> forEach((index, value) => print(index, value))
range(1, 4) |> forEach(value => print(value))
function visit(values:any)
values |> forEach(print)
endforEach() uses the same collection-aware generic for protocol, including a custom
__iter metamethod. It prepares the target and iterator once, then passes each iterator result to the callback in
the iterator's natural order:
| Target | Callback arguments |
|---|---|
| Native array | Index, Value |
| Table | Key, Value |
| Range | Value |
Custom __iter |
All yielded values, in their original order |
The callback can stop traversal by returning false. No return value, nil, true, and other truthy results allow
the traversal to continue:
{1 to 1000} |> forEach(i => do
if found_target(i) then
print('Found at', i)
return false
end
endA thunk target is resolved for traversal only. The original thunk is returned unchanged, so the next pipeline stage
receives the same value the caller supplied and rawtype() continues to report thunk.
forEach() is not an alias for collection-specific .each() methods. Those methods remain available and retain
their existing callback argument conventions.
When a pipe source returns multiple values, limit it explicitly before calling forEach(), which accepts exactly a
target and a callback:
collection_source() |1> forEach(callback)The result filter operator ([mask]) provides selective extraction of return values from multi-value function calls. It uses a prefix bracket syntax with a mask of _ (drop) and * (keep) characters to specify which values to retain.
Basic Syntax:
result = [mask]function_call()The mask is placed inside square brackets immediately before a function call. Each character in the mask corresponds to a return value position:
_ drops the value at that position* keeps the value at that positionExamples:
function multi()
return 1, 2, 3, 4, 5
end
-- Keep all values (default behaviour)
local a, b, c, d, e = [*]multi() -- a=1, b=2, c=3, d=4, e=5
-- Drop first value, keep the rest
local a, b, c, d = [_*]multi() -- a=2, b=3, c=4, d=5
-- Drop first two values, keep the rest
local a, b, c = [__*]multi() -- a=3, b=4, c=5
-- Keep first value only, drop all others
local a, b = [*_]multi() -- a=1, b=nil
-- Drop first, keep second, drop the rest
local a, b = [_*_]multi() -- a=2, b=nil
-- Keep values at positions 2, 3, 4 only
local a, b, c, d = [_***_]multi() -- a=2, b=3, c=4, d=nil
-- Keep second and fourth values onwards
local a, b, c = [_*_*]multi() -- a=2, b=4, c=5
-- Empty filter drops all values
local x = []multi() -- x=nilWith Method Calls:
obj = {
method = function()
return 10, 20, 30
end
}
second = [_*]obj.method() -- second=20Use Cases:
The result filter is particularly useful when:
-- Skip error code, get file content directly
content = [_*]file.acRead()
-- Get only the second and third return values
local b, c = [_**_]get_stats()Notes:
[_*]variable is a syntax errorTiri distinguishes exceptions from ERR values returned by Kōtuku APIs:
The try statement catches exceptions. It does not inspect returned ERR values. Use checkup to promote supported
direct native-call error results automatically, or check to inspect one expression explicitly.
Structured exception handling is provided through the try-except syntax.
Features:
return, break, continue) within try blocks.pcall approach, especially when JIT compiled.Basic Syntax:
try
risky_operation()
[ except [e] [ when Exception, ... ] ]
print("Error: " .. e.message)
[ except... ]
[ success ]
endThe except blocks catch exceptions raised within the preceding try block. Multiple except handlers are permitted, each with their own optional filter using the when clause. An unfiltered handler is recommended at the end to catch unexpected errors. The exception parameter e (or any name of your choosing) is a table containing information about the error:
| Field | Description |
|---|---|
code |
The ERR code, e.g. ERR_Failed. ERR_Exception is the default for message-only and generic runtime exceptions. |
message |
Human-readable error description. |
source |
Source file where the exception originated (may be nil). |
line |
Line number where the error occurred. |
trace |
Native array of stack frames (only with try<trace> enabled). |
stackTrace |
Formatted traceback string (only with try<trace> enabled). |
The success block is optional and runs if no exception occurred in the try block. It runs only when all resources in the try block have been cleaned up.
A plain try leaves returned error codes unchanged:
local err = ERR_Okay
try
err = mSys.AllocResource(-1024, 0)
except
raise 'Returned ERR values are not promoted by try'
success
assert(err is ERR_Args)
endThere is no support for finally blocks; use defer statements or <close> variables within the try block for
cleanup actions instead.
Exception Handling:
Exceptions come in two flavours: Error codes (16-bit integer constants) and string messages.
raise and Tiri runtime errors have the default error code ERR_Exception.raise and check. Preset error codes are integer
constants following the ERR_ naming convention, e.g. ERR_Okay, ERR_Failed and ERR_Args. The full list of
error codes is available in the Kōtuku Appendix.A key benefit to the use of error codes is that they can be filtered in exception handlers, allowing for specific handling of known error conditions. For example:
try
raise "Tiri exception"
except e
assert(e.code is ERR_Exception)
success
print("No error occurred")
end
try
raise ERR_Failed
except e when ERR_Failed
print('Caught ERR_Failed')
endLazy Exception Syntax:
Suppressing exceptions is permitted with the simplest form of syntax:
try
potentially_failing_operation()
endSimple catch-alls are also possible:
try
risky_operation()
except
print("An error occurred")
endRethrowing Exceptions:
Use bare raise within an except handler to propagate the current exception to an outer handler:
try
try
raise "Original exception"
except e
-- Log and rethrow the same exception.
msg(e.message)
raise
end
except e
-- Catches the rethrown exception.
print("Caught rethrown exception")
endBare raise preserves the original exception object and its code, message, source, line, trace and
stackTrace fields. It is valid only in the lexical body of an except handler. A nested function declaration or
literal starts a new function scope and cannot use the handler's bare rethrow context.
Filtered Exception Handling:
Use the when clause to catch specific ERR codes. Multiple codes are permitted per when clause, up to a limit of 4 per handler.
try
raise ERR_Args
except e when ERR_Args
print("Invalid arguments")
except e when ERR_Read, ERR_Write, ERR_Seek
print("Operation failed")
except e
print("Unexpected error: " .. e.message)
endError codes are constant integers, so raise also accepts custom codes. To prevent namespace pollution, use codes in
the range 8000 to 10000 for client code.
Filter Ordering Rules:
-- Valid: filters before catch-all
try
raise ERR_Failed
except e when ERR_Args
-- First filter
except e when ERR_Failed
-- Second filter (matches)
except e
-- Catch-all (must be last)
end
-- Invalid: catch-all before filter (parse error)
-- try ... except e ... except e when ERR_Failed ... endControl Flow in Try Blocks:
return, break, and continue work correctly within try blocks:
function find_value()
try
result = search()
return result -- Returns from function normally
except e
return nil -- Returns nil on error
end
end
for i in {0 to 10} do
try
if i is 5 then break end -- Exits loop
if i % 2 is 0 then continue end -- Skips to next iteration
process(i)
except e
-- Handle error and continue loop
end
endStack Traces:
By default, stack trace information is not captured for performance reasons. Use the <trace> attribute to enable stack trace capture when an exception occurs:
try<trace>
risky_operation()
try
raise "Inner exception" -- No trace captured: this inner try has no <trace> attribute.
except e
assert(e.trace is nil)
raise
end
except e
print(e.stackTrace) -- Formatted traceback string
-- Or access individual frames
for i, frame in ipairs(e.trace) do
print(f"{frame.source}:{frame.line}: in {frame.func ?? 'anonymous'}")
end
endThe trace field contains an array of frame tables, each with the following fields:
| Field | Description |
|---|---|
source |
Source file name (may be nil). |
line |
Line number (0 if unknown). |
func |
Function name (may be nil for anonymous functions). |
The stackTrace field provides a pre-formatted string in the standard "stack traceback:" format, suitable for logging or display.
checkup ... end automatically promotes supported error results from native Kōtuku calls made directly in its lexical
scope. It does not catch exceptions and has no except, success, or attribute clauses.
checkup
initialise_application()
endCombine checkup with try when promoted errors should be handled locally:
try
checkup
file.acOpen()
file.acRead()
end
except e
print(e.message)
endChecking is immediate rather than dynamic. Calls made inside another Tiri function do not inherit the caller's
checkup scope:
function helper():any
return mSys.AllocResource(-1024, 0)
end
checkup
local err = helper() -- Returns ERR_Args normally.
mSys.AllocResource(-1024, 0) -- Promotes ERR_Args.
endThe block introduces an ordinary lexical scope and supports nesting, return, break, and continue. Exceptions
raised by raise, runtime checks, or called Tiri code pass through unchanged.
Exceptions can be raised at runtime by using the following features:
assert() is used to validate conditions and raise exceptions when the condition is false. It accepts two parameters - the first being the condition to evaluate, and the second an optional error message to include in the exception. Example:
assert(x > 0, 'x must be greater than zero')The message parameter is not evaluated if the condition was true. This allows expensive computations (e.g. long string concatenation) to be written without incurring overhead during normal runtime operations.
check is the equivalent of an assert() for ERR codes. Any non-safe error code passed to this function will raise an exception that includes a readable message matching the ERR code.
Incoming parameters are returned without modification, allowing check to operate seamlessly in function call chains. Example:
err, bytes_read = check file.acRead(file, buffer)The following ERR codes will not raise an exception: Okay, False, LimitedSuccess, Cancelled, NothingDone, Continue, Skip, Retry, DirEmpty
raise is Tiri's exception mechanism. Its statement forms cover message-only exceptions, coded exceptions, coded
exceptions with custom messages and rethrowing the active exception:
raise 'Operation failed' -- ERR_Exception with this message.
raise ERR_Failed -- ERR_Failed with its standard message.
raise ERR_Failed, 'Operation failed due to X' -- ERR_Failed with a custom message.
try
perform_operation()
except e
log(e.message)
raise -- Rethrow the complete original exception.
endAll error codes are raised, including minor codes. The code is also propagated to the Script object's Error field
for reporting to the C or C++ client. raise and check accept custom integer error constants; use codes in the range
8000 to 10000 to avoid conflicts with Kōtuku system codes.
Parenthesised raise(Value) and raise(Code, Message) forms are non-returning expressions. They are useful where a
branch must either produce a value or raise an exception:
port = supplied_port ? supplied_port : raise('A port is required')
result = choose status from
'ready' -> read_result()
else -> raise(ERR_InvalidState, 'The result is not ready')
endThe selected payload is evaluated once. An unselected branch is not evaluated, and a selected raise(...) never
produces a value. Prefer the unparenthesised form when raise is used as a statement.
The global error() built-in has been removed. References to it are treated as undeclared variables. The following
migration table maps older code to the current forms:
| Old form | Replacement | Notes |
|---|---|---|
error(Message) |
raise Message |
Raises ERR_Exception with the supplied message. |
error(e) in an except handler |
bare raise |
Preserves the original exception object and all metadata. |
error(Message) in an expression |
raise(Message) |
The parenthesised form is a non-returning expression. |
error(Message, Level) |
raise Message |
The Level override has no replacement; raise reports its actual source location. |
first-class error reference |
function(Message) raise Message end |
raise is a keyword, so callbacks require a wrapper. |
The defer statement schedules a function to execute when the enclosing scope exits. Deferred functions execute in LIFO order (last deferred, first executed) and are guaranteed to run on normal scope exit, early return, break, or continue.
Basic syntax (no arguments):
function example()
file = io.open("data.txt")
defer
file.close()
end
-- ... use file ...
end -- file.close() executes hereWith argument snapshot:
function example()
status = "initial"
defer(s)
print("Final status: " .. s)
end(status)
status = "modified"
end -- prints "Final status: initial"Multiple defers execute in reverse order:
defer
print("third")
end
defer
print("second")
end
defer
print("first")
end
-- Output: "first", "second", "third"Execution guarantees:
return, break, or continueend(...) are snapshotted at registration timeLimitations:
Deferred expressions provide lazy evaluation of expressions, delaying computation until the value is actually accessed. This is particularly useful for avoiding expensive computations in conditional parameters or logging statements that may not be executed.
Basic syntax:
<type{ expression }> or <{ expression }>The expression inside <{ }> is not evaluated immediately. Instead, evaluation is deferred until the value is accessed through standard API functions or explicitly resolved.
Consider this common pattern:
some_function(conditional_value, "Error occurred: " .. expensive_debug_info())The string concatenation and expensive_debug_info() are processed immediately irrespective of whether some_function() will use the computed variable. With deferred expressions:
some_function(conditional_value, <{ "Error occurred: " .. expensive_debug_info() }>)The expensive computation only occurs if the assertion fails and the message is accessed.
Type Inference:
Tiri automatically infers types from deferred expressions:
str = <{ 'hello' }> -- Inferred as string
num = <{ 42 }> -- Inferred as number
bool = <{ true }> -- Inferred as boolean
tbl = <{ {} }> -- Inferred as table
result = <{ a + b }> -- Inferred as number (arithmetic)
msg = <{ s .. t }> -- Inferred as string (concatenation)Explicit Type Annotation:
When type cannot be inferred (such as function calls), use explicit type annotation:
<str{ getValue() }>
<num{ compute() }>The resolve() Function:
Use resolve() to explicitly evaluate a deferred expression:
lazy = <{ expensive_computation() }>
-- Later, when you need the value:
value = resolve(lazy)The resolve() function returns non-deferred values unchanged, making it safe to call on any value.
Single Evaluation:
Once evaluated, the result is cached in the variable. Subsequent access returns the cached value without re-evaluation:
count = 0
x = <{ count++; count }>
print(resolve(x)) -- Prints 1, increments count and caches result back in x
print(resolve(x)) -- Prints 1 again, uses cached resultSingle-evaluation is a super-power for deferred expressions if used correctly, and offers creative programming opportunities. For instance:
glSelf = <obj{ obj.find('self') }> -- Executes once, does nothing if never used.
activate_object = <num{ object.acActivate() }> -- On resolution stores the ERR code permanentlyImportant Notes:
type() on a deferred expression will return the type associated with the expression without evaluating it.Thunk functions extend deferred expressions to support parameterised lazy evaluation. While deferred expressions wrap a single expression, thunk functions allow you to define reusable lazy computations with parameters.
Syntax:
thunk name(params):type
-- body
return value
endThe thunk keyword declares a function that, when called, is primed by capturing its arguments and returns a deferred value. The body is not executed until the result is accessed through a read operation or explicitly resolved.
Example - Lazy Database Query:
thunk fetch_user(id:num):table
print("Fetching user " .. id)
return database.query("SELECT * FROM users WHERE id = ?", id)
end
user = fetch_user(123) -- Function primed in user and not executed yet
print(user.name) -- Query executes here, prints "Fetching user 123"
print(user.email) -- Uses cached result, no re-queryExample - Conditional Computation:
thunk expensive_report(year:num):str
return generate_annual_report(year) -- Only runs if report is actually used
end
report = expensive_report(2024) -- Store reference to thunk with 2024 parameter value
if user_requested_report then
print(report) -- Report generated only when needed
endAnonymous Thunks:
Anonymous thunks can skip the invocation process if they are parameterless:
local isAvailable = thunk():bool -- Prepare isAvailable without executing the body
-- Do something --
return true
end
print(isAvailable) -- Resolved and cached here, no need to prime with isAvailable()
If one or more parameters are specified in the thunk signature then this feature does not apply.
Key Characteristics:
type() returns the declared type without executing the body.resolve() for explicit evaluation.When is, is not or != is followed by an angle-bracket descriptor, it performs a non-throwing boolean type test.
The left operand is evaluated once and only its first result is tested. Without the descriptor, is and != retain
their ordinary value-equality and inequality meanings.
type(value) is 'Vector' -- Display-name query; honours a valid metatable __name.
rawtype(value) is 'table' -- Exact internal-name query; ignores a metatable __name.
value is <num> -- Direct boolean type test.
value is <array int> -- Native array with declared int element storage.
value is not <nil> -- Negated type test.
value != <str> -- Symbolic spelling of a negated type test.| Descriptor | Matches |
|---|---|
<any> |
Every value, including nil |
<nil>, <bool>, <num>, <str> |
The corresponding primitive type |
<table>, <func>, <range>, <userdata> |
The corresponding outer runtime type |
<array> |
Any native array, but not a table |
<array T> |
A native array whose declared element descriptor is T, including <array struct<Name>> |
<struct> |
Any Tiri structure |
<struct Name> |
The resolved structure definition named Name |
<obj> |
Any valid Kōtuku object wrapper |
<obj Class> |
The named Kōtuku class and its derived classes |
object is accepted as an alias for obj in type-test descriptors; obj is the canonical spelling. Constrained
names are resolved while compiling, so the class or structure must exist and a structure declaration must precede its
use. Aggregate constraints follow the outer type name after whitespace: write <array int>, <obj Vector> and
<struct Point>. Structure array elements retain their canonical descriptor, as in <array struct<Point>>.
For a deferred value with a declared logical type, an unconstrained test uses that declaration without forcing the
body, consistently with type(). A constrained aggregate test resolves the value when its deferred metadata is not
sufficient to decide the result.
The ≈ operator compares two numeric values using an inclusive absolute tolerance of 1e-5. It returns true when Left - Right is between -1e-5 and 1e-5, matching the rule math.abs(Left - Right) <= 1e-5 without evaluating either operand more than once.
if 0.33333 ≈ 1 / 3 then
print('close enough')
endThere is no ASCII alias for this operator. Use not (a ≈ b) when an approximate inequality check is required. Operands must be numeric; non-numeric values fail through the normal arithmetic path. NaN is never approximately equal, and infinities follow the subtraction rule, so math.huge ≈ math.huge is false because the intermediate difference is NaN.
Tiri adds a ?? operator that extends the falsey semantics of the or operator. The ?? operator treats the following values as falsey: nil, false, 0, and "" (empty string). This feature was introduced so that it would be easier to write shorthand for dealing with values that are empty. In addition, the right-hand side is not evaluated if the left-hand is true, leading to faster code.
Examples:
-- Standard 'or' vs '??' with zero
a = 0 or "default" -- a is 0 (zero is truthy by default)
b = 0 ?? "default" -- b is "default" (zero is falsey in ??)
-- Standard 'or' vs '??' with empty string
c = "" or "fallback" -- c is "" (empty string is truthy by default)
d = "" ?? "fallback" -- d is "fallback" (empty string is falsey in ??)
-- Chaining
value = "" ?? 0 ?? "final" -- value is "final" (both "" and 0 are falsey)
-- Short-circuit evaluation
function expensive()
raise "Should not be called"
end
result = "valid" ?? expensive() -- result is "valid", expensive() is never calledIf-Empty Conditional Shorthand
The ?! operator can guard routines and control flow. For instance, if not value then raise 'Value is empty' end
can be written as:
value ?! raise 'Value is empty'Use ?! when the same extended falsey check should guard control flow with return, break, continue,
raise or check:
user_input ?! return ERR_InvalidInputNotes
or treats only nil and false as falsey. Values like 0 and "" are considered truthy.?? operator treats 0 and "" as falsey.?? operator has the same precedence as or and and (lowest priority).or and and.?? are intentionally forbidden.If the ?? operator is used in the absence of a value to its right-hand-side, it is treated as a postfix operator. In this mode it will return a boolean indicating whether a value is present, using extended falsey semantics. It returns false for nil, false, 0, and "" (empty string), and true for all other values.
-- Basic usage
if comment?? then
print("Comment exists")
end
-- In expressions
greeting = name?? and "Hello, " .. name or "Hello, Guest"
-- With field access and expressions
if config.timeout?? then ... end
if (x + y)?? then ... endAs a postfix operator, ?? has high precedence and evaluates before logical operators.
The use of a single ? can be complemented with a C-style ternary conditional operator : that creates an expression equivalent to an if-then-else statement.
result = condition ? true_expr : false_expr
The standard ternary evaluates condition using the same falsey semantics as if: only nil and false are falsey. If truthy, it returns true_expr; if falsey, it returns false_expr. Only one branch is evaluated to ensure run-time efficiency.
status = user ? "logged in" : "guest"
max = a > b ? a : b
msg = error ? "Error: " .. error : "Success"Use ?? : when the condition should use extended falsey semantics, matching the ?? operator: nil, false, 0, "", and empty arrays are falsey. The former :> separator remains accepted but is deprecated and emits a warning.
status = value ?? "present" : "empty"Notes:
or, and, ?, and ?? (lowest priority)a ? b : c ? d : e parses as a ? b : (c ? d : e)local a, b = (cond ? x : y), zTiri provides a dedicated range type, implemented as userdata, for finite numeric sequences. Ranges are primarily
used for iteration, slicing and membership tests, and integrate with the language via both constructor functions and
literal syntax.
Ranges are immutable. Once created, a range's start, stop, step and inclusive properties cannot be modified.
The range() constructor function creates a range object:
r1 = range(0, 5) -- Exclusive: 0,1,2,3,4
r2 = range(0, 5, true) -- Inclusive: 0,1,2,3,4,5
r3 = range(0, 10, false, 2) -- Exclusive with step: 0,2,4,6,8
r4 = range(10, 0) -- Reverse: 10,9,8,...,1 (step inferred as -1)
r5 = range(0, 1, false, 0.25) -- Fractional: 0,0.25,0.5,0.75
r6 = range(1, 0, true, -0.3) -- Reverse fractional: 1,0.7,0.4,0.1Constructor parameters:
range(Start, Stop)
Start up to, but not including, Stop.range(Start, Stop, Inclusive)
Inclusive is true, the Stop value is included.range(Start, Stop, Inclusive, Step)
Step must not be positive or negative zero.Start, Stop and Step may be integer-valued or fractional numbers, but must be finite. nil, non-numeric values,
NaN and positive or negative infinity raise an error. The default step is 1 when Start <= Stop and -1 otherwise.
An explicit step is used as written; a step pointing away from the stop produces an empty range.
Sequence values are calculated from their zero-based ordinal as Start + (Ordinal * Step), avoiding accumulated
addition drift. to excludes the boundary and into permits it only when the step actually reaches it. For example,
{0 into 1 by 0.3} contains 0, 0.3, 0.6 and 0.9, not a forced final 1. Range lengths must fit Tiri's Lua
integer type, and materialised ranges must also fit the native array size.
Range properties and helpers:
r.start / r.stop / r.step / r.inclusive — access range parameters.#r or r.length — number of elements in the range.Ranges report their type as range:
r = range(0, 5)
assert(type(r) is "range")Tiri adds literal syntax for constructing ranges using braces and word separators:
r1 = {0 to 5} -- Exclusive: 0,1,2,3,4
r2 = {0 into 5} -- Inclusive: 0,1,2,3,4,5
r3 = {10 to 1} -- Reverse exclusive: 10,9,8,...,2
r4 = {5 into 1} -- Reverse inclusive: 5,4,3,2,1
r5 = {0 to 10 by 2} -- Exclusive with step: 0,2,4,6,8
r6 = {10 into 0 by -2} -- Reverse inclusive with step: 10,8,6,4,2,0
r7 = {0 to 1 by 0.2} -- Fractional exclusive: 0,0.2,0.4,0.6,0.8Rules:
{Start to Stop} creates an exclusive range.{Start into Stop} creates an inclusive range.{Start to Stop by Step} and {Start into Stop by Step} create stepped ranges.Start > Stop) automatically infer a negative step.Literal ranges are fully compatible with the constructor:
assert({0 to 5} is range(0, 5))
assert({0 into 5} is range(0, 5, true))
assert({0 to 10 by 2} is range(0, 10, false, 2))Invalid literal operands (non‑numeric or nil) will raise an error at runtime when the underlying range() call is evaluated.
A generic for ... in loop accepts a bare native array, table or stored range. The values produced by each collection
are:
| Target | First value | Second value |
|---|---|---|
| Native array | Zero-based index | Element |
| Table | Key | Value |
| Range | Range value | None |
for index, value in array<int> { 10, 20 } do
print(index, value)
end
for key, value in { width = 640, height = 480 } do
print(key, value)
end
local values = range(0, 5)
for value in values do
print(value)
endBefore applying its normal collection fallback, a bare single-target loop looks up the target's __iter metamethod.
The lookup uses only the target metatable or base metatable: an ordinary __iter field and a value supplied by
__index do not select the protocol. A table handler receives no visible arguments and accesses its target through
&&; a native non-table handler receives its target as its ordinary receiver argument.
The handler runs once when the loop is entered and must return a function. The loop uses that function as its iterator
with the existing (Iterator, nil, nil) protocol. Its first result becomes the first loop variable and ends iteration
when it is absent or nil; additional results populate further loop variables. Any additional results from the
__iter handler itself are discarded. A present handler that returns another type raises
__iter must return a function, got TYPE; it does not fall back to __pairs.
local range_mt = {
__iter = function():func
local current = &start - 1
local stop = &stop
return function():num
current++
if current <= stop then return current end
end
end
}
local values = setmetatable({ start = 1, stop = 3 }, range_mt)
for value in values do
print(value)
end__iter takes precedence over a table's __pairs handler, raw table traversal and a callable table's __call.
Removing it restores the normal fallback: tables use pairs(Target) and therefore __pairs, arrays yield their
zero-based index and element, stored ranges yield values, and supported native objects retain their pairs()
integration. Explicit pairs(Target) always uses the three-result __pairs protocol and never consults __iter.
Likewise, iterator functions, ipairs() and explicit iterator, state, control triples retain their existing
behaviour.
The selected iterator is a loop-entry snapshot. Replacing or removing __iter while a loop is running does not alter
that loop, but a later loop entry observes the new metatable state. Direct range literals continue to use specialised
numeric-range lowering and do not materialise a range object.
A dynamic target is prepared once when the loop is entered; unsupported values raise a focused
cannot iterate over a TYPE value error before the first iteration.
Ranges are the only supported syntax for numeric for loops. Lua-style headers such as for i = 0, 8 do are
rejected; use the inclusive form for i in {0 into 8} do. The into separator includes the stop value, while to
excludes it. Both forms retain the normal do ... end block.
An omitted Lua step was always +1, while an unstepped range infers its direction. The exact replacement for a
descending legacy loop that was intentionally empty is therefore an explicit positive step, such as
for i in {8 into 0 by 1} do.
Ranges support Tiri's generic for loops. Use range literals directly in the loop header:
for i in {1 to 6} do
print(i) -- 1,2,3,4,5
end
for i in {1 into 5} do
print(i) -- 1,2,3,4,5
end
for i in {5 to 1} do
print(i) -- 5,4,3,2 (exclusive)
end
for i in {5 into 1} do
print(i) -- 5,4,3,2,1 (inclusive)
endAnonymous loops omit the need for a variable store:
for {0 to 10} do
count++
end
You can also iterate over a range stored in a variable directly:
r = {0 to 5}
sum = 0
for i in r do
sum += i -- 0+1+2+3+4
endConstructor-based ranges can also be used directly:
for i in range(0, 10, false, 2) do
-- i = 0,2,4,6,8
end
for i in {0 to 10 by 2} do
-- i = 0,2,4,6,8
end
for i in {10 into 0 by -2} do
-- i = 10,8,6,4,2,0
endReturns an array matching the range sequence. A range whose stored parameters are all safe 32-bit integers produces
an array<int>; a fractional parameter or a parameter outside that domain produces an array<double>. take() uses
the same element-type policy. filter() retains that array<int> or array<double> policy, while an untyped
map() returns array<any>.
Membership first enforces the range boundary and then compares the nearest reconstructed ordinal using a small
ULP-scaled tolerance. This means 0.3 in range(0, 1, false, 0.1) is true despite ordinary binary
floating-point representation error, while the exclusive stop remains excluded.
result = range.slice(Object, Range)
The range.slice() function provides a unified API for slicing arrays, tables and strings. It is the underlying
implementation used by the t[{range}] and s[{range}] index syntax, as well as table.slice().
Object is an array, table or string to slice. Range is a range object (literal or constructed) specifying the slice bounds.
Collection indices are a separate contract from numeric sequence values. String, table and array slices, plus
range-bounded array.fill() and array.indexOf(), require start, stop and step to be exactly integer-valued and to
fit the supported index type. Whole-valued floating-point parameters such as range(0.0, 5.0, false, 1.0) are valid;
fractional parameters raise an index-range error rather than being truncated or rounded.
Examples:
-- Table slicing
t = {10, 20, 30, 40, 50}
result = range.slice(t, {1 to 4}) -- {20, 30, 40}
result = range.slice(t, {0 into 4}) -- {10, 20, 30, 40, 50}
-- String slicing
s = "Hello, World!"
result = range.slice(s, {0 to 5}) -- "Hello"
result = range.slice(s, {-6 into -1}) -- "World!"
result = range.slice(s, {-6 to -1}) -- "World"
-- With stepped ranges (requires constructor)
t = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
r = range(0, 9, true, 2)
result = range.slice(t, r) -- {0, 2, 4, 6, 8}
-- Reverse slicing
result = range.slice(t, {9 into 0}) -- {9, 8, 7, 6, 5, 4, 3, 2, 1, 0}Ranges provide an each() method for functional-style iteration. The method accepts a callback function that is
invoked once for each value in the range:
sum = 0
{1 to 6}.each(Value => sum += Value)
-- sum is now 15 (1+2+3+4+5)The callback receives (Value, Ordinal). Ordinal is the zero-based position in the generated sequence, rather than
the numeric range value.
Early Termination
The callback can return false to terminate iteration early:
sum = 0
r = {1 to 100}
r.each(Value => do
if Value > 5 then return false end
sum += Value
end)
-- sum is 15 (iteration stopped after 5)Returning true or nil (no return) continues iteration normally.
Method Chaining
The each() method returns the original range object, enabling method chaining:
r = {1 to 6}
r.each(Value => print(Value))
.each(Value => process(Value))Usage with Constructor and Literals
The each() method works with both range literals and constructor-based ranges:
-- With constructor (can chain directly)
range(0, 10, false, 2).each(Value => print(Value))
-- With literal (must store in variable first due to parser limitations)
r = {0 to 10}
r.each(Value => print(Value))Ranges provide several functional programming methods for transforming, filtering, and querying data. These methods offer a declarative style for working with numeric sequences.
Every functional callback receives the generated value followed by its zero-based ordinal. each, filter, map,
find, findIndex, any and all call (Value, Ordinal); reduce calls (Accumulator, Value, Ordinal). Existing
callbacks that declare fewer parameters continue to work.
Returns an array<int> or array<double> containing only values for which the predicate function returns true.
-- Get even numbers from 1 to 10
evens = {1 to 11}.filter(i => i % 2 is 0)
-- evens = {2, 4, 6, 8, 10}
-- Filter with more complex logic
scores = {0 to 100}.filter(function(score)
return score >= 60 and score <= 80
end)Folds all values in the range into a single accumulated result:
-- Sum numbers 1 through 5
sum = {1 into 5}.reduce(0, (acc, i) => acc + i)
-- sum = 15
-- Calculate factorial of 5
factorial = {1 into 5}.reduce(1, (acc, i) => acc * i)
-- factorial = 120
-- Build a comma-separated string
csv = {1 to 4}.reduce("", function(acc, i)
if acc is "" then return tostring(i) end
return acc .. "," .. tostring(i)
end)
-- csv = "1,2,3"Returns an array with each value transformed by the given function. The callback receives (Value, Ordinal). Without
ElementType, the result is array<any>; an explicit type requests checked typed storage and raises if a callback
result cannot be stored in that type.
-- Double each value
doubled = {1 to 6}.map(i => i * 2)
-- doubled = {2, 4, 6, 8, 10}
-- Convert to strings with formatting
labels = {1 to 4}.map(i => "Item " .. tostring(i))
-- labels = {"Item 1", "Item 2", "Item 3"}
-- Request typed result storage
typed_labels = {1 to 4}.map(i => "Item " .. tostring(i), 'str')
-- Calculate squares
squares = {1 into 5}.map(i => i * i)
-- squares = {1, 4, 9, 16, 25}Returns an array containing the first Count values from the range:
-- Get first 3 values
first3 = {1 to 100}.take(3)
-- first3 = {1, 2, 3}
-- Works with reverse ranges
last3 = {10 to 0}.take(3)
-- last3 = {10, 9, 8}
-- If count exceeds range length, returns all available values
all = {1 to 4}.take(10)
-- all = {1, 2, 3}Returns true if any value in the range satisfies the predicate (short-circuits on first match):
-- Check if any value is greater than 5
hasLarge = {1 to 10}.any(i => i > 5)
-- hasLarge = true
-- Check if any value is negative
hasNegative = {0 to 100}.any(i => i < 0)
-- hasNegative = false
-- Efficient: stops at first match
found = {1 to 1000000}.any(i => i is 42)
-- Only checks values 1 through 42Returns true if all values in the range satisfy the predicate (short-circuits on first failure):
-- Check if all values are positive
allPositive = {1 to 10}.all(i => i > 0)
-- allPositive = true
-- Check if all values are less than 5
allSmall = {1 to 10}.all(i => i < 5)
-- allSmall = false (fails at 5)
-- Empty ranges return true (vacuous truth)
emptyCheck = {5 to 5}.all(i => false)
-- emptyCheck = trueReturns the first value that satisfies the predicate, or nil if none found:
-- Find first value greater than 5
first = {1 to 10}.find(i => i > 5)
-- first = 6
-- Find first even number
firstEven = {1 to 10}.find(i => i % 2 is 0)
-- firstEven = 2
-- Returns nil when not found
notFound = {1 to 10}.find(i => i > 100)
-- notFound = nil
-- Works with reverse ranges
fromEnd = {10 to 0}.find(i => i < 5)
-- fromEnd = 4Returns the zero-based ordinal of the first value for which Predicate(Value, Ordinal) is truthy, or nil when no
value matches:
ordinal = {10 to 20 by 2}.findIndex((value, ordinal) => value > 13)
-- ordinal = 2 (the value is 14)Returns the zero-based ordinal of an equal generated value, or nil when the value is absent. It uses the same
boundary, step-alignment and floating-point tolerance as range membership:
ordinal = {10 to 20 by 2}.indexOf(14)
-- ordinal = 2Method Return Types:
| Method | Returns |
|---|---|
filter() |
array<int> or array<double> |
reduce() |
Single value (type depends on reducer) |
map() |
Array |
take() |
array<int> or array<double> |
any() |
Boolean |
all() |
Boolean |
find() |
Value or nil |
findIndex() |
Ordinal or nil |
indexOf() |
Ordinal or nil |
Note: Methods that return arrays (filter, map, take) cannot be chained with other range methods since arrays
are not ranges. Use reduce() directly on a range for aggregation, or store intermediate results:
-- Sum of squares of even numbers from 1 to 20
sum = {1 to 21}.reduce(0, function(acc, i)
if i % 2 is 0 then return acc + (i * i) end
return acc
end)
-- sum = 2**2 + 4**2 + 6**2 + ... + 20**2 = 1540Strings support range objects as indices to perform slicing. Indexing is zero‑based and inclusive of the start
position; the end index is interpreted according to the range's inclusive flag.
s = "Hello, World!"
assert(s[{0 to 5}] is "Hello") -- Exclusive: indices 0..4
assert(s[{7 to 12}] is "World") -- Exclusive: indices 7..11
assert(s[{0 into 4}] is "Hello") -- Inclusive: indices 0..4
assert(s[{-6 into -1}] is "World!") -- Negative inclusive: indices 7..12
assert(s[{-6 to -1}] is "World") -- Negative exclusive: indices 7..11Out‑of‑bounds and empty ranges behave predictably:
start equals stop yield an empty string.The # operator reports the length of a value. Its result depends on the operand:
| Operand history or type | Result of #Value |
|---|---|
| Empty table with no classifying key access | 0 |
| Table with a sequence-compatible usage history | Numerical sequence length |
| Table ever addressed with a non-numeric key | nil permanently |
| Table ever addressed with a negative or fractional key | nil permanently |
| String | String length |
| Native array | Array element count |
Table with a __len metamethod |
Metamethod result |
Every table carries a permanent usage classification. Because # is meaningless once a table has been used as
something other than a dense sequence, the operator reports nil rather than the length of whatever happens to sit in
the array part.
| Classification | Recorded history | #Table |
|---|---|---|
sequence |
Only sequence-compatible key usage | Numerical length |
sparse |
Negative or fractional numerical keys | nil |
associative |
One or more non-numeric keys | nil |
mixed |
Both sparse numerical and non-numeric usage | nil |
entries = { 10, 20 }
print(#entries) -- 2
entries.name = 'example'
print(#entries) -- nilThe classification is permanent. It records usage history rather than the table's current live shape, so removing the entry does not restore a numerical length:
entries = { 10, 20 }
entries.name = 'example'
entries.name = nil
assert(#entries is nil)
table.clear(entries)
assert(#entries is nil)The following operations classify a table as associative:
Table.name = Value.Table['name'] = Value, Table[true] = Value,
Table[SomeTable] = Value or Table[SomeFunction] = Value.nil through a non-numeric key, even when no live entry results.nil.rawset().{ 1, 2, name = 'x' } and { 1, 2, [true] = 'x' }.A numerical key classifies a table as sparse when it cannot be a valid zero-based sequence index:
negative = { 10, 20, 30 }
negative[-5] = 'a'
assert(#negative is nil)
assert(table.kind(negative) is 'sparse')
fractional = { 10, 20, 30 }
fractional[1.5] = 'c'
assert(#fractional is nil)
assert(table.kind(fractional) is 'sparse')Distant positive keys are not classified. Detecting a gap would require computing the sequence length on every integer store, which is the hottest table operation in the interpreter. A table with a hole therefore retains a numerical length, and that length stops at the first missing index:
gapped = { 10, 20, 30 }
gapped[9999] = 'b'
assert(#gapped is 3) -- The gap is not detected
assert(table.kind(gapped) is 'sequence')
assert(table.size(gapped) is 4) -- But every live entry is still countedUse table.size() rather than # whenever a table may contain gaps.
Keys supplied only by an __index metamethod leave the target unclassified, because no store reaches it. When a
store is diverted by __newindex, the table that ultimately receives the raw store is classified rather than the
proxy.
A table's own __len metamethod takes precedence over the classification:
entries = { 1, 2 }
entries.name = 'x'
setmetatable(entries, { __len = function(Self):num return 7 end })
print(#entries) -- 7
setmetatable(entries, nil)
print(#entries) -- nilFunctions that infer a numerical boundary from the table itself reject any table whose classification is not
sequence, reporting both the function and the classification:
records = { 10, 20 }
records.name = 'example'
records.insert(30)
-- Error: 'insert'() requires a sequence table; received associative tableThe guard applies to records.insert(), records.remove(), records.sort(), table slicing, and records.concat() when its
end index is omitted.
Explicitly bounded operations remain valid on any classification, because the caller supplies the domain rather than claiming the whole table is a sequence:
records = { 'a', 'b', 'c' }
records.name = 'example'
assert(records.concat(',', 0, 2) is 'a,b,c') -- Explicit bounds: permitted
records.move(0, 2, 3) -- Explicit bounds: permittedrecords.empty() is independent of classification and continues to inspect live entries. Serialisation and the
Lua-compatible C API also continue to use the raw numerical boundary.
kind = Table.kind() -- 'sequence', 'sparse', 'associative' or 'mixed'
count = Table.size() -- Every live entry in the array and hash partsTable.kind() reports the permanent usage classification. It performs raw inspection, never invokes metamethods, and
always returns a string.
Table.size() counts every live entry regardless of classification, ignoring nodes whose value is nil and never
invoking __index. It is independent of # and of any __len metamethod, and runs in O(n):
assert(table.size({ 10, 20, name = 'example' }) is 3)
assert(table.size({ [1000] = true }) is 1)table.empty() remains the preferred emptiness test; it inspects live entries directly rather than calling # or
table.size().
The primary result type of # is always num. It is non-null for strings and native arrays, while tables and
unknown operands carry a nullable numeric result because an ordinary table can yield nil. Tiri's num type
accommodates that nullable result, so no any annotation is required:
function countOf(Items):num
return #Items
endAn explicit __len metamethod must return a number. Returning nil or any other type raises an error rather than
changing the primary result type of the operator.
Migration note: code that applies # to a record-like, sparse or mixed table receives nil where it previously
received the array-part length, and sequence library functions now reject those tables outright. To migrate:
array<Type> for dense, typed sequences.table.size() to count entries and table.empty() to test emptiness.{ 10, 20, name = 'x' } with separate sequence and metadata values.A table constructor that provably repeats a key is rejected at compile time. The overwritten intermediate value can never be observed and the later entry unambiguously wins, so accepting the literal could only hide a mistake:
invalid = { name = 'first', name = 'second' } -- Error: Duplicate key 'name'
invalid = { 10, [0] = 20 } -- Error: positional entry 0 collides
invalid = { ['name'] = 1, name = 2 } -- Error: equivalent string keys
invalid = { [1] = 'a', [1.0] = 'b' } -- Error: equivalent numerical keysConstant numerical keys are canonicalised so that equivalent integer and floating representations collide, and named fields collide with equivalent constant string keys. Positional entries occupy consecutive indices from zero and collide with explicit keys covering the same index.
Keys that cannot be proven without evaluating user code are never diagnosed:
local key = 'name'
valid = { [key] = 1, [key] = 2 } -- Accepted: the parser cannot prove the collisionA literal is classified from its final key set rather than its construction order, so a literal listing keys 0
through N - 1 remains a sequence even when explicit key syntax lists them out of order.
Structurally mutating a table while iterating it — adding or removing a live key — produces results that depend on the table's internal layout and traversal state. This is unsupported; the iteration may visit entries more than once, skip entries, or continue for longer than the table has elements:
local t = {}
for i in {0 to 5} do t[i] = i end
for k, v in pairs(t) do t[k + 100] = v end -- Unsupported: layout-dependentReplacing the value of an existing key is not structural and is safe. When entries must be added or removed during a traversal, collect the keys first and mutate afterwards:
local pending = {}
for k, v in pairs(t) do pending.insert(k) end
for _, k in pairs(pending) do t[k] = nil endTiri does not currently detect this condition; the cost of a per-advance check is not justified by the measured iteration cost, so mutation during traversal is documented as unsupported rather than diagnosed.
Tables support range objects as indices to extract subsequences. Indexing is zero‑based and follows the same inclusive/exclusive semantics as string slicing. Table slices always return a new table containing copies of the selected elements.
t = {10, 20, 30, 40, 50}
subset = t[{1 to 4}] -- Returns {20, 30, 40} (exclusive)
subset = t[{1 into 3}] -- Returns {20, 30, 40} (inclusive)
subset = t[{0 to 5}] -- Returns {10, 20, 30, 40, 50} (full table)
subset = t[{-3 into -1}] -- Returns {30, 40, 50} (negative inclusive)
subset = t[{-3 to -1}] -- Returns {30, 40} (negative exclusive)Negative Indices:
Negative indices count backwards from the end of the table. The range operator still controls the stop point:
... includes the resolved stop index and .. excludes it.
t = {10, 20, 30, 40, 50}
t[{-2 into -1}] -- Returns {40, 50} (inclusive)
t[{-2 to -1}] -- Returns {40} (exclusive)
t[{1 into -1}] -- Returns {20, 30, 40, 50} (inclusive)
t[{1 to -1}] -- Returns {20, 30, 40} (exclusive)
t[{-3 into 4}] -- Returns {30, 40, 50} (inclusive)Reverse Slicing:
Ranges where the start index is greater than the stop index produce reversed subsequences. The direction is auto-detected based on the resolved indices.
t = {10, 20, 30, 40, 50}
t[{4 to 0}] -- Returns {50, 40, 30, 20} (reverse exclusive)
t[{4 into 0}] -- Returns {50, 40, 30, 20, 10} (reverse inclusive)
t[{3 to 1}] -- Returns {40, 30} (reverse partial)Step Support:
Stepped ranges can use either the range literal by clause or the range() constructor. The explicit step is used
exactly as written, so reverse stepped ranges should use a negative step.
t = {10, 20, 30, 40, 50, 60, 70}
r = {0 into 6 by 2} -- Every 2nd element
t[r] -- Returns {10, 30, 50, 70}
r = {6 into 0 by -2} -- Reverse with step
t[r] -- Returns {70, 50, 30, 10}
r = range(0, 7, true, 2) -- Constructor form remains availableOut-of-Bounds and Empty Ranges:
start equals stop return an empty tablet = {10, 20, 30}
t[{0 to 10}] -- Returns {10, 20, 30} (end clipped)
t[{-10 to 3}] -- Returns {10, 20, 30} (start clipped to 0)
t[{2 to 2}] -- Returns {} (empty exclusive range)
t[{5 to 10}] -- Returns {} (beyond table length)Metatable Interaction:
Tables with custom __index metamethods will use the custom handler instead of the base table slicing. Tables with
other metamethods (like __len, __tostring, etc.) but no __index will still support range slicing through the
base metatable fallback.
t = {10, 20, 30}
setmetatable(t, { __len = function() return 100 end })
t[{0 to 3}] -- Returns {10, 20, 30} (slicing still works)Tiri extends the in operator to work with collection targets outside of for loops. It dispatches to the target's
__contains metamethod and always returns a boolean. Native arrays, ranges, strings and structures provide built-in
handlers.
Membership has comparison precedence: use not (Value in Target) when negating it. Only the target is consulted.
A table handler receives Value as its sole visible argument and accesses the selected table through &&. A table
without __contains performs a raw key-presence check, so a key with the value false is present, a nil value is
absent, and __index is never called. A non-table target without __contains raises an operator/type error.
For a native struct, membership tests whether the candidate string names a field in the structure definition. It
does not read the field value, so a field remains present when its stored value is zero, empty or nil. A non-string
candidate returns false; pointer and object fields count as present even when their current value is nil.
local set_mt = {
__contains = function(Value):bool
return &items[Value] != nil
end
}
local colours = setmetatable({ items = { red=true } }, set_mt)
assert('red' in colours)
assert('ready' in { ready=false })r = {0 to 10} -- Exclusive: 0–9
ri = {0 into 10} -- Inclusive: 0–10
assert(5 in r)
assert(not (10 in r)) -- 10 excluded
assert(10 in ri)
assert(not (11 in ri))in can be used anywhere a boolean expression is expected, including conditionals:
if 5 in {0 to 10} then
print("in range")
end
if not (11 in {0 to 10}) then
print("out of range")
endMembership tests work with both literal ranges and ranges stored in variables, including their step handling and inclusive/exclusive behaviour.
The choose ... from syntax provides pattern matching for selecting a result value (or executing a branch) without
verbose if/elseif chains.
Basic form:
status_text = choose status from
200 -> 'OK'
404 -> 'Not Found'
else -> 'Unknown'
endSemantics:
status) is evaluated exactly once.else is optional. If omitted and no case matches, the result is nil (expression context) and no action is taken
(statement context).else must be the final case. An else-only choose is valid and always matches.Patterns:
nil match using is semantics (no implicit type coercion)._ matches anything and is typically used as a catch-all.< Expr, <= Expr, > Expr, >= Expr use normal relational operators.<Type> applies a positive Type Tests descriptor to the complete single-value
scrutinee. It accepts the same unconstrained and constrained descriptors, such as <num>, <table>,
<array int>, <struct Point> and <obj Widget>.{} matches only a table with no live associative or array entries. It does not match
native arrays, nil, or populated tables.{ key = value, ... } patterns match tables using open-record semantics:
is(p0, p1, ...) match tuples created from multiple scrutinee values:
choose (a, b) from ... end evaluates a and b once and matches by arity(x) is a parenthesised expression; a tuple requires a commaA type pattern is recognised only when the complete descriptor's closing > is followed by when or ->. This
keeps it distinct from a relational arm: <table> -> is a type pattern, while < limit -> is a less-than pattern.
Type descriptors apply to the whole single-value scrutinee; tuple-contained descriptors such as (<str>, <num>) are
not supported.
Guards:
Cases may include a when clause to add a conditional guard:
icon = choose notification from
{ type = 'message', unread = true } -> 'icon-inbox-unread'
{ type = 'message' } when notification.priority > 5 -> 'icon-priority'
else -> 'icon-default'
endThe guard is evaluated only after the pattern has matched. Guard failure proceeds to the next case.
Type-dispatch example:
description = choose value from
<table> -> 'table'
<array int> when #value > 0 -> 'non-empty integer array'
<num> -> 'number'
else -> 'other'
endDesugaring (conceptual):
choose ... from lowers to an if/elseif chain using a temporary to ensure the scrutinee is evaluated once. In
complex expression positions, the compiler may wrap the choose in a small function to yield a value.
Examples:
Tuple scrutinee with wildcard patterns:
movement = choose (dx, dy) from
(0, 0) -> 'standing'
(0, _) -> 'vertical'
(_, 0) -> 'horizontal'
else -> 'diagonal'
endWildcard as a catch-all (including NaN):
label = choose value from
nil -> 'unset'
_ -> 'set'
endNesting choose expressions:
msg = choose status from
200 -> 'OK'
else -> choose retry_count from
0 -> 'Failed (no retry)'
else -> 'Failed (will retry)'
end
endConditionals with when guards, against a table with pattern filtering:
notification = { type = 'message', unread = true, priority = 7 }
icon = choose notification from
{ type = 'message' } when notification.unread -> 'icon-inbox-unread'
{ type = 'message' } -> (notification.priority > 5 ? 'icon-priority' : 'icon-inbox')
else -> 'icon-default'
endFree-standing choose statements for flexible assignments and control flow:
choose state from
'save' -> result = 'saved'
'load' -> result = 'loaded'
else -> raise('Invalid state')
endGotchas:
< 30 before < 60).NaN never matches literal numeric patterns (nan is nan is false). Use _ or a guard if you need to handle it.choose expressions.The include statement loads the definitions for an API's functions, classes, constants and structures. It accepts
one or more string-literal API names and loads each in sequence, for example:
include 'core','xml','display'
Parenthesised calls such as include('xml') are not supported. Module names must be string literals; dynamic include
names are not permitted. API interfaces are protected from being loaded more than once. A module declaration also
loads the API definitions required for its function signatures.
module core as mSys
module display as mGfxThe module <name> as <namespace> declaration records a static Kōtuku module dependency and gives its exported
functions a compiler-managed namespace. Both names are identifiers, not expressions or string literals. Module names
are matched case-insensitively, while namespace and function names are case-sensitive. Exported function names must use
their canonical spelling, such as mGfx.DrawPixel; mGfx.drawPixel is invalid. Names beginning with m followed by an
uppercase letter are recommended for namespaces.
A module declaration is permitted only at compilation-unit level. It may appear in a selected compile-time @if
branch, and its namespace is visible from the declaration to the end of that source unit. An imported file has its own
namespace scope, so its declarations do not leak into the importing file or sibling imports.
The namespace is compiler metadata rather than a Tiri value. It is not added to _G, cannot be assigned or passed as
an argument, and supports only named function selection:
module core as mCore
local started = mCore.PreciseTime() -- Direct call
local clock <const> = mCore.PreciseTime
local later = clock() -- Extracted callableDynamic indexing, member replacement, safe navigation and bare namespace reads are rejected during parsing. Repeating the same module/namespace pair has no effect. A second namespace may refer to the same dependency, but a namespace cannot be rebound to another module.
mSys is an implicit namespace for the core module, so every compilation unit behaves as though it begins with
module core as mSys. The dependency is created on first use; a unit that never mentions mSys neither resolves
core nor emits an activation statement. Declaring module core as mSys explicitly is permitted and redundant, but
binding mSys to any other module is rejected. mSys is subject to every namespace restriction above: it is absent
from _G, cannot be assigned, indexed, passed or safe-navigated, and cannot be declared as a variable, parameter or
function.
Declaration loads and initialises the module during compilation so the parser can validate function names and result
metadata. A required module that cannot be loaded is a compilation error. Calls and extracted callables use the
shared module bridge for argument conversion, result conversion and error propagation. mod.test(ModuleName, Options)
resolves the named module, loading it into the process-wide store when necessary, runs its integrated unit-test suite,
and returns the number of passed and total tests. Options is optional.
The underlying Kōtuku module is loaded once into a process-wide C++ store and remains resident until the Tiri module is expunged. A declaration creates no module object or callable table; when it executes, it materialises one lightweight closure per function its compilation unit references. Re-executing a loaded chunk creates new closures backed by the same process-wide callable records, and collecting a namespace callable does not unload the module.
Guard optional dependencies with the modules: form of the compile-time exists condition:
@if(exists='modules:audio')
module audio as mAudio
local play <const> = mAudio.PlaySample
@endA successful module availability check uses the normal loader, initialises the module and leaves it resident for the
process. The result is cached for the current compilation. Missing, incompatible or unsuccessfully initialised
modules evaluate to false; module probing is therefore not side-effect-free.
import 'name'
import 'name', 'other/name'
import 'name' as namespaceThe import statement loads and inlines a Tiri script file at parse time. Inlining means that the requested script is loaded directly into the call site during compilation, which in turn improves the parser's efficacy at producing optimised code. Scripts loaded via import are often referred to as 'libraries' and are typically designed to be shared. In that spirit, if a library is imported more than once, all subsequent imports do not result in reloading.
Existing libraries are available in the scripts: volume, which is the default search location for the import process. For instance, import 'gui' would load the script at scripts:gui.tiri. In order to ensure that libraries cannot be imported multiple times, import's naming conventions are strict. Do not include the file extension, nor prefix the file with a full path. Only alpha-numeric characters are allowed for naming.
Multiple libraries can be imported with a comma-separated list. List entries must be string literals; dynamic import names are not permitted. The as namespace alias form is only valid for single-item imports, so import 'gui' as myGui is valid but import 'gui' as myGui, 'options' is rejected.
Namespaces:
Libraries will typically declare a default namespace that matches the library name. For instance, the gui library declares a gui namespace. To use a custom namespace, add the as namespace clause. For example:
import 'gui' as myGuiApplication Specific Libraries:
Complex applications can benefit from using import to create custom libraries and split the code across multiple files. The import statement will search the local folder if the reference is prefixed with ./. Standard path rules still apply, e.g. ./lib/customlib would be valid, ./../../h4cklib!3.tiri is not.
Behavioural Notes:
import statement can only appear in the outside scope of a script, not inside functions. Attempting to use import inside a function results in a parse error.global keyword in the imported file become accessible to the importing script.Example - Exporting Globals:
-- helpers.tiri
global function formatDate(Timestamp)
return os.date('%Y-%m-%d', Timestamp)
end
global HELPER_VERSION = '1.0'
-- Private to helpers.tiri
local internal_cache = {}-- main.tiri
import './helpers'
print(formatDate(os.time())) -- Uses the imported global function
print(HELPER_VERSION) -- Accesses the imported global variable
-- internal_cache is not accessible hereConditional Pre-Processing:
The import statement works seamlessly with compile-time conditionals. Imported files can use @if(imported=true) to include code only when being imported, or @if(imported=false) for code that runs only when executed directly:
-- library.tiri
global function doWork()
-- Always available
end
@if(imported=false)
-- Only runs when library.tiri is executed directly
print('Running library.tiri as main script')
doWork()
@end
@if(imported=true)
-- Only runs when imported
print('library.tiri loaded as import')
@endLibrary Namespaces:
A library can create a namespace value and publish it to importers with one declaration. The name is an identifier, not a quoted string, and the optional initialiser must be a table, function or thunk literal on the same line:
namespace gui {
theme = 'light',
dpi = 160
}
gui.parseRGB = function(Hex)
...
endThe declaration evaluates the initialiser once and exposes the resulting value through a readonly local named gui.
The imported namespace and the library's local refer to the same value. Assigning a different value to gui is an
error, but fields of a table namespace remain mutable.
An imported component can join an existing namespace without replacing it:
import 'gui'
namespace gui
gui.button = function(Options)
...
endThe join fails immediately if no registry entry exists. Importing the root library first, as above, ensures the namespace has been created. Multiple component files can join and extend the same namespace.
Namespace values may also be callable. For example, this declaration exports a function-valued options library:
namespace options function(Config:table!):table
return createParser(Config)
endimport 'options' as parserFactory publishes the same registry value as a readonly parserFactory local. The alias
changes only the importing file's local name; it does not rename the library namespace. _LIB remains runtime
implementation storage and is not part of the library authoring pattern. A namespace declaration does not define
_NS.
Use loadFile() to load, parse and execute a Tiri script at runtime. This feature is useful for re-using code, or breaking a large project into multiple script files. Example:
loadFile('programs:tools/project/example.tiri')
If the path is not fully qualified, the current path of the process will be used to determine the location of the source file.
If the executed script ends with a return statement, the value(s) will be returned from loadFile() in their original form.
Note: Code loaded via loadFile() will lose the efficiency gains afforded to scripts loaded via import.
Use exec() to parse a Tiri statement and execute it. exec() will raise an exception if the statement is unparseable or fails during execution. Example:
exec([[
print("Hello World")
]])
The module declaration gives Tiri programs a compiler-managed namespace for a Kōtuku API:
module display as mGfx
local err, x, y = mGfx.getCursorPos()Tiri returns multiple results when a function declares them. The first result is typically an ERR code. Module
namespaces are not global values, cannot be reassigned or passed as arguments, and support only named function access.
The following names are conventional for commonly used APIs:
| Module | Namespace |
|---|---|
| audio | mAudio |
| core | mSys |
| display | mGfx |
| font | mFont |
| network | mNet |
| vector | mVec |
| xml | mXML |
mSys is available in every compilation unit without a declaration because the compiler manages it as an implicit
namespace for core. The remaining namespaces require an explicit module declaration.
When calling an API function, Tiri uses best efforts to convert function values to the types declared in the function definition. For instance, if a string value is used for a numeric argument, Tiri will automatically convert the string to an integer. If the type cannot be converted (for instance, an abstract pointer cannot be converted to a number) then a type mismatch occurs and an exception will be raised.
While type conversion is convenient, we recommend using the correct type whenever possible as this will ensure that your calls are being made efficiently.
When calling functions that copy results to a user-supplied buffer, pass an array interface. The following example illustrates reading content from a file into an array buffer:
buffer = array<byte, file.size>
err, result = file.acRead(buffer)
print(tostring(buffer))Notice that the acRead() call hasn't been given its second parameter (the amount of data to read). This is possible because Tiri knows the size of the buffer and will use it for the second parameter if not already defined.
Some functions may return multiple result values. Here is an example of a module function that returns two results:
ERR ListMemory(INT Flags, ListMemory **Memory)
The first result is an ERR code. The ListMemory result is stored in the variable pointed to by the Memory argument. In a C program, this function would be used as follows:
if (!ListMemory(0, &memory)) { ... }
In Tiri, pointer results are dropped from function specifications because writing to pointer buffers is risky. Instead, these parameters are handled internally and their values are returned as part of the result set. The following example shows how to call ListMemory() in Tiri:
err, list = mSys.ListMemory(0)
Some functions like the above can return allocated memory or some other form of temporary resource. These are normally marked as such in the function definition, which allows the garbage collector to automatically remove it once its references have been reduced to zero. Remember to use local wherever possible to clean up resources that have gone out of scope.
The object interface provides the necessary functionality to create and manage objects. It allows you to read and manipulate object fields, call methods and actions, manage subscriptions and connect to existing named objects.
For a statically named root class with all initial fields available, use the concise compiler-owned constructor form:
file = obj<File> { path='readme.md', flags='!READ' }It has the same runtime behaviour as obj.new('File', { path='readme.md', flags='!READ' }): fields are assigned,
then Init() is called immediately, and the root object is owned by the script. Braces are mandatory, including for
an empty initialiser such as obj<Time> {}; this ensures immediate initialisation rather than the staged behaviour
of obj.new('Time'). obj< must be adjacent, so a variable comparison remains available as obj < class_id > value.
This syntax is compiler-owned and does not look up the public obj namespace or obj.new member. Rebinding either
therefore does not affect obj<Class> { ... }. obj<Class> without the table is a type constraint in declaration
and annotation contexts, not an object constructor.
Use the public obj.new() constructor for dynamic class expressions, numeric class IDs, staged field assignment, or
when deliberately relying on a rebindable namespace call. Here's an example that creates an object, sets necessary
field values and initialises it in stages:
file = obj.new('file')
file.path = 'readme.md'
file.flags = '!READ'
file.init()The first result from new() is the created object's interface and the second result is an ERR code if creation fails. Take note that an object must always be initialised before any real interaction occurs (anything beyond setting field values).
The above example can be simplified by defining key-values immediately after the class name:
file = obj.new('File', { path='readme.md', flags='!READ' } )
In this case the path and flags values will be set on the new object and then initialised automatically (it is assumed all field settings are being defined up-front). The field values may be of any type and Tiri will use best efforts to convert them to the correct field type.
In the event of an error, new() will throw an exception. It never returns nil. We advise guarding your calls with try, particularly if using classes like File where error management is good practice.
Initialises the object if not already done so with obj.new(). Useful if you need to set field values after creation but before initialisation. It returns no result and throws an exception if initialisation fails.
Kōtuku enforces OO relationships so that an object will always have a parent, and consequently it may have siblings and its own children. By default, all objects created by obj.new() are owned by the Tiri script (which exists as a runtime object).
Sometimes a new object will need to be declared as the child of an existing object. Imagine for instance, that we have a window open and we want to draw a rectangle to it. This can be achieved with something like:
viewport = glWindow.clientViewport({ aspectRatio = ARF_MEET })
viewport.new('VectorRectangle', { x=0, y=0, width='100%', height='100%', fill='rgb(255,0,0)' })viewport.new() is an Object method that creates a child of viewport, whereas obj.new() creates a root object
owned by the script. When the rectangle is initialised, it will determine that it is owned by the viewport and will
appear in that context when displayed. Direct dot calls use the Object method; extracting viewport.new or using a
computed member such as viewport['new'] is ordinary field access and does not produce a bound function.
It is the case that all objects support the new() method for the purpose of supporting these complex hierarchies.
Be aware that object relationships have absolute priority over other factors in determining their lifetime. In the above example, if the viewport is destroyed (e.g. the user closes the window) then the rectangle will be destroyed with it because its state is linked to the parent. Objects created as children are weakly referenced.
Obtain a list of the children that have been attached to an object by calling the children() method. It returns an array of UID's that can be converted to interfaces with obj.find(). The children() method also supports a class filter in the first parameter, e.g. thing.children('VectorRectangle') would return a list of all rectangles.
Access the interface of an existing object by searching for its name or UID:
window = obj.find('my_window')
winsurface = obj.find(window.surface)If the object is not found, nil is returned. If multiple objects exist with the same name, the most recently created object is returned first.
The with statement locks one or more Kōtuku objects for the duration of a block, automatically unlocking them when the scope exits through any path (normal exit, break, continue, return, or exception unwinding).
with object do
-- object is locked here
end
-- object is automatically unlockedVariables declared inside the with block are not visible outside it, following the same rules as do ... end blocks.
Passing a non-object value (such as a table, string, or number) to with will raise a runtime error. Use with exclusively with Kōtuku objects.
Multiple objects:
with obj1, obj2, obj3 do
-- all three objects are locked
end
-- all three are unlocked in LIFO order (obj3 first, then obj2, then obj1)Why lock objects?
Locking serves two purposes:
Performance example:
xml = obj.new('xml', { source = myfile })
with xml do
for i = 0, 100000 do
val = xml.source -- Immediate access granted to the source field
end
endIf an object is loosely coupled to a script (a weak reference), the exists() method can be called at any time to confirm it is not destroyed. The result is a boolean value.
It is not uncommon that a script may need to create an object that outlives its scope. Calling detach() will unlink the object from the script and the garbage collector, demoting it to a weak reference. Once detached, the object can only be removed if its parent object is destroyed, or the free() method is called.
Note: If an object has been created as the child of another, e.g. viewport.new('VectorRectangle', { ... }) then the object is already weakly referenced and detach() will do nothing.
Call free() to terminate an object resource immediately without removing the interface. If the object is weakly referenced then free() is the most practical means of removal.
Calling free() is discouraged in general usage - a safer pattern is to set object references to nil and either wait
for garbage collection or call processing.collect().
The garbage collector will automatically terminate an object once all references to that object are released or the script is terminated. If an object has a single reference, you can manually terminate it by setting the reference to nil, for example:
file = obj.new('File')
...
file = nilAfter successfully initialising an object interface, interaction with it is possible through actions, methods and fields. The following example illustrates how we could change the size of the VectorRectangle we created earlier:
rect.acRedimension(20, 20, 0, 100, 100, 0)Action calls are identified by their ac prefix. Method calls are prefixed by mt. Field references can be directly accessed without a prefix. The naming scheme for methods, actions and fields is case insensitive.
For information about the actions and methods supported by an object class, refer to its published API documentation on our website.
Object fields are accessible by conventional means, i.e. value = my_object.field_name and my_object.field_name = value for get and set respectively. There are get() and set() methods that are equivalent to these, which allow strings to be used for dynamic field access. Some classes also support key-values via getKey() and setKey() methods, which can be used for accessing dynamic field names.
Some classes support key-value pairs in cases where custom string names need to be associated with an object. The getKey() and setKey() methods are provided to manage these fields. For example:
surface.setKey('Title', 'My Window')
title = surface.getKey('Title', 'UNDEFINED')Some classes treat keys as parseable strings that are backed by built-in functionality. For instance, the following statement retrieves the content from slot 10 of an item array:
item = view.getKey('item[10]')
The state() method is a canonical Object method that attaches a per-script table to the object and returns it. This
allows you to store custom data with the object that is separate from its definition. Repeated calls for the same
object return the same table; the table is removed when the object is destroyed. As with new, direct dot calls are
methods, while extracted or computed member reads are ordinary fields and do not return a bound function. A native state field can coexist with this method: object.state() returns the attached table, while object.state and object['state'] read the native field. Assignments to the field do not change the attached table.
Kōtuku allows clients to monitor the calls made to an object by subscribing to its actions and methods. One commonly used strategy involves subscribing to the free() action of an object so that the client is notified of object termination.
Subscription is enabled via the subscribe() method. The following example illustrates the creation of an HTTP object that performs a download and calls my_function() on completion of the download process. Note that Args is a table that contains named arguments for the action that was subscribed to. The CustomRef is an optional value that will be passed to the subscriber.
function my_function(UID, Args, CustomRef)
print('Download complete.')
end
http = obj.new('http', { ... } )
http.acActivate()
handle = http.subscribe('deactivate', my_function, customref)To unsubscribe from an action, call unsubscribe() with the action/method name. Doing so will allow the garbage collector to remove associated resources that are no longer required.
The processing interface assists with the management of your program's idle state and reaction to signals. Its most basic feature is the commonly used sleep() function, as follows:
processing.sleep([Timeout], [WakeOnSignal=true])
Calling sleep() in this way will put the script to sleep until a message is received to awaken it. Typically this means that the function won't return until a QUIT message is received, which would be delivered if the user opts to close the main window.
sleep() can also return early if a Timeout in seconds is specified in the first parameter. Waking on signal can be disabled by setting the second parameter to false.
If a Tiri script is utilising threads, synchronisation can be an important issue that requires careful management. The processing interface makes it possible to put the main thread to sleep and wake it once a series of signals has been triggered. In the following working example, we create a basic thread that prints a message and then signals two XML objects in order to wake the main thread. This might be plausible if we were asking the thread to process XML data for instance:
function testSignals()
signal_a = obj.new('xml', { flags='NEW' })
signal_b = obj.new('xml', { flags='NEW' })
-- Note that the thread code is parsed as a string and can't see any variables without a call to obj.find().
-- The termination handler on the other hand has access to signal the objects directly.
async.script([[
msg('Thread is now in session.')
]],
function()
msg('Thread has been executed.')
signal_a.acSignal()
signal_b.acSignal()
end)
proc = processing.new({ timeout=1.0, signals = { signal_a, signal_b } })
msg('Sleeping....')
err = proc.sleep()
assert(err is ERR_Okay, "Unexpected error: " .. mSys.GetErrorMsg(err))
endNotice that we called processing.new() to create a dedicated interface for signal management. The two objects that will be signalled are passed in via the signals field. When the thread completes, it calls acSignal() on the objects to change their signal state. It is necessary for both objects to change state in order to wake the main thread.
If no signal objects are listed in a call to processing.new(), it is possible to manually call the signal() method to wake the sleeping thread instead. This simple technique is sometimes used in passive event systems, e.g. the user clicking a mouse button could result in a signal() call that wakes the main thread.
The flush() method will clear pending signals associated with the script and processing object.
Using flush() is rarely necessary expect in special circumstances. For instance if a prior call to sleep() resulted in a timeout, clearing pending signals guarantees a fresh start.
The task() function returns a Tiri object that references the current task:
currentTask = processing.task()
This function provides access to the task object representing your script's execution context. The returned object can be used to modify task properties such as process priority, which is useful for applications requiring consistent performance:
task = processing.task()
if task then
task.set('Priority', 15) -- Set high priority for time-critical operations
endUse delayedCall() to call a function on the next message processing cycle inside processing.sleep(). This is useful in situations where calling a function is necessary, but to do so immediately would cause logistical issues with the order of execution.
processing.delayedCall(function
print('This message is appearing after being delayed.')
end)processing.collect([Mode], [Options])
Controls the garbage collector. The optional Mode parameter specifies the collection mode:
| Mode | Description |
|---|---|
full |
Performs a full garbage collection cycle (default). |
step |
Performs an incremental collection step. |
The optional Options table supports the following fields:
| Field | Type | Description |
|---|---|---|
stepSize |
integer | Step size for incremental collection (only used with "step" mode). |
Returns an integer result from the underlying GC operation. The meaning varies by mode:
"step" mode: returns 1 if collection is not finished, 0 if finished0 on successExamples:
-- Full collection (default)
processing.collect()
processing.collect("full")
-- Incremental step collection
result = processing.collect("step", { stepSize = 100 })Starts the automatic garbage collector if it is not already running. The garbage collector runs in the background and will periodically reclaim memory used by unreferenced objects.
Stops the automatic garbage collector. When called, it will cease to reclaim memory until startCollector() is called again.
stats = processing.gcStats()
Returns a table containing garbage collector statistics. This is useful for monitoring memory usage and GC behaviour.
| Field | Type | Description |
|---|---|---|
memoryKB |
integer | Memory usage in kilobytes. |
memoryBytes |
integer | Remainder bytes (total bytes = memoryKB × 1024 + memoryBytes). |
memoryMB |
number | Total memory usage in megabytes (convenience field). |
isRunning |
boolean | true if the garbage collector is currently running. |
pause |
integer | Current pause multiplier (controls GC frequency, default 200). |
stepMul |
integer | Current step multiplier (controls GC speed, default 200). |
Example:
stats = processing.gcStats()
print(f"Memory: {stats.memoryMB} MB")
print(f"GC Running: {stats.isRunning}")
print(f"Pause: {stats.pause}, StepMul: {stats.stepMul}")
-- Monitor memory before and after allocation
before = processing.gcStats().memoryMB
t = {}
for i in {0 to 10000} do t[i] = string.rep("x", 100) end
after = processing.gcStats().memoryMB
print(f"Allocated: {after - before} MB")
-- Clean up and verify
t = nil
processing.collect()
final = processing.gcStats().memoryMB
print(f"After collection: {final} MB")Exponentiation uses the right-associative ** operator. The redundant math.pow() interface is not provided. Code
that needs exponentiation as a first-class function can wrap the operator in a function or closure.
result = math.ldexp(Value, Exponent)
Multiply Value by 2 raised to Exponent. Exponent must be a finite integer; fractional values, NaN and infinity
raise an argument error. Overflow and underflow follow IEEE-754 behaviour, so sufficiently large positive and
negative exponents produce infinity and zero respectively.
fraction = math.random()
integer = math.random(Stop)
integer = math.random(Min, Max)With no arguments, return a floating-point value in [0, 1). math.random(Stop) requires a finite integer Stop
greater than zero and returns an integer in [0, Stop). math.random(Min, Max) requires finite integer bounds with
Min <= Max and returns an integer in the inclusive range [Min, Max]. Equal explicit bounds return that bound.
Invalid calls raise an argument error without advancing the pseudo-random number generator. math.randomSeed(Seed)
restarts the generator, allowing a sequence to be reproduced on a supported platform. This generator is intended
for simulation and general-purpose randomisation, not cryptography.
result = math.clamp(Value, Lower, Upper)
Constrain Value to the inclusive interval [Lower, Upper]. The bounds must be ordered and must not be NaN;
Lower > Upper and NaN bounds raise an argument error. Infinite bounds are permitted. A NaN Value is propagated.
All-integer arguments produce an integer result; a range containing floating-point arguments produces a
floating-point result.
result = math.round(Value)
Round Value to the nearest integer-valued number, with halfway cases rounded away from zero. NaN and positive or
negative infinity are preserved. An exact negative-zero result retains its sign. The function accepts exactly one
argument; use string formatting for decimal presentation.
minimum = math.min(Value, ...)
maximum = math.max(Value, ...)Return the least or greatest of one or more numeric arguments. A NaN in any argument is propagated. When equal
positive and negative zeros are present, math.min() returns negative zero and math.max() returns positive zero,
independently of argument order. All-integer arguments produce an integer result; otherwise the result is a
floating-point number.
The Lua-based strings interface is extended with a number of useful functions that are commonly required in programming. The following functions are included:
str = string.alloc(Size)
Return a distinct mutable byte buffer of exactly Size bytes. Every byte is initially NUL (0), including for
sizes that are not word-aligned. A zero size returns a distinct empty buffer. Negative sizes raise an argument
error.
Ordinary Tiri strings are immutable and interned, so equal text normally refers to the same string object.
string.alloc() buffers are mutable and are not interned. Use them when a Kōtuku API declares a mutable string or
byte-span argument and writes data into caller-provided storage, such as File.acRead(). The buffer length remains
Size; when an API reports the number of bytes written, use that result to select the valid portion. For example,
buffer.sub(0, bytes_written) creates an ordinary immutable string containing the written bytes.
Mutable buffers are permitted as table keys, but use object identity rather than byte content. The same buffer
continues to retrieve its entry after its contents change, while another buffer or ordinary string with identical
bytes is a different key. Table hashing uses the buffer's immutable allocation identity, so mutation does not
invalidate table lookup or JIT assumptions. In contrast, string.hash() and ordered string comparisons read the
current bytes. Convert a buffer to an ordinary string before using its content as a key.
str.cap()
Recreate a string with the first character in upper case.
count = str.count(Keyword)
Count the number of non-overlapping occurrences of Keyword in the string. Returns 0 if either string is empty or if Keyword is not found.
"hello world hello".count("hello") -- 2
"aaa".count("aa") -- 1 (non-overlapping)str.decap()
Recreate a string with the first character in lower case.
str.escXML()
Escape str for an XML attribute value or content.
hash = str.hash([CaseSensitive])
Return a hash value for the string. The CaseSensitive parameter is optional and defaults to false.
byte, ... = str.byte([Start], [Stop])
Returns the byte values in [Start, Stop). With no Stop, returns the single byte at Start; with no Start, this
is the first byte. An explicit Stop may equal the string length.
start, stop = str.find(Needle, [Start])
Finds the first literal Needle at or after Start. A successful result is the half-open span [start, stop), so
str.sub(start, stop) returns the matched bytes without endpoint adjustment. Returns nil, nil when no match is
found.
str.join(Table, Separator)
Join the contents of Table into a single string, using Separator between each item. If Separator is not specified, the items are joined consecutively.
str.pop([Count])
Returns the string with Count characters removed from the end. If Count is not specified, it defaults to 1. If Count is greater than or equal to the string length, an empty string is returned. If Count is zero or negative, the original string is returned unchanged.
"hello".pop() -- "hell"
"hello".pop(2) -- "hel"
"hello".pop(10) -- ""
"hello".pop(0) -- "hello"result, count = str.replace(Keyword, Replacement, [Limit])
Replace Keyword with Replacement, without pattern matching. Limit will restrict the total number of replacements if specified. Returns the modified string and the count of replacements made.
-- Replace all occurrences
result, count = "hello world".replace("o", "0") -- result = "hell0 w0rld", count = 2
-- Replace only the first occurrence
result, count = "aaa".replace("a", "b", 1) -- result = "baa", count = 1bytes = string.toArray(Text) or bytes = Text.toArray()
Returns a new, independently owned array<byte> containing the string's raw bytes. The array length equals the string's
byte length, including embedded NUL bytes. No terminator is appended and no character decoding is performed. An empty
string produces an empty byte array. Changing the array does not change the source string.
bytes = "A\0B".toArray() -- Three bytes: 65, 0, 66str.trim()
Trims whitespace from the left and right sides of a string.
str.rtrim()
Trims whitespace from the right side of a string.
str.split([Separator])
Split a string into an array of substrings. If Separator is a single character, each occurrence splits the string. If Separator is a multi-character string, it is matched as a whole delimiter.
If Separator is not specified, the string is split on any individual whitespace character (space, tab, newline, carriage return).
str.startsWith(Cmp)
Returns true if the string starts with Cmp.
str.sub(Start, [Stop])
Extract a substring from Start up to but not including Stop. If Stop is not specified, the substring extends to
the end of the string. Negative indices count from the end and retain their exclusive-stop semantics. string.substr()
is a deprecated behavioural alias of this canonical interface.
"hello world".sub(0, 5) -- "hello"
"hello world".sub(6) -- "world"
"hello world".sub(-5) -- "world"str.endsWith(Cmp)
Returns true if the string ends with Cmp.
str.unescapeXML()
Unescape XML entities (<, >, &, ", ') in str, returning the decoded string. This is the reverse of escXML().
"<div>Hello & World</div>".unescapeXML()
-- Returns: "<div>Hello & World</div>"Tiri supports a simplified threading model so as to minimise the potential problems occurring from their use. The functionality is as follows:
The script() method compiles a statement string and executes it in a separate script state. The code may not directly share variables with its creator, but it can find existing objects and interact with them by calling obj.find().
The Callback parameter is a function that will be executed once the threaded script has returned. It executes in the space of the caller and not the thread itself, which means it can access local variables safely.
async.script([[
print('Thread is now in session.')
]],
function()
print('Thread has finished executing.')
end)
The async.action() and async.method() interfaces provide a convenient means of executing an object function in a separate thread. There is some overhead in executing a new thread, but this strategy becomes highly effective when used to perform lengthy processes such as the loading and parsing of data.
Here's an example that reads the first 1Kb of data from a file and prints the content:
function on_complete(Action, File, Error, Buffer)
print('Read string: ' .. Buffer)
File.free()
end
str_buffer = string.rep(1024)
async.action(file, AC_Read, on_complete, str_buffer, str_buffer)
processing.sleep()
The Action parameter is accepted as an action ID (fast) or a string (slower). The Key makes it possible to pass a custom parameter to the callback, and can be used to pass multiple parameters if a table is used.
The Callback routine receives notification of the thread's completion, and in this example uses this as an opportunity to free the file object. This is a recommended pattern, and ensures that the target object is not destroyed while the thread is executing. Never declare the object value as local.
Internally, callbacks are executed on the next message processing cycle and this is why a call to processing.sleep() is used in this example.
The Error parameter in the callback reflects the ERR code returned by the action - but bear in mind the possibility that if thread preparation fails, the callback will never be executed and an exception will be thrown instead.
The structure interface is provided so that Tiri can use C/C++ structures declared in the Kōtuku API. You will find that many class and module API's use structures for returning condensed information.
Structures are created using the struct interface's new() method. The prototype is as follows:
newstruct = struct.new(struct_def, [fields])
The struct_def is the name of a structure definition declared in the Kōtuku API or registered with MAKESTRUCT().
The optional fields table initialises supported primitive and object fields. Example:
point = struct.new('Point', { x=10, y=20 })
struct.new() returns the native Tiri struct type. Structures returned by Kōtuku APIs can reference external
memory. Embedded and pointer sub-structures are non-owning views and must not outlive their parent structure or the
API resource that owns their memory.
Structure-valued object fields are also exposed as live struct views rather than copied tables. Assigning a field on
the view immediately updates the object, while assigning a compatible struct to the object field copies its values.
These views do not support pairs() and must not outlive the object that owns the field memory.
The struct statement declares a named native layout at top-level scope. Fields may use scalar native types, owned
strings, fixed inline arrays, embedded structures, typed pointers, object references and dynamically sized arrays.
struct Point
X: int
Y: int
end
struct Sample
Name: str
Scores: array<int>
Labels: array<str>
Points: array<struct<Point>>
endDeclarations use an end-terminated block like other Tiri statements, while structure values retain { } because
they are constructor expressions.
Instances of a declared structure are created with the explicit struct<Name> construction expression. Its
initialiser table is optional. Omitting it or providing an empty table zero-initialises every field:
local point = struct<Point> { X=10, Y=20 }
local blank = struct<Point> { }
local implicit_blank = struct<Point>A declaration only registers the layout - it does not bind a variable named after the structure, so Point { X=10 }
and Point() are not valid construction forms. struct.new('Point', { X=10 }) remains available when the
definition name is only known at runtime.
Scalar fields validate their exact Tiri value category before changing native storage. A bool field accepts only a
boolean, integer and floating-point fields accept only a number, and an owned str accepts only a string. Numeric
strings and numbers assigned to strings are not converted implicitly; call an explicit conversion helper first when
that conversion is intended. nil does not clear primitive fields.
Integer fields accept finite numbers. They truncate fractional components toward zero and wrap the result modulo the
destination width; signed fields interpret the wrapped bits using two's-complement semantics. Clients are responsible
for enforcing application-specific numeric ranges. NaN and infinity raise an error without changing the previous
integer field value. A float accepts normal native precision loss and underflow, plus IEEE NaN and infinity, but
rejects finite values beyond the finite float range. A double accepts any Tiri number, including NaN and infinity.
Scalar char is an unsigned 8-bit numeric field with the range 0 through 255. It has the same unsigned 8-bit
representation as byte, array<char> and array<byte>. Use int8 for signed 8-bit values. char is distinct
from char[N], which is a fixed bounded character buffer and accepts an exact string value. The other integer ranges
follow their named native widths for truncation and wrapping. Since Tiri numbers cannot represent every 64-bit integer
exactly, conversion uses the numeric value supplied by the program without inventing missing precision.
An array<T> field owns a kt::vector<T> in the native structure. Supported scalar element types are bool,
char, byte, the fixed-width signed and unsigned integer types, float and double. str and
struct<Name> elements are also supported. Assigning a native array to the field resizes the vector and copies its
elements. Reading the field returns a fixed-length snapshot, so modifying that array does not update the structure;
assign the complete array back to the field to apply changes.
Struct element types must have a trivial owned layout. They may contain scalar, pointer and embedded trivial struct
fields, but cannot contain owned str or array<T> fields at any depth. This restriction permits safe byte-wise
storage while retaining the same element stride as the corresponding C++ structure. Existing MAKESTRUCT definitions
with non-trivial vector elements remain readable for native API compatibility, but Tiri rejects assignment, copying
and cloning through those unsafe legacy layouts.
The borrowed and callable types cstr, ptr, obj and func cannot be vector elements. Nested vectors and fixed
dimensions such as array<int>[4] are also rejected. Use int[4] for fixed inline storage.
The byte size of a structure can be retrieved by passing a structure reference to struct.size(), for example:
print('The size of the structure is ' .. struct.size(xmltag))
The struct.size() function also accepts a structure name (as a string) to report the size of a registered
definition, e.g. struct.size('XTag').
The
xmltag.structSize()method is deprecated in favour ofstruct.size(xmltag)and will emit a runtime warning.
To get the total number of fields in the structure, use #, e.g. #xmltag.
Use FieldName in StructValue to test whether a native structure definition declares a field. The lookup follows the
same field-name matching rules as ordinary structure access and does not read the field value:
local point = struct<Point> { }
assert('X' in point)
assert(not ('Missing' in point))This is a definition-based test, so declared fields remain present when their values are zero, empty or nil.
Non-string candidates return false, and pointer or object fields count as present even when they currently contain
nil.
The MAKESTRUCT() function is used to build structure definitions. A structure definition is a single string that defines all fields in a structure, matched in the order in which they appear in the structure. Consider the following C structure:
struct XMLTag {
int Index;
int ID;
XMLTag *Child;
XMLTag *Prev;
XMLTag *Next;
APTR Private;
XMLAttrib *Attrib;
int16_t TotalAttrib;
uint16_t Nest;
};To define this structure in Tiri we would use the following code:
MAKESTRUCT('XMLTag', 'lIndex,lID,pChild,pPrev:XMLTag,pNext:XMLTag,pPrivate,pAttrib,wTotalAttrib,uwNest')
Notice that each field name is defined using the same order and names as identified in the structure. Each name is prefixed with a lower-case character that indicates the field type. Using the correct field types is the most crucial part of the structure definition and each must be chosen from the following table:
| Character | Field Type |
|---|---|
| l | Integer (32-bit) |
| d | Double (64-bit) |
| x | Integer (64-bit) |
| f | Float (32-bit) |
| w | Integer (16-bit) |
| c | Char (8-bit) |
| p | Pointer. You can use ':StructName' as a suffix to reference other structures. |
| s | String |
| o | Object (Pointer) |
| u | Unsigned (Use in conjunction with a type) |
Fixed arrays are also permitted in the structure definition if a field name is followed with enclosed square brackets that contain the array size, e.g. [12].
The regular expression interface provides compiled regex objects for high-performance pattern matching and text manipulation. Unlike Lua's basic pattern matching, the regex interface offers full PCRE-compatible regular expression support through compiled objects that can be created once and reused multiple times.
For more information on our regex implementation, please refer to our Regex Manual.
To create a compiled regex object, use the new() method with the following prototype:
rx = regex.new(Pattern, [Flags])
The Pattern is a string containing the regular expression pattern to compile. The optional Flags parameter can be used to modify regex behaviour using the flag constants described below.
-- Simple pattern
digits = regex.new('\\d+')
-- Case insensitive pattern
email = regex.new('[\\w._%+-]+@[\\w.-]+\\.[A-Za-z]{2,}', regex.ICASE)If the provided pattern is invalid, an error will be raised. For this reason it is advisable to use try when creating regex objects that use untested patterns, e.g. from user input.
The following flags can be combined to modify regex compilation:
| Flag | Description |
|---|---|
regex.ICASE |
Case insensitive matching |
regex.MULTILINE |
^ and $ match line boundaries |
regex.DOT_ALL |
. matches newlines |
Flags can be combined: regex.ICASE | regex.MULTILINE.
The following RMATCH flags can be used with the optional Flags parameter in test(), match(), search(), replace(), and split() methods:
| Flag | Description |
|---|---|
regex.NOT_BEGIN_OF_LINE |
Do not treat the beginning of text as start of line |
regex.NOT_END_OF_LINE |
Do not treat the end of text as end of line |
regex.NOT_BEGIN_OF_WORD |
Do not treat the beginning of text as start of word |
regex.NOT_END_OF_WORD |
Do not treat the end of text as end of word |
regex.NOT_NULL |
Do not match empty sequences |
regex.CONTINUOUS |
Only match at the beginning of text |
regex.PREV_AVAILABLE |
Previous character is available for look-behind |
regex.REPLACE_NO_COPY |
Do not copy non-matching parts in replace operations |
regex.REPLACE_FIRST_ONLY |
Replace only the first occurrence |
Example usage:
wordRegex = regex.new('\\w+')
-- Replace only the first word
result = wordRegex.replace('hello world', 'goodbye', regex.REPLACE_FIRST_ONLY)
-- Result: 'goodbye world'Compiled regex objects provide the following read-only properties:
pattern: The original pattern stringflags: The compilation flags usederror: Error message if compilation failed (only available in internal error states)rx = regex.new('\\d+')
print('Pattern: ' .. rx.pattern)
print('Flags: ' .. rx.flags)Note: Invalid regex patterns will raise an error during construction. Use try to handle potential compilation errors:
local rx
try
rx = regex.new('[invalid')
except ex
print('Regex compilation failed: ' .. ex.message)
endRegex objects are compiled once and can be reused many times, making them significantly more efficient than traditional string matching for complex patterns. Store regex objects in deferred expressions to create them on demand and avoid recreating them:
global emailValidator = <{ regex.new('^[\\w._%+-]+@[\\w.-]+\\.[A-Za-z]{2,}$') }>
for email in values(emailList) do
if emailValidator.test(email) then
processEmail(email)
end
endescaped = regex.escape(Text)
Use the static regex.escape() method to escape all regex metacharacters in a string so it can be used as a literal pattern. This is essential when constructing patterns from user input or dynamic data that may contain special regex characters.
-- Escape special characters for literal matching
escaped = regex.escape('hello.world') -- 'hello\\.world'
escaped = regex.escape('a+b*c?d') -- 'a\\+b\\*c\\?d'
escaped = regex.escape('(group)[class]') -- '\\(group\\)\\[class\\]'
-- Safe pattern from user input
userInput = '[[email protected]]'
safePattern = regex.new(regex.escape(userInput))result = rx.test(Text, [RMATCH])
Use the test() method to test a pattern against a string. It returns true if the pattern matches anywhere in the text, false otherwise.
start, stop, captures = rx.findFirst(Text, [Offset], [RMATCH])
The findFirst() method offers the fastest way to locate the first match within a Text string. The optional Offset
parameter specifies where to begin searching. The returned span is [start, stop), or nil, nil if no match is found.
If captures are used, they are returned as a string array in the third result.
digits = regex.new('\\d+')
-- Find first occurrence
start, stop = digits.findFirst('abc123def456') -- start = 3, stop = 6 (matches '123')
match = 'abc123def456'.sub(start, stop) -- '123'
-- Start searching from a specific position
start, stop = digits.findFirst('abc123def456', 6) -- start = 9, stop = 12 (matches '456')This method is optimised for speed when you only need the position of a match, not the matched text or capture groups. For quickly extracting captures, regex.extract() is often preferred.
iter = rx.findAll(Text, [Offset], [RMATCH])
Use the findAll() method to iterate over all matches in a string:
for start, stop, captures in rx.findAll(Text, [Offset], [RMATCH]) do
...
endEach iteration yields:
start: The inclusive position of the matchstop: The exclusive position of the matchcaptures: An array of capture group strings (or nil if no bracketed captures were defined)-- Simple iteration without captures
digits = regex.new('[0-9]+')
for start, stop in digits.findAll('abc123def456ghi789') do
print('abc123def456ghi789'.sub(start, stop))
end
-- Iteration with capture groups
pattern = regex.new('([a-z]+)([0-9]+)')
for start, stop, caps in pattern.findAll('abc123def456') do
print(f'Match: {caps[0]}, letters: {caps[1]}, digits: {caps[2]}')
end
-- Extract emails with structured data
emailRegex = regex.new('([^@\\s]+)@([^.\\s]+)\\.([^\\s]+)')
for start, stop, caps in emailRegex.findAll('Contact [email protected] or [email protected]') do
print(f'User: {caps[1]}, Domain: {caps[2]}, TLD: {caps[3]}')
endNotes:
() are defined in the pattern, captures will be nil.`capture, ... = rx.extract(Text, Offset, [RMATCH])
The extract() method provides a convenient interface when using patterns that are designed for capturing. Rather than emitting an array of captures as in findFirst(), captured results are instead returned in sequence, as individual string values. The downside to this convenience is that the start and end points of the match are omitted.
matches = rx.match(Text, [RMATCH])
Use the match() method to perform a whole-string match and return capture groups. Returns an array containing the full match and any capture groups, or nil if no match is found. Array indices start at 0 (full match), with capture groups at indices 1, 2, etc.
rx_url = regex.new('(https?)://([^/]+)(.*)')
matches = rx_url.match('https://example.com/path')
-- matches[0] = 'https://example.com/path' (full match)
-- matches[1] = 'https' (first capture group)
-- matches[2] = 'example.com' (second capture group)
-- matches[3] = '/path' (third capture group)all_matches = rx.search(Text, [RMATCH])
Use the search() method to find all matches in the text. Returns an array where each element is a match array (as returned by match()), or nil if no matches are found.
rx_word = regex.new('(\\w+)')
all_words = rx_word.search('hello world test')
-- all_words[0][0] = 'hello', all_words[0][1] = 'hello'
-- all_words[1][0] = 'world', all_words[1][1] = 'world'
-- all_words[2][0] = 'test', all_words[2][1] = 'test'result = rx.replace(Text, Replacement, [RMATCH])
Use the replace() method to replace all occurrences of a pattern. Replacement strings support backreferences using $1, $2, etc., to reference capture groups.
rx_phone = regex.new('(\\d{3})-(\\d{3})-(\\d{4})')
formatted = rx_phone.replace('555-123-4567', '($1) $2-$3')
-- Result: '(555) 123-4567'parts = rx.split(Text, [RMATCH])
Use the split() method to split text using the regex pattern as a delimiter. Returns an array containing the split parts (empty strings are excluded).
csvRegex = regex.new('\\s*,\\s*') -- Split on commas and eliminate whitespace
fields = csvRegex.split('apple, banana, cherry')
-- fields = { 'apple', 'banana', 'cherry' }Tiri includes a native array interface that is fully integrated with the JIT compiler. We strongly recommend its use for the storage of sequentially arranged data, and that tables are avoided for that use case.
Arrays are typed containers that store elements of a single primitive type. They provide efficient storage and direct memory access, making them ideal for numerical computations, buffer management, and interoperability with C/C++ APIs.
The member type is also part of an annotated or inferred array binding. Parameters, results, locals and globals use
the array<Element> spelling:
function first_word(Values:array<str>):str
return Values[0]
end
local values:array<int> = array<int> { 1, 2 }
values = array<float> { 1.0 } -- Error: array<float> does not satisfy array<int>Bindings inferred from an array constructor retain the same member identity. A dynamically sourced replacement is
checked at runtime before assignment, and global member contracts remain active across separately compiled chunks.
Use array<any> only when different member types are intentional; it is distinct from an array<any> instance, whose
own elements use mixed-value storage.
Arrays are largely compatible with tables so that they may be used interchangeably in code. In particular the ipairs(), pairs() and values() functions behave identically for both types. Numeric indexes on tables and arrays function identically.
Arrays can also be used directly in a generic loop. They yield a zero-based index followed by the element:
for index, value in array<str> { 'first', 'second' } do
print(index, value)
endTiri provides concise syntax for creating typed arrays using the array<type> pattern:
arr = array<type> -- Empty array of specified type
arr = array<type, size> -- Pre-allocated array with size elements
arr = array<type> { v1, v2, ... } -- Array initialised with values
arr = array<type, size> { v1, v2, ... } -- Pre-allocated with initial valuesExamples:
-- Create typed arrays with concise syntax
numbers = array<int> { 1, 2, 3, 4, 5 }
names = array<string, 100> -- Pre-allocate 100 string slots
points = array<table> { {x=0, y=0}, {x=1, y=1} }
-- Chain methods directly on the result
result = array<int> { 1, 2, 3 }.concat(', ', '%d') -- Returns '1, 2, 3'
-- Pre-allocated with initial values (size > values count)
buffer = array<int, 10> { 1, 2, 3 } -- {1, 2, 3, 0, 0, 0, 0, 0, 0, 0}
-- Dynamic size expression with variables and functions
n = 8
arr = array<int, n> { 100, 200 } -- {100, 200, 0, 0, 0, 0, 0, 0}When both a size and initial values are provided, the array is first initialised with the values, then resized to the specified size. If the size is larger than the number of values, the remaining elements are zero-initialised (or nil for reference types like strings and tables). If the size is smaller than the number of values, the values take precedence and the size is effectively ignored.
Supported Element Types:
| Type | Size | Description |
|---|---|---|
byte |
1 | Unsigned 8-bit integer (0-255). char is accepted as a synonym |
int8 |
1 | Signed 8-bit integer (-128 to 127) |
int16 |
2 | Signed 16-bit integer |
int |
4 | Signed 32-bit integer |
int64 |
8 | Signed 64-bit integer |
uint8 |
1 | Unsigned 8-bit numeric integer |
uint16 |
2 | Unsigned 16-bit numeric integer |
uint |
4 | Unsigned 32-bit numeric integer |
uint64 |
8 | Unsigned 64-bit numeric integer |
float |
4 | 32-bit floating point |
double |
8 | 64-bit floating point |
string |
8 | String reference |
obj |
varies | Object reference |
struct |
varies | Structure reference |
table |
varies | Table reference |
array |
varies | Array reference (use for creating multi-dimensional arrays) |
any |
16 | Any type from the above (note: convenience comes at a cost of efficiency) |
pointer |
8 | Memory pointer (unavailable for client use) |
Typed arrays validate the exact Tiri category of every stored value. Numeric arrays accept numbers, string arrays
accept strings, and table, array and object arrays accept their matching reference category. Numeric strings are not
converted to numbers, and numbers are not formatted as strings. nil clears a reference slot and writes zero to a
numeric slot. array<any> remains the explicit variant container.
Integer elements accept finite numbers, truncate fractional components toward zero and wrap modulo the destination
width. Non-finite integer values raise before mutation. A float accepts normal precision loss and underflow, plus
IEEE NaN and infinity, but rejects a finite value beyond the finite float range. A double accepts every Tiri
number. Initialisers, array.of(), indexed assignment, push(), insert(), fill(), table-backed copy(), map()
and non-byte array ..= use this same contract.
Array member types may be recursive. The complete identity is enforced by constructors, annotations and later mutations, while each inner array remains an independent reference:
matrix:array<array<double>> = array<array<double>> {
array<double> { 1.0, 0.0 },
array<double> { 0.0, 1.0 }
}
print(matrix[1][1]) -- 1.0
print(type(matrix)) -- array
print(matrix.type()) -- array
print(rawtype(matrix)) -- array<array<double>>array<array> is the unconstrained form and accepts inner arrays with different element storage. An exact form such
as array<array<int>> accepts only array<int> members (and nil where reference slots permit it). Recursion may be
repeated and can contain named structures, for example array<array<struct<Point>>>. A size may be declared only for
the array being constructed: array<array<int>, 4> is valid, but array<array<int, 4>> is not.
uint8, uint16, uint and uint64 are numeric array members with distinct binding identities from signed arrays.
For example, array<uint> preserves values above the signed 32-bit limit:
mask = array<uint> { 0, 4294967295 }
print(mask[1]) -- 4294967295Tiri numbers use IEEE-754 doubles, so array<uint64> retains its native bits but script-level values above
2^53 - 1 cannot be guaranteed exact after a read/write round trip.
Byte arrays also provide explicit buffer operations. string.toArray(String), copy(String), setString(), and
array<byte>.push(String) copy raw string bytes, including embedded NUL bytes. Compound concatenation with a byte
array appends string bytes, formatted numbers or another byte array. These buffer operations do not make strings valid
ordinary numeric elements: bytes[0] = 'A' still raises, while bytes.push('A') appends one raw byte.
array<uint8> deliberately does not provide these buffer operations: it is a numeric array, not a byte buffer.
arr = array.new(Type, Size)The array <> syntax desugars to array.new() and array.of() calls and is recommended for ordinary construction.
Use array.new() when the element type is supplied dynamically. Both arguments are required. To copy a string into a
byte array, use string.toArray(String).
| Parameter | Description |
|---|---|
Type |
Element type name, such as 'int', 'byte', or 'struct<Point>'. |
Size |
Non-negative number of elements. Zero creates an empty array. |
Examples:
-- Create a 100-element integer array
int_arr = array.new('int', 100)
-- Create an array from a string (each byte becomes an element)
byte_arr = string.toArray('Hello World')
-- Create an empty array (useful for dynamic filling)
empty_arr = array.new('double', 0)arr = array.of(Type, Value1, Value2, ...)As for array.new(), calling array.of() is not recommended unless an edge case requires it. Calling array.of() will populate a new array with the given values. Type is the element type. The remaining arguments are the values to populate the array with. At least one value must be provided.
Examples:
-- Create a string array with two domain names
domains = array.of('string', 'google.com', 'amazon.co.uk')
-- Mixed types
arr = array.of('any', 42, 'hello', true, nil, { x = 10 })Array elements are accessed using zero-based indexing:
arr = array<int, 10>
arr[0] = 100 -- Set first element
arr[9] = 999 -- Set last element
value = arr[5] -- Read element at index 5The # operator returns the array length:
total = #arr -- Returns 10Returns the public runtime category array. Use rawtype() when the element annotation is required.
arr = array<float, 10>
print(arr.type()) -- Prints 'array'
print(rawtype(arr)) -- Prints 'array<float>'
byte_arr = string.toArray('test')
print(byte_arr.type()) -- Prints 'array'
print(rawtype(byte_arr)) -- Prints 'array<byte>'Returns true if the array is read-only, false otherwise. Read-only arrays are typically created by the system when wrapping external memory buffers.
Returns a copy of the array in table format.
length = arr.push(Value, ...)
Appends one or more elements to the end of the array, growing capacity as needed. Returns the new length of the array. Arrays grow automatically when pushing beyond current capacity.
External arrays (wrapping C/C++ memory) cannot grow and will raise an error. Exact element validation is performed
before the array grows or changes; if any supplied value is incompatible, the complete operation raises and preserves
the previous length and contents. Calling push() with no arguments returns the current length without modification.
For byte arrays only, a string argument appends its raw bytes as the buffer operation described above.
value = arr.pop([Count])
Removes and returns the last element(s) from the array. Count indicates the number of elements to pop (defaults to 1). Returns nil if the array is empty.
arr = array<int> { 1, 2, 3, 4, 5 }
a, b = arr.pop(2) -- a = 5, b = 4, arr = {1, 2, 3}Pop returns nil if used on an empty array. When popping GC-tracked types (strings, tables), the reference is cleared to allow garbage collection. Read-only arrays cannot be popped.
arr.clear()
Resets the array length to zero without deallocating storage. The capacity is preserved for efficient reuse.
For GC-tracked types (strings, tables), references are nullified to allow garbage collection. Read-only arrays cannot be cleared. Use fill(0) if the intention is to clear a newly allocated array.
new_length = arr.resize(NewSize)
Resizes an array to the specified length, growing or shrinking as needed. Returns the new length of the array.
When growing, new elements are zero-initialised for numeric types, or set to nil for reference types (strings, tables). When shrinking, excess elements are discarded and references are cleared for garbage collection.
arr = array<int> { 1, 2, 3 }
array.resize(arr, 7) -- arr = {1, 2, 3, 0, 0, 0, 0}
array.resize(arr, 2) -- arr = {1, 2}
array.resize(arr, 0) -- arr = {} (equivalent to clear)External arrays (wrapping C/C++ memory) and cached string arrays cannot grow and will raise an error. Read-only arrays cannot be resized.
arr.fill(Value, [Start], [Stop])
arr.fill(Value, Range)
Fills array elements with a value that satisfies the array's exact element contract. nil can therefore clear
reference arrays or zero numeric arrays. Start is an inclusive starting index (default: 0), and Stop is an
exclusive stopping index (default: array length). A Stop beyond the array length is clipped. A Start at or beyond
the array length, or a Stop that is not greater than Start, selects no elements. Negative positional bounds raise
an index-range error. Range is a range object specifying which elements to fill.
arr = array<int, 10>
arr.fill(0) -- Fill entire array with zeros
arr.fill(99, 3, 8) -- Fill indexes 3-7 with value 99
arr.fill(42, 2, 6) -- Fill indexes 2, 3, 4 and 5
arr.fill(42, {2 to 6}) -- Select the same indexes with a rangenewLength = arr.insert(Index, Value, ...)
Inserts one or more values at the specified index, shifting subsequent elements to make room. The array grows automatically if needed.
Index refers to the position to insert at. Must be between 0 and the array length (inclusive). Value is one or more values to insert, which must match the array's element type.
-- Insert multiple values
arr = array<int> { 1, 5 }
arr.insert(1, 2, 3, 4) -- arr = {1, 2, 3, 4, 5}newLength = arr.remove(Index [, Count])
Removes one or more elements at the specified Index, shifting subsequent elements down. Count is the number of elements to remove (defaults to 1) and is automatically limited to available elements.
-- Remove multiple elements
arr = array<int> { 1, 2, 3, 4, 5 }
arr.remove(1, 3) -- arr = {1, 5}, returns 2A Count of 0 does nothing and returns the current length. Negative Count values raise an error.
arr.reverse()
Reverses the array elements in place.
arr = array<int, 5>
for i = 0, 4 do arr[i] = i end -- {0, 1, 2, 3, 4}
arr.reverse() -- {4, 3, 2, 1, 0}arr.sort([Descending])
Sorts the array elements in place. If Descending is true, sort in descending order (default: false for ascending).
arr = array<int, 5>
arr[0] = 30; arr[1] = 10; arr[2] = 50; arr[3] = 20; arr[4] = 40
arr.sort() -- {10, 20, 30, 40, 50}bool = Value in arr
Returns true if the specified value exists in the array, false otherwise. Array membership is value based and
string comparisons are case-sensitive. The former array.contains() and instance .contains() APIs are unavailable.
value = arr.first()
Returns the first element of the array, or nil if the array is empty. Provides bounds-safe access without risking
index-out-of-bounds errors.
This method is equivalent to arr[0] but returns nil for empty arrays instead of raising an error.
value = arr.last()
Returns the last element of the array, or nil if the array is empty. Provides bounds-safe access without risking
index-out-of-bounds errors.
This method is equivalent to arr[#arr - 1] but returns nil for empty arrays instead of raising an error.
index = arr.indexOf(Value, [Start], [Stop])
index = arr.indexOf(Value, Range)
Searches for a Value in the array. Start is a starting index for search (defaults to 0). Stop is an exclusive
ending index (defaults to array length). Range is a range object specifying the search bounds. The index where the
value was found is returned, or nil if not found.
arr = array<int, 10>
for i = 0, 9 do arr[i] = i * 10 end
-- Search starting from index 6
idx = arr.indexOf(50, 6) -- Returns nil (50 is at index 5)
-- Search within a range
idx = arr.indexOf(30, {0 to 5}) -- Returns 3value = arr.find(Predicate)
Returns the first value for which Predicate(Value, Index) is truthy, or nil when no value matches. Index is the
zero-based array position. The search short-circuits at the first match. Use indexOf(Value, [Bounds]) for an
equality search. If a matching callback changes its current source element, find() returns the value originally
supplied to that callback rather than re-reading the modified element.
arr = array<int> { 10, 20, 30 }
value = arr.find((value, index) => value > 10 and index is 1)
-- value = 20arr.copy(Source, [DestIndex], [SrcIndex], [Count])
Copies data from a source into the array. Source is an array, string or table. DestIndex is a starting index in destination array (default: 0). SrcIndex is a starting index in source (default: 0). Count is the number of elements to copy (default: all remaining). Array sources require the same complete element identity. Table elements are validated before the destination changes, so an incompatible value rejects the complete copy. String sources are supported only for byte arrays.
-- Copy from another array
src = array<int, 10>
dst = array<int, 20>
dst.copy(src, 5, 0, 10) -- Copy 10 elements from src[0] to dst[5]
-- Copy from a string
bytes = array<byte, 100>
bytes.copy('Hello', 0)
-- Copy from a table
arr = array<int, 5>
arr.copy({10, 20, 30, 40, 50})str = arr.getString([Start], [Length])
Extracts a substring from a byte/char array. Start is the starting byte index (0-based). Length is the number of bytes to extract (defaults to all).
arr = string.toArray('Hello World')
print(arr.getString(0, 5)) -- Prints 'Hello'
print(arr.getString(6)) -- Prints 'World'count = arr.setString(String, [Start])
Copy String content into a byte array. Start is a starting index in the array (default: 0). The number of bytes written is returned.
arr = array<byte, 20>
arr.setString('Hello', 0)
arr.setString(' World', 5)
print(arr.getString(0, 11)) -- Prints 'Hello World'new_arr = arr.slice(Range)
Creates a new array containing a subset of elements. Range is a range object specifying which elements to extract. A new array containing the selected elements is returned.
arr = array<int, 10>
for i = 0, 9 do arr[i] = i * 10 end -- {0, 10, 20, 30, 40, 50, 60, 70, 80, 90}
sub = arr.slice({2 to 5}) -- {20, 30, 40}
sub = arr.slice({5 into 2}) -- {50, 40, 30, 20} Reverse slice
sub = arr.slice({-3 into -1}) -- {70, 80, 90} Negative inclusive slice
sub = arr.slice({-3 to -1}) -- {70, 80} Negative exclusive slicestr = arr.concat([Separator], [Format], [Start], [Stop])
Concatenates array elements into a string. Separator is a string to insert between elements (default: empty string).
Format is an optional printf-style format string for each element (e.g., '%d', '%.2f'). If Format is not
specified, the most efficient conversion path is used. Start and Stop are optional zero-based half-open bounds
that limit the elements to concatenate. Stop may equal the array length.
arr = array<int, 5>
for i = 0, 4 do arr[i] = i * 10 end
str = arr.concat(', ', '%d') -- Result: '0, 10, 20, 30, 40'
farr = array<double> { 1.5, 2.75, 3.125 }
str = farr.concat(' | ', '%.2f') -- Result: '1.50 | 2.75 | 3.13'
names = array<str> { 'apple', 'banana', 'cherry' }
str = names.concat(', ') -- Result: 'apple, banana, cherry'
numbers = array<int> { 1, 2, 3, 4, 5 }
str = numbers.concat('-') -- Result: '1-2-3-4-5'
str = numbers.concat('-', nil, 1, 4) -- Result: '2-3-4'copy = arr.clone()
Creates a copy of the array. For primitive types (int, float, etc.), this is a deep copy. For reference types (strings, tables), the references are copied (shallow copy).
Array functional methods use the source length recorded before their first callback, so values appended during a callback are not visited. If a callback shortens the array, iteration stops when the next recorded position is no longer available.
arr.each(Callback)
Iterates over array elements, calling the Callback function for each element. The callback receives (value, index) as arguments. Returns the original array for method chaining.
It stops only when the callback explicitly returns false; nil and every truthy result continue iteration.
-- Basic iteration
arr = array<int> { 10, 20, 30 }
arr.each(function(v, i)
print('Index ' .. i .. ': ' .. v)
end)
-- Output: Index 0: 10, Index 1: 20, Index 2: 30
-- Using arrow function syntax
arr.each(v => print(v))
-- Chaining
arr.each(v => print('Processing:', v))
.each(v => log('Logged:', v))
-- With statements in arrow function body
total = 0
arr.each(v => do total += v end)
print(total) -- 60new_array = arr.map(Transform, [ElementType])
new_array = arr.mapSame(Transform)
Both callbacks receive (Value, Index). mapSame() returns a new array that retains the source array's exact storage
descriptor, including a structure definition. map(Transform, [ElementType]) returns array<any> when no type is
specified; an explicit type requests checked destination storage. An invalid destination type is rejected before the
first callback; an incompatible callback result raises without modifying the source array. Use mapSame() whenever a
transformation must preserve the source storage type.
-- Double all values
arr = array<int> { 1, 2, 3, 4, 5 }
doubled = arr.mapSame(v => v * 2)
-- String transformation
names = array<str> { 'hello', 'world' }
upper = names.mapSame(s => s.upper())
-- Chaining map operations
result = arr.mapSame(v => v * 2).mapSame(v => v + 1)
-- Type-changing map with checked string storage
labels = arr.map(v => 'Value ' .. tostring(v), 'str')index = arr.findIndex(Predicate)
Returns the zero-based index of the first value for which Predicate(Value, Index) is truthy, or nil when no value
matches. It is the predicate counterpart to equality-search indexOf() and short-circuits at the first match.
arr = array<int> { 10, 20, 30 }
index = arr.findIndex((value, index) => value > 15 and index is 1)
-- index = 1new_array = arr.filter(predicate)
Returns a new array containing only elements that satisfy the Predicate function. Elements for which the predicate returns a true value are included.
-- Filter even numbers
arr = array<int> { 1, 2, 3, 4, 5, 6 }
evens = arr.filter(v => v % 2 is 0) -- evens = {2, 4, 6}
-- Filter by index
arr = array<int> { 10, 20, 30, 40, 50 }
odd_indices = arr.filter((v, i) => i % 2 is 1) -- odd_indices = {20, 40}
-- String filtering
names = array<str> { 'apple', 'banana', 'apricot', 'cherry' }
a_words = names.filter(s => s.startsWith('a')) -- a_words = {'apple', 'apricot'}
-- Chained filtering
large_evens = arr.filter(v => v % 2 is 0).filter(v => v > 10)result = arr.reduce(Initial, Reducer)
Folds all array elements into a single accumulated value. The reducer function is called for each element, receiving the current accumulator, the element value, and the element index. Initial is the initial value for the accumulator (any type). Reducer is a function receiving (accumulator, value, index) and returning the new accumulator.
-- Sum all elements
arr = array<int> { 1, 2, 3, 4, 5 }
sum = arr.reduce(0, (acc, v) => acc + v) -- sum = 15
-- Product of elements
product = arr.reduce(1, (acc, v) => acc * v) -- product = 120
-- String concatenation
parts = array<str> { 'a', 'b', 'c' }
result = parts.reduce('', (acc, v) => acc .. v) -- result = 'abc'
-- Build a table
arr = array.<int> { 1, 2, 3 }
squares = arr.reduce({}, function(acc, v, i)
acc[i] = v * v
return acc
end)
-- squares = {[0]=1, [1]=4, [2]=9}
-- Find maximum
arr = array<int> { 3, 1, 4, 1, 5, 9, 2, 6 }
max = arr.reduce(arr.first(), (acc, v) => v > acc ? v : acc) -- max = 9bool = arr.any(Predicate)
Returns true if any element in the array satisfies the Predicate function. Short-circuits on the first match, returning immediately without checking remaining elements. The Predicate arguments are (value, index) and must return a boolean.
-- Check if any element is even
arr = array<int> { 1, 3, 5, 6, 7 }
has_even = arr.any(v => v % 2 is 0) -- has_even = true (found 6)
-- Check if any element is negative
arr = array<int> { 1, 2, 3, 4, 5 }
has_negative = arr.any(v => v < 0) -- has_negative = false
-- String array
names = array<str> { 'apple', 'banana', 'cherry' }
has_long = names.any(s => #s > 6) -- has_long = true (banana, cherry)bool = arr.all(Predicate)
Returns true if all elements in the array satisfy the Predicate function. Short-circuits on the first failure, returning immediately without checking remaining elements. The Predicate arguments are (value, index) and must return a boolean.
-- Check if all elements are positive
arr = array<int> { 1, 2, 3, 4, 5 }
all_positive = arr.all(v => v > 0) -- all_positive = true
-- String validation
names = array<str> { 'abc', 'def', 'ghi' }
all_short = names.all(s => #s is 3) -- all_short = trueThe functional methods can be chained together to create data processing pipelines:
-- Filter, then map, then reduce
arr = array<int> { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }
result = arr.filter(v => v % 2 is 0) -- {2, 4, 6, 8, 10}
.mapSame(v => v * 10) -- {20, 40, 60, 80, 100}
.reduce(0, (acc, v) => acc + v) -- 300
-- Check conditions on transformed data
has_large = arr.mapSame(v => v * 2).any(v => v > 15)
all_small = arr.filter(v => v < 5).all(v => v < 10)
-- Use each() in a chain (returns original array)
arr.each(v => log('Before:', v))
.mapSame(v => v * 2)
.each(v => log('After:', v))The following functions are included as standard so that messages can be logged to the console.
msg() accepts a single string parameter and prints it to the debug log. The log level must be set to api or higher in order to be visible.
If the log-level is not high enough to display the message, the call is silently discarded during parsing to maximise efficiency.
print() accepts a single string parameter and prints it to stdout. If stdout is unavailable on a system like Android, the message is printed to the debug log instead.
For targeted printing, use io.write() and target stderr or stdout instead of the print() function.
Tiri uses the @ prefix for two related parser features: parser annotation expressions and function annotations.
Parser annotation expressions expand to literal values during parsing. Function annotations attach metadata to
functions at parse time, providing a declarative way to mark functions with attributes that can be queried at runtime
for features such as test discovery, deprecation warnings, and capability requirements.
Parser annotation expressions are compile-time expressions that resolve to literal values while the source is parsed. They can be used anywhere an expression is valid. The generated bytecode receives the resolved string or number literal, so no runtime metadata lookup is required.
| Expression | Type | Resolved value |
|---|---|---|
@FunctionName |
str |
The enclosing function name. |
@SourceFile |
str |
The current source file path. |
@SourceLine |
num |
The 1-based source line number of the annotation expression. |
For @FunctionName, top-level code resolves to "Main". Named and dot-qualified function declarations use the final
declared function name, not the table path. Anonymous function expressions, anonymous thunk expressions, and arrow
functions resolve to "Anonymous".
top_level_name = @FunctionName -- "Main"
top_level_file = @SourceFile
top_level_line = @SourceLine
function logLocation()
print(f"{@FunctionName} in {@SourceFile}:{@SourceLine}")
end
function widget.update(Widget:table)
return @FunctionName -- "update"
end
callback = () => @FunctionName -- "Anonymous"Only the parser annotation names listed above are valid in expression position. Other @Name forms are declaration
annotations and must appear before a function declaration or in the syntax documented below.
Annotations use the @ prefix and are placed immediately before a function declaration:
@AnnotationName
function myFunction()
-- function body
endAnnotations can include named arguments in parentheses:
@Test(name="My Test Case", timeout=5)
function testSomething()
-- test body
endSupported Argument Types:
| Type | Example |
|---|---|
| String | name='Test Name' |
| Number | timeout=5.0, priority=1 |
| Boolean | enabled=true, network=false |
| Array | labels=['unit', 'smoke', 'critical'] |
| Bare Identifier | deprecated (equivalent to deprecated=true) |
Multiple Annotations:
Multiple annotations can be stacked on a single function:
@Test(name='Network Test')
@Requires(network=true)
function testNetworkFeature()
-- test body
endAnnotations can also be placed on the same line using semicolons:
@BeforeEach; @Requires(network=true)
function setupNetwork()
-- setup body
endFunction Types:
Annotations work with all function declaration styles:
-- Regular functions
@Test function regularFunc() end
-- Local functions
@Test local function localFunc() end
-- Global functions
@Test global function globalFunc() endThe @Doc annotation attaches source documentation to a function. It is primarily intended for tooling such as the Tiri LSP and API documentation extraction. When debug.validate(Source, { symbols = true }) is used, the parser returns a symbols table that includes the function signature, source span, annotations, parameter types, result types, and structured documentation parsed from @Doc(text=...).
debug.validate() reports all source lines and columns as 1-based values, including diagnostics, tips, symbols, and
struct fields. A symbol's line and column identify the first character of its declared name. For complete function
and struct declarations, endLine and endColumn identify the first character of the terminating end token and are
not exclusive bounds. Diagnostic and tip endColumn values are 1-based and exclusive. A position of 0 indicates
that the parser could not determine that part of the source location.
source = [=[
@Doc(text=[[
Returns a greeting.
-INPUT-
Name: Person to greet
-RESULTS-
str: Greeting text
-ERRORS-
Args: If the name is invalid
]])
function greet(Name: str):str
return "Hello " .. Name
end
]=]
result = debug.validate(source, { symbols = true })
symbol = result.symbols[0]
print(symbol.signature) -- greet(Name: str):str
print(symbol.doc.summary) -- Returns a greeting.
print(symbol.params[0].type) -- str
print(symbol.params[0].doc) -- Person to greetLeading whitespace is removed from each documentation line before parsing, so @Doc blocks may be indented with the
function they document.
Long Form:
The long form uses marker sections. Text before the first marker becomes the summary/body text. Marker names are matched exactly.
| Marker | Line Format | Description |
|---|---|---|
-INPUT- |
Name: Description |
Documents a parameter. Signature names and types are merged into the symbol data. |
-RESULTS- |
type: Description |
Documents a result value. May be repeated for multi-result functions. |
-ERRORS- |
Code: Description |
Documents an error code that may be thrown or returned. |
-EXAMPLE- |
free-form text | Provides example code until the next marker or end of the documentation block. |
Short Form:
If the first non-empty documentation line starts with @, the compact parser is used. Compact entries are strictly one line each; descriptions and examples cannot span multiple lines.
@Doc(text=[[
@desc Returns a greeting.
@input Person to greet
@result Greeting text
@result Additional argument
@error Args: If the name is invalid
@error Failed: Random failure
]])
function greetCompact(Name: str):<str, str>
return "Hello " .. Name, "extra"
end| Entry | Line Format | Description |
|---|---|---|
@desc |
@desc Description |
Sets the function summary. |
@input |
@input Description |
Documents the next parameter in function declaration order. |
@result |
@result Description |
Documents one result value. Types are taken from the signature or inferred from the function body, falling back to any. |
@error |
@error Code: Description |
Documents one error code. |
For normal script execution, large @Doc payloads are omitted from runtime annotation registration unless the script is configured to process documentation. Tooling that calls debug.validate(Source, { symbols = true }) enables documentation processing for that validation pass. The older "symbols" flag argument and { flags = "symbols" } option are deprecated compatibility forms.
The @if ... @end construct provides a compile-time pre-processor, allowing code to be included or excluded based on conditions evaluated during parsing. This allows you to quickly eliminate the compilation of bytecode that the program doesn't need.
Basic Syntax:
@if(condition=value)
-- Code included only when condition matches
@endSupported Conditions:
| Condition | Value Type | Description |
|---|---|---|
imported |
boolean | true when file is being imported; false when file is the main script. |
debug |
boolean | true when log level is higher than 'warning'; false otherwise. |
platform |
string | Matches against current platform: "windows", "linux", "osx", "native". |
exists |
string | Checks for a relative file, or loads and tests a module with exists='modules:name'. A successful module check initialises and retains that module. |
Examples:
-- Platform-specific code
@if(platform="windows")
path_separator = "\\"
@end
@if(platform="linux")
path_separator = "/"
@end
-- Debug-only code (only included when logging is enabled)
@if(debug=true)
print("Debug: Entering critical section")
@endNesting:
Compile-time conditionals can be nested:
@if(imported=false)
@if(platform="windows")
print("Running as main script on Windows")
@end
@endThe following annotations are recommended for general-purpose code organisation:
| Annotation | Arguments | Description |
|---|---|---|
@Doc |
text:str |
Attaches parser-readable documentation to a function for LSP support and documentation extraction. |
@Deprecated |
message:str, since:str |
Marks a function as deprecated. Tools may emit warnings when deprecated functions are called. |
@Override |
(none) | Indicates that a function overrides a parent implementation. Useful for documentation and tooling. |
@SuppressWarnings |
<flags> |
Suppresses specific warnings. Flags are bare identifiers: @SuppressWarnings(unused, deprecated) |
Examples:
@Deprecated(message='Use newApi() instead', since='2.0')
function oldApi()
return newApi()
end
@Override
function customBehaviour()
-- Override parent implementation
end
@SuppressWarnings(unused, experimental)
function internalHelper()
-- Implementation
endThe Flute test framework recognises the following annotations for test discovery and configuration:
| Annotation | Arguments | Description |
|---|---|---|
@Test |
name:str, timeout:num, priority:num, labels:array |
Marks a function as a test case |
@BeforeEach |
(none) | Runs before each test in the file |
@AfterEach |
(none) | Runs after each test in the file |
@BeforeAll |
(none) | Runs once before all tests in the file |
@AfterAll |
(none) | Runs once after all tests in the file |
@Disabled |
reason:str |
Skips the test with an optional reason |
@Requires |
display:bool, network:bool, audio:bool |
Specifies runtime requirements; test is skipped if requirements are not met |
Test Argument Reference:
| Argument | Type | Default | Description |
|---|---|---|---|
name |
string | function name | Display name for the test |
timeout |
number | 3.0 | Maximum execution time in seconds |
priority |
number | 0 | Execution order (lower runs first) |
labels |
array | [] |
Tags for filtering tests (e.g., ['unit', 'smoke']) |
Requirements Reference:
| Requirement | Description |
|---|---|
audio |
Requires working audio module |
display |
Requires working display module |
font |
Requires working font module |
network |
Requires working network module |
ssl |
Requires SSL to be built-in to network module |
Examples:
@Test(name='User Login', labels=['integration', 'auth'])
@Requires(network=true)
function testUserLogin()
-- Test implementation
end
@BeforeEach
function setupTestEnvironment()
-- Runs before each test
end
@Disabled(reason='Pending implementation')
@Test
function testFutureFeature()
-- Will be skipped
endThe debug.fileSources() function provides access to the file source tracking system, which maintains metadata about all source files involved in the current script execution. This is particularly useful for debugging, tooling, and understanding import hierarchies.
array = debug.fileSources()
Returns an array of all registered file sources for the current script execution. Each entry in the returned array contains the following fields:
| Field | Type | Description |
|---|---|---|
index |
number | File index (0 = main file, 255 = overflow fallback) |
path |
string | Full resolved path to the source file |
filename |
string | Short filename for error display |
namespace |
string | Declared namespace (empty string if none) |
firstLine |
number | First line number in unified line space |
sourceLines |
number | Total number of lines in the source file |
parentIndex |
number | Index of the file that imported this one (0 for main) |
importLine |
number | Line number in parent where import occurred (0 for main) |
isOverflow |
boolean | True if this is the overflow fallback entry (index 255) |
Example - List All File Sources:
sources = debug.fileSources()
print("File sources:")
for i = 0, #sources - 1 do
src = sources[i]
print(f" [{src.index}] {src.filename}")
print(f" path: {src.path}")
if src.namespace != "" then
print(f" namespace: {src.namespace}")
end
if src.parentIndex != 0 or src.importLine != 0 then
print(f" imported from [{src.parentIndex}] at line {src.importLine}")
end
endExample - Correlate with debug.getInfo():
The debug.getInfo() function returns a fileIndex field when the 'S' option is used. This index can be used to look up the corresponding file source:
sources = debug.fileSources()
info = debug.getInfo(1, "S") -- Get info about current function
if info.fileIndex then
local src = sources[info.fileIndex]
print(f"Current function is in: {src.filename}")
print(f"Full path: {src.path}")
endNotes:
fileIndex field in debug.getInfo() results corresponds to indices in this arrayThe debug.anno interface provides programmatic access to function annotations at runtime.
Registers annotations for a function. Returns the created entry table.
Parameters:
| Parameter | Type | Description |
|---|---|---|
func |
function | The function to annotate |
annotations |
string or table | Annotation data (see below) |
source |
string | Source file path (default: '<runtime>') |
name |
string | Function name (default: inferred or '<anonymous>') |
Annotation Formats:
Table format:
debug.anno.set(myFunc, {
{ name = 'Test', args = { name = 'My Test', labels = { 'unit' } } },
{ name = 'Requires', args = { network = true } }
}, 'myfile.tiri', 'myFunc')String format (parsed):
debug.anno.set(myFunc, '@Test(name='My Test'); @Requires(network=true)')Returns: Entry table with fields:
name: Function namesource: Source file pathannotations: Array of annotation tablesRetrieves the annotation entry for a function.
Parameters:
| Parameter | Type | Description |
|---|---|---|
func |
function | The function to query |
Returns: Entry table if the function is annotated, nil otherwise.
@Test(name='Example')
function exampleFunc() end
entry = debug.anno.get(exampleFunc)
if entry then
print('Function: ' .. entry.name)
print('Source: ' .. entry.source)
for i, anno in ipairs(entry.annotations) do
print('Annotation: ' .. anno.name)
end
endReturns a shallow copy of all registered annotations.
Returns: Table mapping function references to their entry tables.
all = debug.anno.list()
for func, entry in pairs(all) do
print(entry.name .. ' has ' .. #entry.annotations .. ' annotations')
endEntry Table Structure:
{
name = 'functionName', -- Function name
source = 'path/to/file.tiri', -- Source file
annotations = { -- Array of annotations
{
name = 'Test', -- Annotation name
args = { -- Named arguments
name = 'Test Name',
timeout = 5,
labels = { 'unit', 'smoke' }
}
}
}
}Tiri programs can receive system-wide events whenever they are broadcast. Two functions are provided for the purpose of event management. The first is subscribeEvent(), which will connect a Tiri function to a specific event:
error, handle = subscribeEvent(eventname, function)
The eventname is a string that must follow the format group.subgroup.name, for example system.task.created. Valid event strings are described in full detail in the Events Manual of the Kōtuku SDK. A single asterisk wildcard is allowed in the subgroup and/or name if listening to multiple events is desirable, for example system.*.* would listen for all system events. The referenced function will receive two arguments if the event is signalled - EventID and Args. The Args parameter is a table containing named parameters - if the event does not include any parameters then the table will be empty.
To unsubscribe from an event, call unsubscribeEvent() with the event handle that was returned by the initial subscribeEvent() call:
unsubscribeEvent(handle)
Tiri is developed by Paul Manias and runs on Mike Pall's LuaJIT framework, which in turn is based on the Lua programming language.
Lua is designed and implemented by a team at PUC-Rio, the Pontifical Catholic University of Rio de Janeiro in Brazil. Lua was born and raised at Tecgraf, the Computer Graphics Technology Group of PUC-Rio, and is now housed at Lua.org. Both Tecgraf and Lua.org are laboratories of the Department of Computer Science.