Expand description
The Rust types of the records and enums a project’s modules declare, lowered from what the
checker knows of the files (htl rs, htl::teal_types!()).
The types that cross between Teal and a Rust host are written in one place only when that
place is Rust: #[derive(TealRecord)] writes the .d.tl from a struct. A project that
designs its data in Teal first (the records an event loop passes around, a config a script
returns) would copy each record into a struct by hand, with nothing to say when the copy
has fallen behind. This module is the other direction, the way shapes
is for lshape: the record is the source, and the Rust type is generated from it, into
one file the project commits (htl rs) or into the crate at cargo build
(htl::teal_types!()). Nothing here reads a file or runs a process; the caller (the
htl rs subcommand or htl-macros) hands over what a check produced (Source) and
writes, compares, or parses what comes back (Rendered).
§What is written
One file. The modules named by [rust] modules (Spec::modules), and the clients of
[rust] exports (Exports, below), are merged into one
tree by their path segments (geom.a and geom.b both sit in one pub mod geom), and
each record becomes a struct and each enum a unit-only enum, deriving
::htl::TealRecord so that the host converts it to and from a Lua table:
// Generated by htl rs. Do not edit: rerun `htl rs`.
// Sources: src/ev/init.tl
#[allow(non_snake_case, non_camel_case_types, dead_code, clippy::all)]
pub mod ev {
#[derive(Debug, Clone, PartialEq, ::htl::TealRecord)]
#[teal(name = "Event")]
pub struct Event {
pub kind: ::core::option::Option<Kind>,
pub at: ::core::option::Option<i64>,
pub note: ::core::option::Option<::std::string::String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, ::htl::TealRecord)]
#[teal(name = "Kind")]
pub enum Kind {
#[teal(name = "close")]
Close,
#[teal(name = "open")]
Open,
}
}- Deterministic, and a function of the Teal alone: no timestamp, no machine path, only
the project-relative source paths of the second line. A child module sits after its
parent’s types and the children are in bytewise order of their segment; a module’s types
are in the order they are declared (a nested type after the one it is nested in); an
enum’s variants are in the order of their members, sorted bytewise. The order
[rust] modulesis written in does not change the output. - Usable both ways: there is no inner attribute (
#![..]), so the file works undermod teal_types;and underinclude!; each top-levelpub modcarries its own outer#[allow(..)]. - Immune to shadowing: every std type is written as a full path (
::core::option::Option,::std::string::String,::std::vec::Vec,::std::collections::HashMap,::htl::mlua::Value), so a Teal record calledOptionorStringcannot hide it. A type of another generated module is asuper::path from the module that names it (super::geom::types::Point); one of the same module is its bare name. - Not a declaration: the derive writes a
.d.tlonly under#[teal(dts = "..")]or a#[host_module(records = [..])], and neither is written here. The Teal types keep being declared in Teal; a host that wants them in a.d.tlfor its module says so itself.
The first line starts with HEADER: a caller that is about to overwrite a file checks
it, so that a hand-written file of the same name is never touched.
§What is lowered
In position order, every record (top level or nested) except the one the file returns, and
every enum, of each listed module (shapes’s candidates are the same
declarations). The record a file returns (return M) is the module itself, not a type a
value can have; its nested records are the module’s types. A declaration is named by its
path with the returned record’s name dropped: M.Event is Event, M.Outer.Inner is
Outer_Inner, and a top-level Event is Event; #[teal(name = "..")] carries that name
(without a r#), so the Teal side keeps the name it has. A field is named as Teal names it,
with r# in front of a Rust keyword (match is r#match; the derive reads unraw; gen,
which edition 2024 reserves, is written raw too, so the file builds in every edition); a
variant is the member in PascalCase (in_review is InReview).
§The mapping
The table is closed: what it does not list is a problem (below), and nothing is written
for a problem, because a placeholder (Value where a type could not be lowered) would be a
crossing that silently accepts anything.
| Teal | Rust |
|---|---|
integer number string boolean | i64 f64 ::std::string::String bool |
any | ::htl::mlua::Value |
{T} | ::std::vec::Vec<T> |
{K : V}, K a string, an integer, a boolean or an enum of a listed module | ::std::collections::HashMap<K, V> |
T | nil | the field is nilable (one Option<T>, never Option<Option<T>>); as a map’s value, the value is Option<T> |
| a record or enum of a listed module | that type, as a path from where it is named |
an alias (type A = B) | what A names, read by this table |
enum E | a unit-only enum, one variant per member, with #[teal(name = "<member>")] on each, deriving Hash and Ord |
| a field of a record | see Nilability |
a field named by [rust.types] | the type written there, as it is written |
§Nilability
A field is Option<T> unless the record is marked ---@struct and the field is neither
marked ---@optional nor typed T | nil: then it is T. A record that is not marked is
all Option, because a Teal record field is nilable and nothing says otherwise.
The consequence of a T is run-time: on a ---@struct record a required field is a
field the Rust struct insists on. A table that reaches the host without it (the Teal side
built it with as, which checks nothing, or struct-fields was a warning nobody acted
on) fails FromLua with the field named, Event.at: expected integer, got nil. That is
the point of marking a record ---@struct, and it is the reason an unmarked record
lowers to Option fields, which read whatever arrives.
§Problems
The rule is everything the mapping does not list. Each is reported at file:line:col
of the field (or the declaration), and a run with any problem writes nothing
(Rendered::Refused), the way contract::publish skips a
declaration it cannot produce.
| problem | |
|---|---|
a function or method field; a tuple {A, B}; thread; userdata; a type that is only nil; an empty table | no Rust type |
a sequence of a nilable type ({T | nil}) | a nil inside a Lua sequence is a hole; #[derive(TealRecord)] refuses Vec<Option<T>> |
a generic (Box<integer>), a type variable | no Rust type |
a union other than T | nil | no Rust type; a union of records needs a tag, which is not lowered |
a map keyed by number, any, a record, or anything but a string, an integer, a boolean or an enum of a listed module | Rust’s HashMap key must be Eq + Hash |
| a field whose type is an interface | an interface is never lowered |
| the record a module returns | a module is not a type a value has |
a type of an own module that is not in [rust] modules | named with its module: list it, or give the field a type under [rust.types] |
| a type declared outside the project’s sources (a dependency’s record, a type htl’s library declares) | it has no Rust type here |
record R is I | the fields of R are partly written in I; not lowered |
a record with a where clause | it is a member of a tagged union, which is not lowered |
a record marked ---@extensible | a Rust struct drops the keys it does not know on the way back |
a record that contains itself, or a cycle of records, with no {T} or {K : V} on the way | the struct would have infinite size |
a type, field or module segment that cannot be a Rust name: not an identifier, or self Self super crate _ (a module segment may not be a keyword either) | name it otherwise |
a type named i64, f64 or bool | it would hide the primitive the mapping writes |
an enum member that cannot be PascalCased into an identifier, or that holds a ", a \ or a control character | the member goes into #[teal(name = "..")] |
| two members of one enum that make one variant name; two declarations of one module that make one Rust name | rename one |
a type with the name of a child module (geom.types is a module and geom has a type types) | one namespace in Rust |
[rust] modules names a module that is no own source, or one whose check kept no index | the name is wrong or the file did not parse |
a [rust.types] key that matches no field, one that matches two, a value that is not a Rust type | see below |
| the client of an export | see Exports |
A project whose check fails on any own file (a listed module or not) is stopped before
this runs, by the subcommand: an unlisted module with a type error stops htl rs too,
because a record that does not check is not a record a Rust type can be generated from,
and the declarations of the unlisted module are where a listed one’s types would resolve.
§[rust]
[rust]
modules = ["ev", "geom.types"] # the module names `require` takes
exports = ["app"] # the modules a client is written for (Exports)
out = "src/teal_types.rs" # the command's: where the file is written
[rust.types]
"ev.Event.at" = "u32"The lowering takes Spec, which is modules, exports and types (Spec::from a
RustConfig, the one construction htl rs and
htl::teal_types!() share); out is where the caller writes. Empty modules and
exports are Rendered::Nothing: the opt-in is per module, because a module of shared
types is what a Rust host imports.
A key of types is <module>.<type>.<field>, where <type> is the declaration’s path as
its name is made (Event, Outer.Inner), dotted. The key is read against every listed
module that is a prefix of it, with the rest being the <type>.<field> of a field of that
module. One module whose field it names is the key’s resolution: "ev.Event.at" is the
field at of M.Event in ev. No module, and the key matches no field; two (a module
geom with a type types.X and a module geom.types with a type X both have
geom.types.X.f), and the key is ambiguous. Both are problems, and so is a value that is
not a Rust type (syn::parse_str::<syn::Type>).
The value is the author’s statement. The field gets that text, not wrapped in an Option
(whether nil is allowed is the author’s to write), and nothing checks that the type
crosses as the Teal type does, which is where #[teal(as = "..")] stands on the Rust
side. A field with an entry is not lowered, so its Teal type is free to be one the mapping
has no row for.
§Exports
The types above carry data; a project that keeps its logic in Teal also has Rust call
it. [rust] exports names the modules whose functions are called from Rust, and each gets
a client in the module’s own pub mod (after its types, when it is listed in modules
too): a struct over an htl::run::Instance, with one async method per function the
record’s body declares (check: function(string): types.Output). The struct is named
for the record the module returns, in PascalCase: local record app is App, and
local record M is M, so the clients of the M-style modules of a project are all M
and are told apart by their module path. The generated code names htl::run, so the host
builds htl with its async feature; without it rustc says run is not in htl.
pub mod app {
/// The functions of the Teal module `app`, called on a runner instance; ..
#[derive(Debug, Clone)]
pub struct App {
inst: ::htl::run::Instance,
}
impl App {
/// `require("app")` in a fresh instance of `runner`.
pub async fn load(runner: &::htl::run::Runner, extra_paths: &[::std::path::PathBuf])
-> ::core::result::Result<Self, ::htl::run::CallError> { .. }
/// The instance the functions are called on.
pub fn instance(&self) -> &::htl::run::Instance { .. }
/// `app.check`, typed `function(string): types.Output` in Teal.
pub async fn check(&self, a1: ::std::string::String)
-> ::core::result::Result<super::types::Output, ::htl::run::CallError> {
self.inst.call("check", (a1,)).await
}
}
}A method is the function’s field name (r# before a keyword), called with .; its
arguments are a1, a2, … (the index keeps no parameter names, #557). Each argument
and result is typed by the mapping above, with the nilability of a T | nil and nothing
else: a parameter is T unless its Teal type is T | nil, then Option<T>. An optional
parameter (title?: string) is T too: the index says how many arguments a call has to
pass (FnSig::required) and not which ones, so the
host always passes a value. No result is (), one is its type, several are a tuple. The
methods are in position order.
A function the client cannot call is a problem, at the line of the function:
| problem | |
|---|---|
a method (function(self: app), called with :) | a client calls with . |
a generic function (function<T>) | a type variable has no Rust type |
a variadic argument or result (...: T, : T...) | no Rust type |
| more than 16 arguments, or more than 16 results | they cross as one tuple each way, and mlua converts at most 16 |
| an overload (two function types for one field) | no one Rust signature |
| an argument or result the mapping has no row for | as for a field |
any, also inside a {T}, a {K : V} or a T | nil; a record that holds any in a field, directly or through other records | a Lua value does not leave the VM thread htl::run keeps it on |
a function named load or instance, or one whose name is no Rust name | the client writes those two itself |
a function named clone or clone_from | the client derives Clone, and an inherent method of that name would win over the trait’s in app.clone() |
| a module that returns no record; a record whose PascalCase name is no Rust name or is the name of a type or module beside it | there is no client to name |
A field a [rust.types] entry types is the author’s declaration, not read for any: a
mlua::Value the entry names is stopped by rustc and not by this module.
A note, which does not fail the run, is said for a record that declares no function, and
for each function the module defines outside the record’s body (function app.helper()
with no helper: function(..) in the body): the type index keeps no type for it, so it is
not in the client. Declaring helper: function(..) in the body adds it. A field that is
not a function is left out without a word.
§Drift
The file is committed, and the Teal moves. htl rs --check runs the same function with
“compare” where htl rs has “write”: it exits non-zero, saying the file is stale, when the
text differs, the file is missing, or modules is empty and a file htl rs wrote is still
there. It is a command and not a lint of htl check because a run replayed from the run
cache keeps no type index, so a lint that compared text would re-check every file on a fully
cached run (the cost the lshape-version lint avoids by reading files).
Structs§
- Spec
- What a project asks
htl rsfor:[rust]ofhtl.tomlwithout the key the command alone reads (out).
Enums§
- Rendered
- One of three outcomes, so “written with problems” is not a state.
Constants§
- HEADER
- What starts the first line of the file
htl rswrites. A caller that is about to overwrite a file checks that its first line starts with this, and leaves it alone otherwise.
Functions§
- generate
- Lower the records and enums of the listed modules of
sources, and the clients of the exported ones.