use std::path::{Path, PathBuf};
use syn::punctuated::Punctuated;
use syn::{
Attribute, Expr, FnArg, GenericArgument, ImplItem, Item, ItemEnum, ItemImpl, ItemStruct, Lit,
Meta, Pat, PathArguments, ReturnType, Token, Type,
};
pub fn teal_type(ty: &Type, self_name: &str) -> Result<String, String> {
match ty {
Type::Reference(r) => teal_type(&r.elem, self_name),
Type::Paren(p) => teal_type(&p.elem, self_name),
Type::Tuple(t) if t.elems.is_empty() => Ok(String::new()),
Type::Tuple(t) => {
let parts: Result<Vec<_>, _> =
t.elems.iter().map(|e| teal_type(e, self_name)).collect();
Ok(parts?.join(", "))
}
Type::Slice(s) => Ok(format!("{{{}}}", teal_type(&s.elem, self_name)?)),
Type::Array(a) => Ok(format!("{{{}}}", teal_type(&a.elem, self_name)?)),
Type::Path(p) => {
let seg = p.path.segments.last().ok_or("empty type path")?;
let ident = seg.ident.to_string();
let args: Vec<&Type> = match &seg.arguments {
PathArguments::AngleBracketed(ab) => ab
.args
.iter()
.filter_map(|a| match a {
GenericArgument::Type(t) => Some(t),
_ => None,
})
.collect(),
_ => Vec::new(),
};
let arg = |i: usize| -> Result<String, String> {
args.get(i)
.ok_or_else(|| format!("{ident}: missing type argument {i}"))
.and_then(|t| teal_type(t, self_name))
};
Ok(match ident.as_str() {
"f32" | "f64" => "number".into(),
"i8" | "i16" | "i32" | "i64" | "i128" | "isize" | "u8" | "u16" | "u32" | "u64"
| "u128" | "usize" => "integer".into(),
"bool" => "boolean".into(),
"String" | "str" => "string".into(),
"Self" => self_name.into(),
"Vec" | "VecDeque" | "HashSet" | "BTreeSet" => format!("{{{}}}", arg(0)?),
"HashMap" | "BTreeMap" => format!("{{{}:{}}}", arg(0)?, arg(1)?),
"Option" | "Result" | "Box" | "Rc" | "Arc" => arg(0)?,
"Strict" => arg(0)?,
"UserDataRef" | "UserDataRefMut" | "UserDataOwned" => arg(0)?,
"Value" => "any".into(),
"Table" => "{any:any}".into(),
"Function" => "function".into(),
"LuaString" => "string".into(),
other => other.to_string(),
})
}
_ => Err(
"unsupported type for Teal mapping (use a path, reference, tuple, slice or array type)"
.into(),
),
}
}
pub fn is_option(ty: &Type) -> bool {
match ty {
Type::Reference(r) => is_option(&r.elem),
Type::Paren(p) => is_option(&p.elem),
Type::Path(p) => p
.path
.segments
.last()
.map(|s| s.ident == "Option")
.unwrap_or(false),
_ => false,
}
}
pub fn is_result(ty: &Type) -> bool {
match ty {
Type::Path(p) => p
.path
.segments
.last()
.map(|s| s.ident == "Result")
.unwrap_or(false),
_ => false,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RenameRule {
Lower,
Upper,
Pascal,
Camel,
Snake,
ScreamingSnake,
Kebab,
ScreamingKebab,
}
impl RenameRule {
pub const NAMES: &'static [&'static str] = &[
"lowercase",
"UPPERCASE",
"PascalCase",
"camelCase",
"snake_case",
"SCREAMING_SNAKE_CASE",
"kebab-case",
"SCREAMING-KEBAB-CASE",
];
pub fn parse(s: &str) -> Result<Self, String> {
Ok(match s {
"lowercase" => Self::Lower,
"UPPERCASE" => Self::Upper,
"PascalCase" => Self::Pascal,
"camelCase" => Self::Camel,
"snake_case" => Self::Snake,
"SCREAMING_SNAKE_CASE" => Self::ScreamingSnake,
"kebab-case" => Self::Kebab,
"SCREAMING-KEBAB-CASE" => Self::ScreamingKebab,
other => {
return Err(format!(
"`rename_all` must be one of {}, got {other:?}",
Self::NAMES
.iter()
.map(|n| format!("{n:?}"))
.collect::<Vec<_>>()
.join(", ")
));
}
})
}
pub fn apply(self, variant: &str) -> String {
let snake = || {
let mut s = String::new();
for (i, ch) in variant.char_indices() {
if i > 0 && ch.is_uppercase() {
s.push('_');
}
s.push(ch.to_ascii_lowercase());
}
s
};
match self {
Self::Lower => variant.to_ascii_lowercase(),
Self::Upper => variant.to_ascii_uppercase(),
Self::Pascal => variant.to_string(),
Self::Camel => {
let mut c = variant.chars();
match c.next() {
Some(first) => first.to_ascii_lowercase().to_string() + c.as_str(),
None => String::new(),
}
}
Self::Snake => snake(),
Self::ScreamingSnake => snake().to_ascii_uppercase(),
Self::Kebab => snake().replace('_', "-"),
Self::ScreamingKebab => snake().to_ascii_uppercase().replace('_', "-"),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct TealAttrs {
pub name: Option<String>,
pub rename_all: Option<RenameRule>,
pub dts: Option<String>,
pub uses: Vec<String>,
pub records: Vec<String>,
pub errors: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ErrMode {
Raise,
Return,
}
fn lit_str(l: &Lit) -> Result<String, String> {
match l {
Lit::Str(s) => Ok(s.value()),
_ => Err("expected a string literal".into()),
}
}
fn type_list(arr: &syn::ExprArray, key: &str) -> Result<Vec<String>, String> {
let mut out = Vec::new();
for e in &arr.elems {
match e {
Expr::Path(p) => out.push(
p.path
.segments
.last()
.map(|s| s.ident.to_string())
.unwrap_or_default(),
),
_ => return Err(format!("`{key}` expects a list of type names")),
}
}
Ok(out)
}
pub fn parse_attr_metas(metas: impl IntoIterator<Item = Meta>) -> Result<TealAttrs, String> {
let mut out = TealAttrs::default();
for meta in metas {
let Meta::NameValue(nv) = meta else {
return Err("expected `key = value` pairs".into());
};
let key = nv
.path
.get_ident()
.map(|i| i.to_string())
.unwrap_or_default();
match (key.as_str(), &nv.value) {
("name", Expr::Lit(l)) => out.name = Some(lit_str(&l.lit)?),
("rename_all", Expr::Lit(l)) => {
out.rename_all = Some(RenameRule::parse(&lit_str(&l.lit)?)?)
}
("dts", Expr::Lit(l)) => out.dts = Some(lit_str(&l.lit)?),
("uses", Expr::Array(arr)) => out.uses = type_list(arr, "uses")?,
("records", Expr::Array(arr)) => out.records = type_list(arr, "records")?,
("errors", Expr::Lit(l)) => {
let v = lit_str(&l.lit)?;
if v != "raise" && v != "return" {
return Err(format!(
"`errors` must be \"raise\" or \"return\", got {v:?}"
));
}
out.errors = Some(v);
}
(k, _) => return Err(format!("unknown or malformed attribute `{k}`")),
}
}
Ok(out)
}
fn parse_named_attr(attrs: &[Attribute], name: &str) -> Result<Option<TealAttrs>, String> {
let mut metas = Vec::new();
let mut found = false;
for a in attrs {
if a.path().is_ident(name) {
found = true;
if let Meta::List(_) = &a.meta {
let list = a
.parse_args_with(Punctuated::<Meta, Token![,]>::parse_terminated)
.map_err(|e| e.to_string())?;
metas.extend(list);
}
}
}
if !found {
return Ok(None);
}
parse_attr_metas(metas).map(Some)
}
pub fn parse_teal_attrs(attrs: &[Attribute]) -> Result<TealAttrs, String> {
Ok(parse_named_attr(attrs, "teal")?.unwrap_or_default())
}
pub fn parse_host_module_attr(attrs: &[Attribute]) -> Result<Option<TealAttrs>, String> {
parse_named_attr(attrs, "host_module")
}
pub fn derives_teal_record(attrs: &[Attribute]) -> bool {
attrs.iter().any(|a| {
if !a.path().is_ident("derive") {
return false;
}
a.parse_args_with(Punctuated::<syn::Path, Token![,]>::parse_terminated)
.map(|paths| {
paths.iter().any(|p| {
p.segments
.last()
.map(|s| s.ident == "TealRecord")
.unwrap_or(false)
})
})
.unwrap_or(false)
})
}
fn uses_header(uses: &[String]) -> String {
let mut s = String::new();
for u in uses {
s.push_str(&format!("local type {u} = require(\"{u}\")\n"));
}
if !uses.is_empty() {
s.push('\n');
}
s
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RecordKind {
Record {
fields: Vec<(String, String)>,
},
Alias {
inner: String,
},
Enum {
variants: Vec<String>,
},
Union {
variants: Vec<UnionVariant>,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnionVariant {
pub name: String,
pub word: String,
pub shape: VariantShape,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum VariantShape {
Unit,
Newtype(String),
Struct(Vec<(String, String)>),
}
#[derive(Debug, Clone)]
pub struct RecordDecl {
pub name: String,
pub kind: RecordKind,
pub decl: String,
pub attrs: TealAttrs,
}
impl RecordDecl {
pub fn what(&self) -> String {
let kw = match self.kind {
RecordKind::Record { .. } => "record",
RecordKind::Alias { .. } | RecordKind::Union { .. } => "type",
RecordKind::Enum { .. } => "enum",
};
format!("{kw} {}", self.name)
}
}
fn record_fields(
fields: &syn::FieldsNamed,
self_name: &str,
) -> Result<Vec<(String, String)>, String> {
let mut out = Vec::new();
for f in &fields.named {
let fi = f.ident.as_ref().unwrap().to_string();
if f.attrs.iter().any(|a| a.path().is_ident("teal")) {
return Err(format!(
"TealRecord: {self_name}.{fi}: `#[teal(..)]` on a record field is not supported; \
fields are declared under their Rust names"
));
}
let tt = teal_type(&f.ty, self_name)?;
out.push((fi, tt));
}
Ok(out)
}
fn struct_kind(st: &ItemStruct, name: &str) -> Result<RecordKind, String> {
match &st.fields {
syn::Fields::Named(fields) => Ok(RecordKind::Record {
fields: record_fields(fields, name)?,
}),
syn::Fields::Unnamed(u) if u.unnamed.len() == 1 => Ok(RecordKind::Alias {
inner: teal_type(&u.unnamed[0].ty, name)?,
}),
syn::Fields::Unnamed(_) => Err(format!(
"TealRecord: `{name}` is a tuple struct with more than one field; only a newtype (`struct {name}(T)`) lowers to a Teal type"
)),
syn::Fields::Unit => Err(format!(
"TealRecord: `{name}` is a unit struct and has nothing to declare"
)),
}
}
fn variant_word(
v: &syn::Variant,
rule: Option<RenameRule>,
enum_name: &str,
) -> Result<String, String> {
let attrs = parse_teal_attrs(&v.attrs)
.map_err(|e| format!("TealRecord: {enum_name}::{}: {e}", v.ident))?;
if attrs.rename_all.is_some()
|| attrs.dts.is_some()
|| !attrs.uses.is_empty()
|| !attrs.records.is_empty()
|| attrs.errors.is_some()
{
return Err(format!(
"TealRecord: {enum_name}::{}: only `#[teal(name = \"..\")]` applies to a variant \
(`rename_all` goes on the enum)",
v.ident
));
}
Ok(match attrs.name {
Some(n) => n,
None => match rule {
Some(r) => r.apply(&v.ident.to_string()),
None => v.ident.to_string(),
},
})
}
fn reject_duplicate_words(en: &ItemEnum, words: &[String], enum_name: &str) -> Result<(), String> {
for (i, w) in words.iter().enumerate() {
if let Some(j) = words[..i].iter().position(|p| p == w) {
return Err(format!(
"TealRecord: {enum_name}::{} and {enum_name}::{} both reach Teal as {w:?}; \
give one a `#[teal(name = \"..\")]` of its own",
en.variants[j].ident, en.variants[i].ident
));
}
}
Ok(())
}
fn enum_kind(en: &ItemEnum, name: &str, rule: Option<RenameRule>) -> Result<RecordKind, String> {
if en.variants.is_empty() {
return Err(format!(
"TealRecord: `{name}` has no variants and has nothing to declare"
));
}
let words: Vec<String> = en
.variants
.iter()
.map(|v| variant_word(v, rule, name))
.collect::<Result<_, _>>()?;
reject_duplicate_words(en, &words, name)?;
if en
.variants
.iter()
.all(|v| matches!(v.fields, syn::Fields::Unit))
{
return Ok(RecordKind::Enum { variants: words });
}
let mut variants = Vec::new();
for (v, word) in en.variants.iter().zip(words) {
let vname = v.ident.to_string();
let shape = match &v.fields {
syn::Fields::Unit => VariantShape::Unit,
syn::Fields::Unnamed(u) if u.unnamed.len() == 1 => {
VariantShape::Newtype(teal_type(&u.unnamed[0].ty, name)?)
}
syn::Fields::Unnamed(_) => {
return Err(
"TealRecord: tuple variants with more than one field are not supported"
.to_string(),
);
}
syn::Fields::Named(fields) => {
if fields
.named
.iter()
.any(|f| f.ident.as_ref().is_some_and(|i| i == "kind"))
{
return Err(format!(
"TealRecord: {name}::{vname}: a field named `kind` collides with the variant tag"
));
}
VariantShape::Struct(record_fields(fields, name)?)
}
};
variants.push(UnionVariant {
name: vname,
word,
shape,
});
}
Ok(RecordKind::Union { variants })
}
fn record_parts(item: &Item) -> Result<(String, TealAttrs, RecordKind), String> {
let (attrs, ident) = match item {
Item::Struct(st) => (parse_teal_attrs(&st.attrs)?, st.ident.to_string()),
Item::Enum(en) => (parse_teal_attrs(&en.attrs)?, en.ident.to_string()),
_ => return Err("TealRecord: only structs and enums are supported".into()),
};
let name = attrs.name.clone().unwrap_or(ident);
let kind = match item {
Item::Struct(st) => {
if attrs.rename_all.is_some() {
return Err(format!(
"TealRecord: `{name}`: `rename_all` applies to enum variants; \
record fields are declared under their Rust names"
));
}
struct_kind(st, &name)?
}
Item::Enum(en) => enum_kind(en, &name, attrs.rename_all)?,
_ => unreachable!(),
};
Ok((name, attrs, kind))
}
fn variant_fields(v: &UnionVariant) -> Vec<(String, String)> {
let mut fields = vec![("kind".to_string(), "string".to_string())];
match &v.shape {
VariantShape::Unit => {}
VariantShape::Newtype(t) => fields.push(("value".to_string(), t.clone())),
VariantShape::Struct(fs) => fields.extend(fs.iter().cloned()),
}
fields
}
fn kind_decl(name: &str, kind: &RecordKind, indent: &str) -> String {
let local = if indent.is_empty() { "local " } else { "" };
let inner = format!("{indent} ");
let mut s = String::new();
match kind {
RecordKind::Record { fields } => {
s.push_str(&format!("{indent}{local}record {name}\n"));
for (f, t) in fields {
s.push_str(&format!("{inner}{f}: {t}\n"));
}
s.push_str(&format!("{indent}end\n"));
}
RecordKind::Alias { inner: t } => {
s.push_str(&format!("{indent}{local}type {name} = {t}\n"));
}
RecordKind::Enum { variants } => {
s.push_str(&format!("{indent}{local}enum {name}\n"));
for v in variants {
s.push_str(&format!("{inner}\"{v}\"\n"));
}
s.push_str(&format!("{indent}end\n"));
}
RecordKind::Union { variants } => {
for v in variants {
s.push_str(&format!("{indent}{local}record {name}_{}\n", v.name));
s.push_str(&format!("{inner}where self.kind == \"{}\"\n", v.word));
for (f, t) in variant_fields(v) {
s.push_str(&format!("{inner}{f}: {t}\n"));
}
s.push_str(&format!("{indent}end\n"));
}
let members: Vec<String> = variants
.iter()
.map(|v| format!("{name}_{}", v.name))
.collect();
s.push_str(&format!(
"{indent}{local}type {name} = {}\n",
members.join(" | ")
));
}
}
s
}
pub fn record_decl(item: &Item) -> Result<RecordDecl, String> {
let (name, attrs, kind) = record_parts(item)?;
if attrs.dts.is_some() && matches!(kind, RecordKind::Union { .. }) {
return Err(format!(
"TealRecord: `{name}` has data-carrying variants and cannot be a `.d.tl` module of its own \
(a caller narrows it with `is {name}_<Variant>`, and a module exports one name); \
drop `dts` and declare it nested in the host module with `records = [{name}]`"
));
}
let mut decl = uses_header(&attrs.uses);
decl.push_str(&kind_decl(&name, &kind, ""));
decl.push_str(&format!("\nreturn {name}\n"));
Ok(RecordDecl {
name,
kind,
decl,
attrs,
})
}
pub fn find_item<'a>(items: &'a [Item], name: &str) -> Option<&'a Item> {
for it in items {
match it {
Item::Struct(s) if s.ident == name => return Some(it),
Item::Enum(e) if e.ident == name => return Some(it),
Item::Mod(m) => {
if let Some((_, inner)) = &m.content
&& let Some(s) = find_item(inner, name)
{
return Some(s);
}
}
_ => {}
}
}
None
}
fn nested_record_decls(
names: &[String],
file_items: Option<&[Item]>,
) -> Result<Vec<String>, String> {
if names.is_empty() {
return Ok(Vec::new());
}
let items = file_items.ok_or_else(|| {
"host_module: `records` needs the source file (unavailable in this expansion); \
declare the record in its own module and use `uses` instead"
.to_string()
})?;
let mut out = Vec::new();
for name in names {
let it = find_item(items, name).ok_or_else(|| {
format!(
"host_module: `{name}` not found in this file (records must live in the same file; \
use `uses` for records from other modules)"
)
})?;
let (renamed, attrs, kind) = record_parts(it)?;
if attrs.name.is_some() {
return Err(format!(
"host_module: `{name}` is nested through `records` and cannot be renamed \
(`#[teal(name = \"{renamed}\")]`); to declare it as `{renamed}`, give it a \
`.d.tl` of its own (`#[teal(dts = ..)]`) and import it with `uses = [{renamed}]`"
));
}
out.push(kind_decl(name, &kind, " "));
}
Ok(out)
}
#[derive(Clone)]
pub struct HostParam {
pub name: String,
pub owned_ty: Type,
pub by_ref: bool,
pub teal: String,
pub optional: bool,
}
#[derive(Clone)]
pub struct HostMethod {
pub name: String,
pub receiver: Option<bool>,
pub params: Vec<HostParam>,
pub ret_teal: String,
pub ret_is_result: bool,
pub ret_is_unit: bool,
pub is_async: bool,
}
#[derive(Clone)]
pub struct HostDecl {
pub type_name: String,
pub module: String,
pub decl: String,
pub methods: Vec<HostMethod>,
pub attrs: TealAttrs,
pub err_mode: ErrMode,
}
pub fn host_decl(
imp: &ItemImpl,
attrs: TealAttrs,
file_items: Option<&[Item]>,
) -> Result<HostDecl, String> {
let type_name = match &*imp.self_ty {
Type::Path(p) => p
.path
.segments
.last()
.map(|s| s.ident.to_string())
.unwrap_or_default(),
_ => return Err("host_module: impl target must be a plain type".into()),
};
let module = attrs
.name
.clone()
.unwrap_or_else(|| type_name.to_lowercase());
let err_mode = match attrs.errors.as_deref() {
Some("return") => ErrMode::Return,
_ => ErrMode::Raise,
};
let mut decl = uses_header(&attrs.uses);
decl.push_str(&format!("local record {module}\n"));
for r in nested_record_decls(&attrs.records, file_items)? {
decl.push_str(&r);
}
let mut methods = Vec::new();
for it in &imp.items {
let ImplItem::Fn(f) = it else { continue };
if !matches!(f.vis, syn::Visibility::Public(_)) {
continue;
}
let fname = f.sig.ident.to_string();
let mut receiver: Option<bool> = None;
let mut params = Vec::new();
let mut teal_params = Vec::new();
for a in &f.sig.inputs {
match a {
FnArg::Receiver(r) => receiver = Some(r.mutability.is_some()),
FnArg::Typed(pt) => {
let (owned_ty, by_ref): (Type, bool) = match &*pt.ty {
Type::Reference(r) => {
if r.mutability.is_some() {
return Err(format!(
"host_module: `{fname}`: `&mut` parameters are not supported"
));
}
let owned: Type = match &*r.elem {
Type::Path(p) if p.path.is_ident("str") => {
syn::parse_quote!(::std::string::String)
}
Type::Slice(s) => {
let e = &s.elem;
syn::parse_quote!(::std::vec::Vec<#e>)
}
other => other.clone(),
};
(owned, true)
}
other => (other.clone(), false),
};
let pname = match &*pt.pat {
Pat::Ident(pi) => pi.ident.to_string(),
_ => format!("a{}", params.len()),
};
let teal = teal_type(&owned_ty, &module)?;
let optional = is_option(&owned_ty);
params.push(HostParam {
name: pname,
owned_ty,
by_ref,
teal,
optional,
});
}
}
}
let mut required_seen = false;
for p in params.iter_mut().rev() {
if !p.optional {
required_seen = true;
} else if required_seen {
p.optional = false;
}
}
for p in ¶ms {
let mark = if p.optional { "?" } else { "" };
teal_params.push(format!("{}{mark}: {}", p.name, p.teal));
}
if receiver.is_some() {
teal_params.insert(0, format!("self: {module}"));
}
let (ret_teal, ret_is_result) = match &f.sig.output {
ReturnType::Default => (String::new(), false),
ReturnType::Type(_, t) => (teal_type(t, &module)?, is_result(t)),
};
let ret_is_unit = ret_teal.is_empty();
let teal_ret = if ret_is_result && err_mode == ErrMode::Return {
if ret_is_unit {
"boolean, string".to_string()
} else {
format!("{ret_teal}, string")
}
} else {
ret_teal.clone()
};
let ret_suffix = if teal_ret.is_empty() {
String::new()
} else {
format!(": {teal_ret}")
};
decl.push_str(&format!(
" {fname}: function({}){ret_suffix}\n",
teal_params.join(", ")
));
methods.push(HostMethod {
name: fname,
receiver,
params,
ret_teal,
ret_is_result,
ret_is_unit,
is_async: f.sig.asyncness.is_some(),
});
}
decl.push_str(&format!("end\n\nreturn {module}\n"));
Ok(HostDecl {
type_name,
module,
decl,
methods,
attrs,
err_mode,
})
}
#[derive(Debug, Clone)]
pub struct Generated {
pub target: PathBuf,
pub text: String,
pub source: PathBuf,
pub what: String,
}
fn walk_items<'a>(items: &'a [Item], out: &mut Vec<&'a Item>) {
for it in items {
out.push(it);
if let Item::Mod(m) = it
&& let Some((_, inner)) = &m.content
{
walk_items(inner, out);
}
}
}
pub fn scan_rust_file(path: &Path, manifest_dir: &Path) -> Result<Vec<Generated>, String> {
let src =
std::fs::read_to_string(path).map_err(|e| format!("reading {}: {e}", path.display()))?;
let file = syn::parse_file(&src).map_err(|e| format!("parsing {}: {e}", path.display()))?;
let mut flat = Vec::new();
walk_items(&file.items, &mut flat);
let mut out = Vec::new();
for it in flat {
match it {
Item::Impl(imp) => {
if let Some(attrs) = parse_host_module_attr(&imp.attrs)? {
let hd = host_decl(imp, attrs, Some(&file.items))?;
if let Some(dts) = &hd.attrs.dts {
out.push(Generated {
target: manifest_dir.join(dts),
text: hd.decl.clone(),
source: path.to_path_buf(),
what: format!("host_module {}", hd.module),
});
}
}
if let Some(attrs) = crate::cexport::parse_c_export_attr(&imp.attrs)?
&& attrs.header.is_some()
{
let hd = host_decl(imp, TealAttrs::default(), Some(&file.items))?;
let plan = crate::cexport::plan(&hd, imp, attrs)?;
if let Some(header) = &plan.header_path {
out.push(Generated {
target: manifest_dir.join(header),
text: plan.header(),
source: path.to_path_buf(),
what: format!("c_export {}", plan.prefix),
});
}
}
}
Item::Struct(ItemStruct { attrs, .. }) | Item::Enum(ItemEnum { attrs, .. })
if derives_teal_record(attrs) =>
{
let rd = record_decl(it)?;
if let Some(dts) = &rd.attrs.dts {
out.push(Generated {
target: manifest_dir.join(dts),
text: rd.decl.clone(),
source: path.to_path_buf(),
what: rd.what(),
});
}
}
_ => {}
}
}
Ok(out)
}
pub fn host_module_names(manifest_dir: &Path) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for sub in ["src", "examples", "tests", "benches"] {
let dir = manifest_dir.join(sub);
if !dir.is_dir() {
continue;
}
for e in walkdir::WalkDir::new(&dir)
.sort_by_file_name()
.into_iter()
.flatten()
{
let p = e.path();
if !p.is_file() || p.extension().and_then(|s| s.to_str()) != Some("rs") {
continue;
}
let Ok(src) = std::fs::read_to_string(p) else {
continue;
};
if !src.contains("host_module") {
continue;
}
let Ok(file) = syn::parse_file(&src) else {
continue;
};
let mut flat = Vec::new();
walk_items(&file.items, &mut flat);
for it in flat {
let Item::Impl(imp) = it else { continue };
let Ok(Some(attrs)) = parse_host_module_attr(&imp.attrs) else {
continue;
};
let name = match (attrs.name, &*imp.self_ty) {
(Some(n), _) => Some(n),
(None, Type::Path(p)) => p
.path
.segments
.last()
.map(|s| s.ident.to_string().to_lowercase()),
(None, _) => None,
};
if let Some(n) = name
&& !out.contains(&n)
{
out.push(n);
}
}
}
}
out
}
pub fn find_cargo_package_root(start: &Path) -> Option<PathBuf> {
let mut dir = if start.is_dir() {
start.to_path_buf()
} else {
crate::parent_dir(start)
};
if let Ok(abs) = std::fs::canonicalize(&dir) {
dir = abs;
}
loop {
let manifest = dir.join("Cargo.toml");
if manifest.is_file()
&& let Ok(text) = std::fs::read_to_string(&manifest)
&& text.contains("[package]")
{
return Some(dir);
}
if !dir.pop() {
return None;
}
}
}
pub fn generate_crate(manifest_dir: &Path) -> Result<Vec<(PathBuf, bool)>, String> {
let mut results = Vec::new();
for sub in ["src", "examples", "tests", "benches"] {
let dir = manifest_dir.join(sub);
if !dir.is_dir() {
continue;
}
for e in walkdir::WalkDir::new(&dir).sort_by_file_name() {
let e = e.map_err(|e| e.to_string())?;
let p = e.path();
if !p.is_file() || p.extension().and_then(|s| s.to_str()) != Some("rs") {
continue;
}
for g in scan_rust_file(p, manifest_dir)? {
let written = crate::write_if_changed(&g.target, &g.text)
.map_err(|err| format!("writing {}: {err}", g.target.display()))?;
results.push((g.target, written));
}
}
}
Ok(results)
}
#[cfg(test)]
mod tests {
use super::*;
fn item(src: &str) -> Item {
syn::parse_str(src).unwrap()
}
#[test]
fn a_struct_with_named_fields_is_a_record_as_before() {
let rd = record_decl(&item(
"#[derive(TealRecord)] pub struct Point { pub x: f64, pub y: f64 }",
))
.unwrap();
assert_eq!(rd.what(), "record Point");
assert_eq!(
rd.decl,
"local record Point\n x: number\n y: number\nend\n\nreturn Point\n"
);
}
#[test]
fn a_newtype_is_a_type_alias_of_its_inner_type() {
let rd = record_decl(&item("#[derive(TealRecord)] pub struct Sql(pub String);")).unwrap();
assert_eq!(
rd.kind,
RecordKind::Alias {
inner: "string".into()
}
);
assert_eq!(rd.what(), "type Sql");
assert_eq!(rd.decl, "local type Sql = string\n\nreturn Sql\n");
}
#[test]
fn a_unit_enum_is_a_teal_enum_of_its_variant_names() {
let rd = record_decl(&item(
"#[derive(TealRecord)] pub enum Mode { Fast, Careful }",
))
.unwrap();
assert_eq!(rd.what(), "enum Mode");
assert_eq!(
rd.decl,
"local enum Mode\n \"Fast\"\n \"Careful\"\nend\n\nreturn Mode\n"
);
}
#[test]
fn rename_all_spells_the_enum_entries() {
let rd = record_decl(&item(
"#[derive(TealRecord)] #[teal(rename_all = \"snake_case\")] pub enum State { Open, InReview }",
))
.unwrap();
assert_eq!(
rd.kind,
RecordKind::Enum {
variants: vec!["open".into(), "in_review".into()]
}
);
assert_eq!(
rd.decl,
"local enum State\n \"open\"\n \"in_review\"\nend\n\nreturn State\n"
);
}
#[test]
fn every_rename_rule_spells_a_variant_serde_s_way() {
let spellings: Vec<String> = RenameRule::NAMES
.iter()
.map(|n| RenameRule::parse(n).unwrap().apply("InReview"))
.collect();
assert_eq!(
spellings,
vec![
"inreview",
"INREVIEW",
"InReview",
"inReview",
"in_review",
"IN_REVIEW",
"in-review",
"IN-REVIEW",
]
);
let e = record_decl(&item(
"#[derive(TealRecord)] #[teal(rename_all = \"Title Case\")] pub enum S { A }",
))
.unwrap_err();
assert!(
e.contains("`rename_all` must be one of") && e.contains("\"snake_case\""),
"{e}"
);
}
#[test]
fn a_variant_name_wins_over_rename_all() {
let rd = record_decl(&item(
"#[derive(TealRecord)] #[teal(rename_all = \"snake_case\")] pub enum State { Open, #[teal(name = \"REVIEW\")] InReview }",
))
.unwrap();
assert_eq!(
rd.decl,
"local enum State\n \"open\"\n \"REVIEW\"\nend\n\nreturn State\n"
);
let e = record_decl(&item(
"#[derive(TealRecord)] pub enum State { #[teal(rename_all = \"lowercase\")] Open }",
))
.unwrap_err();
assert!(
e.contains("State::Open") && e.contains("`rename_all` goes on the enum"),
"{e}"
);
}
#[test]
fn two_variants_reaching_the_same_word_are_refused() {
let e = record_decl(&item(
"#[derive(TealRecord)] #[teal(rename_all = \"lowercase\")] pub enum State { Open, OPEN }",
))
.unwrap_err();
assert_eq!(
e,
"TealRecord: State::Open and State::OPEN both reach Teal as \"open\"; \
give one a `#[teal(name = \"..\")]` of its own"
);
let e = record_decl(&item(
"#[derive(TealRecord)] pub enum State { Open, #[teal(name = \"Open\")] Closed }",
))
.unwrap_err();
assert!(e.contains("State::Open and State::Closed"), "{e}");
}
#[test]
fn renaming_record_fields_is_refused_not_ignored() {
let e = record_decl(&item(
"#[derive(TealRecord)] #[teal(rename_all = \"snake_case\")] pub struct P { pub x: f64 }",
))
.unwrap_err();
assert!(e.contains("`rename_all` applies to enum variants"), "{e}");
let e = record_decl(&item(
"#[derive(TealRecord)] pub struct P { #[teal(name = \"ex\")] pub x: f64 }",
))
.unwrap_err();
assert!(e.contains("P.x") && e.contains("not supported"), "{e}");
}
#[test]
fn a_data_enum_is_a_union_of_where_records() {
let rd = record_decl(&item(
"#[derive(TealRecord)] pub enum Shape { Dot, Circle(f64), Rect { w: f64, h: f64 } }",
))
.unwrap();
assert_eq!(rd.what(), "type Shape");
assert_eq!(
rd.decl,
"local record Shape_Dot\n where self.kind == \"Dot\"\n kind: string\nend\n\
local record Shape_Circle\n where self.kind == \"Circle\"\n kind: string\n value: number\nend\n\
local record Shape_Rect\n where self.kind == \"Rect\"\n kind: string\n w: number\n h: number\nend\n\
local type Shape = Shape_Dot | Shape_Circle | Shape_Rect\n\nreturn Shape\n"
);
}
#[test]
fn rename_all_spells_the_kind_tag_and_leaves_the_record_names() {
let rd = record_decl(&item(
"#[derive(TealRecord)] #[teal(rename_all = \"kebab-case\")] pub enum State { Open, InReview(f64) }",
))
.unwrap();
assert_eq!(
rd.decl,
"local record State_Open\n where self.kind == \"open\"\n kind: string\nend\n\
local record State_InReview\n where self.kind == \"in-review\"\n kind: string\n value: number\nend\n\
local type State = State_Open | State_InReview\n\nreturn State\n"
);
}
#[test]
fn a_data_enum_refuses_to_be_a_module_of_its_own() {
let e = record_decl(&item(
"#[derive(TealRecord)] #[teal(dts = \"types/Shape.d.tl\")] pub enum Shape { Dot, Circle(f64) }",
))
.unwrap_err();
assert!(e.contains("records = [Shape]"), "{e}");
assert!(e.contains("is Shape_<Variant>"), "{e}");
}
#[test]
fn a_tuple_variant_with_two_fields_is_refused() {
let e = record_decl(&item(
"#[derive(TealRecord)] pub enum Pair { Two(f64, f64) }",
))
.unwrap_err();
assert_eq!(
e,
"TealRecord: tuple variants with more than one field are not supported"
);
}
#[test]
fn a_tuple_struct_with_two_fields_and_a_unit_struct_are_refused() {
let e = record_decl(&item("#[derive(TealRecord)] pub struct P(f64, f64);")).unwrap_err();
assert!(e.contains("newtype"), "{e}");
let e = record_decl(&item("#[derive(TealRecord)] pub struct U;")).unwrap_err();
assert!(e.contains("unit struct"), "{e}");
}
#[test]
fn uses_imports_with_local_type() {
let rd = record_decl(&item(
"#[derive(TealRecord)] #[teal(uses = [Mode])] pub struct Run { pub mode: Mode }",
))
.unwrap();
assert!(
rd.decl
.starts_with("local type Mode = require(\"Mode\")\n\nlocal record Run\n"),
"{}",
rd.decl
);
}
#[test]
fn every_kind_nests_in_a_host_module() {
let file: syn::File = syn::parse_str(
"#[derive(TealRecord)] pub enum Mode { Fast, Careful }\n\
#[derive(TealRecord)] pub struct Label(pub String);\n\
#[derive(TealRecord)] pub enum Shape { Dot, Circle(f64) }\n\
#[derive(TealRecord)] pub struct Point { pub x: f64 }\n\
pub struct Host;\n\
#[host_module(name = \"host\", records = [Mode, Label, Shape, Point])]\n\
impl Host {\n pub fn area(&self, s: Shape, m: Mode) -> Label { todo!() }\n}\n",
)
.unwrap();
let imp = file
.items
.iter()
.find_map(|i| match i {
Item::Impl(imp) => Some(imp),
_ => None,
})
.unwrap();
let attrs = parse_host_module_attr(&imp.attrs).unwrap().unwrap();
let hd = host_decl(imp, attrs, Some(&file.items)).unwrap();
assert_eq!(
hd.decl,
"local record host\n\
\x20 enum Mode\n \"Fast\"\n \"Careful\"\n end\n\
\x20 type Label = string\n\
\x20 record Shape_Dot\n where self.kind == \"Dot\"\n kind: string\n end\n\
\x20 record Shape_Circle\n where self.kind == \"Circle\"\n kind: string\n value: number\n end\n\
\x20 type Shape = Shape_Dot | Shape_Circle\n\
\x20 record Point\n x: number\n end\n\
\x20 area: function(self: host, s: Shape, m: Mode): Label\n\
end\n\nreturn host\n"
);
}
#[test]
fn a_struct_variant_with_a_field_named_kind_is_refused() {
let e = record_decl(&item(
"#[derive(TealRecord)] pub enum Op { Get, Set { kind: String, n: i64 } }",
))
.unwrap_err();
assert_eq!(
e,
"TealRecord: Op::Set: a field named `kind` collides with the variant tag"
);
}
fn host_impl(src: &str) -> HostDecl {
let file: syn::File = syn::parse_str(src).unwrap();
let imp = file
.items
.iter()
.find_map(|i| match i {
Item::Impl(imp) => Some(imp),
_ => None,
})
.unwrap();
let attrs = parse_host_module_attr(&imp.attrs).unwrap().unwrap();
host_decl(imp, attrs, Some(&file.items)).unwrap()
}
#[test]
fn an_option_parameter_is_declared_optional() {
let hd = host_impl(
"pub struct Api;\n\
#[host_module(name = \"api\")]\n\
impl Api {\n\
\x20 pub fn find(&self, name: &str, scope: Option<String>) -> Option<String> { todo!() }\n\
}\n",
);
assert!(
hd.decl
.contains("find: function(self: api, name: string, scope?: string): string"),
"{}",
hd.decl
);
}
#[test]
fn every_trailing_option_is_marked() {
let hd = host_impl(
"pub struct Api;\n\
#[host_module(name = \"api\")]\n\
impl Api {\n\
\x20 pub fn page(&self, n: i64, size: Option<i64>, cursor: Option<String>) {}\n\
}\n",
);
assert!(
hd.decl
.contains("page: function(self: api, n: integer, size?: integer, cursor?: string)"),
"{}",
hd.decl
);
}
#[test]
fn an_option_followed_by_a_required_parameter_stays_required() {
let hd = host_impl(
"pub struct Api;\n\
#[host_module(name = \"api\")]\n\
impl Api {\n\
\x20 pub fn at(&self, scope: Option<String>, n: i64, tail: Option<i64>) {}\n\
}\n",
);
assert!(
hd.decl
.contains("at: function(self: api, scope: string, n: integer, tail?: integer)"),
"{}",
hd.decl
);
}
#[test]
fn a_field_and_a_return_are_not_marked() {
let rd = record_decl(&item(
"#[derive(TealRecord)] pub struct Outcome { pub did: String, pub blocked: Option<String> }",
))
.unwrap();
assert_eq!(
rd.decl,
"local record Outcome\n did: string\n blocked: string\nend\n\nreturn Outcome\n"
);
let hd = host_impl(
"pub struct Api;\n\
#[host_module(name = \"api\")]\n\
impl Api {\n\
\x20 pub fn last(&self) -> Option<String> { todo!() }\n\
}\n",
);
assert!(
hd.decl.contains("last: function(self: api): string"),
"{}",
hd.decl
);
}
#[test]
fn a_renamed_item_cannot_be_nested() {
let file: syn::File = syn::parse_str(
"#[derive(TealRecord)] #[teal(name = \"Pt\")] pub struct Point { pub x: f64, pub next: Option<Box<Self>> }\n\
pub struct Host;\n\
#[host_module(name = \"host\", records = [Point])]\n\
impl Host {\n pub fn origin(&self) -> Point { todo!() }\n}\n",
)
.unwrap();
let imp = file
.items
.iter()
.find_map(|i| match i {
Item::Impl(imp) => Some(imp),
_ => None,
})
.unwrap();
let attrs = parse_host_module_attr(&imp.attrs).unwrap().unwrap();
let e = match host_decl(imp, attrs, Some(&file.items)) {
Ok(hd) => panic!("rename accepted: {}", hd.decl),
Err(e) => e,
};
assert!(
e.contains("`Point` is nested through `records` and cannot be renamed")
&& e.contains("uses = [Pt]"),
"{e}"
);
}
}