diff --git a/crates/js-component-bindgen-component/src/lib.rs b/crates/js-component-bindgen-component/src/lib.rs index 729167482..ff50bc1b5 100644 --- a/crates/js-component-bindgen-component/src/lib.rs +++ b/crates/js-component-bindgen-component/src/lib.rs @@ -2,8 +2,8 @@ use std::path::PathBuf; use anyhow::{Context, Result}; use js_component_bindgen::{ - generate_types, - source::wit_parser::{Resolve, UnresolvedPackage}, + generate_types, generate_typescript_stubs, + source::wit_parser::{PackageId, Resolve, UnresolvedPackage}, transpile, }; @@ -116,32 +116,8 @@ impl Guest for JsComponentBindgenComponent { name: String, opts: TypeGenerationOptions, ) -> Result)>, String> { - let mut resolve = Resolve::default(); - let id = match opts.wit { - Wit::Source(source) => { - let pkg = UnresolvedPackage::parse(&PathBuf::from(format!("{name}.wit")), &source) - .map_err(|e| e.to_string())?; - resolve.push(pkg).map_err(|e| e.to_string())? - } - Wit::Path(path) => { - let path = PathBuf::from(path); - if path.is_dir() { - resolve.push_dir(&path).map_err(|e| e.to_string())?.0 - } else { - let contents = std::fs::read(&path) - .with_context(|| format!("failed to read file {path:?}")) - .map_err(|e| e.to_string())?; - let text = match std::str::from_utf8(&contents) { - Ok(s) => s, - Err(_) => return Err("input file is not valid utf-8".into()), - }; - let pkg = UnresolvedPackage::parse(&path, text).map_err(|e| e.to_string())?; - resolve.push(pkg).map_err(|e| e.to_string())? - } - } - Wit::Binary(_) => todo!(), - }; - + let (resolve, id) = + resolve_package(opts.wit, Some(name.as_str())).map_err(|e| e.to_string())?; let world_string = opts.world.map(|world| world.to_string()); let world = resolve .select_world(id, world_string.as_deref()) @@ -166,4 +142,45 @@ impl Guest for JsComponentBindgenComponent { Ok(files) } + + fn generate_typescript_stubs(opts: TypescriptStubOptions) -> Result { + let (resolve, id) = resolve_package(opts.wit, None).map_err(|e| e.to_string())?; + let world_string = opts.world.map(|world| world.to_string()); + let world = resolve + .select_world(id, world_string.as_deref()) + .map_err(|e| e.to_string())?; + + let files = generate_typescript_stubs(resolve, world).map_err(|e| e.to_string())?; + + Ok(files) + } +} + +fn resolve_package(wit_opt: Wit, name: Option<&str>) -> Result<(Resolve, PackageId)> { + let mut resolve = Resolve::default(); + let id = match wit_opt { + Wit::Source(source) => { + let name = name.unwrap_or("world"); + let pkg = UnresolvedPackage::parse(&PathBuf::from(format!("{name}.wit")), &source)?; + resolve.push(pkg)? + } + Wit::Path(path) => { + let path = PathBuf::from(path); + if path.is_dir() { + resolve.push_dir(&path)?.0 + } else { + let contents = std::fs::read(&path) + .with_context(|| format!("failed to read file {path:?}"))?; + let text = match std::str::from_utf8(&contents) { + Ok(s) => s, + Err(_) => anyhow::bail!("input file is not valid utf-8"), + }; + let pkg = UnresolvedPackage::parse(&path, text)?; + resolve.push(pkg)? + } + } + Wit::Binary(_) => todo!(), + }; + + Ok((resolve, id)) } diff --git a/crates/js-component-bindgen-component/wit/js-component-bindgen.wit b/crates/js-component-bindgen-component/wit/js-component-bindgen.wit index c0b19cde5..00562c815 100644 --- a/crates/js-component-bindgen-component/wit/js-component-bindgen.wit +++ b/crates/js-component-bindgen-component/wit/js-component-bindgen.wit @@ -83,6 +83,13 @@ world js-component-bindgen { map: option, } + record typescript-stub-options { + /// wit to generate typing from + wit: wit, + /// world to generate typing for + %world: option, + } + enum export-type { function, instance, @@ -100,4 +107,6 @@ world js-component-bindgen { export generate: func(component: list, options: generate-options) -> result; export generate-types: func(name: string, options: type-generation-options) -> result; + + export generate-typescript-stubs: func(options: typescript-stub-options) -> result; } diff --git a/crates/js-component-bindgen/src/files.rs b/crates/js-component-bindgen/src/files.rs index b1f8ede54..5ef436521 100644 --- a/crates/js-component-bindgen/src/files.rs +++ b/crates/js-component-bindgen/src/files.rs @@ -25,7 +25,16 @@ impl Files { self.files.remove(name) } - pub fn iter(&self) -> impl Iterator { + pub fn iter(&self) -> impl Iterator { self.files.iter().map(|p| (p.0.as_str(), p.1.as_slice())) } } + +impl IntoIterator for Files { + type Item = (String, Vec); + type IntoIter = std::collections::btree_map::IntoIter>; + + fn into_iter(self) -> Self::IntoIter { + self.files.into_iter() + } +} diff --git a/crates/js-component-bindgen/src/lib.rs b/crates/js-component-bindgen/src/lib.rs index 5977a6f95..7bad41c73 100644 --- a/crates/js-component-bindgen/src/lib.rs +++ b/crates/js-component-bindgen/src/lib.rs @@ -2,6 +2,7 @@ mod core; mod files; mod transpile_bindgen; mod ts_bindgen; +mod ts_stubgen; pub mod esm_bindgen; pub mod function_bindgen; @@ -70,11 +71,18 @@ pub fn generate_types( ts_bindgen(&name, &resolve, world_id, &opts, &mut files); - let mut files_out: Vec<(String, Vec)> = Vec::new(); - for (name, source) in files.iter() { - files_out.push((name.to_string(), source.to_vec())); - } - Ok(files_out) + Ok(files.into_iter().collect()) +} + +pub fn generate_typescript_stubs( + resolve: Resolve, + world_id: WorldId, +) -> Result)>, anyhow::Error> { + let mut files = files::Files::default(); + + ts_stubgen::ts_stubgen(&resolve, world_id, &mut files)?; + + Ok(files.into_iter().collect()) } /// Generate the JS transpilation bindgen for a given Wasm component binary diff --git a/crates/js-component-bindgen/src/source.rs b/crates/js-component-bindgen/src/source.rs index 737b44c42..509a0546f 100644 --- a/crates/js-component-bindgen/src/source.rs +++ b/crates/js-component-bindgen/src/source.rs @@ -96,6 +96,12 @@ impl From for String { } } +impl AsRef for Source { + fn as_ref(&self) -> &str { + &self.s + } +} + #[cfg(test)] mod tests { use super::Source; diff --git a/crates/js-component-bindgen/src/ts_stubgen.rs b/crates/js-component-bindgen/src/ts_stubgen.rs new file mode 100644 index 000000000..77a304bbd --- /dev/null +++ b/crates/js-component-bindgen/src/ts_stubgen.rs @@ -0,0 +1,999 @@ +use crate::files::Files; +use crate::function_bindgen::{array_ty, as_nullable, maybe_null}; +use crate::names::{is_js_identifier, maybe_quote_id, LocalNames, RESERVED_KEYWORDS}; +use crate::source::Source; +use crate::{dealias, uwrite, uwriteln}; +use anyhow::bail; +use heck::*; +use indexmap::map::Entry; +use indexmap::IndexMap; +use std::fmt::Write; +use wit_parser::*; + +struct TsStubgen<'a> { + resolve: &'a Resolve, + files: &'a mut Files, + world: &'a World, +} + +pub fn ts_stubgen(resolve: &Resolve, id: WorldId, files: &mut Files) -> anyhow::Result<()> { + let world = &resolve.worlds[id]; + let mut bindgen = TsStubgen { + resolve, + files, + world, + }; + + let mut world_types: Vec = Vec::new(); + + { + let mut import_interface: IndexMap = IndexMap::new(); + + for (name, import) in world.imports.iter() { + match import { + WorldItem::Function(_) => match name { + // Happens with `using` in world. + WorldKey::Name(name) => { + bail!("Function imported by name not implemented {name}"); + } + WorldKey::Interface(id) => { + let import_specifier = resolve.id_of(*id).unwrap(); + match import_interface.entry(import_specifier) { + Entry::Vacant(entry) => { + entry.insert(*id); + } + Entry::Occupied(_) => { + unreachable!( + "multiple imports of the same interface: {import_specifier}", + import_specifier = resolve.id_of(*id).unwrap() + ); + } + } + } + }, + WorldItem::Interface(_) => match name { + // TODO: Is this even possible? + WorldKey::Name(name) => { + bail!("Interface imported by name not implemented {name}"); + } + WorldKey::Interface(id) => { + let import_specifier = resolve.id_of(*id).unwrap(); + match import_interface.entry(import_specifier) { + Entry::Vacant(entry) => { + entry.insert(*id); + } + Entry::Occupied(_) => { + unreachable!( + "multiple imports of the same interface: {import_specifier}", + import_specifier = resolve.id_of(*id).unwrap() + ); + } + } + } + }, + + // `use` statement in world will be considered `WorldItem::Type` + // type declaration (record, enum, etc. ) in world will also be considered `WorldItem::Type` + WorldItem::Type(tid) => { + world_types.push(*tid); + } + } + } + + bindgen.import_interfaces(import_interface.values().copied()); + } + + { + let mut export_interfaces: Vec = Vec::new(); + let mut export_functions: Vec = Vec::new(); + + for (name, export) in world.exports.iter() { + match export { + WorldItem::Function(f) => { + let export_name = match name { + WorldKey::Name(export_name) => export_name, + WorldKey::Interface(_) => { + unreachable!("world function export with interface") + } + }; + let export_name = export_name.to_lower_camel_case(); + export_functions.push(ExportFunction { + export_name, + func: f, + }); + } + WorldItem::Interface(id) => { + let id = *id; + if let WorldKey::Name(name) = name { + export_interfaces.push(ExportInterface { + name: name.clone(), + id, + }) + } else { + let interface = &resolve.interfaces[id]; + let name = interface.name.as_ref().expect("interface has name").clone(); + export_interfaces.push(ExportInterface { + name: name.clone(), + id, + }) + } + } + + WorldItem::Type(_) => { + unreachable!( + "type export not supported. only functions and interfaces can be exported." + ) + } + } + } + + bindgen.process_exports(&world_types, &export_functions, &export_interfaces); + + Ok(()) + } +} + +struct ExportInterface { + name: String, + id: InterfaceId, +} + +struct ExportFunction<'a> { + export_name: String, + func: &'a Function, +} + +impl<'a> TsStubgen<'a> { + fn import_interfaces(&mut self, ifaces: impl Iterator) { + for id in ifaces { + let name = self.resolve.interfaces[id].name.as_ref().unwrap(); + self.generate_interface(name, id); + } + } + + fn process_exports( + &mut self, + types: &[TypeId], + funcs: &[ExportFunction], + interfaces: &[ExportInterface], + ) { + let mut gen = TsInterface::new(self.resolve); + + // Type defs must be first, because they can generate imports. + for tid in types { + let tdef = &self.resolve.types[*tid]; + match tdef.kind { + TypeDefKind::Resource => {} + _ => { + gen.type_def(*tid, None, None); + } + } + } + + let mut resources: IndexMap = IndexMap::new(); + + struct ResourceExport<'a> { + ident: String, + ident_static: String, + ident_instance: String, + functions: Vec<&'a Function>, + } + + for iface in interfaces { + let ExportInterface { name, id } = iface; + let id = *id; + let iface = &self.resolve.interfaces[id]; + + uwriteln!( + gen.src, + "export interface {} {{", + AsUpperCamelCase(name.as_str()) + ); + + for (_name, func) in iface.functions.iter() { + match func.kind { + FunctionKind::Freestanding => { + gen.ts_import_func(func, false); + } + FunctionKind::Method(tid) + | FunctionKind::Static(tid) + | FunctionKind::Constructor(tid) => match resources.entry(tid) { + Entry::Occupied(mut e) => { + let resource = e.get_mut(); + resource.functions.push(func); + } + Entry::Vacant(e) => { + let ident = { + let resource = &self.resolve.types[tid]; + resource.name.as_ref().unwrap().to_upper_camel_case() + }; + let ident_static = format!("{}Static", ident); + let ident_instance = format!("{}Instance", ident); + let resource = e.insert(ResourceExport { + ident, + ident_static, + ident_instance, + functions: vec![func], + }); + uwriteln!(gen.src, "{}: {}", resource.ident, resource.ident_static); + } + }, + } + } + + uwriteln!(gen.src, "}}"); + gen.types(id); + } + + let mut world_src = gen.finish(); + + { + let src = &mut world_src; + + if !resources.is_empty() { + uwriteln!(src, "") + } + + // Replace ident with ident_base in each of the signatures. + + for (_, resource) in resources { + let ResourceExport { + ident, + ident_static, + ident_instance, + functions, + } = resource; + + let (method_funcs, static_func): (Vec<&Function>, Vec<&Function>) = functions + .iter() + .partition(|f| matches!(f.kind, FunctionKind::Method(_))); + + uwriteln!(src, "export interface {ident_static} {{"); + for func in static_func { + match func.kind { + FunctionKind::Static(_) => { + let signature = with_printer(self.resolve, |mut p| { + p.ts_func_signature(func); + }); + let signature = signature.replace(&ident, &ident_instance); + let f_name = func.item_name(); + uwriteln!(src, "{f_name}{signature},"); + } + FunctionKind::Constructor(_) => { + let params = with_printer(self.resolve, |mut p| { + p.ts_func_params(func); + }); + let params = params.replace(&ident, &ident_instance); + uwriteln!(src, "new{params}: {ident_instance},",); + } + _ => unreachable!("non static resource function"), + } + } + uwriteln!(src, "}}"); + + uwriteln!(src, "export interface {ident_instance} {{"); + for func in method_funcs { + let params = with_printer(self.resolve, |mut p| { + p.ts_func_signature(func); + }); + let params = params.replace(&ident, &ident_instance); + let f_name = func.item_name(); + uwriteln!(src, "{f_name}{params},"); + } + uwriteln!(src, "}}"); + } + } + + let camel_world = AsUpperCamelCase(self.world.name.as_str()); + let kebab_world = AsKebabCase(self.world.name.as_str()); + + uwriteln!(world_src, ""); + uwriteln!(world_src, "export interface {camel_world}World {{",); + for ExportInterface { name, .. } in interfaces { + let lower_camel = AsLowerCamelCase(name); + let upper_camel = AsUpperCamelCase(name); + uwriteln!(world_src, "{lower_camel}: {upper_camel},",); + } + + for func in funcs { + match func.func.kind { + FunctionKind::Freestanding => { + let signature = with_printer(self.resolve, |mut p| { + p.ts_func_signature(func.func); + }); + + uwriteln!( + world_src, + "{export_name}{signature},", + export_name = func.export_name, + ); + } + // TODO: Is this even possible? + FunctionKind::Method(_) + | FunctionKind::Static(_) + | FunctionKind::Constructor(_) => { + unreachable!("cannot export resource in world"); + } + } + } + + uwriteln!(world_src, "}}"); + + self.files + .push(&format!("{kebab_world}.d.ts"), world_src.as_bytes()); + } + + fn generate_interface(&mut self, name: &str, id: InterfaceId) { + let id_name = self.resolve.id_of(id).unwrap_or_else(|| name.to_string()); + let goal_name = interface_goal_name(&id_name); + let goal_name_kebab = goal_name.to_kebab_case(); + let file_name = &format!("interfaces/{}.d.ts", goal_name_kebab); + + let package_name = interface_module_name(self.resolve, id); + + let mut gen = TsInterface::new(self.resolve); + + uwriteln!(gen.src, "declare module \"{package_name}\" {{"); + + gen.types(id); + + for (_, func) in self.resolve.interfaces[id].functions.iter() { + gen.ts_import_func(func, true); + } + + let mut src = gen.finish(); + + uwriteln!(src, "}}"); + + self.files.push(file_name, src.as_bytes()); + } +} + +/// Used to generate a `*.d.ts` file for each imported and exported interface for +/// a component. +/// +/// This generated source does not contain any actual JS runtime code, it's just +/// typescript definitions. +struct TsInterface<'a> { + src: Source, + resolve: &'a Resolve, + needs_ty_option: bool, + needs_ty_result: bool, + local_names: LocalNames, + // Resources are aggregated, because the only way to get metadata for resource is by looking up their functions. + resources: IndexMap<&'a str, ResourceImport<'a>>, +} + +impl<'a> TsInterface<'a> { + fn new(resolve: &'a Resolve) -> Self { + TsInterface { + src: Source::default(), + resources: IndexMap::default(), + local_names: LocalNames::default(), + resolve, + needs_ty_option: false, + needs_ty_result: false, + } + } + + fn finish(mut self) -> Source { + let mut printer = Printer { + resolve: self.resolve, + src: &mut self.src, + needs_ty_option: &mut self.needs_ty_option, + needs_ty_result: &mut self.needs_ty_result, + }; + + for (name, resource) in self.resources.iter() { + uwriteln!(printer.src, "export class {} {{", AsUpperCamelCase(name)); + printer.resource_import(resource); + uwriteln!(printer.src, "}}") + } + + self.post_types(); + + self.src + } + + fn as_printer(&mut self) -> Printer { + Printer { + resolve: self.resolve, + src: &mut self.src, + needs_ty_option: &mut self.needs_ty_option, + needs_ty_result: &mut self.needs_ty_result, + } + } + + fn types(&mut self, iface_id: InterfaceId) { + let iface = &self.resolve.interfaces[iface_id]; + for (name, id) in iface.types.iter() { + self.type_def(*id, Some(name), Some(iface_id)); + } + } + + fn type_def(&mut self, id: TypeId, name: Option<&str>, parent_id: Option) { + let ty = &self.resolve.types[id]; + let name = name.unwrap_or_else(|| ty.name.as_ref().expect("type name")); + let mut printer = self.as_printer(); + match &ty.kind { + TypeDefKind::Record(record) => printer.type_record(id, name, record, &ty.docs), + TypeDefKind::Flags(flags) => printer.type_flags(id, name, flags, &ty.docs), + TypeDefKind::Tuple(tuple) => printer.type_tuple(id, name, tuple, &ty.docs), + TypeDefKind::Enum(enum_) => printer.type_enum(id, name, enum_, &ty.docs), + TypeDefKind::Variant(variant) => printer.type_variant(id, name, variant, &ty.docs), + TypeDefKind::Option(t) => printer.type_option(id, name, t, &ty.docs), + TypeDefKind::Result(r) => printer.type_result(id, name, r, &ty.docs), + TypeDefKind::List(t) => printer.type_list(id, name, t, &ty.docs), + TypeDefKind::Type(t) => self + .as_printer() + .type_alias(id, name, t, parent_id, &ty.docs), + TypeDefKind::Future(_) => todo!("generate for future"), + TypeDefKind::Stream(_) => todo!("generate for stream"), + TypeDefKind::Handle(_) => todo!("generate for handle"), + // Resources are handled by Self::ts_func + TypeDefKind::Resource => {} + TypeDefKind::Unknown => unreachable!(), + } + } + + fn ts_import_func(&mut self, func: &'a Function, declaration: bool) { + // Resource conversion is delayed until because we need to aggregate all resource functions + // before creating the class. + if let FunctionKind::Method(ty) | FunctionKind::Static(ty) | FunctionKind::Constructor(ty) = + func.kind + { + let ty = &self.resolve.types[ty]; + let resource = ty.name.as_ref().unwrap(); + match self.resources.entry(resource) { + Entry::Occupied(mut e) => { + e.get_mut().push_func(func); + } + Entry::Vacant(e) => { + let r = e.insert(Default::default()); + r.push_func(func); + } + } + return; + }; + + self.as_printer().docs(&func.docs); + + let name = func.item_name().to_lower_camel_case(); + + if declaration { + match func.kind { + FunctionKind::Freestanding => { + if is_js_identifier(&name) { + uwrite!(self.src, "export function {name}",); + } else { + let (local_name, _) = self.local_names.get_or_create(&name, &name); + uwriteln!(self.src, "export {{ {local_name} as {name} }};",); + uwriteln!(self.src, "declare function {local_name};",); + }; + } + _ => unreachable!("resource functions should be delayed"), + } + } else { + if is_js_identifier(&name) { + self.src.push_str(&name); + } else { + uwrite!(self.src, "'{name}'"); + } + } + + self.as_printer().ts_func_signature(func); + + let end_character = if declaration { ";" } else { "," }; + self.src.push_str(end_character); + self.src.push_str("\n"); + } + + fn post_types(&mut self) { + if self.needs_ty_option { + self.src + .push_str("export type Option = { tag: 'none' } | { tag: 'some', val: T };\n"); + } + if self.needs_ty_result { + self.src.push_str( + "export type Result = { tag: 'ok', val: T } | { tag: 'err', val: E };\n", + ); + } + } +} + +#[derive(Debug, Default)] +struct ResourceImport<'a> { + constructor: Option<&'a Function>, + method_funcs: Vec<&'a Function>, + static_funcs: Vec<&'a Function>, +} + +impl<'a> ResourceImport<'a> { + fn push_func(&mut self, func: &'a Function) { + match func.kind { + FunctionKind::Method(_) => { + self.method_funcs.push(func); + } + FunctionKind::Static(_) => self.static_funcs.push(func), + FunctionKind::Constructor(_) => { + assert!( + self.constructor.is_none(), + "wit resources can only have one constructor" + ); + self.constructor = Some(func); + } + FunctionKind::Freestanding => { + unreachable!("resource cannot have freestanding function") + } + } + } +} + +fn interface_goal_name(iface_name: &str) -> String { + let iface_name_sans_version = match iface_name.find('@') { + Some(version_idx) => &iface_name[0..version_idx], + None => iface_name, + }; + iface_name_sans_version + .replace(['/', ':'], "-") + .to_kebab_case() +} + +// "golem:api/host@0.2.0"; +fn interface_module_name(resolve: &Resolve, id: InterfaceId) -> String { + let i = &resolve.interfaces[id]; + let i_name = i.name.as_ref().expect("interface name"); + match i.package { + Some(package) => { + let package_name = &resolve.packages[package].name; + let mut s = String::new(); + uwrite!( + s, + "{}:{}/{}", + package_name.namespace, + package_name.name, + i_name + ); + + if let Some(version) = package_name.version.as_ref() { + uwrite!(s, "@{}", version); + } + + s + } + None => i_name.to_string(), + } +} + +struct Printer<'a> { + resolve: &'a Resolve, + src: &'a mut Source, + needs_ty_option: &'a mut bool, + needs_ty_result: &'a mut bool, +} + +fn with_printer(resolve: &Resolve, f: impl FnOnce(Printer)) -> String { + let mut src = Source::default(); + let mut needs_ty_option = false; + let mut needs_ty_result = false; + + let printer = Printer { + resolve, + src: &mut src, + needs_ty_option: &mut needs_ty_option, + needs_ty_result: &mut needs_ty_result, + }; + + f(printer); + + src.into() +} + +impl<'a> Printer<'a> { + fn resource_import(&mut self, resource: &ResourceImport) { + if let Some(func) = resource.constructor { + self.docs(&func.docs); + self.src.push_str("constructor"); + self.ts_func_signature(func); + self.src.push_str("\n"); + } + + for func in resource.method_funcs.iter() { + self.docs(&func.docs); + let name = func.item_name().to_lower_camel_case(); + if is_js_identifier(&name) { + uwrite!(self.src, "{name}"); + } else { + uwrite!(self.src, "'{name}'",); + } + self.ts_func_signature(func); + self.src.push_str(";\n"); + } + + for func in resource.static_funcs.iter() { + self.docs(&func.docs); + let name = func.item_name().to_lower_camel_case(); + if is_js_identifier(&name) { + uwrite!(self.src, "static {name}"); + } else { + uwrite!(self.src, "static '{name}'"); + } + self.ts_func_signature(func); + self.src.push_str(";\n"); + } + } + + fn docs_raw(&mut self, docs: &str) { + self.src.push_str("/**\n"); + for line in docs.lines() { + uwriteln!(self.src, " * {}", line); + } + self.src.push_str(" */\n"); + } + + fn docs(&mut self, docs: &Docs) { + if let Some(docs) = &docs.contents { + self.docs_raw(docs); + } + } + + fn ts_func_signature(&mut self, func: &Function) { + self.ts_func_params(func); + + if matches!(func.kind, FunctionKind::Constructor(_)) { + return; + } + + self.src.push_str(": "); + + match func.results.len() { + 0 => self.src.push_str("void"), + 1 => self.print_ty(func.results.iter_types().next().unwrap()), + _ => { + self.src.push_str("["); + for (i, ty) in func.results.iter_types().enumerate() { + if i != 0 { + self.src.push_str(", "); + } + self.print_ty(ty); + } + self.src.push_str("]"); + } + } + } + + fn ts_func_params(&mut self, func: &Function) { + self.src.push_str("("); + + let param_start = match &func.kind { + FunctionKind::Freestanding => 0, + FunctionKind::Method(_) => 1, + FunctionKind::Static(_) => 0, + FunctionKind::Constructor(_) => 0, + }; + + for (i, (name, ty)) in func.params[param_start..].iter().enumerate() { + if i > 0 { + self.src.push_str(", "); + } + let mut param_name = name.to_lower_camel_case(); + if RESERVED_KEYWORDS + .binary_search(¶m_name.as_str()) + .is_ok() + { + param_name = format!("{}_", param_name); + } + self.src.push_str(¶m_name); + self.src.push_str(": "); + self.print_ty(ty); + } + + self.src.push_str(")"); + } + + fn type_record(&mut self, _id: TypeId, name: &str, record: &Record, docs: &Docs) { + self.docs(docs); + uwriteln!(self.src, "export interface {} {{", AsUpperCamelCase(name)); + for field in record.fields.iter() { + self.docs(&field.docs); + let (option_str, ty) = + as_nullable(self.resolve, &field.ty).map_or(("", &field.ty), |ty| ("?", ty)); + uwrite!( + self.src, + "{}{}: ", + maybe_quote_id(&field.name.to_lower_camel_case()), + option_str, + ); + self.print_ty(ty); + self.src.push_str(",\n"); + } + self.src.push_str("}\n"); + } + + fn type_tuple(&mut self, _id: TypeId, name: &str, tuple: &Tuple, docs: &Docs) { + self.docs(docs); + uwrite!(self.src, "export type {} = ", name.to_upper_camel_case()); + self.print_tuple(tuple); + self.src.push_str(";\n"); + } + + fn type_flags(&mut self, _id: TypeId, name: &str, flags: &Flags, docs: &Docs) { + self.docs(docs); + uwriteln!( + self.src, + "export interface {} {{", + name.to_upper_camel_case() + ); + for flag in flags.flags.iter() { + self.docs(&flag.docs); + uwriteln!( + self.src, + "{}?: boolean,", + AsLowerCamelCase(flag.name.as_str()) + ); + } + self.src.push_str("}\n"); + } + + fn type_variant(&mut self, _id: TypeId, name: &str, variant: &Variant, docs: &Docs) { + self.docs(docs); + uwrite!(self.src, "export type {} = ", AsUpperCamelCase(name)); + for (i, case) in variant.cases.iter().enumerate() { + if i > 0 { + self.src.push_str(" | "); + } + + uwrite!( + self.src, + "{}{}", + AsUpperCamelCase(name), + AsUpperCamelCase(case.name.as_str()), + ); + } + self.src.push_str(";\n"); + for case in variant.cases.iter() { + self.docs(&case.docs); + uwriteln!( + self.src, + "export interface {}{} {{", + AsUpperCamelCase(name), + AsUpperCamelCase(case.name.as_str()) + ); + self.src.push_str("tag: '"); + self.src.push_str(&case.name); + self.src.push_str("',\n"); + if let Some(ty) = case.ty { + self.src.push_str("val: "); + self.print_ty(&ty); + self.src.push_str(",\n"); + } + self.src.push_str("}\n"); + } + } + + fn type_option(&mut self, _id: TypeId, name: &str, payload: &Type, docs: &Docs) { + self.docs(docs); + let name = name.to_upper_camel_case(); + uwrite!(self.src, "export type {name} = "); + if maybe_null(self.resolve, payload) { + *self.needs_ty_option = true; + self.src.push_str("Option<"); + self.print_ty(payload); + self.src.push_str(">"); + } else { + self.print_ty(payload); + self.src.push_str(" | undefined"); + } + self.src.push_str(";\n"); + } + + fn type_result(&mut self, _id: TypeId, name: &str, result: &Result_, docs: &Docs) { + self.docs(docs); + let name = name.to_upper_camel_case(); + *self.needs_ty_result = true; + uwrite!(self.src, "export type {name} = Result<"); + self.print_optional_ty(result.ok.as_ref()); + self.src.push_str(", "); + self.print_optional_ty(result.err.as_ref()); + self.src.push_str(">;\n"); + } + + fn type_enum(&mut self, _id: TypeId, name: &str, enum_: &Enum, docs: &Docs) { + // The complete documentation for this enum, including documentation for variants. + let mut complete_docs = String::new(); + + if let Some(docs) = &docs.contents { + complete_docs.push_str(docs); + // Add a gap before the `# Variants` section. + complete_docs.push('\n'); + } + + writeln!(complete_docs, "# Variants").unwrap(); + + for case in enum_.cases.iter() { + writeln!(complete_docs).unwrap(); + writeln!(complete_docs, "## `\"{}\"`", case.name).unwrap(); + + if let Some(docs) = &case.docs.contents { + writeln!(complete_docs).unwrap(); + complete_docs.push_str(docs); + } + } + + self.docs_raw(&complete_docs); + + self.src + .push_str(&format!("export type {} = ", name.to_upper_camel_case())); + for (i, case) in enum_.cases.iter().enumerate() { + if i != 0 { + self.src.push_str(" | "); + } + self.src.push_str(&format!("'{}'", case.name)); + } + self.src.push_str(";\n"); + } + + fn type_alias( + &mut self, + id: TypeId, + name: &str, + ty: &Type, + parent_id: Option, + docs: &Docs, + ) { + let owner_not_parent = match ty { + Type::Id(type_def_id) => { + let ty = &self.resolve.types[*type_def_id]; + match ty.owner { + TypeOwner::Interface(i) => { + if let Some(parent_id) = parent_id { + if parent_id != i { + Some(i) + } else { + None + } + } else { + Some(i) + } + } + _ => None, + } + } + _ => None, + }; + + let type_name = name.to_upper_camel_case(); + + if let Some(owner_id) = owner_not_parent { + let orig_id = dealias(self.resolve, id); + let orig_name = self.resolve.types[orig_id] + .name + .as_ref() + .unwrap() + .to_upper_camel_case(); + + let package_name = interface_module_name(self.resolve, owner_id); + + if orig_name == type_name { + uwriteln!( + self.src, + "import type {{ {type_name} }} from \"{package_name}\";", + ); + } else { + uwriteln!( + self.src, + "import type {{ {orig_name} as {type_name} }} from \"{package_name}\";", + ); + } + } else { + self.docs(docs); + uwrite!(self.src, "export type {type_name} = "); + self.print_ty(ty); + self.src.push_str(";\n"); + } + } + + fn type_list(&mut self, _id: TypeId, name: &str, ty: &Type, docs: &Docs) { + self.docs(docs); + + uwrite!(self.src, "export type {} = ", AsUpperCamelCase(name)); + self.print_list(ty); + self.src.push_str(";\n"); + } + + fn print_ty(&mut self, ty: &Type) { + match ty { + Type::Bool => self.src.push_str("boolean"), + Type::U8 + | Type::S8 + | Type::U16 + | Type::S16 + | Type::U32 + | Type::S32 + | Type::F32 + | Type::F64 => self.src.push_str("number"), + Type::U64 | Type::S64 => self.src.push_str("bigint"), + Type::Char => self.src.push_str("string"), + Type::String => self.src.push_str("string"), + Type::Id(id) => { + let ty = &self.resolve.types[*id]; + if let Some(name) = &ty.name { + return self.src.push_str(&name.to_upper_camel_case()); + } + match &ty.kind { + TypeDefKind::Type(t) => self.print_ty(t), + TypeDefKind::Tuple(t) => self.print_tuple(t), + TypeDefKind::Record(_) => panic!("anonymous record"), + TypeDefKind::Flags(_) => panic!("anonymous flags"), + TypeDefKind::Enum(_) => panic!("anonymous enum"), + TypeDefKind::Option(t) => { + if maybe_null(self.resolve, t) { + *self.needs_ty_option = true; + self.src.push_str("Option<"); + self.print_ty(t); + self.src.push_str(">"); + } else { + self.print_ty(t); + self.src.push_str(" | undefined"); + } + } + TypeDefKind::Result(r) => { + *self.needs_ty_result = true; + self.src.push_str("Result<"); + self.print_optional_ty(r.ok.as_ref()); + self.src.push_str(", "); + self.print_optional_ty(r.err.as_ref()); + self.src.push_str(">"); + } + TypeDefKind::Variant(_) => panic!("anonymous variant"), + TypeDefKind::List(v) => self.print_list(v), + TypeDefKind::Future(_) => todo!("anonymous future"), + TypeDefKind::Stream(_) => todo!("anonymous stream"), + TypeDefKind::Unknown => unreachable!(), + TypeDefKind::Resource => todo!(), + TypeDefKind::Handle(h) => { + let ty = match h { + Handle::Own(r) => r, + Handle::Borrow(r) => r, + }; + let ty = &self.resolve.types[*ty]; + if let Some(name) = &ty.name { + self.src.push_str(&name.to_upper_camel_case()); + } else { + panic!("anonymous resource handle"); + } + } + } + } + } + } + + fn print_optional_ty(&mut self, ty: Option<&Type>) { + match ty { + Some(ty) => self.print_ty(ty), + None => self.src.push_str("void"), + } + } + + fn print_list(&mut self, ty: &Type) { + match array_ty(self.resolve, ty) { + Some("Uint8Array") => self.src.push_str("Uint8Array"), + Some(ty) => self.src.push_str(ty), + None => { + self.print_ty(ty); + self.src.push_str("[]"); + } + } + } + + fn print_tuple(&mut self, tuple: &Tuple) { + self.src.push_str("["); + for (i, ty) in tuple.types.iter().enumerate() { + if i > 0 { + self.src.push_str(", "); + } + self.print_ty(ty); + } + self.src.push_str("]"); + } +} diff --git a/crates/js-component-bindgen/tests/ts_stubgen.rs b/crates/js-component-bindgen/tests/ts_stubgen.rs new file mode 100644 index 000000000..53ff3d7b8 --- /dev/null +++ b/crates/js-component-bindgen/tests/ts_stubgen.rs @@ -0,0 +1,755 @@ +use std::collections::HashMap; + +use js_component_bindgen::generate_typescript_stubs; +use wit_parser::UnresolvedPackage; + +// Enable this to write the generated files to the `tests/temp` directory +static IS_DEBUG: bool = false; + +#[test] +fn basic_ts() { + let wit = " + package test:t-basic; + + world test { + export basic-test; + } + + interface basic-test { + bool-test: func(a: bool) -> bool; + s8-test: func(a: s8) -> s8; + s16-test: func(a: s16) -> s16; + s32-test: func(a: s32) -> s32; + s64-test: func(a: s64) -> s64; + u8-test: func(a: u8) -> u8; + u16-test: func(a: u16) -> u16; + u32-test: func(a: u32) -> u32; + u64-test: func(a: u64) -> u64; + f32-test: func(a: f32) -> f32; + f64-test: func(a: f64) -> f64; + char-test: func(c: char) -> char; + string-test: func(s: string) -> string; + u8-list-test: func(a: list) -> list; + string-list-test: func(a: list) -> list; + option-nullable-test: func(a: option) -> option; + option-nested-test: func(a: option>) -> option>; + result-success-test: func(a: u8) -> result; + result-fail-test: func(a: u8) -> result<_, u8>; + result-both-test: func(a: u8) -> result; + result-none-test: func() -> result; + + variant variant-test { + none, + any, + something(string) + } + + variant enum-test { + a, + b, + c, + } + + record record-test { + a: u8, + b: string, + c: option + } + + flags flag-test { + a, + b, + c, + } + } + "; + + let expected = " + export interface BasicTest { + boolTest(a: boolean): boolean, + s8Test(a: number): number, + s16Test(a: number): number, + s32Test(a: number): number, + s64Test(a: bigint): bigint, + u8Test(a: number): number, + u16Test(a: number): number, + u32Test(a: number): number, + u64Test(a: bigint): bigint, + f32Test(a: number): number, + f64Test(a: number): number, + charTest(c: string): string, + stringTest(s: string): string, + u8ListTest(a: Uint8Array): Uint8Array, + stringListTest(a: string[]): string[], + optionNullableTest(a: number | undefined): number | undefined, + optionNestedTest(a: Option): Option, + resultSuccessTest(a: number): Result, + resultFailTest(a: number): Result, + resultBothTest(a: number): Result, + resultNoneTest(): Result, + } + export type VariantTest = VariantTestNone | VariantTestAny | VariantTestSomething; + export interface VariantTestNone { + tag: 'none', + } + export interface VariantTestAny { + tag: 'any', + } + export interface VariantTestSomething { + tag: 'something', + val: string, + } + export type EnumTest = EnumTestA | EnumTestB | EnumTestC; + export interface EnumTestA { + tag: 'a', + } + export interface EnumTestB { + tag: 'b', + } + export interface EnumTestC { + tag: 'c', + } + export interface RecordTest { + a: number, + b: string, + c?: EnumTest, + } + export interface FlagTest { + a?: boolean, + b?: boolean, + c?: boolean, + } + export type Option = { tag: 'none' } | { tag: 'some', val: T }; + export type Result = { tag: 'ok', val: T } | { tag: 'err', val: E }; + + export interface TestWorld { + basicTest: BasicTest, + } +"; + + test_single_file(wit, expected); +} + +#[test] +fn export_resource() { + let wit = " + package test:t-resource; + + world test { + export resource-test; + } + + interface resource-test { + resource blob { + constructor(init: list); + write: func(bytes: list); + read: func(n: u32) -> list; + merge: static func(lhs: blob, rhs: blob) -> blob; + } + } + "; + + let expected = " + export interface ResourceTest { + Blob: BlobStatic + } + + export interface BlobStatic { + new(init: Uint8Array): BlobInstance, + merge(lhs: BlobInstance, rhs: BlobInstance): BlobInstance, + } + export interface BlobInstance { + write(bytes: Uint8Array): void, + read(n: number): Uint8Array, + } + + export interface TestWorld { + resourceTest: ResourceTest, + } + "; + + test_single_file(wit, expected); +} + +#[test] +fn resource_import() { + let wit = &[ + WitFile { + wit: " + package test:resource-source; + + interface resource-test { + resource blob { + constructor(init: list); + write: func(bytes: list); + read: func(n: u32) -> list; + merge: static func(lhs: blob, rhs: blob) -> blob; + } + } + ", + }, + WitFile { + wit: " + package test:resource-import; + + world test { + import test:resource-source/resource-test; + export run: func() -> (); + } + ", + }, + ]; + + let expected = &[ + ExpectedTs { + file_name: "test.d.ts", + expected: " + export interface TestWorld { + run(): void, + }", + }, + ExpectedTs { + file_name: "interfaces/test-resource-source-resource-test.d.ts", + expected: r#" + declare module "test:resource-source/resource-test" { + export class Blob { + constructor(init: Uint8Array) + write(bytes: Uint8Array): void; + read(n: number): Uint8Array; + static merge(lhs: Blob, rhs: Blob): Blob; + } + } + "#, + }, + ]; + + test_files(wit, expected); +} + +#[test] +fn imports() { + let wit = &[ + WitFile { + wit: " + package test:types; + + interface types { + type dimension = u32; + record point { + x: dimension, + y: dimension, + } + } + ", + }, + WitFile { + wit: " + package test:canvas; + + interface canvas { + use test:types/types.{dimension, point}; + type canvas-id = u64; + draw-line: func(canvas: canvas-id, origin: point, target: point, thickness: dimension); + } + ", + }, + WitFile { + wit: " + package test:t-imports; + + world test { + import test:canvas/canvas; + export run: func(); + } + ", + }, + ]; + + let expected = &[ + ExpectedTs { + file_name: "test.d.ts", + expected: " + export interface TestWorld { + run(): void, + }", + }, + ExpectedTs { + file_name: "interfaces/test-canvas-canvas.d.ts", + expected: r#" + declare module "test:canvas/canvas" { + import type { Dimension } from "test:types/types"; + import type { Point } from "test:types/types"; + export type CanvasId = bigint; + export function drawLine(canvas: CanvasId, origin: Point, target: Point, thickness: Dimension): void; + } + "#, + }, + ExpectedTs { + file_name: "interfaces/test-types-types.d.ts", + expected: r#" + declare module "test:types/types" { + export type Dimension = number; + export interface Point { + x: Dimension, + y: Dimension, + } + } + "#, + }, + ]; + + test_files(wit, expected); +} + +#[test] +fn rpc() { + let wit = &[ + WitFile { + wit: " + package golem:rpc@0.1.0; + + interface types { + type node-index = s32; + + record wit-value { + nodes: list, + } + + variant wit-node { + record-value(list), + variant-value(tuple>), + enum-value(u32), + flags-value(list), + tuple-value(list), + list-value(list), + option-value(option), + result-value(result, option>), + prim-u8(u8), + prim-u16(u16), + prim-u32(u32), + prim-u64(u64), + prim-s8(s8), + prim-s16(s16), + prim-s32(s32), + prim-s64(s64), + prim-float32(float32), + prim-float64(float64), + prim-char(char), + prim-bool(bool), + prim-string(string), + handle(tuple) + } + + record uri { + value: string, + } + + variant rpc-error { + protocol-error(string), + denied(string), + not-found(string), + remote-internal-error(string) + } + + resource wasm-rpc { + constructor(location: uri); + + invoke-and-await: func(function-name: string, function-params: list) -> result; + invoke: func(function-name: string, function-params: list) -> result<_, rpc-error>; + } + } + + world wit-value { + import types; + } + ", + }, + WitFile { + wit: " + package rpc:counters; + + interface api { + + record counter-value { + value: u64, + } + + resource counter { + constructor(name: string); + inc-by: func(value: u64); + get-value: func() -> counter-value; + } + + inc-global-by: func(value: u64); + get-global-value: func() -> counter-value; + get-all-dropped: func() -> list>; + } + + world counters { + export api; + } + ", + }, + WitFile { + wit: " + package rpc:counters-stub; + + interface stub-counters { + use golem:rpc/types@0.1.0.{uri}; + use rpc:counters/api.{counter-value}; + + resource api { + constructor(location: uri); + inc-global-by: func(value: u64); + get-global-value: func() -> counter-value; + get-all-dropped: func() -> list>; + } + + resource counter { + constructor(location: uri, name: string); + inc-by: func(value: u64); + get-value: func() -> counter-value; + } + + } + + world wasm-rpc-stub-counters { + export stub-counters; + } + ", + }, + WitFile { + wit: " + package test:rpc; + + interface api { + test1: func() -> list>; + test2: func() -> u64; + test3: func() -> u64; + } + + world test { + import rpc:counters-stub/stub-counters; + export api; + } + " + } + ]; + + let expected = &[ + ExpectedTs { + file_name: "test.d.ts", + expected: " + export interface Api { + test1(): [string, bigint][], + test2(): bigint, + test3(): bigint, + } + + export interface TestWorld { + api: Api, + } + ", + }, + ExpectedTs { + file_name: "interfaces/golem-rpc-types.d.ts", + expected: r#" + declare module "golem:rpc/types@0.1.0" { + export type NodeIndex = number; + export interface Uri { + value: string, + } + export type WitNode = WitNodeRecordValue | WitNodeVariantValue | WitNodeEnumValue | WitNodeFlagsValue | WitNodeTupleValue | WitNodeListValue | WitNodeOptionValue | WitNodeResultValue | WitNodePrimU8 | WitNodePrimU16 | WitNodePrimU32 | WitNodePrimU64 | WitNodePrimS8 | WitNodePrimS16 | WitNodePrimS32 | WitNodePrimS64 | WitNodePrimFloat32 | WitNodePrimFloat64 | WitNodePrimChar | WitNodePrimBool | WitNodePrimString | WitNodeHandle; + export interface WitNodeRecordValue { + tag: 'record-value', + val: Int32Array, + } + export interface WitNodeVariantValue { + tag: 'variant-value', + val: [number, NodeIndex | undefined], + } + export interface WitNodeEnumValue { + tag: 'enum-value', + val: number, + } + export interface WitNodeFlagsValue { + tag: 'flags-value', + val: boolean[], + } + export interface WitNodeTupleValue { + tag: 'tuple-value', + val: Int32Array, + } + export interface WitNodeListValue { + tag: 'list-value', + val: Int32Array, + } + export interface WitNodeOptionValue { + tag: 'option-value', + val: NodeIndex | undefined, + } + export interface WitNodeResultValue { + tag: 'result-value', + val: Result, + } + export interface WitNodePrimU8 { + tag: 'prim-u8', + val: number, + } + export interface WitNodePrimU16 { + tag: 'prim-u16', + val: number, + } + export interface WitNodePrimU32 { + tag: 'prim-u32', + val: number, + } + export interface WitNodePrimU64 { + tag: 'prim-u64', + val: bigint, + } + export interface WitNodePrimS8 { + tag: 'prim-s8', + val: number, + } + export interface WitNodePrimS16 { + tag: 'prim-s16', + val: number, + } + export interface WitNodePrimS32 { + tag: 'prim-s32', + val: number, + } + export interface WitNodePrimS64 { + tag: 'prim-s64', + val: bigint, + } + export interface WitNodePrimFloat32 { + tag: 'prim-float32', + val: number, + } + export interface WitNodePrimFloat64 { + tag: 'prim-float64', + val: number, + } + export interface WitNodePrimChar { + tag: 'prim-char', + val: string, + } + export interface WitNodePrimBool { + tag: 'prim-bool', + val: boolean, + } + export interface WitNodePrimString { + tag: 'prim-string', + val: string, + } + export interface WitNodeHandle { + tag: 'handle', + val: [Uri, bigint], + } + export interface WitValue { + nodes: WitNode[], + } + export type RpcError = RpcErrorProtocolError | RpcErrorDenied | RpcErrorNotFound | RpcErrorRemoteInternalError; + export interface RpcErrorProtocolError { + tag: 'protocol-error', + val: string, + } + export interface RpcErrorDenied { + tag: 'denied', + val: string, + } + export interface RpcErrorNotFound { + tag: 'not-found', + val: string, + } + export interface RpcErrorRemoteInternalError { + tag: 'remote-internal-error', + val: string, + } + + export class WasmRpc { + constructor(location: Uri) + invokeAndAwait(functionName: string, functionParams: WitValue[]): Result; + invoke(functionName: string, functionParams: WitValue[]): Result; + } + + export type Result = { tag: 'ok', val: T } | { tag: 'err', val: E }; + } + "#, + }, + ExpectedTs { + file_name: "interfaces/rpc-counters-stub-stub-counters.d.ts", + expected: r#" + declare module "rpc:counters-stub/stub-counters" { + import type { Uri } from "golem:rpc/types@0.1.0"; + import type { CounterValue } from "rpc:counters/api"; + + export class Api { + constructor(location: Uri) + incGlobalBy(value: bigint): void; + getGlobalValue(): CounterValue; + getAllDropped(): [string, bigint][]; + } + + export class Counter { + constructor(location: Uri, name: string) + incBy(value: bigint): void; + getValue(): CounterValue; + } + } + "#, + }, + ExpectedTs { + file_name: "interfaces/rpc-counters-api.d.ts", + expected: r#" + declare module "rpc:counters/api" { + export interface CounterValue { + value: bigint, + } + export function incGlobalBy(value: bigint): void; + export function getGlobalValue(): CounterValue; + export function getAllDropped(): [string, bigint][]; + + export class Counter { + constructor(name: string) + incBy(value: bigint): void; + getValue(): CounterValue; + } + } + "#, + }, + ]; + + test_files(wit, expected); +} + +#[test] +fn inline_interface() { + let wit = " + + package test:inline; + + world test { + export example: interface { + do-nothing: func(); + } + } + "; + + let expected = " + export interface Example { + doNothing(): void, + } + + export interface TestWorld { + example: Example, + } + "; + + test_single_file(wit, expected); +} + +struct WitFile { + wit: &'static str, +} + +struct ExpectedTs { + file_name: &'static str, + expected: &'static str, +} + +#[track_caller] +fn test_files(wit: &[WitFile], expected: &[ExpectedTs]) { + let mut resolver = js_component_bindgen::source::wit_parser::Resolve::default(); + + for (ii, wit_file) in wit.iter().enumerate() { + let file_name = format!("tests{ii}.wit"); + let package = + UnresolvedPackage::parse(file_name.as_ref(), wit_file.wit).expect("valid wit"); + resolver.push(package).expect("push package"); + } + + let world = resolver + .worlds + .iter() + .find(|(_, w)| w.name == "test") + .expect("world exists") + .0; + + let mut files = generate_typescript_stubs(resolver, world) + .expect("Successful generation") + .into_iter() + .collect::>(); + + if IS_DEBUG { + write_files(files.iter()); + } + + for ExpectedTs { + file_name, + expected, + } in expected + { + let Some(file) = files.remove(*file_name) else { + let all_files = files.iter().map(|(name, _)| name).collect::>(); + panic!("Expected file `{file_name}` not found in files: {all_files:?}",) + }; + let actual = std::str::from_utf8(&file).expect("valid utf8"); + compare_str(actual, expected); + } + + if !files.is_empty() { + let all_files = files.iter().map(|(name, _)| name).collect::>(); + panic!("Missing expected files: {all_files:?}") + } +} + +#[track_caller] +fn test_single_file(wit: &'static str, expected: &'static str) { + test_files( + &[WitFile { wit }], + &[ExpectedTs { + file_name: "test.d.ts", + expected, + }], + ) +} + +#[track_caller] +fn compare_str(actual: &str, expected: &str) { + fn remove_whitespace(s: &str) -> impl Iterator { + s.lines().map(|l| l.trim()).filter(|l| !l.is_empty()) + } + + let mut expected_iter = remove_whitespace(expected); + let mut actual_iter = remove_whitespace(actual); + + loop { + match (expected_iter.next(), actual_iter.next()) { + (None, None) => break, + (Some(e), Some(a)) => { + assert_eq!(e, a, "\nExpected:`{e}`\nActual:`{a}`\nFull:\n{actual}"); + } + (e, a) => { + assert_eq!(e, a, "\nExpected:`{e:?}`\nActual:`{a:?}`\nFull:\n{actual}"); + } + } + } +} + +fn write_files<'a>(files: impl Iterator)>) { + let prefix = std::path::Path::new("tests/temp"); + let _ = std::fs::remove_dir_all(&prefix); + for (name, data) in files { + let name = name.to_string(); + let data = String::from_utf8(data.to_vec()).unwrap(); + let path = prefix.join(name); + + // Create the parent directory if it doesn't exist + std::fs::create_dir_all(path.parent().unwrap()).expect("Create parent directory"); + + std::fs::write(path, data).expect("Write file"); + } +} diff --git a/src/cmd/stubgen.js b/src/cmd/stubgen.js new file mode 100644 index 000000000..7d7981ab2 --- /dev/null +++ b/src/cmd/stubgen.js @@ -0,0 +1,30 @@ +import { $init, generateTypescriptStubs} from '../../obj/js-component-bindgen-component.js'; +import { resolve } from 'node:path'; +import { platform } from 'node:process'; +import { writeFiles } from '../common.js' + +const isWindows = platform === 'win32'; + +export async function stubgen (witPath, opts) { + const files = await stubgenWit(witPath, opts); + await writeFiles(files, opts.quiet ? false : 'Generated Typescript Files'); +} + +/** + * @param {string} witPath + * @param {{ + * worldName?: string, + * outDir?: string, + * }} opts + * @returns {Promise<{ [filename: string]: Uint8Array }>} + */ +async function stubgenWit (witPath, opts) { + await $init; + let outDir = (opts.outDir ?? '').replace(/\\/g, '/'); + if (!outDir.endsWith('/') && outDir !== '') + outDir += '/'; + return Object.fromEntries(generateTypescriptStubs({ + wit: { tag: 'path', val: (isWindows ? '//?/' : '') + resolve(witPath) }, + world: opts.worldName + }).map(([name, file]) => [`${outDir}${name}`, file])); +} \ No newline at end of file diff --git a/src/cmd/transpile.js b/src/cmd/transpile.js index 088028b77..91e9765a6 100644 --- a/src/cmd/transpile.js +++ b/src/cmd/transpile.js @@ -1,9 +1,7 @@ import { $init, generate, generateTypes } from '../../obj/js-component-bindgen-component.js'; -import { writeFile } from 'node:fs/promises'; -import { mkdir } from 'node:fs/promises'; -import { dirname, extname, basename, resolve } from 'node:path'; +import { extname, basename, resolve } from 'node:path'; import c from 'chalk-template'; -import { readFile, sizeStr, table, spawnIOTmp, setShowSpinner, getShowSpinner } from '../common.js'; +import { readFile, writeFiles, spawnIOTmp, setShowSpinner, getShowSpinner} from '../common.js'; import { optimizeComponent } from './opt.js'; import { minify } from 'terser'; import { fileURLToPath } from 'url'; @@ -50,22 +48,6 @@ export async function typesComponent (witPath, opts) { }).map(([name, file]) => [`${outDir}${name}`, file])); } -async function writeFiles(files, summaryTitle) { - await Promise.all(Object.entries(files).map(async ([name, file]) => { - await mkdir(dirname(name), { recursive: true }); - await writeFile(name, file); - })); - if (!summaryTitle) - return; - console.log(c` - {bold ${summaryTitle}:} - -${table(Object.entries(files).map(([name, source]) => [ - c` - {italic ${name}} `, - c`{black.italic ${sizeStr(source.length)}}` - ]))}`); -} - export async function transpile (componentPath, opts, program) { const varIdx = program?.parent.rawArgs.indexOf('--'); if (varIdx !== undefined && varIdx !== -1) diff --git a/src/common.js b/src/common.js index 657abdcb7..30b8628b2 100644 --- a/src/common.js +++ b/src/common.js @@ -1,6 +1,6 @@ -import { normalize, resolve, sep } from 'node:path'; +import { dirname, normalize, resolve, sep } from 'node:path'; import { tmpdir } from 'node:os'; -import { readFile, writeFile, rm, mkdtemp } from 'node:fs/promises'; +import { readFile, writeFile, rm, mkdir, mkdtemp } from 'node:fs/promises'; import { spawn } from 'node:child_process'; import { argv0 } from 'node:process'; import c from 'chalk-template'; @@ -108,3 +108,19 @@ export async function spawnIOTmp (cmd, input, args) { await rm(tmpDir, { recursive: true }); } } + +export async function writeFiles(files, summaryTitle) { + await Promise.all(Object.entries(files).map(async ([name, file]) => { + await mkdir(dirname(name), { recursive: true }); + await writeFile(name, file); + })); + if (!summaryTitle) + return; + console.log(c` + {bold ${summaryTitle}:} + +${table(Object.entries(files).map(([name, source]) => [ + c` - {italic ${name}} `, + c`{black.italic ${sizeStr(source.length)}}` + ]))}`); +} diff --git a/src/jco.js b/src/jco.js index 15ac28af1..129925aae 100755 --- a/src/jco.js +++ b/src/jco.js @@ -2,6 +2,7 @@ import { program, Option } from 'commander'; import { opt } from './cmd/opt.js'; import { transpile, types } from './cmd/transpile.js'; +import { stubgen } from './cmd/stubgen.js' import { run as runCmd, serve as serveCmd } from './cmd/run.js'; import { parse, print, componentNew, componentEmbed, metadataAdd, metadataShow, componentWit } from './cmd/wasm-tools.js'; import { componentize } from './cmd/componentize.js'; @@ -54,6 +55,15 @@ program.command('transpile') .option('--', 'for --optimize, custom wasm-opt arguments (defaults to best size optimization)') .action(asyncAction(transpile)); +program.command('stubgen') + .description("Generate typescript stubs based on a WIT component defintion") + .usage(' -o ') + .argument('', "Path to WIT definitions") + .requiredOption('-o, --out-dir ', 'output directory') + .option('-n, --world-name ', 'WIT world to generate types for') + .option('-q, --quiet', 'disable output summary') + .action(asyncAction(stubgen)) + program.command('types') .description('Generate types for the given WIT') .usage(' -o ')