Skip to content

Commit

Permalink
feat(hujsonfmt): add basic hujsonfmt CLI command
Browse files Browse the repository at this point in the history
Added as a separate Go module with its own go.mod file to avoid
polluting the root module's dependency tree.

The CLI interface and flags are very similar to gofmt:

    $ hujsonfmt -h
    usage: hujsonfmt [flags] [path ...]
      -d	display diffs instead of rewriting files
      -l	list files whose formatting differs from hujsonfmt's
      -m	minify results
      -s	standardize results to plain JSON
      -w	write result to (source) file instead of stdout

Given paths can either point directly to a file, or to a directory. In
the case of a directory, it will be recursively walked finding
all *.hujson files.

Input can also be provided via stdin if no paths are provided, or a
single path of "-" is provided:

    $ echo '{\n// hai\n"foo":"bar"}' | ./hujsonfmt
    {
    	// hai
    	"foo": "bar",
    }
  • Loading branch information
jimeh authored and knyar committed Dec 23, 2022
1 parent 78a2c9d commit 2048673
Show file tree
Hide file tree
Showing 4 changed files with 267 additions and 0 deletions.
8 changes: 8 additions & 0 deletions cmd/hujsonfmt/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
module github.com/tailscale/hujson/cmd/hujsonfmt

go 1.18

require (
github.com/hexops/gotextdiff v1.0.3
github.com/tailscale/hujson v0.0.0-20220630195928-54599719472f
)
5 changes: 5 additions & 0 deletions cmd/hujsonfmt/go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/tailscale/hujson v0.0.0-20220630195928-54599719472f h1:n4r/sJ92cBSBHK8n9lR1XLFr0OiTVeGfN5TR+9LaN7E=
github.com/tailscale/hujson v0.0.0-20220630195928-54599719472f/go.mod h1:DFSS3NAGHthKo1gTlmEcSBiZrRJXi28rLNd/1udP1c8=
248 changes: 248 additions & 0 deletions cmd/hujsonfmt/hujsonfmt.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
package main

import (
"flag"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"runtime"
"strings"

"github.com/hexops/gotextdiff"
"github.com/hexops/gotextdiff/myers"
"github.com/hexops/gotextdiff/span"
"github.com/tailscale/hujson"
)

var (
min = flag.Bool("m", false, "minify results")
stand = flag.Bool("s", false, "standardize results to plain JSON")
diff = flag.Bool("d", false, "display diffs instead of rewriting files")
list = flag.Bool("l", false,
"list files whose formatting differs from hujsonfmt's",
)
write = flag.Bool("w", false,
"write result to (source) file instead of stdout",
)

chmodSupported = runtime.GOOS != "windows"
huJSONExt = ".hujson"
)

func usage() {
fmt.Fprintf(os.Stderr, "usage: hujsonfmt [flags] [path ...]\n")
flag.PrintDefaults()
}

func main() {
err := mainE()
if err != nil {
fmt.Fprintf(os.Stderr, "%s\n", err)
usage()
os.Exit(1)
}
}

func mainE() error {
flag.Usage = usage
flag.Parse()

args := flag.Args()

if len(args) == 0 || (len(args) == 1 && args[0] == "-") {
stat, _ := os.Stdin.Stat()
if (stat.Mode() & os.ModeCharDevice) != 0 {
return fmt.Errorf("no files paths or stdin provided")
}
if *write {
return fmt.Errorf("cannot use -w with standard input")
}

return processFile(nil, "<standard input>", os.Stdin)
}

for _, arg := range args {
info, err := os.Stat(arg)
switch {
case err != nil:
return err
case !info.IsDir():
err := processFile(info, arg, nil)
if err != nil {
return err
}
default:
err := filepath.WalkDir(
arg,
func(path string, f fs.DirEntry, err error) error {
if err != nil || !isHuJSONFile(f) {
return err
}

return processFile(info, path, nil)
},
)
if err != nil {
return err
}
}
}

return nil
}

func isHuJSONFile(f fs.DirEntry) bool {
return strings.HasSuffix(f.Name(), huJSONExt) && !f.IsDir()
}

func processFile(info fs.FileInfo, filename string, in io.Reader) error {
src, err := readFile(filename, in)
if err != nil {
return err
}

// The main hujson functions will sometimes modify the original input byte
// slice. Hence we create a copy of the src byte slice to avoid modifying
// src, enabling us to reliably print diffs.
input := make([]byte, len(src))
_ = copy(input, src)

output, err := processSrc(input)
if err != nil {
return err
}

switch {
case *diff:
printDiff(filename, src, output)
case *list:
fmt.Println(filename)
case *write:
err = writeFile(info, filename, src, output)
if err != nil {
return err
}
default:
fmt.Print(string(output))
}

return nil
}

func readFile(path string, in io.Reader) ([]byte, error) {
if in == nil {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
in = f
}

src, err := io.ReadAll(in)
if err != nil {
return nil, err
}

return src, nil
}

func processSrc(src []byte) ([]byte, error) {
var r []byte
var err error
switch {
case *min:
r, err = hujson.Minimize(src)
case *stand:
r, err = hujson.Standardize(src)
default:
r, err = hujson.Format(src)
}
if err != nil {
return nil, err
}

return r, nil
}

func printDiff(filename string, src, modified []byte) {
origFile := filename + ".orig"
old := string(src)
new := string(modified)
edits := myers.ComputeEdits(
span.URIFromPath(origFile), old, new,
)
diff := fmt.Sprint(
gotextdiff.ToUnified(origFile, filename, old, edits),
)

if diff == "" {
return
}

fmt.Printf("diff %s %s\n", origFile, filename)
fmt.Println(diff)
}

func writeFile(info fs.FileInfo, filename string, src, data []byte) error {
if info == nil {
panic("-w should not have been allowed with standard input")
}

perms := info.Mode().Perm()

var bak string
bak, err := backupFile(filename, src, perms)
if err != nil {
return err
}

err = os.WriteFile(filename, data, perms)
if err != nil {
_ = os.Rename(bak, filename)

return err
}

err = os.Remove(bak)
if err != nil {
return err
}

return nil
}

func backupFile(
filename string,
data []byte,
perms fs.FileMode,
) (backupFile string, err error) {
var f *os.File
f, err = os.CreateTemp(filepath.Dir(filename), filepath.Base(filename))
if err != nil {
return "", err
}
defer f.Close()

backupFile = f.Name()

if chmodSupported {
err = f.Chmod(perms)
if err != nil {
_ = os.Remove(backupFile)

return "", err
}
}

_, err = f.Write(data)
if err != nil {
_ = os.Remove(backupFile)

return "", err
}

return backupFile, nil
}
6 changes: 6 additions & 0 deletions go.work
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
go 1.18

use (
.
./cmd/hujsonfmt
)

0 comments on commit 2048673

Please sign in to comment.