Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for static enum methods via TS namespaces #4258

Draft
wants to merge 10 commits into
base: main
Choose a base branch
from
Next Next commit
Add support for static enum methods via TS namespaces
RunDevelopment committed Nov 9, 2024
commit 631c5a70d6715043b2f7e9fbf2d4008a1237ba8a
309 changes: 262 additions & 47 deletions crates/cli-support/src/js/mod.rs
Original file line number Diff line number Diff line change
@@ -61,6 +61,8 @@ pub struct Context<'a> {

exported_classes: Option<BTreeMap<String, ExportedClass>>,

exported_namespaces: Option<BTreeMap<String, ExportedNamespace>>,

/// A map of the name of npm dependencies we've loaded so far to the path
/// they're defined in as well as their version specification.
pub npm_dependencies: HashMap<String, (PathBuf, String)>,
@@ -120,6 +122,35 @@ struct FieldAccessor {
is_optional: bool,
}

struct ExportedNamespace {
name: String,
contents: String,
/// The TypeScript for the namespace's methods.
typescript: String,
/// Whether TypeScript for this namespace should be emitted (i.e., `skip_typescript` wasn't specified).
generate_typescript: bool,
}

enum ClassOrNamespace<'a> {
Class(&'a mut ExportedClass),
Namespace(&'a mut ExportedNamespace),
}

/// Different JS constructs that can be exported.
enum ExportJs<'a> {
/// A class of the form `class Name {...}`.
Class(&'a str),
/// An anonymous function expression of the form `function(...) {...}`.
///
/// Note that the function name is not included in the string.
Function(&'a str),
/// An arbitrary JS expression.
Expression(&'a str),
/// A namespace as a function expression for initiating the namespace. The
/// function expression is of the form `(function(Name) {...})`.
Namespace(&'a str),
}

const INITIAL_HEAP_VALUES: &[&str] = &["undefined", "null", "true", "false"];
// Must be kept in sync with `src/lib.rs` of the `wasm-bindgen` crate
const INITIAL_HEAP_OFFSET: usize = 128;
@@ -143,6 +174,7 @@ impl<'a> Context<'a> {
typescript_refs: Default::default(),
used_string_enums: Default::default(),
exported_classes: Some(Default::default()),
exported_namespaces: Some(Default::default()),
config,
threads_enabled: config.threads.is_enabled(module),
module,
@@ -163,38 +195,72 @@ impl<'a> Context<'a> {
fn export(
&mut self,
export_name: &str,
contents: &str,
export: ExportJs,
comments: Option<&str>,
) -> Result<(), Error> {
let definition_name = self.generate_identifier(export_name);
if contents.starts_with("class") && definition_name != export_name {
// The definition is intended to allow for exports to be renamed to
// avoid conflicts. Since namespaces intentionally have the same name as
// other exports, we must not rename them.
let definition_name = if matches!(export, ExportJs::Namespace(_)) {
export_name.to_owned()
} else {
self.generate_identifier(export_name)
};

if matches!(export, ExportJs::Class(_)) && definition_name != export_name {
bail!("cannot shadow already defined class `{}`", export_name);
}

let contents = contents.trim();
// write out comments
if let Some(c) = comments {
self.globals.push_str(c);
}

fn namespace_init_arg(name: &str) -> String {
format!("{name} || ({name} = {{}})", name = name)
}

let global = match self.config.mode {
OutputMode::Node { module: false } => {
if contents.starts_with("class") {
format!("{}\nmodule.exports.{1} = {1};\n", contents, export_name)
} else {
format!("module.exports.{} = {};\n", export_name, contents)
OutputMode::Node { module: false } => match export {
ExportJs::Class(class) => {
format!("{}\nmodule.exports.{1} = {1};\n", class, export_name)
}
}
OutputMode::NoModules { .. } => {
if contents.starts_with("class") {
format!("{}\n__exports.{1} = {1};\n", contents, export_name)
} else {
format!("__exports.{} = {};\n", export_name, contents)
ExportJs::Function(expr) | ExportJs::Expression(expr) => {
format!("module.exports.{} = {};\n", export_name, expr)
}
}
ExportJs::Namespace(namespace) => {
format!(
"{}({});\n",
namespace,
namespace_init_arg(&format!("module.exports.{}", export_name))
)
}
},
OutputMode::NoModules { .. } => match export {
ExportJs::Class(class) => {
format!("{}\n__exports.{1} = {1};\n", class, export_name)
}
ExportJs::Function(expr) | ExportJs::Expression(expr) => {
format!("__exports.{} = {};\n", export_name, expr)
}
ExportJs::Namespace(namespace) => {
format!(
"{}({});\n",
namespace,
namespace_init_arg(&format!("__exports.{}", export_name))
)
}
},
OutputMode::Bundler { .. }
| OutputMode::Node { module: true }
| OutputMode::Web
| OutputMode::Deno => {
if let Some(body) = contents.strip_prefix("function") {
| OutputMode::Deno => match export {
ExportJs::Class(class) => {
assert_eq!(export_name, definition_name);
format!("export {}\n", class)
}
ExportJs::Function(function) => {
let body = function.strip_prefix("function").unwrap();
if export_name == definition_name {
format!("export function {}{}\n", export_name, body)
} else {
@@ -203,14 +269,25 @@ impl<'a> Context<'a> {
definition_name, body, definition_name, export_name,
)
}
} else if contents.starts_with("class") {
}
ExportJs::Expression(expr) => {
assert_eq!(export_name, definition_name);
format!("export {}\n", contents)
} else {
format!("export const {} = {};\n", export_name, expr)
}
ExportJs::Namespace(namespace) => {
assert_eq!(export_name, definition_name);
format!("export const {} = {};\n", export_name, contents)

// In some cases (e.g. string enums), a namespace may be
// exported without an existing object of the same name.
// In that case, we need to create the object before
// initializing the namespace.
let mut definition = String::new();
if !self.defined_identifiers.contains_key(export_name) {
definition = format!("export const {} = {{}};\n", export_name)
}
format!("{}{}({});\n", definition, namespace, export_name)
}
}
},
};
self.global(&global);
Ok(())
@@ -225,6 +302,9 @@ impl<'a> Context<'a> {
// `__wrap` and such.
self.write_classes()?;

// Write out generated JS for namespaces.
self.write_namespaces()?;

// Initialization is just flat out tricky and not something we
// understand super well. To try to handle various issues that have come
// up we always remove the `start` function if one is present. The JS
@@ -1005,6 +1085,22 @@ __wbg_set_wasm(wasm);"
Ok((js, ts))
}

fn require_class_or_namespace(&mut self, name: &str) -> ClassOrNamespace {
if self.aux.enums.contains_key(name) || self.aux.string_enums.contains_key(name) {
ClassOrNamespace::Namespace(self.require_namespace(name))
} else {
ClassOrNamespace::Class(self.require_class(name))
}
}

fn require_class(&mut self, name: &str) -> &'_ mut ExportedClass {
self.exported_classes
.as_mut()
.expect("classes already written")
.entry(name.to_string())
.or_default()
}

fn write_classes(&mut self) -> Result<(), Error> {
for (class, exports) in self.exported_classes.take().unwrap() {
self.write_class(&class, &exports)?;
@@ -1152,10 +1248,10 @@ __wbg_set_wasm(wasm);"

self.write_class_field_types(class, &mut ts_dst);

dst.push_str("}\n");
dst.push('}');
ts_dst.push_str("}\n");

self.export(name, &dst, Some(&class.comments))?;
self.export(name, ExportJs::Class(&dst), Some(&class.comments))?;

if class.generate_typescript {
self.typescript.push_str(&class.comments);
@@ -1283,6 +1379,57 @@ __wbg_set_wasm(wasm);"
}
}

fn require_namespace(&mut self, name: &str) -> &'_ mut ExportedNamespace {
self.exported_namespaces
.as_mut()
.expect("namespaces already written")
.entry(name.to_string())
.or_insert_with(|| {
let _enum = self.aux.enums.get(name);
let string_enum = self.aux.string_enums.get(name);

let generate_typescript = _enum.map_or(true, |e| e.generate_typescript)
&& string_enum.map_or(true, |e| e.generate_typescript);

ExportedNamespace {
name: name.to_string(),
contents: String::new(),
typescript: String::new(),
generate_typescript,
}
})
}

fn write_namespaces(&mut self) -> Result<(), Error> {
for (class, namespace) in self.exported_namespaces.take().unwrap() {
self.write_namespace(&class, &namespace)?;
}
Ok(())
}

fn write_namespace(&mut self, name: &str, namespace: &ExportedNamespace) -> Result<(), Error> {
if namespace.contents.is_empty() {
// don't emit empty namespaces
return Ok(());
}

let dst = format!(
"(function({name}) {{\n{contents}}})",
contents = namespace.contents
);
self.export(name, ExportJs::Namespace(&dst), None)?;

if namespace.generate_typescript {
let ts_dst = format!(
"export namespace {name} {{\n{ts}}}\n",
ts = namespace.typescript
);
self.typescript.push_str(&ts_dst);
}

Ok(())
}

fn expose_drop_ref(&mut self) {
if !self.should_write_global("drop_ref") {
return;
@@ -2461,11 +2608,11 @@ __wbg_set_wasm(wasm);"
}

fn require_class_wrap(&mut self, name: &str) {
require_class(&mut self.exported_classes, name).wrap_needed = true;
self.require_class(name).wrap_needed = true;
}

fn require_class_unwrap(&mut self, name: &str) {
require_class(&mut self.exported_classes, name).unwrap_needed = true;
self.require_class(name).unwrap_needed = true;
}

fn add_module_import(&mut self, module: String, name: &str, actual: &str) {
@@ -2831,11 +2978,15 @@ __wbg_set_wasm(wasm);"
self.typescript.push_str(";\n");
}

self.export(name, &format!("function{}", code), Some(&js_docs))?;
self.export(
name,
ExportJs::Function(&format!("function{}", code)),
Some(&js_docs),
)?;
self.globals.push('\n');
}
AuxExportKind::Constructor(class) => {
let exported = require_class(&mut self.exported_classes, class);
let exported = self.require_class(class);

if exported.has_constructor {
bail!("found duplicate constructor for class `{}`", class);
@@ -2850,7 +3001,7 @@ __wbg_set_wasm(wasm);"
receiver,
kind,
} => {
let exported = require_class(&mut self.exported_classes, class);
let mut exported = self.require_class_or_namespace(class);

let mut prefix = String::new();
if receiver.is_static() {
@@ -2859,6 +3010,17 @@ __wbg_set_wasm(wasm);"
let ts = match kind {
AuxExportedMethodKind::Method => ts_sig,
AuxExportedMethodKind::Getter => {
let class = match exported {
ClassOrNamespace::Class(ref mut class) => class,
ClassOrNamespace::Namespace(_) => {
bail!(
"the getter `{}` is not supported on `{}`. Enums only support static methods on them.",
name,
class
);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above.

}
};

prefix += "get ";
// For getters and setters, we generate a separate TypeScript definition.
if export.generate_typescript {
@@ -2873,14 +3035,25 @@ __wbg_set_wasm(wasm);"
is_optional: false,
};

exported.push_accessor_ts(location, accessor, false);
class.push_accessor_ts(location, accessor, false);
}
// Add the getter to the list of readable fields (used to generate `toJSON`)
exported.readable_properties.push(name.clone());
class.readable_properties.push(name.clone());
// Ignore the raw signature.
None
}
AuxExportedMethodKind::Setter => {
let class = match exported {
ClassOrNamespace::Class(ref mut class) => class,
ClassOrNamespace::Namespace(_) => {
bail!(
"the setter `{}` is not supported on `{}`. Enums only support static methods on them.",
name,
class
);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above.

}
};

prefix += "set ";
if export.generate_typescript {
let location = FieldLocation {
@@ -2893,13 +3066,27 @@ __wbg_set_wasm(wasm);"
is_optional: might_be_optional_field,
};

exported.push_accessor_ts(location, accessor, true);
class.push_accessor_ts(location, accessor, true);
}
None
}
};

exported.push(name, &prefix, &js_docs, &code, &ts_docs, ts);
match exported {
ClassOrNamespace::Class(class) => {
class.push(name, &prefix, &js_docs, &code, &ts_docs, ts);
}
ClassOrNamespace::Namespace(ns) => {
if !receiver.is_static() {
bail!(
"non-static method `{}` on namespace `{}`",
name,
ns.name
);
}
ns.push(name, &js_docs, &code, &ts_docs, ts);
}
}
}
}
}
@@ -3957,7 +4144,7 @@ __wbg_set_wasm(wasm);"

self.export(
&enum_.name,
&format!("Object.freeze({{\n{}}})", variants),
ExportJs::Expression(&format!("{{\n{}}}", variants)),
Some(&docs),
)?;

@@ -4008,7 +4195,7 @@ __wbg_set_wasm(wasm);"
}

fn generate_struct(&mut self, struct_: &AuxStruct) -> Result<(), Error> {
let class = require_class(&mut self.exported_classes, &struct_.name);
let class = self.require_class(&struct_.name);
class.comments = format_doc_comments(&struct_.comments, None);
class.is_inspectable = struct_.is_inspectable;
class.generate_typescript = struct_.generate_typescript;
@@ -4464,17 +4651,6 @@ fn format_doc_comments(comments: &str, js_doc_comments: Option<String>) -> Strin
}
}

fn require_class<'a>(
exported_classes: &'a mut Option<BTreeMap<String, ExportedClass>>,
name: &str,
) -> &'a mut ExportedClass {
exported_classes
.as_mut()
.expect("classes already written")
.entry(name.to_string())
.or_default()
}

/// Returns whether a character has the Unicode `ID_Start` properly.
///
/// This is only ever-so-slightly different from `XID_Start` in a few edge
@@ -4588,6 +4764,45 @@ impl ExportedClass {
}
}

impl ExportedNamespace {
fn push(
&mut self,
function_name: &str,
js_docs: &str,
js: &str,
ts_docs: &str,
ts: Option<&str>,
) {
self.contents.push_str(js_docs);
self.contents.push_str("function ");
self.contents.push_str(function_name);
self.contents.push_str(js);
self.contents.push('\n');
self.contents.push_str(&self.name);
self.contents.push('.');
self.contents.push_str(function_name);
self.contents.push_str(" = ");
self.contents.push_str(function_name);
self.contents.push_str(";\n");

if let Some(ts) = ts {
if !ts_docs.is_empty() {
for line in ts_docs.lines() {
self.typescript.push_str(" ");
self.typescript.push_str(line);
self.typescript.push('\n');
}
}
self.typescript.push_str(" export function ");
self.typescript.push_str(function_name);
self.typescript.push_str(ts);
self.typescript.push_str(";\n");
}
}
}

impl ClassOrNamespace<'_> {}

struct MemView {
name: Cow<'static, str>,
num: usize,
8 changes: 4 additions & 4 deletions crates/cli/tests/reference/enums.js
Original file line number Diff line number Diff line change
@@ -66,7 +66,7 @@ export function option_string_enum_echo(color) {
* A color.
* @enum {0 | 1 | 2}
*/
export const Color = Object.freeze({
export const Color = {
/**
* Green as a leaf.
*/
@@ -79,16 +79,16 @@ export const Color = Object.freeze({
* Red as a rose.
*/
Red: 2, "2": "Red",
});
};
/**
* @enum {0 | 1 | 42 | 43}
*/
export const ImplicitDiscriminant = Object.freeze({
export const ImplicitDiscriminant = {
A: 0, "0": "A",
B: 1, "1": "B",
C: 42, "42": "C",
D: 43, "43": "D",
});
};

const __wbindgen_enum_ColorName = ["green", "yellow", "red"];

23 changes: 23 additions & 0 deletions crates/cli/tests/reference/namespace.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/* tslint:disable */
/* eslint-disable */
/**
* C-style enum
*/
export enum ImageFormat {
PNG = 0,
JPEG = 1,
GIF = 2,
}
/**
* String enum
*/
type Status = "success" | "failure";
export namespace ImageFormat {
export function from_str(s: string): ImageFormat;
}
export namespace Status {
/**
* I have documentation.
*/
export function from_bool(success: boolean): Status;
}
127 changes: 127 additions & 0 deletions crates/cli/tests/reference/namespace.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
let wasm;
export function __wbg_set_wasm(val) {
wasm = val;
}


const lTextDecoder = typeof TextDecoder === 'undefined' ? (0, module.require)('util').TextDecoder : TextDecoder;

let cachedTextDecoder = new lTextDecoder('utf-8', { ignoreBOM: true, fatal: true });

cachedTextDecoder.decode();

let cachedUint8ArrayMemory0 = null;

function getUint8ArrayMemory0() {
if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) {
cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer);
}
return cachedUint8ArrayMemory0;
}

function getStringFromWasm0(ptr, len) {
ptr = ptr >>> 0;
return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len));
}

let WASM_VECTOR_LEN = 0;

const lTextEncoder = typeof TextEncoder === 'undefined' ? (0, module.require)('util').TextEncoder : TextEncoder;

let cachedTextEncoder = new lTextEncoder('utf-8');

const encodeString = (typeof cachedTextEncoder.encodeInto === 'function'
? function (arg, view) {
return cachedTextEncoder.encodeInto(arg, view);
}
: function (arg, view) {
const buf = cachedTextEncoder.encode(arg);
view.set(buf);
return {
read: arg.length,
written: buf.length
};
});

function passStringToWasm0(arg, malloc, realloc) {

if (realloc === undefined) {
const buf = cachedTextEncoder.encode(arg);
const ptr = malloc(buf.length, 1) >>> 0;
getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf);
WASM_VECTOR_LEN = buf.length;
return ptr;
}

let len = arg.length;
let ptr = malloc(len, 1) >>> 0;

const mem = getUint8ArrayMemory0();

let offset = 0;

for (; offset < len; offset++) {
const code = arg.charCodeAt(offset);
if (code > 0x7F) break;
mem[ptr + offset] = code;
}

if (offset !== len) {
if (offset !== 0) {
arg = arg.slice(offset);
}
ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0;
const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len);
const ret = encodeString(arg, view);

offset += ret.written;
ptr = realloc(ptr, len, offset, 1) >>> 0;
}

WASM_VECTOR_LEN = offset;
return ptr;
}
/**
* C-style enum
* @enum {0 | 1 | 2}
*/
export const ImageFormat = {
PNG: 0, "0": "PNG",
JPEG: 1, "1": "JPEG",
GIF: 2, "2": "GIF",
};

const __wbindgen_enum_Status = ["success", "failure"];

(function(ImageFormat) {
/**
* @param {string} s
* @returns {ImageFormat}
*/
function from_str(s) {
const ptr0 = passStringToWasm0(s, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.imageformat_from_str(ptr0, len0);
return ret;
}
ImageFormat.from_str = from_str;
})(ImageFormat);

export const Status = {};
(function(Status) {
/**
* I have documentation.
* @param {boolean} success
* @returns {Status}
*/
function from_bool(success) {
const ret = wasm.status_from_bool(success);
return __wbindgen_enum_Status[ret];
}
Status.from_bool = from_bool;
})(Status);

export function __wbindgen_throw(arg0, arg1) {
throw new Error(getStringFromWasm0(arg0, arg1));
};

42 changes: 42 additions & 0 deletions crates/cli/tests/reference/namespace.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
use wasm_bindgen::prelude::*;

/// C-style enum
#[wasm_bindgen]
#[derive(Copy, Clone)]
pub enum ImageFormat {
PNG,
JPEG,
GIF,
}

#[wasm_bindgen]
impl ImageFormat {
pub fn from_str(s: &str) -> ImageFormat {
match s {
"PNG" => ImageFormat::PNG,
"JPEG" => ImageFormat::JPEG,
"GIF" => ImageFormat::GIF,
_ => panic!("unknown image format: {}", s),
}
}
}

/// String enum
#[wasm_bindgen]
#[derive(Copy, Clone)]
pub enum Status {
Success = "success",
Failure = "failure",
}

#[wasm_bindgen]
impl Status {
/// I have documentation.
pub fn from_bool(success: bool) -> Status {
if success {
Status::Success
} else {
Status::Failure
}
}
}
17 changes: 17 additions & 0 deletions crates/cli/tests/reference/namespace.wat
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
(module $reference_test.wasm
(type (;0;) (func (param i32) (result i32)))
(type (;1;) (func (param i32 i32) (result i32)))
(type (;2;) (func (param i32 i32 i32 i32) (result i32)))
(func $__wbindgen_realloc (;0;) (type 2) (param i32 i32 i32 i32) (result i32))
(func $__wbindgen_malloc (;1;) (type 1) (param i32 i32) (result i32))
(func $imageformat_from_str (;2;) (type 1) (param i32 i32) (result i32))
(func $status_from_bool (;3;) (type 0) (param i32) (result i32))
(memory (;0;) 17)
(export "memory" (memory 0))
(export "imageformat_from_str" (func $imageformat_from_str))
(export "status_from_bool" (func $status_from_bool))
(export "__wbindgen_malloc" (func $__wbindgen_malloc))
(export "__wbindgen_realloc" (func $__wbindgen_realloc))
(@custom "target_features" (after code) "\04+\0amultivalue+\0fmutable-globals+\0freference-types+\08sign-ext")
)