[go: up one dir, main page]

Skip to main content

Module rsgen

Module rsgen 

Source
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] modules is written in does not change the output.
  • Usable both ways: there is no inner attribute (#![..]), so the file works under mod teal_types; and under include!; each top-level pub mod carries 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 called Option or String cannot hide it. A type of another generated module is a super:: 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.tl only 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.tl for 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.

TealRust
integer number string booleani64 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 | nilthe 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 modulethat type, as a path from where it is named
an alias (type A = B)what A names, read by this table
enum Ea unit-only enum, one variant per member, with #[teal(name = "<member>")] on each, deriving Hash and Ord
a field of a recordsee 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 tableno 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 variableno Rust type
a union other than T | nilno 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 moduleRust’s HashMap key must be Eq + Hash
a field whose type is an interfacean interface is never lowered
the record a module returnsa module is not a type a value has
a type of an own module that is not in [rust] modulesnamed 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 Ithe fields of R are partly written in I; not lowered
a record with a where clauseit is a member of a tagged union, which is not lowered
a record marked ---@extensiblea 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 waythe 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 boolit 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 characterthe member goes into #[teal(name = "..")]
two members of one enum that make one variant name; two declarations of one module that make one Rust namerename 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 indexthe 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 typesee below
the client of an exportsee 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 resultsthey 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 foras 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 recordsa 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 namethe client writes those two itself
a function named clone or clone_fromthe 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 itthere 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 rs for: [rust] of htl.toml without 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 rs writes. 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.