Scripting · Script land
Native Nift scripting.
Nift has two deliberately distinct contexts: template land for building websites, and script land for native statements, reusable script files and interactive automation. They share one Nift language/runtime rather than embedding another interpreter.
Template land vs script land
| Context | Purpose | Syntax |
|---|---|---|
| Template land | Generate pages and other tracked output | @if, @for, $[...], @input |
| Script land | Execute native Nift statements | if, for, fn, import, assignments and ordinary calls |
@script { ... } is the explicit bridge from a template into script land. Inside it, ordinary statements do not need $[...] wrappers and language constructs do not need template @ prefixes.
@script {
values := [1, 2, 3, 4]
doubled := values.map(x => x * 2)
if(doubled.contains(8)) {
return doubled.join(", ")
}
return
} An inline script executes in the current template scope. Falling through or using bare return emits nothing; return expression renders exactly that value. Returned strings are values and are not reparsed as template source.
Functions in script land
Standalone .f script files use ordinary function syntax without the template @ prefix:
fn(double(x)) {
return x * 2
}
print(double(21))
Inside a @script { ... } block the same forms work; the template prefix is only required at the top level of template files.
Comments in script land
Standalone .f script and package source uses the same comment syntax as the template language, but without the @ prefix:
// single-line comment
/*
multiline
comment
*/
fn(example(x)) {
// inline comments are fine too
return x
} Use one genuine multiline comment instead of a wall of repeated // lines. A comment can safely contain syntax-looking characters (;, {}, [], (), $[], @) — it is ignored completely. The template forms @// and @/* ... */ remain valid where the file is still template source; bare forms are the preferred syntax inside script-land files and packages.
Native threads (v4.5)
thread(callable, ...args) starts a real native worker and returns a thread handle. Named functions and lambdas use the same first-class callable representation as the rest of Nift. Ordinary values cross the worker boundary by deep copy; worker mutation therefore cannot race the parent. Opaque resources such as files, streams, commands, structs and collections are rejected unless they have an explicit thread-safe contract.
fn(square(x)) { return x * x }
t := thread(square, 12)
print(t.status()) // running, done or error
print(t.join()) // 144
print(t.done()) // true
print(hardware_concurrency()) join() is deterministic and replayable: the first join waits, later joins return the same result or the same worker error. A live handle is joined during runtime teardown rather than allowing std::terminate-style abandonment. hardware_concurrency() returns a worker-count hint of at least one.
Mutexes and explicit shared state
mutex() creates an explicit cross-thread synchronization handle. mutex(initial) also carries one synchronized value so ordinary arrays/objects/structs do not need implicit shared-reference semantics. Call lock() before get()/set() and finish with unlock(); try_lock() is non-blocking and locked() reports logical state.
fn(increment(counter)) {
counter.lock()
value := counter.get()
counter.set(value + 1)
counter.unlock()
}
counter := mutex(0)
a := thread(increment, counter)
b := thread(increment, counter)
a.join(); b.join()
counter.lock()
print(counter.get()) // 2
counter.unlock() Mutex ownership is non-recursive. Double locking by the owner, unlocking from another worker, and reading/writing the synchronized value without ownership are runtime errors. Ordinary values still cross worker boundaries by copy unless they are wrapped in an explicit thread-safe runtime handle.
Atomic integers and booleans
atomic<int>(initial) and atomic<bool>(initial) create small transferable shared-state handles for scalar concurrency. Atomic operations are sequentially consistent; Nift deliberately does not expose C++ memory-order parameters.
fn(increment(counter, count)) {
i := 0
while(i < count) {
counter++
i += 1
}
}
counter := atomic<int>(0)
a := thread(increment, counter, 1000)
b := thread(increment, counter, 1000)
a.join(); b.join()
print(counter) // 2000
ready := atomic<bool>(false)
ready = true Atomics behave like synchronized scalar references in ordinary expressions. atomic<int> supports ++, --, +=, -=, &=, |=, ^=, %= and scalar assignment with =; atomic<bool> supports scalar assignment with =. Both retain explicit load(), store(value), exchange(value) and compare_exchange(expected, desired); integer atomics also expose fetch_add() / fetch_sub(). Atomic integers are signed 64-bit values and all operations are sequentially consistent.
Async functions, futures and await
Declare an async function with (or fn[async] in script-style source), or create an async lambda with async (args) => { ... }. Calling an async callable schedules it on Nift's bounded native worker pool and immediately returns a future.
a := fetch_one(20)
print(type(a)) // future
print(await a) // 40
print(await fetch_one(21)) // 42
work := async (x) => { return x + 1 }
p := work(41)
print(await p) // 42 await future and await async_fn(...) are prefix expressions. Futures expose done() and status() for inspection. The old library-style async(callable, ...), await(...) and .await() spellings are not part of the v4.5 public API.
The pool uses real native worker threads. A worker awaiting nested work can execute queued work while waiting, preventing fixed-pool starvation. Errors are replayed by await; forced cancellation is not part of the initial v4.5 contract.
Runtime platforms (v4.5)
platform() reports the selected execution/output platform independently from host os() and arch(). The default is native. Runtime/build selection deliberately uses --platform so nift init --target=<provider> keeps its existing deployment-provider meaning.
nift --platform=android script.f
nift script.f --platform=android
nift --platform=my.custom script.f
nift --platform=android build
nift build --platform=android --platform=ID is a global execution/build option and may appear before or after the command or script. A literal -- ends Nift option parsing, so nift script.f -- --platform=android passes --platform=android to the script instead. Built-in platform IDs are native, linux, macos, windows, android, ios and wasm. Custom lowercase IDs are accepted with --platform=ID. Platform state belongs to the invocation/runtime; it never changes the real host operating system reported by os().
Native C ABI FFI (v4.5)
Nift can load native shared libraries at runtime and call an explicitly typed C ABI surface without turning arbitrary native memory into ordinary Nift values. Open a library with ffi_open(), invoke a symbol with ffi_call(), and close the handle with ffi_close().
lib := ffi_open("./libmaths.so")
answer := ffi_call(lib, "add_i64", "i64(i64,i64)", 20, 22)
print(answer) // 42
ffi_close(lib) The built-in dispatcher supports integer/bool/pointer/C-string scalar calls plus homogeneous floating-point calls, owned byte buffers, explicitly laid-out native structs passed by address, and a certified synchronous i64(i64) callback bridge. Unsupported signatures fail explicitly rather than guessing an ABI. Package authors are expected to wrap low-level calls behind ordinary Nift functions so application code does not repeat signatures and ownership details.
bytes := ffi_buffer([1, 2, 3])
pair := ffi_struct("i32,i32", [7, 8])
fn(twice(x)) { return x * 2 }
cb := ffi_callback(twice, "i64(i64)") Native filenames remain platform-specific (.so, .dylib, .dll). FFI handles, buffers and callbacks are runtime-owned resources; closing or tearing down a runtime invalidates the corresponding native resources deterministically.
Embedding Nift (v4.5)
The Nift runtime can also be embedded in native applications. The C++ nift::Engine keeps template rendering and a persistent script runtime in one host-owned object: applications can execute scripts, evaluate expressions, set a runtime target, register trusted native functions, and invoke named Nift functions.
nift::Engine engine;
engine.set_platform("native");
engine.register_function("native_mul", [](const std::vector<nift::Value>& args) {
return nift::Value(args[0].number() * args[1].number());
});
engine.execute("fn(scale(x)) { return native_mul(x, 2) }");
auto result = engine.call("scale", {nift::Value(21)}); The versioned C ABI 1.3 exposes the same execute/evaluate result model for non-C++ hosts. The maintained Go, Python, Node and C# bindings are thin adapters over that ABI rather than independent Nift implementations. Separate Engine instances are isolated and can run concurrently; script operations on one Engine are serialized so its persistent bindings remain coherent. Embedded scripts run in hosted mode and do not silently acquire shell-only process-global powers such as changing the host process working directory.
from nift import Engine
e = Engine.new()
print(e.execute("x := 40; return x + 2;")) # 42
print(e.evaluate("x + 1")) # 41 Native distribution is staged by make embed: public headers, static/shared libraries and pkg-config metadata are produced together so C/C++ consumers can build against an installed prefix instead of Nift source internals. FFI and embedding are separate boundaries: FFI lets Nift scripts call explicitly typed C ABI symbols; embedding lets a host application own a Nift runtime.
Recoverable errors (v4.6)
Nift separates a small set of recoverable operational failures from ordinary fatal language/runtime failures. Recoverable failures surface as Error values that try/catch can handle; programmer, type, arity, policy, invariant and native-fault failures stay fatal and bypass catch.
try {
lib := ffi_open("./libmaths.so")
print(ffi_call(lib, "add_i64", "i64(i64,i64)", 20, 22))
ffi_close(lib)
} catch(err) {
print(err.code) // ffi.library_load_failed
print(err.category) // ffi
print(err.message)
} Raise your own recoverable error with throw error(...):
if(!exists("config.json")) {
throw error("configuration is missing", "user.config.not_found")
} error(message) builds an Error value without throwing; the default code is user.raised. error(message, code) validates a custom user.* code and derives its category; error(message, code, cause) chains an optional Error cause. Built-in codes such as io.* or ffi.* cannot be forged through the public constructor.
Error values are immutable and expose these fields:
| Field | Meaning |
|---|---|
message | Diagnostic text. |
code | Stable machine-readable code. |
category | Segment before the first . in the code. |
source | Defining source/path when available. |
line / column | Defining 1-based location when available. |
cause | Chained Error or null. |
The catch binding is a new immutable local. Rethrowing preserves the original Error; propagation appends context frames without replacing the defining origin. Normal return/break/continue pass through try/catch unchanged, and a fatal failure inside a catch body propagates past any outer catch.
Recoverable (catchable) failures include throw error(...), valid filesystem backend failures (io.*), stream backend failures (stream.*), malformed runtime JSON (json.parse_failed), a valid schema rejecting a value (schema.rejected), FFI library load / missing symbol (ffi.library_load_failed, ffi.symbol_not_found), missing or unreadable import sources (io.import_source_unreadable, package.not_installed, package.import_source_unreadable), and a recoverable failure produced inside a future/thread observed at await/join.
Fatal (never caught): syntax/translation/name errors, invalid arity/type/mode, division by zero, mutation/privacy/authority/policy denials, invalid or forged handles, malformed package/config metadata, import cycles, failed imports that created workers, and native faults. Existing result APIs (run(), package/HTTP/database {ok:false}, exists() == false) remain structured results and do not throw automatically.
Error values are interpreter-only: they cannot cross the public embedding/JSON/binding boundary. Render a diagnostic representation deliberately with err.stringify() / err.prettify().
Script files and interactive use
Use import(path) for isolated reusable script files, nift script.f and nift for standalone execution, and the dedicated filesystem and stream references for script-side I/O. Template top level keeps the @import(path) directive.
Nift 4.4 does not resurrect @system. Script land instead provides structured run()/cmd() process APIs and shell command execution with explicit argv/pipeline semantics; --no-process can disable every script-reachable external-process surface.