Skip to content

Commit df34cf8

Browse files
codeheartsalexcrichton
authored andcommitted
Allow for js property inspection (#1876)
* 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
1 parent 181b10b commit df34cf8

File tree

9 files changed

+228
-0
lines changed

9 files changed

+228
-0
lines changed

crates/backend/src/ast.rs

+1
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,7 @@ pub struct Struct {
223223
pub js_name: String,
224224
pub fields: Vec<StructField>,
225225
pub comments: Vec<String>,
226+
pub is_inspectable: bool,
226227
}
227228

228229
#[cfg_attr(feature = "extra-traits", derive(Debug, PartialEq, Eq))]

crates/backend/src/encode.rs

+1
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,7 @@ fn shared_struct<'a>(s: &'a ast::Struct, intern: &'a Interner) -> Struct<'a> {
306306
.map(|s| shared_struct_field(s, intern))
307307
.collect(),
308308
comments: s.comments.iter().map(|s| &**s).collect(),
309+
is_inspectable: s.is_inspectable,
309310
}
310311
}
311312

crates/cli-support/src/js/mod.rs

+54
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,10 @@ pub struct ExportedClass {
5757
typescript: String,
5858
has_constructor: bool,
5959
wrap_needed: bool,
60+
/// Whether to generate helper methods for inspecting the class
61+
is_inspectable: bool,
62+
/// All readable properties of the class
63+
readable_properties: Vec<String>,
6064
/// Map from field name to type as a string plus whether it has a setter
6165
typescript_fields: HashMap<String, (String, bool)>,
6266
}
@@ -644,6 +648,54 @@ impl<'a> Context<'a> {
644648
));
645649
}
646650

651+
// If the class is inspectable, generate `toJSON` and `toString`
652+
// to expose all readable properties of the class. Otherwise,
653+
// the class shows only the "ptr" property when logged or serialized
654+
if class.is_inspectable {
655+
// Creates a `toJSON` method which returns an object of all readable properties
656+
// This object looks like { a: this.a, b: this.b }
657+
dst.push_str(&format!(
658+
"
659+
toJSON() {{
660+
return {{{}}};
661+
}}
662+
663+
toString() {{
664+
return JSON.stringify(this);
665+
}}
666+
",
667+
class
668+
.readable_properties
669+
.iter()
670+
.fold(String::from("\n"), |fields, field_name| {
671+
format!("{}{name}: this.{name},\n", fields, name = field_name)
672+
})
673+
));
674+
675+
if self.config.mode.nodejs() {
676+
// `util.inspect` must be imported in Node.js to define [inspect.custom]
677+
let module_name = self.import_name(&JsImport {
678+
name: JsImportName::Module {
679+
module: "util".to_string(),
680+
name: "inspect".to_string(),
681+
},
682+
fields: Vec::new(),
683+
})?;
684+
685+
// Node.js supports a custom inspect function to control the
686+
// output of `console.log` and friends. The constructor is set
687+
// to display the class name as a typical JavaScript class would
688+
dst.push_str(&format!(
689+
"
690+
[{}.custom]() {{
691+
return Object.assign(Object.create({{constructor: this.constructor}}), this.toJSON());
692+
}}
693+
",
694+
module_name
695+
));
696+
}
697+
}
698+
647699
dst.push_str(&format!(
648700
"
649701
free() {{
@@ -2723,6 +2775,7 @@ impl<'a> Context<'a> {
27232775
fn generate_struct(&mut self, struct_: &AuxStruct) -> Result<(), Error> {
27242776
let class = require_class(&mut self.exported_classes, &struct_.name);
27252777
class.comments = format_doc_comments(&struct_.comments, None);
2778+
class.is_inspectable = struct_.is_inspectable;
27262779
Ok(())
27272780
}
27282781

@@ -2975,6 +3028,7 @@ impl ExportedClass {
29753028
/// generation is handled specially.
29763029
fn push_getter(&mut self, docs: &str, field: &str, js: &str, ret_ty: &str) {
29773030
self.push_accessor(docs, field, js, "get ", ret_ty);
3031+
self.readable_properties.push(field.to_string());
29783032
}
29793033

29803034
/// Used for adding a setter to a class, mainly to ensure that TypeScript

crates/cli-support/src/webidl/mod.rs

+3
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,8 @@ pub struct AuxStruct {
256256
pub name: String,
257257
/// The copied Rust comments to forward to JS
258258
pub comments: String,
259+
/// Whether to generate helper methods for inspecting the class
260+
pub is_inspectable: bool,
259261
}
260262

261263
/// All possible types of imports that can be imported by a wasm module.
@@ -1238,6 +1240,7 @@ impl<'a> Context<'a> {
12381240
let aux = AuxStruct {
12391241
name: struct_.name.to_string(),
12401242
comments: concatenate_comments(&struct_.comments),
1243+
is_inspectable: struct_.is_inspectable,
12411244
};
12421245
self.aux.structs.push(aux);
12431246

crates/macro-support/src/parser.rs

+3
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ macro_rules! attrgen {
4545
(readonly, Readonly(Span)),
4646
(js_name, JsName(Span, String, Span)),
4747
(js_class, JsClass(Span, String, Span)),
48+
(inspectable, Inspectable(Span)),
4849
(is_type_of, IsTypeOf(Span, syn::Expr)),
4950
(extends, Extends(Span, syn::Path)),
5051
(vendor_prefix, VendorPrefix(Span, Ident)),
@@ -322,6 +323,7 @@ impl<'a> ConvertToAst<BindgenAttrs> for &'a mut syn::ItemStruct {
322323
.js_name()
323324
.map(|s| s.0.to_string())
324325
.unwrap_or(self.ident.to_string());
326+
let is_inspectable = attrs.inspectable().is_some();
325327
for (i, field) in self.fields.iter_mut().enumerate() {
326328
match field.vis {
327329
syn::Visibility::Public(..) => {}
@@ -361,6 +363,7 @@ impl<'a> ConvertToAst<BindgenAttrs> for &'a mut syn::ItemStruct {
361363
js_name,
362364
fields,
363365
comments,
366+
is_inspectable,
364367
})
365368
}
366369
}

crates/shared/src/lib.rs

+1
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ macro_rules! shared_api {
116116
name: &'a str,
117117
fields: Vec<StructField<'a>>,
118118
comments: Vec<&'a str>,
119+
is_inspectable: bool,
119120
}
120121

121122
struct StructField<'a> {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# `inspectable`
2+
3+
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`.
4+
5+
The `inspectable` attribute can be used on Rust structs to provide a `toJSON` and `toString` implementation that display all readable fields. For example:
6+
7+
```rust
8+
#[wasm_bindgen(inspectable)]
9+
pub struct Baz {
10+
pub field: i32,
11+
private: i32,
12+
}
13+
14+
#[wasm_bindgen]
15+
impl Baz {
16+
#[wasm_bindgen(constructor)]
17+
pub fn new(field: i32) -> Baz {
18+
Baz { field, private: 13 }
19+
}
20+
}
21+
```
22+
23+
Provides the following behavior as in this JavaScript snippet:
24+
25+
```js
26+
const obj = new Baz(3);
27+
assert.deepStrictEqual(obj.toJSON(), { field: 3 });
28+
obj.field = 4;
29+
assert.strictEqual(obj.toString(), '{"field":4}');
30+
```
31+
32+
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.
33+
34+
```rust
35+
#[wasm_bindgen]
36+
impl Baz {
37+
#[wasm_bindgen(js_name = toJSON)]
38+
pub fn to_json(&self) -> i32 {
39+
self.field
40+
}
41+
42+
#[wasm_bindgen(js_name = toString)]
43+
pub fn to_string(&self) -> String {
44+
format!("Baz: {}", self.field)
45+
}
46+
}
47+
```
48+
49+
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.
50+
51+
## `inspectable` Classes in Node.js
52+
53+
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.

tests/wasm/classes.js

+48
Original file line numberDiff line numberDiff line change
@@ -170,3 +170,51 @@ exports.js_test_option_classes = () => {
170170
assert.ok(c instanceof wasm.OptionClass);
171171
wasm.option_class_assert_some(c);
172172
};
173+
174+
/**
175+
* Invokes `console.log`, but logs to a string rather than stdout
176+
* @param {any} data Data to pass to `console.log`
177+
* @returns {string} Output from `console.log`, without color or trailing newlines
178+
*/
179+
const console_log_to_string = data => {
180+
// Store the original stdout.write and create a console that logs without color
181+
const original_write = process.stdout.write;
182+
const colorless_console = new console.Console({
183+
stdout: process.stdout,
184+
colorMode: false
185+
});
186+
let output = '';
187+
188+
// Change stdout.write to append to our string, then restore the original function
189+
process.stdout.write = chunk => output += chunk.trim();
190+
colorless_console.log(data);
191+
process.stdout.write = original_write;
192+
193+
return output;
194+
};
195+
196+
exports.js_test_inspectable_classes = () => {
197+
const inspectable = wasm.Inspectable.new();
198+
const not_inspectable = wasm.NotInspectable.new();
199+
// Inspectable classes have a toJSON and toString implementation generated
200+
assert.deepStrictEqual(inspectable.toJSON(), { a: inspectable.a });
201+
assert.strictEqual(inspectable.toString(), `{"a":${inspectable.a}}`);
202+
// Inspectable classes in Node.js have improved console.log formatting as well
203+
assert.strictEqual(console_log_to_string(inspectable), `Inspectable { a: ${inspectable.a} }`);
204+
// Non-inspectable classes do not have a toJSON or toString generated
205+
assert.strictEqual(not_inspectable.toJSON, undefined);
206+
assert.strictEqual(not_inspectable.toString(), '[object Object]');
207+
// Non-inspectable classes in Node.js have no special console.log formatting
208+
assert.strictEqual(console_log_to_string(not_inspectable), `NotInspectable { ptr: ${not_inspectable.ptr} }`);
209+
inspectable.free();
210+
not_inspectable.free();
211+
};
212+
213+
exports.js_test_inspectable_classes_can_override_generated_methods = () => {
214+
const overridden_inspectable = wasm.OverriddenInspectable.new();
215+
// Inspectable classes can have the generated toJSON and toString overwritten
216+
assert.strictEqual(overridden_inspectable.a, 0);
217+
assert.deepStrictEqual(overridden_inspectable.toJSON(), 'JSON was overwritten');
218+
assert.strictEqual(overridden_inspectable.toString(), 'string was overwritten');
219+
overridden_inspectable.free();
220+
};

tests/wasm/classes.rs

+64
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ extern "C" {
3030
fn js_return_none2() -> Option<OptionClass>;
3131
fn js_return_some(a: OptionClass) -> Option<OptionClass>;
3232
fn js_test_option_classes();
33+
fn js_test_inspectable_classes();
34+
fn js_test_inspectable_classes_can_override_generated_methods();
3335
}
3436

3537
#[wasm_bindgen_test]
@@ -489,3 +491,65 @@ mod works_in_module {
489491
pub fn foo(&self) {}
490492
}
491493
}
494+
495+
#[wasm_bindgen_test]
496+
fn inspectable_classes() {
497+
js_test_inspectable_classes();
498+
}
499+
500+
#[wasm_bindgen(inspectable)]
501+
#[derive(Default)]
502+
pub struct Inspectable {
503+
pub a: u32,
504+
// This private field will not be exposed unless a getter is provided for it
505+
#[allow(dead_code)]
506+
private: u32,
507+
}
508+
509+
#[wasm_bindgen]
510+
impl Inspectable {
511+
pub fn new() -> Self {
512+
Self::default()
513+
}
514+
}
515+
516+
#[wasm_bindgen]
517+
#[derive(Default)]
518+
pub struct NotInspectable {
519+
pub a: u32,
520+
}
521+
522+
#[wasm_bindgen]
523+
impl NotInspectable {
524+
pub fn new() -> Self {
525+
Self::default()
526+
}
527+
}
528+
529+
#[wasm_bindgen_test]
530+
fn inspectable_classes_can_override_generated_methods() {
531+
js_test_inspectable_classes_can_override_generated_methods();
532+
}
533+
534+
#[wasm_bindgen(inspectable)]
535+
#[derive(Default)]
536+
pub struct OverriddenInspectable {
537+
pub a: u32,
538+
}
539+
540+
#[wasm_bindgen]
541+
impl OverriddenInspectable {
542+
pub fn new() -> Self {
543+
Self::default()
544+
}
545+
546+
#[wasm_bindgen(js_name = toJSON)]
547+
pub fn to_json(&self) -> String {
548+
String::from("JSON was overwritten")
549+
}
550+
551+
#[wasm_bindgen(js_name = toString)]
552+
pub fn to_string(&self) -> String {
553+
String::from("string was overwritten")
554+
}
555+
}

0 commit comments

Comments
 (0)