htl — Holistic Typed Lua
Teal (typed Lua) with the toolchain hidden
behind cargo. One binary type-checks, lints, formats, tests, bundles and runs
.tl; one proc macro makes Teal type errors fail cargo build; one resolver puts
.tl modules into mlua-pkg's require
chain. The Teal compiler (tl.lua) is embedded in the mlua state — there is no
luarocks, no tl CLI, no generated .lua in your tree.
scripts/foo.tl ──include_tl!──▶ cargo build (Teal type error = rustc error, with span)
──htl run ─────▶ check → gen → load, in one mlua state
──htl build────▶ stripped Lua 5.4 bytecode bundle (.hb), no source shipped
Rust impl Host ──#[host_module]▶ UserData impl + host.d.tl (Rust signature change breaks .tl at build)
Install
[]
= "0.1" # embedding: engine + proc macros in one import
| crate | role |
|---|---|
htl |
umbrella: re-exports htl-core and (feature macros, default on) the proc macros. Depend on this one. |
htl-core |
engine: Htl, lints, fmt, bundle, test runner, mlua-pkg resolver |
htl-macros |
include_tl! / include_tl_bytes! / TealRecord / host_module; generated code targets ::htl:: |
htl-cli |
the htl / cargo-htl binaries |
CLI
| command | what it does |
|---|---|
htl new <name> / htl init [dir] |
scaffold: mlua-pkg.toml, src/<mod>/init.tl, src/main.tl, tests/, README (--lib, --embed for a Rust host) |
htl check [paths] [--strict] [--lint +rule,-rule] [--no-cache] [--cache-mode per-module|whole-run] [--explain-cache] |
type-check; htl lints as lint: (advisory, --strict fails on them); a module reached through require (an installed dep, a [check] paths dir) is checked with the file and its type errors are errors too, once per run, with the file that required it; what has not changed is replayed from .htl/ (see Caching) |
htl run <file.tl | app.hb> [args] |
check then execute; require of a .tl with type errors fails |
htl test [paths] [--filter s] [--lib mod] [--coverage] [--lcov file] [--no-cache] |
*_test.tl and tests/**/*.tl, one isolated state per file; checking is replayed from .htl/, the run never is (see Caching) |
htl fmt [paths] [--check] [--indent N] |
whitespace formatter (indentation from the syntax tree, blank lines, trailing space) |
htl gen <file.tl> [-o out.lua] |
readable Lua, the escape hatch out of htl |
htl build <entry.tl> -o app.hb [--debug] [--source] [--extra a,b] [--host x,y] |
link the entry's require closure into one bundle (see Bundles) |
htl bundle info <app.hb> [--format json] |
what a bundle records, without running it: format, the htl that built it, payload kind, the Lua its bytecode is for, entry, modules, host-provided names |
htl pkg install |
fetch every dependency mlua-pkg.toml declares into .htl/modules/ and write mlua-pkg.lock; the deps' own types/ are then copied into the project's (see types/) |
htl pkg add <name> <git> [--tag t | --rev r | --branch b] [--entry dir] [--target-dir dir] |
write the dependency into the manifest (install fetches it); a patch_dir the entry already declared is kept |
htl pkg update [name] [--dry-run] [--force] |
refresh dependencies and bump the pins that follow releases, then install |
htl pkg clean [--all] |
remove cached packages the lockfile no longer refers to, or the whole cache |
htl pkg patch <dep> [--force] |
take that dependency's source into patches/<dep>/, where the project owns it and install resolves it from (see Patched dependencies) |
htl types add <library> [--from dir] [--force] |
the declarations a library never shipped, from teal-types, into types/ with the commit they came from recorded beside each |
htl cache status [path] [--entries] / htl cache clear [path] |
report what the store holds, or empty it (see Caching) |
htl dts [dir] |
write the .d.tl files this project declares: from Rust source, the ones #[host_module] / #[derive(TealRecord)] ask for, no build needed; from Teal, the module each ---@contract type is declared in. check / run / test / build do this automatically; exits non-zero when something it was asked to write could not be |
mlua-pkg.toml is detected by walking up from the file: installed deps become
visible to the checker and to run / test / build automatically. They go under
.htl/modules/, beside the check cache — htl decides that one location, and the installer
is mlua-pkg's library rather than its binary, so
there is no second process to agree with and nothing on PATH to install. MLUA_PKG_DIR
and a target/ in the working directory, which the mlua-pkg binary reads, are not
consulted. Vendored is kept for the other
thing: a copy of a dependency committed to the repo, which a target_dir entry in the
manifest declares and nothing does by default. When a
directory is given, check / fmt / build / test walk the project's own files only:
target/, node_modules/, .mlua-pkgs/ and any
dot-directory are not entered, so dependencies' sources and tests stay theirs. A
directory passed explicitly is always walked. A target_dir copy is not entered either,
and there the manifest is what says so: the copy sits in the repo under a name the project
chose, so nothing about the path tells it apart from the project's own code beside it.
mlua-pkg install rewrites it every time it runs — checking it would report a dependency's
errors as the project's, htl fmt would write a diff against upstream that the next
install undoes, and its *_test.tl are a dependency's suite (Go's ./... has excluded
vendor/ since 1.9 for the same reason). A patch_dir dependency is the one thing in
between: check reads it, fmt and test do not (see Patched dependencies). What is
not walked is still checked: a dependency is checked through the require that reaches
it, and a type error in it is reported as an error with the dependency's own path and
the file that required it —
error: .htl/modules/vendored/mathx/init.tl:12:8: in local declaration: got string, expected number
(required by src/geometry.tl)
— once per run however many files require it, and replayed from the cache like the
requirer's own diagnostics. Paths read against the directory the command ran in, whether
the walk or a require found the file, and one that lies outside it is written in full
rather than as a stack of ... htl run would refuse the module at that require; the
check says so first. Files under tests/ are checked with the
project root and src/ on the search path, the same as htl test, so htl check tests
and htl test agree.
Caching
htl check stores what it worked out under .htl/cache/ at the project root and replays
whatever has not moved. The summary says how much: [cached] when everything came from the
store and no checker was built at all, [36/48 cached] when some of it did, and nothing
when none did. --format json carries the same as summary.cached and summary.replayed.
htl init puts .htl/ in .gitignore — one line for the cache and the installed deps
beside it; add it by hand in an existing project.
There are two separate controls. Whether to cache is --no-cache, which neither reads
nor writes. How the cache is grained is --cache-mode, or [cache] mode in htl.toml
with the flag overriding it:
| mode | entry | an edit costs |
|---|---|---|
per-module (default) |
one per module | that module, whatever requires it, and what those pull in |
whole-run |
one for the walk | the whole walk, wherever the edit landed |
Measured on a 57-module project of about 14,000 lines, release build, wall clock:
| per-module | whole-run | |
|---|---|---|
cold (--no-cache) |
1.81 s | 1.81 s |
| nothing edited | 0.018 s | 0.017 s |
| a module nothing requires | 0.33 s | 1.75 s |
| a module 10 others require | 0.81 s | 1.75 s |
| the type module 32 others require | 1.74 s | 1.75 s |
What per-module saves depends entirely on where the edit lands. Editing a leaf is a
hundredfold; editing the module at the bottom of the dependency graph saves nothing at all,
because everything above it has to be checked again anyway. Neither mode is slower than a
cold check. whole-run keeps one entry per invocation rather than one per module, which is
the reason to reach for it if the number of files in .htl/ becomes a problem before
eviction lands.
An entry is used only when the module and everything it required still hash the same, every name it requires still resolves where it did, and the binary that wrote the entry is the one reading it. Content hashes throughout, no timestamps, so touching a file without editing it invalidates nothing and a fresh checkout does not either. Anything unexpected — a corrupt entry, an unreadable store, an htl upgrade — is a miss, which costs the check it would have skipped and never the wrong answer.
Only the names a module actually requires are watched. Adding a module nothing requires leaves every existing entry valid; adding one that could answer to a name something does require invalidates the modules asking for that name, whether or not the checker would still have picked the old file. Writing a new module is a normal thing to do while working, and it costs a check of that module rather than of the project.
The store is bounded, at four entries per module or 256, whichever is larger. A run that finds it over the bound drops what it did not itself use: entries whose files are gone go first, then the oldest until it fits. A dropped entry is a miss on the next run and nothing worse. Eviction is where mtimes are allowed, because being wrong there costs a check rather than a wrong answer; invalidation still refuses them.
Flags are part of the key when they change what a module reports and not when they only
change the verdict: --lint gets its own entries, --strict reuses them and differs in the
exit code alone.
htl test shares the store, for the half of its work that does not depend on the outcome:
checking a test file and generating its Lua. The run is never cached — a test has to run
to say whether it passes, and it does, every time. The summary says how many files had their
checking reused (27 checked from cache), and --no-cache opts out as it does for htl check.
The modules a test requires are stored too, and put in front of the module searcher before the file runs, so requiring one does not check and generate it mid-execution. Measured on a 27-file suite: 4.65 s without any of this, 5.03 s on the first run (which stores what it generated) and 3.03 s on every run after.
htl cache status says what the store holds — entries by kind, total size, how recently they
were used, and with --entries the files each one covers. htl cache clear empties it. Both
find the store beside htl.toml, which is not necessarily where you are standing:
htl check src run from anywhere in a repo writes to the project root.
--explain-cache (or HTL_CACHE_DEBUG=1) prints why each lookup missed and one line at the
end with what the run did: hits, misses, entries written, entries evicted.
Embedding in Rust
use ;
// Teal record <-> plain table (IntoLua / FromLua)
const MAIN: &str = include_tl!; // checked at cargo build
const UTIL: & = include_tl_bytes!; // same, as stripped bytecode
exec passes its arguments to the script as ... and nothing else. A script that reads
arg[1], as htl run lets it, needs h.set_arg("main.tl", &args)? before exec: that
fills the arg table the way the lua CLI and htl run do, so the same main.tl runs
unchanged both ways (htl new --embed writes both calls).
#[derive(TealRecord)] is checked in one direction at build time and one at runtime:
the .d.tl it writes is what the Teal side is compiled against, while a table coming
back the other way is compared field by field as it converts. A table that does not fit
says which record, which field, what the record declared and what arrived:
Outcome.cause: expected string, got nil
Outcome.depth: expected integer, got string
Recording.outcome.cause: expected string, got nil
A Teal record literal may leave fields out and htl check is right to pass it, so this
message is the whole signal for that direction; require_fields in htl.toml is the
check-time counterpart when a module's table is meant to be complete.
Result<T, E> returns raise a Lua error on Err by default. With
#[host_module(name = "store", errors = "return")] they come back Lua-style instead:
Ok(v) -> v, nil, Ok(()) -> true, nil, Err(e) -> nil, tostring(e), and the
.d.tl says function(...): T, string (boolean, string for unit), so
local ok, err = store:write(name, text) needs no pcall.
#[host_module] turns the plain impl into a mlua::UserData impl and writes
scripts/host.d.tl when it expands, so scripts/main.tl sees
host:scale(p: Point, k: number): Point and host.Point. Change a Rust signature
and the next cargo build fails inside the .tl that relied on it. &str,
&[T] and &Record parameters are accepted (&mut is not); nested records come
from structs in the same source file, records from other modules via
uses = [Name] + their own .d.tl.
async fn (feature async)
A method may be async, in the same impl as the sync ones and with no annotation
saying which the block contains. It is registered through mlua's async variant, and its
Teal declaration is the one the same signature produces without async — a function that
yields internally and hands back the same values is an ordinary call from Lua, and Teal
has no way to say otherwise.
Three things follow from mlua, not from htl:
- The executor is yours. mlua yields to whatever is polling and provides nothing of
its own, so an async method runs under
call_async(which creates the coroutine for you) or anAsyncThreadyou drive. Called from a plainload(..).eval()there is nothing to suspend, and Lua raises rather than blocking. - The receiver is borrowed across every await.
add_async_methodhands over aUserDataRef<T>that the future holds until it resolves, so nothing else may take the value exclusively meanwhile. Prefer&selfover&mut self. - The future must be
'static, andSendas well when mlua'ssendfeature is on.
The feature is off by default: it turns on mlua's async, and a host with no async
method should be built as it was without it.
Runtime resolution through mlua-pkg:
let mut reg = new;
reg.add;
reg.add; // .tl / init.tl -> check + gen; .d.tl -> type-only table
reg.add;
reg.install?;
// or, with an mlua-pkg.toml: htl::pkg::Project::find(dir)?.registry()
A .tl that fails its type check is Some(Err) in mlua-pkg's terms: it never falls
through to a later resolver. Native modules must be registered before the Teal
resolver and described by a .d.tl for the checker.
Teal resolves every require("literal") at check time, and htl keeps it that way. When a
module exists only at run time (the user's Tasks.tl that a long-built host loads), the
same two shapes that TypeScript, Kotlin scripting and Gradle use apply:
- Declare it (
declare module/.d.tsin TS terms): shipTasks.d.tlin the host's tree with the contract (local tsk = require("tsk") local Tasks: tsk.Tasks return Tasks). The build checks the host's scripts against the declaration; at run time aTealResolverrooted at the user's project serves the real file. - Hand the user a typed constructor (
defineConfig/satisfies UserConfigin TS terms): the SDK exportsdefine: function(t: tsk.Tasks): tsk.Tasksand the user writesreturn tsk.define({ ... }). Field-level errors with line numbers, no annotation on the user's side, andexpect_typebecomes a belt-and-braces check.
A dynamic require(name_in_a_variable) typed as any is the escape hatch, like
GDScript's load() or a shorthand declare module "x"; use it only when the module name
itself is unknown until run time.
Errors that come out of running Lua (a host function's Err, a Lua error(...)) carry
mlua's stack traceback:; htl::user_message(&err) returns the innermost cause alone,
which is what htl run / htl test print.
For mod / plugin directories, TealResolver::new("mods")?.expect_type("defs.Mod") holds
every served module to a record type: a mod that returns the wrong shape is rejected at
require time even if it never annotates its own return value. It rejects fields of the
wrong type; on its own it does not reject missing fields (every Teal record field is
nilable). Chain .require_fields(["name", "monsters"]) to name the fields that must be
present: the module is rejected at require naming the nil ones, and a field added to
the record later stays optional until it is added to the list, so the type can grow
without breaking the modules already written against it. .require_all_fields() takes
every declared field, for types that are settled.
Lints (htl check, include_tl!)
| rule | default | catches |
|---|---|---|
nil-index |
on | t[k].x, t[k]:m(), t[k](), t[k][j] — Teal types a map/array lookup as V, not V | nil |
struct-fields |
on | a table built for a record marked ---@struct that leaves out a field the record declares and ---@optional does not exempt. Silent until a record carries the marker (see below) |
enum-exhaustive |
on | if e == "a" ... elseif e == "b" ... end over an enum with a value left unhandled and no else; enums nested in records and enums from required modules count |
union-exhaustive |
on | if x is A ... elseif x is B ... end over a union with a variant never tested and no else. The variants come from the checker, so a chain that predates a variant is reported once the union gains it (see "Unions of records") |
shadow-local |
on | a local / loop var / parameter reusing an enclosing local's name; when that outer local is a required module the message says which module and where it was required |
no-global |
on | global declarations |
no-any |
off | explicit any annotations and as any casts |
explicit-number |
off | local n = 0 (inferred integer) that is later assigned a number expression (n = n * 1.5, n = a / b): names the declaration and the assignment; write local n: number = 0. Plain integer counters are not reported |
class-record |
off | a record declaring metamethods (metamethod __index: Actor = a class): its metatable is attached by setmetatable at run time and is not part of the value, so serialization and the Rust boundary drop it; keep such records out of saved data and host signatures |
require-cycle |
on (project-level) | a loop in the require graph of the files htl check <dir> just checked, e.g. a.tl -> b.tl -> a.tl. Teal types the back edge as an opaque circular require, so without this the symptom is "cannot index" somewhere else |
Silence one occurrence with a trailing -- htl: allow(nil-index). include_tl!
treats lints as errors (HTL_LINT=warn downgrades, HTL_LINTS=+no-any,-shadow-local
configures).
Records built whole (---@struct)
Every Teal record field is nilable and Teal has ? for function parameters but not for
record fields, so a record the program builds itself still reads as if any field might be
absent. ---@struct says it does not:
local record MonsterDef ---@struct
id: string
hp: integer
inflicts: Status ---@optional
---@optional
home: BranchId
end
Every table built as a MonsterDef must then set id and hp; inflicts and home may
be absent. Adding an unmarked field makes the construction sites that predate it report,
which is the point — the default for a new field is mandatory, and ---@optional is the
exception you write on purpose.
A misspelled field is the case where two rules each hold half the answer: the checker says
unknown field colour about the key that exists, and this says color is missing. When
the key the literal sets is a near miss for the one it wants, the message names it instead
of repeating the standing advice, because "mark it ---@optional" is the wrong fix for a
typo:
MonsterDef is built without color (the literal sets `colour`)
One edit counts as a near miss in any name, two once the name is at least eight characters long. An extra key that is nothing like the missing one is not offered.
The markers go where the record is declared, and the report lands where it is
built, so an SDK can declare the shape its mods must fill in. Both marker forms work:
trailing on the field's own line, or on the line above it. Every construction site counts
— a bare literal, an element of an array or map of that record, a literal passed as a
typed argument, and a function's return.
This is a lint, not a type. The file stays valid Teal and other tooling ignores the
comment; use sites still see a nilable field. What it removes is the reason to guard, and
the doubt about whether a field was ever set. Data arriving from outside the program — a
mod's return value, a save file, a host — is a different question, and [[contract]] with
require_fields is what checks that.
Project config (htl.toml)
htl check / htl test / htl fmt / include_tl! all read the nearest htl.toml
above the file, so the CLI and the build agree. Flags and HTL_LINTS / HTL_LINT
override it (htl new writes a commented one).
[]
= ["class-record", "explicit-number"]
= ["shadow-local"]
= true # lints fail check/test and include_tl!; false makes the macro advisory
[]
= 3
[]
= ["mods", "~/.cache/tsk/sdk"] # extra dirs require() resolves from while checking
[[]] # where this project accepts modules written outside it
= "mods" # relative to htl.toml; "sites/*" = every subdirectory of sites/
# module = "Site" # optional: only this module name (in each dir) is held to it
[check] paths is for modules the host supplies at run time from somewhere the
checker would not look (an SDK cache, a mods dir): the CLI, include_tl! and
contract_resolvers all add them, plus the htl.toml dir, its src/ and its
types/. types/ is the conventional home for .d.tl (the DefinitelyTyped shape:
declarations the module's author did not ship), searched without any configuration;
htl new creates it.
Three kinds arrive there. The ones written by hand; the ones a dependency published; and the ones for a library that published none of its own.
A package keeps its own declarations at types/ in its root, which is outside the entry
directory require resolves through, so htl pkg copies them in. htl types add <library> is the other half: teal-types is where the Teal ecosystem collects declarations
for libraries that ship none, as types/<library>/<module>.d.tl, and add takes one
library's worth. The library's own directory is dropped and the path below it kept, since
that path is the module name — socket/http.d.tl stays require("socket.http").
Both write a .src note beside each file: what published it, at which commit, and the
path it had there. Nothing else records that. luasocket-tl-type is versioned 0.0.2-1
against a luasocket at 3.x, its rockspec declares no dependency on luasocket, and its
source names no revision — so without the note, a declaration carries no evidence of what
it was written against.
Copying rather than searching the installed deps is what makes them survive a fresh clone:
.htl/ is gitignored and empty until someone installs, types/ is committed. A name
types/ already has is reported and left alone (--force replaces it): two libraries
publishing a module of the same name is a real situation, and there is no registry to
arbitrate it with.
Source beats declaration: when both defs.tl and a defs.d.tl are reachable, the
checker reads the .tl, wherever the two sit on the path (Teal's own order is .d.tl
first). So a .d.tl a host writes out for external script authors never shadows the
source it was made from inside the repo, and a check that runs before the host has
rewritten it still sees the current types.
Between two declarations of one module there is no such rule, only position: the
directories above are consulted in the order they are listed, and the first hit is the
one read. duplicate-declaration reports it — a project that keeps a hand-written
xlib.d.tl under types/ and also has one arriving from a [check] paths directory is
told which is in effect and which is not, rather than being left to work out why a type
is not what the file in front of it says.
Data from outside the program (---@contract)
htl.toml says where modules arrive; the record says what they must be. Marking the
record is what makes the contract discoverable — a directory carries no evidence of which
of a project's records is the one its modules must satisfy.
local record defs
record Mod ---@contract
name: string ---@required
monsters: {Monster} ---@required
items: {Item} ---@required
factions: {Faction}
npcs: {Npc}
end
end
return defs
Every module directly under mods/ must return a value assignable to defs.Mod and set
the three marked fields. factions and npcs are for the mods that want them, and that
asymmetry is the point: a record cannot say which of its own fields are mandatory (every
Teal record field is nilable and there is no ? for them), and holding modules to all
of them would break every one written before a field was added. Marking the mandatory
ones lets the type grow.
The default is the opposite of ---@struct's, and each marker says which regime its
record is under: ---@struct is about a record the program builds itself, where a new
field is mandatory unless marked ---@optional; ---@contract is about a value arriving
from outside, where a new field is optional unless marked ---@required.
A bare ---@contract inherits the directory from htl.toml, which is what a project with
one contract writes. ---@contract("plugins") names its own, ---@contract(module = "S")
narrows a directory to one module name, and both can be given at once.
The module the contract type is declared in is what an outside author writes their
modules against, so htl publishes it: types/defs.d.tl here, alongside the .d.tl a
Rust host's #[host_module] writes, regenerated by htl dts and by check / run / test /
build. ---@contract(dts = "sdk/defs.d.tl") sends it somewhere else. Commit the result,
the same as the Rust-generated ones: it is what makes a fresh clone check before anything
has been built.
What it writes is the declaring module with its bodies removed: each function the module
exported becomes a field of the record it was on, keeping its parameter names and its doc
comment, which is what a hand-written .d.tl says.
function defs.describe(m: Mod): string --> describe: function(m: Mod): string
return m.name
end
A local function is not part of what the module declares and leaves nothing behind, a
field the record already declares is left as the author wrote it, and a method keeps the
self its definition left implicit. The published marker names its directory outright,
since whoever reads the declaration does not have the htl.toml a bare ---@contract
inherits from. A function on a table the module declares no record for is reported rather
than dropped — a declaration missing a function is worse than one that was not written.
Two lints follow:
contract— a module under the directory whose return value is not assignable to the record, or whose returned table literal leaves a---@requiredfield out, is reported athtl checktime instead of at the firstrequire. The literal is found throughreturn { … },return define({ … }),return { … } as T, andlocal m: T = { … } … m.f = … return m. A marker that cannot be turned into a contract — one naming no directory in a project whosehtl.tomldeclares none or several, two markers claiming one directory, a marker on the record a module returns rather than on one inside it — is reported here too.contract-unenforced— a contract is only a guarantee if the host enforces it. When a Cargo package is found,htl checkscans its Rust sources forcontract_resolvers(and otherwise tells you to add it, or to say where it is enforced withenforced_by.
Hosts build their resolvers from the same markers, so the two cannot drift:
let = find?.expect;
let mut reg = new;
for r in contract_resolvers?
That one call is what contract-unenforced looks for. A resolver assembled by hand from
TealResolver::new(…).expect_type(…).require_fields([…]) still works, but it restates
what the record already says, which is the drift the marker exists to remove.
Enforcement the scan cannot see — a Lua-side validator that checks the table before the host uses it, a resolver in a sibling crate, generated code, or one built by hand on purpose — is named instead:
[[]]
= "mods"
= "mods/_validate.lua" # relative to htl.toml; ~ and absolute paths work
That contract is then not held to the scan, and the others in the project still are. It
takes a path rather than a true because the file has to exist: a name that points at
nothing is reported under the same rule, whether or not the call was found elsewhere, so
the key stays a claim htl check can hold to something rather than a per-contract off
switch.
Patched dependencies (htl pkg patch)
A dependency needs one line changed. htl pkg patch mathx copies its package root — the
whole package, so its types/ comes with it — out of the pinned revision and into
patches/mathx/, writes patch_dir = "patches/mathx" onto that dependency in
mlua-pkg.toml, and records the commit it was taken from as patch_base in the lockfile.
patched patches/mathx (mathx at 3f2a9c1)
From there the directory is the project's code: edited, diffed, reviewed and committed
with git like anything else in the tree. There is no patch file and nothing is applied —
htl pkg install leaves the directory alone and resolves the dependency from it. This is
the shape of Cargo's [patch] with a path source, and of Go's replace pointing at a
directory in the module tree. Removing patch_dir and the directory returns the
dependency to its fetched form at the next install.
What is checked, and what is not. The copy is committed, project-owned code whose
errors are the project's to fix, so htl check walks it and names the dependency each
directory stands in for. htl fmt and htl test do not touch it: formatting it would
turn every file into a diff against its base and hide the change inside it, and its
*_test.tl are the dependency's suite rather than the project's. .htl/modules is not
descended into at all, patched or otherwise; its modules are checked through the
require that reaches them and their errors reported against the requirer, never offered
to htl fix (a fix there would go at the next install — patching is how a dependency is
edited). The criterion for walking is who writes the directory — one that install
regenerates (target_dir) is skipped, one that the project edits is checked.
Upgrading. A patch is bound to the revision it was taken from. When the pin moves — the dependency was upgraded — install fetches the new revision and resolves from it, the copy is left alone, and every install says so until the patch is refreshed or removed:
patch patches/mathx is not in use (taken from 3f2a9c1, mathx is now at 8b07e44)
carry the change forward: commit it, then `htl pkg patch mathx`
drop it: remove patch_dir from mlua-pkg.toml and delete patches/mathx
Install does not fail over it; the project builds against the new upstream. htl pkg patch on an already patched dependency refreshes the copy from the revision the pin now
resolves to and records that as the new base. The copy is overwritten rather than merged,
so carrying the project's own change forward onto it is a merge git performs — which is
why a directory with uncommitted changes is refused, naming them, and why --force (which
discards them) is a flag rather than the default. Outside a repository the question cannot
be asked at all, and that is said rather than guessed at.
Tests
local t = require -- typed via test.d.tl
t.
expect(x) is generic, so t.expect(1 + 1):to_equal("2") is a type error and the
file is refused before it runs.
The split follows Go / Rust rather than Jest: htl invests in the runner and keeps
the assertion surface small enough to read in one screen. Matchers: to_equal,
to_not_equal, to_be_truthy / to_be_falsy, to_be_nil / to_not_be_nil,
to_be_close, to_be_greater_than / to_be_less_than / to_be_at_least /
to_be_at_most, to_contain / to_not_contain (substring or array element),
to_match / to_not_match (Lua pattern), to_have_length, to_error. A function returning two values is asserted with
t.expect_all(f()):to_equal(false, "no door") (t.expect(f()) is a 2-argument call and
a type error; the message says so).
Snapshots: t.expect(session.frame(s)):to_match_snapshot("first floor") compares
the value with tests/__snapshots__/<test file>/<name>.snap. The first run writes the
file (and says so); later runs fail with a -expected +actual line diff when the value
changed; htl test --update rewrites the differing ones. A string is stored as is, an
array of strings as its lines (a rendered screen), anything else in a sorted,
one-entry-per-line form, so the files read well in a review. A name used twice in one
file is an error.
Coverage: htl test --coverage prints, per .tl module the tests' checks depended on,
how many of its statements ran (executed/all %), and a total; a module no test
reached shows 0/n. Under a module it names the functions nothing entered, since the
percentage says how much was missed and not what:
coverage: src/combat.tl 124/181 68.4%
never ran: resolve_counter (61), flee_path (130)
--coverage-lines adds the unexecuted line ranges under those. Statements are counted
from the .tl syntax tree and matched against Lua's line hook (Teal keeps line numbers
when it generates Lua), so the numbers are .tl lines. A function counts as entered
when a line strictly between its function and its end ran: defining a function runs
both of those lines, so neither says anything about calls. A function with nothing in
between — written on one line, or with an empty body — is not reported. The hook slows
the run, and code that runs inside a coroutine the program creates is not seen.
--lcov coverage.info writes the same run as an lcov tracefile, which is what Codecov,
Coveralls, GitLab, genhtml and editor gutters read; it implies --coverage, and the
table and --format json are unchanged. One record per module the table lists: FN /
FNDA from the functions above (1 when the body was entered, 0 when not), DA per
line a statement starts on, with a count of 1 or 0 — the hook records whether a line
ran, not how often, and consumers treat any non-zero as covered. Two statements starting
on one line share the entry, so LF / LH differ from the table's total / executed
by exactly those lines. There is no branch data and no BRDA. SF is relative to the
project root (the htl.toml directory) rather than to where the command ran, so the
file resolves against the repository wherever CI stood; a module outside the root is
written absolute.
Randomness: the runner seeds each file before it runs, prints the seed of every run, and
takes it back with --seed, so a test that draws is one whose failure can be looked at
again:
htl test: seed 8014255196 (repeat with --seed 8014255196)
t.rng() is that stream, shaped like math.random (rng(), rng(m), rng(m, n));
math.random is the same stream, so a test already using it repeats too. Each file's
seed is derived from the run's seed and the file's own path rather than drawn from one
shared stream, so what a file draws does not depend on which other files ran or in what
order — running it alone, or with --filter, reproduces what it did in the full run. A
test that calls math.randomseed itself takes over from there; the runner does not seed
again.
Runner: htl test [paths] [--filter substr] [--fail-fast] [-v | -q] [--slow MS] [--update] [--seed N] [--coverage [--coverage-lines]] [--lcov FILE]. Each file runs in a fresh state; -v prints every test with its time,
-q only failures (with their details), errors and the summary line, --slow 50 the
tests over 50 ms, --fail-fast stops at the first failure. The run has one checker
(htl::testing::TestSession) and one fresh program state per file: globals,
package.loaded and module state never cross files, while a module is type-checked
and generated once and served to every file whose search path resolves that name to
the same file (Htl::with_checker is the same split for hosts that run many
programs). HTL_PROFILE=1 prints per-phase and per-file timings to stderr. Any library exposing
run(filter, opts) -> {passed, failed, failures, tests?, snapshots_written?, snapshots_updated?}
(and optionally configure({snapshot_dir, update, mkdir})) plugs in via --lib
(bring its .d.tl); files that use no such library pass if they run to completion.
Fixing (htl fix)
Some diagnostics carry a mechanical fix; htl check marks them (fixable: htl fix)
and --format json carries the edits. htl fix [paths] applies them:
- Every fix has an applicability:
safe(what the program does at run time is unchanged),unsafe(it may change; applied only with--unsafe),suggest(shown, never applied). Today: a forward reference gets its declaration inserted into the record (safe);explicit-numbergets: number(safe);no-globalbecomeslocal(unsafe).htl.toml[fix] unsafe = ["no-global"]promotes a rule,disable = [..]turns its fix off;--rule a,blimits a run. - The working tree is the undo. A file git reports as modified or staged is refused
(
--allow-dirty), and so is a file outside a repository (--allow-no-vcs).--dry-runreports without writing;--diffprints a unified diff per file instead. - A file with a syntax error is never touched. Type errors elsewhere do not block (a fix is often what removes one); after each pass the file is re-checked and put back if it has more errors than before. Edits that overlap an applied one wait for the next pass; passes are capped at 4; two passes producing the same edits are reported as fixes undoing each other.
- Everything applied is listed (
fixed: file:line: rule (safe)), as is everything skipped and why. Exit code ashtl check(remaining errors → 1);--exit-non-zero-on-fixalso fails when a file changed, for CI.
Machine-readable output
htl check --format json and htl test --format json print one JSON document on
stdout and nothing on stderr (the text form is stderr-only, so the two never mix).
The exit code is the same as in text mode. Field names are stable; fields may be
added, not renamed.
check:{ files, diagnostics: [{ severity: "error"|"warning"|"lint", file, line, col, rule?, message, required_by?, origin? }], summary: { errors, warnings, lints, strict, ok } }.ruleis the lint rule (nil-index,contract, ...), split out of the message. An error in a module the check reached throughrequirehasfileset to that module andrequired_byto the file that required it;originis"dependency"(installed under.htl/modules, or a vendored copy) or"external"(a[check] pathsor contract directory), and absent for a file of the project's own.test:{ files: [{ path, ok, diagnostics, error?, file_level, passed, failed, failures, tests: [{ name, ok, ms }], duration_ms, snapshots_written, snapshots_updated }], summary: { files, files_run, passed, failed, files_with_errors, duration_ms, ok, seed }, coverage?: { modules: [{ path, executed, total, unexecuted: [[first, last]], never_ran?: [{ name, line }] }], executed, total } }(coveragewith--coverage;never_ranis absent when every function of the module ran).
GitHub Actions annotations from a check, for instance:
|
Bundles (htl build)
htl build src/main.tl -o app.hb follows require("<literal>") from the entry and
links everything it reaches into one file: .tl modules type-checked and generated,
plain .lua modules (a dependency's own sources) as they are. A require that resolves only
to a .d.tl is recorded as host-provided (a Rust #[host_module], a preload);
any other unresolved require is a build error, so "module not found" happens here
and not on the first require at the user's machine. htl run app.hb runs it; a host
does Htl::run_bundle(&Bundle::decode(bytes)?, &args) after registering its modules,
and is refused up front, naming them, if one is missing.
- Payload is stripped Lua 5.4 bytecode by default.
--debugkeeps line numbers and local names (tracebacks with lines; module names survive stripping since the loader supplies them).--sourcestores generated Lua instead: larger and readable, and bound to no Lua build. - Portability. A bytecode bundle runs on any host whose Lua chunk header matches
the one it was compiled by: version, bytecode format, the sizes of instruction /
integer / number, and endianness. Nothing about the CPU or the OS is in a Lua chunk,
and the Lua htl vendors has a 4-byte instruction, 8-byte integer and 8-byte double on
every 64-bit little-endian platform, so a bundle built on an arm64 Mac loads on
x86_64 Linux and cross-building between mainstream desktop and server targets needs
nothing.
--sourceis for the cases the header refuses: a big-endian target, a host whose Lua was built with a non-defaultLUA_INT_TYPE/LUA_FLOAT_TYPE, and a bundle that has to outlive a Lua upgrade (mlua pins the Lua htl vendors, and a new one may change the format).install_bundlechecks the header before the firstrequireand refuses on mismatch, naming both sides:compiled for Lua 5.4, format 0, 4/8/8, little-endian by htl 0.1.19, but this host runs ... on htl 0.2.0. The htl version is advisory (a bundle from an older htl whose header agrees still loads); it is in the message because the header alone cannot say why two 5.4 builds disagree. htl bundle info app.hbprints what the file records — format version, the htl that built it, payload kind, the Lua the bytecode is for in the same words as the mismatch message, entry, modules, host-provided names — without creating a Lua state. That is what a build step checks in and a bug report pastes;--format jsonfor the same. A--sourcebundle says its Lua isany; a format 1 bundle (HTLB\x01, before the fingerprint) says it was not recorded.- A dynamic
require(expr)cannot be followed: list its targets under[build] extrainhtl.toml(or--extra). Modules the host provides without a.d.tlgo under[build] host(or--host). - Bundled modules are installed as
package.preloadentries, the same place a host puts its own (a name the host preloaded first is left alone: the host wins). So everything that defers to preload, a.d.tlstepping aside for the implementation or an mlua-pkg resolver over a mods dir, sees bundled modules too, and files on disk do not override the bundle. htl build <dir>(the older form) still bundles every.tlunder a directory.
From Rust, include_bundle! does the same at cargo build and keeps the guarantee
include_tl! gives a single file: every linked .tl / .lua / .d.tl is tracked,
so an edit rebuilds, and a Teal type error anywhere in the closure fails the build.
const BUNDLE: & = include_bundle!;
// payload = "source" for a target whose Lua header differs (big-endian, non-default
// number types; see Portability above); debug = true keeps line numbers.
// [build] extra / host in htl.toml are merged in.
Host .htl_preload?;
h.run_bundle?;
Doing the same from a build.rs with htl::link::link works too: take the bundle
through Linked::bundle() / into_bundle() (an Err lists every type error; link
itself returns Ok so the whole list can be shown, and never hands out a bundle with
a module missing), and emit cargo:rerun-if-changed=<file> for each of
Linked::inputs(). Name files, not the directory: cargo compares the mtime of the
path it is given, and editing a file inside a directory does not change the
directory's.
Layout of a project (htl new)
<name>/
├── mlua-pkg.toml [package] entry = "src/<mod>" → consumers require("<name>")
├── htl.toml [lint] / [fmt] / [[contract]] shared by the CLI and include_tl!
├── src/<mod>/init.tl the module (require("<mod>") from src/ and tests/)
├── types/ .d.tl the project consumes (hand-written) and publishes
│ (a ---@contract type), searched by default
├── patches/<dep>/ a dependency taken into the tree (htl pkg patch), committed;
│ checked, not formatted, its tests not run
├── src/main.tl entry script
└── tests/<mod>_test.tl
mlua-pkg's entry is a directory, so a consumer's require("<name>") looks for
<name>/init.tl. A flat package can instead ship <name>/<name>.tl (e.g. entry = "src"
with src/<name>.tl); htl resolves that form in the checker and in TealResolver.
Unions of records (where)
Teal refuses a union of two record types on its own:
cannot discriminate a union between multiple table types: A | B
The refusal is about run time, not syntax: is narrows with a type() check, and two
records are both table. A record can supply its own discriminator with a where clause,
and then the union type-checks and is narrows it:
local record Monster
where self.kind == "monster"
kind: string
hp: integer
end
local record Item
where self.kind == "item"
kind: string
weight: number
end
local function describe(e: Monster | Item): string
if e is Monster then
return "hp " .. tostring(e.hp) -- e.weight here is an error
else
return "weight " .. tostring(e.weight)
end
end
where takes an expression that uses self once; comparing an enum-typed tag field
works the same way and is the usual shape. Inside a narrowed branch the other variant's
fields are not in scope — reaching for one is invalid key 'weight' in record 'e' of type Monster — and a partially narrowed value keeps its remaining variants, so after is A
over A | B | C the value is B | C and a field only B has is still an error.
That "once" is the cost of the form, and it decides where the form belongs. One record cannot answer to two tag values:
cannot use argument 'self' multiple times in macroexp
So a type with seven tag values needs seven records, and it is worth writing them only
when the variants carry different data. Where several tags carry the same data, a union
buys nothing an enum field on one record does not already give: the branches are guarded
by enum-exhaustive either way, and the declarations are the only thing that grew.
A worked example from a project that decided against one. Its Effect has five fields and
seven tag values, but only four payload shapes among them — power, power + damage,
status, and nothing at all. As a union that is seven records, four of them structurally
identical, around thirty lines of declaration, to gain field safety at the one place it is
read. It stayed an enum plus a record, and that was the right call.
The question to ask is not "does this have a tag" — plenty of records do — but "do the variants hold different things". When they do, the union pays for itself at every use site. When they do not, the tag was already saying it.
A variant nobody handled is not a type error — an is chain that covers A and B and
falls through compiles, and goes on compiling when C joins the union — so the
union-exhaustive lint reports it. It reads the union's members from the checker rather
than from the tests, and stays quiet for a chain with an else, for a single is (that
is a guard, not a dispatch), and where every branch returns and code follows, which is the
else written differently. Those are the same exemptions enum-exhaustive makes.
This is a Teal feature, not an htl one; it is documented here because the error above is
what a reader meets first, and it reads like a dead end rather than a pointer to where.
Pitfalls the checker now names
- Case-insensitive filesystems (macOS, Windows):
require("site")from a file calledSite.tlresolves to that very file. Teal reports it as "no type information for required module"; htl appends that the module resolved to the requiring file itself and that one of the names has to change. - Numeric inference:
local n = 0isinteger,0.0isnumber; opt into theexplicit-numberlint to be told where an annotation is missing. - Forward references:
function world.tickcallingworld.observethat is defined further down is "invalid key 'observe' in record 'world'", because Teal adds a record's fields in source order. htl names the later definition and hands over the line to paste into the record (observe: function(w: World, what: string)), which also makes the record the module's declared API; moving the definition up is the other fix. - A union of two records: "cannot discriminate a union between multiple table
types" reads like a limit on the type system, and it is a limit on
is, which each record can lift for itself with awhereclause (see above). - Multi-value call in last position:
t.expect(can_cast(x))withcan_castreturningboolean, stringis a 2-argument call, and Teal reports "wrong number of arguments" atexpect. htl names the expanding call and the two fixes (bind first, or parenthesize to keep the first value).
Releasing and using a local checkout
docs/releasing.md: the publish order, the crates.io per-crate 24-hour version limit
and what to do when it hits, and how a consumer runs against an unpublished htl with
[patch.crates-io] (all three crates, plus one cargo update -p htl -p htl-core -p htl-macros, without which cargo keeps the locked version and warns that the patch
was not used).
What is deliberately not here
- No Teal fork:
tl.luais vendored verbatim (0.24.8, MIT) and swapped as a file. - No token-level formatting:
htl fmtrecomputes indentation and whitespace only. - No Luau: PUC Lua 5.4 / LuaJIT via mlua features. Bytecode bundles are bound to the
Lua chunk header of the
htlthat built them, which every 64-bit little-endian host shares (see Bundles, Portability); there is no dual bytecode-plus-source payload, since shipping the source is what the bytecode form exists to avoid. .d.tlfiles are written syntactically, from Rust source (htl dts, and the macros at expansion time write the same text) and from Teal (the module a---@contracttype is declared in). There is no reflection on types either way: a Rust field of typeFoois declared asFooand it is on you that a TealFooexists, and a Teal signature is carried across as it was written.
License
MIT OR Apache-2.0. Teal (crates/htl-core/vendor/tl.lua) is MIT, see
crates/htl-core/vendor/LICENSE.teal.