diff --git a/crates/backend/src/ast.rs b/crates/backend/src/ast.rs index a462a112bc9..1f09d9b0be1 100644 --- a/crates/backend/src/ast.rs +++ b/crates/backend/src/ast.rs @@ -223,6 +223,7 @@ pub struct Struct { pub js_name: String, pub fields: Vec, pub comments: Vec, + pub is_inspectable: bool, } #[cfg_attr(feature = "extra-traits", derive(Debug, PartialEq, Eq))] diff --git a/crates/backend/src/encode.rs b/crates/backend/src/encode.rs index 23a233adf04..c678dd8f924 100644 --- a/crates/backend/src/encode.rs +++ b/crates/backend/src/encode.rs @@ -306,6 +306,7 @@ fn shared_struct<'a>(s: &'a ast::Struct, intern: &'a Interner) -> Struct<'a> { .map(|s| shared_struct_field(s, intern)) .collect(), comments: s.comments.iter().map(|s| &**s).collect(), + is_inspectable: s.is_inspectable, } } diff --git a/crates/cli-support/src/js/mod.rs b/crates/cli-support/src/js/mod.rs index b31f406a896..d1dcfad84b2 100644 --- a/crates/cli-support/src/js/mod.rs +++ b/crates/cli-support/src/js/mod.rs @@ -57,6 +57,10 @@ pub struct ExportedClass { typescript: String, has_constructor: bool, wrap_needed: bool, + /// Whether to generate helper methods for inspecting the class + is_inspectable: bool, + /// All readable properties of the class + readable_properties: Vec, /// Map from field name to type as a string plus whether it has a setter typescript_fields: HashMap, } @@ -644,6 +648,54 @@ impl<'a> Context<'a> { )); } + // If the class is inspectable, generate `toJSON` and `toString` + // to expose all readable properties of the class. Otherwise, + // the class shows only the "ptr" property when logged or serialized + if class.is_inspectable { + // Creates a `toJSON` method which returns an object of all readable properties + // This object looks like { a: this.a, b: this.b } + dst.push_str(&format!( + " + toJSON() {{ + return {{{}}}; + }} + + toString() {{ + return JSON.stringify(this); + }} + ", + class + .readable_properties + .iter() + .fold(String::from("\n"), |fields, field_name| { + format!("{}{name}: this.{name},\n", fields, name = field_name) + }) + )); + + if self.config.mode.nodejs() { + // `util.inspect` must be imported in Node.js to define [inspect.custom] + let module_name = self.import_name(&JsImport { + name: JsImportName::Module { + module: "util".to_string(), + name: "inspect".to_string(), + }, + fields: Vec::new(), + })?; + + // Node.js supports a custom inspect function to control the + // output of `console.log` and friends. The constructor is set + // to display the class name as a typical JavaScript class would + dst.push_str(&format!( + " + [{}.custom]() {{ + return Object.assign(Object.create({{constructor: this.constructor}}), this.toJSON()); + }} + ", + module_name + )); + } + } + dst.push_str(&format!( " free() {{ @@ -2723,6 +2775,7 @@ impl<'a> Context<'a> { fn generate_struct(&mut self, struct_: &AuxStruct) -> Result<(), Error> { let class = require_class(&mut self.exported_classes, &struct_.name); class.comments = format_doc_comments(&struct_.comments, None); + class.is_inspectable = struct_.is_inspectable; Ok(()) } @@ -2975,6 +3028,7 @@ impl ExportedClass { /// generation is handled specially. fn push_getter(&mut self, docs: &str, field: &str, js: &str, ret_ty: &str) { self.push_accessor(docs, field, js, "get ", ret_ty); + self.readable_properties.push(field.to_string()); } /// Used for adding a setter to a class, mainly to ensure that TypeScript diff --git a/crates/cli-support/src/webidl/mod.rs b/crates/cli-support/src/webidl/mod.rs index 3519d1a4c61..da0ae730b28 100644 --- a/crates/cli-support/src/webidl/mod.rs +++ b/crates/cli-support/src/webidl/mod.rs @@ -256,6 +256,8 @@ pub struct AuxStruct { pub name: String, /// The copied Rust comments to forward to JS pub comments: String, + /// Whether to generate helper methods for inspecting the class + pub is_inspectable: bool, } /// All possible types of imports that can be imported by a wasm module. @@ -1238,6 +1240,7 @@ impl<'a> Context<'a> { let aux = AuxStruct { name: struct_.name.to_string(), comments: concatenate_comments(&struct_.comments), + is_inspectable: struct_.is_inspectable, }; self.aux.structs.push(aux); diff --git a/crates/macro-support/src/parser.rs b/crates/macro-support/src/parser.rs index 8be880bc4d0..38edd2d4d96 100644 --- a/crates/macro-support/src/parser.rs +++ b/crates/macro-support/src/parser.rs @@ -45,6 +45,7 @@ macro_rules! attrgen { (readonly, Readonly(Span)), (js_name, JsName(Span, String, Span)), (js_class, JsClass(Span, String, Span)), + (inspectable, Inspectable(Span)), (is_type_of, IsTypeOf(Span, syn::Expr)), (extends, Extends(Span, syn::Path)), (vendor_prefix, VendorPrefix(Span, Ident)), @@ -322,6 +323,7 @@ impl<'a> ConvertToAst for &'a mut syn::ItemStruct { .js_name() .map(|s| s.0.to_string()) .unwrap_or(self.ident.to_string()); + let is_inspectable = attrs.inspectable().is_some(); for (i, field) in self.fields.iter_mut().enumerate() { match field.vis { syn::Visibility::Public(..) => {} @@ -361,6 +363,7 @@ impl<'a> ConvertToAst for &'a mut syn::ItemStruct { js_name, fields, comments, + is_inspectable, }) } } diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index b8fa9f2413d..ceea8baf210 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -116,6 +116,7 @@ macro_rules! shared_api { name: &'a str, fields: Vec>, comments: Vec<&'a str>, + is_inspectable: bool, } struct StructField<'a> { diff --git a/guide/src/reference/attributes/on-rust-exports/inspectable.md b/guide/src/reference/attributes/on-rust-exports/inspectable.md new file mode 100644 index 00000000000..30ff462973e --- /dev/null +++ b/guide/src/reference/attributes/on-rust-exports/inspectable.md @@ -0,0 +1,53 @@ +# `inspectable` + +By default, structs exported from Rust become JavaScript classes with a single `ptr` property. All other properties are implemented as getters, which are not displayed when calling `toJSON`. + +The `inspectable` attribute can be used on Rust structs to provide a `toJSON` and `toString` implementation that display all readable fields. For example: + +```rust +#[wasm_bindgen(inspectable)] +pub struct Baz { + pub field: i32, + private: i32, +} + +#[wasm_bindgen] +impl Baz { + #[wasm_bindgen(constructor)] + pub fn new(field: i32) -> Baz { + Baz { field, private: 13 } + } +} +``` + +Provides the following behavior as in this JavaScript snippet: + +```js +const obj = new Baz(3); +assert.deepStrictEqual(obj.toJSON(), { field: 3 }); +obj.field = 4; +assert.strictEqual(obj.toString(), '{"field":4}'); +``` + +One or both of these implementations can be overridden as desired. Note that the generated `toString` calls `toJSON` internally, so overriding `toJSON` will affect its output as a side effect. + +```rust +#[wasm_bindgen] +impl Baz { + #[wasm_bindgen(js_name = toJSON)] + pub fn to_json(&self) -> i32 { + self.field + } + + #[wasm_bindgen(js_name = toString)] + pub fn to_string(&self) -> String { + format!("Baz: {}", self.field) + } +} +``` + +Note that the output of `console.log` will remain unchanged and display only the `ptr` field in browsers. It is recommended to call `toJSON` or `JSON.stringify` in these situations to aid with logging or debugging. Node.js does not suffer from this limitation, see the section below. + +## `inspectable` Classes in Node.js + +When the `nodejs` target is used, an additional `[util.inspect.custom]` implementation is provided which calls `toJSON` internally. This method is used for `console.log` and similar functions to display all readable fields of the Rust struct. diff --git a/tests/wasm/classes.js b/tests/wasm/classes.js index 86de4727457..9033aa5cdac 100644 --- a/tests/wasm/classes.js +++ b/tests/wasm/classes.js @@ -170,3 +170,51 @@ exports.js_test_option_classes = () => { assert.ok(c instanceof wasm.OptionClass); wasm.option_class_assert_some(c); }; + +/** + * Invokes `console.log`, but logs to a string rather than stdout + * @param {any} data Data to pass to `console.log` + * @returns {string} Output from `console.log`, without color or trailing newlines + */ +const console_log_to_string = data => { + // Store the original stdout.write and create a console that logs without color + const original_write = process.stdout.write; + const colorless_console = new console.Console({ + stdout: process.stdout, + colorMode: false + }); + let output = ''; + + // Change stdout.write to append to our string, then restore the original function + process.stdout.write = chunk => output += chunk.trim(); + colorless_console.log(data); + process.stdout.write = original_write; + + return output; +}; + +exports.js_test_inspectable_classes = () => { + const inspectable = wasm.Inspectable.new(); + const not_inspectable = wasm.NotInspectable.new(); + // Inspectable classes have a toJSON and toString implementation generated + assert.deepStrictEqual(inspectable.toJSON(), { a: inspectable.a }); + assert.strictEqual(inspectable.toString(), `{"a":${inspectable.a}}`); + // Inspectable classes in Node.js have improved console.log formatting as well + assert.strictEqual(console_log_to_string(inspectable), `Inspectable { a: ${inspectable.a} }`); + // Non-inspectable classes do not have a toJSON or toString generated + assert.strictEqual(not_inspectable.toJSON, undefined); + assert.strictEqual(not_inspectable.toString(), '[object Object]'); + // Non-inspectable classes in Node.js have no special console.log formatting + assert.strictEqual(console_log_to_string(not_inspectable), `NotInspectable { ptr: ${not_inspectable.ptr} }`); + inspectable.free(); + not_inspectable.free(); +}; + +exports.js_test_inspectable_classes_can_override_generated_methods = () => { + const overridden_inspectable = wasm.OverriddenInspectable.new(); + // Inspectable classes can have the generated toJSON and toString overwritten + assert.strictEqual(overridden_inspectable.a, 0); + assert.deepStrictEqual(overridden_inspectable.toJSON(), 'JSON was overwritten'); + assert.strictEqual(overridden_inspectable.toString(), 'string was overwritten'); + overridden_inspectable.free(); +}; diff --git a/tests/wasm/classes.rs b/tests/wasm/classes.rs index ee904c0514a..42b222ea8eb 100644 --- a/tests/wasm/classes.rs +++ b/tests/wasm/classes.rs @@ -30,6 +30,8 @@ extern "C" { fn js_return_none2() -> Option; fn js_return_some(a: OptionClass) -> Option; fn js_test_option_classes(); + fn js_test_inspectable_classes(); + fn js_test_inspectable_classes_can_override_generated_methods(); } #[wasm_bindgen_test] @@ -489,3 +491,65 @@ mod works_in_module { pub fn foo(&self) {} } } + +#[wasm_bindgen_test] +fn inspectable_classes() { + js_test_inspectable_classes(); +} + +#[wasm_bindgen(inspectable)] +#[derive(Default)] +pub struct Inspectable { + pub a: u32, + // This private field will not be exposed unless a getter is provided for it + #[allow(dead_code)] + private: u32, +} + +#[wasm_bindgen] +impl Inspectable { + pub fn new() -> Self { + Self::default() + } +} + +#[wasm_bindgen] +#[derive(Default)] +pub struct NotInspectable { + pub a: u32, +} + +#[wasm_bindgen] +impl NotInspectable { + pub fn new() -> Self { + Self::default() + } +} + +#[wasm_bindgen_test] +fn inspectable_classes_can_override_generated_methods() { + js_test_inspectable_classes_can_override_generated_methods(); +} + +#[wasm_bindgen(inspectable)] +#[derive(Default)] +pub struct OverriddenInspectable { + pub a: u32, +} + +#[wasm_bindgen] +impl OverriddenInspectable { + pub fn new() -> Self { + Self::default() + } + + #[wasm_bindgen(js_name = toJSON)] + pub fn to_json(&self) -> String { + String::from("JSON was overwritten") + } + + #[wasm_bindgen(js_name = toString)] + pub fn to_string(&self) -> String { + String::from("string was overwritten") + } +}