use std::collections::BTreeMap;
use syn::{
Fields, GenericArgument, ItemEnum, ItemStruct, PathArguments, Type, TypeArray, TypePath as SynTypePath,
TypeReference, TypeSlice,
};
use crate::attr::{
ContainerAttrs, FieldAttrs, VariantAttrs, extract_container_attrs, extract_field_attrs, extract_ontogen_attrs,
extract_variant_attrs,
};
use crate::order;
use crate::resolve::ModuleImports;
use crate::types::{BigIntBehavior, EmitConfig, EmitError, RenameAll, TypePath};
pub fn emit(
roots: &[TypePath],
type_pool: &BTreeMap<TypePath, syn::Item>,
config: &EmitConfig,
) -> Result<String, Vec<EmitError>> {
emit_with_imports(roots, type_pool, &ModuleImports::default(), config)
}
pub fn emit_with_imports(
roots: &[TypePath],
type_pool: &BTreeMap<TypePath, syn::Item>,
imports: &ModuleImports,
config: &EmitConfig,
) -> Result<String, Vec<EmitError>> {
let mut errors: Vec<EmitError> = Vec::new();
let graph = order::dependency_graph_with_imports(type_pool, imports);
let reachable = order::reachable_from(roots, &graph);
for root in roots {
if !type_pool.contains_key(root) {
errors.push(EmitError::UnresolvedReference {
name: format!("root type `{root}` is not present in the type pool"),
referenced_by: root.clone(),
});
}
}
let mut names: BTreeMap<TypePath, String> = BTreeMap::new();
for path in &reachable {
let Some(item) = type_pool.get(path) else {
continue;
};
let attrs = item_attrs(item);
match extract_ontogen_attrs(attrs, path) {
Ok(ontogen) => {
let name = ontogen.ts_name.unwrap_or_else(|| path.terminal().to_string());
names.insert(path.clone(), name);
}
Err(err) => {
errors.push(err);
names.insert(path.clone(), path.terminal().to_string());
}
}
}
{
let mut by_name: BTreeMap<String, Vec<TypePath>> = BTreeMap::new();
for (path, name) in &names {
by_name.entry(name.clone()).or_default().push(path.clone());
}
for (name, paths) in by_name {
if paths.len() > 1 {
errors.push(EmitError::NameCollision { name, paths });
}
}
}
let ordered = order::topo_order(&graph, &reachable);
let mut outputs: Vec<String> = Vec::with_capacity(ordered.len());
for path in &ordered {
let Some(item) = type_pool.get(path) else {
continue;
};
let ontogen_attrs = match extract_ontogen_attrs(item_attrs(item), path) {
Ok(a) => a,
Err(_) => continue, };
let resolved_name = names.get(path).cloned().unwrap_or_else(|| path.terminal().to_string());
if let Some(target) = ontogen_attrs.ts_opaque {
outputs.push(format!("export type {resolved_name} = {target};"));
continue;
}
match item {
syn::Item::Struct(s) => match emit_struct_named(s, config, Some(&resolved_name)) {
Ok(ts) => outputs.push(ts),
Err(e) => errors.push(e),
},
syn::Item::Enum(e) => match emit_enum_named(e, config, Some(&resolved_name)) {
Ok(ts) => outputs.push(ts),
Err(err) => errors.push(err),
},
syn::Item::Type(t) => {
let synthetic_path = TypePath::new(vec![path.terminal().to_string()]).expect("non-empty");
match emit_type(&t.ty, config, &synthetic_path) {
Ok(inner) => outputs.push(format!("export type {resolved_name} = {inner};")),
Err(err) => errors.push(err),
}
}
_ => {
}
}
}
if !errors.is_empty() {
return Err(errors);
}
Ok(outputs.join("\n\n"))
}
fn item_attrs(item: &syn::Item) -> &[syn::Attribute] {
match item {
syn::Item::Struct(s) => &s.attrs,
syn::Item::Enum(e) => &e.attrs,
syn::Item::Type(t) => &t.attrs,
_ => &[],
}
}
pub(crate) fn emit_type(ty: &Type, config: &EmitConfig, referenced_by: &TypePath) -> Result<String, EmitError> {
if let Some(inner) = peel_smart_pointer(ty) {
return emit_type(inner, config, referenced_by);
}
if let Type::Reference(TypeReference { elem, .. }) = ty {
if let Type::Slice(TypeSlice { elem: slice_elem, .. }) = elem.as_ref() {
let inner = emit_type(slice_elem, config, referenced_by)?;
return Ok(format!("{inner}[]"));
}
return emit_type(elem, config, referenced_by);
}
if let Type::Array(TypeArray { elem, .. }) = ty {
let inner = emit_type(elem, config, referenced_by)?;
return Ok(format!("{inner}[]"));
}
if let Type::Slice(TypeSlice { elem, .. }) = ty {
let inner = emit_type(elem, config, referenced_by)?;
return Ok(format!("{inner}[]"));
}
let path = match ty {
Type::Path(p) => p,
other => {
return Err(EmitError::UnsupportedShape {
type_path: referenced_by.clone(),
reason: format!("type expression `{}` is not supported in phase 1", quote::quote!(#other)),
});
}
};
if let Some(name) = terminal_ident(path)
&& matches!(name.as_str(), "RefCell" | "Mutex" | "RwLock")
{
return Err(EmitError::UnsupportedShape {
type_path: referenced_by.clone(),
reason: format!(
"{name}<T> is a runtime-coordination primitive and shouldn't appear in wire types; refactor or \
use #[ontogen::ts_opaque]"
),
});
}
if let Some(container) = match_container(path) {
return emit_container(container, config, referenced_by);
}
if let Some(name) = single_segment_ident(path)
&& let Some(rendered) = primitive_ts(&name, config)
{
return Ok(rendered.to_string());
}
let segments: Vec<String> = path.path.segments.iter().map(|s| s.ident.to_string()).collect();
if segments.is_empty() {
return Err(EmitError::UnsupportedShape {
type_path: referenced_by.clone(),
reason: "type path had no segments".to_string(),
});
}
let mut canonical_segs = segments.clone();
if canonical_segs.first().map(String::as_str) == Some("crate") {
canonical_segs.remove(0);
}
if let Ok(canonical) = TypePath::new(canonical_segs)
&& let Some(rendering) = crate::external::resolve(&canonical, &config.external_types)
{
return Ok(rendering);
}
Ok(segments.last().expect("non-empty after the early return above").clone())
}
#[allow(dead_code)] pub(crate) fn emit_struct(item: &ItemStruct, config: &EmitConfig) -> Result<String, EmitError> {
emit_struct_named(item, config, None)
}
pub(crate) fn emit_struct_named(
item: &ItemStruct,
config: &EmitConfig,
name_override: Option<&str>,
) -> Result<String, EmitError> {
let raw_name = item.ident.to_string();
let name = name_override.map(str::to_string).unwrap_or_else(|| raw_name.clone());
let referenced_by = TypePath::new(vec![raw_name]).expect("single segment is non-empty");
let container = extract_container_attrs(&item.attrs, &referenced_by)?;
let effective_rename_all = container.rename_all.or(config.case_default);
match &item.fields {
Fields::Named(fields) => {
let collected = collect_named_fields(fields, config, &referenced_by, effective_rename_all)?;
let object = (!collected.properties.is_empty()).then(|| {
let body = collected
.properties
.iter()
.map(|(key, opt, ty_ts)| format!(" {key}{opt}: {ty_ts};"))
.collect::<Vec<_>>()
.join("\n");
format!("{{\n{body}\n}}")
});
Ok(format!("export type {name} = {};", intersect(&collected.intersections, object)))
}
Fields::Unnamed(_) => Err(EmitError::UnsupportedShape {
type_path: referenced_by,
reason: "tuple structs are not supported in phase 1; wrap in a named-field struct or use \
#[ontogen::ts_opaque]"
.to_string(),
}),
Fields::Unit => Err(EmitError::UnsupportedShape {
type_path: referenced_by,
reason: "unit structs are not supported in phase 1; use a named-field struct or #[ontogen::ts_opaque]"
.to_string(),
}),
}
}
struct NamedFields {
intersections: Vec<String>,
properties: Vec<(String, &'static str, String)>,
}
fn collect_named_fields(
fields: &syn::FieldsNamed,
config: &EmitConfig,
referenced_by: &TypePath,
rename_all: Option<RenameAll>,
) -> Result<NamedFields, EmitError> {
let mut out = NamedFields { intersections: Vec::new(), properties: Vec::with_capacity(fields.named.len()) };
for field in &fields.named {
let field_attrs = extract_field_attrs(&field.attrs, referenced_by)?;
if field_attrs.skip {
continue;
}
if field_attrs.flatten {
out.intersections.push(flatten_member(&field.ty, &field_attrs, config, referenced_by)?);
continue;
}
let raw_ident = field.ident.as_ref().expect("Fields::Named guarantees a field ident").to_string();
let wire_name = field_wire_name(&raw_ident, &field_attrs, rename_all);
let key = format_ts_key(&wire_name);
let ty_ts = emit_type(&field.ty, config, referenced_by)?;
let opt = if field_attrs.default { "?" } else { "" };
out.properties.push((key, opt, ty_ts));
}
Ok(out)
}
fn intersect(members: &[String], object: Option<String>) -> String {
match (members.is_empty(), object) {
(true, Some(object)) => object,
(true, None) => "{}".to_string(),
(false, Some(object)) => format!("{} & {object}", members.join(" & ")),
(false, None) => members.join(" & "),
}
}
fn flatten_member(
ty: &Type,
attrs: &FieldAttrs,
config: &EmitConfig,
referenced_by: &TypePath,
) -> Result<String, EmitError> {
if attrs.default {
return Err(EmitError::UnsupportedShape {
type_path: referenced_by.clone(),
reason: "#[serde(flatten, default)] makes the whole flattened group absent-or-present as a unit, which a \
TS intersection can't express; drop the `default` or use #[ontogen::ts_opaque(target = \"...\")]"
.to_string(),
});
}
let mut inner = ty;
while let Some(peeled) = peel_smart_pointer(inner) {
inner = peeled;
}
if let Type::Path(path) = inner
&& matches!(match_container(path), Some(Container::Option(_)))
{
return Err(EmitError::UnsupportedShape {
type_path: referenced_by.clone(),
reason: "#[serde(flatten)] on an Option<T> makes the whole flattened group absent-or-present as a unit, \
which a TS intersection can't express; flatten a non-Option field or use \
#[ontogen::ts_opaque(target = \"...\")]"
.to_string(),
});
}
let rendered = emit_type(inner, config, referenced_by)?;
if !is_object_shaped(&rendered) {
return Err(EmitError::UnsupportedShape {
type_path: referenced_by.clone(),
reason: format!(
"#[serde(flatten)] needs a field type that renders to a TS object, but this one renders as \
`{rendered}`; intersecting that would void or silently drop the parent type. Flatten a struct or a \
map, or use #[ontogen::ts_opaque(target = \"...\")]"
),
});
}
Ok(rendered)
}
const NON_OBJECT_TS_KEYWORDS: &[&str] = &[
"any",
"bigint",
"boolean",
"never",
"null",
"number",
"object",
"string",
"symbol",
"undefined",
"unknown",
"void",
];
fn is_object_shaped(rendered: &str) -> bool {
if rendered.starts_with("Record<") {
return true;
}
is_valid_ts_ident(rendered) && !NON_OBJECT_TS_KEYWORDS.contains(&rendered)
}
fn field_wire_name(raw_ident: &str, attrs: &FieldAttrs, rename_all: Option<RenameAll>) -> String {
if let Some(explicit) = &attrs.rename {
return explicit.clone();
}
if let Some(mode) = rename_all {
return mode.apply_to_field(raw_ident);
}
raw_ident.to_string()
}
fn variant_field_rename_all(container: &ContainerAttrs, variant: &VariantAttrs) -> Option<RenameAll> {
variant.rename_all.or(container.rename_all_fields)
}
fn variant_wire_name(raw_ident: &str, attrs: &VariantAttrs, rename_all: Option<RenameAll>) -> String {
if let Some(explicit) = &attrs.rename {
return explicit.clone();
}
if let Some(mode) = rename_all {
return mode.apply_to_variant(raw_ident);
}
raw_ident.to_string()
}
fn format_ts_key(name: &str) -> String {
if is_valid_ts_ident(name) {
name.to_string()
} else {
let escaped = name.replace('\\', "\\\\").replace('"', "\\\"");
format!("\"{escaped}\"")
}
}
fn quote(config: &EmitConfig, s: &str) -> String {
let d = config.quote_style.delimiter();
format!("{d}{s}{d}")
}
fn is_valid_ts_ident(s: &str) -> bool {
let mut chars = s.chars();
let Some(first) = chars.next() else {
return false;
};
if !(first.is_ascii_alphabetic() || first == '_' || first == '$') {
return false;
}
chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '$')
}
#[allow(dead_code)] pub(crate) fn emit_enum(item: &ItemEnum, config: &EmitConfig) -> Result<String, EmitError> {
emit_enum_named(item, config, None)
}
pub(crate) fn emit_enum_named(
item: &ItemEnum,
config: &EmitConfig,
name_override: Option<&str>,
) -> Result<String, EmitError> {
let raw_name = item.ident.to_string();
let name = name_override.map(str::to_string).unwrap_or_else(|| raw_name.clone());
let referenced_by = TypePath::new(vec![raw_name]).expect("single segment is non-empty");
let container = extract_container_attrs(&item.attrs, &referenced_by)?;
let effective_rename_all = container.rename_all.or(config.case_default);
if item.variants.is_empty() {
return Ok(format!("export type {name} = never;"));
}
let mut variant_lines: Vec<String> = Vec::with_capacity(item.variants.len());
for variant in &item.variants {
let variant_attrs = extract_variant_attrs(&variant.attrs, &referenced_by)?;
if variant_attrs.skip {
continue;
}
let raw_ident = variant.ident.to_string();
let wire_name = variant_wire_name(&raw_ident, &variant_attrs, effective_rename_all);
match &variant.fields {
Fields::Unit => {
variant_lines.push(quote(config, &wire_name));
}
Fields::Unnamed(fields) => {
let key = format_ts_key(&wire_name);
match fields.unnamed.len() {
0 => variant_lines.push(quote(config, &wire_name)),
1 => {
let payload_ts = emit_type(&fields.unnamed[0].ty, config, &referenced_by)?;
variant_lines.push(format!("{{ {key}: {payload_ts} }}"));
}
_ => {
return Err(EmitError::UnsupportedShape {
type_path: referenced_by,
reason: format!(
"enum variant `{raw_ident}` has {} tuple fields; phase-1 supports unit, single-tuple, \
or struct variants (refactor into a struct variant for multi-field payloads)",
fields.unnamed.len()
),
});
}
}
}
Fields::Named(fields) => {
let key = format_ts_key(&wire_name);
let field_rename_all = variant_field_rename_all(&container, &variant_attrs);
let collected = collect_named_fields(fields, config, &referenced_by, field_rename_all)?;
let object = (!collected.properties.is_empty()).then(|| {
let body = collected
.properties
.iter()
.map(|(field_key, opt, ty_ts)| format!("{field_key}{opt}: {ty_ts}"))
.collect::<Vec<_>>()
.join("; ");
format!("{{ {body} }}")
});
let payload = intersect(&collected.intersections, object);
variant_lines.push(format!("{{ {key}: {payload} }}"));
}
}
}
if variant_lines.is_empty() {
return Ok(format!("export type {name} = never;"));
}
let body = variant_lines.join(" | ");
Ok(format!("export type {name} = {body};"))
}
const SMART_POINTERS: &[&str] = &["Box", "Rc", "Arc", "Cow", "Pin"];
fn peel_smart_pointer(ty: &Type) -> Option<&Type> {
let Type::Path(path) = ty else {
return None;
};
let segment = path.path.segments.last()?;
let name = segment.ident.to_string();
if !SMART_POINTERS.contains(&name.as_str()) {
return None;
}
let PathArguments::AngleBracketed(args) = &segment.arguments else {
return None;
};
args.args.iter().find_map(|arg| match arg {
GenericArgument::Type(inner) => Some(inner),
_ => None,
})
}
fn terminal_ident(path: &SynTypePath) -> Option<String> {
if path.qself.is_some() {
return None;
}
path.path.segments.last().map(|s| s.ident.to_string())
}
fn single_segment_ident(path: &SynTypePath) -> Option<String> {
if path.qself.is_some() {
return None;
}
if path.path.segments.len() != 1 {
return None;
}
let segment = &path.path.segments[0];
if !matches!(segment.arguments, PathArguments::None) {
return None;
}
Some(segment.ident.to_string())
}
enum Container<'a> {
Option(&'a Type),
Vec(&'a Type),
Map(&'a Type, &'a Type),
Set(&'a Type),
}
fn match_container(path: &SynTypePath) -> Option<Container<'_>> {
if path.qself.is_some() {
return None;
}
let segment = path.path.segments.last()?;
let name = segment.ident.to_string();
let PathArguments::AngleBracketed(args) = &segment.arguments else {
return None;
};
let type_args: Vec<&Type> = args
.args
.iter()
.filter_map(|arg| match arg {
GenericArgument::Type(t) => Some(t),
_ => None,
})
.collect();
match (name.as_str(), type_args.as_slice()) {
("Option", [inner]) => Some(Container::Option(inner)),
("Vec", [inner]) => Some(Container::Vec(inner)),
("HashMap" | "BTreeMap", [k, v]) => Some(Container::Map(k, v)),
("HashSet" | "BTreeSet", [inner]) => Some(Container::Set(inner)),
_ => None,
}
}
fn emit_container(
container: Container<'_>,
config: &EmitConfig,
referenced_by: &TypePath,
) -> Result<String, EmitError> {
match container {
Container::Option(inner) => {
let rendered = emit_type(inner, config, referenced_by)?;
if rendered.contains(" | ") { Ok(format!("({rendered}) | null")) } else { Ok(format!("{rendered} | null")) }
}
Container::Vec(inner) | Container::Set(inner) => {
let rendered = emit_type(inner, config, referenced_by)?;
if rendered.contains(" | ") { Ok(format!("({rendered})[]")) } else { Ok(format!("{rendered}[]")) }
}
Container::Map(key, value) => {
let key_ts = emit_type(key, config, referenced_by)?;
if !is_record_key_renderable(&key_ts) {
return Err(EmitError::UnsupportedShape {
type_path: referenced_by.clone(),
reason: format!(
"map key must render to `string` or a number-like primitive for TS `Record<K, V>`; got \
`{key_ts}`"
),
});
}
let value_ts = emit_type(value, config, referenced_by)?;
Ok(format!("Record<{key_ts}, {value_ts}>"))
}
}
}
fn is_record_key_renderable(rendered: &str) -> bool {
matches!(rendered, "string" | "number" | "bigint")
}
fn primitive_ts(name: &str, config: &EmitConfig) -> Option<&'static str> {
match name {
"bool" => Some("boolean"),
"u64" | "i64" | "u128" | "i128" | "usize" | "isize" => Some(bigint_rendering(config.bigint_behavior)),
"u8" | "u16" | "u32" | "i8" | "i16" | "i32" | "f32" | "f64" => Some("number"),
"char" => Some("string"),
"String" | "str" | "PathBuf" | "Path" | "OsString" | "OsStr" | "CString" | "CStr" => Some("string"),
_ => None,
}
}
fn bigint_rendering(behavior: BigIntBehavior) -> &'static str {
match behavior {
BigIntBehavior::Number => "number",
BigIntBehavior::BigInt => "bigint",
BigIntBehavior::String => "string",
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::QuoteStyle;
fn tp(name: &str) -> TypePath {
TypePath::new(vec![name.to_string()]).expect("non-empty")
}
fn ty(src: &str) -> Type {
syn::parse_str(src).unwrap_or_else(|err| panic!("failed to parse `{src}`: {err}"))
}
fn emit(src: &str) -> String {
let config = EmitConfig::default();
emit_type(&ty(src), &config, &tp("Test")).unwrap_or_else(|err| panic!("emit_type(`{src}`) errored: {err}"))
}
fn emit_err(src: &str) -> EmitError {
let config = EmitConfig::default();
emit_type(&ty(src), &config, &tp("Test")).expect_err("expected an EmitError")
}
#[test]
fn primitive_bool() {
assert_eq!(emit("bool"), "boolean");
}
#[test]
fn primitive_small_integers_render_as_number() {
for src in ["u8", "u16", "u32", "i8", "i16", "i32"] {
assert_eq!(emit(src), "number", "{src} should render as number");
}
}
#[test]
fn primitive_floats_render_as_number() {
assert_eq!(emit("f32"), "number");
assert_eq!(emit("f64"), "number");
}
#[test]
fn primitive_big_integers_default_to_number() {
for src in ["u64", "i64", "u128", "i128", "usize", "isize"] {
assert_eq!(emit(src), "number", "{src} should default to number");
}
}
#[test]
fn primitive_big_integers_honor_bigint_behavior() {
let config = EmitConfig { bigint_behavior: BigIntBehavior::BigInt, ..Default::default() };
let rendered = emit_type(&ty("u64"), &config, &tp("Test")).unwrap();
assert_eq!(rendered, "bigint");
let config = EmitConfig { bigint_behavior: BigIntBehavior::String, ..Default::default() };
let rendered = emit_type(&ty("i64"), &config, &tp("Test")).unwrap();
assert_eq!(rendered, "string");
}
#[test]
fn primitive_string_owned_and_borrowed() {
assert_eq!(emit("String"), "string");
assert_eq!(emit("&str"), "string");
}
#[test]
fn primitive_char_renders_as_string() {
assert_eq!(emit("char"), "string");
}
#[test]
fn primitive_std_string_like_types_render_as_string() {
assert_eq!(emit("PathBuf"), "string");
assert_eq!(emit("OsString"), "string");
assert_eq!(emit("CString"), "string");
assert_eq!(emit("Path"), "string");
assert_eq!(emit("OsStr"), "string");
assert_eq!(emit("CStr"), "string");
assert_eq!(emit("&Path"), "string");
assert_eq!(emit("Option<PathBuf>"), "string | null");
assert_eq!(emit("Vec<PathBuf>"), "string[]");
}
#[test]
fn std_string_like_full_path_resolves_through_external_table() {
assert_eq!(emit("std::path::PathBuf"), "string");
assert_eq!(emit("std::path::Path"), "string");
assert_eq!(emit("std::ffi::OsString"), "string");
assert_eq!(emit("std::ffi::OsStr"), "string");
assert_eq!(emit("std::ffi::CString"), "string");
assert_eq!(emit("std::ffi::CStr"), "string");
}
#[test]
fn container_option_renders_union_with_null() {
assert_eq!(emit("Option<u32>"), "number | null");
assert_eq!(emit("Option<String>"), "string | null");
}
#[test]
fn container_vec_renders_as_array() {
assert_eq!(emit("Vec<u32>"), "number[]");
assert_eq!(emit("Vec<String>"), "string[]");
}
#[test]
fn container_set_renders_as_array() {
assert_eq!(emit("HashSet<u32>"), "number[]");
assert_eq!(emit("BTreeSet<String>"), "string[]");
}
#[test]
fn container_hashmap_renders_as_record() {
assert_eq!(emit("HashMap<String, u32>"), "Record<string, number>");
assert_eq!(emit("BTreeMap<String, bool>"), "Record<string, boolean>");
}
#[test]
fn container_hashmap_accepts_numeric_keys() {
assert_eq!(emit("HashMap<u32, String>"), "Record<number, string>");
}
#[test]
fn container_hashmap_rejects_unsupported_keys() {
match emit_err("HashMap<MyKey, u32>") {
EmitError::UnsupportedShape { reason, .. } => {
assert!(reason.contains("map key"), "reason was: {reason}");
}
other => panic!("expected UnsupportedShape, got {other:?}"),
}
}
#[test]
fn container_nested_option_in_option() {
let rendered = emit("Option<Option<u32>>");
assert_eq!(rendered, "(number | null) | null");
}
#[test]
fn container_vec_of_options() {
let rendered = emit("Vec<Option<u32>>");
assert_eq!(rendered, "(number | null)[]");
}
#[test]
fn smart_pointer_box_is_transparent() {
assert_eq!(emit("Box<u32>"), emit("u32"));
assert_eq!(emit("Box<String>"), "string");
}
#[test]
fn smart_pointer_rc_arc_are_transparent() {
assert_eq!(emit("Rc<u32>"), "number");
assert_eq!(emit("Arc<String>"), "string");
}
#[test]
fn smart_pointer_cow_is_transparent() {
assert_eq!(emit("Cow<'a, str>"), "string");
assert_eq!(emit("Cow<'static, [u32]>"), "number[]");
}
#[test]
fn smart_pointer_pin_is_transparent() {
assert_eq!(emit("Pin<Box<u32>>"), "number");
}
#[test]
fn smart_pointer_nested_peels_all_the_way() {
assert_eq!(emit("Arc<Box<Vec<Option<u32>>>>"), "(number | null)[]");
}
#[test]
fn reference_amp_t_unwraps_to_owned() {
assert_eq!(emit("&u32"), "number");
assert_eq!(emit("&String"), "string");
}
#[test]
fn reference_amp_slice_renders_as_array() {
assert_eq!(emit("&[u32]"), "number[]");
assert_eq!(emit("&[String]"), "string[]");
}
#[test]
fn reference_array_renders_as_array() {
assert_eq!(emit("[u8; 32]"), "number[]");
}
#[test]
fn refcell_is_rejected() {
match emit_err("RefCell<u32>") {
EmitError::UnsupportedShape { reason, .. } => {
assert!(reason.contains("RefCell"), "reason was: {reason}");
}
other => panic!("expected UnsupportedShape, got {other:?}"),
}
}
#[test]
fn mutex_is_rejected() {
match emit_err("Mutex<u32>") {
EmitError::UnsupportedShape { reason, .. } => {
assert!(reason.contains("Mutex"), "reason was: {reason}");
}
other => panic!("expected UnsupportedShape, got {other:?}"),
}
}
#[test]
fn rwlock_is_rejected() {
match emit_err("RwLock<u32>") {
EmitError::UnsupportedShape { reason, .. } => {
assert!(reason.contains("RwLock"), "reason was: {reason}");
}
other => panic!("expected UnsupportedShape, got {other:?}"),
}
}
#[test]
fn unknown_ident_falls_through_to_terminal() {
assert_eq!(emit("Workout"), "Workout");
}
#[test]
fn multi_segment_path_collapses_to_terminal_for_now() {
assert_eq!(emit("crate::models::Workout"), "Workout");
}
fn struct_item(src: &str) -> syn::ItemStruct {
syn::parse_str(src).unwrap_or_else(|err| panic!("failed to parse struct `{src}`: {err}"))
}
fn enum_item(src: &str) -> syn::ItemEnum {
syn::parse_str(src).unwrap_or_else(|err| panic!("failed to parse enum `{src}`: {err}"))
}
fn assert_fixture_matches(scenario: &str) {
let manifest = env!("CARGO_MANIFEST_DIR");
let rs_path = format!("{manifest}/tests/fixtures/{scenario}.rs");
let ts_path = format!("{manifest}/tests/fixtures/{scenario}.ts");
let rs = std::fs::read_to_string(&rs_path).unwrap_or_else(|e| panic!("read {rs_path}: {e}"));
let parsed: syn::File = syn::parse_str(&rs).unwrap_or_else(|e| panic!("parse {rs_path}: {e}"));
let item =
parsed.items.into_iter().next().unwrap_or_else(|| panic!("fixture {scenario} has no top-level item"));
let config = EmitConfig::default();
let actual = match &item {
syn::Item::Struct(s) => emit_struct(s, &config),
syn::Item::Enum(e) => emit_enum(e, &config),
_ => panic!("fixture {scenario} top-level item is not a struct or enum"),
}
.unwrap_or_else(|e| panic!("emit failed for {scenario}: {e}"));
if std::env::var("UPDATE_TS_FIXTURES").is_ok() {
let canonical = format!("{}\n", actual.trim_end());
std::fs::write(&ts_path, &canonical).unwrap_or_else(|e| panic!("write {ts_path}: {e}"));
return;
}
let expected = std::fs::read_to_string(&ts_path).unwrap_or_default();
assert_eq!(
actual.trim(),
expected.trim(),
"fixture {scenario} mismatch (run with UPDATE_TS_FIXTURES=1 to refresh)"
);
}
#[test]
fn struct_named_fields_emit_export_type() {
assert_fixture_matches("struct_named_fields_emit_export_type");
}
#[test]
fn struct_with_all_primitive_field_types() {
assert_fixture_matches("struct_with_all_primitive_field_types");
}
#[test]
fn struct_field_ref_str() {
assert_fixture_matches("struct_field_ref_str");
}
#[test]
fn struct_field_containers() {
assert_fixture_matches("struct_field_containers");
}
#[test]
fn struct_field_smart_pointer_box_transparent() {
assert_fixture_matches("struct_field_smart_pointer_box_transparent");
}
#[test]
fn struct_field_unknown_ident_falls_through() {
assert_fixture_matches("struct_field_unknown_ident_falls_through");
}
#[test]
fn struct_empty_named_fields() {
assert_fixture_matches("struct_empty_named_fields");
}
#[test]
fn struct_tuple_is_rejected() {
let config = EmitConfig::default();
let item = struct_item("pub struct NewType(pub u32);");
match emit_struct(&item, &config).expect_err("tuple struct should fail") {
EmitError::UnsupportedShape { reason, .. } => {
assert!(reason.contains("tuple"), "reason was: {reason}");
}
other => panic!("expected UnsupportedShape, got {other:?}"),
}
}
#[test]
fn struct_unit_is_rejected() {
let config = EmitConfig::default();
let item = struct_item("pub struct Marker;");
match emit_struct(&item, &config).expect_err("unit struct should fail") {
EmitError::UnsupportedShape { reason, .. } => {
assert!(reason.contains("unit"), "reason was: {reason}");
}
other => panic!("expected UnsupportedShape, got {other:?}"),
}
}
#[test]
fn struct_error_propagates_from_field_emission() {
let config = EmitConfig::default();
let item = struct_item(
"pub struct Bad {
pub locked: std::sync::Mutex<u32>,
}",
);
let err = emit_struct(&item, &config).expect_err("Mutex field should fail");
assert!(matches!(err, EmitError::UnsupportedShape { .. }));
}
#[test]
fn enum_c_style_emits_string_literal_union() {
assert_fixture_matches("enum_c_style_emits_string_literal_union");
}
#[test]
fn enum_c_style_quote_style_single_default() {
let config = EmitConfig::default();
assert_eq!(config.quote_style, QuoteStyle::Single);
let item = enum_item(
"#[serde(rename_all = \"lowercase\")]
pub enum Letter {
A,
B,
}",
);
let ts = emit_enum(&item, &config).expect("emit ok");
assert_eq!(ts, "export type Letter = 'a' | 'b';");
}
#[test]
fn enum_c_style_quote_style_double() {
let config = EmitConfig { quote_style: QuoteStyle::Double, ..EmitConfig::default() };
let item = enum_item(
"#[serde(rename_all = \"lowercase\")]
pub enum Letter {
A,
B,
}",
);
let ts = emit_enum(&item, &config).expect("emit ok");
assert_eq!(ts, "export type Letter = \"a\" | \"b\";");
}
#[test]
fn enum_tuple_zero_arg_variant_respects_quote_style() {
let single = EmitConfig::default();
let item = enum_item(
"pub enum E {
Foo(),
}",
);
let ts = emit_enum(&item, &single).expect("emit ok");
assert_eq!(ts, "export type E = 'Foo';");
let double = EmitConfig { quote_style: QuoteStyle::Double, ..EmitConfig::default() };
let ts = emit_enum(&item, &double).expect("emit ok");
assert_eq!(ts, "export type E = \"Foo\";");
}
#[test]
fn enum_single_variant_c_style() {
assert_fixture_matches("enum_single_variant_c_style");
}
#[test]
fn enum_empty_emits_never() {
assert_fixture_matches("enum_empty_emits_never");
}
#[test]
fn enum_tuple_variant_externally_tagged() {
assert_fixture_matches("enum_tuple_variant_externally_tagged");
}
#[test]
fn enum_struct_variant_externally_tagged() {
assert_fixture_matches("enum_struct_variant_externally_tagged");
}
#[test]
fn enum_tuple_variant_with_primitive_payload() {
assert_fixture_matches("enum_tuple_variant_with_primitive_payload");
}
#[test]
fn enum_multi_field_tuple_variant_is_rejected() {
let config = EmitConfig::default();
let item = enum_item(
"pub enum Bad {
Two(u32, u32),
}",
);
let err = emit_enum(&item, &config).expect_err("multi-tuple variant should fail");
match err {
EmitError::UnsupportedShape { reason, .. } => {
assert!(reason.contains("tuple"), "reason was: {reason}");
}
other => panic!("expected UnsupportedShape, got {other:?}"),
}
}
#[test]
fn enum_error_propagates_from_variant_emission() {
let config = EmitConfig::default();
let item = enum_item(
"pub enum Bad {
Locked(Mutex<u32>),
}",
);
let err = emit_enum(&item, &config).expect_err("Mutex variant payload should fail");
assert!(matches!(err, EmitError::UnsupportedShape { .. }));
}
#[test]
fn struct_rename_all_camel_case() {
assert_fixture_matches("struct_rename_all_camel_case");
}
#[test]
fn struct_field_rename_wins_over_container() {
assert_fixture_matches("struct_field_rename_wins_over_container");
}
#[test]
fn struct_field_serde_skip_drops_field() {
assert_fixture_matches("struct_field_serde_skip_drops_field");
}
#[test]
fn struct_field_serde_default_optional() {
assert_fixture_matches("struct_field_serde_default_optional");
}
#[test]
fn struct_field_rename_with_hyphen_quotes_key() {
assert_fixture_matches("struct_field_rename_with_hyphen_quotes_key");
}
#[test]
fn enum_rename_all_snake_case() {
assert_fixture_matches("enum_rename_all_snake_case");
}
#[test]
fn enum_variant_rename_wins_over_container() {
assert_fixture_matches("enum_variant_rename_wins_over_container");
}
#[test]
fn enum_rename_all_spares_variant_fields() {
assert_fixture_matches("enum_rename_all_spares_variant_fields");
}
#[test]
fn enum_rename_all_fields() {
assert_fixture_matches("enum_rename_all_fields");
}
#[test]
fn enum_variant_rename_all_wins_over_container() {
assert_fixture_matches("enum_variant_rename_all_wins_over_container");
}
#[test]
fn enum_variant_rename_all_does_not_touch_the_variant_key() {
let config = EmitConfig::default();
let item = enum_item(
r#"
#[serde(rename_all = "camelCase")]
pub enum Event {
#[serde(rename_all = "UPPERCASE")]
ToolCall { prompt_template: String },
}
"#,
);
let ts = emit_enum(&item, &config).expect("emit ok");
assert_eq!(ts, "export type Event = { toolCall: { PROMPT_TEMPLATE: string } };");
}
#[test]
fn config_case_default_does_not_reach_variant_fields() {
let config = EmitConfig { case_default: Some(crate::types::RenameAll::CamelCase), ..Default::default() };
let item = enum_item(
"pub enum Event {
ToolCall { prompt_template: String },
}",
);
let ts = emit_enum(&item, &config).expect("emit ok");
assert_eq!(ts, "export type Event = { toolCall: { prompt_template: string } };");
}
#[test]
fn enum_field_rename_wins_over_every_rename_all() {
let config = EmitConfig::default();
let item = enum_item(
r#"
#[serde(rename_all_fields = "camelCase")]
pub enum Event {
#[serde(rename_all = "UPPERCASE")]
ToolCall {
#[serde(rename = "tmpl")]
prompt_template: String,
},
}
"#,
);
let ts = emit_enum(&item, &config).expect("emit ok");
assert!(ts.contains("tmpl: string"), "ts was: {ts}");
}
#[test]
fn struct_rename_all_fields_is_inert() {
let config = EmitConfig::default();
let item = struct_item(
r#"
#[serde(rename_all_fields = "camelCase")]
pub struct Foo {
pub prompt_template: String,
}
"#,
);
let ts = emit_struct(&item, &config).expect("emit ok");
assert!(ts.contains("prompt_template: string"), "ts was: {ts}");
}
#[test]
fn enum_rename_all_fields_rejects_unknown_mode() {
let config = EmitConfig::default();
let item = enum_item(
r#"
#[serde(rename_all_fields = "Train-Case")]
pub enum Event {
ToolCall { prompt_template: String },
}
"#,
);
match emit_enum(&item, &config).expect_err("unknown mode should fail") {
EmitError::UnsupportedSerdeAttr { attr, .. } => {
assert!(attr.contains("rename_all_fields"), "attr was: {attr}");
assert!(attr.contains("Train-Case"), "attr was: {attr}");
}
other => panic!("expected UnsupportedSerdeAttr, got {other:?}"),
}
}
#[test]
fn struct_field_flatten_intersection() {
assert_fixture_matches("struct_field_flatten_intersection");
}
#[test]
fn struct_field_flatten_only() {
assert_fixture_matches("struct_field_flatten_only");
}
#[test]
fn struct_field_flatten_catch_all_map() {
assert_fixture_matches("struct_field_flatten_catch_all_map");
}
#[test]
fn enum_struct_variant_flatten() {
assert_fixture_matches("enum_struct_variant_flatten");
}
#[test]
fn struct_field_flatten_peels_smart_pointers() {
let config = EmitConfig::default();
let item = struct_item(
"pub struct Step {
#[serde(flatten)]
pub meta: Box<StepMeta>,
pub program: String,
}",
);
let ts = emit_struct(&item, &config).expect("boxed flatten should emit");
assert!(ts.starts_with("export type Step = StepMeta & {"), "ts was: {ts}");
}
#[test]
fn struct_field_flatten_respects_rename_all_on_siblings() {
let config = EmitConfig::default();
let item = struct_item(
r#"
#[serde(rename_all = "camelCase")]
pub struct Step {
#[serde(flatten)]
pub meta: StepMeta,
pub program_name: String,
}
"#,
);
let ts = emit_struct(&item, &config).expect("emit ok");
assert!(ts.contains("StepMeta & {"), "ts was: {ts}");
assert!(ts.contains("programName: string"), "ts was: {ts}");
}
fn assert_flatten_rejected(field_ty: &str, needle: &str) {
let config = EmitConfig::default();
let item = struct_item(&format!(
"pub struct Holder {{
#[serde(flatten)]
pub inner: {field_ty},
pub tail: u32,
}}"
));
let Err(err) = emit_struct(&item, &config) else {
panic!("flatten of `{field_ty}` should have been rejected");
};
match err {
EmitError::UnsupportedShape { reason, .. } => {
assert!(reason.contains(needle), "flatten of `{field_ty}` — reason was: {reason}");
}
other => panic!("expected UnsupportedShape for `{field_ty}`, got {other:?}"),
}
}
#[test]
fn struct_field_flatten_rejects_option() {
assert_flatten_rejected("Option<StepMeta>", "absent-or-present");
assert_flatten_rejected("Box<Option<StepMeta>>", "absent-or-present");
}
#[test]
fn struct_field_flatten_rejects_non_object_renderings() {
assert_flatten_rejected("String", "renders as `string`");
assert_flatten_rejected("u32", "renders as `number`");
assert_flatten_rejected("Vec<StepMeta>", "renders as `StepMeta[]`");
assert_flatten_rejected("serde_json::Value", "renders as `unknown`");
}
#[test]
fn struct_field_flatten_rejects_default_combination() {
let config = EmitConfig::default();
let item = struct_item(
"pub struct Holder {
#[serde(flatten, default)]
pub inner: StepMeta,
}",
);
match emit_struct(&item, &config).expect_err("flatten + default should be rejected") {
EmitError::UnsupportedShape { reason, .. } => {
assert!(reason.contains("flatten, default"), "reason was: {reason}");
}
other => panic!("expected UnsupportedShape, got {other:?}"),
}
}
#[test]
fn struct_field_flatten_and_skip_leave_only_flatten() {
let config = EmitConfig::default();
let item = struct_item(
"pub struct Holder {
#[serde(flatten)]
pub inner: StepMeta,
#[serde(skip)]
pub cached: u32,
}",
);
let ts = emit_struct(&item, &config).expect("emit ok");
assert_eq!(ts, "export type Holder = StepMeta;");
}
#[test]
fn struct_rejects_split_rename_on_field() {
let config = EmitConfig::default();
let item = struct_item(
r#"pub struct Foo {
#[serde(rename(serialize = "wireName", deserialize = "WIRE_NAME"))]
pub a: u32,
}"#,
);
let err = emit_struct(&item, &config).expect_err("split-rename should fail");
match err {
EmitError::UnsupportedSerdeAttr { attr, .. } => {
assert!(attr.contains("split-rename"), "attr was: {attr}");
}
other => panic!("expected UnsupportedSerdeAttr, got {other:?}"),
}
}
#[test]
fn enum_rejects_tag_attr_on_container() {
let config = EmitConfig::default();
let item = enum_item(
r#"
#[serde(tag = "type")]
pub enum Msg {
Click,
Hover,
}
"#,
);
let err = emit_enum(&item, &config).expect_err("tag-attr should fail");
match err {
EmitError::UnsupportedSerdeAttr { attr, .. } => {
assert!(attr.contains("tag"), "attr was: {attr}");
}
other => panic!("expected UnsupportedSerdeAttr, got {other:?}"),
}
}
#[test]
fn config_case_default_applies_when_container_has_no_rename_all() {
let config = EmitConfig { case_default: Some(crate::types::RenameAll::CamelCase), ..Default::default() };
let item = struct_item(
"pub struct Foo {
pub user_name: String,
pub age_years: u32,
}",
);
let ts = emit_struct(&item, &config).unwrap();
assert!(ts.contains("userName: string"), "ts was: {ts}");
assert!(ts.contains("ageYears: number"), "ts was: {ts}");
}
#[test]
fn container_rename_all_wins_over_config_case_default() {
let config = EmitConfig { case_default: Some(crate::types::RenameAll::CamelCase), ..Default::default() };
let item = struct_item(
r#"
#[serde(rename_all = "snake_case")]
pub struct Foo {
pub user_name: String,
}
"#,
);
let ts = emit_struct(&item, &config).unwrap();
assert!(ts.contains("user_name: string"), "ts was: {ts}");
assert!(!ts.contains("userName"), "ts was: {ts}");
}
}