Modules: include vs import
Two statements bring in another file. include is C++'s #include: the file's text runs where it is written. import is a module: it runs once at program scope and again only when its file changes, which is what hot reload is built on.
include "f.jai" | import "f.jai" | |
|---|---|---|
| Runs | every time it is reached | the first time it is reached, then only after one of the module's files changed |
| Declares into | the scope where it is written | program scope, wherever it is written; the module is a unit of its own |
| Value | in expression position, the file's last top-level expression | the statement has none; the call form import("f.jai") is a bool, whether a module ran |
| Path | a literal, <f.jai>, or the call form include(expr) with any string expression | a literal, or the call form import(expr), which also takes several paths, an array of them, and jai::force |
| Allowed | wherever a statement may stand: a function or lambda body, a block, a loop, a conditional, a coroutine | the same places, except directly in a class or namespace body (a parse error) |
An error in an included or imported file is an error at the line that reached it, catchable by a try around that line. A throw arrives as the thrown value, a run-time error with its own text, and a file that does not parse or check raises its listing, named by the file's path. Uncaught, the stack trace lists the file's own frames first, then the frame of each include or import that reached it.
include: runs every time
auto libname = "mod_" + "math" + ".jai";
include(libname); // the call form
print("mod_add(2, 3) = {}, MOD_PI = {}", mod_add(2, 3), MOD_PI);
auto load_count = 0;
include "mod_count.jai";
include "mod_count.jai";
print("load_count = {}", load_count);with mod_math.jai:
function mod_add(int a, int b) -> int { return a + b; }
auto MOD_PI = 3.14159;and mod_count.jai:
load_count = load_count + 1;mod_add(2, 3) = 5, MOD_PI = 3.14159
load_count = 2Includes nest. #include "x.jai"; is accepted as include "x.jai";. A data-file config is var cfg = include "config.jai";.
Where the declarations land
void load_rules() {
include "tuning.jai"; // SPEED is a local
print("inside load_rules: SPEED = {}", SPEED);
}
load_rules();
void load_physics() {
import "physics.jai"; // gravity() is declared at program scope
}
load_physics();
print("after load_physics: gravity() = {}", gravity());with tuning.jai:
int SPEED = 3;and physics.jai:
float gravity() { return 9.81; }inside load_rules: SPEED = 3
after load_physics: gravity() = 9.81An include inside a function body is that function's own text, pasted in when the function is parsed (for a literal path). It is not a reload; to replace program-scope functions and classes from inside a function, import the file.
- The file reads and writes the function's parameters and locals, and the variables it declares are locals.
- A function it declares is a nested function of the body: callable below the include, gone when the body returns, and capturing nothing (the body's locals are a parse error inside it).
- A program-scope function of the same name is hidden inside the body and unchanged elsewhere. The checker warns:
function 'helper' from the include in function 'reload' is local to it and hides the program-scope 'helper' there without replacing it; import the file to replace program-scope functions. - A class written in a function, method or lambda body is a parse error, and so is one that a file included there declares:
an include inside a function body cannot declare class 'Rules' (a class is a program-scope type and the included text is the function's own); import 'rules.jai' instead.
import: a module, run once
The call form is true when the module, or a module it imports, ran on this reach. The statement form yields nothing: auto x = import "mod_math.jai"; does not parse.
auto load_count = 0;
import "mod_count.jai"
import "mod_count.jai"
print("load_count = {}", load_count);
bool first = import("mod_math.jai");
bool again = import("mod_math.jai");
print("first reach ran it: {}, the next one: {}", first, again);load_count = 1
first reach ran it: true, the next one: falseImports may form cycles. An import of a module that is still running does nothing, and the file the program started with counts as one. Code after an import sees everything the module declared, whichever way the cycle closes.
Reloading from a running program
An import acts only when it is reached, so a running program reloads its modules by reaching their imports again, once per frame or tick. A rejected reload raises a catchable error at the import, and the program keeps its old code, so the loop guards the import:
array<string> MODULES = ["rules.jai", "monsters.jai", "items.jai"];
void tick() {
if (DEV_MODE) {
try {
if (import(MODULES)) { print("reloaded"); }
} catch (e) {
print("reload failed: {}", e);
}
}
step_world();
}| Question | Rule |
|---|---|
| When is an edit noticed? | At most once per import_recheck_interval (0.2 seconds by default) the imports that are reached check their files; in between, a reached import costs nanoseconds. One frame checks every import it reaches and the frames after it check none, so a frame sees all of a set of edits or none. A module that has never run loads when first reached, whatever the window. |
| What counts as changed? | The module's file, or any file its last run included (at the top level, nested, or in a function body), by modification time and size. The check continues through the modules that run imported, and theirs, each once: an import inside an if counts when its branch ran, a computed import(p) as the file p named. A changed inner module re-runs alone; its importer does not. |
| What is checked before the re-run? | The changed module is parsed and checked, with the modules it imports, before any of it runs. Every other loaded unit (the root file, other modules, an executed string that declared something) is checked again against the new declarations, never run again. An edited file whose import is not reached yet counts with its new text. |
| What rejects a reload? | A mistake in the module, or a break in code that is not changing, such as a field dropped while a function elsewhere still reads it. The error names the file and line (root.jai:12:41: error: no member 'a' in class 'C'). The rejected reload then waits until a file it involved changes: the module, a file it includes, or the code it would break. |
| What does the re-run apply? | Its functions and classes replace the old ones in place (live instances migrate, callers see the new bodies) and its top-level statements run again. A variable declared with an initializer (int spawn_rate = 3;) takes the new value. One declared without (int kills;) keeps its value, unless its declared type changed, which resets it to the new type's default. So state survives a reload by being declared without an initializer. |
| A module is no longer imported? | It stops being checked and stays loaded: imports never unload. |
| A loaded module's file disappears? | The import raises Failed to open import file once, then waits quietly with the code it had until the file is back, and re-runs it then. A file that never loaded raises every time it is reached. |
The window. import_recheck_interval(seconds) sets it from a script and import_recheck_interval() reads it (the host spells it engine::import_recheck_interval). At 0 every reach checks. The window runs on the real clock, never a host's virtual time source, and a loop that keeps reaching its imports inside one long run sees an edit once the window has passed.
Groups. Modules that must change together go in one import: import("rules.jai", "monsters.jai"), or import(MODULES) with an array of paths. Every changed module is parsed and checked, knowing the names the others declare, before any runs. A mistake anywhere is one catchable error and nothing is applied. Otherwise the changed modules run in the listed order, and the value is true when any ran.
jai::force as the last argument of the call form, import(MODULES, jai::force), checks at once, whatever the window. It suits an occasional trigger (an F5 key, an editor's ping). Unchanged files still do not re-run. Any other directive is a parse error naming it.
A file's own reload hook
A file may declare void hot_reload_migrate(map<string, var> previous) at its top level to convert data whose shape changed:
map<string, int> scores = {}; // was: array<int> scores = [12, 40, 7];
void hot_reload_migrate(map<string, var> previous) {
array<int> old = previous["scores"];
for (int i = 0; i < old.size(); i++) { scores["player" + to_string(i)] = old[i]; }
}The hook runs once whenever the same file runs again, after the re-run's top-level code and never on a first load. previous holds every program-scope variable the file declared as it was before the re-run, including one the new text removed, retyped or initializes again. The hook belongs to its file and nothing can call it; an error inside it leaves the program as the re-run left it. Hot Reload has the exact rules.
Import modes and tracking (host API)
The host adds search roots with add_include_path, forgets modules with reset_import(path) and reset_imports() so their next import re-runs them, and queries with is_imported(path) and get_imported_files(). set_import_behavior replaces the default file_timestamp policy described here with once or always (Import modes, Engine API, Hot Reload). reset_import is an escape hatch, for a file regenerated with an unchanged timestamp or a re-run under once; it is not the reload mechanism.
The jaibite disk cache (.cache/foo.jai.jaibite beside foo.jai, the automatic disk cache) only makes loads cheaper: it never changes whether a file runs.
Program-scope redeclarations
Each source an engine runs is a unit: a file by its path, a string by its content (the same string executed every frame is one unit), a jaibite object by itself. Included text belongs to its includer's unit; an imported file is its own. A program-scope variable belongs to the unit that declared it.
- Another unit's declaration of the same name gets its own cell and shadows the old one, as a nested block does in C++. Code reads its own unit's cell when its unit declared the name. Otherwise it reads the newest cell declared no later than the code was compiled (top-level code: the start of its current run; a function: the run that declared it), so code compiled before a redeclaration keeps its binding. A name only a later unit declares resolves to the newest cell.
- The same unit run again writes in place, with the initializer rule of a re-run.
- One run of a unit declares a top-level variable once: a second declaration in the same text is
redeclaration of 'x' in the same scope, except anextern's definition and aninlinedeclaration. - Functions and classes replace in place whatever the unit.
{ }blocks scope as in C++, at program scope too. A declaration inside a bare block, anif, a loop body or atryshadows the outer name until the block ends. A function called from inside the block still sees the binding of the scope it was declared in.
int G = 1;
int main_g() { return G; }
import "rival.jai"; // another unit: its G is a cell of its own
print(main_g(), " ", rival_g(), " ", G);
int hp = 10;
void show() { print("show: {}", hp); }
{
int hp = 99;
print("block: {}", hp);
show();
}
print("after: {}", hp);with rival.jai:
int G = 5;
int rival_g() { return G; }1 5 1
block: 99
show: 10
after: 10The host reads and assigns the newest cell (add_global after a redeclaration writes it), and a reference bound before a redeclaration stays on the cell it bound. A unit that uses a global without owning one declares it extern; one variable shared by every unit that declares it is inline (below).
Where files are
The path ladder. import, include, the checker's gather across files and resolve_path resolve a path the same way. An absolute path stands or falls as given. A relative one is tried, first hit wins:
- against the folder of the file doing the asking (a module imports its sibling by bare name, wherever the program was started from);
- against each
add_include_pathroot, in the order added; - against the working directory.
The hit's canonical spelling is the key the unit is cached and re-imported under. When no rung has the file, the statement raises the catchable Failed to open include file: helpers.jai (Failed to open import file: ... for an import, the path as written) when it is reached, and the checker leaves the file out of its gather. A host can confine the ladder to chosen folders (Safety Rails).
Location builtins. The working directory is whatever the launching shell chose, so a program builds its file paths from these queries, which every engine carries. Locations are absolute, with forward slashes and no trailing separator:
| builtin | answers |
|---|---|
current_script_dir() | the folder of the file whose code is executing, so a module finds files beside itself |
root_script_dir() | the folder of the file the program started with, fixed for the run: the anchor for a save beside the program |
app_data_dir(name) | the platform's per-user folder for an app called name, for per-user saves |
file_data(p) | a map of what the filesystem says about p (null when nothing is there), so a watcher polls file_data(p)["modified"] without a try |
Every location builtin and each file_data key: location builtins.
All of these (the lexical file_name too), include and import require the jai::filesystem privilege and raise the catchable Insufficient privilege: filesystem. on an engine whose host disabled it (Filesystem access is a host choice).
File operations are host functions. read_file, write_file, list_dir and writable_dir come from the host: the standalone runner provides them under the same privilege, and an embedding host chooses which to expose. The runner's read_file(path) returns the file's bytes exactly ("" for an existing empty file) and raises a catchable Cannot read file error naming the path and the reason when it cannot (Files). A program that expects absence asks file_exists(path) first, or catches.
What the checker sees across files
A unit (a file, or a string handed to execute()) whose top-level import and include lines carry literal paths is the root of a program. Before the root runs, every file those paths reach is gathered (recursively, each once, cycles included), the top-level names of all of them (functions, globals, classes, enums, namespaces) are pooled, and every unit is checked against the pool.
- Import order does not matter for names. A function may use a global that a file imported later declares. Order still matters for values: a top-level statement that read that global before its import ran would find it undefined at run time.
- A mistake anywhere fails the root before any unit runs, the listing named by the file's resolved path. A file that does not parse stops the program there: nothing is checked until it parses.
- Class knowledge crosses files. A subclass whose parent lives in another unit resolves inherited fields and methods, and a typed mismatch against a cross-file field is the error the same-file case reports.
- Function signatures and global types cross files. A call of a function one other file declares checks its argument count, its arguments and the use of its result, and a read or store of a typed global checks against its declaration: with
int twice(int n)inlib.jai,string s = twice("x");fails the importer's load. A name two files declare, or a file reached through a computed path, leaves the call to run time. - A reload is checked against its users. An edit that changes a function's signature or a global's type while another loaded file's calls no longer fit does not load: the listing names those calls in their own file, and the old code keeps running until the edit and its callers agree (saved together, they load together).
- Without filesystem access the checker checks only the source it was given in memory.
import "rules.jai";
import "config.jai"; // rules.jai reads its names
print("damage = {}", damage(3));with rules.jai:
function damage(int hits) -> int { return hits * BASE_DAMAGE; }and config.jai:
int BASE_DAMAGE = 7;damage = 21Misspell the name in rules.jai and the root's load fails with scripts/rules.jai:1:50: error: use of undeclared identifier 'BASE_DAMGE' (path shortened here).
Three escape valves
A name that is not in the program comes from the host, from a file whose path is computed at run time, or from a module only some programs import. Three spellings tell the checker so:
- A dynamic path.
import(expr)andinclude(expr)cannot be followed at check time, so a unit that contains one drops undeclared identifier from error to warning for that unit. externdeclares a name without defining it:extern function net_relay;,extern int tick_rate;(checked as an int),extern config;(untyped). The pool carries it to every unit. At run time it declares nothing: the name resolves when it is used, and raises if nothing has defined it by then. Where a real definition exists theexternis inert, so a shared leaf can call into a module only some programs import.[[warn_mode]]at the top of a unit. The whole file is checked but reported as warnings, and a warn-mode leaf never fails its program.
A variable that several files declare, each wanting it to exist whichever of them a program imports, is an inline variable, as in C++17: the first declaration to run creates it and the others adopt it.
import "hud.jai";
import "netplay.jai";
award_frag();
print("frags = {}, relay waits for the host: {}", frags, relay_ready());with hud.jai:
inline int frags;
void award_frag() { frags += 1; }and netplay.jai:
inline int frags = 100; // adopts hud.jai's variable: no initializer runs
extern function net_relay; // the host registers it later
function on_packet(var p) { return net_relay(p); }
function relay_ready() -> bool { return true; }frags = 1, relay waits for the host: trueTypes That Fail Early and the reference's extern and inline have the full rules and error texts for all of these.