From b05979c322a5e10537f0e40facec77cc395f7460 Mon Sep 17 00:00:00 2001 From: Nick Sieger Date: Thu, 30 Dec 2021 17:04:02 -0600 Subject: [PATCH] Add go port of jsonpretty --- .gitignore | 2 + .goreleaser.yaml | 30 +++++++ go.mod | 3 + main.go | 199 +++++++++++++++++++++++++++++++++++++++++++++++ main_test.go | 180 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 414 insertions(+) create mode 100644 .goreleaser.yaml create mode 100644 go.mod create mode 100644 main.go create mode 100644 main_test.go diff --git a/.gitignore b/.gitignore index a35d992..4545b73 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ *.gem pkg /Gemfile.lock + +dist/ diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..5641820 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,30 @@ +# This is an example .goreleaser.yml file with some sensible defaults. +# Make sure to check the documentation at https://goreleaser.com +before: + hooks: + # You may remove this if you don't use go modules. + - go mod tidy +builds: + - env: + - CGO_ENABLED=0 + goos: + - linux + - windows + - darwin +archives: + - replacements: + darwin: Darwin + linux: Linux + windows: Windows + 386: i386 + amd64: x86_64 +checksum: + name_template: 'checksums.txt' +snapshot: + name_template: "{{ incpatch .Version }}-next" +changelog: + sort: asc + filters: + exclude: + - '^docs:' + - '^test:' diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a30697d --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/nicksieger/jsonpretty + +go 1.17 diff --git a/main.go b/main.go new file mode 100644 index 0000000..6162f3c --- /dev/null +++ b/main.go @@ -0,0 +1,199 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "os" + "regexp" + "strings" +) + +var ( + version = "dev" + commit = "" + date = "" + builtBy = "" +) + +type JsonArg struct { + file *os.File + content []byte +} + +func (j JsonArg) Close() error { + if j.file != nil { + return j.file.Close() + } + return nil +} + +func usage(code int) { + fmt.Printf("usage: %s [args|@filename|@- (stdin)]\n", os.Args[0]) + fmt.Printf("Parse and pretty-print JSON, either from stdin or from arguments concatenated together\n") + os.Exit(code) +} + +func versionInfo() string { + if commit != "" && date != "" { + return fmt.Sprintf("%s (%s on %s)", version, commit[0:7], date[0:10]) + } + return version +} + +func checkFlags(arg string) { + if arg[0] == '-' && len(arg) > 1 { + code := 1 + if arg == "-v" || strings.HasPrefix(arg, "--v") { + fmt.Printf("jsonpretty version %s\n", versionInfo()) + os.Exit(0) + } else if arg == "-h" || strings.HasPrefix(arg, "--h") { + code = 0 + } + if code == 1 { + fmt.Printf("unrecognized flag %s\n", arg) + } + usage(code) + } +} + +func buildJsonSource(args []JsonArg) *bytes.Buffer { + buffer := bytes.NewBuffer(nil) + for _, arg := range args { + if arg.file != nil { + _, err := buffer.ReadFrom(arg.file) + if err != nil { + fmt.Fprintf(os.Stderr, "error reading file: %v\n", err) + } + } else { + _, err := buffer.Write(arg.content) + if err != nil { + fmt.Fprintf(os.Stderr, "error appending content: %v\n", err) + } + } + } + return buffer +} + +func readArguments(args []string) *bytes.Buffer { + jsonArgs := []JsonArg{} + defer func() { + for _, j := range jsonArgs { + _ = j.Close() + } + }() + for _, arg := range args { + checkFlags(arg) + + if arg == "-" || arg == "@-" { + jsonArgs = append(jsonArgs, JsonArg{file: os.Stdin}) + } else if strings.HasPrefix(arg, "@") { + file, err := os.Open(arg[1:]) + if err != nil { + fmt.Fprintf(os.Stderr, "skipping '%s' due to error: %v\n", arg, err) + continue + } + jsonArgs = append(jsonArgs, JsonArg{file: file}) + } else { + jsonArgs = append(jsonArgs, JsonArg{content: []byte(arg)}) + } + } + + if len(jsonArgs) == 0 { + jsonArgs = append(jsonArgs, JsonArg{file: os.Stdin}) + } + return buildJsonSource(jsonArgs) +} + +var httpRegex *regexp.Regexp = regexp.MustCompile("^HTTP/\\d") + +func parseHeaders(src *bytes.Buffer) ([]byte, []byte, error) { + headerBuffer := bytes.NewBuffer(nil) + line, err := src.ReadBytes('\n') + if err != nil && err != io.EOF { + return nil, nil, err + } + + if httpRegex.Match(line) { + for { + _, err = headerBuffer.Write(line) + if err != nil { + return nil, nil, err + } + if (len(line) == 1 && line[0] == '\n') || + (len(line) == 2 && line[0] == '\r' && line[1] == '\n') { + break + } + + line, err = src.ReadBytes('\n') + + if err == io.EOF { + _, err = headerBuffer.Write(line) + if err != nil { + return nil, nil, err + } + break + } + + if err != nil && err != io.EOF { + return nil, nil, err + } + } + } else { + newsrc := bytes.NewBuffer(line) + _, err = newsrc.Write(src.Bytes()) + if err != nil { + return nil, nil, err + } + src = newsrc + } + return headerBuffer.Bytes(), src.Bytes(), nil +} + +var jsonpRegexp *regexp.Regexp = regexp.MustCompile("^([a-zA-Z$_][^ \t\r\n(]+)\\(") +var endingParen *regexp.Regexp = regexp.MustCompile("\\)[ \t\r\n]*$") + +func cleanJsonp(jsonSrc []byte) ([]byte, string) { + match := jsonpRegexp.FindSubmatch(jsonSrc) + ending := endingParen.FindSubmatch(jsonSrc) + if match != nil && ending != nil { + return jsonSrc[len(match[0]) : len(jsonSrc)-len(ending[0])], string(match[1]) + } + return jsonSrc, "" +} + +func handleParseError(err error, jsonSrc []byte) { + if err != nil { + fmt.Fprintf(os.Stderr, "error parsing json: %v\ninput:\n%s\n", err, string(jsonSrc)) + os.Exit(1) + } +} + +func main() { + buffer := readArguments(os.Args[1:]) + headers, jsonSrc, err := parseHeaders(buffer) + handleParseError(err, jsonSrc) + + jsonSrc, jsonpName := cleanJsonp(jsonSrc) + + if len(headers) > 0 { + fmt.Print(string(headers)) + } + + if jsonpName != "" { + fmt.Printf("jsonp method name: %s\n\n", jsonpName) + } + + var jsonObj interface{} + err = json.Unmarshal(jsonSrc, &jsonObj) + handleParseError(err, jsonSrc) + + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + err = enc.Encode(&jsonObj) + if err != nil { + fmt.Fprintf(os.Stderr, "error encoding json: %v\n", err) + os.Exit(1) + } +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..e709992 --- /dev/null +++ b/main_test.go @@ -0,0 +1,180 @@ +package main + +import ( + "bytes" + "fmt" + "os" + "testing" +) + +type Testfile struct { + name string + content string + file *os.File +} + +var stdinSaved *os.File = os.Stdin + +func Setup(contents []string, t *testing.T) []Testfile { + var err error + os.Stdin, err = os.CreateTemp("", "stdin") + if err != nil { + t.FailNow() + } + + testfiles := make([]Testfile, len(contents)) + for i, c := range contents { + testfiles[i] = Testfile{content: c} + file, err := os.CreateTemp("", fmt.Sprintf("tf%d", i)) + if err != nil { + t.Logf("unable to create tempfile for '%s'", c) + t.FailNow() + } + _, err = file.Write([]byte(c)) + if err != nil { + t.Logf("unable to write tempfile with '%s'", c) + t.FailNow() + } + err = file.Close() + if err != nil { + t.Logf("unable to close tempfile for '%s'", c) + t.FailNow() + } + testfiles[i].file = file + testfiles[i].name = file.Name() + } + return testfiles +} + +func TearDown(testfiles []Testfile) { + for _, tf := range testfiles { + _ = os.Remove(tf.name) + } + _ = os.Stdin.Close() + os.Stdin = stdinSaved +} + +func assertEqual(expect []byte, actual []byte, t *testing.T) { + if bytes.Compare(expect, actual) != 0 { + t.Errorf("bytes did not match:\n. expected:\n %s\n actual:\n %s\n", + string(expect), string(actual)) + } +} + +func TestReadArguments(t *testing.T) { + files := Setup([]string{"1", "2", "{\"foo\": true, \"bar\": false}"}, t) + defer TearDown(files) + + tests := [][]Testfile{ + {}, // nothing + {Testfile{content: "{\"hello\":\"world\"}"}}, + {Testfile{content: "["}, files[0], Testfile{content: ","}, files[1], Testfile{content: "]"}}, + {files[2]}, + } + + for i, test := range tests { + t.Run(fmt.Sprintf("test%d", i), func(t *testing.T) { + expected := bytes.NewBuffer(nil) + args := make([]string, len(test)) + for j, testfile := range test { + if testfile.name != "" { + args[j] = fmt.Sprintf("@%s", testfile.name) + } else { + args[j] = testfile.content + } + expected.WriteString(testfile.content) + } + expect := expected.Bytes() + actual := readArguments(args) + assertEqual(expect, actual.Bytes(), t) + }) + } +} + +func TestReadArgumentsStdin(t *testing.T) { + f := Setup([]string{}, t) + defer TearDown(f) + + expect := []byte("{\"foo\": true, \"bar\": false}") + _, err := os.Stdin.Write(expect) + if err != nil { + t.Logf("unable to write to stdin\n") + t.FailNow() + } + _ = os.Stdin.Close() + + reopen := func(t *testing.T) { + f, err := os.Open(os.Stdin.Name()) + if err != nil { + t.Logf("unable to reopen stdin") + t.FailNow() + } + os.Stdin = f + } + + t.Run("no args", func(t *testing.T) { + reopen(t) + actual := readArguments([]string{}) + assertEqual(expect, actual.Bytes(), t) + }) + + t.Run("-", func(t *testing.T) { + reopen(t) + actual := readArguments([]string{"-"}) + assertEqual(expect, actual.Bytes(), t) + }) + + t.Run("@-", func(t *testing.T) { + reopen(t) + actual := readArguments([]string{"@-"}) + assertEqual(expect, actual.Bytes(), t) + }) +} + +func TestParseHeaders(t *testing.T) { + tests := [][]string{ + {"", "", ""}, + {"{}", "", "{}"}, + {"not http\n\nhello\nworld", "", "not http\n\nhello\nworld"}, + {"HTTP/1.1 200 OK\nContent-Type: application/json\nContent-Length: 19\n\n{\"hello\":\"world\"}\n", + "HTTP/1.1 200 OK\nContent-Type: application/json\nContent-Length: 19\n\n", + "{\"hello\":\"world\"}\n"}, + {"HTTP/1.1 200 OK\nContent-Type: application/json\nContent-Length: 0\n\n", + "HTTP/1.1 200 OK\nContent-Type: application/json\nContent-Length: 0\n\n", + ""}, + {"myfunc(1,2,3)", "", "myfunc(1,2,3)"}, + } + + for i, test := range tests { + t.Run(fmt.Sprintf("test%d", i), func(t *testing.T) { + buffer := bytes.NewBuffer(nil) + buffer.WriteString(test[0]) + headers, jsonsrc, err := parseHeaders(buffer) + if err != nil { + t.Logf("error parseHeaders: %v", err) + t.FailNow() + } + assertEqual([]byte(test[1]), headers, t) + assertEqual([]byte(test[2]), jsonsrc, t) + }) + } +} + +func TestCleanJsonp(t *testing.T) { + tests := [][]string{ + {"jsonp(true)", "true", "jsonp"}, + {"true", "true", ""}, + {"myJSFunc({\"hello\":\"world\"})", "{\"hello\":\"world\"}", "myJSFunc"}, + {"[1,2,3]", "[1,2,3]", ""}, + {"myfunc(1,2,3)", "1,2,3", "myfunc"}, + {"myfunc(1,2,3)\n", "1,2,3", "myfunc"}, + } + + for i, test := range tests { + t.Run(fmt.Sprintf("test%d", i), func(t *testing.T) { + jsonSrc, jsonpName := cleanJsonp([]byte(test[0])) + assertEqual([]byte(test[1]), jsonSrc, t) + assertEqual([]byte(test[2]), []byte(jsonpName), t) + }) + } +}