├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── build.yml │ ├── goreleaser.yml │ └── lint.yml ├── .gitignore ├── .golangci-lint.yml ├── .goreleaser.yml ├── Dockerfile ├── LICENSE ├── README.md ├── go.mod ├── go.sum ├── main.go └── testdata ├── 1.json ├── 2.json └── 3.json /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [caarlos0] 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | time: "08:00" 8 | labels: 9 | - "dependencies" 10 | commit-message: 11 | prefix: "chore" 12 | include: "scope" 13 | - package-ecosystem: "github-actions" 14 | directory: "/" 15 | schedule: 16 | interval: "daily" 17 | time: "08:00" 18 | labels: 19 | - "dependencies" 20 | commit-message: 21 | prefix: "chore" 22 | include: "scope" 23 | - package-ecosystem: "docker" 24 | directory: "/" 25 | schedule: 26 | interval: "daily" 27 | time: "08:00" 28 | labels: 29 | - "dependencies" 30 | commit-message: 31 | prefix: "chore" 32 | include: "scope" 33 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | pull_request: 5 | push: 6 | 7 | permissions: 8 | contents: read 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: actions/setup-go@v5 16 | with: 17 | go-version: ~1.17 18 | - uses: actions/cache@v4.2.3 19 | with: 20 | path: ~/go/pkg/mod 21 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 22 | restore-keys: | 23 | ${{ runner.os }}-go- 24 | - run: go test -v ./... 25 | dependabot: 26 | needs: [build] # <-- important! 27 | runs-on: ubuntu-latest 28 | permissions: 29 | pull-requests: write 30 | contents: write 31 | if: ${{ github.actor == 'dependabot[bot]' && github.event_name == 'pull_request'}} 32 | steps: 33 | - id: metadata 34 | uses: dependabot/fetch-metadata@v2 35 | with: 36 | github-token: "${{ secrets.GITHUB_TOKEN }}" 37 | - run: | 38 | gh pr review --approve "$PR_URL" 39 | gh pr merge --squash --auto "$PR_URL" 40 | env: 41 | PR_URL: ${{github.event.pull_request.html_url}} 42 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 43 | -------------------------------------------------------------------------------- /.github/workflows/goreleaser.yml: -------------------------------------------------------------------------------- 1 | name: goreleaser 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | permissions: 9 | contents: write 10 | id-token: write 11 | packages: write 12 | 13 | jobs: 14 | goreleaser: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 0 20 | - uses: actions/setup-go@v5 21 | with: 22 | go-version: ~1.17 23 | - uses: actions/cache@v4.2.3 24 | with: 25 | path: ~/go/pkg/mod 26 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 27 | restore-keys: | 28 | ${{ runner.os }}-go- 29 | - uses: sigstore/cosign-installer@v3.8.2 30 | - uses: anchore/sbom-action/download-syft@v0.20.0 31 | - uses: docker/setup-qemu-action@v3 32 | - uses: docker/login-action@v3 33 | with: 34 | username: caarlos0 35 | password: ${{ secrets.DOCKER_PASSWORD }} 36 | - uses: docker/login-action@v3 37 | with: 38 | registry: ghcr.io 39 | username: caarlos0 40 | password: ${{ secrets.GH_PAT }} 41 | - uses: goreleaser/goreleaser-action@v6 42 | with: 43 | distribution: goreleaser-pro 44 | version: latest 45 | args: release --clean 46 | env: 47 | GITHUB_TOKEN: ${{ secrets.GH_PAT }} 48 | FURY_TOKEN: ${{ secrets.FURY_TOKEN }} 49 | GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }} 50 | TWITTER_CONSUMER_KEY: ${{ secrets.TWITTER_CONSUMER_KEY }} 51 | TWITTER_CONSUMER_SECRET: ${{ secrets.TWITTER_CONSUMER_SECRET }} 52 | TWITTER_ACCESS_TOKEN: ${{ secrets.TWITTER_ACCESS_TOKEN }} 53 | TWITTER_ACCESS_TOKEN_SECRET: ${{ secrets.TWITTER_ACCESS_TOKEN_SECRET }} 54 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: lint 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | golangci: 9 | name: lint 10 | if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Install Go 14 | uses: actions/setup-go@v5 15 | with: 16 | go-version: ~1.16 17 | 18 | - uses: actions/checkout@v4 19 | - name: golangci-lint 20 | uses: golangci/golangci-lint-action@v8.0.0 21 | with: 22 | # Use supplied Go version 23 | skip-go-installation: true 24 | # Optional: golangci-lint command line arguments. 25 | args: --issues-exit-code=0 26 | # Optional: working directory, useful for monorepos 27 | # working-directory: somedir 28 | # Optional: show only new issues if it's a pull request. The default value is `false`. 29 | only-new-issues: true 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | -------------------------------------------------------------------------------- /.golangci-lint.yml: -------------------------------------------------------------------------------- 1 | linters: 2 | enable: 3 | - thelper 4 | - gofumpt 5 | - tparallel 6 | - unconvert 7 | - unparam 8 | - wastedassign 9 | - revive 10 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | variables: 4 | homepage: https://github.com/caarlos0/jsonfmt 5 | repository: https://github.com/caarlos0/jsonfmt 6 | description: Like gofmt, but for JSON files 7 | includes: 8 | - from_url: 9 | url: caarlos0/goreleaserfiles/main/build.yml 10 | - from_url: 11 | url: caarlos0/goreleaserfiles/main/package.yml 12 | - from_url: 13 | url: caarlos0/goreleaserfiles/main/release.yml 14 | - from_url: 15 | url: caarlos0/goreleaserfiles/main/docker.yml 16 | - from_url: 17 | url: caarlos0/goreleaserfiles/main/sbom.yml 18 | - from_url: 19 | url: caarlos0/goreleaserfiles/main/cosign_checksum.yml 20 | - from_url: 21 | url: caarlos0/goreleaserfiles/main/cosign_docker.yml 22 | 23 | furies: 24 | - account: caarlos0 25 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM scratch 2 | COPY jsonfmt /usr/local/bin/jsonfmt 3 | ENTRYPOINT ["/usr/local/bin/jsonfmt"] 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Carlos Alexandro Becker 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # jsonfmt 2 | 3 | Like `gofmt`, but for JSON files. 4 | 5 | Usage: `jsonfmt` or `jsonfmt -w` to autofix the issues. 6 | 7 | ## Install 8 | 9 | **homebrew**: 10 | 11 | ```sh 12 | brew install caarlos0/tap/jsonfmt 13 | ``` 14 | 15 | **docker**: 16 | 17 | ```sh 18 | docker run -v $PWD:/data --workdir /data caarlos0/jsonfmt -h 19 | ``` 20 | 21 | **apt**: 22 | 23 | ```sh 24 | echo 'deb [trusted=yes] https://repo.caarlos0.dev/apt/ /' | sudo tee /etc/apt/sources.list.d/caarlos0.list 25 | sudo apt update 26 | sudo apt install jsonfmt 27 | ``` 28 | 29 | **yum**: 30 | 31 | ```sh 32 | echo '[caarlos0] 33 | name=caarlos0 34 | baseurl=https://repo.caarlos0.dev/yum/ 35 | enabled=1 36 | gpgcheck=0' | sudo tee /etc/yum.repos.d/caarlos0.repo 37 | sudo yum install jsonfmt 38 | ``` 39 | 40 | **deb/rpm**: 41 | 42 | Download the `.deb` or `.rpm` from the [releases page][releases] and 43 | install with `dpkg -i` and `rpm -i` respectively. 44 | 45 | **manually**: 46 | 47 | Download the pre-compiled binaries from the [releases page][releases] or 48 | clone the repo build from source. 49 | 50 | [releases]: https://github.com/caarlos0/jsonfmt/releases 51 | 52 | 53 | ## Stargazers over time 54 | 55 | [![Stargazers over time](https://starchart.cc/caarlos0/jsonfmt.svg)](https://starchart.cc/caarlos0/jsonfmt) 56 | 57 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/caarlos0/jsonfmt 2 | 3 | go 1.16 4 | 5 | require ( 6 | github.com/goreleaser/fileglob v1.3.0 7 | github.com/hashicorp/go-multierror v1.1.1 8 | github.com/pmezard/go-difflib v1.0.0 9 | github.com/spf13/cobra v1.9.1 10 | ) 11 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/caarlos0/testfs v0.4.4 h1:3PHvzHi5Lt+g332CiShwS8ogTgS3HjrmzZxCm6JCDr8= 2 | github.com/caarlos0/testfs v0.4.4/go.mod h1:bRN55zgG4XCUVVHZCeU+/Tz1Q6AxEJOEJTliBy+1DMk= 3 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 4 | github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= 5 | github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= 6 | github.com/goreleaser/fileglob v1.3.0 h1:/X6J7U8lbDpQtBvGcwwPS6OpzkNVlVEsFUVRx9+k+7I= 7 | github.com/goreleaser/fileglob v1.3.0/go.mod h1:Jx6BoXv3mbYkEzwm9THo7xbr5egkAraxkGorbJb4RxU= 8 | github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= 9 | github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 10 | github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= 11 | github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= 12 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 13 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 14 | github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE= 15 | github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= 16 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 17 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 18 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 19 | github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= 20 | github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= 21 | github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= 22 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 23 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 24 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 25 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "os" 9 | 10 | "github.com/goreleaser/fileglob" 11 | "github.com/hashicorp/go-multierror" 12 | "github.com/pmezard/go-difflib/difflib" 13 | "github.com/spf13/cobra" 14 | ) 15 | 16 | var ( 17 | version = "master" 18 | write bool 19 | failfast bool 20 | indent string 21 | rootCmd = &cobra.Command{ 22 | Use: "jsonfmt", 23 | Short: "Like gofmt, but for JSON.", 24 | Long: `A fast and 0-options way to format JSON files`, 25 | Args: cobra.ArbitraryArgs, 26 | SilenceUsage: true, 27 | RunE: func(cmd *cobra.Command, args []string) error { 28 | if len(args) == 0 { 29 | args = []string{"**/*.json"} 30 | } 31 | return doRun(args) 32 | }, 33 | } 34 | ) 35 | 36 | func init() { 37 | rootCmd.PersistentFlags().BoolVarP(&write, "write", "w", false, "write changes to the files") 38 | rootCmd.PersistentFlags().BoolVarP(&failfast, "failfast", "f", false, "exit on first error") 39 | rootCmd.PersistentFlags().StringVarP(&indent, "indent", "i", " ", "indentation string") 40 | 41 | rootCmd.Version = version 42 | } 43 | 44 | func main() { 45 | if err := rootCmd.Execute(); err != nil { 46 | os.Exit(1) 47 | } 48 | } 49 | 50 | func doRun(globs []string) error { 51 | var rerr error 52 | 53 | files, err := findFiles(globs) 54 | if err != nil { 55 | return err 56 | } 57 | 58 | for _, file := range files { 59 | bts, err := file.Read() 60 | if err != nil { 61 | return err 62 | } 63 | 64 | var out bytes.Buffer 65 | if err := json.Indent(&out, bytes.TrimSpace(bts), "", indent); err != nil { 66 | return fmt.Errorf("failed to format json file: %s: %v", file.Name(), err) 67 | } 68 | if _, err := out.Write([]byte{'\n'}); err != nil { 69 | return fmt.Errorf("failed to write: %v", err) 70 | } 71 | 72 | if bytes.Equal(bts, out.Bytes()) { 73 | continue 74 | } 75 | 76 | if write { 77 | if err := file.Write(out.Bytes()); err != nil { 78 | return fmt.Errorf("failed to write file: %s: %v", file.Name(), err) 79 | } 80 | continue 81 | } 82 | 83 | diff, err := difflib.GetUnifiedDiffString(difflib.UnifiedDiff{ 84 | A: difflib.SplitLines(string(bts)), 85 | B: difflib.SplitLines(out.String()), 86 | FromFile: "Original", 87 | ToFile: "Formatted", 88 | Context: 3, 89 | }) 90 | if err != nil { 91 | return fmt.Errorf("failed to generate diff: %s: %v", file.Name(), err) 92 | } 93 | 94 | rerr = multierror.Append(rerr, fmt.Errorf("file %s differs:\n\n%s", file.Name(), diff)) 95 | 96 | if failfast { 97 | break 98 | } 99 | } 100 | return rerr 101 | } 102 | 103 | func findFiles(globs []string) ([]file, error) { 104 | if len(globs) == 1 && (globs)[0] == "-" { 105 | return []file{stdInOut{}}, nil 106 | } 107 | 108 | var files []file 109 | var rerr error 110 | 111 | for _, glob := range globs { 112 | matches, err := fileglob.Glob(glob, fileglob.MaybeRootFS, fileglob.MatchDirectoryIncludesContents) 113 | if err != nil { 114 | return files, err 115 | } 116 | if len(matches) == 0 { 117 | rerr = multierror.Append(rerr, fmt.Errorf("no matches found: %s", glob)) 118 | } 119 | 120 | for _, match := range matches { 121 | files = append(files, realFile{match}) 122 | } 123 | } 124 | 125 | return files, rerr 126 | } 127 | 128 | type file interface { 129 | Name() string 130 | Read() ([]byte, error) 131 | Write(b []byte) error 132 | } 133 | 134 | type stdInOut struct{} 135 | 136 | func (f stdInOut) Name() string { return "-" } 137 | func (f stdInOut) Read() ([]byte, error) { return io.ReadAll(os.Stdin) } 138 | func (f stdInOut) Write(b []byte) error { 139 | _, err := os.Stdout.Write(b) 140 | return err 141 | } 142 | 143 | type realFile struct { 144 | path string 145 | } 146 | 147 | func (f realFile) Name() string { return f.path } 148 | func (f realFile) Read() ([]byte, error) { return os.ReadFile(f.path) } 149 | func (f realFile) Write(b []byte) error { 150 | stat, err := os.Stat(f.Name()) 151 | if err != nil { 152 | return err 153 | } 154 | return os.WriteFile(f.Name(), b, stat.Mode()) 155 | } 156 | -------------------------------------------------------------------------------- /testdata/1.json: -------------------------------------------------------------------------------- 1 | 2 | { 3 | "zsdasd": true, 4 | "foo": 1, 5 | 6 | 7 | 8 | 9 | "bar": 2 , 10 | "test": 11 | false 12 | 13 | 14 | 15 | } 16 | 17 | 18 | -------------------------------------------------------------------------------- /testdata/2.json: -------------------------------------------------------------------------------- 1 | { 2 | "zzzz": 1, 3 | "baz": { 4 | "zzzzzzz": 324324, 5 | "foo": 1, 6 | "bar" : 2, 7 | "test": false 8 | } 9 | 10 | 11 | } 12 | -------------------------------------------------------------------------------- /testdata/3.json: -------------------------------------------------------------------------------- 1 | [ 2 | 3 | 4 | 5 | 6 | { 7 | "zzzz": 1, 8 | "baz": { 9 | "zzzzzzz": 324324, 10 | "foo": 1, 11 | "bar" : 2, 12 | "test": false 13 | } 14 | 15 | 16 | } 17 | 18 | , 19 | 20 | {}, 21 | 22 | { 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | },{"fdasfdad":324} 34 | 35 | 36 | 37 | 38 | ] 39 | --------------------------------------------------------------------------------