diff --git a/cmd/hujsonfmt/go.mod b/cmd/hujsonfmt/go.mod new file mode 100644 index 0000000..dfcbdf9 --- /dev/null +++ b/cmd/hujsonfmt/go.mod @@ -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 +) diff --git a/cmd/hujsonfmt/go.sum b/cmd/hujsonfmt/go.sum new file mode 100644 index 0000000..2e88308 --- /dev/null +++ b/cmd/hujsonfmt/go.sum @@ -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= diff --git a/cmd/hujsonfmt/hujsonfmt.go b/cmd/hujsonfmt/hujsonfmt.go new file mode 100644 index 0000000..b73fb11 --- /dev/null +++ b/cmd/hujsonfmt/hujsonfmt.go @@ -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, "", 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 +} diff --git a/go.work b/go.work new file mode 100644 index 0000000..d57b4e1 --- /dev/null +++ b/go.work @@ -0,0 +1,6 @@ +go 1.18 + +use ( + . + ./cmd/hujsonfmt +)