Repository: google/starlark-go
Pull request #269 (2020-04-22 12:46:49)

Test with Go 1.14


Patch:
diff --git a/.travis.yml b/.travis.yml
index 474f6b9..2c97810 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -3,9 +3,9 @@ language: go
 go_import_path: go.starlark.net
 
 go:
-    - "1.11.x"
     - "1.12.x"
     - "1.13.x"
+    - "1.14.x"
     - "master"
 
 env:
diff --git a/cmd/starlark/starlark.go b/cmd/starlark/starlark.go
index 9768971..af4f71f 100644
--- a/cmd/starlark/starlark.go
+++ b/cmd/starlark/starlark.go
@@ -19,6 +19,7 @@ import (
 	"go.starlark.net/repl"
 	"go.starlark.net/resolve"
 	"go.starlark.net/starlark"
+	"go.starlark.net/starlarkjson"
 )
 
 // flags
@@ -88,6 +89,10 @@ func doMain() int {
 	thread := &starlark.Thread{Load: repl.MakeLoad()}
 	globals := make(starlark.StringDict)
 
+	// Ideally this statement would update the predeclared environment.
+	// TODO(adonovan): plumb predeclared env through to the REPL.
+	starlark.Universe["json"] = starlarkjson.Module
+
 	switch {
 	case flag.NArg() == 1 || *execprog != "":
 		var (
@@ -113,7 +118,6 @@ func doMain() int {
 		fmt.Println("Welcome to Starlark (go.starlark.net)")
 		thread.Name = "REPL"
 		repl.REPL(thread, globals)
-		return 0
 	default:
 		log.Print("want at most one Starlark file name")
 		return 1
diff --git a/doc/spec.md b/doc/spec.md
index fd8b4f8..7c2e66a 100644
--- a/doc/spec.md
+++ b/doc/spec.md
@@ -345,6 +345,12 @@ using the other kind, as in these examples:
 'Yes, it\'s a classic.'
 ```
 
+Literal occurrences of the _opposite_ kind of quotation mark, such as
+an apostrophe within a double-quoted string literal, may be escaped
+by a backslash, but this is not necessary: `"it's"` and `"it\'s"` are
+equivalent.
+
+
 #### String escapes
 
 Within a string literal, the backslash character `\` indicates the
@@ -2945,7 +2951,7 @@ load("module.star", "x", "y", "z")       # assigns x, y, and z
 load("module.star", "x", y2="y", "z")    # assigns x, y2, and z
 ```
 
-A load statement within a function is a static error.
+A load statement may not be nested inside any other statement.
 
 
 ## Module execution
diff --git a/resolve/resolve.go b/resolve/resolve.go
index 440bcf0..9510fdb 100644
--- a/resolve/resolve.go
+++ b/resolve/resolve.go
@@ -214,7 +214,8 @@ type resolver struct {
 	// isGlobal may be nil.
 	isGlobal, isPredeclared, isUniversal func(name string) bool
 
-	loops int // number of enclosing for loops
+	loops   int // number of enclosing for/while loops
+	ifstmts int // number of enclosing if statements loops
 
 	errors ErrorList
 }
@@ -497,8 +498,10 @@ func (r *resolver) stmt(stmt syntax.Stmt) {
 			r.errorf(stmt.If, "if statement not within a function")
 		}
 		r.expr(stmt.Cond)
+		r.ifstmts++
 		r.stmts(stmt.True)
 		r.stmts(stmt.False)
+		r.ifstmts--
 
 	case *syntax.AssignStmt:
 		r.expr(stmt.RHS)
@@ -551,8 +554,13 @@ func (r *resolver) stmt(stmt syntax.Stmt) {
 		}
 
 	case *syntax.LoadStmt:
+		// A load statement may not be nested in any other statement.
 		if r.container().function != nil {
 			r.errorf(stmt.Load, "load statement within a function")
+		} else if r.loops > 0 {
+			r.errorf(stmt.Load, "load statement within a loop")
+		} else if r.ifstmts > 0 {
+			r.errorf(stmt.Load, "load statement within a conditional")
 		}
 
 		for i, from := range stmt.From {
diff --git a/starlark/library.go b/starlark/library.go
index 7a9440e..fb47148 100644
--- a/starlark/library.go
+++ b/starlark/library.go
@@ -215,7 +215,7 @@ func chr(thread *Thread, _ *Builtin, args Tuple, kwargs []Tuple) (Value, error)
 	if i > unicode.MaxRune {
 		return nil, fmt.Errorf("chr: Unicode code point U+%X out of range (>0x10FFFF)", i)
 	}
-	return String(string(i)), nil
+	return String(string(rune(i))), nil
 }
 
 // https://github.com/google/starlark-go/blob/master/doc/spec.md#dict
diff --git a/starlark/unpack.go b/starlark/unpack.go
index 6c870f9..386bbc5 100644
--- a/starlark/unpack.go
+++ b/starlark/unpack.go
@@ -10,6 +10,12 @@ import (
 	"strings"
 )
 
+// An Unpacker defines custom argument unpacking behavior.
+// See UnpackArgs.
+type Unpacker interface {
+	Unpack(v Value) error
+}
+
 // UnpackArgs unpacks the positional and keyword arguments into the
 // supplied parameter variables.  pairs is an alternating list of names
 // and pointers to variables.
@@ -21,6 +27,10 @@ import (
 // If the parameter name ends with "?",
 // it and all following parameters are optional.
 //
+// If the variable implements Unpacker, its Unpack argument
+// is called with the argument value, allowing an application
+// to define its own argument validation and conversion.
+//
 // If the variable implements Value, UnpackArgs may call
 // its Type() method while constructing the error message.
 //
@@ -127,6 +137,8 @@ func UnpackPositionalArgs(fnname string, args Tuple, kwargs []Tuple, min int, va
 func unpackOneArg(v Value, ptr interface{}) error {
 	// On failure, don't clobber *ptr.
 	switch ptr := ptr.(type) {
+	case Unpacker:
+		return ptr.Unpack(v)
 	case *Value:
 		*ptr = v
 	case *string:
diff --git a/starlarkjson/json.go b/starlarkjson/json.go
new file mode 100644
index 0000000..bfa397a
--- /dev/null
+++ b/starlarkjson/json.go
@@ -0,0 +1,480 @@
+// Copyright 2020 The Bazel Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// Package starlarkjson defines utilities for converting Starlark values
+// to/from JSON strings. The most recent IETF standard for JSON is
+// https://www.ietf.org/rfc/rfc7159.txt.
+package starlarkjson // import "go.starlark.net/starlarkjson"
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"log"
+	"math"
+	"math/big"
+	"sort"
+	"strconv"
+	"strings"
+	"unicode/utf8"
+
+	"go.starlark.net/starlark"
+	"go.starlark.net/starlarkstruct"
+)
+
+// Module json is a Starlark module of JSON-related functions.
+//
+//   json = module(
+//      encode,
+//      decode,
+//      indent,
+//   )
+//
+// def encode(x):
+//
+// The encode function accepts one required positional argument,
+// which it converts to JSON by cases:
+// - A Starlark value that implements Go's standard json.Marshal
+//   interface defines its own JSON encoding.
+// - None, True, and False are converted to null, true, and false, respectively.
+// - Starlark int values, no matter how large, are encoded as decimal integers.
+//   Some decoders may not be able to decode very large integers.
+// - Starlark float values are encoded using decimal point notation,
+//   even if the value is an integer.
+//   It is an error to encode a non-finite floating-point value.
+// - Starlark strings are encoded as JSON strings, using UTF-16 escapes.
+// - a Starlark IterableMapping (e.g. dict) is encoded as a JSON object.
+//   It is an error if any key is not a string.
+// - any other Starlark Iterable (e.g. list, tuple) is encoded as a JSON array.
+// - a Starlark HasAttrs (e.g. struct) is encoded as a JSON object.
+// It an application-defined type matches more than one the cases describe above,
+// (e.g. it implements both Iterable and HasFields), the first case takes precedence.
+// Encoding any other value yields an error.
+//
+// def decode(x):
+//
+// The decode function accepts one positional parameter, a JSON string.
+// It returns the Starlark value that the string denotes.
+// - Numbers are parsed as int or float, depending on whether they
+//   contain a decimal point.
+// - JSON objects are parsed as new unfrozen Starlark dicts.
+// - JSON arrays are parsed as new unfrozen Starlark lists.
+// Decoding fails if x is not a valid JSON string.
+//
+// def indent(str, *, prefix="", indent="\t"):
+//
+// The indent function pretty-prints a valid JSON encoding,
+// and returns a string containing the indented form.
+// It accepts one required positional parameter, the JSON string,
+// and two optional keyword-only string parameters, prefix and indent,
+// that specify a prefix of each new line, and the unit of indentation.
+//
+var Module = &starlarkstruct.Module{
+	Name: "json",
+	Members: starlark.StringDict{
+		"encode": starlark.NewBuiltin("json.encode", encode),
+		"decode": starlark.NewBuiltin("json.decode", decode),
+		"indent": starlark.NewBuiltin("json.indent", indent),
+	},
+}
+
+func encode(thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
+	var x starlark.Value
+	if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 1, &x); err != nil {
+		return nil, err
+	}
+
+	buf := new(bytes.Buffer)
+
+	var quoteSpace [128]byte
+	quote := func(s string) {
+		// Non-trivial escaping is handled by Go's encoding/json.
+		if isPrintableASCII(s) {
+			buf.Write(strconv.AppendQuote(quoteSpace[:0], s))
+		} else {
+			// TODO(adonovan): opt: RFC 8259 mandates UTF-8 for JSON.
+			// Can we avoid this call?
+			data, _ := json.Marshal(s)
+			buf.Write(data)
+		}
+	}
+
+	var emit func(x starlark.Value) error
+	emit = func(x starlark.Value) error {
+		switch x := x.(type) {
+		case json.Marshaler:
+			// Application-defined starlark.Value types
+			// may define their own JSON encoding.
+			data, err := x.MarshalJSON()
+			if err != nil {
+				return err
+			}
+			buf.Write(data)
+
+		case starlark.NoneType:
+			buf.WriteString("null")
+
+		case starlark.Bool:
+			if x {
+				buf.WriteString("true")
+			} else {
+				buf.WriteString("false")
+			}
+
+		case starlark.Int:
+			fmt.Fprint(buf, x)
+
+		case starlark.Float:
+			if !isFinite(float64(x)) {
+				return fmt.Errorf("cannot encode non-finite float %v", x)
+			}
+			fmt.Fprintf(buf, "%g", x) // always contains a decimal point
+
+		case starlark.String:
+			quote(string(x))
+
+		case starlark.IterableMapping:
+			// e.g. dict (must have string keys)
+			buf.WriteByte('{')
+			iter := x.Iterate()
+			defer iter.Done()
+			var k starlark.Value
+			for i := 0; iter.Next(&k); i++ {
+				if i > 0 {
+					buf.WriteByte(',')
+				}
+				s, ok := starlark.AsString(k)
+				if !ok {
+					return fmt.Errorf("%s has %s key, want string", x.Type(), k.Type())
+				}
+				v, found, err := x.Get(k)
+				if err != nil || !found {
+					log.Fatalf("internal error: mapping %s has %s among keys but value lookup fails", x.Type(), k)
+				}
+
+				quote(s)
+				buf.WriteByte(':')
+				if err := emit(v); err != nil {
+					return fmt.Errorf("in %s key %s: %v", x.Type(), k, err)
+				}
+			}
+			buf.WriteByte('}')
+
+		case starlark.Iterable:
+			// e.g. tuple, list
+			buf.WriteByte('[')
+			iter := x.Iterate()
+			defer iter.Done()
+			var elem starlark.Value
+			for i := 0; iter.Next(&elem); i++ {
+				if i > 0 {
+					buf.WriteByte(',')
+				}
+				if err := emit(elem); err != nil {
+					return fmt.Errorf("at %s index %d: %v", x.Type(), i, err)
+				}
+			}
+			buf.WriteByte(']')
+
+		case starlark.HasAttrs:
+			// e.g. struct
+			buf.WriteByte('{')
+			var names []string
+			names = append(names, x.AttrNames()...)
+			sort.Strings(names)
+			for i, name := range names {
+				v, err := x.Attr(name)
+				if err != nil || v == nil {
+					log.Fatalf("internal error: dir(%s) includes %q but value has no .%s field", x.Type(), name, name)
+				}
+				if i > 0 {
+					buf.WriteByte(',')
+				}
+				quote(name)
+				buf.WriteByte(':')
+				if err := emit(v); err != nil {
+					return fmt.Errorf("in field .%s: %v", name, err)
+				}
+			}
+			buf.WriteByte('}')
+
+		default:
+			return fmt.Errorf("cannot encode %s as JSON", x.Type())
+		}
+		return nil
+	}
+
+	if err := emit(x); err != nil {
+		return nil, fmt.Errorf("%s: %v", b.Name(), err)
+	}
+	return starlark.String(buf.String()), nil
+}
+
+// isPrintableASCII reports whether s contains only printable ASCII.
+func isPrintableASCII(s string) bool {
+	for i := 0; i < len(s); i++ {
+		b := s[i]
+		if b < 0x20 || b >= 0x80 {
+			return false
+		}
+	}
+	return true
+}
+
+// isFinite reports whether f represents a finite rational value.
+// It is equivalent to !math.IsNan(f) && !math.IsInf(f, 0).
+func isFinite(f float64) bool {
+	return math.Abs(f) <= math.MaxFloat64
+}
+
+func indent(thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
+	prefix, indent := "", "\t" // keyword-only
+	if err := starlark.UnpackArgs(b.Name(), nil, kwargs,
+		"prefix?", &prefix,
+		"indent?", &indent,
+	); err != nil {
+		return nil, err
+	}
+	var str string // positional-only
+	if err := starlark.UnpackPositionalArgs(b.Name(), args, nil, 1, &str); err != nil {
+		return nil, err
+	}
+
+	buf := new(bytes.Buffer)
+	if err := json.Indent(buf, []byte(str), prefix, indent); err != nil {
+		return nil, fmt.Errorf("%s: %v", b.Name(), err)
+	}
+	return starlark.String(buf.String()), nil
+}
+
+func decode(thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (_ starlark.Value, err error) {
+	var s string
+	if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 1, &s); err != nil {
+		return nil, err
+	}
+
+	// The decoder necessarily makes certain representation choices
+	// such as list vs tuple, struct vs dict, int vs float.
+	// In principle, we could parameterize it to allow the caller to
+	// control the returned types, but there's no compelling need yet.
+
+	// Use panic/recover with a distinguished type (failure) for error handling.
+	type failure string
+	fail := func(format string, args ...interface{}) {
+		panic(failure(fmt.Sprintf(format, args...)))
+	}
+
+	i := 0
+
+	// skipSpace consumes leading spaces, and reports whether there is more input.
+	skipSpace := func() bool {
+		for ; i < len(s); i++ {
+			b := s[i]
+			if b != ' ' && b != '\t' && b != '\n' && b != '\r' {
+				return true
+			}
+		}
+		return false
+	}
+
+	// next consumes leading spaces and returns the first non-space.
+	// It panics if at EOF.
+	next := func() byte {
+		if skipSpace() {
+			return s[i]
+		}
+		fail("unexpected end of file")
+		panic("unreachable")
+	}
+
+	// parse returns the next JSON value from the input.
+	// It consumes leading but not trailing whitespace.
+	// It panics on error.
+	var parse func() starlark.Value
+	parse = func() starlark.Value {
+		b := next()
+		switch b {
+		case '"':
+			// string
+
+			// Find end of quotation.
+			// Also, record whether trivial unquoting is safe.
+			// Non-trivial unquoting is handled by Go's encoding/json.
+			safe := true
+			closed := false
+			j := i + 1
+			for ; j < len(s); j++ {
+				b := s[j]
+				if b == '\\' {
+					safe = false
+					j++ // skip x in \x
+				} else if b == '"' {
+					closed = true
+					j++ // skip '"'
+					break
+				} else if b >= utf8.RuneSelf {
+					safe = false
+				}
+			}
+			if !closed {
+				fail("unclosed string literal")
+			}
+
+			r := s[i:j]
+			i = j
+
+			// unquote
+			if safe {
+				r = r[1 : len(r)-1]
+			} else if err := json.Unmarshal([]byte(r), &r); err != nil {
+				fail("%s", err)
+			}
+			return starlark.String(r)
+
+		case 'n':
+			if strings.HasPrefix(s[i:], "null") {
+				i += len("null")
+				return starlark.None
+			}
+
+		case 't':
+			if strings.HasPrefix(s[i:], "true") {
+				i += len("true")
+				return starlark.True
+			}
+
+		case 'f':
+			if strings.HasPrefix(s[i:], "false") {
+				i += len("false")
+				return starlark.False
+			}
+
+		case '[':
+			// array
+			var elems []starlark.Value
+
+			i++ // '['
+			b = next()
+			if b != ']' {
+				for {
+					elem := parse()
+					elems = append(elems, elem)
+					b = next()
+					if b != ',' {
+						if b != ']' {
+							fail("got %q, want ',' or ']'", b)
+						}
+						break
+					}
+					i++ // ','
+				}
+			}
+			i++ // ']'
+			return starlark.NewList(elems)
+
+		case '{':
+			// object
+			dict := new(starlark.Dict)
+
+			i++ // '{'
+			b = next()
+			if b != '}' {
+				for {
+					key := parse()
+					if _, ok := key.(starlark.String); !ok {
+						fail("got %s for object key, want string", key.Type())
+					}
+					b = next()
+					if b != ':' {
+						fail("after object key, got %q, want ':' ", b)
+					}
+					i++ // ':'
+					value := parse()
+					dict.SetKey(key, value) // can't fail
+					b = next()
+					if b != ',' {
+						if b != '}' {
+							fail("in object, got %q, want ',' or '}'", b)
+						}
+						break
+					}
+					i++ // ','
+				}
+			}
+			i++ // '}'
+			return dict
+
+		default:
+			// number?
+			if isdigit(b) || b == '-' {
+				// scan literal. Allow [0-9+-eE.] for now.
+				float := false
+				var j int
+				for j = i + 1; j < len(s); j++ {
+					b = s[j]
+					if isdigit(b) {
+						// ok
+					} else if b == '.' ||
+						b == 'e' ||
+						b == 'E' ||
+						b == '+' ||
+						b == '-' {
+						float = true
+					} else {
+						break
+					}
+				}
+				num := s[i:j]
+				i = j
+
+				// Unlike most C-like languages,
+				// JSON disallows a leading zero before a digit.
+				digits := num
+				if num[0] == '-' {
+					digits = num[1:]
+				}
+				if digits == "" || digits[0] == '0' && len(digits) > 1 && isdigit(digits[1]) {
+					fail("invalid number: %s", num)
+				}
+
+				// parse literal
+				if float {
+					x, err := strconv.ParseFloat(num, 64)
+					if err != nil {
+						fail("invalid number: %s", num)
+					}
+					return starlark.Float(x)
+				} else {
+					x, ok := new(big.Int).SetString(num, 10)
+					if !ok {
+						fail("invalid number: %s", num)
+					}
+					return starlark.MakeBigInt(x)
+				}
+			}
+		}
+		fail("unexpected character %q", b)
+		panic("unreachable")
+	}
+	defer func() {
+		x := recover()
+		switch x := x.(type) {
+		case failure:
+			err = fmt.Errorf("json.decode: at offset %d, %s", i, x)
+		case nil:
+			// nop
+		default:
+			panic(x) // unexpected panic
+		}
+	}()
+	x := parse()
+	if skipSpace() {
+		fail("unexpected character %q after value", s[i])
+	}
+	return x, nil
+}
+
+func isdigit(b byte) bool {
+	return b >= '0' && b <= '9'
+}
diff --git a/syntax/quote.go b/syntax/quote.go
index 45c0c76..49cb259 100644
--- a/syntax/quote.go
+++ b/syntax/quote.go
@@ -130,9 +130,10 @@ func unquote(quoted string) (s string, triple bool, err error) {
 			// Ignore the escape and the line break.
 			quoted = quoted[2:]
 
-		case 'a', 'b', 'f', 'n', 'r', 't', 'v', '\\', quote:
+		case 'a', 'b', 'f', 'n', 'r', 't', 'v', '\\', '\'', '"':
 			// One-char escape.
-			// We escape only the kind of quotation mark in use.
+			// Escapes are allowed for both kinds of quotation
+			// mark, not just the kind in use.
 			buf.WriteByte(unesc[quoted[1]])
 			quoted = quoted[2:]
 


Test patch:
diff --git a/resolve/testdata/resolve.star b/resolve/testdata/resolve.star
index d8afd41..64e0081 100644
--- a/resolve/testdata/resolve.star
+++ b/resolve/testdata/resolve.star
@@ -142,6 +142,21 @@ load("foo",
      c="_d", ### "load: names with leading underscores are not exported: _d"
      _e="f") # ok
 
+---
+# option:globalreassign
+if M:
+    load("foo", "bar") ### "load statement within a conditional"
+
+---
+# option:globalreassign
+for x in M:
+    load("foo", "bar") ### "load statement within a loop"
+
+---
+# option:recursion option:globalreassign
+while M:
+    load("foo", "bar") ### "load statement within a loop"
+
 ---
 # return statements must be within a function
 
diff --git a/starlark/eval_test.go b/starlark/eval_test.go
index f5a667a..e7aef9d 100644
--- a/starlark/eval_test.go
+++ b/starlark/eval_test.go
@@ -16,6 +16,8 @@ import (
 	"go.starlark.net/internal/chunkedfile"
 	"go.starlark.net/resolve"
 	"go.starlark.net/starlark"
+	"go.starlark.net/starlarkjson"
+	"go.starlark.net/starlarkstruct"
 	"go.starlark.net/starlarktest"
 	"go.starlark.net/syntax"
 )
@@ -119,6 +121,7 @@ func TestExecFile(t *testing.T) {
 		"testdata/float.star",
 		"testdata/function.star",
 		"testdata/int.star",
+		"testdata/json.star",
 		"testdata/list.star",
 		"testdata/misc.star",
 		"testdata/set.star",
@@ -132,6 +135,7 @@ func TestExecFile(t *testing.T) {
 			predeclared := starlark.StringDict{
 				"hasfields": starlark.NewBuiltin("hasfields", newHasFields),
 				"fibonacci": fib{},
+				"struct":    starlark.NewBuiltin("struct", starlarkstruct.Make),
 			}
 
 			setOptions(chunk.Source)
@@ -186,6 +190,9 @@ func load(thread *starlark.Thread, module string) (starlark.StringDict, error) {
 	if module == "assert.star" {
 		return starlarktest.LoadAssertModule()
 	}
+	if module == "json.star" {
+		return starlark.StringDict{"json": starlarkjson.Module}, nil
+	}
 
 	// TODO(adonovan): test load() using this execution path.
 	filename := filepath.Join(filepath.Dir(thread.CallFrame(0).Pos.Filename()), module)
@@ -669,6 +676,45 @@ func TestUnpackUserDefined(t *testing.T) {
 	}
 }
 
+type optionalStringUnpacker struct {
+	str   string
+	isSet bool
+}
+
+func (o *optionalStringUnpacker) Unpack(v starlark.Value) error {
+	s, ok := starlark.AsString(v)
+	if !ok {
+		return fmt.Errorf("got %s, want string", v.Type())
+	}
+	o.str = s
+	o.isSet = ok
+	return nil
+}
+
+func TestUnpackCustomUnpacker(t *testing.T) {
+	a := optionalStringUnpacker{}
+	wantA := optionalStringUnpacker{str: "a", isSet: true}
+	b := optionalStringUnpacker{str: "b"}
+	wantB := optionalStringUnpacker{str: "b"}
+
+	// Success
+	if err := starlark.UnpackArgs("unpack", starlark.Tuple{starlark.String("a")}, nil, "a?", &a, "b?", &b); err != nil {
+		t.Errorf("UnpackArgs failed: %v", err)
+	}
+	if a != wantA {
+		t.Errorf("for a, got %v, want %v", a, wantA)
+	}
+	if b != wantB {
+		t.Errorf("for b, got %v, want %v", b, wantB)
+	}
+
+	// failure
+	err := starlark.UnpackArgs("unpack", starlark.Tuple{starlark.MakeInt(42)}, nil, "a", &a)
+	if want := "unpack: for parameter a: got int, want string"; fmt.Sprint(err) != want {
+		t.Errorf("unpack args error = %q, want %q", err, want)
+	}
+}
+
 func TestDocstring(t *testing.T) {
 	globals, _ := starlark.ExecFile(&starlark.Thread{}, "doc.star", `
 def somefunc():
diff --git a/starlark/testdata/json.star b/starlark/testdata/json.star
new file mode 100644
index 0000000..615d70b
--- /dev/null
+++ b/starlark/testdata/json.star
@@ -0,0 +1,147 @@
+# Tests of json module.
+# option:float
+
+load("assert.star", "assert")
+load("json.star", "json")
+
+assert.eq(dir(json), ["decode", "encode", "indent"])
+
+# Some of these cases were inspired by github.com/nst/JSONTestSuite.
+
+## json.encode
+
+assert.eq(json.encode(None), "null")
+assert.eq(json.encode(True), "true")
+assert.eq(json.encode(False), "false")
+assert.eq(json.encode(-123), "-123")
+assert.eq(json.encode(12345*12345*12345*12345*12345*12345), "3539537889086624823140625")
+assert.eq(json.encode(float(12345*12345*12345*12345*12345*12345)), "3.539537889086625e+24")
+assert.eq(json.encode(12.345e67), "1.2345e+68")
+assert.eq(json.encode("hello"), '"hello"')
+assert.eq(json.encode([1, 2, 3]), "[1,2,3]")
+assert.eq(json.encode((1, 2, 3)), "[1,2,3]")
+assert.eq(json.encode(range(3)), "[0,1,2]") # a built-in iterable
+assert.eq(json.encode(dict(x = 1, y = "two")), '{"x":1,"y":"two"}')
+assert.eq(json.encode(struct(x = 1, y = "two")), '{"x":1,"y":"two"}')  # a user-defined HasAttrs
+assert.eq(json.encode("\x80"), '"\\ufffd"') # invalid UTF-8 -> replacement char
+
+def encode_error(expr, error):
+    assert.fails(lambda: json.encode(expr), error)
+
+encode_error(float("NaN"), "json.encode: cannot encode non-finite float NaN")
+encode_error({1: "two"}, "dict has int key, want string")
+encode_error(len, "cannot encode builtin_function_or_method as JSON")
+encode_error(struct(x=[1, {"x": len}]), # nested failure
+             'in field .x: at list index 1: in dict key "x": cannot encode...')
+encode_error(struct(x=[1, {"x": len}]), # nested failure
+             'in field .x: at list index 1: in dict key "x": cannot encode...')
+encode_error({1: 2}, 'dict has int key, want string')
+
+## json.decode
+
+assert.eq(json.decode("null"), None)
+assert.eq(json.decode("true"), True)
+assert.eq(json.decode("false"), False)
+assert.eq(json.decode("-123"), -123)
+assert.eq(json.decode("-0"), -0)
+assert.eq(json.decode("3539537889086624823140625"), 3539537889086624823140625)
+assert.eq(json.decode("3539537889086624823140625.0"), float(3539537889086624823140625))
+assert.eq(json.decode("3.539537889086625e+24"), 3.539537889086625e+24)
+assert.eq(json.decode("0e+1"), 0)
+assert.eq(json.decode("-0.0"), -0.0)
+assert.eq(json.decode(
+    "-0.000000000000000000000000000000000000000000000000000000000000000000000000000001"),
+    -0.000000000000000000000000000000000000000000000000000000000000000000000000000001)
+assert.eq(json.decode('[]'), [])
+assert.eq(json.decode('[1]'), [1])
+assert.eq(json.decode('[1,2,3]'), [1, 2, 3])
+assert.eq(json.decode('{"one": 1, "two": 2}'), dict(one=1, two=2))
+assert.eq(json.decode('{"foo\\u0000bar": 42}'), {"foo\x00bar": 42})
+assert.eq(json.decode('"\\ud83d\\ude39\\ud83d\\udc8d"'), "😹💍")
+assert.eq(json.decode('"\\u0123"'), 'ģ')
+assert.eq(json.decode('"\x7f"'), "\x7f")
+
+def decode_error(expr, error):
+    assert.fails(lambda: json.decode(expr), error)
+
+decode_error('truefalse',
+             "json.decode: at offset 4, unexpected character 'f' after value")
+
+decode_error('"abc', "unclosed string literal")
+decode_error('"ab\\gc"', "invalid character 'g' in string escape code")
+decode_error("'abc'", "unexpected character '\\\\''")
+
+decode_error("1.2.3", "invalid number: 1.2.3")
+decode_error("+1", "unexpected character '\\+'")
+decode_error("-abc", "invalid number: -")
+decode_error("-", "invalid number: -")
+decode_error("-00", "invalid number: -00")
+decode_error("00", "invalid number: 00")
+decode_error("--1", "invalid number: --1")
+decode_error("-+1", "invalid number: -\\+1")
+decode_error("1e1e1", "invalid number: 1e1e1")
+decode_error("0123", "invalid number: 0123")
+decode_error("000.123", "invalid number: 000.123")
+decode_error("-0123", "invalid number: -0123")
+decode_error("-000.123", "invalid number: -000.123")
+decode_error("0x123", "unexpected character 'x' after value")
+
+decode_error('[1, 2 ', "unexpected end of file")
+decode_error('[1, 2, ', "unexpected end of file")
+decode_error('[1, 2, ]', "unexpected character ']'")
+decode_error('[1, 2, }', "unexpected character '}'")
+decode_error('[1, 2}', "got '}', want ',' or ']'")
+
+decode_error('{"one": 1', "unexpected end of file")
+decode_error('{"one" 1', "after object key, got '1', want ':'")
+decode_error('{"one": 1 "two": 2', "in object, got '\"', want ',' or '}'")
+decode_error('{"one": 1,', "unexpected end of file")
+decode_error('{"one": 1, }', "unexpected character '}'")
+decode_error('{"one": 1]', "in object, got ']', want ',' or '}'")
+
+def codec(x):
+    return json.decode(json.encode(x))
+
+# string round-tripping
+strings = [
+    "😿", # U+1F63F CRYING_CAT_FACE
+    "🐱‍👤", # CAT FACE + ZERO WIDTH JOINER + BUST IN SILHOUETTE
+]
+assert.eq(codec(strings), strings)
+
+# codepoints is a string with every 16-bit code point.
+codepoints = ''.join(['%c' % c for c in range(65536)])
+assert.eq(codec(codepoints), codepoints)
+
+# number round-tripping
+numbers = [
+    0, 1, -1, +1, 1.23e45, -1.23e-45,
+    3539537889086624823140625,
+    float(3539537889086624823140625),
+]
+assert.eq(codec(numbers), numbers)
+
+## json.indent
+
+s = json.encode(dict(x = 1, y = ["one", "two"]))
+
+assert.eq(json.indent(s), '''{
+	"x": 1,
+	"y": [
+		"one",
+		"two"
+	]
+}''')
+
+assert.eq(json.decode(json.indent(s)), {"x": 1, "y": ["one", "two"]})
+
+assert.eq(json.indent(s, prefix='¶', indent='–––'), '''{
+¶–––"x": 1,
+¶–––"y": [
+¶––––––"one",
+¶––––––"two"
+¶–––]
+¶}''')
+
+assert.fails(lambda: json.indent("!@#$%^& this is not json"), 'invalid character')
+---
diff --git a/syntax/scan_test.go b/syntax/scan_test.go
index fecf26f..0f2d9f2 100644
--- a/syntax/scan_test.go
+++ b/syntax/scan_test.go
@@ -200,16 +200,16 @@ pass`, "pass newline pass EOF"}, // consecutive newlines are consolidated
 		{`"\+"`, `foo.star:1:1: invalid escape sequence \+`},
 		{`"\w"`, `foo.star:1:1: invalid escape sequence \w`},
 		{`"\""`, `"\"" EOF`},
-		{`"\'"`, `foo.star:1:1: invalid escape sequence \'`},
+		{`"\'"`, `"'" EOF`},
 		{`'\w'`, `foo.star:1:1: invalid escape sequence \w`},
 		{`'\''`, `"'" EOF`},
-		{`'\"'`, `foo.star:1:1: invalid escape sequence \"`},
+		{`'\"'`, `"\"" EOF`},
 		{`"""\w"""`, `foo.star:1:1: invalid escape sequence \w`},
 		{`"""\""""`, `"\"" EOF`},
-		{`"""\'"""`, `foo.star:1:1: invalid escape sequence \'`},
+		{`"""\'"""`, `"'" EOF`},
 		{`'''\w'''`, `foo.star:1:1: invalid escape sequence \w`},
 		{`'''\''''`, `"'" EOF`},
-		{`'''\"'''`, `foo.star:1:1: invalid escape sequence \"`}, // error
+		{`'''\"'''`, `"\"" EOF`},
 		{`r"\w"`, `"\\w" EOF`},
 		{`r"\""`, `"\\\"" EOF`},
 		{`r"\'"`, `"\\'" EOF`},