From 78e7c9924f5d04310f131bf1fa0dc6922d2e490b Mon Sep 17 00:00:00 2001 From: Kate Hart Date: Fri, 22 Nov 2019 23:45:24 -0800 Subject: [PATCH 1/4] Add support for #[wasm_bindgen(inspectable)] This annotation generates a `toJSON` and `toString` implementation for generated JavaScript classes which display all readable properties available via the class or its getters This is useful because wasm-bindgen classes currently serialize to display one value named `ptr`, which does not model the properties of the struct in Rust This annotation addresses rustwasm/wasm-bindgen#1857 --- crates/backend/src/ast.rs | 1 + crates/backend/src/encode.rs | 1 + crates/cli-support/src/js/mod.rs | 36 +++++++++++ crates/cli-support/src/webidl/mod.rs | 3 + crates/macro-support/src/parser.rs | 3 + crates/shared/src/lib.rs | 1 + .../attributes/on-rust-exports/inspectable.md | 47 ++++++++++++++ tests/wasm/classes.js | 22 +++++++ tests/wasm/classes.rs | 64 +++++++++++++++++++ 9 files changed, 178 insertions(+) create mode 100644 guide/src/reference/attributes/on-rust-exports/inspectable.md 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..c971b3d49dc 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,36 @@ 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 JavaScript object of all readable properties + // This looks like { a: this.a, b: this.b } + let readable_properties_js_object = format!( + "{{{}}}", + class + .readable_properties + .iter() + .fold(String::from("\n"), |fields, field_name| { + format!("{}{name}: this.{name},\n", fields, name = field_name) + }) + ); + + dst.push_str(&format!( + " + toJSON() {{ + return {readable_properties}; + }} + + toString() {{ + return JSON.stringify({readable_properties}); + }} + ", + readable_properties = readable_properties_js_object + )); + } + dst.push_str(&format!( " free() {{ @@ -2723,6 +2757,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 +3010,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..5f334f86943 --- /dev/null +++ b/guide/src/reference/attributes/on-rust-exports/inspectable.md @@ -0,0 +1,47 @@ +# `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: + +```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) + } +} +``` diff --git a/tests/wasm/classes.js b/tests/wasm/classes.js index 86de4727457..d6dabbf7782 100644 --- a/tests/wasm/classes.js +++ b/tests/wasm/classes.js @@ -170,3 +170,25 @@ exports.js_test_option_classes = () => { assert.ok(c instanceof wasm.OptionClass); wasm.option_class_assert_some(c); }; + +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}}`); + // Non-inspectable classes do not have a toJSON or toString generated + assert.strictEqual(not_inspectable.toJSON, undefined); + assert.strictEqual(not_inspectable.toString(), '[object Object]'); + 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") + } +} From 8929eef1c83a922d9be15ae69468246331012946 Mon Sep 17 00:00:00 2001 From: Kate Hart Date: Sat, 23 Nov 2019 16:01:52 -0800 Subject: [PATCH 2/4] Support console.log for inspectable attr in Nodejs `#[wasm_bindgen(inspectable)]` now generates an implementation of `[util.inspect.custom]` for the Node.js target only. This implementation causes `console.log` and friends to yield the same class-style output, but with all readable fields of the Rust struct displayed --- crates/cli-support/src/js/mod.rs | 23 ++++++++++++++++ .../attributes/on-rust-exports/inspectable.md | 6 +++++ tests/wasm/classes.js | 26 +++++++++++++++++++ 3 files changed, 55 insertions(+) diff --git a/crates/cli-support/src/js/mod.rs b/crates/cli-support/src/js/mod.rs index c971b3d49dc..d2aac396abf 100644 --- a/crates/cli-support/src/js/mod.rs +++ b/crates/cli-support/src/js/mod.rs @@ -676,6 +676,29 @@ impl<'a> Context<'a> { ", readable_properties = readable_properties_js_object )); + + if self.config.mode.nodejs() { + // `util.inspect` must be imported in Node.js to define [inspect.custom] + 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!(" + [inspect.custom]() {{ + return Object.assign(Object.create({{constructor: this.constructor}}), {readable_properties}); + }} + ", + readable_properties = readable_properties_js_object + )); + } } dst.push_str(&format!( diff --git a/guide/src/reference/attributes/on-rust-exports/inspectable.md b/guide/src/reference/attributes/on-rust-exports/inspectable.md index 5f334f86943..82c8771e9cd 100644 --- a/guide/src/reference/attributes/on-rust-exports/inspectable.md +++ b/guide/src/reference/attributes/on-rust-exports/inspectable.md @@ -45,3 +45,9 @@ impl Baz { } } ``` + +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. 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 d6dabbf7782..9033aa5cdac 100644 --- a/tests/wasm/classes.js +++ b/tests/wasm/classes.js @@ -171,15 +171,41 @@ exports.js_test_option_classes = () => { 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(); }; From 42a5571fa672c87e154f75506e098900b5f948c9 Mon Sep 17 00:00:00 2001 From: Kate Hart Date: Mon, 25 Nov 2019 18:11:54 -0800 Subject: [PATCH 3/4] Reduce duplication in generated methods Generated `toString` and `[util.inspect.custom]` methods now call `toJSON` to reduce duplication --- crates/cli-support/src/js/mod.rs | 38 ++++++++----------- .../attributes/on-rust-exports/inspectable.md | 4 +- 2 files changed, 18 insertions(+), 24 deletions(-) diff --git a/crates/cli-support/src/js/mod.rs b/crates/cli-support/src/js/mod.rs index d2aac396abf..df6865c61ec 100644 --- a/crates/cli-support/src/js/mod.rs +++ b/crates/cli-support/src/js/mod.rs @@ -652,29 +652,24 @@ impl<'a> Context<'a> { // 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 JavaScript object of all readable properties - // This looks like { a: this.a, b: this.b } - let readable_properties_js_object = format!( - "{{{}}}", - class - .readable_properties - .iter() - .fold(String::from("\n"), |fields, field_name| { - format!("{}{name}: this.{name},\n", fields, name = field_name) - }) - ); - + // 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 {readable_properties}; + return {{{}}}; }} toString() {{ - return JSON.stringify({readable_properties}); + return JSON.stringify(this); }} ", - readable_properties = readable_properties_js_object + 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() { @@ -691,13 +686,12 @@ impl<'a> Context<'a> { // 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!(" - [inspect.custom]() {{ - return Object.assign(Object.create({{constructor: this.constructor}}), {readable_properties}); - }} - ", - readable_properties = readable_properties_js_object - )); + " + [inspect.custom]() { + return Object.assign(Object.create({constructor: this.constructor}), this.toJSON()); + } + " + ); } } diff --git a/guide/src/reference/attributes/on-rust-exports/inspectable.md b/guide/src/reference/attributes/on-rust-exports/inspectable.md index 82c8771e9cd..30ff462973e 100644 --- a/guide/src/reference/attributes/on-rust-exports/inspectable.md +++ b/guide/src/reference/attributes/on-rust-exports/inspectable.md @@ -29,7 +29,7 @@ obj.field = 4; assert.strictEqual(obj.toString(), '{"field":4}'); ``` -One or both of these implementations can be overridden as desired: +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] @@ -50,4 +50,4 @@ Note that the output of `console.log` will remain unchanged and display only the ## `inspectable` Classes in Node.js -When the `nodejs` target is used, an additional `[util.inspect.custom]` implementation is provided. This method is used for `console.log` and similar functions to display all readable fields of the Rust struct. +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. From 28c8267e20a9abc019a187d55c11159edaff774a Mon Sep 17 00:00:00 2001 From: Kate Hart Date: Tue, 26 Nov 2019 09:52:07 -0800 Subject: [PATCH 4/4] Store module name in variable --- crates/cli-support/src/js/mod.rs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/crates/cli-support/src/js/mod.rs b/crates/cli-support/src/js/mod.rs index df6865c61ec..d1dcfad84b2 100644 --- a/crates/cli-support/src/js/mod.rs +++ b/crates/cli-support/src/js/mod.rs @@ -674,7 +674,7 @@ impl<'a> Context<'a> { if self.config.mode.nodejs() { // `util.inspect` must be imported in Node.js to define [inspect.custom] - self.import_name(&JsImport { + let module_name = self.import_name(&JsImport { name: JsImportName::Module { module: "util".to_string(), name: "inspect".to_string(), @@ -685,13 +685,14 @@ impl<'a> Context<'a> { // 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( + dst.push_str(&format!( " - [inspect.custom]() { - return Object.assign(Object.create({constructor: this.constructor}), this.toJSON()); - } - " - ); + [{}.custom]() {{ + return Object.assign(Object.create({{constructor: this.constructor}}), this.toJSON()); + }} + ", + module_name + )); } }