use super::enums;
use super::types::{opaque_instance_method_is_dropped, opaque_static_method_is_dropped};
use crate::codegen::naming::{to_node_name, wire_variant_value};
use crate::codegen::shared::{binding_fields, substitute_excluded_types};
use crate::core::config::NodeCapsuleTypeConfig;
use crate::core::hash::{self, CommentStyle};
use crate::core::ir::{ApiSurface, EnumDef, EnumVariant, FunctionDef, ParamDef, TypeDef, TypeRef};
use std::collections::HashMap;
#[allow(
clippy::too_many_arguments,
reason = "independent codegen inputs with no natural grouping"
)]
pub(super) fn gen_dts(
api: &ApiSurface,
prefix: &str,
exclude_functions: &ahash::AHashSet<String>,
trait_bridges: &[crate::core::config::TraitBridgeConfig],
capsule_types: &HashMap<String, NodeCapsuleTypeConfig>,
streaming_item_types: &ahash::AHashMap<String, String>,
default_types: &ahash::AHashSet<String>,
adapter_bodies: &crate::adapters::AdapterBodies,
) -> String {
let header = hash::header(CommentStyle::DoubleSlash);
let mut lines: Vec<String> = header.lines().map(|l| l.to_string()).collect();
lines.push("/* eslint-disable */".to_string());
if !capsule_types.is_empty() {
let mut by_module: std::collections::BTreeMap<&str, Vec<&str>> = std::collections::BTreeMap::new();
for cfg in capsule_types.values() {
by_module
.entry(cfg.from_module.as_str())
.or_default()
.push(cfg.type_name.as_str());
}
for (module, mut names) in by_module {
names.sort_unstable();
lines.push(format!("import type {{ {} }} from \"{module}\";", names.join(", ")));
}
}
lines.push(String::new());
lines.push(
"export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };"
.to_string(),
);
let mut opaque_types: Vec<&TypeDef> = api
.types
.iter()
.filter(|t| t.is_opaque && !t.is_trait && !capsule_types.contains_key(&t.name))
.collect();
opaque_types.sort_by(|a, b| a.name.cmp(&b.name));
let opaque_type_names: ahash::AHashSet<String> = opaque_types.iter().map(|t| t.name.clone()).collect();
let capsule_type_names: ahash::AHashSet<String> = capsule_types.keys().cloned().collect();
let mut plain_types: Vec<&TypeDef> = api.types.iter().filter(|t| !t.is_opaque && !t.is_trait).collect();
plain_types.sort_by(|a, b| a.name.cmp(&b.name));
let mut visitor_traits: Vec<&TypeDef> = api.types.iter().filter(|t| t.is_trait).collect();
visitor_traits.sort_by(|a, b| a.name.cmp(&b.name));
let mut sorted_enums: Vec<&EnumDef> = api.enums.iter().collect();
sorted_enums.sort_by(|a, b| a.name.cmp(&b.name));
let mut sorted_fns: Vec<&FunctionDef> = api
.functions
.iter()
.filter(|f| {
if exclude_functions.contains(&f.name) {
return false;
}
if f.sanitized && crate::backends::napi::trait_bridge::find_bridge_param(f, trait_bridges).is_none() {
return false;
}
true
})
.collect();
sorted_fns.sort_by(|a, b| a.name.cmp(&b.name));
let mut trait_bridge_fns: Vec<(String, String, String)> = Vec::new();
for bridge in trait_bridges {
if let Some(register) = &bridge.register_fn {
let js_name = crate::codegen::naming::to_node_name(register);
trait_bridge_fns.push((js_name, format!("impl: {}", bridge.trait_name), "void".to_string()));
}
if let Some(unregister) = &bridge.unregister_fn {
let js_name = crate::codegen::naming::to_node_name(unregister);
trait_bridge_fns.push((js_name, "name: string".to_string(), "void".to_string()));
}
if let Some(clear) = &bridge.clear_fn {
let js_name = crate::codegen::naming::to_node_name(clear);
trait_bridge_fns.push((js_name, String::new(), "void".to_string()));
}
}
trait_bridge_fns.sort_by(|a, b| a.0.cmp(&b.0));
let mut service_entrypoint_fns: Vec<(String, String, String)> = Vec::new();
for service in &api.services {
for entrypoint in &service.entrypoints {
let bridge_name = to_node_name(&format!("{}_{}", service.name.to_lowercase(), entrypoint.method));
let registrations_param = "registrations: Array<[string, any[], (...args: any[]) => any]>".to_string();
let return_type = if entrypoint.is_async {
"Promise<void>".to_string()
} else {
"void".to_string()
};
service_entrypoint_fns.push((bridge_name, registrations_param, return_type));
}
}
service_entrypoint_fns.sort_by(|a, b| a.0.cmp(&b.0));
enum Decl<'a> {
Class(&'a TypeDef),
Interface(&'a TypeDef),
VisitorInterface(&'a TypeDef),
Enum(&'a EnumDef),
Function(&'a FunctionDef),
TraitBridgeFunction {
name: String,
params: String,
return_type: String,
},
ServiceEntrypoint {
name: String,
params: String,
return_type: String,
},
}
let mut all_decls: Vec<(String, Decl<'_>)> = Vec::new();
for t in &opaque_types {
all_decls.push((format!("{prefix}{}", t.name), Decl::Class(t)));
}
for t in &plain_types {
all_decls.push((format!("{prefix}{}", t.name), Decl::Interface(t)));
}
for t in &visitor_traits {
all_decls.push((format!("{prefix}{}", t.name), Decl::VisitorInterface(t)));
}
for e in &sorted_enums {
all_decls.push((format!("{prefix}{}", e.name), Decl::Enum(e)));
}
for f in &sorted_fns {
all_decls.push((to_node_name(&f.name), Decl::Function(f)));
}
for (name, params, ret) in trait_bridge_fns {
all_decls.push((
name.clone(),
Decl::TraitBridgeFunction {
name,
params,
return_type: ret,
},
));
}
for (name, params, ret) in service_entrypoint_fns {
all_decls.push((
name.clone(),
Decl::ServiceEntrypoint {
name,
params,
return_type: ret,
},
));
}
all_decls.sort_by_key(|a| a.0.to_lowercase());
all_decls.dedup_by(|a, b| a.0 == b.0);
let no_prefix: &str = "";
let _ = prefix;
for (_, decl) in &all_decls {
lines.push(String::new());
match decl {
Decl::Class(typ) => {
lines.extend(format_jsdoc(&typ.doc, ""));
lines.push(format!("export declare class {} {{", typ.name));
let declared_methods = typ.methods.iter().filter(|method| {
if method.receiver.is_some() {
!opaque_instance_method_is_dropped(
method,
&typ.name,
adapter_bodies,
&capsule_type_names,
&opaque_type_names,
)
} else {
!opaque_static_method_is_dropped(method, &typ.name, adapter_bodies)
}
});
for method in declared_methods {
let js_name = to_node_name(&method.name);
let params = dts_params(&method.params, no_prefix, default_types);
let streaming_key = format!("{}.{}", typ.name, method.name);
let ret = if let Some(item_type) = streaming_item_types.get(&streaming_key) {
format!("Promise<AsyncGenerator<{item_type}, void, undefined>>")
} else {
dts_return_type_capsule(
&method.return_type,
method.error_type.is_some(),
method.is_async,
no_prefix,
capsule_types,
)
};
lines.extend(format_jsdoc(&method.doc, " "));
if method.is_static {
lines.push(format!(" static {js_name}({params}): {ret}"));
} else {
lines.push(format!(" {js_name}({params}): {ret}"));
}
}
lines.push("}".to_string());
}
Decl::Interface(typ) => {
lines.extend(format_jsdoc(&typ.doc, ""));
lines.push(format!("export interface {} {{", typ.name));
for field in binding_fields(&typ.fields) {
let js_name = to_node_name(&field.name);
let ts_ty = dts_type(&field.ty, no_prefix);
lines.extend(format_jsdoc(&field.doc, " "));
let is_optional = matches!(field.ty, TypeRef::Optional(_)) || field.optional || typ.has_default;
if is_optional {
lines.push(format!(" readonly {js_name}?: {ts_ty}"));
} else {
lines.push(format!(" readonly {js_name}: {ts_ty}"));
}
}
lines.push("}".to_string());
}
Decl::VisitorInterface(typ) => {
let excluded: std::collections::HashSet<&str> = api
.excluded_type_paths
.keys()
.map(String::as_str)
.chain(api.types.iter().filter(|t| t.binding_excluded).map(|t| t.name.as_str()))
.collect();
lines.extend(format_jsdoc(&typ.doc, ""));
lines.push(format!("export interface {} {{", typ.name));
if trait_bridge_requires_plugin_name(typ, trait_bridges) {
lines.push(" name(): string".to_string());
lines.push(" version?(): string".to_string());
lines.push(" initialize?(): void".to_string());
lines.push(" shutdown?(): void".to_string());
}
for method in &typ.methods {
let js_name = to_node_name(&method.name);
if trait_bridge_requires_plugin_name(typ, trait_bridges) && method.name == "name" {
continue;
}
let sub_params: Vec<ParamDef> = method
.params
.iter()
.map(|p| ParamDef {
ty: substitute_excluded_types(&p.ty, &excluded),
..p.clone()
})
.collect();
let params = dts_params(&sub_params, no_prefix, default_types);
let ret = trait_bridge_dts_return_type(
&substitute_excluded_types(&method.return_type, &excluded),
method.is_async,
no_prefix,
);
lines.extend(format_jsdoc(&method.doc, " "));
let optional_marker = if method.has_default_impl { "?" } else { "" };
lines.push(format!(" {js_name}{optional_marker}({params}): {ret}"));
}
lines.push("}".to_string());
}
Decl::Enum(e) => {
let is_data_enum = e.serde_tag.is_some();
lines.extend(format_jsdoc(&e.doc, ""));
if is_data_enum && e.serde_content.is_some() {
let tag_field = e.serde_tag.as_deref().unwrap_or("type");
let mut member_lines: Vec<String> = Vec::new();
for variant in &e.variants {
let tag_value = wire_variant_value(
&variant.name,
variant.serde_rename.as_deref(),
e.serde_rename_all.as_deref(),
);
let mut obj_fields: Vec<String> = vec![format!("{tag_field}: '{tag_value}'")];
for field in &variant.fields {
let js_name = if crate::codegen::conversions::is_tuple_variant(&variant.fields) {
e.serde_content
.as_deref()
.expect("adjacent content is present")
.to_string()
} else {
to_node_name(&field.name)
};
let ts_ty = dts_type(&field.ty, no_prefix);
if matches!(field.ty, TypeRef::Optional(_)) {
obj_fields.push(format!("{js_name}?: {ts_ty}"));
} else {
obj_fields.push(format!("{js_name}: {ts_ty}"));
}
}
member_lines.push(format!(" | {{ {} }}", obj_fields.join("; ")));
}
lines.push(format!("export type {} =", e.name));
lines.extend(member_lines);
lines.push(format!("export declare const {}: {{", e.name));
for variant in &e.variants {
if let Some(field) = variant.fields.first() {
lines.push(format!(
" {}({}: {}): {};",
variant.name,
e.serde_content.as_deref().expect("adjacent content is present"),
dts_type(&field.ty, no_prefix),
e.name
));
} else {
lines.push(format!(" readonly {}: {};", variant.name, e.name));
}
}
lines.push("};".to_string());
} else if is_data_enum && e.variants.iter().any(|v| !v.fields.is_empty()) {
let tag_field = e.serde_tag.as_deref().unwrap_or("type");
let mut member_lines: Vec<String> = Vec::new();
for variant in &e.variants {
let tag_value = wire_variant_value(
&variant.name,
variant.serde_rename.as_deref(),
e.serde_rename_all.as_deref(),
);
let mut obj_fields: Vec<String> = vec![format!("{tag_field}: '{tag_value}'")];
for field in &variant.fields {
let js_name = enums::tagged_enum_field_js_name(variant, field);
let ts_ty = dts_type(&field.ty, no_prefix);
if matches!(field.ty, TypeRef::Optional(_)) {
obj_fields.push(format!("{js_name}?: {ts_ty}"));
} else {
obj_fields.push(format!("{js_name}: {ts_ty}"));
}
}
member_lines.push(format!(" | {{ {} }}", obj_fields.join("; ")));
}
lines.push(format!("export type {} =", e.name));
lines.extend(member_lines);
} else if is_data_enum {
let tag_field = e.serde_tag.as_deref().unwrap_or("type");
let tag_values: Vec<String> = e
.variants
.iter()
.map(|v| {
format!(
"'{}'",
wire_variant_value(&v.name, v.serde_rename.as_deref(), e.serde_rename_all.as_deref())
)
})
.collect();
lines.push(format!(
"export type {} = {{ {tag_field}: {} }};",
e.name,
tag_values.join(" | ")
));
} else if e.serde_untagged && e.variants.iter().any(|v| !v.fields.is_empty()) {
lines.push(format!("export type {} =", e.name));
for variant in &e.variants {
lines.push(format!(" | {}", untagged_variant_dts_type(variant, no_prefix)));
}
} else {
lines.push(format!("export declare enum {} {{", e.name));
for variant in &e.variants {
let value = wire_variant_value(
&variant.name,
variant.serde_rename.as_deref(),
e.serde_rename_all.as_deref(),
);
lines.extend(format_jsdoc(&variant.doc, " "));
lines.push(format!(" {} = \"{}\",", variant.name, value));
}
lines.push("}".to_string());
}
}
Decl::Function(func) => {
let js_name = to_node_name(&func.name);
let params = dts_params(&func.params, no_prefix, default_types);
let ret = dts_return_type_capsule(
&func.return_type,
func.error_type.is_some(),
func.is_async,
no_prefix,
capsule_types,
);
lines.extend(format_jsdoc(&func.doc, ""));
lines.push(format!("export declare function {js_name}({params}): {ret};"));
}
Decl::TraitBridgeFunction {
name,
params,
return_type,
} => {
lines.push(format!("export declare function {name}({params}): {return_type};"));
}
Decl::ServiceEntrypoint {
name,
params,
return_type,
} => {
lines.push(format!("export declare function {name}({params}): {return_type};"));
}
}
}
let mut sorted_streaming: Vec<(&String, &String)> = streaming_item_types.iter().collect();
sorted_streaming.sort_by_key(|(k, _)| k.as_str());
for (owner_method_key, item_type) in sorted_streaming {
let method_name = owner_method_key
.split('.')
.next_back()
.unwrap_or(owner_method_key.as_str());
let iter_class_name = method_name
.split('_')
.map(|part| {
let mut chars = part.chars();
match chars.next() {
None => String::new(),
Some(first) => first.to_uppercase().to_string() + chars.as_str(),
}
})
.collect::<String>()
+ "Iterator";
lines.push(String::new());
lines.push(format!("export declare class {iter_class_name} {{"));
lines.push(format!(
" next(value?: undefined): Promise<IteratorResult<{item_type}, void>>"
));
lines.push(format!(
" [Symbol.asyncIterator](): AsyncGenerator<{item_type}, void, undefined>"
));
lines.push("}".to_string());
}
let mut sorted_errors: Vec<_> = api.errors.iter().filter(|e| !e.methods.is_empty()).collect();
sorted_errors.sort_by_key(|e| e.name.as_str());
for error in sorted_errors {
let class_name = format!("{}Info", error.name);
lines.push(String::new());
lines.push(format!("export declare class {class_name} {{"));
lines.push(" code(): number".to_string());
for method in &error.methods {
let (js_name, ret_type): (&str, &str) = match method.name.as_str() {
"status_code" => ("statusCode", "number"),
"is_transient" => ("isTransient", "boolean"),
"error_type" => ("errorType", "string"),
_ => continue,
};
lines.push(format!(" {js_name}(): {ret_type}"));
}
lines.push("}".to_string());
}
lines.push(String::new());
lines.join("\n")
}
fn trait_bridge_requires_plugin_name(typ: &TypeDef, trait_bridges: &[crate::core::config::TraitBridgeConfig]) -> bool {
trait_bridges
.iter()
.any(|bridge| bridge.trait_name == typ.name && bridge.super_trait.as_deref().is_some())
}
fn trait_bridge_dts_return_type(return_type: &TypeRef, is_async: bool, prefix: &str) -> String {
let base = match return_type {
TypeRef::Unit => "void".to_string(),
other => dts_type(other, prefix),
};
if is_async { format!("Promise<{base}>") } else { base }
}
pub(super) fn format_jsdoc(doc: &str, indent: &str) -> Vec<String> {
let sanitized =
crate::codegen::doc_emission::sanitize_rust_idioms(doc, crate::codegen::doc_emission::DocTarget::TsDoc);
let doc = sanitized.trim();
if doc.is_empty() {
return vec![];
}
let sections = crate::codegen::doc_emission::parse_rustdoc_sections(doc);
let rendered = crate::codegen::doc_emission::render_jsdoc_sections(§ions);
let body = if rendered.trim().is_empty() {
doc.to_string()
} else {
rendered
};
let lines: Vec<&str> = body.lines().collect();
if lines.len() == 1 {
vec![format!("{indent}/** {} */", lines[0].trim())]
} else {
let mut out = Vec::with_capacity(lines.len() + 2);
out.push(format!("{indent}/**"));
for line in &lines {
let trimmed = line.trim_end();
if trimmed.is_empty() {
out.push(format!("{indent} *"));
} else {
out.push(format!("{indent} * {trimmed}"));
}
}
out.push(format!("{indent} */"));
out
}
}
pub(super) fn dts_type(ty: &TypeRef, prefix: &str) -> String {
match ty {
TypeRef::Primitive(p) => match p {
crate::core::ir::PrimitiveType::Bool => "boolean".to_string(),
crate::core::ir::PrimitiveType::U8
| crate::core::ir::PrimitiveType::U16
| crate::core::ir::PrimitiveType::U32
| crate::core::ir::PrimitiveType::I8
| crate::core::ir::PrimitiveType::I16
| crate::core::ir::PrimitiveType::I32
| crate::core::ir::PrimitiveType::F32
| crate::core::ir::PrimitiveType::F64 => "number".to_string(),
crate::core::ir::PrimitiveType::U64
| crate::core::ir::PrimitiveType::I64
| crate::core::ir::PrimitiveType::Usize
| crate::core::ir::PrimitiveType::Isize => "number".to_string(),
},
TypeRef::String | TypeRef::Char | TypeRef::Path => "string".to_string(),
TypeRef::Bytes => "Uint8Array".to_string(),
TypeRef::Json => "JsonValue".to_string(),
TypeRef::Duration => "number".to_string(),
TypeRef::Unit => "void".to_string(),
TypeRef::Optional(inner) => format!("{} | null", dts_type(inner, prefix)),
TypeRef::Vec(inner) => format!("Array<{}>", dts_type(inner, prefix)),
TypeRef::Map(k, v) => format!("Record<{}, {}>", dts_type(k, prefix), dts_type(v, prefix)),
TypeRef::Named(name) => format!("{prefix}{name}"),
}
}
fn untagged_variant_dts_type(variant: &EnumVariant, prefix: &str) -> String {
if variant.fields.is_empty() {
return "null".to_string();
}
if variant.is_tuple {
if variant.fields.len() == 1 {
return dts_type(&variant.fields[0].ty, prefix);
}
let elems: Vec<String> = variant.fields.iter().map(|f| dts_type(&f.ty, prefix)).collect();
return format!("[{}]", elems.join(", "));
}
let fields: Vec<String> = variant
.fields
.iter()
.map(|field| {
let js_name = to_node_name(&field.name);
let ts_ty = dts_type(&field.ty, prefix);
if matches!(field.ty, TypeRef::Optional(_)) {
format!("{js_name}?: {ts_ty}")
} else {
format!("{js_name}: {ts_ty}")
}
})
.collect();
format!("{{ {} }}", fields.join("; "))
}
pub(super) fn dts_params(params: &[ParamDef], prefix: &str, default_types: &ahash::AHashSet<String>) -> String {
dts_params_with_order(params, prefix, true, default_types)
}
fn dts_params_with_order(
params: &[ParamDef],
prefix: &str,
reorder_for_typescript: bool,
default_types: &ahash::AHashSet<String>,
) -> String {
if !reorder_for_typescript {
let has_required_after = required_after_optional(params, default_types);
return params
.iter()
.enumerate()
.map(|(idx, p)| dts_param(p, prefix, param_is_optional(p, default_types), !has_required_after[idx]))
.collect::<Vec<_>>()
.join(", ");
}
let mut required: Vec<&ParamDef> = Vec::new();
let mut optional: Vec<&ParamDef> = Vec::new();
for p in params {
if param_is_optional(p, default_types) {
optional.push(p);
} else {
required.push(p);
}
}
let ordered: Vec<&ParamDef> = if params
.iter()
.zip(required.iter().chain(optional.iter()))
.all(|(a, b)| std::ptr::eq(a as *const ParamDef, *b as *const ParamDef))
{
params.iter().collect()
} else {
required.into_iter().chain(optional).collect()
};
ordered
.iter()
.map(|p| dts_param(p, prefix, param_is_optional(p, default_types), true))
.collect::<Vec<_>>()
.join(", ")
}
fn dts_param(p: &ParamDef, prefix: &str, is_optional: bool, allow_question_optional: bool) -> String {
let js_name = to_node_name(&p.name);
let ts_ty = dts_type(&p.ty, prefix);
if is_optional && allow_question_optional {
format!("{js_name}?: {ts_ty} | undefined | null")
} else if is_optional {
format!("{js_name}: {ts_ty} | undefined | null")
} else {
format!("{js_name}: {ts_ty}")
}
}
fn param_is_optional(p: &ParamDef, default_types: &ahash::AHashSet<String>) -> bool {
p.optional
|| p.default.is_some()
|| p.typed_default.is_some()
|| matches!(&p.ty, TypeRef::Named(name) if default_types.contains(name.as_str()))
}
fn required_after_optional(params: &[ParamDef], default_types: &ahash::AHashSet<String>) -> Vec<bool> {
let mut seen_optional = false;
let mut result = vec![false; params.len()];
for (idx, param) in params.iter().enumerate() {
let is_optional = param_is_optional(param, default_types);
result[idx] = seen_optional && !is_optional;
seen_optional |= is_optional;
}
result
}
pub(super) fn dts_return_type_capsule(
ret: &TypeRef,
_has_error: bool,
is_async: bool,
prefix: &str,
capsule_types: &HashMap<String, NodeCapsuleTypeConfig>,
) -> String {
let base = match ret {
TypeRef::Unit => "void".to_string(),
TypeRef::Named(name) => {
if let Some(cfg) = capsule_types.get(name.as_str()) {
cfg.type_name.clone()
} else {
dts_type(ret, prefix)
}
}
other => dts_type(other, prefix),
};
if is_async { format!("Promise<{base}>") } else { base }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::ir::{EnumVariant, FieldDef, ParamDef, TypeDef, TypeRef};
#[test]
fn format_jsdoc_escapes_embedded_block_comment_closers() {
let lines = format_jsdoc("Supports literal `/** example */` syntax.", " ");
assert_eq!(lines, vec![" /** Supports literal `/** example * /` syntax. */"]);
}
fn make_param(name: &str, optional: bool) -> ParamDef {
ParamDef {
name: name.to_string(),
ty: TypeRef::String,
optional,
default: None,
sanitized: false,
typed_default: None,
is_ref: false,
is_mut: false,
newtype_wrapper: None,
original_type: None,
map_is_ahash: false,
map_key_is_cow: false,
vec_inner_is_ref: false,
map_is_btree: false,
core_wrapper: crate::core::ir::CoreWrapper::None,
}
}
#[test]
fn dts_params_reorders_required_after_optional() {
let params = vec![
make_param("ctx", false),
make_param("lang", true),
make_param("code", false),
];
let result = dts_params(¶ms, "Js", &ahash::AHashSet::new());
let ctx_pos = result.find("ctx:").expect("ctx not found");
let code_pos = result.find("code:").expect("code not found");
let lang_pos = result.find("lang?:").expect("lang? not found");
assert!(ctx_pos < lang_pos, "ctx should come before lang?: {result}");
assert!(code_pos < lang_pos, "code should come before lang?: {result}");
}
#[test]
fn dts_params_preserves_already_valid_order() {
let params = vec![
make_param("ctx", false),
make_param("code", false),
make_param("lang", true),
];
let result = dts_params(¶ms, "Js", &ahash::AHashSet::new());
assert_eq!(result, "ctx: string, code: string, lang?: string | undefined | null");
}
#[test]
fn dts_params_all_required_preserves_order() {
let params = vec![make_param("a", false), make_param("b", false), make_param("c", false)];
let result = dts_params(¶ms, "Js", &ahash::AHashSet::new());
assert_eq!(result, "a: string, b: string, c: string");
}
#[test]
fn dts_params_treats_defaulted_params_as_optional() {
let mut params = vec![make_param("path", false), make_param("config", false)];
params[1].default = Some("Default::default()".to_string());
let result = dts_params(¶ms, "Js", &ahash::AHashSet::new());
assert_eq!(
result, "path: string, config?: string | undefined | null",
"defaulted params must be optional in generated declarations"
);
}
#[test]
fn trait_bridge_dts_return_type_wraps_async_methods_in_promise() {
assert_eq!(
trait_bridge_dts_return_type(&TypeRef::Named("ExtractionResult".to_string()), true, ""),
"Promise<ExtractionResult>"
);
assert_eq!(trait_bridge_dts_return_type(&TypeRef::Unit, true, ""), "Promise<void>");
assert_eq!(
trait_bridge_dts_return_type(&TypeRef::Named("ExtractionResult".to_string()), false, ""),
"ExtractionResult"
);
}
#[test]
fn plugin_trait_bridge_requires_name_in_typescript_interface() {
let typ = TypeDef {
name: "DocumentExtractor".to_string(),
rust_path: String::new(),
original_rust_path: String::new(),
fields: Vec::new(),
methods: Vec::new(),
is_opaque: false,
is_clone: false,
is_copy: false,
doc: String::new(),
cfg: None,
is_trait: true,
has_default: false,
has_stripped_cfg_fields: false,
is_return_type: false,
serde_rename_all: None,
has_serde: false,
serde_container_default: false,
super_traits: Vec::new(),
binding_excluded: false,
binding_exclusion_reason: None,
is_variant_wrapper: false,
has_lifetime_params: false,
has_private_fields: false,
version: Default::default(),
};
let bridges = vec![crate::core::config::TraitBridgeConfig {
trait_name: "DocumentExtractor".to_string(),
super_trait: Some("Plugin".to_string()),
..Default::default()
}];
assert!(trait_bridge_requires_plugin_name(&typ, &bridges));
}
#[test]
fn adjacent_enum_dts_declares_runtime_namespace() {
let api = ApiSurface {
enums: vec![EnumDef {
name: "Action".to_string(),
serde_tag: Some("type".to_string()),
serde_content: Some("output".to_string()),
serde_rename_all: Some("snake_case".to_string()),
variants: vec![
EnumVariant {
name: "Skip".to_string(),
..Default::default()
},
EnumVariant {
name: "Custom".to_string(),
fields: vec![FieldDef {
name: "_0".to_string(),
ty: TypeRef::String,
..Default::default()
}],
..Default::default()
},
],
..Default::default()
}],
..Default::default()
};
let dts = gen_dts(
&api,
"",
&Default::default(),
&[],
&Default::default(),
&Default::default(),
&Default::default(),
&Default::default(),
);
assert!(dts.contains("| { type: 'custom'; output: string }"));
assert!(dts.contains("export declare const Action: {"));
assert!(dts.contains("readonly Skip: Action;"));
assert!(dts.contains("Custom(output: string): Action;"));
}
#[test]
fn internally_tagged_newtype_variants_declare_discriminated_union() {
let api = ApiSurface {
enums: vec![EnumDef {
name: "InternalNewtype".to_string(),
serde_tag: Some("role".to_string()),
serde_rename_all: Some("snake_case".to_string()),
variants: vec![
EnumVariant {
name: "System".to_string(),
fields: vec![FieldDef {
name: "_0".to_string(),
ty: TypeRef::Named("SystemMessage".to_string()),
..Default::default()
}],
..Default::default()
},
EnumVariant {
name: "User".to_string(),
fields: vec![FieldDef {
name: "_0".to_string(),
ty: TypeRef::Named("UserMessage".to_string()),
..Default::default()
}],
..Default::default()
},
],
..Default::default()
}],
..Default::default()
};
let dts = gen_dts(
&api,
"",
&Default::default(),
&[],
&Default::default(),
&Default::default(),
&Default::default(),
&Default::default(),
);
assert_eq!(
dts.lines()
.skip_while(|l| *l != "export type InternalNewtype =")
.take(3)
.collect::<Vec<_>>(),
vec![
"export type InternalNewtype =",
" | { role: 'system'; system: SystemMessage }",
" | { role: 'user'; user: UserMessage }",
],
"expected a discriminated union keyed by the variant-derived field name, got:\n{dts}"
);
assert!(
!dts.contains("0:"),
"must not emit the tuple field's synthetic `_0` name as a `0:` key:\n{dts}"
);
assert!(
!dts.contains("system?:") && !dts.contains("user?:"),
"a field belonging to only one variant must not be optional:\n{dts}"
);
}
#[test]
fn internally_tagged_struct_variants_declare_discriminated_union() {
let api = ApiSurface {
enums: vec![EnumDef {
name: "AuthConfig".to_string(),
serde_tag: Some("type".to_string()),
serde_rename_all: Some("snake_case".to_string()),
variants: vec![
EnumVariant {
name: "Basic".to_string(),
fields: vec![
FieldDef {
name: "username".to_string(),
ty: TypeRef::String,
..Default::default()
},
FieldDef {
name: "password".to_string(),
ty: TypeRef::String,
..Default::default()
},
],
..Default::default()
},
EnumVariant {
name: "Bearer".to_string(),
fields: vec![FieldDef {
name: "token".to_string(),
ty: TypeRef::String,
..Default::default()
}],
..Default::default()
},
],
..Default::default()
}],
..Default::default()
};
let dts = gen_dts(
&api,
"",
&Default::default(),
&[],
&Default::default(),
&Default::default(),
&Default::default(),
&Default::default(),
);
assert_eq!(
dts.lines()
.skip_while(|l| *l != "export type AuthConfig =")
.take(3)
.collect::<Vec<_>>(),
vec![
"export type AuthConfig =",
" | { type: 'basic'; username: string; password: string }",
" | { type: 'bearer'; token: string }",
],
"expected one discriminated-union member per variant with required fields, got:\n{dts}"
);
assert!(
!dts.contains("username?:") && !dts.contains("password?:") && !dts.contains("token?:"),
"a field belonging to only one variant must not be optional:\n{dts}"
);
assert!(
!dts.contains("export type AuthConfig = {"),
"must not emit a single flattened object type:\n{dts}"
);
}
#[test]
fn internally_tagged_all_unit_variants_declare_object_not_string_enum() {
let api = ApiSurface {
enums: vec![EnumDef {
name: "InternalAllUnit".to_string(),
serde_tag: Some("kind".to_string()),
variants: vec![
EnumVariant {
name: "A".to_string(),
..Default::default()
},
EnumVariant {
name: "B".to_string(),
..Default::default()
},
],
..Default::default()
}],
..Default::default()
};
let dts = gen_dts(
&api,
"",
&Default::default(),
&[],
&Default::default(),
&Default::default(),
&Default::default(),
&Default::default(),
);
assert!(
dts.contains("export type InternalAllUnit = { kind: 'A' | 'B' };"),
"expected an object type matching the napi glue struct, got:\n{dts}"
);
assert!(
!dts.contains("export declare enum InternalAllUnit"),
"must not emit a plain string enum for an internally-tagged enum:\n{dts}"
);
}
#[test]
fn untagged_enum_declares_bare_union_of_variant_shapes() {
let api = ApiSurface {
enums: vec![EnumDef {
name: "Untagged".to_string(),
serde_untagged: true,
variants: vec![
EnumVariant {
name: "Single".to_string(),
is_tuple: true,
fields: vec![FieldDef {
name: "_0".to_string(),
ty: TypeRef::String,
..Default::default()
}],
..Default::default()
},
EnumVariant {
name: "Pair".to_string(),
fields: vec![
FieldDef {
name: "x".to_string(),
ty: TypeRef::Primitive(crate::core::ir::PrimitiveType::I32),
..Default::default()
},
FieldDef {
name: "y".to_string(),
ty: TypeRef::Primitive(crate::core::ir::PrimitiveType::I32),
..Default::default()
},
],
..Default::default()
},
],
..Default::default()
}],
..Default::default()
};
let dts = gen_dts(
&api,
"",
&Default::default(),
&[],
&Default::default(),
&Default::default(),
&Default::default(),
&Default::default(),
);
assert!(
dts.contains("export type Untagged =\n | string\n | { x: number; y: number }"),
"expected a bare union of each variant's own shape, got:\n{dts}"
);
assert!(
!dts.contains("export declare enum Untagged"),
"must not emit a plain string enum for an untagged data enum:\n{dts}"
);
}
#[test]
fn gen_dts_includes_service_entrypoint_bridge_functions() {
use crate::core::ir::{EntrypointDef, EntrypointKind, MethodDef, ReceiverKind, ServiceDef};
let api = ApiSurface {
crate_name: "test".to_string(),
version: "0.1.0".to_string(),
types: vec![],
functions: vec![],
enums: vec![],
errors: vec![],
excluded_type_paths: Default::default(),
excluded_trait_names: Default::default(),
services: vec![ServiceDef {
name: "App".to_string(),
rust_path: "test::App".to_string(),
constructor: MethodDef {
name: "new".to_string(),
params: vec![],
return_type: TypeRef::Named("App".to_string()),
is_async: false,
is_static: false,
error_type: None,
receiver: Some(ReceiverKind::Owned),
cfg: None,
doc: String::new(),
sanitized: false,
trait_source: None,
returns_ref: false,
returns_cow: false,
return_newtype_wrapper: None,
has_default_impl: false,
binding_excluded: false,
binding_exclusion_reason: None,
version: Default::default(),
},
configurators: vec![],
registrations: vec![],
entrypoints: vec![EntrypointDef {
method: "into_router".to_string(),
kind: EntrypointKind::Finalize,
is_async: true,
params: vec![],
return_type: TypeRef::Unit,
error_type: None,
doc: String::new(),
}],
doc: String::new(),
cfg: None,
}],
handler_contracts: vec![],
unsupported_public_items: vec![],
};
let dts = gen_dts(
&api,
"",
&ahash::AHashSet::new(),
&[],
&Default::default(),
&Default::default(),
&Default::default(),
&Default::default(),
);
assert!(
dts.contains("export declare function appIntoRouter"),
"dts should declare appIntoRouter bridge function for App.into_router"
);
assert!(
dts.contains("registrations: Array<[string, any[], (...args: any[]) => any]>"),
"service entrypoint should have registrations parameter"
);
assert!(
dts.contains("Promise<void>"),
"async into_router entrypoint should return Promise<void>"
);
}
#[test]
fn opaque_by_value_param_without_adapter_is_not_declared_in_dts() {
use crate::core::ir::{MethodDef, ReceiverKind};
let api = ApiSurface {
types: vec![
TypeDef {
name: "Worker".to_string(),
is_opaque: true,
methods: vec![MethodDef {
name: "process".to_string(),
receiver: Some(ReceiverKind::Ref),
cfg: None,
params: vec![ParamDef {
name: "handle".to_string(),
ty: TypeRef::Named("Handle".to_string()),
is_ref: false,
..Default::default()
}],
return_type: TypeRef::Unit,
..Default::default()
}],
..Default::default()
},
TypeDef {
name: "Handle".to_string(),
is_opaque: true,
..Default::default()
},
],
..Default::default()
};
let dts = gen_dts(
&api,
"",
&Default::default(),
&[],
&Default::default(),
&Default::default(),
&Default::default(),
&Default::default(),
);
assert!(
!dts.contains("process("),
"no #[napi] wrapper exists for an opaque-by-value param with no adapter override: {dts}"
);
}
#[test]
fn sanitized_static_method_without_adapter_is_not_declared_in_dts() {
use crate::core::ir::MethodDef;
let api = ApiSurface {
types: vec![TypeDef {
name: "Config".to_string(),
is_opaque: true,
methods: vec![MethodDef {
name: "fromRaw".to_string(),
receiver: None,
cfg: None,
is_static: true,
sanitized: true,
return_type: TypeRef::Named("Config".to_string()),
..Default::default()
}],
..Default::default()
}],
..Default::default()
};
let dts = gen_dts(
&api,
"",
&Default::default(),
&[],
&Default::default(),
&Default::default(),
&Default::default(),
&Default::default(),
);
assert!(
!dts.contains("static fromRaw"),
"gen_static_method also drops a sanitized static method with no adapter override: {dts}"
);
}
#[test]
fn delegatable_methods_are_still_declared_in_dts() {
use crate::core::ir::{MethodDef, ReceiverKind};
let api = ApiSurface {
types: vec![TypeDef {
name: "Worker".to_string(),
is_opaque: true,
methods: vec![
MethodDef {
name: "run".to_string(),
receiver: Some(ReceiverKind::Ref),
cfg: None,
return_type: TypeRef::Unit,
..Default::default()
},
MethodDef {
name: "create".to_string(),
receiver: None,
cfg: None,
is_static: true,
return_type: TypeRef::Named("Worker".to_string()),
..Default::default()
},
],
..Default::default()
}],
..Default::default()
};
let dts = gen_dts(
&api,
"",
&Default::default(),
&[],
&Default::default(),
&Default::default(),
&Default::default(),
&Default::default(),
);
assert!(
dts.contains("run("),
"delegatable instance method must still be declared: {dts}"
);
assert!(
dts.contains("static create("),
"non-sanitized static method must still be declared: {dts}"
);
}
}