-
-
Notifications
You must be signed in to change notification settings - Fork 2
Tiri Reference Manual
Tiri is a Lua-based scripting language built on top of LuaJIT. Lua was chosen as our preferred programming language due to its extensive popularity amongst game developers, a testament to its low overhead, speed and lightweight processing when compared to common scripting languages. We have heavily customised our Lua implementation for full integration with Kōtuku, as well as extending it with many new language features that are often requested by Lua developers. This approach is known as a 'hard fork', meaning we do not maintain compatibility with Lua as a language.
Tiri is fully interoperable with Kōtuku's C++ APIs due to a requirement that all interfaces are fully described. You can therefore read our online API documentation with confidence that the interfaces are universal, irrespective of language.
This manual expands on the information in the existing Lua Reference Manual, and covers features that are exclusive to Tiri. The official Lua 5.1 Reference Manual is required reading if you are unfamiliar with the language, and a working knowledge of Lua is assumed from this point onward.
Tiri was designed with the following goals in mind:
- 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.
- Resources
- Usage
-
Lua Extensions
- Breaking Changes
- Bitwise Operations
- Bitshift Operators
- Unicode Operators
- Script Parameters
- Compound Assignment Operators
- Concatenation Assignment
- String Interpolation
- Postfix Increment
- Arrow Functions
- Continue Statement
- To-Be-Closed Variables
- Constant Variables
- Enum Declarations
- Safe Navigation Operator
- Type Annotations
- Sticky Types (Type Inference)
- Variable Scoping
- Result Management
- Exception Handling
- Deference
- Equality Operators
- Ranges
- Choose Expressions
- Script Management
- Module Interface
- Object Interface
- Action & Method Subscriptions
- Processing Interface
- Math Interface
- Strings Interface
- Thread Interface
- Structure Interface
- Regular Expression Interface
- Array Interface
- Logging
- Annotations
- Event Subsystem
- Credits
For more information on the usage of available classes and modules, please refer to the Kōtuku API at www.kotuku.dev.
For general information on the syntax provided by Lua, please read the following online manuals:
To run a Tiri script, use the Origo executable:
origo myfile.tiri
Named arguments can be passed to the Tiri script by following the script location with a series of variable values:
origo myfile.tiri name='John' surname=Bloggs
To debug a Tiri script, use the --log-api parameter.
For further information on available options, execute Origo with the --help parameter.
Tiri files can use the file extension .lua or .tiri for identification. Ideally, scripts should start with the comment -- $TIRI near the start of the document so that they can be correctly identified by the Tiri parser.
A number of extensions have been added (and some features removed) to Lua 5.2 in order to add value to the Tiri language. This section examines the major changes in regards to the published Lua Reference Manual.
The Package and OS libraries normally found in Lua are removed as they duplicate features already found in Kōtuku's Script, File and Time classes. The introspective debug library is also removed for the purpose of reducing size.
The following Lua/LuaJIT features are unsupported or modified:
-
~=not equal operator; replaced with!= -
==equality operator; replaced withis -
gotoand::label::statements; superceded bycontinue,break,defer -
bit.*library; replaced with native bitwise operators -
select(); replaced with result masks[_*] -
loadstring(); replaced withload() -
dofile(); replaced withloadFile() -
pcall()andxpcall(); replaced withtry … except -
getfenv(),setfenv(),gcinfo(),table.maxn(),unpack(); removed -
string.gsub(),string.match(),string.gmatch(); removed -
string.sub(); renamed tostring.substr() -
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
globalkeyword 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
Tiri supports C-style bitwise operators on 32-bit integers:
-
~xbitwise NOT -
a & bbitwise AND -
a | bbitwise OR -
a ^ bbitwise XOR
Results follow two's-complement 32-bit integer behaviour.
Compound assignment variants are not supported: &=, |=, ^= (bitwise) do not exist. Use x = x & y and x = x | y.
Examples:
-- Bitwise NOT, shifts left by 2
result = ~a << 2
-- Bitwise AND, then shifts right logical by 1
result = (a & b) >> 1
-- Masks out the lower 8 bits
result = a & 0xFFThe has operator tests whether a bitwise flag is set:
-
a has bis equivalent toa & 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')
endWhen both operands are constants, the expression is folded at compile time.
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 << 3evaluates as1 + (1 << 3)producing9. - The right-hand side of a shift is parsed as a full expression. For example,
8 >> 1 + 1produces2. - Shifts are left-associative when chained:
x << y << zparses as(x << y) << z.
Tiri supports Unicode alternatives for some multi-character ASCII operators as well as Unicode arithmetic operators. These provide a cleaner visual appearance in editors with good Unicode font support.
| Unicode | ASCII | Description |
|---|---|---|
≠ |
!= |
Not equal |
≈ |
None | Approximate equality |
≤ |
<= |
Less than or equal |
≥ |
>= |
Greater than or equal |
« |
<< |
Left shift |
» |
>> |
Right shift |
‥ |
.. |
Concatenation |
⁇ |
?? |
Null coalescing (if-empty) |
▷ |
:> |
Ternary separator |
⧺ |
++ |
Increment |
| Unicode | ASCII | Description |
|---|---|---|
× |
* |
Multiplication |
÷ |
/ |
Division |
↑ |
** |
Exponentiation (right-associative) |
Examples:
-- Comparison and logical operators
x = 5
x⧺ -- increment
name = user ⁇ 'Anonymous' -- null coalescing
max = a ≥ b ? a ▷ b -- comparison with ternary
msg = 'Hello' ‥ ' World' -- concatenation
flags = 1 « 4 -- left shift
-- Arithmetic operators
result = 10 × 5 ÷ 2 -- 25 (multiplication and division)
area = radius × radius × PI -- circle areaBoth ASCII and Unicode forms can be mixed freely in the same source file:
x = 5 * 3 × 2 ÷ 1 / 2 -- All forms work togetherArguments passed to the Tiri script can be accessed via the arg() function. In the following example either 'width' is returned or 1024 otherwise:
width = arg('width', 1024)
All arguments are managed as strings regardless of the type of the value.
Tiri adds C-style compound assignment operators for convenience:
-
+=,-=,*=,/=,%=on numeric values -
..=for string 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.
The ..= operator appends to an existing string:
s = 'a'
s ..= 'bc' -- s is 'abc'
s ..= tostring(1) -- s is 'abc1'Semantics mirror s = s .. rhs. The left-hand side is evaluated once and the RHS uses only its first return value.
Tiri supports Python-style f-strings for embedding expressions directly within string literals. F-strings are prefixed with f and use curly braces {} to delimit expressions.
Basic Syntax:
name = "World"
greeting = f"Hello {name}" -- "Hello World"
a = 10
b = 20
result = f'{a} + {b} = {a + b}' -- '10 + 20 = 30'Both single and double quotes are supported.
Expression Support:
Any valid Tiri expression can be used inside the braces:
-- Arithmetic
f"Result: {1 + 2 * 3}" -- "Result: 7"
-- Function calls
f"Upper: {string.upper('hello')}" -- "Upper: HELLO"
-- Table field access
user = {name = "Alice", age = 30}
f"{user.name} is {user.age}" -- "Alice is 30"
-- Method calls
f"Name: {obj:getName()}"
-- Nested tables in expressions
f"Point: {point.x}, {point.y}"
-- Nil handling with ??
f"Your name is {user.name ?? 'unknown'}"It is strongly recommended that nil handling is always employed with the ?? operator if the data originates from outside the script.
Automatic Type Conversion:
All interpolated expressions are automatically wrapped in tostring(), ensuring proper conversion of any value type:
f"{nil}" -- "nil"
f"{true}" -- "true"
f"{42}" -- "42"
f"{3.14}" -- "3.14"Escaping Braces:
To include literal braces in the output, double them:
f"Use {{braces}} for interpolation" -- "Use {braces} for interpolation"Restrictions:
- Long string syntax is not supported:
f[[...]]is invalid - Empty expressions
{}or whitespace-only expressions{ }are syntax errors
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.
=> 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.
Tiri adds a continue statement for all loop forms:
for {1 to 10} do
if i % 2 is 0 then continue end
-- odd numbers only
end
while cond do
if skip() then continue end
work()
end
repeat
if not ready() then continue end
until donecontinue skips the remainder of the current loop body and advances to the next iteration. In repeat … until, it jumps to the condition check.
The <close> attribute marks local variables for automatic cleanup via the __close metamethod when scope exits. This is more optimal than manually calling the garbage collector.
resource <close> = acquire_resource()
-- resource.__close(resource, nil) called automatically when scope endsThe __close metamethod receives two arguments: the object being closed and an error value (nil for normal exit, the error object during error unwinding).
Execution order:
- Close handlers run before
deferblocks (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
endThe <const> attribute marks local or global variables as constant, preventing reassignment after initialisation. This provides compile-time enforcement of immutability for variable bindings.
Syntax:
local max_size <const> = 100
local prefix <const>:str = "test_"
global DEBUG_MODE <const> = trueThe <const> attribute can appear before or after a type annotation:
local value <const>:num = 42 -- Attribute before type
local value:num <const> = 42 -- Type before attribute (also valid)Initialiser requirement:
Const variables must be initialised at declaration. Declaring a const without an initialiser is a compile-time error:
local x <const> -- Error: const 'x' requires an initialiserReassignment prevention:
Attempting to reassign a const variable produces a compile-time error:
local x <const> = 1
x = 2 -- Error: cannot assign to const local 'x'
global CONFIG <const> = {}
CONFIG = {} -- Error: cannot assign to const global 'CONFIG'Table contents are mutable:
The <const> attribute protects the variable binding, not the contents of the value. Table and object contents can still be modified:
local data <const> = { x = 1, y = 2 }
data.x = 999 -- Valid: modifying table contents
data.z = 3 -- Valid: adding new fields
data = {} -- Error: cannot reassign the bindingMultiple declarations:
In multiple variable declarations, each variable can independently have the <const> attribute:
local a <const>, b, c <const> = 1, 2, 3
-- a and c are const, b is mutable
b = 20 -- Valid
a = 10 -- Error: cannot assign to const local 'a'Scope behaviour:
Const variables follow normal scoping rules. A const in an outer scope can be shadowed by a new variable in an inner scope:
local x <const> = 1
do
local x = 2 -- Valid: shadows outer const
x = 3 -- Valid: inner x is not const
end
-- Outer x is still 1 and still constEnum declarations generate a group of compile-time numeric constants from a shared prefix. Their use is favoured over constant variable declarations, as the parser can replace enum references with their numeric value for efficiency. The enum prefix is used only to build the generated names; it does not create a runtime table, variable, or value.
Syntax:
[global] enum PREFIX {
MEMBER = 0,
NEXT_MEMBER,
}Each generated constant is named PREFIX_MEMBER and is substituted by the parser as a numeric constant. For example:
enum HTTP_METHOD {
GET = 0,
POST,
PUT,
DELETE,
}
assert(HTTP_METHOD_GET is 0)
assert(HTTP_METHOD_PUT is 2)The bare and global forms are equivalent:
enum MODE { FAST, SLOW }
global enum ERR { OKAY = 0, FAIL }If the first member has no explicit value, it starts at 0. Later members without explicit values increment from the
previous member value:
enum SAMPLE {
FIRST, -- SAMPLE_FIRST = 0
SECOND, -- SAMPLE_SECOND = 1
CUSTOM = 10, -- SAMPLE_CUSTOM = 10
AFTER, -- SAMPLE_AFTER = 11
}Explicit values must be integer literals. Signed decimal and hexadecimal forms are accepted:
enum MASK {
NONE = 0x0,
READ = 0x1,
WRITE = 0x2,
ALL = 0x3,
}Enum declarations must appear at the top-level scope of a script or imported file. They are not permitted inside
functions or statement blocks, and local enum is invalid.
Enum prefixes and member names must use uppercase identifier style. The declaration must contain at least one member
and cannot use a prefix type annotation, <const>, or <close> attribute. The enum word is reserved and cannot be
used as an identifier.
The safe navigation operator (?.) provides null-safe access to object fields, methods, and indexes.
obj?.field -- Safe field access
obj?.method() -- Safe method call
obj?[key] -- Safe index access
obj?.a?.b?.c -- ChainingIf the object is nil, the safe navigation operator returns nil without attempting to access the field/method/index. This prevents "attempt to index a nil value" errors.
Important: The safe navigation operator only checks for nil. Other falsey values like false, 0, or "" are treated as valid objects and field access proceeds normally.
-- Safe field access
user = nil
name = user?.name -- Returns nil instead of error
user2 = { name = "Alice" }
name2 = user2?.name -- Returns "Alice"
-- Chaining
city = user?.profile?.address?.city -- Returns nil if any level is nil
-- With default values using if-empty operator
displayName = user?.name ?? "Guest" -- "Guest" if user or name is nil
-- Safe method calls
result = obj?.calculate() -- Returns nil if obj or calculate() is nil
-- Multiple return values preserved
a, b = obj?.getTwoValues() -- Both a and b will be nil if obj or getTwoValues() is nil
-- Safe index access
value = table?[key] -- Returns nil if table is nilTiri supports optional type annotations on function parameters to surface static analysis diagnostics during parsing. Annotations follow the parameter name after a colon and constrain the expected argument type.
function process(Path:str, Count:num, Options:table)
-- Path must be a string
-- Count must be a number
-- Options must be a table
endUntyped parameters omit the annotation:
function mixed(Untyped, Typed:bool): <any,bool>
return Untyped, Typed
end| Name | Notes |
|---|---|
any |
Accepts any type |
nil |
Explicit nil |
num |
Numeric values |
str |
Text strings |
bool |
Boolean values |
table |
Tables and dictionaries |
func |
Callable values |
thread |
Coroutines |
obj |
Kōtuku objects (userdata) |
Unknown type names raise diagnostics during parsing with the UnknownTypeName error code.
Tiri employs a lazy type inference system that commits local variables 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
nilvalue acts as a placeholder that does not commit the type. - Explicit type annotations are optional, primarily useful for pre-declarations or guarding function parameters.
- The
anytype annotation opts out of type fixing for variables that genuinely need variant behaviour.
Benefits:
- Error detection: Type mismatches are reported as compile-time errors, catching bugs before execution
- Code clarity: Reading code becomes easier when variables maintain consistent types
- Optimisation potential: The JIT can generate more efficient code when variable types are known to be stable
When a local variable 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.
Type annotations can be added to local variable 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 (or boolean), num (or number), str (or string), table,
array, func (or function), obj (or object).
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()The any type is the escape hatch when you need flexibility. However it does come with disadvantages: 1. It disables the type safety benefits for that variable; 2. The JIT compiler has fewer optimisation opportunities when dealing with variant types.
The nil value has special status in the type system, allowing variables to be cleared to their empty state regardless of their type.
Uninitialised variables: Variables declared without an initial value start as nil with no type commitment. The type is fixed when the first non-nil value is assigned:
local result -- result is nil (type uncommitted)
result = nil -- still uncommitted
result = calculate() -- result is now fixed to whatever calculate() returns
result = "fallback" -- Error if calculate() returned a non-string typeNil as a clear operation: Once a variable has a fixed type, assigning nil clears the value but preserves the type constraint. The next non-nil assignment must still match the fixed type:
name = "Alice" -- Assign a string to new variable 'name'
name = nil -- Clear the value
name = "Bob" -- Valid
name = 42 -- Error: Cannot assign 'number' to variable of type 'string'This design allows variables to represent "optional" or "nullable" values naturally without requiring a separate nullable type annotation, while still enforcing type consistency for non-nil values.
Function parameter type annotations serve as guards that validate incoming arguments. Unlike local variable type fixing (which is lazy), parameter annotations are checked at call-time:
function greet(Name: str, Times: num)
for i in {1 into Times} do
print("Hello, " .. Name)
end
end
greet("World", 3) -- OK
greet(42, 3) -- ERROR: expected 'string' for parameter 'Name'Parameter annotations are particularly important for public APIs and library functions where input validation is critical. They provide documentation and runtime safety at the function boundary.
Mixing typed and untyped parameters:
function process(Data, Count: num, Validate: bool)
-- Data accepts any type (no annotation)
-- Count must be a number
-- Validate must be a boolean
endUntyped parameters remain fully dynamic and accept any value, preserving flexibility where needed.
Tiri supports optional return type declarations on functions to enable compile-time validation of returned values. Additionally, more optimal code can be generated through the narrowing of return types. Return types are declared after the parameter list using a colon followed by the type (or a tuple of types for multiple returns).
Single return type:
function calculate_area(Radius: num): num
return math.pi * Radius * Radius
end
function get_name(): str
return "Alice"
endMultiple return types:
Functions that return multiple values can declare each type in angle brackets:
function divide(A: num, B: num):<num, num>
return math.floor(A / B), A % B -- quotient and remainder
end
function parse_header(Line: str):<str, str, num>
-- Returns name, value, and position
return name, value, pos
endVariadic return types:
When the last return value can repeat (e.g., returning variable numbers of values), use ... after the last type:
function get_values():<num, ...>
return 1, 2, 3, 4, 5 -- First is num, rest are also num
endWhen no explicit return type is declared, Tiri infers the return type from the function's return statements using a
"first-wins" rule:
- The first
returnstatement with a non-nil value establishes the expected type(s) - Subsequent
returnstatements must be consistent with the established types - Returning
nilis always permitted (it acts as "no value" and doesn't establish a type)
function find_user(Id: num)
user = database.lookup(Id)
if not user then return nil end -- nil doesn't establish a type
return user -- First non-nil return: fixes type to whatever user is
end
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'
endType mismatch errors:
function broken()
if condition then
return "text" -- Establishes type as 'str'
end
return 42 -- Error: inconsistent return type, expected 'str', got 'num'
endUse any to opt out of return type checking for functions that genuinely return different types:
function json_decode(Text: str):<any, num>
-- Returns decoded value (could be table, string, number, bool, or nil)
-- and the position after parsing
return decoded_value, pos
end
function dynamic_result(Mode: str): any
if Mode is "number" then return 42 end
if Mode is "string" then return "hello" end
return { key = "value" }
endRecursive functions (functions that call themselves) require explicit return type declarations. This is because the type inference cannot determine the return type before analysing the function body, which contains the recursive call:
-- Error: recursive function 'factorial' must have explicit return type declaration
function factorial(N: num)
if N <= 1 then return 1 end
return N * factorial(N - 1)
end
-- Correct: explicit return type declared
function factorial(N: num): num
if N <= 1 then return 1 end
return N * factorial(N - 1)
endThis requirement also applies to mutually recursive functions (function A calls function B, which calls function A).
Declaring return types provides several advantages:
- Documentation: Return types serve as built-in documentation for function contracts
- Error detection: Type mismatches in return statements are caught at parse time
- Call-site inference: When a function has declared return types, variables assigned from calls can infer their types
-
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'Tiri enforces local-by-default variable scoping, a significant departure from standard Lua where undeclared variables are implicitly global. This design prevents accidental pollution of the global namespace and catches common programming errors at parse time.
Variables and functions are local by default. Any assignment to an undeclared variable creates a new local in the current scope:
counter = 0 -- Creates local 'counter'
name = "Alice" -- Creates local 'name'
bare_var -- Invalid, results in an error from the parser
a, b, c -- Invalid: use 'local a, b, c' or an assignment instead
function example() -- Function is local
total = 100 -- Creates local 'total' in function scope
counter += 1 -- Modifies 'counter' in the outer scope
endThe local keyword remains available for explicit declarations and is required when initialising multiple variables on one line:
local a, b, c = 1, 2, 3 -- Multiple locals on one line
local config -- Explicit nil initialisationTo create or access global variables, use the global keyword. It is recommended that global declarations appear before any reference to the variable:
global DEBUG_MODE = true
global APP_VERSION
global function configure() -- Global function is accessible in the parent context
APP_VERSION <const> = "1.0" -- Assigns to the global and makes it immutable
global LATE_CREATION = true -- Valid, but won't exist until this function runs
global DEBUG_MODE = false -- Modifies the original global without shadowing it
local DEBUG_MODE = true -- Valid; this local shadows the global within this scope
print(DEBUG_MODE) -- Prints local 'true'
endIt is recommended that global variables follow our naming conventions, which are UPPER_CASE for constants and glCamelCase for mutable globals. Exceptions may apply - but sticking to conventions helps with code readability.
Global Functions:
Declaring a function with the global keyword allows the parent scope to access it. This feature allows library scripts to expose functions and variables to the caller.
Global Declaration Rules:
-
globaldeclarations 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
globalkeyword can appear at any scope level, but the variable becomes globally visible
The blank identifier _ allows you to explicitly ignore values in assignments and loop variables:
-- Ignore error from function call
file, _ := openFile("data.txt")
-- Ignore multiple return values
_, _, result := getValues()
-- Loop without index
for _, value in ipairs(items) do
print(value)
end
-- Works with pairs() too
for _, v in pairs(table) do
process(v)
end
-- Multiple positions
local x, _, y = 1, 2, 3 -- x=1, y=3Notes:
- 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)
The pipe operator (|>) provides a functional programming style for chaining function calls. It passes the result of the left-hand side expression as the first argument(s) to the right-hand side function call.
Basic Syntax:
result = expression |> function_call()The expression on the left is evaluated first, and its result is prepended to the arguments of the function call on the right. This allows for readable left-to-right data flow instead of deeply nested function calls.
Multi-Value Support:
When the left-hand side returns multiple values (e.g., from a function call), all values are forwarded as arguments:
local function get_bounds()
return 10, 20
end
local result = get_bounds() |> math.max() -- math.max(10, 20) = 20Result Limiting:
Use |N> syntax to limit the number of return values forwarded from the left-hand side:
local function get_many()
return 1, 2, 3, 4, 5
end
local result = get_many() |2> math.max() -- math.max(1, 2) = 2Chaining:
Pipes can be chained for multi-step transformations:
local function double(x) return x * 2 end
local function square(x) return x * x end
local result = 3 |> double() |> square() -- square(double(3)) = 36Real-World Examples:
-- Data transformation pipeline
local function load_config(path)
return [*_]obj.new('file', { path = path, flags = '!READ' })
end
local function parse_json(file)
local content = [_*]file.acRead()
return json.decode(content)
end
local function validate(config)
assert(config.version, "Missing version")
return config
end
local config = "config.json" |> load_config() |> parse_json() |> validate()-- Processing user input with additional arguments
local function clamp(value, min, max)
return math.max(min, math.min(max, value))
end
local safe_value = tonumber(arg('value', '50')) |> clamp(0, 100)Notes:
- The right-hand side must be a function call;
x |> 5is a syntax error - Pipe has higher precedence than logical operators (
and,or) but lower than comparison operators - Right-associative:
a |> b() |> c()evaluates asa |> (b() |> c()) - For method calls, use full syntax:
obj |> obj:method()(notobj |> method())
When the left-hand side of a pipe is a range literal, the pipe automatically iterates, calling the right-hand function for each value:
{1 to 5} |> print -- Prints 1, 2, 3, 4
{1 into 5} |> print -- Prints 1, 2, 3, 4, 5
-- With anonymous function
{0 to 10} |> function(i)
if i % 2 is 0 then print(i, 'is even') end
endThe pipe returns the original range, enabling chaining:
{1 to 10} |> i => log('Processing', i)
|> i => validate(i)
|> i => store(i)Return false from the callback to terminate early:
{1 to 1000} |> i => do
if found_target(i) then
print('Found at', i)
return false -- Stop iterating
end
endPipe Iteration Notes:
- Only range literals (
{start to stop}) trigger automatic iteration at parse time - For ranges stored in variables, use the
:each()method directly:r:each(func) - Chained pipes continue iteration:
{1 to 5} |> func1 |> func2calls both functions for each value
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=nilWith Method Calls:
obj = {
method = function(self)
return 10, 20, 30
end
}
second = [_*]obj:method() -- second=20Use 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;
[_*]variableis a syntax error - Maximum mask length is 64 positions
Tiri supports an exception handling mechanism that recognises that errors can come in one of two forms:
- Internal errors, as generated by the Tiri language and the runtime engine.
- External errors, as generated by system API calls.
In normal operation, these distinct patterns are handled differently, with external errors requiring manual handling. We designed the exception handling system to resolve this problem by unifying all error forms, providing a simple syntax for error management, and supporting exception handlers to deal with errors when they arise.
Structured exception handling is provided through the try-except syntax, and is the primary means offered for exception management.
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
pcallapproach, especially when JIT compiled.
Basic Syntax:
try
risky_operation()
[ except [e] [ when Exception, ... ] ]
print("Error: " .. e.message)
[ except... ]
[ success ]
endThe except blocks catch exceptions raised within the preceding try block. Multiple except handlers are permitted, each with their own optional filter using the when clause. An unfiltered handler is recommended at the end to catch unexpected errors. The exception parameter e (or any name of your choosing) is a table containing information about the error:
| Field | Description |
|---|---|
code |
The ERR code, e.g. ERR_Failed. ERR_Exception is the default for Lua errors. |
message |
Human-readable error description. |
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.
There is no support for finally blocks; the standard pattern is to use defer statements or __close within the try block for cleanup actions instead.
Exception Handling:
Exceptions come in two flavours: Error codes (16-bit integer constants) and string messages.
- Error messages originate from
error()or Tiri runtime errors. They have a default error code ofERR_Exception. - Error codes are returned by API calls and can be triggered by calling
raiseandcheck. Preset error codes are integer constants following theERR_naming convention, e.g.ERR_Okay,ERR_FailedandERR_Args. The full list of error codes are listed 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
error("Lua error")
except e
assert(e.code is ERR_Exception)
success
print("No error occurred")
end
try
raise ERR_Failed
except e when ERR_Failed
print('Caught ERR_Failed')
endLazy Exception Syntax:
Suppressing exceptions is permitted with the simplest form of syntax:
try
potentially_failing_operation()
endSimple catch-alls are also possible:
try
risky_operation()
except
print("An error occurred")
endRe-throwing Exceptions:
Call error() within an except block to propagate the exception to an outer handler:
try
try
error("Original error")
except e
-- Log and re-throw
msg(e.message)
error(e)
end
except e
-- Catches the re-thrown exception
print("Caught re-thrown error")
endFiltered Exception Handling:
Use the when clause to catch specific ERR codes. Multiple codes are permitted per when clause, up to a limit of 4 per handler.
try
raise ERR_Args
except e when ERR_Args
print("Invalid arguments")
except e when ERR_Read, ERR_Write, ERR_Seek
print("Operation failed")
except e
print("Unexpected error: " .. e.message)
endError codes are just constant integers, so custom codes can also be created and thrown through use of the raise function. 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 ... endControl Flow in Try Blocks:
return, break, and continue work correctly within try blocks:
function find_value()
try
result = search()
return result -- Returns from function normally
except e
return nil -- Returns nil on error
end
end
for i in {0 to 10} do
try
if i is 5 then break end -- Exits loop
if i % 2 is 0 then continue end -- Skips to next iteration
process(i)
except e
-- Handle error and continue loop
end
endStack Traces:
By default, stack trace information is not captured for performance reasons. Use the <trace> attribute to enable stack trace capture when an exception occurs:
try<trace>
risky_operation()
try
error("Inner error") -- No trace captured, missing <trace> from prior try statement
except e
assert(e.trace is nil)
error(e)
end
except e
print(e.stackTrace) -- Formatted traceback string
-- Or access individual frames
for i, frame in ipairs(e.trace) do
print(f"{frame.source}:{frame.line}: in {frame.func ?? 'anonymous'}")
end
endThe trace field contains an array of frame tables, each with the following fields:
| Field | Description |
|---|---|
source |
Source file name (may be nil). |
line |
Line number (0 if unknown). |
func |
Function name (may be nil for anonymous functions). |
The stackTrace field provides a pre-formatted string in the standard "stack traceback:" format, suitable for logging or display.
Exceptions can be raised at runtime by using the following features:
assert() is used to validate conditions and raise exceptions when the condition is false. It accepts two parameters - the first being the condition to evaluate, and the second an optional error message to include in the exception. Example:
assert(x > 0, 'x must be greater than zero')The message parameter is not evaluated if the condition was true. This allows expensive computations (e.g. long string concatenation) to be written without incurring overhead during normal runtime operations.
check is the equivalent of an assert() for ERR codes. Any non-safe error code passed to this function will raise an exception that includes a readable message matching the ERR code.
Incoming parameters are returned without modification, allowing check to operate seamlessly in function call chains. Example:
err, bytes_read = check file.acRead(file, buffer)The following ERR codes will not raise an exception: Okay, False, LimitedSuccess, Cancelled, NothingDone, Continue, Skip, Retry, DirEmpty
raise will raise an exception immediately from an ERR code; for example raise ERR_Failed. Unlike check, all codes have coverage, including minor codes. The error code will also be propagated to the Script object's Error field for reporting back to the C/C++ client program.
Raise also accepts an optional second parameter for a custom error message, e.g. raise ERR_Failed, 'Operation failed due to X'. If you only want to supply a message, use raise 'Operation failed due to X'; the error code defaults to ERR_Exception.
The raise and check keywords will work for custom error codes defined by the client, the only requirement is that they are integer constants. Use codes in the range 8000 to 10000 to avoid conflicts with Kōtuku system codes.
error() is the standard function for raising customised exceptions with the generic error code ERR_Exception. It should be used when raising an exception with a custom error message, e.g. error('I have failed.').
Passing an exception table to error() causes it to rethrow the exception to the next capture block.
The defer statement schedules a function to execute when the enclosing scope exits. Deferred functions execute in LIFO order (last deferred, first executed) and are guaranteed to run on normal scope exit, early return, break, or continue.
Basic syntax (no arguments):
function example()
file = io.open("data.txt")
defer
file:close()
end
-- ... use file ...
end -- file:close() executes hereWith argument snapshot:
function example()
status = "initial"
defer(s)
print("Final status: " .. s)
end(status)
status = "modified"
end -- prints "Final status: initial"Multiple defers execute in reverse order:
defer
print("third")
end
defer
print("second")
end
defer
print("first")
end
-- Output: "first", "second", "third"Execution guarantees:
- Defers execute when leaving scope via normal flow,
return,break, orcontinue - 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 provide lazy evaluation of expressions, delaying computation until the value is actually accessed. This is particularly useful for avoiding expensive computations in conditional parameters or logging statements that may not be executed.
Basic syntax:
<type{ expression }> or <{ expression }>The expression inside <{ }> is not evaluated immediately. Instead, evaluation is deferred until the value is accessed through standard API functions or explicitly resolved.
Consider this common pattern:
some_function(conditional_value, "Error occurred: " .. expensive_debug_info())The string concatenation and expensive_debug_info() are processed immediately irrespective of whether some_function() will use the computed variable. With deferred expressions:
some_function(conditional_value, <{ "Error occurred: " .. expensive_debug_info() }>)The expensive computation only occurs if the assertion fails and the message is accessed.
Type Inference:
Tiri automatically infers types from deferred expressions:
str = <{ 'hello' }> -- Inferred as string
num = <{ 42 }> -- Inferred as number
bool = <{ true }> -- Inferred as boolean
tbl = <{ {} }> -- Inferred as table
result = <{ a + b }> -- Inferred as number (arithmetic)
msg = <{ s .. t }> -- Inferred as string (concatenation)Explicit Type Annotation:
When type cannot be inferred (such as function calls), use explicit type annotation:
<str{ getValue() }>
<num{ compute() }>The resolve() Function:
Use resolve() to explicitly evaluate a deferred expression:
lazy = <{ expensive_computation() }>
-- Later, when you need the value:
value = resolve(lazy)The resolve() function returns non-deferred values unchanged, making it safe to call on any value.
Single Evaluation:
Once evaluated, the result is cached in the variable. Subsequent access returns the cached value without re-evaluation:
count = 0
x = <{ count++; count }>
print(resolve(x)) -- Prints 1, increments count and caches result back in x
print(resolve(x)) -- Prints 1 again, uses cached resultSingle-evaluation is a super-power for deferred expressions if used correctly, and offers creative programming opportunities. For instance:
glSelf = <obj{ obj.find('self') }> -- Executes once, does nothing if never used.
activate_object = <num{ object.acActivate() }> -- On resolution stores the ERR code permanentlyImportant Notes:
- 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
Thunk functions extend deferred expressions to support parameterised lazy evaluation. While deferred expressions wrap a single expression, thunk functions allow you to define reusable lazy computations with parameters.
Syntax:
thunk name(params):type
-- body
return value
endThe thunk keyword declares a function that, when called, is primed by capturing its arguments and returns a deferred value. The body is not executed until the result is accessed through a read operation or explicitly resolved.
Example - Lazy Database Query:
thunk fetch_user(id:num):table
print("Fetching user " .. id)
return database.query("SELECT * FROM users WHERE id = ?", id)
end
user = fetch_user(123) -- Function primed in user and not executed yet
print(user.name) -- Query executes here, prints "Fetching user 123"
print(user.email) -- Uses cached result, no re-queryExample - Conditional Computation:
thunk expensive_report(year:num):str
return generate_annual_report(year) -- Only runs if report is actually used
end
report = expensive_report(2024) -- Store reference to thunk with 2024 parameter value
if user_requested_report then
print(report) -- Report generated only when needed
endAnonymous Thunks:
Anonymous thunks can skip the invocation process if they are parameterless:
local isAvailable = thunk():bool -- Prepare isAvailable without executing the body
-- Do something --
return true
end
print(isAvailable) -- Resolved and cached here, no need to prime with isAvailable()
If one or more parameters are specified in the thunk signature then this feature does not apply.
Key Characteristics:
- 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.
The ≈ operator compares two numeric values using an inclusive absolute tolerance of 1e-5. It returns true when Left - Right is between -1e-5 and 1e-5, matching the rule math.abs(Left - Right) <= 1e-5 without evaluating either operand more than once.
if 0.33333 ≈ 1 / 3 then
print('close enough')
endThere is no ASCII alias for this operator. Use not (a ≈ b) when an approximate inequality check is required. Operands must be numeric; non-numeric values fail through the normal arithmetic path. NaN is never approximately equal, and infinities follow the subtraction rule, so math.huge ≈ math.huge is false because the intermediate difference is NaN.
Tiri adds a ?? operator that extends the falsey semantics of the or operator. The ?? operator treats the following values as falsey: nil, false, 0, and "" (empty string). This feature was introduced so that it would be easier to write shorthand for dealing with values that are empty. In addition, the right-hand side is not evaluated if the left-hand is true, leading to faster code.
Examples:
-- Standard 'or' vs '??' with zero
a = 0 or "default" -- a is 0 (zero is truthy by default)
b = 0 ?? "default" -- b is "default" (zero is falsey in ??)
-- Standard 'or' vs '??' with empty string
c = "" or "fallback" -- c is "" (empty string is truthy by default)
d = "" ?? "fallback" -- d is "fallback" (empty string is falsey in ??)
-- Chaining
value = "" ?? 0 ?? "final" -- value is "final" (both "" and 0 are falsey)
-- Short-circuit evaluation
function expensive()
error("Should not be called")
end
result = "valid" ?? expensive() -- result is "valid", expensive() is never calledIf-Empty Conditional Shorthand
The ?! operator can be used as a conditional operator for routines and control flow. For instance if not value then error() end can now be written as simply as:
value ?! error('Value is empty')Use ?! when the same extended falsey check should guard control flow with return, break, continue,
raise or check:
user_input ?! return ERR_InvalidInputNotes
- Standard
ortreats onlynilandfalseas falsey. Values like0and""are considered truthy. - The
??operator treats0and""as falsey. - The
??operator has the same precedence asorandand(lowest priority). - It is left-associative, like
orandand. - Line splits immediately following
??are intentionally forbidden.
If the ?? operator is used in the absence of a value to its right-hand-side, it is treated as a postfix operator. In this mode it will return a boolean indicating whether a value is present, using extended falsey semantics. It returns false for nil, false, 0, and "" (empty string), and true for all other values.
-- Basic usage
if comment?? then
print("Comment exists")
end
-- In expressions
greeting = name?? and "Hello, " .. name or "Hello, Guest"
-- With field access and expressions
if config.timeout?? then ... end
if (x + y)?? then ... endAs a postfix operator, ?? has high precedence and evaluates before logical operators.
The use of a single ? can be complemented with a C-style ternary conditional operator :> that creates an expression equivalent to an if-then-else statement.
result = condition ? true_expr :> false_expr
The standard ternary evaluates condition using the same falsey semantics as if: only nil and false are falsey. If truthy, it returns true_expr; if falsey, it returns false_expr. Only one branch is evaluated to ensure run-time efficiency.
status = user ? "logged in" :> "guest"
max = a > b ? a :> b
msg = error ? "Error: " .. error :> "Success"Use ?? :> when the condition should use extended falsey semantics, matching the ?? operator: nil, false, 0, "", and empty arrays are falsey.
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 :> eparses asa ? b :> (c ? d :> e) - Use parentheses in expression lists:
local a, b = (cond ? x :> y), z
Tiri provides a dedicated range type, implemented as userdata, for expressing numeric intervals. Ranges are
primarily used for iteration, slicing and membership tests, and integrate with the language via both constructor
functions and literal syntax.
Ranges are immutable. Once created, a range's start, stop, step and inclusive properties cannot be modified.
The range() constructor function creates a range object:
r1 = range(0, 5) -- Exclusive: 0,1,2,3,4
r2 = range(0, 5, true) -- Inclusive: 0,1,2,3,4,5
r3 = range(0, 10, false, 2) -- Exclusive with step: 0,2,4,6,8
r4 = range(10, 0) -- Reverse: 10,9,8,...,1 (step inferred as -1)Constructor parameters:
-
range(Start, Stop)- Creates an exclusive range from
Startup to, but not including,Stop.
- Creates an exclusive range from
-
range(Start, Stop, Inclusive)- When
Inclusiveistrue, theStopvalue is included.
- When
-
range(Start, Stop, Inclusive, Step)- Explicit step value (positive or negative).
Stepmust be a non‑zero integer.
- Explicit step value (positive or negative).
All arguments must be integers (or integer‑valued numbers). Passing non‑integer or nil values will raise an error.
Range properties and helpers:
-
r.start/r.stop/r.step/r.inclusive— access range parameters. -
#rorr.length— number of elements in the range.
Ranges report their type as range:
r = range(0, 5)
assert(type(r) is "range")Tiri adds literal syntax for constructing ranges using braces and word separators:
r1 = {0 to 5} -- Exclusive: 0,1,2,3,4
r2 = {0 into 5} -- Inclusive: 0,1,2,3,4,5
r3 = {10 to 1} -- Reverse exclusive: 10,9,8,...,2
r4 = {5 into 1} -- Reverse inclusive: 5,4,3,2,1
r5 = {0 to 10 by 2} -- Exclusive with step: 0,2,4,6,8
r6 = {10 into 0 by -2} -- Reverse inclusive with step: 10,8,6,4,2,0Rules:
-
{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 negativestep. - 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.
Ranges support Tiri's generic for loops via a special iterator protocol. The recommended style is to use range literals directly in the loop header:
for i in {1 to 6} do
print(i) -- 1,2,3,4,5
end
for i in {1 into 5} do
print(i) -- 1,2,3,4,5
end
for i in {5 to 1} do
print(i) -- 5,4,3,2 (exclusive)
end
for i in {5 into 1} do
print(i) -- 5,4,3,2,1 (inclusive)
endAnonymous loops omit the need for a variable store:
for {0 to 10} do
count++
end
You can also iterate using a range stored in a variable by calling it to obtain the iterator triple:
r = {0 to 5}
sum = 0
for i in r() do
sum += i -- 0+1+2+3+4
endFor constructor‑based ranges, the iterator is obtained in the same way:
for i in range(0, 10, false, 2)() do
-- i = 0,2,4,6,8
end
for i in {0 to 10 by 2} do
-- i = 0,2,4,6,8
end
for i in {10 into 0 by -2} do
-- i = 10,8,6,4,2,0
endReturns the range as an array of integers that match its sequencing and step attributes.
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.
Examples:
-- Table slicing
t = {10, 20, 30, 40, 50}
result = range.slice(t, {1 to 4}) -- {20, 30, 40}
result = range.slice(t, {0 into 4}) -- {10, 20, 30, 40, 50}
-- String slicing
s = "Hello, World!"
result = range.slice(s, {0 to 5}) -- "Hello"
result = range.slice(s, {-6 into -1}) -- "World!"
result = range.slice(s, {-6 to -1}) -- "World"
-- With stepped ranges (requires constructor)
t = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
r = range(0, 9, true, 2)
result = range.slice(t, r) -- {0, 2, 4, 6, 8}
-- Reverse slicing
result = range.slice(t, {9 into 0}) -- {9, 8, 7, 6, 5, 4, 3, 2, 1, 0}Ranges provide an each() method for functional-style iteration. The method accepts a callback function that is
invoked once for each value in the range:
sum = 0
{1 to 6}:each(Value => sum += Value)
-- sum is now 15 (1+2+3+4+5)The callback receives a single argument: the current value in the iteration sequence.
Early Termination
The callback can return false to terminate iteration early:
sum = 0
r = {1 to 100}
r:each(Value => do
if Value > 5 then return false end
sum += Value
end)
-- sum is 15 (iteration stopped after 5)Returning true or nil (no return) continues iteration normally.
Method Chaining
The each() method returns the original range object, enabling method chaining:
r = {1 to 6}
r:each(Value => print(Value))
:each(Value => process(Value))Usage with Constructor and Literals
The each() method works with both range literals and constructor-based ranges:
-- With constructor (can chain directly)
range(0, 10, false, 2):each(Value => print(Value))
-- With literal (must store in variable first due to parser limitations)
r = {0 to 10}
r:each(Value => print(Value))Ranges provide several functional programming methods for transforming, filtering, and querying data. These methods offer a declarative style for working with numeric sequences.
Returns an array containing only values for which the predicate function returns true:
-- Get even numbers from 1 to 10
evens = {1 to 11}:filter(i => i % 2 is 0)
-- evens = {2, 4, 6, 8, 10}
-- Filter with more complex logic
scores = {0 to 100}:filter(function(score)
return score >= 60 and score <= 80
end)Folds all values in the range into a single accumulated result:
-- Sum numbers 1 through 5
sum = {1 into 5}:reduce(0, (acc, i) => acc + i)
-- sum = 15
-- Calculate factorial of 5
factorial = {1 into 5}:reduce(1, (acc, i) => acc * i)
-- factorial = 120
-- Build a comma-separated string
csv = {1 to 4}:reduce("", function(acc, i)
if acc is "" then return tostring(i) end
return acc .. "," .. tostring(i)
end)
-- csv = "1,2,3"Returns an array with each value transformed by the given function:
-- 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"}
-- Calculate squares
squares = {1 into 5}:map(i => i * i)
-- squares = {1, 4, 9, 16, 25}Returns a table containing the first Count values from the range:
-- Get first 3 values
first3 = {1 to 100}:take(3)
-- first3 = {1, 2, 3}
-- Works with reverse ranges
last3 = {10 to 0}:take(3)
-- last3 = {10, 9, 8}
-- If count exceeds range length, returns all available values
all = {1 to 4}:take(10)
-- all = {1, 2, 3}Returns true if any value in the range satisfies the predicate (short-circuits on first match):
-- Check if any value is greater than 5
hasLarge = {1 to 10}:any(i => i > 5)
-- hasLarge = true
-- Check if any value is negative
hasNegative = {0 to 100}:any(i => i < 0)
-- hasNegative = false
-- Efficient: stops at first match
found = {1 to 1000000}:any(i => i is 42)
-- Only checks values 1 through 42Returns true if all values in the range satisfy the predicate (short-circuits on first failure):
-- Check if all values are positive
allPositive = {1 to 10}:all(i => i > 0)
-- allPositive = true
-- Check if all values are less than 5
allSmall = {1 to 10}:all(i => i < 5)
-- allSmall = false (fails at 5)
-- Empty ranges return true (vacuous truth)
emptyCheck = {5 to 5}:all(i => false)
-- emptyCheck = trueReturns the first value that satisfies the predicate, or nil if none found:
-- Find first value greater than 5
first = {1 to 10}:find(i => i > 5)
-- first = 6
-- Find first even number
firstEven = {1 to 10}:find(i => i % 2 is 0)
-- firstEven = 2
-- Returns nil when not found
notFound = {1 to 10}:find(i => i > 100)
-- notFound = nil
-- Works with reverse ranges
fromEnd = {10 to 0}:find(i => i < 5)
-- fromEnd = 4Method Return Types:
| Method | Returns |
|---|---|
filter() |
Table |
reduce() |
Single value (type depends on reducer) |
map() |
Table |
take() |
Table |
any() |
Boolean |
all() |
Boolean |
find() |
Value or nil
|
Note: Methods that return tables (filter, map, take) cannot be chained with other range methods since tables
are not ranges. Use reduce() directly on a range for aggregation, or store intermediate results:
-- Sum of squares of even numbers from 1 to 20
sum = {1 to 21}:reduce(0, function(acc, i)
if i % 2 is 0 then return acc + (i * i) end
return acc
end)
-- sum = 2**2 + 4**2 + 6**2 + ... + 20**2 = 1540Strings support range objects as indices to perform slicing. Indexing is zero‑based and inclusive of the start
position; the end index is interpreted according to the range's inclusive flag.
s = "Hello, World!"
assert(s[{0 to 5}] is "Hello") -- Exclusive: indices 0..4
assert(s[{7 to 12}] is "World") -- Exclusive: indices 7..11
assert(s[{0 into 4}] is "Hello") -- Inclusive: indices 0..4
assert(s[{-6 into -1}] is "World!") -- Negative inclusive: indices 7..12
assert(s[{-6 to -1}] is "World") -- Negative exclusive: indices 7..11Out‑of‑bounds and empty ranges behave predictably:
- Ranges entirely beyond the string length return an empty string.
- Exclusive ranges where
startequalsstopyield an empty string. - Inclusive single‑element ranges return the corresponding character.
Tables support range objects as indices to extract subsequences. Indexing is zero‑based and follows the same inclusive/exclusive semantics as string slicing. Table slices always return a new table containing copies of the selected elements.
t = {10, 20, 30, 40, 50}
subset = t[{1 to 4}] -- Returns {20, 30, 40} (exclusive)
subset = t[{1 into 3}] -- Returns {20, 30, 40} (inclusive)
subset = t[{0 to 5}] -- Returns {10, 20, 30, 40, 50} (full table)
subset = t[{-3 into -1}] -- Returns {30, 40, 50} (negative inclusive)
subset = t[{-3 to -1}] -- Returns {30, 40} (negative exclusive)Negative Indices:
Negative indices count backwards from the end of the table. The range operator still controls the stop point:
... includes the resolved stop index and .. excludes it.
t = {10, 20, 30, 40, 50}
t[{-2 into -1}] -- Returns {40, 50} (inclusive)
t[{-2 to -1}] -- Returns {40} (exclusive)
t[{1 into -1}] -- Returns {20, 30, 40, 50} (inclusive)
t[{1 to -1}] -- Returns {20, 30, 40} (exclusive)
t[{-3 into 4}] -- Returns {30, 40, 50} (inclusive)Reverse Slicing:
Ranges where the start index is greater than the stop index produce reversed subsequences. The direction is auto-detected based on the resolved indices.
t = {10, 20, 30, 40, 50}
t[{4 to 0}] -- Returns {50, 40, 30, 20} (reverse exclusive)
t[{4 into 0}] -- Returns {50, 40, 30, 20, 10} (reverse inclusive)
t[{3 to 1}] -- Returns {40, 30} (reverse partial)Step Support:
Stepped ranges can use either the range literal by clause or the range() constructor. The explicit step is used
exactly as written, so reverse stepped ranges should use a negative step.
t = {10, 20, 30, 40, 50, 60, 70}
r = {0 into 6 by 2} -- Every 2nd element
t[r] -- Returns {10, 30, 50, 70}
r = {6 into 0 by -2} -- Reverse with step
t[r] -- Returns {70, 50, 30, 10}
r = range(0, 7, true, 2) -- Constructor form remains availableOut-of-Bounds and Empty Ranges:
- Indices beyond the table length are clipped to valid bounds
- Exclusive ranges where
startequalsstopreturn 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)Tiri extends the in operator to work with ranges outside of for loops. Membership tests are implemented in terms
of the range's contains method, and always return a boolean.
r = {0 to 10} -- Exclusive: 0–9
ri = {0 into 10} -- Inclusive: 0–10
assert(5 in r)
assert(not (10 in r)) -- 10 excluded
assert(10 in ri)
assert(not (11 in ri))in can be used anywhere a boolean expression is expected, including conditionals:
if 5 in {0 to 10} then
print("in range")
end
if not (11 in {0 to 10}) then
print("out of range")
endMembership tests work with both literal ranges and ranges stored in variables. The semantics follow the contains
method exactly, including step handling and inclusive/exclusive behaviour.
The choose ... from syntax provides pattern matching for selecting a result value (or executing a branch) without
verbose if/elseif chains.
Basic form:
status_text = choose status from
200 -> 'OK'
404 -> 'Not Found'
else -> 'Unknown'
endSemantics:
- The scrutinee expression (
status) is evaluated exactly once. - Cases are tested in order; the first match wins.
-
elseis optional. If omitted and no case matches, the result isnil(expression context) and no action is taken (statement context). -
elsemust be the final case. Anelse-onlychooseis valid and always matches.
Patterns:
-
Literal patterns: numbers, strings, booleans, and
nilmatch usingissemantics (no implicit type coercion). -
Wildcard:
_matches anything and is typically used as a catch-all. -
Relational patterns:
< Expr,<= Expr,> Expr,>= Expruse normal relational operators. -
Table patterns:
{ key = value, ... }match tables using open-record semantics:- Extra keys on the scrutinee are ignored
- Matching is shallow and compares values using
is -
{}matches any table value (but does not match non-table values)
-
Tuple patterns:
(p0, p1, ...)match tuples created from multiple scrutinee values:-
choose (a, b) from ... endevaluatesaandbonce and matches by arity - Tuple arity mismatches are compile-time errors
-
(x)is a parenthesised expression; a tuple requires a comma
-
Guards:
Cases may include a when clause to add a conditional guard:
icon = choose notification from
{ type = 'message', unread = true } -> 'icon-inbox-unread'
{ type = 'message' } when notification.priority > 5 -> 'icon-priority'
else -> 'icon-default'
endThe guard is evaluated only after the pattern has matched. Guard failure proceeds to the next case.
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'
endWildcard as a catch-all (including NaN):
label = choose value from
nil -> 'unset'
_ -> 'set'
endNesting choose expressions:
msg = choose status from
200 -> 'OK'
else -> choose retry_count from
0 -> 'Failed (no retry)'
else -> 'Failed (will retry)'
end
endConditionals with when guards, against a table with pattern filtering:
notification = { type = 'message', unread = true, priority = 7 }
icon = choose notification from
{ type = 'message' } when notification.unread -> 'icon-inbox-unread'
{ type = 'message' } -> (notification.priority > 5 ? 'icon-priority' :> 'icon-inbox')
else -> 'icon-default'
endFree-standing choose statements for flexible assignments and control flow:
choose state from
'save' -> result = 'saved'
'load' -> result = 'loaded'
else -> error('Invalid state')
endGotchas:
- Order matters, especially with relational patterns: place more specific cases first (
< 30before< 60). -
NaNnever matches literal numeric patterns (nan is nanis false). Use_or a guard if you need to handle it. - Table patterns are shallow: nested tables must be checked via guards or nested
chooseexpressions.
The include statement is used to load the definitions for an API's functions and classes. It accepts a series of API names as input, and will load each in sequence, for example:
include 'core','xml','display'
API interfaces are protected from being loaded more than once. Calls to mod.load() result in API definitions being loaded automatically, and may make a call to include being unnecessary in that case.
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.
Namespaces:
Libraries will typically declare a default namespace that matches the library name. For instance, the gui library declares a gui namespace. To use a custom namespace, add the as namespace clause. For example:
import 'gui' as myGuiApplication Specific Libraries:
Complex applications can benefit from using import to create custom libraries and split the code across multiple files. The import statement will search the local folder if the reference is prefixed with ./. Standard path rules still apply, e.g. ./lib/customlib would be valid, ./../../h4cklib!3.tiri is not.
Behavioural Notes:
-
Top-level only: The
importstatement can only appear in the outside scope of a script, not inside functions. Attempting to useimportinside 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.
-
Global exports: Global variables and functions declared with the
globalkeyword 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 hereConditional Pre-Processing:
The import statement works seamlessly with compile-time conditionals. Imported files can use @if(imported=true) to include code only when being imported, or @if(imported=false) for code that runs only when executed directly:
-- library.tiri
global function doWork()
-- Always available
end
@if(imported=false)
-- Only runs when library.tiri is executed directly
print('Running library.tiri as main script')
doWork()
@end
@if(imported=true)
-- Only runs when imported
print('library.tiri loaded as import')
@endLibrary Development Pattern:
If developing a Tiri library, it is common to define a namespace and export global variables or functions under that namespace. This example based on the gui library shows a common pattern for defining a library with a namespace and exporting global variables:
namespace 'gui'
_LIB[_NS] = {
theme = 'light',
dpi = 160,
...
}
gui = _LIB[_NS]
gui.parseRGB = function(hex)
...
end
Note that _LIB and _NS are special predefined variables available during import. _LIB is a table used to store library interfaces, and _NS contains the declared namespace string, in this case gui.
Use loadFile() to load, parse and execute a Tiri script at runtime. This feature is useful for re-using code, or breaking a large project into multiple script files. Example:
loadFile('programs:tools/project/example.tiri')
If the path is not fully qualified, the current path of the process will be used to determine the location of the source file.
If the executed script ends with a return statement, the value(s) will be returned from loadFile() in their original form.
Note: Code loaded via loadFile() will lose the efficiency gains afforded to scripts loaded via import.
Use exec() to parse a Tiri statement and execute it. exec() will raise an exception if the statement is unparseable or fails during execution. Example:
exec([[
print("Hello World")
]])
The module interface provides a means for Tiri programs to communicate with Kōtuku's APIs. They are loaded using the mod.load() function, for instance:
mGfx = mod.load('display')
Calling mod.load() returns the function table for the requested module, allowing calls to be made to its functions. For instance:
err, x, y = mGfx.getCursorPos()
Notice that Tiri is capable of returning multiple results if a function has declared more than one return value. Typically the first value will be an ERR code.
For consistency, we use a set of commonly named global variables to store module function tables. This is because loading an API more than once is undesirable. The following table illustrates the global names that we use for the default set of APIs.
| Module | Global Name |
|---|---|
| audio | mAudio |
| core | mSys |
| display | mGfx |
| font | mFont |
| network | mNet |
| vector | mVec |
| xml | mXML |
Taking the above into account, our first example should now be written as follows:
mGfx ?= mod.load('display')
Note that the Core module, identified by the mSys global variable is considered an essential API and is always available by default.
When calling an API function, Tiri uses best efforts to convert function values to the types declared in the function definition. For instance, if a string value is used for a numeric argument, Tiri will automatically convert the string to an integer. If the type cannot be converted (for instance, an abstract pointer cannot be converted to a number) then a type mismatch occurs and an exception will be raised.
While type conversion is convenient, we recommend using the correct type whenever possible as this will ensure that your calls are being made efficiently.
When calling functions that copy results to a user-supplied buffer, pass an array interface. The following example illustrates reading content from a file into an array buffer:
buffer = array<byte, file.size>
err, result = file.acRead(buffer)
print(tostring(buffer))Notice that the acRead() call hasn't been given its second parameter (the amount of data to read). This is possible because Tiri knows the size of the buffer and will use it for the second parameter if not already defined.
Some functions may return multiple result values. Here is an example of a module function that returns two results:
ERR ListMemory(INT Flags, ListMemory **Memory)
The first result is an ERR code. The ListMemory result is stored in the variable pointed to by the Memory argument. In a C program, this function would be used as follows:
if (!ListMemory(0, &memory)) { ... }
In Tiri, pointer results are dropped from function specifications because writing to pointer buffers is risky. Instead, these parameters are handled internally and their values are returned as part of the result set. The following example shows how to call ListMemory() in Tiri:
err, list = mSys.ListMemory(0)
Some functions like the above can return allocated memory or some other form of temporary resource. These are normally marked as such in the function definition, which allows the garbage collector to automatically remove it once its references have been reduced to zero. Remember to use local wherever possible to clean up resources that have gone out of scope.
The object interface provides the necessary functionality to create and manage objects. It allows you to read and manipulate object fields, call methods and actions, manage subscriptions and connect to existing named objects.
To create a new object, use the object interface's new() method with the name of the class that will be instantiated. Here's an example that creates an object, sets necessary field values and initialises it:
file = obj.new('file')
file.path = 'readme.md'
file.flags = '!READ'
err = file.init()The first result from new() is the created object's interface and the second result is an ERR code if creation fails. Take note that an object must always be initialised before any real interaction occurs (anything beyond setting field values).
The above example can be simplified by defining key-values immediately after the class name:
file = obj.new('File', { path='readme.md', flags='!READ' } )
In this case the path and flags values will be set on the new object and then initialised automatically (it is assumed all field settings are being defined up-front). The field values may be of any type and Tiri will use best efforts to convert them to the correct field type.
In the event of an error, new() will throw an exception. It never returns nil. We advise guarding your calls with try, particularly if using classes like File where error management is good practice.
Initialises the object if not already done so with obj.new(). Useful if you need to set field values after creation but before initialisation. Does not throw in normal operation, instead an ERR code is returned to indicate success or failure.
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)' })Notice that the call to viewport.new() looks suspiciously like an obj.new() call. Functionally the creation process is identical, but the rectangle is now going to be a child of the viewport instead of our script. When the rectangle is initialised, it will determine that it is owned by the viewport and will appear in that context when displayed.
It is the case that all objects support the new() method for the purpose of supporting these complex hierarchies.
Be aware that object relationships have absolute priority over other factors in determining their lifetime. In the above example, if the viewport is destroyed (e.g. the user closes the window) then the rectangle will be destroyed with it because its state is linked to the parent. Objects created as children are weakly referenced.
Obtain a list of the children that have been attached to an object by calling the children() method. It returns an array of UID's that can be converted to interfaces with obj.find(). The children() method also supports a class filter in the first parameter, e.g. thing.children('VectorRectangle') would return a list of all rectangles.
Access the interface of an existing object by searching for its name or UID:
window = obj.find('my_window')
winsurface = obj.find(window.surface)If the object is not found, nil is returned. If multiple objects exist with the same name, the most recently created object is returned first.
The with statement locks one or more Kōtuku objects for the duration of a block, automatically unlocking them when the scope exits through any path (normal exit, break, continue, return, or exception unwinding).
with object do
-- object is locked here
end
-- object is automatically unlockedVariables declared inside the with block are not visible outside it, following the same rules as do ... end blocks.
Passing a non-object value (such as a table, string, or number) to with will raise a runtime error. Use with exclusively with Kōtuku objects.
Multiple objects:
with obj1, obj2, obj3 do
-- all three objects are locked
end
-- all three are unlocked in LIFO order (obj3 first, then obj2, then obj1)Why lock objects?
Locking serves two purposes:
- Thread safety: In multi-threaded programs, locking prevents data corruption when objects are shared across threads.
- 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
endIf an object is loosely coupled to a script (a weak reference), the exists() method can be called at any time to confirm it is not destroyed. The result is a boolean value.
It is not uncommon that a script may need to create an object that outlives its scope. Calling detach() will unlink the object from the script and the garbage collector, demoting it to a weak reference. Once detached, the object can only be removed if its parent object is destroyed, or the free() method is called.
Note: If an object has been created as the child of another, e.g. viewport.new('VectorRectangle', { ... }) then the object is already weakly referenced and detach() will do nothing.
Call free() to terminate an object resource immediately without removing the interface. If the object is weakly referenced then free() is the most practical means of removal.
Calling free() is discouraged in general usage - a safer pattern is to set object references to nil and either wait for garbage collection or call collectgarbage().
The garbage collector will automatically terminate an object once all references to that object are released or the script is terminated. If an object has a single reference, you can manually terminate it by setting the reference to nil, for example:
file = obj.new('File')
...
file = nilAfter successfully initialising an object interface, interaction with it is possible through actions, methods and fields. The following example illustrates how we could change the size of the VectorRectangle we created earlier:
rect.acRedimension(20, 20, 0, 100, 100, 0)Action calls are identified by their ac prefix. Method calls are prefixed by mt. Field references can be directly accessed without a prefix. The naming scheme for methods, actions and fields is case insensitive.
For information about the actions and methods supported by an object class, refer to its published API documentation on our website.
Object fields are accessible by conventional means, i.e. value = my_object.field_name and my_object.field_name = value for get and set respectively. There are get() and set() methods that are equivalent to these, which allow strings to be used for dynamic field access. Some classes also support key-values via getKey() and setKey() methods, which can be used for accessing dynamic field names.
Some classes support key-value pairs in cases where custom string names need to be associated with an object. The getKey() and setKey() methods are provided to manage these fields. For example:
surface.setKey('Title', 'My Window')
title = surface.getKey('Title', 'UNDEFINED')Some classes treat keys as parseable strings that are backed by built-in functionality. For instance, the following statement retrieves the content from slot 10 of an item array:
item = view.getKey('item[10]')
The _state() method is a special accessor that attaches a table to the object and returns it. This allows you to store custom data with the object that is seperate to its definition. The table will be removed when the object is destroyed.
Kōtuku allows clients to monitor the calls made to an object by subscribing to its actions and methods. One commonly used strategy involves subscribing to the free() action of an object so that the client is notified of object termination.
Subscription is enabled via the subscribe() method. The following example illustrates the creation of an HTTP object that performs a download and calls my_function() on completion of the download process. Note that Args is a table that contains named arguments for the action that was subscribed to. The CustomRef is an optional value that will be passed to the subscriber.
function my_function(UID, Args, CustomRef)
print('Download complete.')
end
http = obj.new('http', { ... } )
http.acActivate()
handle = http.subscribe('deactivate', my_function, customref)To unsubscribe from an action, call unsubscribe() with the action/method name. Doing so will allow the garbage collector to remove associated resources that are no longer required.
The processing interface assists with the management of your program's idle state and reaction to signals. Its most basic feature is the commonly used sleep() function, as follows:
processing.sleep([Timeout], [WakeOnSignal=true])
Calling sleep() in this way will put the script to sleep until a message is received to awaken it. Typically this means that the function won't return until a QUIT message is received, which would be delivered if the user opts to close the main window.
sleep() can also return early if a Timeout in seconds is specified in the first parameter. Waking on signal can be disabled by setting the second parameter to false.
If a Tiri script is utilising threads, synchronisation can be an important issue that requires careful management. The processing interface makes it possible to put the main thread to sleep and wake it once a series of signals has been triggered. In the following working example, we create a basic thread that prints a message and then signals two XML objects in order to wake the main thread. This might be plausible if we were asking the thread to process XML data for instance:
function testSignals()
signal_a = obj.new('xml', { flags='NEW' })
signal_b = obj.new('xml', { flags='NEW' })
-- Note that the thread code is parsed as a string and can't see any variables without a call to obj.find().
-- The termination handler on the other hand has access to signal the objects directly.
async.script([[
msg('Thread is now in session.')
]],
function()
msg('Thread has been executed.')
signal_a.acSignal()
signal_b.acSignal()
end)
proc = processing.new({ timeout=1.0, signals = { signal_a, signal_b } })
msg('Sleeping....')
err = proc.sleep()
assert(err is ERR_Okay, "Unexpected error: " .. mSys.GetErrorMsg(err))
endNotice that we called processing.new() to create a dedicated interface for signal management. The two objects that will be signalled are passed in via the signals field. When the thread completes, it calls acSignal() on the objects to change their signal state. It is necessary for both objects to change state in order to wake the main thread.
If no signal objects are listed in a call to processing.new(), it is possible to manually call the signal() method to wake the sleeping thread instead. This simple technique is sometimes used in passive event systems, e.g. the user clicking a mouse button could result in a signal() call that wakes the main thread.
The flush() method will clear pending signals associated with the script and processing object.
Using flush() is rarely necessary expect in special circumstances. For instance if a prior call to sleep() resulted in a timeout, clearing pending signals guarantees a fresh start.
The task() function returns a Tiri object that references the current task:
currentTask = processing.task()
This function provides access to the task object representing your script's execution context. The returned object can be used to modify task properties such as process priority, which is useful for applications requiring consistent performance:
task = processing.task()
if task then
task.set('Priority', 15) -- Set high priority for time-critical operations
endUse delayedCall() to call a function on the next message processing cycle inside processing.sleep(). This is useful in situations where calling a function is necessary, but to do so immediately would cause logistical issues with the order of execution.
processing.delayedCall(function
print('This message is appearing after being delayed.')
end)processing.collect([Mode], [Options])
Controls the garbage collector. The optional Mode parameter specifies the collection mode:
| Mode | Description |
|---|---|
full |
Performs a full garbage collection cycle (default). |
step |
Performs an incremental collection step. |
The optional Options table supports the following fields:
| Field | Type | Description |
|---|---|---|
stepSize |
integer | Step size for incremental collection (only used with "step" mode). |
Returns an integer result from the underlying GC operation. The meaning varies by mode:
- For
"step"mode: returns1if collection is not finished,0if finished - For other modes: returns
0on success
Examples:
-- Full collection (default)
processing.collect()
processing.collect("full")
-- Incremental step collection
result = processing.collect("step", { stepSize = 100 })Starts the automatic garbage collector if it is not already running. The garbage collector runs in the background and will periodically reclaim memory used by unreferenced objects.
Stops the automatic garbage collector. When called, it will cease to reclaim memory until startCollector() is called again.
stats = processing.gcStats()
Returns a table containing garbage collector statistics. This is useful for monitoring memory usage and GC behaviour.
| Field | Type | Description |
|---|---|---|
memoryKB |
integer | Memory usage in kilobytes. |
memoryBytes |
integer | Remainder bytes (total bytes = memoryKB × 1024 + memoryBytes). |
memoryMB |
number | Total memory usage in megabytes (convenience field). |
isRunning |
boolean |
true if the garbage collector is currently running. |
pause |
integer | Current pause multiplier (controls GC frequency, default 200). |
stepMul |
integer | Current step multiplier (controls GC speed, default 200). |
Example:
stats = processing.gcStats()
print(f"Memory: {stats.memoryMB} MB")
print(f"GC Running: {stats.isRunning}")
print(f"Pause: {stats.pause}, StepMul: {stats.stepMul}")
-- Monitor memory before and after allocation
before = processing.gcStats().memoryMB
t = {}
for i in {0 to 10000} do t[i] = string.rep("x", 100) end
after = processing.gcStats().memoryMB
print(f"Allocated: {after - before} MB")
-- Clean up and verify
t = nil
processing.collect()
final = processing.gcStats().memoryMB
print(f"After collection: {final} MB")math.round(Num, [DecimalPlaces])
Round floating point Num to the nearest integer. If DecimalPlaces is specified then the function will round to the place indicated. For example math.round(8.2468,2) rounds to 8.25.
The Lua-based strings interface is extended with a number of useful functions that are commonly required in programming. The following functions are included:
str = string.alloc(Size)
Create a string of a given size in bytes. This feature is intended for creating sized data buffers that can be passed to Kōtuku interfaces.
str:cap()
Recreate a string with the first character in upper case.
count = str:count(Keyword)
Count the number of non-overlapping occurrences of Keyword in the string. Returns 0 if either string is empty or if Keyword is not found.
"hello world hello":count("hello") -- 2
"aaa":count("aa") -- 1 (non-overlapping)str:decap()
Recreate a string with the first character in lower case.
str:escXML()
Escape str for an XML attribute value or content.
hash = str:hash([CaseSensitive])
Return a hash value for the string. The CaseSensitive parameter is optional and defaults to false.
str:join(Table, Separator)
Join the contents of Table into a single string, using Separator between each item. If Separator is not specified, the items are joined consecutively.
str:pop([Count])
Returns the string with Count characters removed from the end. If Count is not specified, it defaults to 1. If Count is greater than or equal to the string length, an empty string is returned. If Count is zero or negative, the original string is returned unchanged.
"hello":pop() -- "hell"
"hello":pop(2) -- "hel"
"hello":pop(10) -- ""
"hello":pop(0) -- "hello"result, count = str:replace(Keyword, Replacement, [Limit])
Replace Keyword with Replacement, without pattern matching. Limit will restrict the total number of replacements if specified. Returns the modified string and the count of replacements made.
-- Replace all occurrences
result, count = "hello world":replace("o", "0") -- result = "hell0 w0rld", count = 2
-- Replace only the first occurrence
result, count = "aaa":replace("a", "b", 1) -- result = "baa", count = 1str:trim()
Trims whitespace from the left and right sides of a string.
str:rtrim()
Trims whitespace from the right side of a string.
str:split([Separator])
Split a string into an array of substrings. If Separator is a single character, each occurrence splits the string. If Separator is a multi-character string, it is matched as a whole delimiter.
If Separator is not specified, the string is split on any individual whitespace character (space, tab, newline, carriage return).
str:startsWith(Cmp)
Returns true if the string starts with Cmp.
str:substr(Start, [End])
Extract a substring from Start up to but not including End (exclusive semantics). If End is not specified, the substring extends to the end of the string. Negative indices count from the end. This is the preferred alternative to string.sub().
"hello world":substr(0, 5) -- "hello"
"hello world":substr(6) -- "world"
"hello world":substr(-5) -- "world"str:endsWith(Cmp)
Returns true if the string ends with Cmp.
str:unescapeXML()
Unescape XML entities (<, >, &, ", ') in str, returning the decoded string. This is the reverse of escXML().
"<div>Hello & World</div>":unescapeXML()
-- Returns: "<div>Hello & World</div>"Tiri supports a simplified threading model so as to minimise the potential problems occurring from their use. The functionality is as follows:
The script() method compiles a statement string and executes it in a separate script state. The code may not directly share variables with its creator, but it can find existing objects and interact with them by calling obj.find().
The Callback parameter is a function that will be executed once the threaded script has returned. It executes in the space of the caller and not the thread itself, which means it can access local variables safely.
async.script([[
print('Thread is now in session.')
]],
function()
print('Thread has finished executing.')
end)
The async.action() and async.method() interfaces provide a convenient means of executing an object function in a separate thread. There is some overhead in executing a new thread, but this strategy becomes highly effective when used to perform lengthy processes such as the loading and parsing of data.
Here's an example that reads the first 1Kb of data from a file and prints the content:
function on_complete(Action, File, Error, Buffer)
print('Read string: ' .. Buffer)
File.free()
end
str_buffer = string.rep(1024)
async.action(file, AC_Read, on_complete, str_buffer, str_buffer)
processing.sleep()
The Action parameter is accepted as an action ID (fast) or a string (slower). The Key makes it possible to pass a custom parameter to the callback, and can be used to pass multiple parameters if a table is used.
The Callback routine receives notification of the thread's completion, and in this example uses this as an opportunity to free the file object. This is a recommended pattern, and ensures that the target object is not destroyed while the thread is executing. Never declare the object value as local.
Internally, callbacks are executed on the next message processing cycle and this is why a call to processing.sleep() is used in this example.
The Error parameter in the callback reflects the ERR code returned by the action - but bear in mind the possibility that if thread preparation fails, the callback will never be executed and an exception will be thrown instead.
The structure interface is provided so that Tiri can use C/C++ structures declared in the Kōtuku API. You will find that many class and module API's use structures for returning condensed information.
Structures are created using the struct interface's new() method. The prototype is as follows:
newstruct = struct.new(struct_def, [address])
The struct_def is a reference to a named structure definition declared in the Kōtuku API, or one returned from MAKESTRUCT(). The address is an optional setting for linking a struct to an existing pointer rather than allocating an empty memory block for the structure. Example:
xmltag = struct.new('XMLTag', address)
It is possible to manually change the address pointer that is assigned to a struct - this saves on having to create a struct object for every address to be processed. Example:
oldaddress = xmltag.ptr(NewAddress)
To retrieve the current address:
address = xmltag.ptr()
Note that nil is returned if the address is set to zero.
The byte size of a structure can be retrieved with the structsize() method, for example:
print('The size of the structure is ' .. xmltag.structsize())
To get the total number of fields in the structure, use #, e.g. #xmltag.
The MAKESTRUCT() function is used to build structure definitions. A structure definition is a single string that defines all fields in a structure, matched in the order in which they appear in the structure. Consider the following C structure:
struct XMLTag {
int Index;
int ID;
XMLTag *Child;
XMLTag *Prev;
XMLTag *Next;
APTR Private;
XMLAttrib *Attrib;
int16_t TotalAttrib;
uint16_t Nest;
};To define this structure in Tiri we would use the following code:
MAKESTRUCT('XMLTag', 'lIndex,lID,pChild,pPrev:XMLTag,pNext:XMLTag,pPrivate,pAttrib,wTotalAttrib,uwNest')
Notice that each field name is defined using the same order and names as identified in the structure. Each name is prefixed with a lower-case character that indicates the field type. Using the correct field types is the most crucial part of the structure definition and each must be chosen from the following table:
| Character | Field Type |
|---|---|
| l | Integer (32-bit) |
| d | Double (64-bit) |
| x | Integer (64-bit) |
| f | Float (32-bit) |
| w | Integer (16-bit) |
| c | Char (8-bit) |
| p | Pointer. You can use ':StructName' as a suffix to reference other structures. |
| s | String |
| o | Object (Pointer) |
| u | Unsigned (Use in conjunction with a type) |
Fixed arrays are also permitted in the structure definition if a field name is followed with enclosed square brackets that contain the array size, e.g. [12].
The regular expression interface provides compiled regex objects for high-performance pattern matching and text manipulation. Unlike Lua's basic pattern matching, the regex interface offers full PCRE-compatible regular expression support through compiled objects that can be created once and reused multiple times.
For more information on our regex implementation, please refer to our Regex Manual.
To create a compiled regex object, use the new() method with the following prototype:
rx = regex.new(Pattern, [Flags])
The Pattern is a string containing the regular expression pattern to compile. The optional Flags parameter can be used to modify regex behaviour using the flag constants described below.
-- Simple pattern
digits = regex.new('\\d+')
-- Case insensitive pattern
email = regex.new('[\\w._%+-]+@[\\w.-]+\\.[A-Za-z]{2,}', regex.ICASE)If the provided pattern is invalid, an error will be raised. For this reason it is advisable to use try when creating regex objects that use untested patterns, e.g. from user input.
The following flags can be combined to modify regex compilation:
| Flag | Description |
|---|---|
regex.ICASE |
Case insensitive matching |
regex.MULTILINE |
^ and $ match line boundaries |
regex.DOT_ALL |
. matches newlines |
Flags can be combined: regex.ICASE | regex.MULTILINE.
The following RMATCH flags can be used with the optional Flags parameter in test(), match(), search(), replace(), and split() methods:
| Flag | Description |
|---|---|
regex.NOT_BEGIN_OF_LINE |
Do not treat the beginning of text as start of line |
regex.NOT_END_OF_LINE |
Do not treat the end of text as end of line |
regex.NOT_BEGIN_OF_WORD |
Do not treat the beginning of text as start of word |
regex.NOT_END_OF_WORD |
Do not treat the end of text as end of word |
regex.NOT_NULL |
Do not match empty sequences |
regex.CONTINUOUS |
Only match at the beginning of text |
regex.PREV_AVAILABLE |
Previous character is available for look-behind |
regex.REPLACE_NO_COPY |
Do not copy non-matching parts in replace operations |
regex.REPLACE_FIRST_ONLY |
Replace only the first occurrence |
Example usage:
wordRegex = regex.new('\\w+')
-- Replace only the first word
result = wordRegex.replace('hello world', 'goodbye', regex.REPLACE_FIRST_ONLY)
-- Result: 'goodbye world'Compiled regex objects provide the following read-only properties:
-
pattern: The original pattern 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)
endRegex objects are compiled once and can be reused many times, making them significantly more efficient than traditional string matching for complex patterns. Store regex objects in deferred expressions to create them on demand and avoid recreating them:
global emailValidator = <{ regex.new('^[\\w._%+-]+@[\\w.-]+\\.[A-Za-z]{2,}$') }>
for email in values(emailList) do
if emailValidator.test(email) then
processEmail(email)
end
endescaped = regex.escape(Text)
Use the static regex.escape() method to escape all regex metacharacters in a string so it can be used as a literal pattern. This is essential when constructing patterns from user input or dynamic data that may contain special regex characters.
-- Escape special characters for literal matching
escaped = regex.escape('hello.world') -- 'hello\\.world'
escaped = regex.escape('a+b*c?d') -- 'a\\+b\\*c\\?d'
escaped = regex.escape('(group)[class]') -- '\\(group\\)\\[class\\]'
-- Safe pattern from user input
userInput = '[user@example.com]'
safePattern = regex.new(regex.escape(userInput))result = rx.test(Text, [RMATCH])
Use the test() method to test a pattern against a string. It returns true if the pattern matches anywhere in the text, false otherwise.
start, stop, captures = rx.findFirst(Text, [Offset], [RMATCH])
The findFirst() method offers the fastest way to locate the first match within a Text string. The optional Offset parameter specifies where to begin searching. The start and stop positions of the match are returned, 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
pos, len = digits.findFirst('abc123def456') -- pos = 3, len = 3 (matches '123')
-- Start searching from a specific position
pos, len = digits.findFirst('abc123def456', 6) -- pos = 9, len = 3 (matches '456')This method is optimised for speed when you only need the position of a match, not the matched text or capture groups. For quickly extracting captures, regex.extract() is often preferred.
iter = rx.findAll(Text, [Offset], [RMATCH])
Use the findAll() method to iterate over all matches in a string:
for start, stop, captures in rx.findAll(Text, [Offset], [RMATCH]) do
...
endEach iteration yields:
-
pos: The position of the match -
len: The length of the match -
captures: An array of capture group strings (ornilif no bracketed captures were defined)
-- Simple iteration without captures
digits = regex.new('[0-9]+')
for pos, len in digits.findAll('abc123def456ghi789') do
print(f'Found at {pos}, length {len}')
end
-- Iteration with capture groups
pattern = regex.new('([a-z]+)([0-9]+)')
for pos, len, 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 pos, len, caps in emailRegex.findAll('Contact alice@foo.com or bob@bar.org') do
print(f'User: {caps[1]}, Domain: {caps[2]}, TLD: {caps[3]}')
endNotes:
- If no bracketed capture groups
()are defined in the pattern,captureswill benil. - Zero-width matches advance by at least one character to prevent infinite loops.
`capture, ... = rx.extract(Text, Offset, [RMATCH])
The extract() method provides a convenient interface when using patterns that are designed for capturing. Rather than emitting an array of captures as in findFirst(), captured results are instead returned in sequence, as individual string values. The downside to this convenience is that the start and end points of the match are omitted.
matches = rx.match(Text, [RMATCH])
Use the match() method to perform a whole-string match and return capture groups. Returns an array containing the full match and any capture groups, or nil if no match is found. Array indices start at 0 (full match), with capture groups at indices 1, 2, etc.
rx_url = regex.new('(https?)://([^/]+)(.*)')
matches = rx_url.match('https://example.com/path')
-- matches[0] = 'https://example.com/path' (full match)
-- matches[1] = 'https' (first capture group)
-- matches[2] = 'example.com' (second capture group)
-- matches[3] = '/path' (third capture group)all_matches = rx.search(Text, [RMATCH])
Use the search() method to find all matches in the text. Returns an array where each element is a match array (as returned by match()), or nil if no matches are found.
rx_word = regex.new('(\\w+)')
all_words = rx_word.search('hello world test')
-- all_words[0][0] = 'hello', all_words[0][1] = 'hello'
-- all_words[1][0] = 'world', all_words[1][1] = 'world'
-- all_words[2][0] = 'test', all_words[2][1] = 'test'result = rx.replace(Text, Replacement, [RMATCH])
Use the replace() method to replace all occurrences of a pattern. Replacement strings support backreferences using $1, $2, etc., to reference capture groups.
rx_phone = regex.new('(\\d{3})-(\\d{3})-(\\d{4})')
formatted = rx_phone.replace('555-123-4567', '($1) $2-$3')
-- Result: '(555) 123-4567'parts = rx.split(Text, [RMATCH])
Use the split() method to split text using the regex pattern as a delimiter. Returns an array containing the split parts (empty strings are excluded).
csvRegex = regex.new('\\s*,\\s*') -- Split on commas and eliminate whitespace
fields = csvRegex.split('apple, banana, cherry')
-- fields = { 'apple', 'banana', 'cherry' }Tiri includes a native array interface that is fully integrated with the JIT compiler. We strongly recommend its use for the storage of sequentially arranged data, and that tables are avoided for that use case.
Arrays are typed containers that store elements of a single primitive type. They provide efficient storage and direct memory access, making them ideal for numerical computations, buffer management, and interoperability with C/C++ APIs.
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.
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 valuesExamples:
-- Create typed arrays with concise syntax
numbers = array<int> { 1, 2, 3, 4, 5 }
names = array<string, 100> -- Pre-allocate 100 string slots
points = array<table> { {x=0, y=0}, {x=1, y=1} }
-- Chain methods directly on the result
result = array<int> { 1, 2, 3 }:concat(', ', '%d') -- Returns '1, 2, 3'
-- Pre-allocated with initial values (size > values count)
buffer = array<int, 10> { 1, 2, 3 } -- {1, 2, 3, 0, 0, 0, 0, 0, 0, 0}
-- Dynamic size expression with variables and functions
n = 8
arr = array<int, n> { 100, 200 } -- {100, 200, 0, 0, 0, 0, 0, 0}When both a size and initial values are provided, the array is first initialised with the values, then resized to the specified size. If the size is larger than the number of values, the remaining elements are zero-initialised (or nil for reference types like strings and tables). If the size is smaller than the number of values, the values take precedence and the size is effectively ignored.
Supported Element Types:
| Type | Size | Description |
|---|---|---|
byte |
1 | Unsigned 8-bit integer (0-255) |
int16 |
2 | Signed 16-bit integer |
int |
4 | Signed 32-bit integer |
int64 |
8 | Signed 64-bit integer |
float |
4 | 32-bit floating point |
double |
8 | 64-bit floating point |
string |
8 | String reference |
object |
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) |
arr = array.new(Size, Type)
arr = array.new(String)The traditional array <> syntax is syntactic sugar that desugars to array.new() and array.of() calls. We recommend against calling array.new() directly unless an edge case makes it necessary. There is however, one unique feature in that passing a string to array.new() will create a byte array from the string's contents.
Examples:
-- Create a 100-element integer array
int_arr = array.new(100, 'int')
-- Create an array from a string (each byte becomes an element)
byte_arr = array.new('Hello World')
-- Create an empty array (useful for dynamic filling)
empty_arr = array.new(0, 'double')arr = array.of(Type, Value1, Value2, ...)As for array.new(), calling array.of() is not recommended unless an edge case requires it. Calling array.of() will populate a new array with the given values. Type is the element type. The remaining arguments are the values to populate the array with. At least one value must be provided.
Examples:
-- Create a string array with two domain names
domains = array.of('string', 'google.com', 'amazon.co.uk')
-- Mixed types
arr = array.of('any', 42, 'hello', true, nil, { x = 10 })Array elements are accessed using zero-based indexing:
arr = array<int, 10>
arr[0] = 100 -- Set first element
arr[9] = 999 -- Set last element
value = arr[5] -- Read element at index 5The # operator returns the array length:
total = #arr -- Returns 10Returns the element type of the array as a string.
arr = array<float, 10>
print(arr:type()) -- Prints 'float'
byte_arr = array.new('test')
print(byte_arr:type()) -- Prints 'char'Returns true if the array is read-only, false otherwise. Read-only arrays are typically created by the system when wrapping external memory buffers.
Returns a copy of the array in table format.
length = arr:push(Value, ...)
Appends one or more elements to the end of the array, growing capacity as needed. Returns the new length of the array. Arrays grow automatically when pushing beyond current capacity.
External arrays (wrapping C/C++ memory) cannot grow and will raise an error. Type validation is performed; pushing incompatible types raises an error. Calling push() with no arguments returns the current length without modification.
value = arr:pop([Count])
Removes and returns the last element(s) from the array. Count indicates the number of elements to pop (defaults to 1). Returns nil if the array is empty.
arr = array<int> { 1, 2, 3, 4, 5 }
a, b = arr:pop(2) -- a = 5, b = 4, arr = {1, 2, 3}Pop returns nil if used on an empty array. When popping GC-tracked types (strings, tables), the reference is cleared to allow garbage collection. Read-only arrays cannot be popped.
arr:clear()
Resets the array length to zero without deallocating storage. The capacity is preserved for efficient reuse.
For GC-tracked types (strings, tables), references are nullified to allow garbage collection. Read-only arrays cannot be cleared. Use fill(0) if the intention is to clear a newly allocated array.
new_length = arr:resize(NewSize)
Resizes an array to the specified length, growing or shrinking as needed. Returns the new length of the array.
When growing, new elements are zero-initialised for numeric types, or set to nil for reference types (strings, tables). When shrinking, excess elements are discarded and references are cleared for garbage collection.
arr = array<int> { 1, 2, 3 }
array.resize(arr, 7) -- arr = {1, 2, 3, 0, 0, 0, 0}
array.resize(arr, 2) -- arr = {1, 2}
array.resize(arr, 0) -- arr = {} (equivalent to clear)External arrays (wrapping C/C++ memory) and cached string arrays cannot grow and will raise an error. Read-only arrays cannot be resized.
arr:fill(Value, [Start], [Count])
arr:fill(Value, Range)
Fills array elements with a value. Value is the value to fill with. Start is a starting index (default: 0). Count is the number of elements to fill (default: all remaining). 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, 5) -- Fill elements 3-7 with value 99
arr:fill(42, {2 to 6}) -- Fills indices 2, 3, 4, 5newLength = arr:insert(Index, Value, ...)
Inserts one or more values at the specified index, shifting subsequent elements to make room. The array grows automatically if needed.
Index refers to the position to insert at. Must be between 0 and the array length (inclusive). Value is one or more values to insert, which must match the array's element type.
-- Insert multiple values
arr = array<int> { 1, 5 }
arr:insert(1, 2, 3, 4) -- arr = {1, 2, 3, 4, 5}newLength = arr:remove(Index [, Count])
Removes one or more elements at the specified Index, shifting subsequent elements down. Count is the number of elements to remove (defaults to 1) and is automatically limited to available elements.
-- Remove multiple elements
arr = array<int> { 1, 2, 3, 4, 5 }
arr:remove(1, 3) -- arr = {1, 5}, returns 2A Count of 0 does nothing and returns the current length. Negative Count values raise an error.
arr:reverse()
Reverses the array elements in place.
arr = array<int, 5>
for i = 0, 4 do arr[i] = i end -- {0, 1, 2, 3, 4}
arr:reverse() -- {4, 3, 2, 1, 0}arr:sort([Descending])
Sorts the array elements in place. If Descending is true, sort in descending order (default: false for ascending).
arr = array<int, 5>
arr[0] = 30; arr[1] = 10; arr[2] = 50; arr[3] = 20; arr[4] = 40
arr:sort() -- {10, 20, 30, 40, 50}bool = arr:contains(Value)
Returns true if the specified value exists in the array, false otherwise. This is a convenience wrapper around find() that returns a boolean instead of an index. String comparisons are case-sensitive.
value = arr:first()
Returns the first element of the array, or nil if the array is empty. Provides bounds-safe access without risking
index-out-of-bounds errors.
This method is equivalent to arr[0] but returns nil for empty arrays instead of raising an error.
value = arr:last()
Returns the last element of the array, or nil if the array is empty. Provides bounds-safe access without risking
index-out-of-bounds errors.
This method is equivalent to arr[#arr - 1] but returns nil for empty arrays instead of raising an error.
index = arr:find(Value, [Start], [End])
index = arr:find(Value, Range)
Searches for a Value in the array. Start is a starting index for search (defaults to 0). End is an ending index, exclusive (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:find(50, 6) -- Returns nil (50 is at index 5)
-- Search within a range
idx = arr:find(30, {0 to 5}) -- Returns 3arr: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).
-- Copy from another array
src = array<int, 10>
dst = array<int, 20>
dst:copy(src, 5, 0, 10) -- Copy 10 elements from src[0] to dst[5]
-- Copy from a string
bytes = array<byte, 100>
bytes:copy('Hello', 0)
-- Copy from a table
arr = array<int, 5>
arr:copy({10, 20, 30, 40, 50})str = arr:getString([Start], [Length])
Extracts a substring from a byte/char array. Start is the starting byte index (0-based). Length is the number of bytes to extract (defaults to all).
arr = array.new('Hello World')
print(arr:getString(0, 5)) -- Prints 'Hello'
print(arr:getString(6)) -- Prints 'World'count = arr:setString(String, [Start])
Copy String content into a byte array. Start is a starting index in the array (default: 0). The number of bytes written is returned.
arr = array<byte, 20>
arr:setString('Hello', 0)
arr:setString(' World', 5)
print(arr:getString(0, 11)) -- Prints 'Hello World'new_arr = arr:slice(Range)
Creates a new array containing a subset of elements. Range is a range object specifying which elements to extract. A new array containing the selected elements is returned.
arr = array<int, 10>
for i = 0, 9 do arr[i] = i * 10 end -- {0, 10, 20, 30, 40, 50, 60, 70, 80, 90}
sub = arr:slice({2 to 5}) -- {20, 30, 40}
sub = arr:slice({5 into 2}) -- {50, 40, 30, 20} Reverse slice
sub = arr:slice({-3 into -1}) -- {70, 80, 90} Negative inclusive slice
sub = arr:slice({-3 to -1}) -- {70, 80} Negative exclusive slicestr = arr:concat([Separator], [Format], [Start], [End])
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 End are optional zero-based, inclusive indexes
that limit the elements to concatenate.
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<string> { '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, 3) -- Result: '2-3-4'copy = arr:clone()
Creates a copy of the array. For primitive types (int, float, etc.), this is a deep copy. For reference types (strings, tables), the references are copied (shallow copy).
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.
-- Basic iteration
arr = array<int> { 10, 20, 30 }
arr:each(function(v, i)
print('Index ' .. i .. ': ' .. v)
end)
-- Output: Index 0: 10, Index 1: 20, Index 2: 30
-- Using arrow function syntax
arr:each(v => print(v))
-- Chaining
arr:each(v => print('Processing:', v))
:each(v => log('Logged:', v))
-- With statements in arrow function body
total = 0
arr:each(v => do total += v end)
print(total) -- 60new_array = arr:map(Transform)
Returns a new array with each element transformed by the given Transform function. The original array is not modified.
-- Double all values
arr = array<int> { 1, 2, 3, 4, 5 }
doubled = arr:map(v => v * 2)
-- String transformation
names = array<string> { 'hello', 'world' }
upper = names:map(s => s:upper())
-- Chaining map operations
result = arr:map(v => v * 2):map(v => v + 1)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<string> { 'apple', 'banana', 'apricot', 'cherry' }
a_words = names:filter(s => s:startsWith('a')) -- a_words = {'apple', 'apricot'}
-- Chained filtering
large_evens = arr:filter(v => v % 2 is 0):filter(v => v > 10)result = arr:reduce(Initial, Reducer)
Folds all array elements into a single accumulated value. The reducer function is called for each element, receiving the current accumulator, the element value, and the element index. Initial is the initial value for the accumulator (any type). Reducer is a function receiving (accumulator, value, index) and returning the new accumulator.
-- Sum all elements
arr = array<int> { 1, 2, 3, 4, 5 }
sum = arr:reduce(0, (acc, v) => acc + v) -- sum = 15
-- Product of elements
product = arr:reduce(1, (acc, v) => acc * v) -- product = 120
-- String concatenation
parts = array<string> { 'a', 'b', 'c' }
result = parts:reduce('', (acc, v) => acc .. v) -- result = 'abc'
-- Build a table
arr = array.<int> { 1, 2, 3 }
squares = arr:reduce({}, function(acc, v, i)
acc[i] = v * v
return acc
end)
-- squares = {[0]=1, [1]=4, [2]=9}
-- Find maximum
arr = array<int> { 3, 1, 4, 1, 5, 9, 2, 6 }
max = arr:reduce(arr:first(), (acc, v) => v > acc ? v :> acc) -- max = 9bool = arr:any(Predicate)
Returns true if any element in the array satisfies the Predicate function. Short-circuits on the first match, returning immediately without checking remaining elements. The Predicate arguments are (value, index) and must return a boolean.
-- Check if any element is even
arr = array<int> { 1, 3, 5, 6, 7 }
has_even = arr:any(v => v % 2 is 0) -- has_even = true (found 6)
-- Check if any element is negative
arr = array<int> { 1, 2, 3, 4, 5 }
has_negative = arr:any(v => v < 0) -- has_negative = false
-- String array
names = array<string> { 'apple', 'banana', 'cherry' }
has_long = names:any(s => #s > 6) -- has_long = true (banana, cherry)bool = arr:all(Predicate)
Returns true if all elements in the array satisfy the Predicate function. Short-circuits on the first failure, returning immediately without checking remaining elements. The Predicate arguments are (value, index) and must return a boolean.
-- Check if all elements are positive
arr = array<int> { 1, 2, 3, 4, 5 }
all_positive = arr:all(v => v > 0) -- all_positive = true
-- String validation
names = array<string> { 'abc', 'def', 'ghi' }
all_short = names:all(s => #s is 3) -- all_short = trueThe functional methods can be chained together to create data processing pipelines:
-- Filter, then map, then reduce
arr = array<int> { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }
result = arr:filter(v => v % 2 is 0) -- {2, 4, 6, 8, 10}
:map(v => v * 10) -- {20, 40, 60, 80, 100}
:reduce(0, (acc, v) => acc + v) -- 300
-- Check conditions on transformed data
has_large = arr:map(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))
:map(v => v * 2)
:each(v => log('After:', v))The following functions are included as standard so that messages can be logged to the console.
msg() accepts a single string parameter and prints it to the debug log. The log level must be set to api or higher in order to be visible.
If the log-level is not high enough to display the message, the call is silently discarded during parsing to maximise efficiency.
print() accepts a single string parameter and prints it to stdout. If stdout is unavailable on a system like Android, the message is printed to the debug log instead.
For targeted printing, use io.write() and target stderr or stdout instead of the print() function.
Tiri uses the @ prefix for two related parser features: parser annotation expressions and function annotations.
Parser annotation expressions expand to literal values during parsing. Function annotations attach metadata to
functions at parse time, providing a declarative way to mark functions with attributes that can be queried at runtime
for features such as test discovery, deprecation warnings, and capability requirements.
Parser annotation expressions are compile-time expressions that resolve to literal values while the source is parsed. They can be used anywhere an expression is valid. The generated bytecode receives the resolved string or number literal, so no runtime metadata lookup is required.
| Expression | Type | Resolved value |
|---|---|---|
@FunctionName |
str |
The enclosing function name. |
@SourceFile |
str |
The current source file path. |
@SourceLine |
num |
The 1-based source line number of the annotation expression. |
For @FunctionName, top-level code resolves to "Main". Named function declarations use the declared function name.
Method declarations use the method name, not the table or object 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()
return @FunctionName -- "update"
end
callback = () => @FunctionName -- "Anonymous"Only the parser annotation names listed above are valid in expression position. Other @Name forms are declaration
annotations and must appear before a function declaration or in the syntax documented below.
Annotations use the @ prefix and are placed immediately before a function declaration:
@AnnotationName
function myFunction()
-- function body
endAnnotations can include named arguments in parentheses:
@Test(name="My Test Case", timeout=5)
function testSomething()
-- test body
endSupported Argument Types:
| Type | Example |
|---|---|
| String | name='Test Name' |
| Number |
timeout=5.0, priority=1
|
| Boolean |
enabled=true, network=false
|
| Array | labels=['unit', 'smoke', 'critical'] |
| Bare Identifier |
deprecated (equivalent to deprecated=true) |
Multiple Annotations:
Multiple annotations can be stacked on a single function:
@Test(name='Network Test')
@Requires(network=true)
function testNetworkFeature()
-- test body
endAnnotations can also be placed on the same line using semicolons:
@BeforeEach; @Requires(network=true)
function setupNetwork()
-- setup body
endFunction Types:
Annotations work with all function declaration styles:
-- Regular functions
@Test function regularFunc() end
-- Local functions
@Test local function localFunc() end
-- Global functions
@Test global function globalFunc() endThe @Doc annotation attaches source documentation to a function. It is primarily intended for tooling such as the Tiri LSP and API documentation extraction. When debug.validate(Source, "symbols") 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=...).
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")
symbol = result.symbols[0]
print(symbol.signature) -- greet(Name: str):str
print(symbol.doc.summary) -- Returns a greeting.
print(symbol.params[0].type) -- str
print(symbol.params[0].doc) -- Person to greetLeading whitespace is removed from each documentation line before parsing, so @Doc blocks may be indented with the
function they document.
Long Form:
The long form uses marker sections. Text before the first marker becomes the summary/body text. Marker names are matched exactly.
| Marker | Line Format | Description |
|---|---|---|
-INPUT- |
Name: Description |
Documents a parameter. Signature names and types are merged into the symbol data. |
-RESULTS- |
type: Description |
Documents a result value. May be repeated for multi-result functions. |
-ERRORS- |
Code: Description |
Documents an error code that may be thrown or returned. |
-EXAMPLE- |
free-form text | Provides example code until the next marker or end of the documentation block. |
Short Form:
If the first non-empty documentation line starts with @, the compact parser is used. Compact entries are strictly one line each; descriptions and examples cannot span multiple lines.
@Doc(text=[[
@desc Returns a greeting.
@input Person to greet
@result Greeting text
@result Additional argument
@error Args: If the name is invalid
@error Failed: Random failure
]])
function greetCompact(Name: str):<str, str>
return "Hello " .. Name, "extra"
end| Entry | Line Format | Description |
|---|---|---|
@desc |
@desc Description |
Sets the function summary. |
@input |
@input Description |
Documents the next parameter in function declaration order. |
@result |
@result Description |
Documents one result value. Types are taken from the signature or inferred from the function body, falling back to any. |
@error |
@error Code: Description |
Documents one error code. |
For normal script execution, large @Doc payloads are omitted from runtime annotation registration unless the script is configured to process documentation. Tooling that calls debug.validate(Source, "symbols") enables documentation processing for that validation pass.
The @if ... @end construct provides a compile-time pre-processor, allowing code to be included or excluded based on conditions evaluated during parsing. This allows you to quickly eliminate the compilation of bytecode that the program doesn't need.
Basic Syntax:
@if(condition=value)
-- Code included only when condition matches
@endSupported Conditions:
| Condition | Value Type | Description |
|---|---|---|
imported |
boolean |
true when file is being imported; false when file is the main script. |
debug |
boolean |
true when log level is higher than 'warning'; false otherwise. |
platform |
string | Matches against current platform: "windows", "linux", "osx", "native". |
exists |
string | Checks for existence of a file at compile time: exists='path/to/file'. The path is calculated relative to the script. |
Examples:
-- Platform-specific code
@if(platform="windows")
path_separator = "\\"
@end
@if(platform="linux")
path_separator = "/"
@end
-- Debug-only code (only included when logging is enabled)
@if(debug=true)
print("Debug: Entering critical section")
@endNesting:
Compile-time conditionals can be nested:
@if(imported=false)
@if(platform="windows")
print("Running as main script on Windows")
@end
@endThe following annotations are recommended for general-purpose code organisation:
| Annotation | Arguments | Description |
|---|---|---|
@Doc |
text:str |
Attaches parser-readable documentation to a function for LSP support and documentation extraction. |
@Deprecated |
message:str, since:str
|
Marks a function as deprecated. Tools may emit warnings when deprecated functions are called. |
@Override |
(none) | Indicates that a function overrides a parent implementation. Useful for documentation and tooling. |
@SuppressWarnings |
<flags> |
Suppresses specific warnings. Flags are bare identifiers: @SuppressWarnings(unused, deprecated)
|
Examples:
@Deprecated(message='Use newApi() instead', since='2.0')
function oldApi()
return newApi()
end
@Override
function customBehaviour()
-- Override parent implementation
end
@SuppressWarnings(unused, experimental)
function internalHelper()
-- Implementation
endThe Flute test framework recognises the following annotations for test discovery and configuration:
| Annotation | Arguments | Description |
|---|---|---|
@Test |
name:str, timeout:num, priority:num, labels:array
|
Marks a function as a test case |
@BeforeEach |
(none) | Runs before each test in the file |
@AfterEach |
(none) | Runs after each test in the file |
@BeforeAll |
(none) | Runs once before all tests in the file |
@AfterAll |
(none) | Runs once after all tests in the file |
@Disabled |
reason:str |
Skips the test with an optional reason |
@Requires |
display:bool, network:bool, audio:bool
|
Specifies runtime requirements; test is skipped if requirements are not met |
Test Argument Reference:
| Argument | Type | Default | Description |
|---|---|---|---|
name |
string | function name | Display name for the test |
timeout |
number | 3.0 | Maximum execution time in seconds |
priority |
number | 0 | Execution order (lower runs first) |
labels |
array | [] |
Tags for filtering tests (e.g., ['unit', 'smoke']) |
Requirements Reference:
| Requirement | Description |
|---|---|
audio |
Requires working audio module |
display |
Requires working display module |
font |
Requires working font module |
network |
Requires working network module |
ssl |
Requires SSL to be built-in to network module |
Examples:
@Test(name='User Login', labels=['integration', 'auth'])
@Requires(network=true)
function testUserLogin()
-- Test implementation
end
@BeforeEach
function setupTestEnvironment()
-- Runs before each test
end
@Disabled(reason='Pending implementation')
@Test
function testFutureFeature()
-- Will be skipped
endThe debug.fileSources() function provides access to the file source tracking system, which maintains metadata about all source files involved in the current script execution. This is particularly useful for debugging, tooling, and understanding import hierarchies.
array = debug.fileSources()
Returns an array of all registered file sources for the current script execution. Each entry in the returned array contains the following fields:
| Field | Type | Description |
|---|---|---|
index |
number | File index (0 = main file, 255 = overflow fallback) |
path |
string | Full resolved path to the source file |
filename |
string | Short filename for error display |
namespace |
string | Declared namespace (empty string if none) |
firstLine |
number | First line number in unified line space |
sourceLines |
number | Total number of lines in the source file |
parentIndex |
number | Index of the file that imported this one (0 for main) |
importLine |
number | Line number in parent where import occurred (0 for main) |
isOverflow |
boolean | True if this is the overflow fallback entry (index 255) |
Example - List All File Sources:
sources = debug.fileSources()
print("File sources:")
for i = 0, #sources - 1 do
src = sources[i]
print(f" [{src.index}] {src.filename}")
print(f" path: {src.path}")
if src.namespace != "" then
print(f" namespace: {src.namespace}")
end
if src.parentIndex != 0 or src.importLine != 0 then
print(f" imported from [{src.parentIndex}] at line {src.importLine}")
end
endExample - Correlate with debug.getInfo():
The debug.getInfo() function returns a fileIndex field when the 'S' option is used. This index can be used to look up the corresponding file source:
sources = debug.fileSources()
info = debug.getInfo(1, "S") -- Get info about current function
if info.fileIndex then
local src = sources[info.fileIndex]
print(f"Current function is in: {src.filename}")
print(f"Full path: {src.path}")
endNotes:
- 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
fileIndexfield indebug.getInfo()results corresponds to indices in this array
The debug.anno interface provides programmatic access to function annotations at runtime.
Registers annotations for a function. Returns the created entry table.
Parameters:
| Parameter | Type | Description |
|---|---|---|
func |
function | The function to annotate |
annotations |
string or table | Annotation data (see below) |
source |
string | Source file path (default: '<runtime>') |
name |
string | Function name (default: inferred or '<anonymous>') |
Annotation Formats:
Table format:
debug.anno.set(myFunc, {
{ name = 'Test', args = { name = 'My Test', labels = { 'unit' } } },
{ name = 'Requires', args = { network = true } }
}, 'myfile.tiri', 'myFunc')String format (parsed):
debug.anno.set(myFunc, '@Test(name='My Test'); @Requires(network=true)')Returns: Entry table with fields:
-
name: Function name -
source: Source file path -
annotations: Array of annotation tables
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
endReturns a shallow copy of all registered annotations.
Returns: Table mapping function references to their entry tables.
all = debug.anno.list()
for func, entry in pairs(all) do
print(entry.name .. ' has ' .. #entry.annotations .. ' annotations')
endEntry Table Structure:
{
name = 'functionName', -- Function name
source = 'path/to/file.tiri', -- Source file
annotations = { -- Array of annotations
{
name = 'Test', -- Annotation name
args = { -- Named arguments
name = 'Test Name',
timeout = 5,
labels = { 'unit', 'smoke' }
}
}
}
}Tiri programs can receive system-wide events whenever they are broadcast. Two functions are provided for the purpose of event management. The first is subscribeEvent(), which will connect a Tiri function to a specific event:
error, handle = subscribeEvent(eventname, function)
The eventname is a string that must follow the format group.subgroup.name, for example system.task.created. Valid event strings are described in full detail in the Events Manual of the Kōtuku SDK. A single asterisk wildcard is allowed in the subgroup and/or name if listening to multiple events is desirable, for example system.*.* would listen for all system events. The referenced function will receive two arguments if the event is signalled - EventID and Args. The Args parameter is a table containing named parameters - if the event does not include any parameters then the table will be empty.
To unsubscribe from an event, call unsubscribeEvent() with the event handle that was returned by the initial subscribeEvent() call:
unsubscribeEvent(handle)
Tiri is developed by Paul Manias and runs on Mike Pall's LuaJIT framework, which in turn is based on the Lua programming language.
Lua is designed and implemented by a team at PUC-Rio, the Pontifical Catholic University of Rio de Janeiro in Brazil. Lua was born and raised at Tecgraf, the Computer Graphics Technology Group of PUC-Rio, and is now housed at Lua.org. Both Tecgraf and Lua.org are laboratories of the Department of Computer Science.