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 struct mirrored as a Teal `record` (plain table with named fields).
8pub trait TealRecord {
9    /// Teal record name (also the module name of its `.d.tl`).
10    const NAME: &'static str;
11    /// Full `.d.tl` text: `local record NAME ... end  return NAME`.
12    const DECL: &'static str;
13}
14
15/// Why a value coming back from Lua did not fit a `#[derive(TealRecord)]` struct.
16///
17/// `FromLua` is the only place the two sides of a record are compared — the `.d.tl` is
18/// generated from Rust, so what a host *offers* is checked at build time, while what it
19/// *receives* is checked when a value crosses. This carries enough to act on: which
20/// record, which field, the Teal type declared for it, and what arrived instead.
21///
22/// ```text
23/// Outcome.cause: expected string, got nil
24/// Outcome.depth: expected integer, got string
25/// ```
26///
27/// A record inside a record extends the path rather than nesting the message, so the
28/// innermost field is what the reader sees: `Recording.outcome.cause`.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct FieldError {
31    /// `Record.field`, one segment per level of nesting.
32    pub path: String,
33    /// The Teal type the record declares for the field.
34    pub expected: String,
35    /// The Lua type that arrived: `nil`, `string`, `table`, ...
36    pub got: String,
37}
38
39impl std::fmt::Display for FieldError {
40    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41        write!(
42            f,
43            "{}: expected {}, got {}",
44            self.path, self.expected, self.got
45        )
46    }
47}
48
49impl std::error::Error for FieldError {}
50
51/// Name the record and the field a conversion failed on.
52///
53/// Called by the `FromLua` that `#[derive(TealRecord)]` generates, once per field that
54/// fails. `cause` is what the field's own conversion returned, and is only replaced when
55/// it says nothing the caller does not already know — a plain type mismatch. Anything
56/// else (a host function's own error, a borrow failure) is kept and given the field as
57/// context, because it carries more than this can reconstruct.
58pub fn field_error(
59    record: &str,
60    field: &str,
61    expected: &str,
62    got: &str,
63    cause: mlua::Error,
64) -> mlua::Error {
65    if let Some(inner) = cause.downcast_ref::<FieldError>() {
66        // A record inside a record. The head of the inner path is this field's own
67        // record name, which the outer path is about to state; drop it and keep going.
68        let rest = inner
69            .path
70            .split_once('.')
71            .map(|(_, r)| r)
72            .unwrap_or(&inner.path);
73        return mlua::Error::external(FieldError {
74            path: format!("{record}.{field}.{rest}"),
75            expected: inner.expected.clone(),
76            got: inner.got.clone(),
77        });
78    }
79    if !matches!(cause, mlua::Error::FromLuaConversionError { .. }) {
80        return mlua::ErrorContext::context(cause, format!("{record}.{field}"));
81    }
82    mlua::Error::external(FieldError {
83        path: format!("{record}.{field}"),
84        expected: expected.to_string(),
85        got: got.to_string(),
86    })
87}
88
89/// A Rust type exposed to Teal as a userdata module via `#[host_module]`.
90pub trait HostModule {
91    /// Module name used in `require("...")`.
92    const MODULE: &'static str;
93    /// Full `.d.tl` text for the module.
94    const DECL: &'static str;
95}