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

Allow for js property inspection #1876

Merged
merged 4 commits into from
Nov 26, 2019
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
53 changes: 53 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,53 @@ 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]
self.import_name(&JsImport {
name: JsImportName::Module {
module: "util".to_string(),
name: "inspect".to_string(),
},
fields: Vec::new(),
})?;
Copy link
Contributor

Choose a reason for hiding this comment

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

The return value of this is a String which I think should be used instead of import.custom below

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm not sure if I can, this import gives me "inspect" back but the symbol name has to be "inspect.custom"… Do you know if there's another method I should try to get the symbol name in a variable?

Copy link
Contributor

Choose a reason for hiding this comment

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

Oh sorry what I mean is that below instead of [inspect.custom] you'll want to do [{}.custom] where {} is the return value of this function. That way if the identifier inspect is imported from elsewhere it'll still work.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

That makes sense! I've updated it


// 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(
"
[inspect.custom]() {
return Object.assign(Object.create({constructor: this.constructor}), this.toJSON());
}
"
);
}
}

dst.push_str(&format!(
"
free() {{
Expand Down Expand Up @@ -2723,6 +2774,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 +3027,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")
}
}