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}