diff --git a/README.md b/README.md index 48ad29a..b305b03 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,74 @@ # jsonenums -This tool is similar to golang.org/x/tools/cmd/stringer but generates MarshalJSON and UnmarshalJSON methods. + +jsonenums is a tool to automate the creation of methods that satisfy the +`json.Marshaler` and `json.Unmarshaler` interfaces. +Given the name of a (signed or unsigned) integer type T that has constants +defined, jsonenums will create a new self-contained Go source file implementing + +``` + func (t T) MarshalJSON() ([]byte, error) + func (t *T) UnmarshalJSON([]byte) error +``` + +The file is created in the same package and directory as the package that +defines T. It has helpful defaults designed for use with go generate. + +jsonenums is a simple implementation of a concept and the code might not be the +most performant or beautiful to read. + +For example, given this snippet, + +``` + package painkiller + + type Pill int + + const ( + Placebo Pill = iota + Aspirin + Ibuprofen + Paracetamol + Acetaminophen = Paracetamol + ) +``` + +running this command + +``` + jsonenums -type=Pill +``` + +in the same directory will create the file `pill_jsonenums.go`, in package +`painkiller`, containing a definition of + +``` + func (r Pill) MarshalJSON() ([]byte, error) + func (r *Pill) UnmarshalJSON([]byte) error +``` + +`MarshalJSON` will translate the value of a `Pill` constant to the `[]byte` +representation of the respective constant name, so that the call +`json.Marshal(painkiller.Aspirin) will return the bytes `[]byte("\"Aspirin\"")`. + +`UnmarshalJSON` performs the opposite operation; given the `[]byte` +representation of a `Pill` constant it will change the receiver to equal the +corresponding constant. So given `[]byte("\"Aspirin\"")` the receiver will +change to `Aspirin` and the returned error will be `nil`. + +Typically this process would be run using go generate, like this: + +``` + //go:generate jsonenums -type=Pill +``` + +If multiple constants have the same value, the lexically first matching name +will be used (in the example, Acetaminophen will print as "Paracetamol"). + +With no arguments, it processes the package in the current directory. Otherwise, +the arguments must name a single directory holding a Go package or a set of Go +source files that represent a single Go package. + +The `-type` flag accepts a comma-separated list of types so a single run can +generate methods for multiple types. The default output file is t_jsonenums.go, +where t is the lower-cased name of the first type listed. THe suffix can be +overridden with the `-suffix` flag. diff --git a/example/shirtsize.go b/example/shirtsize.go new file mode 100644 index 0000000..58bb247 --- /dev/null +++ b/example/shirtsize.go @@ -0,0 +1,77 @@ +// Copyright 2014 The Go 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 main + +import ( + "encoding/json" + "fmt" + "log" + "os" + "strings" +) + +//go:generate jsonenums -type=ShirtSize + +type ShirtSize byte + +const ( + NA ShirtSize = iota + XS + S + M + L + XL +) + +//go:generate jsonenums -type=WeekDay + +type WeekDay int + +const ( + Monday WeekDay = iota + Tuesday + Wednesday + Thursday + Friday + Saturday + Sunday +) + +func (d WeekDay) String() string { + switch d { + case Monday: + return "Dilluns" + case Tuesday: + return "Dimarts" + case Wednesday: + return "Dimecres" + case Thursday: + return "Dijous" + case Friday: + return "Divendres" + case Saturday: + return "Dissabte" + case Sunday: + return "Diumenge" + default: + return "invalid WeekDay" + } +} + +func main() { + v := struct { + Size ShirtSize + Day WeekDay + }{M, Friday} + if err := json.NewEncoder(os.Stdout).Encode(v); err != nil { + log.Fatal(err) + } + + input := `{"Size":"XL", "Day":"Dimarts"}` + if err := json.NewDecoder(strings.NewReader(input)).Decode(&v); err != nil { + log.Fatal(err) + } + fmt.Printf("decoded %s as %+v\n", input, v) +} diff --git a/example/shirtsize_jsonenums.go b/example/shirtsize_jsonenums.go new file mode 100644 index 0000000..41938cb --- /dev/null +++ b/example/shirtsize_jsonenums.go @@ -0,0 +1,47 @@ +// generated by jsonenums -type=ShirtSize; DO NOT EDIT + +package main + +import ( + "encoding/json" + "fmt" +) + +func (r ShirtSize) MarshalJSON() ([]byte, error) { + if s, ok := interface{}(r).(fmt.Stringer); ok { + return json.Marshal(s.String()) + } + s, ok := map[ShirtSize]string{ + NA: "NA", XS: "XS", S: "S", M: "M", L: "L", XL: "XL", + }[r] + if !ok { + return nil, fmt.Errorf("invalid ShirtSize: %d", r) + } + return json.Marshal(s) +} + +var _ShirtSizeNameToValue = map[string]ShirtSize{ + "NA": NA, "XS": XS, "S": S, "M": M, "L": L, "XL": XL, +} + +func init() { + var v ShirtSize + if _, ok := interface{}(v).(fmt.Stringer); ok { + _ShirtSizeNameToValue = map[string]ShirtSize{ + interface{}(NA).(fmt.Stringer).String(): NA, interface{}(XS).(fmt.Stringer).String(): XS, interface{}(S).(fmt.Stringer).String(): S, interface{}(M).(fmt.Stringer).String(): M, interface{}(L).(fmt.Stringer).String(): L, interface{}(XL).(fmt.Stringer).String(): XL, + } + } +} + +func (r *ShirtSize) UnmarshalJSON(data []byte) error { + var s string + if err := json.Unmarshal(data, &s); err != nil { + return fmt.Errorf("ShirtSize should be a string, got %s", data) + } + v, ok := _ShirtSizeNameToValue[s] + if !ok { + return fmt.Errorf("invalid ShirtSize %q", s) + } + *r = v + return nil +} diff --git a/example/weekday_jsonenums.go b/example/weekday_jsonenums.go new file mode 100644 index 0000000..a58aa14 --- /dev/null +++ b/example/weekday_jsonenums.go @@ -0,0 +1,47 @@ +// generated by jsonenums -type=WeekDay; DO NOT EDIT + +package main + +import ( + "encoding/json" + "fmt" +) + +func (r WeekDay) MarshalJSON() ([]byte, error) { + if s, ok := interface{}(r).(fmt.Stringer); ok { + return json.Marshal(s.String()) + } + s, ok := map[WeekDay]string{ + Monday: "Monday", Tuesday: "Tuesday", Wednesday: "Wednesday", Thursday: "Thursday", Friday: "Friday", Saturday: "Saturday", Sunday: "Sunday", + }[r] + if !ok { + return nil, fmt.Errorf("invalid WeekDay: %d", r) + } + return json.Marshal(s) +} + +var _WeekDayNameToValue = map[string]WeekDay{ + "Monday": Monday, "Tuesday": Tuesday, "Wednesday": Wednesday, "Thursday": Thursday, "Friday": Friday, "Saturday": Saturday, "Sunday": Sunday, +} + +func init() { + var v WeekDay + if _, ok := interface{}(v).(fmt.Stringer); ok { + _WeekDayNameToValue = map[string]WeekDay{ + interface{}(Monday).(fmt.Stringer).String(): Monday, interface{}(Tuesday).(fmt.Stringer).String(): Tuesday, interface{}(Wednesday).(fmt.Stringer).String(): Wednesday, interface{}(Thursday).(fmt.Stringer).String(): Thursday, interface{}(Friday).(fmt.Stringer).String(): Friday, interface{}(Saturday).(fmt.Stringer).String(): Saturday, interface{}(Sunday).(fmt.Stringer).String(): Sunday, + } + } +} + +func (r *WeekDay) UnmarshalJSON(data []byte) error { + var s string + if err := json.Unmarshal(data, &s); err != nil { + return fmt.Errorf("WeekDay should be a string, got %s", data) + } + v, ok := _WeekDayNameToValue[s] + if !ok { + return fmt.Errorf("invalid WeekDay %q", s) + } + *r = v + return nil +} diff --git a/jsonenums.go b/jsonenums.go new file mode 100644 index 0000000..53798c2 --- /dev/null +++ b/jsonenums.go @@ -0,0 +1,286 @@ +// Copyright 2014 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// JSONenums is a tool to automate the creation of methods that satisfy the +// fmt.Stringer, json.Marshaler and json.Unmarshaler interfaces. +// Given the name of a (signed or unsigned) integer type T that has constants +// defined, stringer will create a new self-contained Go source file implementing +// +// func (t T) String() string +// func (t T) MarshalJSON() ([]byte, error) +// func (t *T) UnmarshalJSON([]byte) error +// +// The file is created in the same package and directory as the package that defines T. +// It has helpful defaults designed for use with go generate. +// +// JSONenums is a simple implementation of a concept and the code might not be +// the most performant or beautiful to read. +// +// For example, given this snippet, +// +// package painkiller +// +// type Pill int +// +// const ( +// Placebo Pill = iota +// Aspirin +// Ibuprofen +// Paracetamol +// Acetaminophen = Paracetamol +// ) +// +// running this command +// +// jsonenums -type=Pill +// +// in the same directory will create the file pill_jsonenums.go, in package painkiller, +// containing a definition of +// +// func (r Pill) String() string +// func (r Pill) MarshalJSON() ([]byte, error) +// func (r *Pill) UnmarshalJSON([]byte) error +// +// That method will translate the value of a Pill constant to the string representation +// of the respective constant name, so that the call fmt.Print(painkiller.Aspirin) will +// print the string "Aspirin". +// +// Typically this process would be run using go generate, like this: +// +// //go:generate stringer -type=Pill +// +// If multiple constants have the same value, the lexically first matching name will +// be used (in the example, Acetaminophen will print as "Paracetamol"). +// +// With no arguments, it processes the package in the current directory. +// Otherwise, the arguments must name a single directory holding a Go package +// or a set of Go source files that represent a single Go package. +// +// The -type flag accepts a comma-separated list of types so a single run can +// generate methods for multiple types. The default output file is t_string.go, +// where t is the lower-cased name of the first type listed. THe suffix can be +// overridden with the -suffix flag. +// +package main + +import ( + "bytes" + "flag" + "fmt" + "go/ast" + "go/build" + "go/format" + "go/parser" + "go/token" + "io/ioutil" + "log" + "os" + "path/filepath" + "strings" + + "golang.org/x/tools/go/exact" + "golang.org/x/tools/go/types" + + _ "golang.org/x/tools/go/gcimporter" +) + +var ( + typeNames = flag.String("type", "", "comma-separated list of type names; must be set") + outputSuffix = flag.String("suffix", "_jsonenums", "suffix to be added to the output file") +) + +func main() { + flag.Parse() + if len(*typeNames) == 0 { + log.Fatalf("the flag -type must be set") + } + types := strings.Split(*typeNames, ",") + + // Only one directory at a time can be processed, and the default is ".". + dir := "." + if args := flag.Args(); len(args) == 1 { + dir = args[0] + } else if len(args) > 1 { + log.Fatalf("only one directory at a time") + } + + pkg, err := parsePackage(dir, *outputSuffix+".go") + if err != nil { + log.Fatalf("parsing package: %v", err) + } + + var analysis = struct { + Command string + PackageName string + TypesAndValues map[string][]string + }{ + Command: strings.Join(os.Args[1:], " "), + PackageName: pkg.name, + TypesAndValues: make(map[string][]string), + } + + // Run generate for each type. + for _, typeName := range types { + values, err := pkg.valuesOfType(typeName) + if err != nil { + log.Fatalf("finding values for type %v: %v", typeName, err) + } + analysis.TypesAndValues[typeName] = values + + var buf bytes.Buffer + if err := generatedTmpl.Execute(&buf, analysis); err != nil { + log.Fatalf("generating code: %v", err) + } + + src, err := format.Source(buf.Bytes()) + if err != nil { + // Should never happen, but can arise when developing this code. + // The user can compile the output to see the error. + log.Printf("warning: internal error: invalid Go generated: %s", err) + log.Printf("warning: compile the package to analyze the error") + src = buf.Bytes() + } + + output := strings.ToLower(typeName + *outputSuffix + ".go") + outputPath := filepath.Join(dir, output) + if err := ioutil.WriteFile(outputPath, src, 0644); err != nil { + log.Fatalf("writing output: %s", err) + } + } +} + +type Package struct { + name string + files []*ast.File + + defs map[*ast.Ident]types.Object +} + +// parsePackage parses the package in the given directory and returns it. +func parsePackage(directory string, skipSuffix string) (*Package, error) { + pkgDir, err := build.Default.ImportDir(directory, 0) + if err != nil { + return nil, fmt.Errorf("cannot process directory %s: %s", directory, err) + } + + var files []*ast.File + fs := token.NewFileSet() + for _, name := range pkgDir.GoFiles { + if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, skipSuffix) { + continue + } + if directory != "." { + name = filepath.Join(directory, name) + } + f, err := parser.ParseFile(fs, name, nil, 0) + if err != nil { + return nil, fmt.Errorf("parsing file %v: %v", name, err) + } + files = append(files, f) + } + if len(files) == 0 { + return nil, fmt.Errorf("%s: no buildable Go files", directory) + } + + // type-check the package + defs := make(map[*ast.Ident]types.Object) + config := types.Config{FakeImportC: true} + info := &types.Info{Defs: defs} + if _, err := config.Check(directory, fs, files, info); err != nil { + return nil, fmt.Errorf("type-checking package: %v", err) + } + + return &Package{ + name: files[0].Name.Name, + files: files, + defs: defs, + }, nil +} + +// generate produces the String method for the named type. +func (pkg *Package) valuesOfType(typeName string) ([]string, error) { + var values, inspectErrs []string + for _, file := range pkg.files { + ast.Inspect(file, func(node ast.Node) bool { + decl, ok := node.(*ast.GenDecl) + if !ok || decl.Tok != token.CONST { + // We only care about const declarations. + return true + } + + if vs, err := pkg.valuesOfTypeIn(typeName, decl); err != nil { + inspectErrs = append(inspectErrs, err.Error()) + } else { + values = append(values, vs...) + } + return false + }) + } + if len(inspectErrs) > 0 { + return nil, fmt.Errorf("inspecting code:\n\t%v", strings.Join(inspectErrs, "\n\t")) + } + if len(values) == 0 { + return nil, fmt.Errorf("no values defined for type %s", typeName) + } + return values, nil +} + +func (pkg *Package) valuesOfTypeIn(typeName string, decl *ast.GenDecl) ([]string, error) { + var values []string + + // The name of the type of the constants we are declaring. + // Can change if this is a multi-element declaration. + typ := "" + // Loop over the elements of the declaration. Each element is a ValueSpec: + // a list of names possibly followed by a type, possibly followed by values. + // If the type and value are both missing, we carry down the type (and value, + // but the "go/types" package takes care of that). + for _, spec := range decl.Specs { + vspec := spec.(*ast.ValueSpec) // Guaranteed to succeed as this is CONST. + if vspec.Type == nil && len(vspec.Values) > 0 { + // "X = 1". With no type but a value, the constant is untyped. + // Skip this vspec and reset the remembered type. + typ = "" + continue + } + if vspec.Type != nil { + // "X T". We have a type. Remember it. + ident, ok := vspec.Type.(*ast.Ident) + if !ok { + continue + } + typ = ident.Name + } + if typ != typeName { + // This is not the type we're looking for. + continue + } + + // We now have a list of names (from one line of source code) all being + // declared with the desired type. + // Grab their names and actual values and store them in f.values. + for _, name := range vspec.Names { + if name.Name == "_" { + continue + } + // This dance lets the type checker find the values for us. It's a + // bit tricky: look up the object declared by the name, find its + // types.Const, and extract its value. + obj, ok := pkg.defs[name] + if !ok { + return nil, fmt.Errorf("no value for constant %s", name) + } + info := obj.Type().Underlying().(*types.Basic).Info() + if info&types.IsInteger == 0 { + return nil, fmt.Errorf("can't handle non-integer constant type %s", typ) + } + value := obj.(*types.Const).Val() // Guaranteed to succeed as this is CONST. + if value.Kind() != exact.Int { + log.Fatalf("can't happen: constant is not an integer %s", name) + } + values = append(values, name.Name) + } + } + return values, nil +} diff --git a/template.go b/template.go new file mode 100644 index 0000000..dd78689 --- /dev/null +++ b/template.go @@ -0,0 +1,63 @@ +// Copyright 2014 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Added as a .go file to avoid embedding issues of the template. + +package main + +import "text/template" + +var generatedTmpl = template.Must(template.New("generated").Parse(` +// generated by jsonenums {{.Command}}; DO NOT EDIT + +package {{.PackageName}} + +import ( + "encoding/json" + "fmt" +) + +{{range $typename, $values := .TypesAndValues}} + +func (r {{$typename}}) MarshalJSON() ([]byte, error) { + if s, ok := interface{}(r).(fmt.Stringer); ok { + return json.Marshal(s.String()) + } + s, ok := map[{{$typename}}]string { + {{range $values}}{{.}}: "{{.}}",{{end}} + }[r] + if !ok { + return nil, fmt.Errorf("invalid {{$typename}}: %d", r) + } + return json.Marshal(s) +} + +var _{{$typename}}NameToValue = map[string]{{$typename}} { + {{range $values}}"{{.}}": {{.}},{{end}} +} + +func init() { + var v {{$typename}} + if _, ok := interface{}(v).(fmt.Stringer); ok { + _{{$typename}}NameToValue = map[string]{{$typename}} { + {{range $values}}interface{}({{.}}).(fmt.Stringer).String(): {{.}},{{end}} + } + } +} + +func (r *{{$typename}}) UnmarshalJSON(data []byte) error { + var s string + if err := json.Unmarshal(data, &s); err != nil { + return fmt.Errorf("{{$typename}} should be a string, got %s", data) + } + v, ok := _{{$typename}}NameToValue[s] + if !ok { + return fmt.Errorf("invalid {{$typename}} %q", s) + } + *r = v + return nil +} + +{{end}} +`))