Skip to main content

htl_core/
teal.rs

1//! Rust <-> Teal type bridge used by `#[derive(TealRecord)]` and `#[host_module]`.
2//!
3//! The macros map Rust types to Teal type names *syntactically* at expansion time
4//! (`f64 -> number`, `String -> string`, `Vec<T> -> {T}`, ...). This module holds the
5//! runtime-side traits the generated code implements.
6
7/// A Rust type mirrored as a Teal declaration: a struct as a `record` (plain table with
8/// named fields), a newtype as a `type` alias, an enum as an `enum` of its variant names
9/// or, when a variant carries data, as a union of `where`-discriminated records. The
10/// lowering is in `htl_core::dts`.
11pub trait TealRecord {
12    /// Teal type name (also the module name of its `.d.tl`).
13    const NAME: &'static str;
14    /// Full `.d.tl` text: `local record NAME ... end  return NAME`, or the `enum` /
15    /// `type` form.
16    const DECL: &'static str;
17}
18
19/// Why a value coming back from Lua did not fit a `#[derive(TealRecord)]` struct.
20///
21/// `FromLua` is the only place the two sides of a record are compared — the `.d.tl` is
22/// generated from Rust, so what a host *offers* is checked at build time, while what it
23/// *receives* is checked when a value crosses. This carries enough to act on: which
24/// record, which field, the Teal type declared for it, and what arrived instead.
25///
26/// ```text
27/// Outcome.cause: expected string, got nil
28/// Outcome.depth: expected integer, got string
29/// ```
30///
31/// A record inside a record extends the path rather than nesting the message, so the
32/// innermost field is what the reader sees: `Recording.outcome.cause`.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct FieldError {
35    /// `Record.field`, one segment per level of nesting.
36    pub path: String,
37    /// The Teal type the record declares for the field.
38    pub expected: String,
39    /// The Lua type that arrived: `nil`, `string`, `table`, ...
40    pub got: String,
41}
42
43impl std::fmt::Display for FieldError {
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        write!(
46            f,
47            "{}: expected {}, got {}",
48            self.path, self.expected, self.got
49        )
50    }
51}
52
53impl std::error::Error for FieldError {}
54
55/// Name the record and the field a conversion failed on.
56///
57/// Called by the `FromLua` that `#[derive(TealRecord)]` generates, once per field that
58/// fails. `cause` is what the field's own conversion returned, and is only replaced when
59/// it says nothing the caller does not already know — a plain type mismatch. Anything
60/// else (a host function's own error, a borrow failure) is kept and given the field as
61/// context, because it carries more than this can reconstruct.
62pub fn field_error(
63    record: &str,
64    field: &str,
65    expected: &str,
66    got: &str,
67    cause: mlua::Error,
68) -> mlua::Error {
69    located(format!("{record}.{field}"), expected, got, cause)
70}
71
72/// Name the type a whole-value conversion failed on: a newtype (`type N = T` on the
73/// Teal side) whose inner conversion refused what arrived. The alias is what the Teal
74/// side declared, so it is the name the reader looks for: a `FieldError` from inside
75/// (the inner type is a record, an enum, or another alias) is re-rooted at the alias —
76/// `Label.y: expected number, got nil`, `Wrap: expected one of ..` — and a plain
77/// mismatch becomes `N: expected T, got <type>`.
78pub fn value_error(name: &str, expected: &str, got: &str, cause: mlua::Error) -> mlua::Error {
79    located(name.to_string(), expected, got, cause)
80}
81
82/// The one policy behind `field_error` and `value_error`, given the location the caller
83/// stands at (`Record.field`, `Enum.Variant.field`, or a bare type name):
84///
85/// - a `FieldError` from inside is re-rooted here: the head of its path is the inner
86///   type's own name, which this location already states, so it is dropped and the rest
87///   (if any — an enum or alias reports with a bare name) appended;
88/// - a plain type mismatch becomes a `FieldError` at this location;
89/// - anything else (a host function's own error, a borrow failure) keeps its message
90///   with the location as context, because it carries more than this can reconstruct.
91fn located(path: String, expected: &str, got: &str, cause: mlua::Error) -> mlua::Error {
92    if let Some(inner) = cause.downcast_ref::<FieldError>() {
93        let path = match inner.path.split_once('.') {
94            Some((_, rest)) => format!("{path}.{rest}"),
95            None => path,
96        };
97        return mlua::Error::external(FieldError {
98            path,
99            expected: inner.expected.clone(),
100            got: inner.got.clone(),
101        });
102    }
103    if !matches!(cause, mlua::Error::FromLuaConversionError { .. }) {
104        return mlua::ErrorContext::context(cause, path);
105    }
106    mlua::Error::external(FieldError {
107        path,
108        expected: expected.to_string(),
109        got: got.to_string(),
110    })
111}
112
113/// Name the enum a value did not belong to, listing what it accepts:
114/// `Mode: expected one of "Fast", "Careful", got "fst"`. `got` is the offending string
115/// in quotes, or the Lua type name when it was not a string (or, for a data-carrying
116/// enum, not a table). Reported as a `FieldError` so an enum inside a record extends the
117/// record's path the way a nested record does.
118pub fn enum_error(name: &str, variants: &[&str], got: &str) -> mlua::Error {
119    let list: Vec<String> = variants.iter().map(|v| format!("\"{v}\"")).collect();
120    mlua::Error::external(FieldError {
121        path: name.to_string(),
122        expected: format!("one of {}", list.join(", ")),
123        got: got.to_string(),
124    })
125}
126
127/// A Rust type exposed to Teal as a userdata module via `#[host_module]`.
128pub trait HostModule {
129    /// Module name used in `require("...")`.
130    const MODULE: &'static str;
131    /// Full `.d.tl` text for the module.
132    const DECL: &'static str;
133}