Fluid 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 the Parasol Framework, 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.
Fluid is fully interoperable with Parasol's C++ APIs thanks to our interfaces being self-describing as a requirement. You can therefore read our online API documentation with confidence that the mechanics are identical whichever language is being used.
This manual expands on the information in the existing Lua Reference Manual, and covers features that are exclusive to Fluid. 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.
For more information on the usage of available classes and modules, please refer to the Parasol API at www.parasol.ws.
For general information on the syntax provided by Lua, please read the following online manuals:
To run a Fluid script, use the parasol executable:
parasol myfile.fluid
Named arguments can be passed to the Fluid script by following the script location with a series of variable values:
parasol myfile.fluid name='John' surname=Bloggs
To debug a Fluid script, use the --log-api parameter.
For further information on available options, execute parasol with the --help parameter.
Fluid files can use the file extension .lua or .fluid for identification. Ideally, scripts should start with the comment -- $FLUID near the start of the document so that they can be correctly identified by the Fluid parser.
A number of extensions have been added (and some features removed) to Lua 5.2 in order to add value to the Fluid language. This section examines the major changes in regards to the published Lua Reference Manual.
The Package, IO and OS libraries normally found in Lua are removed as they duplicate features already found in Parasol's Script, File and Time classes. The introspective debug library is also removed for the purpose of reducing size.
The following Lua/LuaJIT features are unsupported or modified:
~= not equal operator; replaced with !=== equality operator; replaced with isgoto and ::label:: statements; superceded by continue, break, deferbit.* library; replaced with native bitwise operatorsselect(); replaced with result masks [_*]loadstring(); replaced with load()dofile(); replaced with loadFile()getfenv(), setfenv(), gcinfo(), table.maxn(), unpack()global keyword for globalsFluid supports C-style bitwise operators on 32-bit integers:
~x bitwise NOTa & b bitwise ANDa | b bitwise ORResults 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 & 0xFFFluid adds infix bitshift operators for convenience:
<< left shift>> right shiftPrecedence and associativity:
1 + 1 << 3 evaluates as 1 + (1 << 3) producing 9.8 >> 1 + 1 produces 2.x << y << z parses as (x << y) << z.Fluid supports Unicode alternatives for 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 |
≤ |
<= |
Less than or equal |
≥ |
>= |
Greater than or equal |
« |
<< |
Left shift |
» |
>> |
Right shift |
‥ |
.. |
Concatenation |
… |
... |
Varargs (horizontal ellipsis) |
⁇ |
?? |
Null coalescing (if-empty) |
▷ |
:> |
Ternary separator |
⧺ |
++ |
Increment |
| Unicode | ASCII | Description |
|---|---|---|
× |
* |
Multiplication |
÷ |
/ |
Division |
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
-- Varargs
function process(…)
return {…}
end
-- 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 Fluid 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.
Fluid adds C-style compound assignment operators for convenience:
+=, -=, *=, /=, %= on numeric values..= for string concatenationRHS behaviour:
Errors and types:
The ..= operator appends to an existing string:
s = 'a'
s ..= 'bc' -- s is 'abc'
s ..= tostring(1) -- s is 'abc1'Semantics mirror s = s .. rhs. The left-hand side is evaluated once and the RHS uses only its first return value.
Fluid supports a postfix increment operator for convenience:
counter++
obj.field++
t[i]++Notes:
x++ expression itself is unspecified and should not be relied upon in expressions.=> provides concise anonymous function syntax. Single-expression bodies are implicitly returned; multi-statement bodies use do ... end with an explicit return.
value => value * 2(left, right) => left + right() => 42function(...) when needed.Examples:
double = n => n * 2
adder = (a, b) => a + b
on_click = () => do
print('Clicked')
return true
end
numbers = {1..10} |> map(i => i * 3) |> filter(i => i > 10)Arrow bodies bind loosely, so the expression after => extends as far right as possible.
Fluid adds a continue statement for all loop forms:
for {1..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:
defer blocks (both in LIFO order)return, break, continue, and error unwindingError propagation:
When an error is thrown and caught by pcall/xpcall, 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:
ok, err = pcall(function()
file <close> = open_file()
risky_operation() -- If this throws, file.__close still runs
end)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 is nil
-- Multiple return values preserved
a, b = obj?.getTwoValues() -- Both a and b will be nil if obj is nil
-- Safe index access
value = table?[key] -- Returns nil if table is nilThe safe navigation operator works seamlessly with:
??): obj?.field ?? "default"obj?.a?.b?.cFluid 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)
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 |
cdata |
FFI data (if FFI is enabled) |
obj |
Parasol objects (userdata) |
Unknown type names raise diagnostics during parsing with the UnknownTypeName error code.
Fluid 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 -- Error: 'counter' from outer scope not visible here
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 = "1.0" -- Assigns to the global
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'
endGlobal Functions:
Declaring a function with the global keyword makes it accessible from the parent scope. This feature is normally
used in module scripts to expose functions to the caller.
Global Declaration Rules:
global declarations must precede any use of the variable namelocalglobal keyword can appear at any scope level, but the variable becomes globally visibleThe blank identifier _ allows you to explicitly ignore values in assignments and loop variables:
-- Ignore error from function call
file, _ := openFile("data.txt")
-- Ignore multiple return values
_, _, result := getValues()
-- Loop without index
for _, value in ipairs(items) do
print(value)
end
-- Works with pairs() too
for _, v in pairs(table) do
process(v)
end
-- Multiple positions
local x, _, y = 1, 2, 3 -- x=1, y=3Notes:
local x = _ is an error):=), regular assignments, and for loopsThe 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:
x |> 5 is a syntax errorand, or) but lower than comparison operatorsa |> b() |> c() evaluates as a |> (b() |> c())obj |> obj:method() (not obj |> 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..5} |> print -- Prints 1, 2, 3, 4
{1...5} |> print -- Prints 1, 2, 3, 4, 5
-- With anonymous function
{0..10} |> function(i)
if i % 2 is 0 then print(i, 'is even') end
endThe pipe returns the original range, enabling chaining:
{1..10} |> i => log('Processing', i)
|> i => validate(i)
|> i => store(i)Return false from the callback to terminate early:
{1..1000} |> i => do
if found_target(i) then
print('Found at', i)
return false -- Stop iterating
end
endPipe Iteration Notes:
{start..stop}) trigger automatic iteration at parse time:each() method directly: r:each(func){1..5} |> func1 |> func2 calls both functions for each valueThe result filter operator ([mask]) provides selective extraction of return values from multi-value function calls. It uses a prefix bracket syntax with a mask of _ (drop) and * (keep) characters to specify which values to retain.
Basic Syntax:
result = [mask]function_call()The mask is placed inside square brackets immediately before a function call. Each character in the mask corresponds to a return value position:
_ drops the value at that position* keeps the value at that positionExamples:
function multi()
return 1, 2, 3, 4, 5
end
-- Keep all values (default behaviour)
local a, b, c, d, e = [*]multi() -- a=1, b=2, c=3, d=4, e=5
-- Drop first value, keep the rest
local a, b, c, d = [_*]multi() -- a=2, b=3, c=4, d=5
-- Drop first two values, keep the rest
local a, b, c = [__*]multi() -- a=3, b=4, c=5
-- Keep first value only, drop all others
local a, b = [*_]multi() -- a=1, b=nil
-- Drop first, keep second, drop the rest
local a, b = [_*_]multi() -- a=2, b=nil
-- Keep values at positions 2, 3, 4 only
local a, b, c, d = [_***_]multi() -- a=2, b=3, c=4, d=nil
-- Keep second and fourth values onwards
local a, b, c = [_*_*]multi() -- a=2, b=4, c=5
-- Empty filter drops all values
local x = []multi() -- x=nilWith Method Calls:
obj = {
method = function(self)
return 10, 20, 30
end
}
second = [_*]obj:method() -- second=20Use Cases:
The result filter is particularly useful when:
-- Skip error code, get file content directly
content = [_*]file.acRead()
-- Get only the second and third return values
local b, c = [_**_]get_stats()Notes:
[_*]variable is a syntax errorThe defer statement schedules a function to execute when the enclosing scope exits. Deferred functions execute in LIFO order (last deferred, first executed) and are guaranteed to run on normal scope exit, early return, break, or continue.
Basic syntax (no arguments):
function example()
file = io.open("data.txt")
defer
file:close()
end
-- ... use file ...
end -- file:close() executes hereWith argument snapshot:
function example()
status = "initial"
defer(s)
print("Final status: " .. s)
end(status)
status = "modified"
end -- prints "Final status: initial"Multiple defers execute in reverse order:
defer
print("third")
end
defer
print("second")
end
defer
print("first")
end
-- Output: "first", "second", "third"Execution guarantees:
return, break, or continueend(...) are snapshotted at registration timeLimitations:
Deferred expressions provide lazy evaluation of expressions, delaying computation until the value is actually accessed. This is particularly useful for avoiding expensive computations in conditional parameters or logging statements that may not be executed.
Basic syntax:
<type{ expression }> or <{ expression }>The expression inside <{ }> is not evaluated immediately. Instead, evaluation is deferred until the value is accessed through standard API functions or explicitly resolved.
Consider this common pattern:
some_function(conditional_value, "Error occurred: " .. expensive_debug_info())The string concatenation and expensive_debug_info() are processed immediately irrespective of whether some_function() will use the computed variable. With deferred expressions:
some_function(conditional_value, <{ "Error occurred: " .. expensive_debug_info() }>)The expensive computation only occurs if the assertion fails and the message is accessed.
Type Inference:
Fluid automatically infers types from deferred expressions:
str = <{ 'hello' }> -- Inferred as string
num = <{ 42 }> -- Inferred as number
bool = <{ true }> -- Inferred as boolean
tbl = <{ {} }> -- Inferred as table
result = <{ a + b }> -- Inferred as number (arithmetic)
msg = <{ s .. t }> -- Inferred as string (concatenation)Explicit Type Annotation:
When type cannot be inferred (such as function calls), use explicit type annotation:
<str{ getValue() }>
<num{ compute() }>The resolve() Function:
Use resolve() to explicitly evaluate a deferred expression:
lazy = <{ expensive_computation() }>
-- Later, when you need the value:
value = resolve(lazy)The resolve() function returns non-deferred values unchanged, making it safe to call on any value.
Single Evaluation:
Once evaluated, the result is cached in the variable. Subsequent access returns the cached value without re-evaluation:
count = 0
x = <{ count++; count }>
print(resolve(x)) -- Prints 1, increments count and caches result back in x
print(resolve(x)) -- Prints 1 again, uses cached resultSingle-evaluation is a super-power for deferred expressions if used correctly, and offers creative programming opportunities. For instance:
glSelf = <obj{ obj.find('self') }> -- Executes once, does nothing if never used.
activate_object = <num{ object.acActivate() }> -- On resolution stores the ERR code permanentlyImportant Notes:
type() on a deferred expression will return the type associated with the expression without evaluating it.Thunk functions extend deferred expressions to support parameterised lazy evaluation. While deferred expressions wrap a single expression, thunk functions allow you to define reusable lazy computations with parameters.
Syntax:
thunk name(params):type
-- body
return value
endThe thunk keyword declares a function that, when called, is primed by capturing its arguments and returns a deferred value. The body is not executed until the result is accessed through a read operation or explicitly resolved.
Example - Lazy Database Query:
thunk fetch_user(id):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):str
return generate_annual_report(year) -- Only runs if report is actually used
end
report = expensive_report(2024) -- Store reference to thunk with 2024 parameter value
if user_requested_report then
print(report) -- Report generated only when needed
endAnonymous Thunks:
Anonymous thunks can skip the invocation process if they are parameterless:
local isAvailable = thunk():bool -- Prepare isAvailable without executing the body
-- Do something --
return true
end
print(isAvailable) -- Resolved and cached here, no need to prime with isAvailable()
If one or more parameters are specified in the thunk signature then this feature does not apply.
Key Characteristics:
type() returns the declared type without executing the bodyresolve() for explicit evaluationFluid 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')It also works as a guard for controlling program flow with return, break and continue:
user_input ?? return ERR_InvalidInputNotes
or treats only nil and false as falsey. Values like 0 and "" are considered truthy.?? operator treats 0 and "" as falsey.?? operator has the same precedence as or and and (lowest priority).or and and.?? are intentionally forbidden.If the ?? operator is used in the absence of a value to its right-hand-side, it is treated as a postfix operator. In this mode it will return a boolean indicating whether a value is present, using extended falsey semantics. It returns false for nil, false, 0, and "" (empty string), and true for all other values.
-- Basic usage
if comment?? then
print("Comment exists")
end
-- In expressions
greeting = name?? and "Hello, " .. name or "Hello, Guest"
-- With field access and expressions
if config.timeout?? then ... end
if (x + y)?? then ... endAs a postfix operator, ?? has high precedence and evaluates before logical operators.
The use of a single ? can be complemented with a C-style ternary conditional operator :> that creates an expression equivalent to an if-then-else statement.
result = condition ? true_expr :> false_expr
The full ternary evaluates condition using falsey semantics. 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"Notes:
or, and, ? (lowest priority)a ? b :> c ? d :> e parses as a ? b :> (c ? d :> e)local a, b = (cond ? x :> y), zFluid 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)
Start up to, but not including, Stop.range(Start, Stop, Inclusive)
Inclusive is true, the Stop value is included.range(Start, Stop, Inclusive, Step)
Step must be a non‑zero integer.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.#r or r.length — number of elements in the range.range.check(Value) — returns true when Value is a range; false otherwise.Ranges report their type as userdata:
r = range(0, 5)
assert(type(r) is "userdata")
assert(range.check(r) is true)Fluid adds literal syntax for constructing ranges using braces and dot notation:
r1 = {0..5} -- Exclusive: 0,1,2,3,4
r2 = {0...5} -- Inclusive: 0,1,2,3,4,5
r3 = {10..1} -- Reverse exclusive: 10,9,8,...,2
r4 = {5...1} -- Reverse inclusive: 5,4,3,2,1Rules:
{Start..Stop} creates an exclusive range.{Start...Stop} creates an inclusive range.Start > Stop) automatically infer a negative step.{x+1..y*2} is invalid.Literal ranges are fully compatible with the constructor:
assert({0..5} is range(0, 5))
assert({0...5} is range(0, 5, true))Invalid literal operands (non‑numeric or nil) will raise an error at runtime when the underlying range() call is evaluated.
Ranges support Fluid'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..6} do
print(i) -- 1,2,3,4,5
end
for i in {1...5} do
print(i) -- 1,2,3,4,5
end
for i in {5..1} do
print(i) -- 5,4,3,2 (exclusive)
end
for i in {5...1} do
print(i) -- 5,4,3,2,1 (inclusive)
endAnonymous loops omit the need for a variable store:
for {0..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..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
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 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..4}) -- {20, 30, 40}
result = range.slice(t, {0...4}) -- {10, 20, 30, 40, 50}
-- String slicing
s = "Hello, World!"
result = range.slice(s, {0..5}) -- "Hello"
result = range.slice(s, {-6...-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...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..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..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..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..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..11}:filter(i => i % 2 is 0)
-- evens = {2, 4, 6, 8, 10}
-- Filter with more complex logic
scores = {0..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...5}:reduce(0, (acc, i) => acc + i)
-- sum = 15
-- Calculate factorial of 5
factorial = {1...5}:reduce(1, (acc, i) => acc * i)
-- factorial = 120
-- Build a comma-separated string
csv = {1..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..6}:map(i => i * 2)
-- doubled = {2, 4, 6, 8, 10}
-- Convert to strings with formatting
labels = {1..4}:map(i => "Item " .. tostring(i))
-- labels = {"Item 1", "Item 2", "Item 3"}
-- Calculate squares
squares = {1...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..100}:take(3)
-- first3 = {1, 2, 3}
-- Works with reverse ranges
last3 = {10..0}:take(3)
-- last3 = {10, 9, 8}
-- If count exceeds range length, returns all available values
all = {1..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..10}:any(i => i > 5)
-- hasLarge = true
-- Check if any value is negative
hasNegative = {0..100}:any(i => i < 0)
-- hasNegative = false
-- Efficient: stops at first match
found = {1..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..10}:all(i => i > 0)
-- allPositive = true
-- Check if all values are less than 5
allSmall = {1..10}:all(i => i < 5)
-- allSmall = false (fails at 5)
-- Empty ranges return true (vacuous truth)
emptyCheck = {5..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..10}:find(i => i > 5)
-- first = 6
-- Find first even number
firstEven = {1..10}:find(i => i % 2 is 0)
-- firstEven = 2
-- Returns nil when not found
notFound = {1..10}:find(i => i > 100)
-- notFound = nil
-- Works with reverse ranges
fromEnd = {10..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..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..5}] is "Hello") -- Exclusive: indices 0..4
assert(s[{7..12}] is "World") -- Exclusive: indices 7..11
assert(s[{0...4}] is "Hello") -- Inclusive: indices 0..4
assert(s[{-6..-1}] is "World!") -- Negative indices (always inclusive)Out‑of‑bounds and empty ranges behave predictably:
start equals stop yield an empty string.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..4}] -- Returns {20, 30, 40} (exclusive)
subset = t[{1...3}] -- Returns {20, 30, 40} (inclusive)
subset = t[{0..5}] -- Returns {10, 20, 30, 40, 50} (full table)
subset = t[{-3..-1}] -- Returns {30, 40, 50} (negative indices, always inclusive)Negative Indices:
When either the start or stop index is negative, the range is interpreted as inclusive regardless of the .. or ...
syntax used. Negative indices count backwards from the end of the table.
t = {10, 20, 30, 40, 50}
t[{-2..-1}] -- Returns {40, 50} (inclusive due to negative indices)
t[{1...-1}] -- Returns {20, 30, 40, 50} (inclusive due to negative stop)
t[{-3...4}] -- Returns {30, 40, 50} (inclusive due to negative start)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..0}] -- Returns {50, 40, 30, 20} (reverse exclusive)
t[{4...0}] -- Returns {50, 40, 30, 20, 10} (reverse inclusive)
t[{3..1}] -- Returns {40, 30} (reverse partial)Step Support:
For stepped ranges, use the range() constructor. Range literals ({start..stop}) do not support the step parameter.
t = {10, 20, 30, 40, 50, 60, 70}
r = range(0, 7, true, 2) -- Every 2nd element
t[r] -- Returns {10, 30, 50, 70}
r = range(6, 0, true, -2) -- Reverse with step
t[r] -- Returns {70, 50, 30, 10}Out-of-Bounds and Empty Ranges:
start equals stop return an empty tablet = {10, 20, 30}
t[{0..10}] -- Returns {10, 20, 30} (end clipped)
t[{-10..3}] -- Returns {10, 20, 30} (start clipped to 0)
t[{2..2}] -- Returns {} (empty exclusive range)
t[{5..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..3}] -- Returns {10, 20, 30} (slicing still works)Fluid 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..10} -- Exclusive: 0–9
ri = {0...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..10} then
print("in range")
end
if not (11 in {0..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:
status) is evaluated exactly once.else is optional. If omitted and no case matches, the result is nil (expression context) and no action is taken
(statement context).else must be the final case. An else-only choose is valid and always matches.Patterns:
nil match using is semantics (no implicit type coercion)._ matches anything and is typically used as a catch-all.< Expr, <= Expr, > Expr, >= Expr use normal relational operators.{ key = value, ... } match tables using open-record semantics:
is{} matches any table value (but does not match non-table values)(p0, p1, ...) match tuples created from multiple scrutinee values:
choose (a, b) from ... end evaluates a and b once and matches by arity(x) is a parenthesised expression; a tuple requires a commaGuards:
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:
< 30 before < 60).NaN never matches literal numeric patterns (nan is nan is false). Use _ or a guard if you need to handle it.choose expressions.The include statement 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.
The require statement will load a Fluid language file from the scripts: volume and then executes it. It differs from loadFile() in that the script will be registered so as to prevent multiple executions, and the volume restriction improves security.
The chosen library may return a table as an interface - a practice we recommend as it allows the client to define the namespace and avoid conflicts with program variables. E.g.
mylib = require 'mylibrary'
mylib.doSomething()If you're building a complex application and need to include a custom library file, this can be achieved with the ./ prefix. Standard path rules still apply, e.g. ./lib/customlib would be valid, ./../../h4cklib!3 is not.
Use loadFile() to load, parse and execute a Fluid script. This feature is useful for re-using code, or breaking a large project into multiple script files. Example:
loadFile('programs:tools/project/example.fluid')
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.
Use exec() to parse a Fluid statement and execute it. Can raise an exception if the statement is unparseable or fails during execution. Example:
exec('print("Hello World")')
Calls to the Parasol API do not raise exceptions by default, instead opting to return ERR codes that can be managed by the client (or ignored). Promoting ERR codes to exceptions can be a useful at times, so some functionality is provided to enable this feature as seen in the functions described below.
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 automatically treated as a deferred expression, so any expensive computation will be avoided if the assertion passes.
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 if called from a C/C++ program.
error() is the standard function for raising exceptions. It should be used when raising an exception with a custom error message, e.g. error('I have failed.').
Use catch() to switch on exception handling for functions that return ERR codes, as well as regular exceptions that would otherwise be caught by pcall(). Affected code includes: obj.new(); any module function that returns an ERR type; and any method or action call.
catch() is typically called with two parameters - the first being a custom function that will be executed, and the second a function handler that will be activated if an exception is raised. The handler will receive an Exception parameter that contains fields that describe what happened. The fields are code for the ERR code (if any); message describes the exception; line is the line number that raised the fault.
err, result = catch(function()
-- Code to execute
return 'success'
end,
function(Exception)
print('Code: ' .. (Exception.code ?? 'LUA') .. ', Message: ' .. Exception.message)
end)
Sometimes it can be convenient to catch only specific error codes of interest, which can be achieved by listing them just prior to the handler reference:
err, result = catch(function()
-- Code to execute
return 'success'
end,
{ ERR_Failed, ERR_Terminate }, -- Errors to catch
fuction(Exception)
end)
Calling catch() without an exception handler will handle the exception silently and return the Exception table for further inspection, or nil if the calls was successful:
exception, result = catch(function()
-- Code to execute
return 'success'
end)
if exception then
print('Code: ' .. tostring(exception.code) .. ', Message: ' .. exception.message)
end
In all other cases the ERR code is returned by default.
Import Notes:
ERR codes predictably, the scope of the catch does not extend to any sub-routines that are called. This rule does not affect regular exceptions.ERR codes are not treated as exceptions: Okay, False, LimitedSuccess, Cancelled, NothingDone, Continue, Skip, Retry, DirEmpty.The module interface provides a means for Fluid programs to communicate with Parasol'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 Fluid 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, Fluid 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, Fluid 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.new(file.size, 'byte')
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 Fluid 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 Fluid, 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 Fluid:
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 providing a field list 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 our 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 Fluid 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 against problems with pcall() or catch(), 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.
Parasol 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 Fluid 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 they are identical, but the key difference is that 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. If an object is loosely coupled to a script due to such circumstances, you can call the exists() method at any time on the object's interface to confirm it is not destroyed.
You can get 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.
It is possible to access the interface of an existing object by searching for its name (if given one on initialisation) or UID:
window = obj.find('my_window')
winsurface = obj.find(window.surface)If failure occurs, nil is returned by the find() method. If multiple objects exist with the same name, the most recently created object is returned first.
If an object is being shared in a threaded program, locking may be necessary to prevent data corruption. This can be achieved by calling lock() and passing a function that will gain an exclusive lock on the object. The lock will remain until the function returns.
obj:lock(function()
--Code--
end)
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 = nilSometimes you may need to create an object that remains after the script has executed and is not removed by falling out of scope. Calling detach() will unlink the object from the garbage collector.
Call free() to terminate an object resource immediately without removing the interface. If the object is detached then free() is the most practical means of removal.
Calling free() is not recommended in general usage - it is preferable to set object references to nil and either wait for garbage collection or call collectgarbage().
After successfully initialising an object interface, interaction with it is possible through actions, methods and fields. The following example illustrates how we could change the size of the VectorRectangle we created earlier:
rect.acRedimension(20, 20, 0, 100, 100, 0)Action calls are identified by their ac prefix. Method calls are prefixed by mt. Field references can be directly accessed without a prefix. The naming scheme for methods, actions and fields is case insensitive.
For information about the actions and methods supported by an object class, refer to its published API documentation on our website.
Object fields are accessible by conventional means, i.e. thing = my_object.field_name and my_object.field_name = thing for get and set respectively. There are get() and set() methods that are equivalent to these, and they can be useful in special circumstances such as retrieving a value from an array-based field without first retrieving the entire array:
item = view.get('item(10)')
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')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.
To test for the presence of a bit in a flag-based field, the following template supported by get() can be used:
state = object.get('flagfield.flagname')
For example:
state = surface.get('flags.visible')
If the named flag is set, a value of 1 is returned, otherwise 0.
To get the value of a flag or lookup field in string format, prefix the name of the field with a $ symbol when using the get() method as follows:
name = object.get('$flags')
Parasol 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 Fluid 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.
thread.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 Fluid 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)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 Parasol interfaces.
str:cap()
Recreate a string with the first character in upper case.
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: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([Pattern])
Split a string using Pattern to identify each valid word. If Pattern is not specified, it defaults to %s.
str:startsWith(Cmp)
Returns true if the string starts with Cmp.
str:endsWith(Cmp)
Returns true if the string ends with Cmp.
Fluid 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.
thread.script([[
print('Thread is now in session.')
]],
function()
print('Thread has finished executing.')
end)
The thread.action() and thread.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)
thread.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 Fluid can use C/C++ structures declared in the Parasol 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 Parasol 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 Fluid 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:
myregex = 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 catch() 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.DOTALL |
. matches newlines |
Flags can be combined using addition: regex.ICASE + regex.MULTILINE.
The following 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'Use the test() method to perform boolean pattern matching:
result = myregex.test(text, [flags])
This returns true if the pattern matches anywhere in the text, false otherwise.
phoneRegex = regex.new('\\d{3}-\\d{3}-\\d{4}')
if phoneRegex.test(userInput) then
print('Valid phone number format')
endUse the match() method to perform a whole-string match and return capture groups:
matches = myregex.match(text, [flags])
Returns an array containing the full match and any capture groups, or nil if no match is found. Array indices start at 1 (full match), with capture groups at indices 2, 3, etc.
urlRegex = regex.new('(https?)://([^/]+)(.*)')
matches = urlRegex.match('https://example.com/path')
-- matches[1] = 'https://example.com/path' (full match)
-- matches[2] = 'https' (first capture group)
-- matches[3] = 'example.com' (second capture group)
-- matches[4] = '/path' (third capture group)Use the search() method to find all matches in the text:
allMatches = myregex.search(text, [flags])
Returns an array where each element is a match array (as returned by match()), or nil if no matches are found.
wordRegex = regex.new('(\\w+)')
allWords = wordRegex.search('hello world test')
-- allWords[1][1] = 'hello', allWords[1][2] = 'hello'
-- allWords[2][1] = 'world', allWords[2][2] = 'world'
-- allWords[3][1] = 'test', allWords[3][2] = 'test'Use the replace() method to replace all occurrences of a pattern:
result = myregex.replace(text, replacement, [flags])
Replacement strings support backreferences using $1, $2, etc., to reference capture groups.
phoneRegex = regex.new('(\\d{3})-(\\d{3})-(\\d{4})')
formatted = phoneRegex.replace('555-123-4567', '($1) $2-$3')
-- Result: '(555) 123-4567'Use the split() method to split text using the regex pattern as a delimiter:
parts = myregex.split(text, [flags])
Returns an array containing the split parts (empty strings are excluded).
csvRegex = regex.new('\\s*,\\s*')
fields = csvRegex.split('apple, banana, cherry')
-- fields[1] = 'apple', fields[2] = 'banana', fields[3] = 'cherry'Compiled regex objects provide the following read-only properties:
pattern: The original pattern stringflags: The compilation flags usederror: Error message if compilation failed (only available in internal error states)myregex = regex.new('\\d+')
print('Pattern: ' .. myregex.pattern)
print('Flags: ' .. myregex.flags)Note: Invalid regex patterns will raise an error during construction. Use catch() or pcall() to handle potential compilation errors:
local myregex
err = catch(function()
myregex = regex.new('[invalid')
end)
if err then
print('Regex compilation failed: ' .. err.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 frequently used regex objects in variables rather than recreating them:
-- Efficient: compile once, use many times
emailValidator = regex.new('^[\\w._%+-]+@[\\w.-]+\\.[A-Za-z]{2,}$')
for _, email in ipairs(emailList) do
if emailValidator.test(email) then
processEmail(email)
end
endFluid 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.
arr = array.new(Size, Type)
arr = array.new(String)Creates a new array of the specified size and element type. Size is the number of elements (must be non-negative, can be 0 for empty arrays). Type is the element type (see table below). Setting a string as the first argument creates a byte array from the string's contents
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 |
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) |
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, ...)Creates a new array populated with the given values. Type is the element type (see array.new() for supported types).
The remaining arguments are the values to populate the array with. At least one value must be provided.
Examples:
-- Create a string array with domain names
domains = array.of('string', 'google.com', 'parasol.ws', 'amazon.co.uk')
-- Create a double array
values = array.of('double', 1.5, 2.7, 3.14159)
-- Mixed types
arr = array.of('any', 42, 'hello', true, nil, { x = 10 })
-- Access elements
print(domains[0]) -- Prints 'google.com'
print(#numbers) -- Prints 5Array elements are accessed using zero-based indexing:
arr = array.new(10, 'int')
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.new(10, 'float')
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.of('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.
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.new(10, 'int')
arr:fill(0) -- Fill entire array with zeros
arr:fill(99, 3, 5) -- Fill elements 3-7 with value 99
arr:fill(42, {2..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.of('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.of('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.new(5, 'int')
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.new(5, 'int')
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.new(10, 'int')
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..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.new(10, 'int')
dst = array.new(20, 'int')
dst:copy(src, 5, 0, 10) -- Copy 10 elements from src[0] to dst[5]
-- Copy from a string
bytes = array.new(100, 'byte')
bytes:copy('Hello', 0)
-- Copy from a table
arr = array.new(5, 'int')
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.new(20, 'byte')
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.new(10, 'int')
for i = 0, 9 do arr[i] = i * 10 end -- {0, 10, 20, 30, 40, 50, 60, 70, 80, 90}
sub = arr:slice({2..5}) -- {20, 30, 40}
sub = arr:slice({5...2}) -- {50, 40, 30, 20} Reverse slice
sub = arr:slice({-3..-1}) -- {70, 80, 90} Negative indices (from end)str = arr:concat(Format, [Separator])
Concatenates array elements into a string. Format is a printf-style format string for each element (e.g., '%d', '%.2f'). Separator is a string to insert between elements (default: empty string). A string containing all formatted elements is returned.
Note that for simple string concatenation (unformatted), use array.join() instead in order to maximise performance.
arr = array.new(5, 'int')
for i = 0, 4 do arr[i] = i * 10 end
str = arr:concat('%d', ', ') -- Result: '0, 10, 20, 30, 40'
farr = array.of('double', 1.5, 2.75, 3.125)
str = farr:concat('%.2f', ' | ') -- Result: '1.50 | 2.75 | 3.13'str = arr:join([Separator])
Concatenates array elements into a string, inserting an optional separator between elements. This is the complement to
string.split() which returns arrays, and is simpler than concat() as it doesn't require a format string.
For string arrays, elements are concatenated directly. For non-string types (integers, floats, etc.), elements are converted to their string representation automatically.
-- String array joining
parts = array.of('string', 'hello', 'world', '!')
str = parts:join(' ') -- Result: 'hello world !'
-- Join with comma separator
names = array.of('string', 'apple', 'banana', 'cherry')
str = names:join(', ') -- Result: 'apple, banana, cherry'
-- Integer array (automatic conversion)
numbers = array.of('int', 1, 2, 3, 4, 5)
str = numbers:join('-') -- Result: '1-2-3-4-5'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.of('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.of('int', 1, 2, 3, 4, 5)
doubled = arr:map(v => v * 2)
-- String transformation
names = array.of('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.of('int', 1, 2, 3, 4, 5, 6)
evens = arr:filter(v => v % 2 is 0) -- evens = {2, 4, 6}
-- Filter by index
arr = array.of('int', 10, 20, 30, 40, 50)
odd_indices = arr:filter((v, i) => i % 2 is 1) -- odd_indices = {20, 40}
-- String filtering
names = array.of('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.of('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.of('string', 'a', 'b', 'c')
result = parts:reduce('', (acc, v) => acc .. v) -- result = 'abc'
-- Build a table
arr = array.of('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.of('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.of('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.of('int', 1, 2, 3, 4, 5)
has_negative = arr:any(v => v < 0) -- has_negative = false
-- String array
names = array.of('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.of('int', 1, 2, 3, 4, 5)
all_positive = arr:all(v => v > 0) -- all_positive = true
-- String validation
names = array.of('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.of('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.
Fluid supports function annotations that attach metadata to functions at parse time. Annotations provide a declarative way to mark functions with attributes that can be queried at runtime, enabling features like test discovery, deprecation warnings, and capability requirements.
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 following annotations are recommended for general-purpose code organisation:
| Annotation | Arguments | Description |
|---|---|---|
@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.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.fluid', 'myFunc')String format (parsed):
debug.anno.set(myFunc, '@Test(name='My Test'); @Requires(network=true)')Returns: Entry table with fields:
name: Function namesource: Source file pathannotations: Array of annotation tablesRetrieves the annotation entry for a function.
Parameters:
| Parameter | Type | Description |
|---|---|---|
func |
function | The function to query |
Returns: Entry table if the function is annotated, nil otherwise.
@Test(name='Example')
function exampleFunc() end
entry = debug.anno.get(exampleFunc)
if entry then
print('Function: ' .. entry.name)
print('Source: ' .. entry.source)
for i, anno in ipairs(entry.annotations) do
print('Annotation: ' .. anno.name)
end
endReturns a shallow copy of all registered annotations.
Returns: Table mapping function references to their entry tables.
all = debug.anno.list()
for func, entry in pairs(all) do
print(entry.name .. ' has ' .. #entry.annotations .. ' annotations')
endEntry Table Structure:
{
name = 'functionName', -- Function name
source = 'path/to/file.fluid', -- Source file
annotations = { -- Array of annotations
{
name = 'Test', -- Annotation name
args = { -- Named arguments
name = 'Test Name',
timeout = 5,
labels = { 'unit', 'smoke' }
}
}
}
}Fluid 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 Fluid 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 Parasol 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)
Fluid 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.