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] |
type-check; htl lints as lint: (advisory, --strict fails on them) |
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] |
*_test.tl and tests/**/*.tl, one isolated state per file |
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 pkg <args> |
passthrough to mlua-pkg at the nearest mlua-pkg.toml root |
htl dts [dir] |
write the .d.tl files declared by #[host_module] / #[derive(TealRecord)] from Rust source, no build needed (check / run / test / build do this automatically when inside a crate) |
mlua-pkg.toml is detected by walking up from the file: vendored deps become
visible to the checker and to run / test / build automatically. When a
directory is given, check / fmt / build / test walk the project's own files only:
target/, node_modules/, .mlua-pkgs/ (or wherever MLUA_PKG_DIR points) and any
dot-directory are not entered, so dependencies' sources and tests stay theirs. A
directory passed explicitly is always walked. 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.
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
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.
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() for contracts where every declared field is mandatory:
the module is then rejected at require naming the nil fields. Keep the default and
nil-guard on the host side when some fields are optional.
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 |
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 |
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).
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
[[]] # static form of TealResolver::expect_type / require_fields
= "mods" # relative to htl.toml; "sites/*" = every subdirectory of sites/
= "defs.Mod" # every module directly under `dir` must return this record
= true # ... with every declared field present in the returned table
= ["modkit"] # modules in `dir` not held to it (an SDK the host writes there)
# module = "Site" # or: 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 and its src/.
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.
A [[contract]] adds two lints:
contract— a module underdirwhose return value is not assignable totype, or (withrequire_fields) whose returned table literal leaves a declared field 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.contract-unenforced— a contract is only a guarantee if the host enforces it. When a Cargo package is found,htl checkscans its Rust sources forexpect_type("<type>")(plus.require_fields()when required) or for the config-driven helpers below, and otherwise tells you what to add.
Hosts get resolvers from the same file, so the two cannot drift:
let = find?.expect;
let mut reg = new;
for r in contract_resolvers?
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_be_close,
to_be_greater_than / to_be_less_than / to_be_at_least / to_be_at_most,
to_contain (substring or array element), to_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).
Runner: htl test [paths] [--filter substr] [--fail-fast] [-v | -q] [--slow MS]. 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?} plugs in via --lib
(bring its .d.tl); files that use no such library pass if they run to completion.
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 (vendored dependencies) 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, but loads on any Lua build. - The bundle carries the compiling Lua's bytecode header (version, instruction /
integer / number sizes, endianness). Lua's own version byte is
0x54for every 5.4.x, so this is whatinstall_bundlechecks, with a readable message on mismatch. - 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 loaders receive
(modname, "bundle:<name>")like a file searcher. The bundle's searcher sits right afterpackage.preloadand before the file searchers: the host's modules win, 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 cross-compiling (bytecode is produced by the build machine's
// Lua); 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/)
├── 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.
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. - 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).
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; bundles are bound to the Lua
generation of the
htlthat built them. .d.tlfiles come from Rust source syntactically (htl dts, and the macros at expansion time write the same text). There is no reflection on types: a field of typeFoois declared asFooand it is on you that a TealFooexists.
License
MIT OR Apache-2.0. Teal (crates/htl/vendor/tl.lua) is MIT, see vendor/LICENSE.teal.