Tiri Reference Manual

Introduction

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.

Goals

Tiri was designed with the following goals in mind:

  • Laziness: We want developers to be able to write code quickly and with minimal boilerplate. For instance, our type-inference-by-default approach allows variables to type-cast without explicit declarations. Features like safe navigation and short-hand operators are available to reduce verbosity.
  • Simplicity: The language should be easy to learn and understand, with a minimal set of core concepts. Operator tokens and keywords have been carefully chosen to maximise readability and keep the parsing process simplified.
  • Intuitiveness: The syntax and semantics should be logical and consistent. Lua features that weren't aligned with our changes have been removed.
  • Interoperability: It must work seamlessly with existing C++ APIs and libraries to leverage the power of Kōtuku.
  • Performance: Many of our changes to the LuaJIT base code have been made to maximise the efficiency of code produced by the compiler and JIT recorder. Some language features like the introduction of arrays allow for faster processing of sequential data. The new type system enables more optimised code paths to be generated in conjunction with the added type safety guarantees. Some of Tiri's best features are under the hood and out of sight - but they can be felt in day to day usage and are clear in performance comparisons to other scripting languages.

Table of Contents

Resources

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:


Usage

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.

File Recognition

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.


Lua Extensions

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.

Breaking Changes

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 is
  • goto and ::label:: statements; superceded by continue, break, defer
  • bit.* library; replaced with native bitwise operators
  • select(); replaced with result masks [_*]
  • collectgarbage(); replaced with processing.collect()
  • newproxy(); removed without replacement
  • loadstring(); replaced with load()
  • dofile(); replaced with loadFile()
  • pcall() and xpcall(); replaced with try … except
  • math.fmod(); removed because % provides truncating remainder directly
  • math.pow(); removed because ** provides exponentiation directly
  • getfenv(), setfenv(), gcinfo(), table.maxn(), unpack(); removed
  • string.gsub(), string.match(), string.gmatch(); removed
  • string.substr(); deprecated compatibility alias of string.sub()
  • io.* interfaces now throw exceptions on error
  • One-based indexing for tables, strings and arrays; replaced with zero-based indexing
  • Variables are local by default; use the global keyword for globals
  • Arrays are a distinct and performance enhanced type; avoid using tables for sequential data storage
  • Lua patterns deprecated in favour of regex patterns
  • Lua-style numeric loops such as for 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
  • Lua floor-modulo semantics for %; replaced with truncating remainder, whose non-zero result has the dividend's sign

Remainder Operator

The % 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.

Positional spans

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)

Bitwise Operations

Tiri supports C-style bitwise operators on 32-bit integers:

  • ~x bitwise NOT
  • a & b bitwise AND
  • a | b bitwise OR
  • a ^ b bitwise XOR

Results 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 & 0xFF

Flag Testing Operator

The has operator tests whether a bitwise flag is set:

  • a has b is equivalent to a & b != 0

The result is always a boolean. Both operands must be numeric.

Examples:

permissions = PERMIT_READ | PERMIT_WRITE

if permissions has PERMIT_READ then
   print('Readable')
end

When both operands are constants, the expression is folded at compile time.

Bitshift Operators

Tiri adds infix bitshift operators for convenience:

  • << left shift
  • >> right shift

Precedence and associativity:

  • Shifts bind tighter than addition and subtraction, e.g. 1 + 1 << 3 evaluates as 1 + (1 << 3) producing 9.
  • The right-hand side of a shift is parsed as a full expression. For example, 8 >> 1 + 1 produces 2.
  • Shifts are left-associative when chained: x << y << z parses as (x << y) << z.

Unicode Operators

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.

Comparison and Logical Operators

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

Arithmetic Operators

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 area

Both ASCII and Unicode forms can be mixed freely in the same source file:

x = 5 * 3 × 2 ÷ 1 / 2     -- All forms work together

Script Parameters

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

Compound Assignment Operators

Tiri adds C-style compound assignment operators for convenience:

  • +=, -=, *=, /=, %= on numeric values
  • ..= for string concatenation

RHS behaviour:

  • The right-hand side is evaluated exactly once.
  • If the RHS is a function call or vararg, only the first return value is used.

Errors and types:

  • Using a non-assignable left-hand side (e.g. a constant or a function call result) is an error.

Concatenation Assignment

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.

String Interpolation

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:

  • Long string syntax is not supported: f[[...]] is invalid
  • Empty expressions {} or whitespace-only expressions { } are syntax errors

Postfix Increment

Tiri supports a postfix increment operator for convenience:

counter++
obj.field++
t[i]++

Notes:

  • Postfix only; there is no prefix form.
  • The operator mutates the target by adding 1. Use only on assignable values (locals, upvalues, globals, fields, indexed elements).
  • Intended for statement use. The value of the x++ expression itself is unspecified and should not be relied upon in expressions.

Arrow Functions

=> provides concise anonymous function syntax. Single-expression bodies are implicitly returned; multi-statement bodies use do ... end with an explicit return.

  • Single identifiers do not need parentheses: value => value * 2
  • Multiple parameters must be parenthesised: (left, right) => left + right
  • Use empty parentheses for no parameters: () => 42
  • Varargs are not supported; use function(...) 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.

Continue Statement

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 done

continue skips the remainder of the current loop body and advances to the next iteration. In repeat … until, it jumps to the condition check.

To-Be-Closed Variables

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 ends

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

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

  • Close handlers run before defer blocks (both in LIFO order)
  • Triggered on normal scope exit, return, break, continue, and error unwinding

Error 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
end

Array Field Views

The <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.data

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

Resource Array Field Contract

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.

Constant Variables

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> = true

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

Reassignment 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 binding

Multiple 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 const

Enum Declarations

Enum 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,
}

Restrictions

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.

Safe Navigation Operator

The safe navigation operator (?.) provides null-safe access to object fields, methods, and indexes.

Syntax

obj?.field      -- Safe field access
obj?.method()   -- Safe method call
obj?[key]       -- Safe index access
obj?.a?.b?.c    -- Chaining

Behaviour

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

Examples

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

Current Context Expressions

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

Native FUNCTION Callbacks

When 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
end

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

Dispatch-Scoped Table Metamethod 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
end

For 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 & &mask

Type Annotations

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

Syntax

function process(Path:str, Count:num, Options:table)
   -- Path must be a string
   -- Count must be a number
   -- Options must be a table
end

Untyped parameters omit the annotation:

function mixed(Untyped, Typed:bool): <any,bool>
   return Untyped, Typed
end

Supported Type Names

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.


Sticky Types (Type Inference)

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:

  • Typeless variables are automatically fixed to the type of their first non-nil value.
  • The nil value acts as a placeholder that does not commit the type.
  • Explicit type annotations are optional, primarily useful for pre-declarations or guarding function parameters. Array annotations are written as array<Element> because the member type is part of the binding contract.
  • The any type annotation opts out of type fixing for variables that genuinely need variant behaviour.

Benefits:

  1. Error detection: Type mismatches are reported as compile-time errors, catching bugs before execution
  2. Code clarity: Reading code becomes easier when variables maintain consistent types
  3. Optimisation potential: The JIT can generate more efficient code when variable types are known to be stable

Implicit Type Fixing

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.

Explicit Type Annotations

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.

The Integer Type

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
end

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

The Any Type

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 typing

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

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

Nil Semantics

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 type

Nil 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 Parameters

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

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

Untyped parameters remain fully dynamic and accept any value, preserving flexibility where needed.

Function Return Types

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
end

Single return type:

function calculate_area(Radius: num): num
   return math.pi * Radius * Radius
end

function get_name(): str
   return "Alice"
end

Multiple 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
end

Variadic 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
end

Return Type Inference

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

  1. The first statically concrete non-nil value establishes the expected type for its position.
  2. Later concrete values in that position must have the same type.
  3. Returning nil is always permitted; it does not establish a type.
  4. An any or otherwise unknown expression cannot prove compatibility and requires an explicit result declaration.
  5. A returned position with no concrete value by the end of analysis also requires an explicit 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'
end

When 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
end

Without :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'
end

The any Return Type

Use 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" }
end

Recursive Functions

Recursive 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)
end

This requirement also applies to mutually recursive functions (function A calls function B, which calls function A).

Return Type Benefits

Declaring return types provides several advantages:

  1. Documentation: Return types serve as built-in documentation for function contracts
  2. Error detection: Type mismatches in return statements are caught at parse time
  3. Call-site inference: When a function has declared return types, variables assigned from calls can infer their types
  4. Optimisation: The compiler will generate faster code if the return types are narrower than 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'

Global and Captured Variable Contracts

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.


Variable Scoping

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.

Local Variables

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
end

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

Global Variables

To 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'
end

It 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 name
  • A local can shadow a global with the same name if explicitly marked local
  • Global variables are accessible from nested functions without re-declaration
  • The global keyword can appear at any scope level, but the variable becomes globally visible
  • A global's first concrete declaration or meaningful non-nil 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.
  • Separately compiled code executed with 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.
  • Host-registered built-ins and interface globals such as 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.
  • Host-registered constants and enum constants such as 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.

Extern Declarations

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_state

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

Built-in Dot Methods

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.

Compiler-Owned Syntax Versus Explicit Namespace Calls

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:

  • range literals {Start to Stop} and {Start into Stop}, and range slicing Value[{Range}];
  • the range membership operator Value in Range;
  • typed-array construction array<Type>, array<Type> { ... } and array<Type, Size>;
  • declared-struct construction struct<Name> { ... };
  • object construction obj<Class> { ... }; and
  • the bitwise and shift operators &, |, ^, ~, << 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 result

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


Result Management

Blank Identifier

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=3

Notes:

  • The blank identifier does not allocate a variable or consume a register
  • Values are still consumed from the right-hand side for proper stack management
  • Can be used multiple times in the same statement
  • Cannot be read as a variable (e.g., local x = _ is an error)

Pipe Operator

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) = 20

Result 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) = 2

Chaining:

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)) = 36

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

  • The right-hand side must be a function call. A bare function, member or index reference, anonymous function, and arrow function are syntax errors. Use Callback() to pass the value, or forEach(Callback) to traverse it.
  • Pipe has higher precedence than logical operators (and, or) but lower than comparison operators
  • Right-associative: a |> b() |> c() evaluates as a |> (b() |> c())
  • For member calls, use dot syntax: obj |> obj.method() (not obj |> method())

Explicit Traversal with 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)
end

forEach() 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
end

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

Result Filter Operator

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 position
  • The last character determines the behaviour for any excess values beyond those explicitly specified

Examples:

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

With Method Calls:

obj = {
   method = function()
      return 10, 20, 30
   end
}

second = [_*]obj.method()       -- second=20

Use Cases:

The result filter is particularly useful when:

  • A function returns an error code as the first value that you want to ignore
  • You only need specific return values from a multi-value function
  • You want to skip metadata or status values and get directly to the data
-- Skip error code, get file content directly
content = [_*]file.acRead()

-- Get only the second and third return values
local b, c = [_**_]get_stats()

Notes:

  • The mask must be followed by a function call; [_*]variable is a syntax error
  • Maximum mask length is 64 positions

Exception Handling

Tiri distinguishes exceptions from ERR values returned by Kōtuku APIs:

  • Internal errors, as generated by the Tiri language and the runtime engine.
  • External errors, as generated by system API calls.

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.

Try-Except Statement

Structured exception handling is provided through the try-except syntax.

Features:

  • Lazy syntax is permitted.
  • Try blocks can be nested, with each level catching its own exceptions.
  • Multiple except handlers with their own filters for both system and user error codes.
  • Support for control flow statements (return, break, continue) within try blocks.
  • Optional stack trace capture for debugging.
  • Very fast performance compared to the Lua pcall approach, especially when JIT compiled.

Basic Syntax:

try
   risky_operation()
[ except [e] [ when Exception, ... ] ]
   print("Error: " .. e.message)
[ except... ]
[ success ]
end

The 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)
end

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

  • Message-only exceptions raised by raise and Tiri runtime errors have the default error code ERR_Exception.
  • Error codes are returned by API calls and can be triggered with 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')
end

Lazy Exception Syntax:

Suppressing exceptions is permitted with the simplest form of syntax:

try
   potentially_failing_operation()
end

Simple catch-alls are also possible:

try
   risky_operation()
except
   print("An error occurred")
end

Rethrowing 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")
end

Bare 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)
end

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

  • Filtered handlers must appear before the catch-all handler
  • Multiple filtered handlers are checked in order; the first match wins
  • If no filter matches and no catch-all exists, the exception propagates
-- 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 ... end

Control 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
end

Stack 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
end

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

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()
end

Combine checkup with try when promoted errors should be handled locally:

try
   checkup
      file.acOpen()
      file.acRead()
   end
except e
   print(e.message)
end

Checking 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.
end

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

Raising Exceptions

Exceptions can be raised at runtime by using the following features:

assert()

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 and raise

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

All 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')
end

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

Removed error() built-in

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.

Deference

Defer Statement

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 here

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

  • Defers execute when leaving scope via normal flow, return, break, or continue
  • Deferred functions capture upvalues from the enclosing scope
  • Arguments passed via end(...) are snapshotted at registration time
  • Multiple defers in the same scope execute in LIFO order (last registered executes first)

Limitations:

  • Error path execution during stack unwinding is not currently supported
  • Errors in deferred functions propagate normally

Deferred Expressions

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 result

Single-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 permanently

Important Notes:

  • Calling type() on a deferred expression will return the type associated with the expression without evaluating it.
  • They capture upvalues from the enclosing scope
  • Error handling works naturally - errors during evaluation propagate normally
  • Deferred expressions can be nested, stored in tables, and passed as arguments
  • The type annotation or inference is required for correct behaviour with type-checking code

Thunks

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
end

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

Example - 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
end

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

  • Parameters are captured at call time, not resolution time.
  • The body executes once on first access; the result is cached.
  • type() returns the declared type without executing the body.
  • Use resolve() for explicit evaluation.
  • Thunks resolve automatically when used in operations, comparisons, or passed to API functions.

Equality Operators

Type Tests

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.

Approximate Equality

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')
end

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

If-Empty Logical Operator

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 called

If-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_InvalidInput

Notes

  • Standard or treats only nil and false as falsey. Values like 0 and "" are considered truthy.
  • The ?? operator treats 0 and "" as falsey.
  • The ?? operator has the same precedence as or and and (lowest priority).
  • It is left-associative, like or and and.
  • Line splits immediately following ?? are intentionally forbidden.

If-Empty Postfix Operator

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

As a postfix operator, ?? has high precedence and evaluates before logical operators.

Ternary Operator

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:

  • Short-circuit evaluation: only the selected branch is evaluated
  • Precedence: same as or, and, ?, and ?? (lowest priority)
  • Right-associative: a ? b : c ? d : e parses as a ? b : (c ? d : e)
  • Use parentheses in expression lists: local a, b = (cond ? x : y), z

Ranges

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

Range Constructor

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

Constructor parameters:

  • range(Start, Stop)
    • Creates an exclusive range from Start up to, but not including, Stop.
  • range(Start, Stop, Inclusive)
    • When Inclusive is true, the Stop value is included.
  • range(Start, Stop, Inclusive, Step)
    • Explicit finite step value (positive or negative). 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")

Range Literals

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

Rules:

  • {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.
  • Reverse ranges (where Start > Stop) automatically infer a negative step.
  • Explicit steps are used exactly as written. Use a negative step for reverse stepped ranges.
  • Negative indices are supported and behave as expected for interval math.
  • Variable references are supported.
  • Expressions are supported in the start, stop and step positions.

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.

Collection-Aware Generic For

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)
end

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

Iteration with Ranges

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)
end

Anonymous 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
end

Constructor-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
end

range.toArray()

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

range.slice()

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}

Functional Iteration with each()

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))

Functional Range Methods

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.

filter(Predicate)

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)

reduce(Initial, Reducer)

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"

map(Transform, [ElementType])

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}

take(Count)

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}

any(Predicate)

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 42

all(Predicate)

Returns 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 = true

find(Predicate)

Returns 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 = 4

findIndex(Predicate)

Returns 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)

indexOf(Value)

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 = 2

Method 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 = 1540

String Slicing with Ranges

Strings 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..11

Out‑of‑bounds and empty ranges behave predictably:

  • Ranges entirely beyond the string length return an empty string.
  • Exclusive ranges where start equals stop yield an empty string.
  • Inclusive single‑element ranges return the corresponding character.

The Length Operator

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

Table Classification

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

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

  • A named field assignment such as Table.name = Value.
  • An indexed assignment under any non-numeric key, such as Table['name'] = Value, Table[true] = Value, Table[SomeTable] = Value or Table[SomeFunction] = Value.
  • A computed key that evaluates to a non-numeric value.
  • Assigning nil through a non-numeric key, even when no live entry results.
  • Resurrecting a non-numeric key whose value was previously set to nil.
  • A raw store through rawset().
  • Construction or duplication from a table containing non-numeric keys, including table literals such as { 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 counted

Use 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)   -- nil

Sequence-oriented Library Functions

Functions 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 table

The 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: permitted

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

Inspecting Classification and Size

kind = Table.kind()   -- 'sequence', 'sparse', 'associative' or 'mixed'
count = Table.size()  -- Every live entry in the array and hash parts

Table.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
end

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

  • Use array<Type> for dense, typed sequences.
  • Use pure numerical tables only where dynamic sequence elements are required.
  • Use table.size() to count entries and table.empty() to test emptiness.
  • Supply explicit numerical bounds when deliberately operating on slots in a classified table.
  • Replace accidental mixed literals such as { 10, 20, name = 'x' } with separate sequence and metadata values.

Duplicate Keys in Table Literals

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 keys

Constant 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 collision

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

Mutation During Iteration

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-dependent

Replacing 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 end

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

Table Slicing with Ranges

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 available

Out-of-Bounds and Empty Ranges:

  • Indices beyond the table length are clipped to valid bounds
  • Exclusive ranges where start equals stop return an empty table
  • Ranges entirely beyond the table length return an empty table
t = {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)

Membership Testing

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")
end

Membership tests work with both literal ranges and ranges stored in variables, including their step handling and inclusive/exclusive behaviour.


Choose Expressions

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'
end

Semantics:

  • The scrutinee expression (status) is evaluated exactly once.
  • Cases are tested in order; the first match wins.
  • 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:

  • Literal patterns: numbers, strings, booleans, and nil match using is semantics (no implicit type coercion).
  • Wildcard: _ matches anything and is typically used as a catch-all.
  • Relational patterns: < Expr, <= Expr, > Expr, >= Expr use normal relational operators.
  • Type patterns: <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>.
  • Empty-table pattern: {} matches only a table with no live associative or array entries. It does not match native arrays, nil, or populated tables.
  • Table patterns: non-empty { key = value, ... } patterns match tables using open-record semantics:
    • Extra keys on the scrutinee are ignored
    • Matching is shallow and compares values using is
  • Tuple patterns: (p0, p1, ...) match tuples created from multiple scrutinee values:
    • choose (a, b) from ... end evaluates a and b once and matches by arity
    • Tuple arity mismatches are compile-time errors
    • (x) is a parenthesised expression; a tuple requires a comma

A 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'
end

The 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'
end

Desugaring (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'
end

Wildcard as a catch-all (including NaN):

label = choose value from
   nil -> 'unset'
   _ -> 'set'
end

Nesting choose expressions:

msg = choose status from
   200 -> 'OK'
   else -> choose retry_count from
      0 -> 'Failed (no retry)'
      else -> 'Failed (will retry)'
   end
end

Conditionals 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'
end

Free-standing choose statements for flexible assignments and control flow:

choose state from
   'save' -> result = 'saved'
   'load' -> result = 'loaded'
   else -> raise('Invalid state')
end

Gotchas:

  • Order matters, especially with relational patterns: place more specific cases first (< 30 before < 60).
  • NaN never matches literal numeric patterns (nan is nan is false). Use _ or a guard if you need to handle it.
  • Table patterns are shallow: nested tables must be checked via guards or nested choose expressions.

Script Management

include

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

module core as mSys
module display as mGfx

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

Dynamic 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
@end

A 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

import 'name'
import 'name', 'other/name'
import 'name' as namespace

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

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

  • Top-level only: The 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.
  • Parse-time inlining: The imported code becomes part of the importing script's bytecode. This means imported code executes in the same context as if it were written directly in the importing file.
  • Import lists: Comma-separated imports are processed in order and each item is deduplicated by its resolved path, using the same rules as repeated single import statements.
  • Global exports: Global variables and functions declared with the global keyword in the imported file become accessible to the importing script.
  • Private locals: Local variables in the imported file remain private and do not pollute the importing script's namespace.

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 here

Conditional 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')
@end

Library 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)
   ...
end

The 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)
   ...
end

The 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)
end

import '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.

loadFile()

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.

exec()

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")
]])

Module Interface

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.

Type Conversion

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.

Buffer Handling

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.

Multiple Result Values

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.


Object Interface

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.

Object Creation

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.

object.init()

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.

Object Relationships

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.

object.children()

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.

Accessing Objects

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.

With Statement

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 unlocked

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

  1. Thread safety: In multi-threaded programs, locking prevents data corruption when objects are shared across threads.
  2. Performance: Locking an object keeps it in an accessible state, avoiding repeated access/release cycles for each field read or write. Basic read/write field accesses are approximately 2x faster when an object is pre-locked.

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
end

object.exists()

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

object.detach()

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.

object.free()

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().

Garbage Collection

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

Action and Method Calls

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

Field Interface

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.

object.getKey(), object.setKey()

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]')

object.state()

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.


Action & Method Subscriptions

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.


Processing Interface

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.

Signals

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))
end

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

signal()

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.

flush()

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.

task()

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
end

delayedCall()

Use 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)

Garbage Collector

processing.collect()

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:

  • For "step" mode: returns 1 if collection is not finished, 0 if finished
  • For other modes: returns 0 on success

Examples:

-- Full collection (default)
processing.collect()
processing.collect("full")

-- Incremental step collection
result = processing.collect("step", { stepSize = 100 })

processing.startCollector()

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.

processing.stopCollector()

Stops the automatic garbage collector. When called, it will cease to reclaim memory until startCollector() is called again.

processing.gcStats()

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")

Math Interface

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.

math.ldexp()

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.

math.random()

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.

math.clamp()

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.

math.round()

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.

math.min() and math.max()

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.


Strings Interface

The Lua-based strings interface is extended with a number of useful functions that are commonly required in programming. The following functions are included:

string.alloc()

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.

string.cap()

str.cap()

Recreate a string with the first character in upper case.

string.count()

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)

string.decap()

str.decap()

Recreate a string with the first character in lower case.

string.escXML()

str.escXML()

Escape str for an XML attribute value or content.

string.hash()

hash = str.hash([CaseSensitive])

Return a hash value for the string. The CaseSensitive parameter is optional and defaults to false.

string.byte()

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.

string.find()

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.

string.join()

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.

string.pop()

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"

string.replace()

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 = 1

string.toArray()

bytes = 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, 66

string.trim()

str.trim()

Trims whitespace from the left and right sides of a string.

string.rtrim()

str.rtrim()

Trims whitespace from the right side of a string.

string.split()

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

string.startsWith()

str.startsWith(Cmp)

Returns true if the string starts with Cmp.

string.sub()

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"

string.endsWith()

str.endsWith(Cmp)

Returns true if the string ends with Cmp.

string.unescapeXML()

str.unescapeXML()

Unescape XML entities (&lt;, &gt;, &amp;, &quot;, &apos;) in str, returning the decoded string. This is the reverse of escXML().

"&lt;div&gt;Hello &amp; World&lt;/div&gt;".unescapeXML()
-- Returns: "<div>Hello & World</div>"

Async Interface

Tiri supports a simplified threading model so as to minimise the potential problems occurring from their use. The functionality is as follows:

async.script(Statement, Callback)

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)

async.action|method(Object, Action, Callback, Key, Args...)

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.


Structure Interface

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.

Creating a Structure

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.

Declaring Native Structures

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>>
end

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

Additional Functionality

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 of struct.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.

Custom Structures

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


Regular Expression Interface

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.

Creating a Regex

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.

Compilation Flags

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.

Match Flags

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'

Regex Properties

Compiled regex objects provide the following read-only properties:

  • pattern: The original pattern string
  • flags: The compilation flags used
  • error: 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)
end

Performance Considerations

Regex 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
end

regex.escape()

escaped = 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))

regex.test()

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.

regex.findFirst()

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.

regex.findAll()

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

Each iteration yields:

  • start: The inclusive position of the match
  • stop: The exclusive position of the match
  • captures: 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]}')
end

Notes:

  • If no bracketed capture groups () are defined in the pattern, captures will be nil.
  • Zero-width matches advance by at least one character to prevent infinite loops.

regex.extract()

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

regex.match()

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)

regex.search()

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'

regex.replace()

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'

regex.split()

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' }

Array Interface

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.

Table Compatibility

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)
end

Array Syntax

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

Examples:

-- 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])  -- 4294967295

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

array.new()

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)

array.of()

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 Indexes

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 5

The # operator returns the array length:

total = #arr    -- Returns 10

array.type()

Returns 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>'

array.readOnly()

Returns true if the array is read-only, false otherwise. Read-only arrays are typically created by the system when wrapping external memory buffers.

array.table()

Returns a copy of the array in table format.

Array Manipulation Methods

array.push()

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.

array.pop()

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.

array.clear()

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.

array.resize()

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.

array.fill()

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 range

array.insert()

newLength = 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}

array.remove()

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 2

A Count of 0 does nothing and returns the current length. Negative Count values raise an error.

array.reverse()

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}

array.sort()

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}

Array Navigation Methods

Array Membership

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.

array.first()

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.

array.last()

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.

array.indexOf()

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 3

array.find()

value = 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 = 20

Array Data Extraction Methods

array.copy()

arr.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})

array.getString()

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'

array.setString()

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'

array.slice()

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 slice

array.concat()

str = 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'

array.clone()

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

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.

array.each()

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)  -- 60

array.map() and array.mapSame()

new_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')

array.findIndex()

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 = 1

array.filter()

new_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)

array.reduce()

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

array.any()

bool = 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)

array.all()

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 = true

Chaining Functional Methods

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

Logging

The following functions are included as standard so that messages can be logged to the console.

msg()

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

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.


Annotations

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

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.

Annotation Syntax

Annotations use the @ prefix and are placed immediately before a function declaration:

@AnnotationName
function myFunction()
   -- function body
end

Annotations can include named arguments in parentheses:

@Test(name="My Test Case", timeout=5)
function testSomething()
   -- test body
end

Supported 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
end

Annotations can also be placed on the same line using semicolons:

@BeforeEach; @Requires(network=true)
function setupNetwork()
   -- setup body
end

Function 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() end

Documentation Annotation

The @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 greet

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

Compile-Time Pre-Processor

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
@end

Supported 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")
@end

Nesting:

Compile-time conditionals can be nested:

@if(imported=false)
   @if(platform="windows")
      print("Running as main script on Windows")
   @end
@end

Standard Annotations

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

Test Framework Annotations

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

debug.fileSources Interface

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

debug.fileSources()

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
end

Example - 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}")
end

Notes:

  • The main script file is always at index 0
  • Imported files are assigned indices 1-254 in the order they are first encountered
  • Index 255 is reserved as an overflow fallback when the file limit is exceeded
  • File sources are deduplicated; importing the same file multiple times returns the same index
  • The fileIndex field in debug.getInfo() results corresponds to indices in this array

debug.anno Interface

The debug.anno interface provides programmatic access to function annotations at runtime.

debug.anno.set(func, annotations, [source], [name])

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 name
  • source: Source file path
  • annotations: Array of annotation tables

debug.anno.get(func)

Retrieves 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
end

debug.anno.list()

Returns 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')
end

Entry 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' }
         }
      }
   }
}

Event Subsystem

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)


Credits

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.