Skip to content

[WIP] js: tighten up internalize and externalize #618

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

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all 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
2 changes: 0 additions & 2 deletions compiler/expressions.go
Original file line number Diff line number Diff line change
Expand Up @@ -1213,8 +1213,6 @@ func (c *funcContext) internalize(s *expression, t types.Type) *expression {
switch u := t.Underlying().(type) {
case *types.Basic:
switch {
case isBoolean(u):
return c.formatExpr("!!(%s)", s)
case isInteger(u) && !is64Bit(u):
return c.fixNumber(c.formatExpr("$parseInt(%s)", s), u)
case isFloat(u):
Expand Down
34 changes: 29 additions & 5 deletions compiler/prelude/jsmapping.go
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,13 @@ var $internalize = function(v, t, recv) {
}
switch (t.kind) {
case $kindBool:
return !!v;
if (v === undefined || v == null) {
return false;
}
if (v.constructor !== Boolean) {
$throwRuntimeError("tried to internalize non-bool value of type " + $typeof(v));
}
return Boolean(v);
case $kindInt:
return parseInt(v);
case $kindInt8:
Expand Down Expand Up @@ -253,12 +259,9 @@ var $internalize = function(v, t, recv) {
if (t.methods.length !== 0) {
$throwRuntimeError("cannot internalize " + t.string);
}
if (v === null) {
if (v === null || v === undefined) {
return $ifaceNil;
}
if (v === undefined) {
return new $jsObjectPtr(undefined);
}
switch (v.constructor) {
case Int8Array:
return new ($sliceType($Int8))(v);
Expand Down Expand Up @@ -315,6 +318,12 @@ var $internalize = function(v, t, recv) {
case $kindSlice:
return new t($mapArray(v, function(e) { return $internalize(e, t.elem); }));
case $kindString:
if (v === undefined || v == null) {
return "";
}
if (v.constructor !== String) {
$throwRuntimeError("tried to internalize non-string value of type " + $typeof(v));
}
v = String(v);
if ($isASCII(v)) {
return v;
Expand Down Expand Up @@ -367,6 +376,21 @@ var $internalize = function(v, t, recv) {
$throwRuntimeError("cannot internalize " + t.string);
};

var $typeof = function(v) {
if (v === undefined) {
return "undefined";
}

if (v === null) {
return "null";
}

var to = typeof v;
var cn = v.constructor.name;

return to + "/" + cn;
};

/* $isASCII reports whether string s contains only ASCII characters. */
var $isASCII = function(s) {
for (var i = 0; i < s.length; i++) {
Expand Down
2 changes: 1 addition & 1 deletion compiler/prelude/prelude_min.go

Large diffs are not rendered by default.

76 changes: 54 additions & 22 deletions js/js.go
Original file line number Diff line number Diff line change
@@ -1,28 +1,60 @@
// Package js provides functions for interacting with native JavaScript APIs. Calls to these functions are treated specially by GopherJS and translated directly to their corresponding JavaScript syntax.
// Package js provides functions for interacting with native JavaScript APIs.
// Calls to these functions are treated specially by GopherJS and translated
// directly to their corresponding JavaScript syntax.
//
// Use MakeWrapper to expose methods to JavaScript. When passing values directly, the following type conversions are performed:
// Use MakeWrapper to expose methods to JavaScript.
//
// | Go type | JavaScript type | Conversions back to interface{} |
// | --------------------- | --------------------- | ------------------------------- |
// | bool | Boolean | bool |
// | integers and floats | Number | float64 |
// | string | String | string |
// | []int8 | Int8Array | []int8 |
// | []int16 | Int16Array | []int16 |
// | []int32, []int | Int32Array | []int |
// | []uint8 | Uint8Array | []uint8 |
// | []uint16 | Uint16Array | []uint16 |
// | []uint32, []uint | Uint32Array | []uint |
// | []float32 | Float32Array | []float32 |
// | []float64 | Float64Array | []float64 |
// | all other slices | Array | []interface{} |
// | arrays | see slice type | see slice type |
// | functions | Function | func(...interface{}) *js.Object |
// | time.Time | Date | time.Time |
// | - | instanceof Node | *js.Object |
// | maps, structs | instanceof Object | map[string]interface{} |
// Internalization
//
// Additionally, for a struct containing a *js.Object field, only the content of the field will be passed to JavaScript and vice versa.
// When values pass from Javascript to Go, a process known as internalization,
// the following conversion table is applied:
//
// |----------------+---------------+-------------------------+--------|
// | Go target type | Translation | Javascript source value | Result |
// |----------------+---------------+-------------------------+--------|
// | string | UTF16 -> UTF8 | null | "" |
// | | | undefined | "" |
// | | | "" | "" |
// | | | new String("") | "" |
// | | | "ok" † | "ok" |
// | | | new String("ok") † | "ok" |
// |----------------+---------------+-------------------------+--------|
// | bool | none | null | false |
// | | | undefined | false |
// | | | false † | false |
// | | | new Boolean(false) † | false |
Copy link
Member

Choose a reason for hiding this comment

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

Is it worth including true examples here, for the sake of completeness, similar to the non-zero value examples for strings?

Copy link
Member Author

Choose a reason for hiding this comment

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

We would end up adding two lines: true and new Boolean(true) - hence why I opted for the † callout.

// |----------------+---------------+-------------------------+--------|
//
// Any source values not listed in this table cause a runtime panic for a given
// target type if a conversion is attempted, e.g. a Javascript number value
// being assigned to a string type Go variable.
//
// Source values annotated with † are generally applicable to all valid
// values of the target type. e.g. for target type string, "ok" represents
// all valid string primitive values.
//
// Externalization
//
// When values pass from Go to Javascript, a process known as externalization,
// the following conversion table is applied:
//
Copy link
Member

Choose a reason for hiding this comment

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

Table missing?

Copy link
Member

Choose a reason for hiding this comment

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

See next line.

// |----------------+---------------+-----------------+--------+---------+-------------|
// | Go source type | Translation | Go source value | Result | typeof | constructor |
// |----------------+---------------+-----------------+--------+---------+-------------|
// | string | UTF8 -> UTF16 | "" | "" | string | String |
// | | | "ok" † | "ok" | | |
// |----------------+---------------+-----------------+--------+---------+-------------|
// | bool | none | false | false | boolean | Boolean |
// | | | true | true | | |
// |----------------+---------------+-----------------+--------+---------+-------------|
//
// Source values annotated with † are generally applicable to all valid
// values of the target type. e.g. for target type string, "ok" represents
// all valid string values.
//
// Special struct types
//
// To follow....
package js

// Object is a container for a native JavaScript object. Calls to its methods are treated specially by GopherJS and translated directly to their JavaScript syntax. A nil pointer to Object is equal to JavaScript's "null". Object can not be used as a map key.
Expand Down
217 changes: 211 additions & 6 deletions js/js_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -467,7 +467,7 @@ func TestCallWithNull(t *testing.T) {
func TestReflection(t *testing.T) {
o := js.Global.Call("eval", "({ answer: 42 })")
if reflect.ValueOf(o).Interface().(*js.Object) != o {
t.Fail()
t.Fatal()
Copy link
Member

Choose a reason for hiding this comment

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

AFAIK, Fatal() is for reporting a failure message. Maybe FailNow() would be more semantically correct?

Copy link
Member

Choose a reason for hiding this comment

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

Is it not possible to include a helpful error message in the t.Fatal call? If possible, that's better.

Copy link
Member Author

Choose a reason for hiding this comment

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

To be honest I was being a little lazy here in just flipping the existing t.Fail() to t.Fatal() to help with debugging step 2 from the TODO.

I'm sure we could add helpful messages to the t.Fatal() calls... because the existing t.Fail() calls are equally lossy. Could even be something we do in another PR.

Copy link
Member

Choose a reason for hiding this comment

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

It's usually easier to do these minor things as you're working in a relevant context than later. But this is completely up to you, and not critical for getting the PR into a mergeable state.

}

type S struct {
Expand All @@ -477,18 +477,19 @@ func TestReflection(t *testing.T) {

v := reflect.ValueOf(&s).Elem()
if v.Field(0).Interface().(*js.Object).Get("answer").Int() != 42 {
t.Fail()
t.Fatal()
}
if v.Field(0).MethodByName("Get").Call([]reflect.Value{reflect.ValueOf("answer")})[0].Interface().(*js.Object).Int() != 42 {
t.Fail()
t.Fatal()
}
v.Field(0).Set(reflect.ValueOf(js.Global.Call("eval", "({ answer: 100 })")))
if s.Field.Get("answer").Int() != 100 {
t.Fail()
t.Fatal()
}

if fmt.Sprintf("%+v", s) != "{Field:[object Object]}" {
t.Fail()
expFmt := "{Field:[object Object]}"
if v := fmt.Sprintf("%+v", s); v != expFmt {
t.Fatalf("fmt out was: %q; expected %q", v, expFmt)
}
}

Expand Down Expand Up @@ -620,3 +621,207 @@ func TestStructWithNonIdentifierJSTag(t *testing.T) {
t.Errorf("value via js.Object.Get gave %q, want %q", got, want)
}
}

// Internalize is used as a helper type to test $internalize. A struct is used
// in order that all of the Go types can be verified (otherwise we are limited
// to the methods on *Object). Where methods on *Object exist they too will be
// tested
type Internalize struct {
*js.Object

string string `js:"string"`
bool bool `js:"bool"`
}

func TestInternalizeString(t *testing.T) {
fieldName := "string"
zero := ""

s := &Internalize{Object: js.Global.Get("Object").New()}
Copy link
Member

Choose a reason for hiding this comment

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

Stylistic note: I wonder if a table-based test would make sense here? It may make the tests more readable, reduce the need for the // *** comment blocks, and if coupled with t.Run(...) calls for each test, could make the output more readable.

Copy link
Member Author

Choose a reason for hiding this comment

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

You raise a good point. I half considered this myself, but postponed giving it much thought until we've confirmed we're actually testing the right stuff... at which point extracting to table tests should be relatively easy.

The little thought I did give it however amount to the following: I'm not convinced we can actually use table tests. We need to address specific fields of the Internalize struct and do so in such a way that the gopherjs compiler sees us doing so and compiles to the corresponding Javascript. Put another way, the code we write in these tests needs to correspond exactly to the code that will be written in the wild.

Therefore the one approach I have considered fairly seriously is code generating the tests. Because they do follow a formula that's for sure.

But as I said, I haven't given this much time/though so would very much welcome any suggestions.

Copy link
Member Author

Choose a reason for hiding this comment

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

@flimzy just pushed up a code-generated version of the tests which seems to work well. Let me know what you think.


// *************
// undefined
// *************
s.Object.Set(fieldName, jsundefined())

// via struct field
if v := s.string; v != zero {
t.Fatalf("expected string field to be %q, got %q", zero, v)
}

// via *js.Object.String()
if v := s.Object.Get(fieldName).String(); v != zero {
t.Fatalf("expected string field via *js.Object.String() to be %q, got %q", zero, v)
}

// *************
// null
// *************
s.Object.Set(fieldName, jsnull())

// via struct field
if v := s.string; v != zero {
t.Fatalf("expected string field to be %q, got %q", zero, v)
}

// via *js.Object.String()
if v := s.Object.Get(fieldName).String(); v != zero {
t.Fatalf("expected string field via *js.Object.String() to be %q, got %q", zero, v)
}

// *************
// valid primitive string
// *************
exp := "ok"
s.Object.Set(fieldName, jsval(`"`+exp+`"`))

// via struct field
if v := s.string; v != exp {
t.Fatalf("expected string field to be %q, got %q", zero, v)
}

// via *js.Object.String()
if v := s.Object.Get(fieldName).String(); v != exp {
t.Fatalf("expected string field via *js.Object.String() to be %q, got %q", zero, v)
}

// *************
// valid String object
// *************
s.Object.Set(fieldName, jsval(`new String("`+exp+`")`))

// via struct field
if v := s.string; v != exp {
t.Fatalf("expected string field to be %q, got %q", zero, v)
}

// via *js.Object.String()
if v := s.Object.Get(fieldName).String(); v != exp {
t.Fatalf("expected string field via *js.Object.String() to be %q, got %q", zero, v)
}

// *************
// invalid
// *************
s.Object.Set(fieldName, jsval("5"))

shouldRuntimePanic(t, "runtime error: tried to internalize non-string value of type number/Number", func() {
_ = s.string
})

shouldRuntimePanic(t, "runtime error: tried to internalize non-string value of type number/Number", func() {
_ = s.Object.Get(fieldName).String()
})
}

func TestInternalizeBool(t *testing.T) {
fieldName := "bool"
zero := false

s := &Internalize{Object: js.Global.Get("Object").New()}

// *************
// undefined
// *************
s.Object.Set(fieldName, jsundefined())

// via struct field
if v := s.bool; v != zero {
t.Fatalf("expected bool field to be %q, got %q", zero, v)
}

// via *js.Object.Bool()
if v := s.Object.Get(fieldName).Bool(); v != zero {
t.Fatalf("expected bool field via *js.Object.Bool() to be %q, got %q", zero, v)
}

// *************
// null
// *************
s.Object.Set(fieldName, jsnull())

// via struct field
if v := s.bool; v != zero {
t.Fatalf("expected bool field to be %q, got %q", zero, v)
}

// via *js.Object.Bool()
if v := s.Object.Get(fieldName).Bool(); v != zero {
t.Fatalf("expected bool field via *js.Object.Bool() to be %q, got %q", zero, v)
}

// *************
// valid primitive bool
// *************
exp := true
s.Object.Set(fieldName, jsval(`true`))

// via struct field
if v := s.bool; v != exp {
t.Fatalf("expected bool field to be %v, got %v", exp, v)
}

// via *js.Object.Bool()
if v := s.Object.Get(fieldName).Bool(); v != exp {
t.Fatalf("expected bool field via *js.Object.Bool() to be %q, got %q", zero, v)
}

// *************
// valid Bool object
// *************
s.Object.Set(fieldName, jsval(`new Boolean(true)`))

// via struct field
if v := s.bool; v != exp {
t.Fatalf("expected bool field to be %q, got %q", zero, v)
}

// via *js.Object.Bool()
if v := s.Object.Get(fieldName).Bool(); v != exp {
t.Fatalf("expected bool field via *js.Object.Bool() to be %q, got %q", zero, v)
}

// *************
// invalid
// *************
s.Object.Set(fieldName, jsval("5"))

shouldRuntimePanic(t, "runtime error: tried to internalize non-bool value of type number/Number", func() {
_ = s.bool
})

shouldRuntimePanic(t, "runtime error: tried to internalize non-bool value of type number/Number", func() {
_ = s.Object.Get(fieldName).Bool()
})
}

func jsval(v string) *js.Object {
return js.Global.Call("eval", v)
}

func jsnull() *js.Object {
return js.Global.Call("eval", "null")
}

func jsundefined() *js.Object {
return js.Global.Call("eval", "undefined")
}

func consolelog(args ...interface{}) {
js.Global.Get("console").Call("log", args...)
}

func shouldRuntimePanic(t *testing.T, msg string, f func()) {
defer func() {
err, ok := recover().(error)
if !ok {
t.Fatalf("expected to have had to handle panic; we didn't see a panic")
}

if err.Error() != msg {
t.Fatalf("expected error %q, got %q", msg, err)
}
}()

f()
}