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)
──#[c_export]──▶ extern "C" wrappers + host.h (feature `ffi`: a caller that is not Rust)
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 / c_export; 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 for no entry script; --target <name> for what will run the output, --embed being the shorthand for --target bin; --htl <req> for which htl the project depends on; --no-x for no htlx dependency) |
htl check [paths] [--strict] [--lint rule=level] [--no-cache] [--cache-mode per-module|whole-run] [--explain-cache] |
type-check; htl lints as lint:, advisory at their default level and fatal at deny (--strict promotes every warn to deny); 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] [--junit 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 fix [paths] [--rule a,b] [--unsafe] [--dry-run] [--diff] [--exit-non-zero-on-fix] |
apply the fixes diagnostics carry: the safe ones by default, --unsafe for the ones that may change what the program does (see Fixing) |
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] [--no-cache] [--explain-cache] |
link the entry's require closure into one bundle (see Bundles), replaying from the run cache what still holds (see Caching; the directory form is not cached); a bundle is the hb target, so a project whose [build] target is bin or cdylib is refused (see Build targets) |
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 unused [paths] [--format json] [--exit-non-zero-on-unused] [--no-cache] |
the complement of the same closure: modules no entry reaches, and [deps] no reached module requires (see Unused) |
htl resolve <module> [path] [--format json] |
which file require("<module>") resolves to, and the whole chain in search order: what is read, what it shadows, and which crate or dependency each one came from (see types/); exits 1 when the name resolves to nothing |
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; from the crate graph, the ones a dependency ships ([package.metadata.htl] dts) into types/<crate>/. check / run / test / build do this automatically; exits non-zero when something it was asked to write could not be, and never on a file it only left in place |
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. Inside, vendored/<name> is mlua-pkg's link to each dependency's package root,
and entries/<name> is htl's link to the directory below that root the dependency's
entry names (src/<name> for a library htl new --lib wrote), which is where
require("<name>") looks — a diagnostic in a dependency carries that path, and a
dependency's types/ is read from the root beside it. The links are written from
mlua-pkg.lock by htl pkg install and repaired by any command that reads the path.
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/entries/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.
A failure at run time names Teal, not the Lua htl generated — the file and the line that
raised, and the same for every frame that reached it. htl run boom.tl, where boom.tl
calls into depth.tl:
runtime error: ./depth.tl:8: attempt to index a nil value (local 'c')
stack traceback:
[C]: in metamethod 'index'
./depth.tl:8: in function 'depth.field'
./depth.tl:12: in function 'depth.describe'
boom.tl:3: in main chunk
Nothing had to be mapped back. Teal keeps the input's line breaks when it generates Lua,
and htl loads every chunk under its source's own name, so a Lua frame is already a Teal
frame. The innermost line says a value was nil; the frames say which caller passed it,
which is the part a reader who did not write the program cannot guess. htl test reports
the same for a failing test and for a file that raises while loading, and --format json
carries the text unchanged in each file's error and failures.
The exception is stripped bytecode, which is what a bundle holds by default and what
include_tl_bytes! embeds. Stripping drops the line numbers and the chunk's own name
together, so those frames say what raised and not where. A scaffolded Rust host
(htl new --target bin), failing inside its embedded module:
runtime error: ?:-1: attempt to index a nil value
stack traceback:
[C]: in metamethod 'index'
?: in upvalue '?'
?: in function 'sample.greet'
src/main.tl:4: in main chunk
The last frame is the entry script, embedded with include_tl! as source, and it still
names its file and line; the two ? frames are the stripped module, left with the
function names Lua recovered from the calls and nothing to open. A bundle is stripped
throughout unless it was built with htl build --debug (see Bundles). Whichever a host
ships, the .tl is still there, and running it is what gives the frames back —
htl run src/main.tl, the same failure:
runtime error: src/sample/init.tl:9: attempt to index a nil value (local 'g')
stack traceback:
[C]: in metamethod 'index'
src/sample/init.tl:9: in upvalue 'title'
src/sample/init.tl:14: in function 'sample.greet'
src/main.tl:4: in main chunk
Frames are for whoever wrote the Teal. A host embedding htl in a program whose users did
not write it shows them htl::user_message(&err) instead — the innermost cause alone (see
Embedding in Rust).
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.
htl build and the macros (include_bundle!, include_tl!, include_tl_bytes!) read
the same store. A typed module in the closure whose entry still holds is taken from it —
the Lua it generated, and what checking it reported — instead of being generated again,
and htl build says so with the same [cached] / [31/32 cached] suffix (the directory
form of build, the older snapshot, is not cached). The entries are the ones htl test
writes for the modules its test files reach (module, holding the generated Lua beside
what checking said), keyed by the file and the lint selection and stamped without the
binary, so a test run feeds the next build, a build feeds the next test run, and a
cargo build replays what htl build generated. Bytecode is never stored: compiling the
stored Lua is milliseconds, and the bundle a replayed build writes is byte-for-byte the
one a cold build writes. build takes --no-cache and --explain-cache and is always
per-module; the macros keep a store only in a project with an htl.toml, and never under
target/ (the copy cargo publish verifies) or a registry checkout — HTL_NO_CACHE=1
turns it off, HTL_CACHE_DEBUG=1 has them say how much they replayed or why they did
not. Only htl check bounds the store: a build or an expansion sees one closure and
would evict the rest of the project. On a 30-module, 16,000-line project (release CLI): a
cold build 1.3 s; nothing edited 0.03 s; one leaf edited 0.7 s, [30/32 cached] — the
leaf and the entry that requires it are generated, the rest replay; the module every
other one requires edited, 1.3 s again, since every entry read it.
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. A level is in the key because it is written in the same spec as which
rules run — moving one rule between warn and deny changes no diagnostic, and re-checks
anyway.
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).
A string that looks like a number is one only where the program says so, and that is
three layers. Checked Teal is the checker's: "10" + 1 and s * 2 are type errors, on
an any as much as on a string, and tonumber(...) is a number that no integer
accepts. What the checker did not see is Lua's — the far side of a cast, `(v as integer)
- 1
wherevis the"10"thatstd.json.decodeorarghanded over; a functionloadbuilt from a string; Lua source the host gaveexec— and Lua 5.4 reads"10" + 1there as11.h.strict_strings()?inpreloadmakes that an error naming the string (it removes the arithmetic metamethods from the string metatable, which is where Lua keeps that conversion). It does not touch10 .. "", which the VM does, nor"10" < "9", which is a string comparison, nortonumberandmath.tointeger, which convert because they were asked to — so a value that arrives asanyis converted once, where it arrives, by the program:s:match("^%-?%d+$")andmath.tointegerwhen hex, an exponent and surrounding whitespace are not wanted,tonumberwhen they are, and never a cast. The third layer, a host's own argument, is#[host_module]'s: mlua converts a Lua string to ani64parameter and a number to aStringone, so a parameter that may see a value from that edge is aStrict—n: Strictis declaredintegerexactly asi64is, takes a Lua integer (or a float with no fraction) and refuses"10"witherror converting Lua string to integer.Strict,StrictandStrictare the same for their kinds; it derefs toT`.
The second argument to exec is the chunk name: the name every frame of a run-time
failure inside that chunk is reported under. @<path> is a source location and prints as
the path, so @scripts/main.tl gives a reader something to open; =<label> is a bare
label, the honest answer for a module no file backs, which is how htl registers its own
test library as =htl.test. preload takes no chunk name and uses the .tl a require
of that module name would have found (foo.bar → @foo/bar.tl); preload_at takes one
when the source is somewhere else, or when there is no source.
preload_bytes is the exception, and it is worth knowing before reading a failure from
an embedded module. A compiled chunk carries the name it was compiled under, and stripping
drops that name along with the line numbers — so a frame from include_tl_bytes! reads
?: in function 'util.greet', whatever name the load was given. The Teal is still there
to run: htl run scripts/util.tl and htl test name the file and the line.
The macros run the checker — htl-core and the vendored Lua that hosts tl — inside the
proc macro, and the dev profile compiles a proc macro and its dependencies under
[profile.dev.build-override], whose default is opt-level = 0. Left there, a
cargo build that touches a .tl runs the checker about three times slower than the
release-built CLI: on a 30-module, 16,000-line project, 3.8 s against 1.3 s for
htl build of the same closure, and 1.6 s once the host's Cargo.toml says
[]
= 3
htl new --embed writes that section; add it by hand to a host that predates it (one
rebuild of the macro's dependencies, then every build after). The .tl edit loop belongs
to htl check / htl test in any case — an edit to a leaf module costs a few
milliseconds from the cache — and cargo build to the Rust host and the binary.
#[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.
The derive takes more than a struct, so a host's closed sets and aliases reach Teal as
declarations rather than as any:
| Rust | Teal declaration | crosses as |
|---|---|---|
struct Point { x: f64, y: f64 } |
record Point |
a table |
enum Mode { Fast, Careful } |
enum Mode "Fast" "Careful" end |
the variant name, a string; any other string is refused: Mode: expected one of "Fast", "Careful", got "fst" |
enum Shape { Dot, Circle(f64), Rect { w: f64, h: f64 } } |
record Shape_Dot, record Shape_Circle, record Shape_Rect, each where self.kind == "…", and type Shape = Shape_Dot | Shape_Circle | Shape_Rect |
a table with kind; a newtype payload under value, struct fields under their names; union-exhaustive counts the variants, and a missing field reads Shape.Rect.h: expected number, got nil |
#[teal(rename_all = "snake_case")] enum State { Open, InReview } |
enum State "open" "in_review" end |
the renamed word: "open" is accepted, "Open" is refused (State: expected one of "open", "in_review", got "Open") |
struct Label(String) |
type Label = string |
whatever the inner type crosses as |
Option<T> |
T as a field and as a return, name?: T as a method parameter |
nil where the Rust side has None: a Teal record field is nilable already and a return position has no ?, while the mark on a parameter is what lets a caller write api:find("x") |
Vec<T> / &[T] / [T; N] / VecDeque<T> / HashSet<T> |
{T} |
a table used as a sequence |
HashMap<K, V> / BTreeMap<K, V> |
{K:V} |
a table keyed by K |
mlua::Value / serde_json::Value |
any |
unchanged: the deliberate escape hatch |
A data-carrying enum is declared nested in the host module (records = [Shape]), where
its variant records are reachable as host.Shape_Circle for is; a .d.tl module of
its own could export only the union, so #[teal(dts = ..)] on one is refused. Unit
enums and newtypes may stand alone, and uses = [Name] imports every kind with
local type Name = require("Name").
A variant reaches Teal under its Rust name unless the enum says otherwise:
#[teal(rename_all = "..")] on the enum takes serde's set — lowercase, UPPERCASE,
PascalCase, camelCase, snake_case, SCREAMING_SNAKE_CASE, kebab-case,
SCREAMING-KEBAB-CASE, spelled as serde spells them, so a type that is also Serialize
can say the same thing twice and the two agree — and #[teal(name = "..")] on one
variant overrides it. The word is what the declaration lists, what a value must say to
cross, what the message lists when it does not, and, for a data-carrying enum, what
where self.kind == "in_review" tests; the variant records keep their Rust names
(Shape_InReview), since a Teal identifier cannot be kebab-case. Two variants that end
up with the same word are refused at expansion, naming both. Record fields have no
equivalent — they are declared under their Rust names, and #[teal(..)] on a field is
refused rather than ignored.
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). An Option<T> parameter is
declared name?: T, so a caller may leave that argument out (or pass nil) and the method
sees None; Teal parses the mark only on a trailing run of parameters, so an Option
with a required parameter after it is declared as the plain T and has to be passed.
Another host type comes
in as UserDataRef<T> (UserDataRefMut<T> to mutate it, UserDataOwned<T> to keep it)
and is declared as T, the same name a method returning it declared; nested types come
from #[derive(TealRecord)] structs and enums in the same source file, types from
other modules via uses = [Name] + their own .d.tl.
The name a host module registers is a name the Teal sources no longer own. The host puts
it in package.preload, which Lua consults before any path searcher, so a scripts/host.tl
sitting beside #[host_module(name = "host")] is the file the check reads and never the
module the program runs — green check, successful build, attempt to call a nil value at
the first function the two do not share. host-module-shadowed reports that at the
require, naming both. It is a lint rather than a fix: which of the two should give up the
name is the project's decision, and htl moves neither resolution order.
Your own Lua
Htl::new opens every standard library — debug, io and os included — on a state
it makes itself. That is the right state for htl run, for the checker, and for a host
running Teal it wrote. A host running Teal it did not write (a mods directory, a script
a user dropped in) decides what that Teal may reach, and decides it on the Lua: the
libraries at construction, the allocator's bound, the hook that counts instructions.
htl takes the state the host built and adds no limit of its own:
use Htl;
use ;
let checker = new?; // the checker keeps everything it needs
// SAFETY: a state that loads bundles has to accept binary chunks, which mlua's safe
// `new_with` refuses; this one loads only what the host hands it.
let lua = unsafe ;
lua.set_memory_limit?; // mlua's: past it, an allocation is `MemoryError`
let h = with_checker_lua?; // the program runs here; `os` and `io` are nil
Htl::from_lua(lua) is the same for the shared form, where the checker runs on the
host's state too; that state then needs what the checker needs as well.
What htl itself needs from a program state: package (the searcher and preload) and
the base library's load; debug, only for htl test --coverage. The checker state
Htl::new makes uses string, table, math and package, and os.getenv and
io.stderr on its debug paths — and it is not the state a mod runs in.
The limits are mlua's, and so are their edges, which are worth knowing before relying
on one. An instruction hook fires only while Lua is executing Lua, so a host function
that blocks is one instruction; set_global_hook reaches the coroutines a script
starts, set_hook one thread. A thread has one hook, and a script with debug can
replace it — leave debug out of a state that runs Teal you do not trust. A memory
limit is checked after Lua's emergency collection, and MemoryError is what comes back.
htl check settles what a module is; what it may do is settled here, by the host.
Shipping the declaration to your users ([package.metadata.htl] dts)
A crate that registers a module in someone else's Lua state has to hand them the
declaration of it too — their htl check searches their project, not your package. Name
the files your macros write, in your manifest:
# your-crate/Cargo.toml
[]
= ["dts/mq.d.tl"] # written by this crate's own #[host_module(dts = "dts/mq.d.tl")]
A crate with one declaration per module in one directory names the directory and a *
instead of the list: dts = ["types/mine/*.d.tl"]. The * stands for any run of
characters in the file name and nothing else — no ?, no ** — and the directory is
spelled out. A pattern that matches nothing is reported like a listed file that is not
there, under the pattern's own name.
The file's own name is the module name, because types/<crate>/ goes on the search path:
dts/mq.d.tl is require("mq") however deep in the package it sat. A crate whose modules
have a namespace — one that registers mine.thing — says where its paths start, and what
is below that root is kept:
[]
= "types"
= ["types/mine/thing.d.tl", "types/other/log.d.tl"]
which lands at types/<crate>/mine/thing.d.tl, so require("mine.thing") is the module
the crate registered, and two modules called log in two namespaces are two files. An
entry that does not start at the root is reported like a file the crate does not ship.
Every project that depends on the crate then gets them under types/<crate>/ from htl dts (and from check / run / test, which generate before they work). Keep the files
current the way this repository does — the macro rewrites them, CI diffs them — and commit
them; they are what a consumer's checkout copies from, before anything of yours is built.
What htl dts reports, and what it exits on
htl dts says what happened to each declaration, one line each (check / run / test
/ build print the same lines, prefixed dts:):
| line | meaning |
|---|---|
wrote <file> |
written now |
unchanged <file> |
already what it should be |
not written: <why> |
asked for and not written: a crate names a file in [package.metadata.htl] dts that is not a .d.tl, or is not in the package, or does not start at the dts_root that manifest declares, or names two that would be one file under types/<crate>/, or the file could not be written |
left in place: <file> |
under types/<crate>/ from an earlier run, and not what is read now — the crate is gone from the graph, or still there and no longer naming the file, or one this binary carries itself (std.*, whose copy an htl built without that feature may have written: the crate is still a dependency, and the line says so) |
The exit code is about not written and nothing else. It is non-zero when a
declaration this command was asked to write could not be written — so a CI step that
regenerates declarations does not pass having written nothing. left in place fails
nothing: the file is still there and still checked, and whether to delete it is the
project's call, since a script may still require the module and the dependency may be
back on the next branch. htl dts deletes nothing under types/ on its own.
Neither line is a lint. They are this command reporting on its own job, so they carry no
[htl <rule>] name, they are not in --list-lints or [lint], -- htl: allow(...)
does not apply, and htl check --format json never carries one — a lint is a finding
about your code, and these two are about files this command was asked to write.
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:, and there are two things to do with it. A host chooses by
audience: htl::developer_message(&err) returns the cause with the frames below it, which
is what htl run and htl test print because whoever runs them wrote the Teal;
htl::user_message(&err) returns the innermost cause alone, which is what a program puts
in front of people who did not and cannot act on a stack. The C ABI takes the second
(see below).
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.
A C ABI for a host that is not Rust (feature ffi)
#[host_module] hands the Rust host to Lua. #[c_export] hands the same impl block to
a caller that is not written in Rust — a Unity script, a Swift app, a Python REPL — as a
C ABI, with the header written from the same breakdown so a rename moves both.
use ;
Add htl = { version = "…", features = ["ffi"] } and crate-type = ["rlib", "cdylib"]
(plus "staticlib" for Unity on iOS). cargo build writes include/game.h and the
library exports game_* and nothing else.
What the ABI promises. Everything a caller holds is an opaque handle pointer, a
char * this library allocated, or an int. There are no structs by value, no bool,
no bare enums and no variadics — the list every host language breaks on, one way or
another (C# marshals a returned string and then frees it with CoTaskMemFree; Python's
restype = c_char_p copies and leaks the original).
| C | Rust | |
|---|---|---|
const char * |
&str / String, or any serde type as JSON |
borrowed for the call; free it when you like afterwards |
char * |
a String or a serde type returned |
ours: hand it back to game_free, always |
int |
a status, never a value | GAME_OK and friends |
int * |
the out-parameter an i32 result is written through |
so no function returns three meanings in one int |
game_handle * |
the opaque handle | from game_open, to game_close |
Any other signature is a compile error naming the type and this set: a bool parameter,
a struct by value, a float, an integer of another width, a generic or an async fn does
not build, rather than building and going wrong on the far side.
The status enum, as int: OK 0, ERR 1, BAD_HANDLE 2, NOT_FOUND 3, LUA 4,
PANIC 5, WRONG_THREAD 6, INTERRUPTED 7. A char * function answers NULL when it
fails and game_last_status() says which of these it was. game_last_error() is the
message — a pointer owned by the library, valid until the next call on that thread —
and game_last_error_into(buf, len) copies it into a buffer of the caller's own.
Panics do not cross. Since Rust 1.81 a panic reaching an extern "C" frame aborts
the process, which for a plugin host means taking the editor down with it. Every wrapper
catches it, records it as PANIC with the panic's message, and poisons the handle:
every later call on it answers PANIC without running anything.
One handle, one thread. The handle records the thread that opened it and every entry
checks it, so using it elsewhere is WRONG_THREAD rather than a data race
(game_threadsafe() answers 0, the sqlite3_threadsafe() convention). The exception is
game_interrupt(h), callable from any thread: it sets an atomic that a Lua debug hook —
the state's global one, so a loop inside a coroutine is reached too — turns into an
error at the next tick, which is how a runaway mod is stopped from a host that cannot
preempt it. One interrupt stops one run; the handle stays usable.
Conventions the generated code fixes, so a project does not decide them again:
game_open takes one JSON object — pass absolute paths, a seed and names in it rather
than expecting the library to read the environment or the working directory; records
cross as JSON text and an object payload carries "v", a schema version separate from
GAME_ABI_VERSION (which is the shape of the functions, and what a host that never
unloads a library compares before calling anything else).
htl dts writes the header too, so it can be regenerated and diffed without a build.
Both ways of holding Teal are in this repository, built and run on every commit:
examples/ has embed, where include_tl!, include_bundle!,
#[derive(TealRecord)] and #[host_module] all meet in one binary, and resolver, where
nothing is embedded and require goes through mlua-pkg at run time. Its README says what
each one prints and which line of the output is the point.
A project of this shape is htl new --lib --target cdylib <name> (see The cdylib target):
the crate types, the feature, the #[c_export] block and — the part a reader of an ABI
actually needs — a caller in C and a caller in Python that do the round trip and free
what they are handed.
Lints (htl check, include_tl!)
Every rule has a level, and the level is what a project sets: allow is not reported,
warn is reported and does not fail the run, deny is reported and fails htl check. The
three words are selene's and Cargo's [lints]. The default column below is each rule's
level for a project that says nothing, and nothing defaults to deny — which is why
adopting a rule gradually is a thing you can write:
[]
= "deny" # this one stops the run
= "warn" # allow by default; see it while you migrate, without failing CI
= "allow" # quote a name with a `:` — TOML has no bare key for it
| rule | default | catches |
|---|---|---|
nil-index |
warn | t[k].x, t[k]:m(), t[k](), t[k][j] — Teal types a map/array lookup as V, not V | nil |
nil-return |
warn | the same four shapes over a call — f(x).y, f(x):m(), f(x)(), f(x)[k] — where f is declared ---@nilable. Silent until a declaration carries the marker (see below) |
nil-return-unchecked |
allow | the local such a call was bound to, used as the base of a chain before any statement looks at it — local d = f(x) then d:upper(). One report per local, at the first use. Off by default: it is a flow question, and the shapes it gets wrong are the ones where something did check (see below) |
htlx-available |
allow | a for i = 1, #t do loop whose whole body is a function htl-x already has — list.map, list.to_set, list.filter — in a project that depends on htl-x. Silent in a project that does not. Off by default: what it reports is right, and the call it names is a library's (see below) |
struct-fields |
warn | 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). htl fix spells the missing fields at the site, as a suggestion it never applies |
sealed-record |
warn | a table built for a record marked ---@sealed, or an as cast to one, outside the file that declares it — outside the functions the marker names, when it names any (---@sealed(gate.judge)). Silent until a record carries the marker (see below) |
enum-exhaustive |
warn | 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 |
enum-cast |
warn | e as E where E is an enum and the checker types e as string: as is erased, so the word enters the enum with nothing checking it. A string literal ("open" as E) and a value already typed as the enum are not reported (see below) |
enum-table |
warn | a table constructor whose declared type maps an enum ({string: E}, {E: T}) and that leaves a value of the enum out, or lists a word that is not one. An array of the enum ({E}) is a selection, not a mapping, and is not reported. htl fix enum-table fills a {string: E} one in |
union-exhaustive |
warn | 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 |
warn | a local / loop var / parameter reusing the name an enclosing scope bound to a required module: the message names the module, where it was required, and that the module is unreachable for the rest of that scope. Shadowing an ordinary outer local is not this rule — it is tl:redeclaration, which reports the same line and column and says more about it (see below) |
no-global |
warn | global declarations |
no-any |
allow | explicit any annotations and as any casts |
explicit-number |
allow | 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 |
allow | 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 |
duplicate-declaration |
warn | two .d.tl for one module both reachable on the search path: position alone decides which is read, and nothing wrote that order down. Names the one read and the one that was not (see "Project config") |
host-module-shadowed |
warn | a require of a name a #[host_module] in the surrounding crate registers that resolved to a Teal file of that name: package.preload beats the path searcher at run time, so the file is what is checked and the host is what runs. Reported at the require, naming both (see "Rust host") |
contract |
warn | a module under a [[contract]] directory that does not satisfy the contract's type or its ---@required fields, and a ---@contract marker that cannot be turned into a contract or published (see "Data from outside the program") |
contract-unenforced |
warn | a contract the host never builds resolvers for, so it is documentation rather than a run-time guarantee. Say where the enforcement lives with [[contract]] enforced_by when the scan cannot see it |
require-cycle |
warn | 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 |
Teal's own warnings are named too, in a namespace of their own: see Teal's own warnings below.
Every name in either table is a name you can write back, and every one takes a level:
--lint contract=deny, [lint.rules] require-cycle = "allow", HTL_LINTS=tl:unused=allow,
and htl check --list-lints lists them all with their defaults. Where a finding comes
from — one file's syntax tree, the project layer once the files are checked, or the
vendored compiler — changes nothing about how it is named, configured or judged; one
registry holds the rules.
The listing is every rule a check reports under, which is not quite every name htl
takes: htl fix also files an error's fix under a class (forward-ref, tl:error), and
those two are names for --rule and [fix] only — no level, not in [lint.rules], not in
--lint. See Fixing.
On the command line and in HTL_LINTS, +rule and -rule are the older spelling and say
the same thing a level does: + is =warn (report it) and - is =allow (do not). The
level form is what can also say deny. Later entries win, so a flag raises or lowers what
htl.toml set:
htl check src --lint nil-index=deny,no-any=warn,-tl:hint
[lint] strict and --strict are the same statement made run-wide: for this run, every
warn counts as deny. Nothing is advisory under it, because everything a run reports is
at warn or deny — allow is not reported at all, so a rule you silenced does not come
back under strict, and is not counted either. A run's summary says both: 0 error(s), 2 warning(s), 1 lint(s), 1 at deny is three findings said and one of them fatal. htl check
is where levels are judged; htl test's verdict is its tests (findings are reported there
and not weighed).
Silence one occurrence with a trailing -- htl: allow(nil-index), at the line the
finding points at. That works for the project-level rules too — the require a cycle
passes through, the require that resolved to a shadowed declaration, the module that
misses its contract — and for Teal's kinds: -- htl: allow(tl:hint). There is one
exception: contract-unenforced points at the ---@contract marker, and a marker owns
the rest of its line, so a comment there is read as an argument to it. Turn that one off
by name, or answer it with enforced_by.
A comment silences the names it lists and no others, which matters where two rules land on
one line: a local over a required module is both a redeclaration and the thing
shadow-local reports, at the same position, so a line that wants both quiet says
-- htl: allow(tl:redeclaration, shadow-local). The two say different things about that
line — one that a name is shadowed, the other which module it was — which is the whole of
why both are still reported there and nowhere else.
include_tl! treats lints as errors (HTL_LINT=warn downgrades, [lint] strict = false
downgrades, HTL_LINTS=no-any=warn,-shadow-local configures which rules run). Teal's
warnings it reports and builds anyway. The macro reads which rules are on and not what
level they are at: a rule at deny fails htl check and is a lint like any other inside
the macro, whose own switch is strict / HTL_LINT.
Everything htl prints with an [htl <rule>] name is a finding about your code — a lint
of htl's own, or a warning the vendored compiler raised. htl dts's not written and
left in place lines are the command reporting on the declarations it was asked to write,
not findings, and are in neither table: What htl dts
reports.
Teal's own warnings (tl:*)
htl vendors the Teal compiler, and Teal reports warnings of its own. Each carries a kind,
and htl reports it under that kind's name in the tl: namespace — as
warning: src/a.tl:5:10: unused variable n: integer [htl tl:unused], and as
"rule": "tl:unused" in --format json:
| rule | default | catches |
|---|---|---|
tl:unused |
warn | a local, parameter, label or loop variable nothing uses |
tl:unread |
warn | a variable written and never read after |
tl:redeclaration |
warn | a declaration over a name already declared, naming the kind declared and the line and column of the one it shadows. This is where shadowing is reported, shadow-local having been narrowed to the one thing the compiler cannot say — that the shadowed name was a required module. It also sees two declarations in the same scope, which shadow-local never could |
tl:unknown |
warn | a variable the checker cannot resolve |
tl:branch |
warn | a test that can never hold, e.g. x is B where x has been narrowed out of B |
tl:hint |
warn | the compiler's suggestions: . where : was meant, pairs over an array, a string.format pattern that does not match its arguments, and more |
tl:debug |
warn | the checker reporting an ambiguity in what it inferred |
The prefix is not decoration. unused already means something else here — htl unused
reports modules nothing requires, not locals nothing reads — and these seven words are
Teal's to rename, not htl's, so they keep a namespace that says where they came from.
They are warnings rather than lints, and their level is warn, so they fail htl check
under [lint] strict or when the project sets one to "deny". Setting one to "allow"
changes whether it is reported at all, and a kind you silenced is not counted either.
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.
Growing a record that already has construction sites is that report arriving at all of
them at once, which is the moment a project either edits every site in one sitting or
takes the field back out. The way through is two steps, and the first is to add the field
with ---@optional on it — a marker that says "not yet", where the ones above it say "not
always":
---@optional -- new: remove once every site sets it
color: string
Nothing is reported, so the field can land while the sites are still short. Fill them at
whatever pace the work allows and then delete the marker line: every site that still
leaves the field out is reported, and a clean check is what tells you the last one is
done. Deleting it early is how to read that list at any point in between — htl check names every site that is short,
and htl fix --diff spells the missing field into each one as a suggestion it never
writes (color = htl_fixme("string"), a call the checker refuses wherever it lands). That
is a checklist and a line to paste from, not the migration done for you; a field has no
honest default, which is why nothing fills it in.
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 a line of its own above it — a marker trailing
one field is that field's and not the next one's. 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.
Records built where they are declared (---@sealed)
Some records mean "this went through the check": a Judged that only gate.judge() is
supposed to produce, a state a transition may mint and nobody else. In Rust that is a
private field and a constructor; in Teal a record is built by writing { ... } with the
right keys anywhere, and { ok = true } as Judged gets past even a mismatch, so the
invariant the record stands for is a comment. ---@sealed is that comment made a rule:
local record gate
record Judged ---@sealed
verdict: Verdict
at: integer
end
---@sealed(gate.open, gate.reopen)
record Draft
who: string
end
end
A table built as a Judged, or an as cast to one, outside gate.tl is reported:
`gate.Judged` is sealed: built only in gate.tl
Inside the declaring file everything is allowed — the marker is about the boundary, not
the owner. Naming functions narrows it to those, inside that file: a Draft built in
gate.open passes and one built in gate.other is reported (built only in gate.tl by gate.open or gate.reopen). The function is matched on the name as written or on its last
segment, and a function assigned rather than declared (gate.judge = function() ... end)
counts as the function it is written inside. -- htl: allow(sealed-record) keeps one site
the project stands behind.
A test that compares a whole sealed value builds one, and is reported like anywhere else:
t.expect(gate.judge("yes")):to_equal({ verdict = "yes", at = 1 }) writes a literal typed
as gate.Judged in a file that is not gate.tl, which is the rule working rather than
misfiring. Both ways through are ordinary. Either the assertion carries
-- htl: allow(sealed-record), which says this literal exists to be compared and never
leaves the test, or the test asserts the fields it is about
(t.expect(j.verdict):to_equal("yes")), which builds nothing and says which field
differed when it fails.
A record nested inside a sealed one is not sealed by that; mark it too if it should be.
Like ---@struct, this is a lint and not a type: the file stays valid Teal, other tooling
ignores the comment, and what it adds is the one thing a run-time check cannot — that no
other code minted the value. It pairs with ---@struct on the same record, which says
every field is set where this says who may set them; both report at the same site with
their own message.
Records a table may carry more than (---@extensible)
Every Teal record is closed: a table typed as a record may not carry a key the record does
not declare. That is an error rather than a lint, so no allow comment and no [lint]
setting reaches it. The two markers above move a different boundary — ---@optional and
---@required decide which declared fields a literal may leave out. ---@extensible is
about the key the declaration has never heard of:
record Mod ---@contract ---@extensible
name: string ---@required
monsters: {Monster} ---@required
factions: {Faction}
end
A table built as a Mod may now set keys the record does not declare, and htl drops the
checker's unknown field for exactly those keys. What it is for is the direction
---@required does not cover: a mod written against a newer SDK, a save file from a later
version, a table a host will grow next release. Each carries one key more than the
declaration knows about, and without this each is refused the same way a mod that is
short is refused — which leaves a lockstep edit of every declaration as the only way for
a producer to ship a new field.
The marker goes where the record is declared, in both forms — trailing, or on a line
of its own above it — like ---@struct and ---@sealed. A record nested inside an
extensible one is not extensible by that; mark it too if it should be. On a contract
record it travels with the published declaration (---@contract("mods") ---@extensible in
types/defs.d.tl), so a mod author checking against what was published gets what the
declaring project has.
Three things it deliberately does not do:
- The keys stay unreadable.
m.extrathrough the record type is still an error (invalid key 'extra' in record 'm'). The marker buys tolerance where a value is built and nothing else. A program that wants to read what it did not declare wants a map field —extra: {string: any}— which works today and needs nothing from htl. That is the right answer when the keys are to be used and the wrong one at a data boundary, since every producer then has to nest its extra keys under an agreed name, which is a change to the wire shape rather than to the type. - It does not relax which declared fields must be set.
---@structand---@requiredare untouched: a record can be open at one end (keys nobody declared) and closed at the other (the fields it does declare), andstruct-fieldsstill names the near-miss key when a required field is short. - It changes nothing for an unmarked record, which stays closed, as every record is today.
What it costs is one case, and it is worth knowing before you write the marker: a
misspelled optional field becomes silence. colour is no longer an unknown field, and
struct-fields has nothing to say because nothing is missing — the required case is still
caught, the optional case is not. That is the price of the marker rather than an
oversight. A near-miss heuristic here would fire on the very keys the marker exists to
allow, and a warning that is wrong whenever the marker is doing its job is worse than the
silence.
Functions that may return nothing (---@nilable)
A function that returns nil on some inputs — a parent directory that does not exist, an
environment variable that is unset, a pattern that does not match — has no way to say so in
a Teal signature. Every Teal type accepts nil, so function(string): string is already the
declaration of a function that may return nil, and the checker treats the result as a
string at every call site. ---@nilable says the first return value may be nothing:
-- nil when there is no parent.
parent: function(p: string): string ---@nilable
---@nilable
find: function(s: string, pat: string): string
The type does not change. local d = path.parent(p) still gives a string, so if d then … end, d or "." and passing d on all read as they always have, and what the marker
buys is the nil-return lint over the one use that cannot be right whatever the run-time
value is — indexing the call itself:
lint: src/main.tl:4:19: call result may be nil at runtime: path.parent is marked ---@nilable; bind it to a local and nil-check first [htl nil-return]
f(x).y, f(x):m(), f(x)() and f(x)[k] are the four shapes, the same four nil-index
reports over an index, and binding the call to a local is what it asks for. Silence one
occurrence with a trailing -- htl: allow(nil-return).
Why not string | nil. That form is real Teal and the checker does act on it — it
refuses the index. The trouble is everything else: Teal narrows a union with is and with
as, and with nothing else, so if v then, v ~= nil and v or "." all leave the union
in place and every caller ends up writing as string. Declaring a nilable return that way
would turn ordinary Lua into an error across every project on the declaration. The marker
carries the same fact at the cost of being a lint rather than a type.
The marker goes where the function is declared, in both forms — trailing, or on a line
of its own above it — like ---@struct and ---@sealed, and it is read from whichever
file holds the declaration: the project's own source, a .d.tl in types/, or one a crate
ships. mlua-batteries 0.7.2 writes it on path.parent / filename / stem / ext,
env.get / home and regex.find / captures, so a project using std.* gets the rule
without writing anything.
An unmarked function says nothing: no marker means unknown, not nilable, which is why adding the rule is silent on a project until someone writes a marker or depends on a declaration that has one.
Following the local (nil-return-unchecked, off by default). Binding the call is half
the advice, and the other half is the check that follows it: local d = path.parent(p)
and then d:upper() with nothing in between is the same run-time error the one-liner was.
A second rule reports that — the first use of the local as the base of a chain, once per
local, with everything that mentions the name in a condition, asserts it, or re-binds it
counting as the check:
lint: src/main.tl:5:8: 'd' may be nil at runtime: it comes from path.parent, which is marked ---@nilable, and nothing checks it before this [htl nil-return-unchecked]
It is allow by default, unlike every other rule that has a marker behind it, and the
level is the whole difference between the two halves. Indexing a call's result directly is
wrong whatever an analysis says; asking whether a guard stands between two statements is a
question a static rule gets wrong in three shapes that lua-language-server has had open on
its own version for years — a check inside a helper predicate, a check on a second local,
and local d = f(p) or error(...). htl reads a guard loosely to stay wrong in the
forgiving direction (a condition that merely mentions the name ends the tracking), and
the or at the declaration is read, so the third of those is silent here — but the first
two are not: if has_parent(p) then d:upper() end and local ok = d ~= nil followed by
if ok then are both reported, because the name the condition mentions is not d. A rule
that is still sometimes wrong while it is doing its job does not belong in every project by
default. A project that wants it writes it down:
[]
= "warn" # or "deny" to fail the run on it
[lint] strict = true does not turn it on — strict promotes what a run reports to a
failure, and an allow rule reports nothing — so the level is where it is asked for.
One occurrence is silenced with a trailing -- htl: allow(nil-return-unchecked).
A loop a dependency already has (htlx-available, off by default)
htl-x is the collections library htl new writes into a
project's [deps], and a project that has it still writes the loops it has. This rule
reports three of them, each the body of a for i = 1, #t do with nothing else in it:
| loop | call |
|---|---|
out[i] = f(t[i]), or out[#out + 1] = f(t[i]) |
list.map(t, f) |
out[t[i]] = true |
list.to_set(t) |
if p(t[i]) then out[#out + 1] = t[i] end |
list.filter(t, p) |
lint: src/find.tl:16:4: this loop is list.map(rows, row_summary): htlx is a dependency of this project, and `require("htlx.list")` has it [htl htlx-available]
sum, contains, tablex.keys and sorted_pairs are the same idea over other shapes and
are not written yet.
It is silent in a project that does not depend on htl-x — the names come from the
lockfile the project installed from, so telling a project to use a library is never telling
it to take one on. Three more conditions keep a loop from being read as a call it is not:
the index is used only to subscript (t[i], and out[i] on the left), the loop starts at
1 with no step, and the accumulator is declared empty on the line directly above. The
last is what makes the call equal to the loop rather than similar — list.map returns a
new array, and a loop over an out that already held something does not.
Off by default all the same, and for a different reason from the two rules above: what it
reports is right. A loop whose f has a side effect the author wants in that order is code
the project would argue about, and a rule that argues about working code does not belong on
in every project.
[]
= "warn"
The finding carries the rewrite as a htl fix suggestion — the loop replaced by
out = list.map(rows, row_summary) — which htl fix --diff shows and htl fix never
applies: merging that into the declaration above it is the edit a person makes.
The string boundary of an enum (enum-cast, enum-table)
A Teal enum is a string at run time and as is erased along with the types, so
h.state as defs.State promises nothing: with "opne" in the store the value is false
against every variant, drops out of every branch, and nothing raises. enum-exhaustive
guards the if chain; it cannot see that the value never entered the set. enum-cast
reports the cast when the checker types the value as string — a value it already types
as the enum is a cast that restates what is known, and "open" as defs.State is checked
by the literal itself, so neither is reported. -- htl: allow(enum-cast) keeps a cast the
project stands behind.
The hand-written answer is a table, and enum-table is what keeps it level with the enum:
local states: {string: defs.State} = {
open = "open",
assigned = "assigned",
closed = "closed",
missed = "missed",
escalated = "escalated",
withdrawn = "withdrawn",
}
local function stored_state(s: string): defs.State
return states[s] or "open" -- total: a word nobody knows falls to the default
end
The words a constructor lists are its keys, in both shapes that map the enum —
{string: E} above and {E: T}, a table of one thing per value — and the message names
both the values of the enum that are missing and the words that are not values of it. Add
a value to the enum and every such table reports, which is the enum-exhaustive story for
constructors.
An array of the enum ({E}) is left alone: a list of the styles one branch uses or the
behaviours one test walks is a selection, and asking it for every value is noise. So are
an empty constructor (which is how a table that is filled later is written) and one whose
keys are not all literals, since a computed key leaves the word set unknown; a table built
by a call has no constructor to look at, the exemption enum-exhaustive makes as well. A
{string: E} map none of whose keys is a value of the enum is some other map and not this
lookup.
htl fix enum-table fills a {string: E} table in — name = "name" per missing value,
laid out where the entries already there are. For {E: T} it reports and changes nothing:
what an entry maps to is not something a fix can invent.
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).
[]
= "0.6" # the htl command this project expects; a mismatch is refused
[]
= true # for this run, every warn counts as deny (htl check only);
# lints also fail include_tl!, and false makes the macro advisory
[] # allow = not reported, warn = reported, deny = fails the run
= "deny"
= "warn" # allow by default: seen without failing the run
= "allow"
= "allow" # a warning kind of the Teal compiler; quote the `:`
[]
= 3
[]
= ["mods", "~/.cache/tsk/sdk"] # extra dirs require() resolves from while checking
[]
= "bin" # what runs this project's output: hb (the default when absent),
# bin, cdylib (see "Build targets")
[[]] # 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
[toolchain] htl is a cargo requirement ("0.6" = 0.6.x) on the command, which
Cargo.toml does not pin — it pins the crate a Rust host builds against. The command
is what decides whether the project checks: three lints were added on one day and all
three are reported by default, so a project quiet under the release before them says
three new things under the release after — on unchanged sources, and fatally if it runs
--strict. Written down, that arrives as a version the
project moved to rather than as a difference between two machines. A command outside
the requirement is refused before anything is read, naming both versions and this file;
htl installs nothing, so the answer is cargo install htl-cli. Leave the key out and any
command runs the project, as before — which is what htl new currently writes, because a
scaffolded Rust host builds against the released htl crate and that crate rejects a key
newer than itself. Add it by hand to pin a project whose htl already knows 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.
Four kinds arrive there. The ones written by hand; the ones a Rust dependency ships; the ones a Lua dependency published; and the ones for a library that published none of its own. Only the first are anyone's to edit — the rest are copies, and a change to one belongs in the crate or package it came from.
A Rust crate that registers a module in its user's Lua state names the declarations it
ships in its manifest ([package.metadata.htl] dts = ["dts/mq.d.tl"], see "Embedding in
Rust"). htl dts — and check / run / test, which generate before they work —
resolves the crate graph with cargo metadata and writes each of those files to
types/<crate>/<file>, reported like the project's own (wrote types/htl-mq/mq.d.tl) and
committed like them. That directory is on the search path in its own right, so the module
keeps the name it was declared under whatever the crate is called: htl-mq's mq.d.tl is
require("mq"). A crate whose modules have a namespace keeps that too, by naming the
directory its paths start at (dts_root, see "Shipping the declaration to your users"). A
note beside them (.htl-dts) records which crate and version they came from, and is what
tells the directory apart from one laid out by hand, where the path below types/ is the
module name.
Nothing is built to do it, and a project whose dependencies are already resolved and
fetched needs no network. A manifest edited since the last resolve is resolved again, which
writes Cargo.lock and may fetch — what the next cargo build would do anyway, and what
having the dependency's files on disk to copy from requires. Where the graph cannot be
resolved at all, that is reported and the committed copies go on being what the project
checks against. include_tl! never runs cargo at all — it reads types/, as it always
has. A hand-written types/mq.d.tl beside a shipped one is a
duplicate-declaration (the hand-written one is read), which is the message wanted when a
project upgrades a crate that has started shipping its own. Dropping the dependency leaves
the file where it is and says so: what a committed declaration is still for is the
project's to decide, not this command's.
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.
Which file a name resolves to (htl resolve)
That lint fires only when there is more than one declaration, and only at a require the
check happened to reach. The ordinary question — which of these files is in effect, and
what does it hide — is asked far more often, and htl resolve <module> answers it without
anything having to go wrong first:
$ htl resolve mq
htl resolve mq: src/mq.d.tl
order file kind status
1 src/mq.d.tl declaration read
2 types/mq.d.tl declaration shadowed by 1
3 types/htl-mq/mq.d.tl declaration shadowed by 1 (shipped by htl-mq 0.2.0)
searched, in order: ., src, types, types/htl-mq
Three answers in one view: what is read, what it hides, and why — the order is the reason, and printing it is what makes the answer self-explaining. An override on a search path is the mechanism working as intended, so nothing here is a defect and nothing fails.
Every kind the searchers handle is a row: a .tl source, a .d.tl declaration, a plain
.lua, a dependency installed under .htl/modules, a vendored or patched copy, and a
declaration materialised under types/<crate>/ — which names the crate and version its
.htl-dts note records. The rows are in the order the searchers consult, which is by kind
first and position second: a source beats a declaration wherever the two sit, so row 1 is
not necessarily the earliest directory. A .lua under a declaration reads runtime, typed by <n> rather than shadowed: the check reads the declaration and the run loads that
file, and neither hides the other.
A name that resolves to nothing says so and exits non-zero, so a script can ask. --format json carries the same rows ("Machine-readable output"). htl.test is not on a project's
search path — htl test preloads it into the state it runs — so it is not a name to ask
about here.
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.
Both markers are about the fields the record declares. A module that sets a key it does
not declare — written against a newer SDK than this declaration is — is refused by the
checker, and ---@extensible beside
---@contract is what allows it.
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.
A module declaring two contract types is one file and is published once, both markers
named outright.
types/ is searched, so the published declaration puts the claim on the path a second
time. That is one record found twice, not a second claimant of the directory, and only two
different records claiming one directory is reported. Nor do two contract directories
collide over a name: a contract directory is resolved as a directory and is not on the
project's search path, so mods_a/one.tl and mods_b/one.tl are two modules, each held
to the record its own directory is under.
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.
The native modules (std.*)
local json = require -- typed via std/json.d.tl, inside the binary
local str = require
local pretty = require
local rows: = json. -- decode is generic: annotate the result
print
std.* is mlua-batteries — Rust modules
reached from Teal — under the namespace that crate leaves to its host to assemble. htl is
that host: the htl binary carries the crate's default set, json, env, path, time,
string, validate, pretty and argparse, preloads each as std.<name> (and the
namespace table as require("std")) before any command runs a script, and puts the
declarations in front of the checker, so htl check, htl test and include_tl! type
them without the project holding a copy. Each declaration is the crate's own; what a
function does and what it raises is documented there. Every one raises on failure rather
than returning nil, err, so a result-style call is pcall, or a host module under
errors = "return". What pcall receives is a string, one line — json.decode: EOF while parsing an object at line 1 column 1 — the same kind of value a Lua error("...") gives
it: a Rust function's failure would otherwise arrive as a userdata whose tostring carries
a stack traceback: block, and htl re-raises it as the text in front of that block.
The set is the crate's default and not full: a module that reaches the file system,
the network or an async runtime (fs, http, llm, task) is a decision about what a
script may do, and a toolchain does not make it for every project it runs. A Rust host
that wants those depends on the crate itself, under its own prefix, beside std.
What version of the modules a script sees follows where the script runs. Under htl run
/ htl test it is the binary's, pinned like everything else the binary does by
[toolchain] htl in htl.toml. Under a Rust host it is the htl crate's, pinned by the
host's Cargo.toml: the std feature (on by default, off with default-features = false) brings the crate in, and h.install_std()? in the host's preload — which
htl new --target writes — installs it. A host that leaves the call out has its scripts
typed against std.* and failing at the first require, which is the same standing
htl.test has always had in a host.
In a bundle, std.* is a host module: htl build files it with the modules the running
binary provides rather than trying to bundle Rust, and htl run x.hb preloads it before
the bundle starts. A dependency that ships its own declarations for the crate
([package.metadata.htl] dts) is not materialised under types/: the modules are already
on the path under the prefix that answers require.
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).
A test builds values far more often than it asserts them, and what it builds is usually
one valid value with a single thing varied, so a record with a handful of fields and a
dozen tests is a dozen places that spell every field. Adding a field to it then means a
dozen edits, and under ---@struct a dozen reports at once. A factory in a helper module
beside the tests turns those into one place — a table of defaults, and a parameter that
names what this test varies:
local record factory
record Over -- what a test varies, not the record itself
id: string
hp: integer
end
end
function factory.make_def(over: factory.Over): defs.MonsterDef
return {
id = over.id or "rat",
hp = over.hp or 3,
color = "grey",
}
end
factory.make_def{ hp = 1 } then reads as the one thing the test is about, and a field
added to MonsterDef is filled in the factory and nowhere else. The overlay is a record
of its own, listing the fields a test may vary — usually fewer than all of them — and it
has to be: typed as MonsterDef it would make every call a literal built as that record,
reported like any other construction site, which is the factory handing back exactly what
it was written to remove. No lint asks for any of this; it is one way of writing tests
among others, and it is here because the marker is what makes the cost of the other way
arrive all at once.
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.
--junit report.xml writes the run as a JUnit XML report, which is what a CI reads to
show which tests failed rather than a log to scroll: Jenkins' JUnit plugin, GitLab's
report ingestion, and the GitHub Actions reporters all take this file. One <testsuite>
per test file, one <testcase> per test with classname (the file), name (the suite
and test name as the text output composes them) and time in seconds; a failing case
carries a <failure> with the message printed under the file, traceback included. The
totals are the summary line's: the same cases, the same failures. A file that failed to
type-check, or raised outside any test, is a suite whose cases could not run, so it
carries an <error> and no cases — the distinction a report makes between a test that
said no and a file that never got to ask. Nothing is ever <skipped>: --filter
selects before the run, so an excluded test is absent rather than skipped, while a file
with no tests did run and is there as a suite with no cases. The flag composes with
--filter, --seed and --format json, and changes neither the text nor the document.
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. It is
math.random, so the argumentless form returns a float in [0, 1) and the other two an
integer; the declaration types all three integer, since this Teal resolves an overload
by declaration order rather than by arity and cannot type the forms apart. 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] [--junit 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);enum-tablegets the entries a{string: E}lookup is missing (safe — the entry it adds is the identity mapping the table already states for every other value);struct-fieldsgets the fields the site leaves out, one entry each, in the order the record declares them, laid out where the entries already there are (suggest — there is no honest value to put in one. A record field has a type and no zero,hp: integeris not0, andhp = niltype-checks, so a fix that filled the gap would satisfy the lint, pass the checker and ship the wrong value silently. What it writes ishp = htl_fixme("integer"): the declared type to read, in a call to a name the project does not have, so the checker refuses it until a person replaces it);no-globalbecomeslocal(unsafe).htl.toml[fix] unsafe = ["no-global"]promotes a rule,disable = [..]turns its fix off;--rule a,blimits a run. - The names those three take are every rule
htl check --list-lintsnames, plus two that it does not:forward-refandtl:error. A Teal error carries no rule of its own, so those two are the classes an error's fix is filed under —forward-refwhen the message is a record key used before the function that defines it,tl:errorfor any other error. They are names for fixes and nothing else: no check reports under either, so neither has a level, neither is in[lint.rules]or--lint, and-- htl: allow(forward-ref)silences nothing. A name from neither set is refused rather than quietly matching nothing (--rule forwardrefis an error, not a run that fixed nothing).tl:errorwas callederrorbefore it joined the same namespace as Teal's warnings; writing the old name says so. - A rule with no fix is a name these take and nothing more.
--rule require-cycleis a run that fixes nothing, not an error: which rules carry fixes is a fact about the implementations, and[fix] disable = ["contract"]stays valid if one gains a fix later. - 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. Asuggestfix is listed as skipped and its insertion printed under--diff, as a second diff headed<file> (suggested), since nothing ever writes it. - 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, htl test --format json and htl unused --format json print
one JSON document on
stdout and nothing on stderr (the text form is stderr-only, so the two never mix).
htl resolve is a report rather than a run, so both of its forms go to stdout, as
cache status and bundle info do. 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, denied, strict, ok } }.ruleis the lint rule (nil-index,contract, ...), split out of the message.deniedis how many ofwarnings+lintswere said under a rule atdeny— a count of levels, so it overlaps those two rather than adding to them, andokis false whenever it is not zero. 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).unused:{ modules: [{ path, module? }], dependencies: [{ name }], entries: [{ path, module?, kind: "main"|"test"|"contract"|"build"|"host" }], summary: { considered, reached, entries, modules, dependencies, no_entry, check_errors, ok } }. The two kinds carry the names the text form prints them under (module:/dependency:);moduleis the name arequirewould have to spell, absent when the search path gives the file none.check_errorsis what the check behind the graph reported: a file that does not check contributes no edges, so a report from a run with any is a guess.resolve:{ module, read?, candidates: [{ order, path, dir, kind: "source"|"declaration"|"lua", status: "read"|"shadowed"|"runtime", shadowed_by?, origin?: { kind: "crate"|"dependency"|"vendored"|"patched", name, version? } }], searched: [dir], summary: { candidates, shadowed, ok } }.orderis the position in the search order, andstatuswhat became of that candidate:readis the file the checker reads,shadowednames theorderthat is read instead (shadowed_by), andruntimeis the.luaa declaration types — loaded by the run, hidden by nothing.readandokare absent and false when the name resolves to nothing. Paths are relative to the project root when they are inside it.
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, and stripping takes the traceback
with it: every frame of a run-time failure reads
?, with no line.--debugkeeps the line numbers and the local names, and its frames readdepth:8— the module the bundle knows, since a bundle holds modules rather than files.--sourcestores generated Lua instead: larger and readable, bound to no Lua build, and named the same way as--debug. - 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. - A second
install_bundlewrites nothing, because every name is taken. Putting a newer bundle into a state that is already running isHtl::replace_bundle(&b, keep): the names the old bundle installed go frompackage.preloadandpackage.loaded, so the nextrequireof one evaluates the new module, while the host's own modules are untouched — a bundle only takes back what it wrote.keepis the exception, for a module that is the state:replace_bundle(&b, &["world"])leavesworld's evaluated table exactly as it is, mutations included, and the new bundle'sworldis never evaluated. Nothing is run at replace time and the entry is not re-run; what to do with it is the host's. A reference already taken is not reached by any of this: alocal m = require "rules"captured by a closure that is still running keeps the old table until that closure is gone. The returnedReplacedlistsdropped,keptandadded— a module both bundles carry is in the first and the last, which is what happened to it. 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.
Unused (htl unused)
htl check answers whether every file it was given is correct. It never answers whether
every file it was given is reached. htl unused asks the second question of the same
graph a bundle is linked from — a module nobody requires, and a dependency declared and
never used, both keep checking clean forever:
module: src/legacy/parser.tl (legacy.parser)
dependency: strx
htl unused: 1 module, 1 dependency [68 considered, 67 reached, 39 entries]
Nothing new is parsed. htl check already resolves every require — that is what the
require-cycle lint reads and what a cached entry carries — so this walks those edges
from the entries and reports the complement, replaying the check from .htl/ when
nothing moved.
Where it starts is not a guess. The equivalent tools for JavaScript need a plugin
per framework to work out where a project starts; here the project has already said, in
the files htl test, htl build, [[contract]] and a Rust host are pointed at:
src/main.tl(ormain.tlat the root);- every test file, as
htl testdiscovers them — so a module used only by a test is reached, not reported; - every module directly under a
[[contract]]directory: those are loaded by name at run time, from a mods directory the project does not own. Theexcluded ones too —excludesays a module is not held to the contract, not that nothing loads it; - anything named in
[build] extra/[build] host, which is where a dynamicrequire(expr)already has to list its targets forhtl buildto bundle them; - the file a Rust host embeds: the first argument of an
include_bundle!/include_tl!/include_tl_bytes!in the crate around the project. A project whosemainis in Rust has nosrc/main.tl, and its entry is named there and nowhere else.
A project with none of these gets a message saying so rather than a list of everything.
paths narrows what is reported, never what is walked: reachability is a property of
the project, so htl unused src still reads tests/, and a module only a test requires
stays quiet. The dependency question is asked of mlua-pkg.toml [deps]: a name counts
as required when a reached module says require("mathx") or require("mathx.vec"), or
when what a reached module required resolved to a file inside that dependency — both,
because a dependency that is declared but not installed resolves to nothing and is still
required by name.
The exit code is 0 whatever it finds, unless --exit-non-zero-on-unused says otherwise:
"unused" is a question about intent, and CI should opt in to failing on it rather than
out. Deleting is nobody's business here either — htl fix applies mechanical rewrites,
and "this module is unreachable" is not one of those; the fix is a decision.
Exports are not a kind. A third question — which field of a module record no reached
module reads — was considered and left out. htl's premise is a Rust host embedding Teal,
so a module's caller is routinely outside the Teal sources entirely: a #[host_module]
calling into a preloaded module, a ---@contract type published for mod authors, an SDK
a consumer requires. Every one of those reads a field no walk of this project's .tl can
see, and a rule that fires on them is a rule nobody can act on.
Layout of a project (htl new)
<name>/
├── mlua-pkg.toml [package] entry = "src/<mod>" → consumers require("<name>")
├── htl.toml [toolchain] / [lint] / [fmt] / [[contract]], read by CLI and macro
├── src/<mod>/init.tl the module (require("<mod>") from src/ and tests/)
├── types/ .d.tl the project consumes (hand-written, and <crate>/ copied
│ from a dependency) and publishes (a ---@contract type)
├── 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.
mlua-pkg.toml names one dependency from the start: htlx, the collections Lua does not
have (htl-x — htlx.list / tablex / seq /
ordered, pure Teal), pinned at an exact tag, so the README's first step is htl pkg install. htl new --no-x leaves the line out. It is written only under a pin that
resolves a dependency at its entry (main, a checkout, releases from 0.5 on); under
0.4 it would install and then fail every require("htlx.*"), so 0.4 does not get it.
The tag the scaffold pins is a constant in the scaffold, and just e2e scaffolds a
project, installs it and runs a test against it, which is what the constant is allowed to
move on.
Build targets (--target <name>)
A build target is what runs this project's output. That is the axis --target names,
and it is the only thing the entries differ about:
| target | what runs the output | output | Rust in the project |
|---|---|---|---|
hb (the default) — plain htl new, then htl build |
the htl binary, htl run app.hb |
a .hb bundle |
no |
bin |
the OS, as a binary | a binary (library + a six-line main.rs) |
the user's crate |
cdylib |
a C / Python / Unity caller | cdylib + staticlib + a header |
the user's crate |
Host is the other axis: the Rust side that embeds the Lua state, what [build] host and
htl build --host name the modules of. Both words are defined where the target is, in
BuildTarget's doc comment in
crates/htl-core/src/build_target.rs.
--target names an entry in that registry rather than adding a flag per kind: bin and
cdylib are the two the scaffold writes today, and the flag reports the rest as they
arrive. A name that is not registered is refused with the ones that are, before the
directory is created, so a typo leaves nothing behind. htl init --target bin adds the
Rust side to a project that predates it and lists the files it kept rather than skipping
them in silence.
[build] target in htl.toml is the key that records what runs the output, read by every
command that loads the file (absent means hb). htl new --target <name> writes it
whenever the htl the project pins reads it — main, a checkout, a release from 0.5 on —
and not under 0.4, whose include_tl! would refuse the whole file over the unknown key.
htl build is the first command that acts on what the key records. A bundle is the hb
target, so in a bin or a cdylib project the build says which target the project is, who
runs that output and which command builds it — cargo build — and writes nothing. The
record is a decision the project made rather than a note about itself; dropping
[build] target from htl.toml is how a project with Rust in it asks for a bundle anyway.
Which htl the project depends on (--htl <req | main | path:<checkout>>)
A target that writes Rust writes a Cargo.toml, and --htl is what that manifest pins.
Without it a project pins the newest release the scaffold supports — 0.5 today — which is
data in the scaffold rather than the version of the htl you happen to have installed:
the two move at different moments, and this one moves when a release that understands
everything the scaffold writes is on crates.io. A release the scaffold has not been asked
about is refused, with the ones it has, before the directory is created.
--htl main pins the repository's main branch and --htl path:<checkout> a clone of it
(the checkout's root — the dependency written is the crates/htl inside it). Both exist
for dogfooding an unreleased htl, and they replace adding a [patch.crates-io] section by
hand afterwards; the C ABI target's features = ["ffi"] travels onto whichever form the
pin takes.
A scaffold writes only what the htl it pins can read. htl.toml is parsed inside
include_tl! by the pinned crate, and unknown keys there are an error rather than
something ignored — so a key this repository has added and no release carries yet is not
written into a project pinned at a release that lacks it; under main or a checkout it
can be. just e2e-scaffold-unpatched is that rule as a gate: it scaffolds under each
release pin and runs each project's tests against crates.io with nothing patched.
The bin target (--target bin)
--target bin, and --embed which is its shorthand, add a Cargo package to that tree:
├── Cargo.toml htl + anyhow, and [profile.dev.build-override] opt-level = 3
├── src/lib.rs #[host_module] Host, its records, the embedded module,
│ and pub fn preload(&Htl) registering both
├── src/host.d.tl generated from src/lib.rs — by cargo build, and by
│ htl dts / htl check without building
└── src/main.rs the binary: preload, then src/main.tl (omitted with --lib)
The host is a library with a thin binary on top, not a binary that happens to hold a
host. What a project grows — a second #[host_module], an extern "C" layer, a window
loop, a Rust test — grows in src/lib.rs, and every entry point reaches it through
preload: src/main.rs is the six lines that call preload and exec the script, and
another crate that embeds this one calls the same function. --lib means the project has
no entry script, so there is nothing for the binary to run and it is not written at all —
what is left is the library, which is the part someone else embeds.
The cdylib target (--target cdylib)
htl new --lib --target cdylib <name> is the same library with the C ABI on top, for a
caller that is not written in Rust:
├── Cargo.toml crate-type = ["rlib", "cdylib", "staticlib"], htl with
│ features = ["ffi"], serde
├── src/lib.rs the #[host_module] the scripts call, and a #[c_export] Game
│ the caller holds: open / a text call / JSON / a status / close
├── include/<mod>.h written by #[c_export] at cargo build (and by htl dts), committed
├── examples/c/ main.c + a Makefile: every returned char * goes back to _free
└── examples/python/ run.py: restype = c_void_p and ctypes.cast, never c_char_p
The two callers are the point. Each language has one way of reading a char * that looks
natural and is wrong — C never makes you think about the pointer at all, and Python's
restype = c_char_p copies the string and drops the pointer, leaking it every call — so
the scaffold ships a caller in each that does it correctly, and the CI job runs both
against the library it just built.
--target cdylib requires --lib and is refused without it: a cdylib has no entry
point of its own, so there is no src/main.tl for a binary to run. What the callers do
have is a Lua error() and a Rust Err to handle — the generated greet refuses an empty
name in Teal and reset refuses a no-op in Rust — so both error paths are in front of the
reader rather than described.
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).
Running against an unpublished htl
A consumer that needs a change before it is on crates.io points at a checkout, in its own
Cargo.toml:
[]
= { = "/path/to/htl/crates/htl" }
= { = "/path/to/htl/crates/htl-core" }
= { = "/path/to/htl/crates/htl-macros" }
All three, not one. htl re-exports htl-core, and the proc macros in htl-macros run
htl-core at expansion time, so patching only htl builds two versions of the same code
into one graph.
The patch is ignored until the lockfile is updated. Cargo.lock keeps the version it
already resolved, and cargo says so rather than switching:
warning: patch `htl v0.4.0 (...)` was not used in the crate graph
Run cargo update -p htl -p htl-core -p htl-macros once and the lock points at the local
paths. To go back once the version is published, delete the [patch.crates-io] block and
run the same cargo update again.
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.