├── go.mod ├── .gitignore ├── Dockerfile ├── go.sum ├── .github └── workflows │ ├── go.yml │ └── release.yml ├── sorter.go ├── format.go ├── README.md ├── .goreleaser.yml ├── LICENSE ├── format_test.go └── cmd └── yamlfmt └── main.go /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/stuart-warren/yamlfmt 2 | 3 | require gopkg.in/yaml.v3 v3.0.0-20190709130402-674ba3eaed22 4 | 5 | go 1.13 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, build with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | # Release files 14 | dist/* 15 | main -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.13 2 | WORKDIR /go/src/github.com/stuart-warren/yamlfmt/ 3 | COPY . . 4 | RUN CGO_ENABLED=0 GOOS=linux go build -a -o yamlfmt ./cmd/yamlfmt 5 | 6 | FROM alpine:3.16 7 | RUN apk --no-cache add diffutils 8 | WORKDIR /tmp 9 | COPY --from=0 /go/src/github.com/stuart-warren/yamlfmt/yamlfmt /usr/local/bin/yamlfmt 10 | ENTRYPOINT ["/usr/local/bin/yamlfmt"] -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 2 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 3 | gopkg.in/yaml.v3 v3.0.0-20190709130402-674ba3eaed22 h1:0efs3hwEZhFKsCoP8l6dDB1AZWMgnEl3yWXWRZTOaEA= 4 | gopkg.in/yaml.v3 v3.0.0-20190709130402-674ba3eaed22/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 5 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | on: [push, pull_request] 3 | jobs: 4 | build: 5 | name: Build 6 | runs-on: ubuntu-latest 7 | steps: 8 | - name: Set up Go 1.13 9 | uses: actions/setup-go@v1 10 | with: 11 | go-version: 1.13 12 | id: go 13 | 14 | - name: Check out code into the Go module directory 15 | uses: actions/checkout@v1 16 | 17 | - name: Build 18 | run: go build -v . 19 | 20 | - name: Test 21 | run: go test . 22 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | create: 5 | tags: 6 | - v* 7 | 8 | jobs: 9 | release: 10 | name: Release on GitHub 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Check out code 14 | uses: actions/checkout@v1 15 | 16 | - name: Validates GO releaser config 17 | uses: docker://goreleaser/goreleaser:latest 18 | with: 19 | args: check 20 | 21 | - name: Create release on GitHub 22 | uses: docker://goreleaser/goreleaser:latest 23 | with: 24 | args: release 25 | env: 26 | HOMEBREW_GITHUB_TOKEN: ${{ secrets.HOMEBREW_GITHUB_TOKEN }} 27 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 28 | -------------------------------------------------------------------------------- /sorter.go: -------------------------------------------------------------------------------- 1 | package yamlfmt 2 | 3 | import ( 4 | "sort" 5 | 6 | "gopkg.in/yaml.v3" 7 | ) 8 | 9 | type nodes []*yaml.Node 10 | 11 | func (i nodes) Len() int { return len(i) / 2 } 12 | 13 | func (i nodes) Swap(x, y int) { 14 | x *= 2 15 | y *= 2 16 | i[x], i[y] = i[y], i[x] // keys 17 | i[x+1], i[y+1] = i[y+1], i[x+1] // values 18 | } 19 | 20 | func (i nodes) Less(x, y int) bool { 21 | x *= 2 22 | y *= 2 23 | return i[x].Value < i[y].Value 24 | } 25 | 26 | func sortYAML(node *yaml.Node) *yaml.Node { 27 | if node.Kind == yaml.DocumentNode { 28 | for i, n := range node.Content { 29 | node.Content[i] = sortYAML(n) 30 | } 31 | } 32 | if node.Kind == yaml.SequenceNode { 33 | for i, n := range node.Content { 34 | node.Content[i] = sortYAML(n) 35 | } 36 | } 37 | if node.Kind == yaml.MappingNode { 38 | for i, n := range node.Content { 39 | node.Content[i] = sortYAML(n) 40 | } 41 | sort.Sort(nodes(node.Content)) 42 | } 43 | return node 44 | } 45 | -------------------------------------------------------------------------------- /format.go: -------------------------------------------------------------------------------- 1 | package yamlfmt 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | 8 | "gopkg.in/yaml.v3" 9 | ) 10 | 11 | const indent = 2 12 | 13 | // Format reads in a yaml document and outputs the yaml in a standard format. 14 | // If sort is true than dictionary keys are sorted lexicographically 15 | // Indents are set to 2 16 | // Lists are not indented 17 | func Format(r io.Reader, sort bool) ([]byte, error) { 18 | dec := yaml.NewDecoder(r) 19 | out := bytes.NewBuffer(nil) 20 | for { 21 | enc := yaml.NewEncoder(out) 22 | enc.SetIndent(indent) 23 | defer enc.Close() 24 | var doc yaml.Node 25 | err := dec.Decode(&doc) 26 | if err == io.EOF { 27 | break 28 | } 29 | if err != nil { 30 | return nil, fmt.Errorf("failed decoding: %s", err) 31 | } 32 | out.WriteString("---\n") 33 | if sort { 34 | err = enc.Encode(sortYAML(&doc)) 35 | } else { 36 | err = enc.Encode(&doc) 37 | } 38 | if err != nil { 39 | return nil, fmt.Errorf("failed encoding: %s", err) 40 | } 41 | enc.Close() 42 | } 43 | return out.Bytes(), nil 44 | } 45 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # yamlfmt 2 | 3 | [![Go build](https://github.com/stuart-warren/yamlfmt/workflows/Go/badge.svg)](https://github.com/stuart-warren/yamlfmt/actions) 4 | 5 | based on gofmt, yamlfmt formats yaml files into a canonical format 6 | 7 | * lists are not indented 8 | * maps have sorted keys 9 | * indent is 2 spaces 10 | * documents always have separators `---` 11 | 12 | ``` 13 | $ yamlfmt --help 14 | formats yaml files with 2 space indent, sorted dicts and non-indented lists 15 | usage: yamlfmt [flags] [path ...] 16 | -d display diffs instead of rewriting files 17 | -f exit non zero if changes detected 18 | -l list files whose formatting differs from yamlfmt's 19 | -s sort maps & sequences, WARNING: This may break anchors & aliases 20 | -w write result to (source) file instead of stdout 21 | ``` 22 | 23 | Without an explicit path, it processes the standard input. Given a file, it operates on that file; given a directory, it operates on all .yaml and .yml files in that directory, recursively. 24 | 25 | By default, yamlfmt prints the reformatted sources to standard output. 26 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | # This is an example goreleaser.yaml file with some sane defaults. 2 | # Make sure to check the documentation at http://goreleaser.com 3 | before: 4 | hooks: 5 | - go mod tidy 6 | builds: 7 | - env: 8 | - CGO_ENABLED=0 9 | main: ./cmd/yamlfmt/main.go 10 | goos: 11 | - windows 12 | - darwin 13 | - linux 14 | goarch: 15 | - amd64 16 | archives: 17 | - replacements: 18 | darwin: Darwin 19 | linux: Linux 20 | windows: Windows 21 | amd64: x86_64 22 | files: 23 | - README.md 24 | - LICENSE 25 | checksum: 26 | name_template: 'checksums.txt' 27 | snapshot: 28 | name_template: "{{ .Tag }}-next" 29 | changelog: 30 | sort: asc 31 | filters: 32 | exclude: 33 | - '^docs:' 34 | - '^test:' 35 | - Merge pull request 36 | - Merge branch 37 | - go mod tidy 38 | brews: 39 | - name: yamlfmt 40 | tap: 41 | owner: stuart-warren 42 | name: homebrew-apps 43 | token: "{{ .Env.HOMEBREW_GITHUB_TOKEN }}" 44 | folder: Formula 45 | homepage: https://github.com/stuart-warren/yamlfmt 46 | description: based on gofmt, yamlfmt formats yaml files into a canonical format 47 | license: BSD-3-clause 48 | test: | 49 | system "#{bin}/yamlfmt -help" 50 | dependencies: 51 | - name: go 52 | install: |- 53 | bin.install "yamlfmt" 54 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2009 The Go Authors. All rights reserved. # github.com/stuart-warren/yamlfmt/cmd/yamlfmt package 2 | Copyright (c) 2019 Stuart Warren. All rights reserved. # other packages 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are 6 | met: 7 | 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | * Redistributions in binary form must reproduce the above 11 | copyright notice, this list of conditions and the following disclaimer 12 | in the documentation and/or other materials provided with the 13 | distribution. 14 | * Neither the name of Google Inc. nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 19 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 20 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 21 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 22 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 23 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 24 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 25 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 26 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 27 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | © 2019 GitHub, Inc. 30 | -------------------------------------------------------------------------------- /format_test.go: -------------------------------------------------------------------------------- 1 | package yamlfmt_test 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/stuart-warren/yamlfmt" 8 | ) 9 | 10 | func TestYamlSort(t *testing.T) { 11 | var in = `--- 12 | k: 13 | c: l 14 | i: 15 | i: k 16 | v: 17 | # comment 18 | c: i 19 | ` 20 | var expected = `--- 21 | k: 22 | c: l 23 | i: 24 | i: k 25 | v: 26 | # comment 27 | c: i 28 | ` 29 | exp := []byte(expected) 30 | out, err := yamlfmt.Format(bytes.NewReader([]byte(in)), true) 31 | if err != nil { 32 | t.Fatalf("Unexpected error: %s\n", err) 33 | } 34 | if !bytes.Equal(out, exp) { 35 | t.Fatalf("Got:\n%s\nexpected:\n%s\n", out, exp) 36 | } 37 | t.Logf("got:\n%v\n", out) 38 | t.Logf("expected:\n%v\n", exp) 39 | } 40 | 41 | func TestCommentedYaml(t *testing.T) { 42 | var in = `--- 43 | bar: 44 | foo: baz # comment 45 | boo: fizz 46 | ` 47 | var expected = `--- 48 | bar: 49 | boo: fizz 50 | foo: baz # comment 51 | ` 52 | exp := []byte(expected) 53 | out, err := yamlfmt.Format(bytes.NewReader([]byte(in)), true) 54 | if err != nil { 55 | t.Fatalf("Unexpected error: %s\n", err) 56 | } 57 | if !bytes.Equal(out, exp) { 58 | t.Fatalf("Got:\n%s\nexpected:\n%s\n", out, exp) 59 | } 60 | t.Logf("got:\n%v\n", out) 61 | t.Logf("expected:\n%v\n", exp) 62 | } 63 | 64 | func TestYamlMultiSort(t *testing.T) { 65 | var in = `--- 66 | k: 67 | c: l 68 | i: 69 | i: k 70 | v: 71 | # comment 72 | c: i 73 | --- 74 | k: 75 | c: l 76 | i: 77 | i: k 78 | v: 79 | # comment 80 | c: i 81 | ` 82 | var expected = `--- 83 | k: 84 | c: l 85 | i: 86 | i: k 87 | v: 88 | # comment 89 | c: i 90 | --- 91 | k: 92 | c: l 93 | i: 94 | i: k 95 | v: 96 | # comment 97 | c: i 98 | ` 99 | exp := []byte(expected) 100 | out, err := yamlfmt.Format(bytes.NewReader([]byte(in)), true) 101 | if err != nil { 102 | t.Fatalf("Unexpected error: %s\n", err) 103 | } 104 | if !bytes.Equal(out, exp) { 105 | t.Fatalf("Got:\n%s\nexpected:\n%s\n", out, exp) 106 | } 107 | t.Logf("got:\n%v\n", out) 108 | t.Logf("expected:\n%v\n", exp) 109 | } 110 | -------------------------------------------------------------------------------- /cmd/yamlfmt/main.go: -------------------------------------------------------------------------------- 1 | // Most of this is from gofmt: 2 | // Copyright 2009 The Go Authors. All rights reserved. 3 | // Use of this source code is governed by a BSD-style 4 | // license that can be found in the LICENSE file. 5 | 6 | package main 7 | 8 | import ( 9 | "bytes" 10 | "errors" 11 | "flag" 12 | "fmt" 13 | "io" 14 | "io/ioutil" 15 | "log" 16 | "os" 17 | "os/exec" 18 | "path/filepath" 19 | "runtime" 20 | "strings" 21 | 22 | "github.com/stuart-warren/yamlfmt" 23 | ) 24 | 25 | var ( 26 | list bool 27 | write bool 28 | doDiff bool 29 | doFail bool 30 | doSort bool 31 | 32 | errRequiresFmt = errors.New("RequiresFmt") 33 | ) 34 | 35 | func main() { 36 | err := run(os.Stdin, os.Stdout, os.Args) 37 | if err == errRequiresFmt { 38 | os.Exit(1) 39 | } 40 | if err != nil { 41 | log.Fatalln(err) 42 | } 43 | } 44 | 45 | func run(in io.Reader, out io.Writer, args []string) error { 46 | flags := flag.NewFlagSet(args[0], flag.ExitOnError) 47 | 48 | flags.BoolVar(&list, "l", false, "list files whose formatting differs from yamlfmt's") 49 | flags.BoolVar(&write, "w", false, "write result to (source) file instead of stdout") 50 | flags.BoolVar(&doDiff, "d", false, "display diffs instead of rewriting files") 51 | flags.BoolVar(&doFail, "f", false, "exit non zero if changes detected") 52 | flags.BoolVar(&doSort, "s", false, "sort maps & sequences, WARNING: This may break anchors & aliases") 53 | flags.Usage = func() { 54 | fmt.Fprintf(os.Stderr, "formats yaml files with 2 space indent and non-indented sequences\n") 55 | fmt.Fprintf(os.Stderr, "usage: yamlfmt [flags] [path ...]\n") 56 | flags.PrintDefaults() 57 | } 58 | flags.Parse(args[1:]) 59 | 60 | if flags.NArg() == 0 { 61 | if write { 62 | return fmt.Errorf("error: cannot use -w with standard input") 63 | } 64 | if err := processFile("", in, out, true, doSort); err != nil { 65 | return err 66 | } 67 | } 68 | 69 | for i := 0; i < flags.NArg(); i++ { 70 | path := flags.Arg(i) 71 | switch dir, err := os.Stat(path); { 72 | case err != nil: 73 | return err 74 | case dir.IsDir(): 75 | return walkDir(path, doSort) 76 | default: 77 | if err := processFile(path, nil, os.Stdout, false, doSort); err != nil { 78 | return err 79 | } 80 | } 81 | } 82 | return nil 83 | } 84 | 85 | // If in == nil, the source is the contents of the file with the given filename. 86 | func processFile(filename string, in io.Reader, out io.Writer, stdin bool, sort bool) error { 87 | var perm os.FileMode = 0644 88 | if in == nil { 89 | f, err := os.Open(filename) 90 | if err != nil { 91 | return err 92 | } 93 | defer f.Close() 94 | fi, err := f.Stat() 95 | if err != nil { 96 | return err 97 | } 98 | in = f 99 | perm = fi.Mode().Perm() 100 | } 101 | 102 | src, err := ioutil.ReadAll(in) 103 | if err != nil { 104 | return err 105 | } 106 | 107 | res, err := yamlfmt.Format(bytes.NewBuffer(src), sort) 108 | if err != nil { 109 | return err 110 | } 111 | 112 | if !list && !write && !doDiff { 113 | _, err = out.Write(res) 114 | } 115 | 116 | if !bytes.Equal(src, res) { 117 | // formatting has changed 118 | if list { 119 | fmt.Fprintln(out, filename) 120 | } 121 | if write { 122 | // make a temporary backup before overwriting original 123 | bakname, err := backupFile(filename+".", src, perm) 124 | if err != nil { 125 | return err 126 | } 127 | err = ioutil.WriteFile(filename, res, perm) 128 | if err != nil { 129 | os.Rename(bakname, filename) 130 | return err 131 | } 132 | err = os.Remove(bakname) 133 | if err != nil { 134 | return err 135 | } 136 | } 137 | if doDiff { 138 | data, err := diff(src, res, filename) 139 | if err != nil { 140 | return fmt.Errorf("computing diff: %s", err) 141 | } 142 | fmt.Printf("diff -u %s %s\n", filepath.ToSlash(filename+".orig"), filepath.ToSlash(filename)) 143 | out.Write(data) 144 | } 145 | if doFail { 146 | return errRequiresFmt 147 | } 148 | } 149 | 150 | return err 151 | } 152 | 153 | type fileVisitor struct { 154 | changesDetected bool 155 | sort bool 156 | } 157 | 158 | func (fv *fileVisitor) visitFile(path string, f os.FileInfo, err error) error { 159 | if err == nil && isYamlFile(f) { 160 | err = processFile(path, nil, os.Stdout, false, fv.sort) 161 | } 162 | // Don't complain if a file was deleted in the meantime (i.e. 163 | // the directory changed concurrently while running gofmt). 164 | if err != nil && !os.IsNotExist(err) && err != errRequiresFmt { 165 | return err 166 | } 167 | if err == errRequiresFmt { 168 | fv.changesDetected = true 169 | } 170 | return nil 171 | } 172 | 173 | func walkDir(path string, sort bool) error { 174 | fv := fileVisitor{sort: sort} 175 | filepath.Walk(path, fv.visitFile) 176 | var err error 177 | if fv.changesDetected { 178 | err = errRequiresFmt 179 | } 180 | return err 181 | } 182 | 183 | const chmodSupported = runtime.GOOS != "windows" 184 | 185 | // backupFile writes data to a new file named filename with permissions perm, 186 | // with 241 | // --- path/to/file.yaml.orig 2017-02-03 19:13:00.280468375 -0500 242 | // +++ path/to/file.yaml 2017-02-03 19:13:00.280468375 -0500 243 | // ... 244 | func replaceTempFilename(diff []byte, filename string) ([]byte, error) { 245 | bs := bytes.SplitN(diff, []byte{'\n'}, 3) 246 | if len(bs) < 3 { 247 | return nil, fmt.Errorf("got unexpected diff for %s", filename) 248 | } 249 | // Preserve timestamps. 250 | var t0, t1 []byte 251 | if i := bytes.LastIndexByte(bs[0], '\t'); i != -1 { 252 | t0 = bs[0][i:] 253 | } 254 | if i := bytes.LastIndexByte(bs[1], '\t'); i != -1 { 255 | t1 = bs[1][i:] 256 | } 257 | // Always print filepath with slash separator. 258 | f := filepath.ToSlash(filename) 259 | bs[0] = []byte(fmt.Sprintf("--- %s%s", f+".orig", t0)) 260 | bs[1] = []byte(fmt.Sprintf("+++ %s%s", f, t1)) 261 | return bytes.Join(bs, []byte{'\n'}), nil 262 | } 263 | 264 | func diff(b1, b2 []byte, filename string) (data []byte, err error) { 265 | f1, err := writeTempFile("", "yamlfmt", b1) 266 | if err != nil { 267 | return 268 | } 269 | defer os.Remove(f1) 270 | 271 | f2, err := writeTempFile("", "yamlfmt", b2) 272 | if err != nil { 273 | return 274 | } 275 | defer os.Remove(f2) 276 | 277 | cmd := "diff" 278 | data, err = exec.Command(cmd, "-u", f1, f2).CombinedOutput() 279 | if len(data) > 0 { 280 | // diff exits with a non-zero status when the files don't match. 281 | // Ignore that failure as long as we get output. 282 | return replaceTempFilename(data, filename) 283 | } 284 | return 285 | } 286 | --------------------------------------------------------------------------------