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}