Skip to content

Commit f5090b7

Browse files
committed
syntax/typedjson: expose shfmt's "typed JSON" as Go APIs
Create a new package with docs, copy the code, and expose the API. The following changes were made to the API: * Name the main APIs Encode and Decode. * Added options structs, to give us flexibility in the future. * The "pretty" option is now an Indent string, like encoding/json. Note that we now require "Type" keys to come first in each object. This is not required right now, nor is it enforced by our decoder yet, but it will be necessary to implement a performant decoder. This is because we can only decode fields once we know the type; if the "Type" key comes later, we must decode twice or buffer tokens. The typed JSON encoding also changes slightly: since we want Encode and Decode to work on syntax.Node rather than syntax.File, the root node needs to include a "Type" JSON key as well. This also makes shfmt's --to-json behave nicer on empty files; rather than emitting `{}` lacking any information at all, it now emits an empty file node in tthe form of `{"Type":"File"}`. Finally, we apply some minor refactors to the code and tests. Fixes #885.
1 parent c016564 commit f5090b7

File tree

7 files changed

+163
-94
lines changed

7 files changed

+163
-94
lines changed

cmd/shfmt/json_test.go

-70
This file was deleted.

cmd/shfmt/main.go

+5-2
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import (
2424

2525
"mvdan.cc/sh/v3/fileutil"
2626
"mvdan.cc/sh/v3/syntax"
27+
"mvdan.cc/sh/v3/syntax/typedjson"
2728
)
2829

2930
// TODO: this flag business screams generics. try again with Go 1.18+.
@@ -444,7 +445,7 @@ func formatBytes(src []byte, path string, fileLang syntax.LangVariant) error {
444445
var node syntax.Node
445446
var err error
446447
if fromJSON.val {
447-
node, err = readJSON(bytes.NewReader(src))
448+
node, err = typedjson.Decode(bytes.NewReader(src))
448449
if err != nil {
449450
return err
450451
}
@@ -462,7 +463,9 @@ func formatBytes(src []byte, path string, fileLang syntax.LangVariant) error {
462463
}
463464
if toJSON.val {
464465
// must be standard input; fine to return
465-
return writeJSON(out, node, true)
466+
// TODO: change the default behavior to be compact,
467+
// and allow using --to-json=pretty or --to-json=indent.
468+
return typedjson.EncodeOptions{Indent: "\t"}.Encode(out, node)
466469
}
467470
writeBuf.Reset()
468471
printer.Print(&writeBuf, node)

cmd/shfmt/testdata/scripts/tojson.txt

+6-1
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,14 @@ cmp stdout comment.sh.json
1717

1818
-- empty.sh --
1919
-- empty.sh.json --
20-
{}
20+
{
21+
"Type": "File"
22+
}
2123
-- simple.sh --
2224
foo
2325
-- simple.sh.json --
2426
{
27+
"Type": "File",
2528
"Pos": {
2629
"Offset": 0,
2730
"Line": 1,
@@ -109,6 +112,7 @@ foo
109112
((2))
110113
-- arithmetic.sh.json --
111114
{
115+
"Type": "File",
112116
"Pos": {
113117
"Offset": 0,
114118
"Line": 1,
@@ -205,6 +209,7 @@ foo
205209
#
206210
-- comment.sh.json --
207211
{
212+
"Type": "File",
208213
"Pos": {
209214
"Offset": 0,
210215
"Line": 1,

cmd/shfmt/json.go syntax/typedjson/json.go

+65-21
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,20 @@
11
// Copyright (c) 2017, Daniel Martí <[email protected]>
22
// See LICENSE for licensing information
33

4-
package main
4+
// Package typedjson allows encoding and decoding shell syntax trees as JSON.
5+
// The decoding process needs to know what syntax node types to decode into,
6+
// so the "typed JSON" requires "Type" keys in some syntax tree node objects:
7+
//
8+
// - The root node
9+
// - Any node represented as an interface field in the parent Go type
10+
//
11+
// The types of all other nodes can be inferred from context alone.
12+
//
13+
// For the sake of efficiency and simplicity, the "Type" key
14+
// described above must be first in each JSON object.
15+
package typedjson
16+
17+
// TODO: encoding and decoding nodes other than File is untested.
518

619
import (
720
"encoding/json"
@@ -12,32 +25,50 @@ import (
1225
"mvdan.cc/sh/v3/syntax"
1326
)
1427

15-
func writeJSON(w io.Writer, node syntax.Node, pretty bool) error {
28+
// Encode is a shortcut for EncodeOptions.Encode, with the default options.
29+
func Encode(w io.Writer, node syntax.Node) error {
30+
return EncodeOptions{}.Encode(w, node)
31+
}
32+
33+
// EncodeOptions allows configuring how syntax nodes are encoded.
34+
type EncodeOptions struct {
35+
Indent string // e.g. "\t"
36+
37+
// Allows us to add options later.
38+
}
39+
40+
// Encode writes node to w in its typed JSON form,
41+
// as described in the package documentation.
42+
func (opts EncodeOptions) Encode(w io.Writer, node syntax.Node) error {
1643
val := reflect.ValueOf(node)
17-
encVal, _ := encode(val)
44+
encVal, tname := encodeValue(val)
45+
if tname == "" {
46+
panic("node did not contain a named type?")
47+
}
48+
encVal.Elem().Field(0).SetString(tname)
1849
enc := json.NewEncoder(w)
19-
if pretty {
20-
enc.SetIndent("", "\t")
50+
if opts.Indent != "" {
51+
enc.SetIndent("", opts.Indent)
2152
}
2253
return enc.Encode(encVal.Interface())
2354
}
2455

25-
func encode(val reflect.Value) (reflect.Value, string) {
56+
func encodeValue(val reflect.Value) (reflect.Value, string) {
2657
switch val.Kind() {
2758
case reflect.Ptr:
28-
elem := val.Elem()
29-
if !elem.IsValid() {
59+
if val.IsNil() {
3060
break
3161
}
32-
return encode(elem)
62+
return encodeValue(val.Elem())
3363
case reflect.Interface:
3464
if val.IsNil() {
3565
break
3666
}
37-
enc, tname := encode(val.Elem())
38-
if tname != "" {
39-
enc.Elem().Field(0).SetString(tname)
67+
enc, tname := encodeValue(val.Elem())
68+
if tname == "" {
69+
panic("interface did not contain a named type?")
4070
}
71+
enc.Elem().Field(0).SetString(tname)
4172
return enc, ""
4273
case reflect.Struct:
4374
// Construct a new struct with an optional Type, Pos and End,
@@ -71,7 +102,7 @@ func encode(val reflect.Value) (reflect.Value, string) {
71102
if ftyp.Type == exportedPosType {
72103
encodePos(enc.Field(i), fval)
73104
} else {
74-
encElem, _ := encode(fval)
105+
encElem, _ := encodeValue(fval)
75106
if encElem.IsValid() {
76107
enc.Field(i).Set(encElem)
77108
}
@@ -88,7 +119,7 @@ func encode(val reflect.Value) (reflect.Value, string) {
88119
enc := reflect.MakeSlice(anySliceType, n, n)
89120
for i := 0; i < n; i++ {
90121
elem := val.Index(i)
91-
encElem, _ := encode(elem)
122+
encElem, _ := encodeValue(elem)
92123
enc.Index(i).Set(encElem)
93124
}
94125
return enc, ""
@@ -161,19 +192,32 @@ func decodePos(val reflect.Value, enc map[string]interface{}) {
161192
val.Set(reflect.ValueOf(syntax.NewPos(offset, line, column)))
162193
}
163194

164-
func readJSON(r io.Reader) (syntax.Node, error) {
195+
// Decode is a shortcut for DecodeOptions.Decode, with the default options.
196+
func Decode(r io.Reader) (syntax.Node, error) {
197+
return DecodeOptions{}.Decode(r)
198+
}
199+
200+
// DecodeOptions allows configuring how syntax nodes are encoded.
201+
type DecodeOptions struct {
202+
// Empty for now; allows us to add options later.
203+
}
204+
205+
// Decode writes node to w in its typed JSON form,
206+
// as described in the package documentation.
207+
func (opts DecodeOptions) Decode(r io.Reader) (syntax.Node, error) {
165208
var enc interface{}
166209
if err := json.NewDecoder(r).Decode(&enc); err != nil {
167210
return nil, err
168211
}
169-
node := &syntax.File{}
170-
if err := decode(reflect.ValueOf(node), enc); err != nil {
212+
node := new(syntax.Node)
213+
if err := decodeValue(reflect.ValueOf(node).Elem(), enc); err != nil {
171214
return nil, err
172215
}
173-
return node, nil
216+
return *node, nil
174217
}
175218

176219
var nodeByName = map[string]reflect.Type{
220+
"File": reflect.TypeOf((*syntax.File)(nil)).Elem(),
177221
"Word": reflect.TypeOf((*syntax.Word)(nil)).Elem(),
178222

179223
"Lit": reflect.TypeOf((*syntax.Lit)(nil)).Elem(),
@@ -215,7 +259,7 @@ var nodeByName = map[string]reflect.Type{
215259
"CStyleLoop": reflect.TypeOf((*syntax.CStyleLoop)(nil)).Elem(),
216260
}
217261

218-
func decode(val reflect.Value, enc interface{}) error {
262+
func decodeValue(val reflect.Value, enc interface{}) error {
219263
switch enc := enc.(type) {
220264
case map[string]interface{}:
221265
if val.Kind() == reflect.Ptr && val.IsNil() {
@@ -246,14 +290,14 @@ func decode(val reflect.Value, enc interface{}) error {
246290
decodePos(fval, fv.(map[string]interface{}))
247291
continue
248292
}
249-
if err := decode(fval, fv); err != nil {
293+
if err := decodeValue(fval, fv); err != nil {
250294
return err
251295
}
252296
}
253297
case []interface{}:
254298
for _, encElem := range enc {
255299
elem := reflect.New(val.Type().Elem()).Elem()
256-
if err := decode(elem, encElem); err != nil {
300+
if err := decodeValue(elem, encElem); err != nil {
257301
return err
258302
}
259303
val.Set(reflect.Append(val, elem))

syntax/typedjson/json_test.go

+86
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// Copyright (c) 2017, Daniel Martí <[email protected]>
2+
// See LICENSE for licensing information
3+
4+
package typedjson_test
5+
6+
import (
7+
"bytes"
8+
"flag"
9+
"os"
10+
"path/filepath"
11+
"strings"
12+
"testing"
13+
14+
qt "github.com/frankban/quicktest"
15+
16+
"mvdan.cc/sh/v3/syntax"
17+
"mvdan.cc/sh/v3/syntax/typedjson"
18+
)
19+
20+
var update = flag.Bool("u", false, "update output files")
21+
22+
func TestRoundtrip(t *testing.T) {
23+
t.Parallel()
24+
25+
dir := filepath.Join("testdata", "roundtrip")
26+
shellPaths, err := filepath.Glob(filepath.Join(dir, "*.sh"))
27+
qt.Assert(t, err, qt.IsNil)
28+
for _, shellPath := range shellPaths {
29+
shellPath := shellPath // do not reuse the range var
30+
name := strings.TrimSuffix(filepath.Base(shellPath), ".sh")
31+
jsonPath := filepath.Join(dir, name+".json")
32+
t.Run(name, func(t *testing.T) {
33+
shellInput, err := os.ReadFile(shellPath)
34+
qt.Assert(t, err, qt.IsNil)
35+
jsonInput, err := os.ReadFile(jsonPath)
36+
if !*update { // allow it to not exist
37+
qt.Assert(t, err, qt.IsNil)
38+
}
39+
sb := new(strings.Builder)
40+
41+
// Parse the shell source and check that it is well formatted.
42+
parser := syntax.NewParser(syntax.KeepComments(true))
43+
node, err := parser.Parse(bytes.NewReader(shellInput), "")
44+
qt.Assert(t, err, qt.IsNil)
45+
46+
printer := syntax.NewPrinter()
47+
sb.Reset()
48+
err = printer.Print(sb, node)
49+
qt.Assert(t, err, qt.IsNil)
50+
qt.Assert(t, sb.String(), qt.Equals, string(shellInput))
51+
52+
// Validate writing the pretty JSON.
53+
sb.Reset()
54+
encOpts := typedjson.EncodeOptions{Indent: "\t"}
55+
err = encOpts.Encode(sb, node)
56+
qt.Assert(t, err, qt.IsNil)
57+
got := sb.String()
58+
if *update {
59+
err := os.WriteFile(jsonPath, []byte(got), 0o666)
60+
qt.Assert(t, err, qt.IsNil)
61+
} else {
62+
qt.Assert(t, got, qt.Equals, string(jsonInput))
63+
}
64+
65+
// Ensure we don't use the originally parsed node again.
66+
node = nil
67+
68+
// Validate reading the pretty JSON and check that it formats the same.
69+
node2, err := typedjson.Decode(bytes.NewReader(jsonInput))
70+
qt.Assert(t, err, qt.IsNil)
71+
72+
sb.Reset()
73+
err = printer.Print(sb, node2)
74+
qt.Assert(t, err, qt.IsNil)
75+
qt.Assert(t, sb.String(), qt.Equals, string(shellInput))
76+
77+
// Validate that emitting the JSON again produces the same result.
78+
sb.Reset()
79+
err = encOpts.Encode(sb, node2)
80+
qt.Assert(t, err, qt.IsNil)
81+
got = sb.String()
82+
qt.Assert(t, got, qt.Equals, string(jsonInput))
83+
t.Parallel()
84+
})
85+
}
86+
}

cmd/shfmt/testdata/json.json syntax/typedjson/testdata/roundtrip/file.json

+1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
{
2+
"Type": "File",
23
"Pos": {
34
"Offset": 0,
45
"Line": 1,
File renamed without changes.

0 commit comments

Comments
 (0)