Skip to content

Commit

Permalink
Allow for js property inspection (#1876)
Browse files Browse the repository at this point in the history
* 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 #1857

* 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

* Reduce duplication in generated methods

Generated `toString` and `[util.inspect.custom]` methods now call
`toJSON` to reduce duplication

* Store module name in variable
  • Loading branch information
codehearts authored and alexcrichton committed Nov 26, 2019
1 parent 181b10b commit df34cf8
Show file tree
Hide file tree
Showing 9 changed files with 228 additions and 0 deletions.
1 change: 1 addition & 0 deletions crates/backend/src/ast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ pub struct Struct {
pub js_name: String,
pub fields: Vec<StructField>,
pub comments: Vec<String>,
pub is_inspectable: bool,
}

#[cfg_attr(feature = "extra-traits", derive(Debug, PartialEq, Eq))]
Expand Down
1 change: 1 addition & 0 deletions crates/backend/src/encode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}

Expand Down
54 changes: 54 additions & 0 deletions crates/cli-support/src/js/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// Map from field name to type as a string plus whether it has a setter
typescript_fields: HashMap<String, (String, bool)>,
}
Expand Down Expand Up @@ -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() {{
Expand Down Expand Up @@ -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(())
}

Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions crates/cli-support/src/webidl/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);

Expand Down
3 changes: 3 additions & 0 deletions crates/macro-support/src/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down Expand Up @@ -322,6 +323,7 @@ impl<'a> ConvertToAst<BindgenAttrs> 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(..) => {}
Expand Down Expand Up @@ -361,6 +363,7 @@ impl<'a> ConvertToAst<BindgenAttrs> for &'a mut syn::ItemStruct {
js_name,
fields,
comments,
is_inspectable,
})
}
}
Expand Down
1 change: 1 addition & 0 deletions crates/shared/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ macro_rules! shared_api {
name: &'a str,
fields: Vec<StructField<'a>>,
comments: Vec<&'a str>,
is_inspectable: bool,
}

struct StructField<'a> {
Expand Down
53 changes: 53 additions & 0 deletions guide/src/reference/attributes/on-rust-exports/inspectable.md
Original file line number Diff line number Diff line change
@@ -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.
48 changes: 48 additions & 0 deletions tests/wasm/classes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
};
64 changes: 64 additions & 0 deletions tests/wasm/classes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ extern "C" {
fn js_return_none2() -> Option<OptionClass>;
fn js_return_some(a: OptionClass) -> Option<OptionClass>;
fn js_test_option_classes();
fn js_test_inspectable_classes();
fn js_test_inspectable_classes_can_override_generated_methods();
}

#[wasm_bindgen_test]
Expand Down Expand Up @@ -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")
}
}

0 comments on commit df34cf8

Please sign in to comment.