use crate::codegen::naming::{to_node_name, wire_variant_value};
use crate::codegen::shared::binding_fields;
use crate::core::config::NodeCapsuleTypeConfig;
use crate::core::hash::{self, CommentStyle};
use crate::core::ir::{ApiSurface, EnumDef, FunctionDef, ParamDef, TypeDef, TypeRef};
use std::collections::HashMap;
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>,
) -> 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 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));
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,
},
}
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,
},
));
}
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));
for method in &typ.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) => {
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());
}
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 params = dts_params(&method.params, no_prefix, default_types);
let ret = trait_bridge_dts_return_type(&method.return_type, method.is_async);
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() && e.variants.iter().any(|v| !v.fields.is_empty());
lines.extend(format_jsdoc(&e.doc, ""));
if is_data_enum {
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 = 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);
} 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};"));
}
}
}
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} {{"));
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) -> String {
let base = if matches!(return_type, TypeRef::Unit) {
"void"
} else {
"string"
};
if is_async {
format!("Promise<{base}>")
} else {
base.to_string()
}
}
pub(super) fn format_jsdoc(doc: &str, indent: &str) -> Vec<String> {
let doc = doc.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}"),
}
}
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::{ParamDef, TypeDef, TypeRef};
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<string>"
);
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),
"string"
);
}
#[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,
super_traits: Vec::new(),
binding_excluded: false,
binding_exclusion_reason: None,
is_variant_wrapper: false,
has_lifetime_params: false,
};
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));
}
}