diff --git a/cmd/format.go b/cmd/format.go new file mode 100644 index 0000000..7313273 --- /dev/null +++ b/cmd/format.go @@ -0,0 +1,124 @@ +/* +Copyright (c) 2021 amplia-iiot + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ +package cmd + +import ( + "errors" + "fmt" + + "github.com/amplia-iiot/yutil/internal/io" + "github.com/amplia-iiot/yutil/pkg/format" + "github.com/spf13/cobra" +) + +type FormatOptions struct { + outputFile string + inPlace bool + suffix string +} + +var formatOptions FormatOptions + +// formatCmd represents the format command +var formatCmd = &cobra.Command{ + Use: "format [FILE...]", + Short: "Format a yaml file", + Long: `Format a yaml file ordering its keys alphabetically and +cleaning it. + +For example: + +yutil format file.yml +yutil format file.yml -o file.formatted.yml +cat file.yml | yutil format > file.formatted.yml +echo "this is not a yaml" | yutil --no-input format file.yml > file.formatted.yml +`, + Args: func(cmd *cobra.Command, args []string) error { + if inPlaceEnabled(cmd) { + if canAccessStdin() { + return errors.New("stdin not compatible with in place format") + } + if formatOptions.outputFile != "" { + return errors.New("output option not compatible with in place format") + } + } else { + if canAccessStdin() && len(args) != 0 { + return errors.New("only one yaml can be formatted to output, stdin is active") + } else if !canAccessStdin() && len(args) == 0 { + if stdinBlocked() { + return errors.New("requires one file to be formatted, stdin is blocked") + } else { + return errors.New("requires one file to be formatted") + } + } else if !canAccessStdin() && len(args) != 1 { + return errors.New("only one file can be formatted to output") + } + } + for _, file := range args { + if !io.Exists(file) { + return fmt.Errorf("file %s does not exist", file) + } + } + return nil + }, + Run: func(cmd *cobra.Command, args []string) { + var err error + if inPlaceEnabled(cmd) { + if formatOptions.suffix == "" { + err = format.FormatFilesInPlace(args) + } else { + err = format.FormatFilesInPlaceB(args, formatOptions.suffix) + } + } else { + var formatted string + if canAccessStdin() { + formatted, err = format.FormatStdin() + } else { + formatted, err = format.FormatFile(args[0]) + } + if err != nil { + panic(err) + } + if len(formatOptions.outputFile) > 0 { + err = io.WriteToFile(formatOptions.outputFile, formatted) + } else { + err = io.WriteToStdout(formatted) + } + } + if err != nil { + panic(err) + } + }, +} + +func init() { + rootCmd.AddCommand(formatCmd) + + formatCmd.Flags().StringVarP(&formatOptions.outputFile, "output", "o", "", "format yaml to output file instead of stdout (not compatible in place format)") + formatCmd.Flags().BoolVarP(&formatOptions.inPlace, "in-place", "i", false, "format yaml files in place (makes backup if suffix is supplied)") + formatCmd.Flags().StringVarP(&formatOptions.suffix, "suffix", "s", "", "format yaml files in place making a backup with the given suffix (-i is not necessary if suffix is passed)") +} + +// Whether in place format is enabled +func inPlaceEnabled(cmd *cobra.Command) bool { + return formatOptions.inPlace || cmd.Flags().Changed("suffix") +} diff --git a/cmd/root.go b/cmd/root.go index 8681418..ea55399 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -150,3 +150,8 @@ func canAccessStdin() bool { noInput, err := rootCmd.Flags().GetBool("no-input") return err == nil && io.ReceivedStdin() && !noInput } + +func stdinBlocked() bool { + noInput, err := rootCmd.Flags().GetBool("no-input") + return err == nil && noInput +} diff --git a/internal/io/writer.go b/internal/io/writer.go index 49763a1..47ea953 100644 --- a/internal/io/writer.go +++ b/internal/io/writer.go @@ -23,6 +23,8 @@ package io import ( "bufio" + "fmt" + "io" "os" ) @@ -48,3 +50,25 @@ func Write(file *os.File, content string) error { writer.Flush() return nil } + +func Copy(src, dst string) error { + srcStat, err := os.Stat(src) + if err != nil { + return err + } + if !srcStat.Mode().IsRegular() { + return fmt.Errorf("%s is not a regular file", src) + } + srcFile, err := os.Open(src) + if err != nil { + return err + } + defer srcFile.Close() + destination, err := os.Create(dst) + if err != nil { + return err + } + defer destination.Close() + _, err = io.Copy(destination, srcFile) + return err +} diff --git a/pkg/format/content.go b/pkg/format/content.go new file mode 100644 index 0000000..aef7f47 --- /dev/null +++ b/pkg/format/content.go @@ -0,0 +1,43 @@ +/* +Copyright (c) 2021 amplia-iiot + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ +package format + +import ( + "github.com/amplia-iiot/yutil/internal/io" + "github.com/amplia-iiot/yutil/internal/yaml" +) + +func FormatContent(content string) (string, error) { + contentData, err := yaml.Parse(content) + if err != nil { + return "", err + } + return yaml.Compose(contentData) +} + +func FormatStdin() (string, error) { + content, err := io.ReadStdin() + if err != nil { + return "", err + } + return FormatContent(content) +} diff --git a/pkg/format/content_test.go b/pkg/format/content_test.go new file mode 100644 index 0000000..acc67c4 --- /dev/null +++ b/pkg/format/content_test.go @@ -0,0 +1,304 @@ +/* +Copyright (c) 2021 amplia-iiot + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ +package format + +import ( + "reflect" + "testing" +) + +func TestFormatContent(t *testing.T) { + for _, i := range []struct { + content string + expected string + }{ + // Formatted to multiple lines + { + content: `data: {one: 1, two: 2}`, + expected: `data: + one: 1 + two: 2 +`, + }, + // Keys are alphabetically ordered + { + content: `data: {b: b, c: c, a: a}`, + expected: `data: + a: a + b: b + c: c +`, + }, + // Null should be formatted `null` + { + content: `data:`, + expected: `data: null +`, + }, + { + content: `data: !!null null`, + expected: `data: null +`, + }, + { + content: `data: null`, + expected: `data: null +`, + }, + { + content: `data: Null`, + expected: `data: null +`, + }, + // Comments are lost + { + content: `with-comment: 42 # The meaning of life`, + expected: `with-comment: 42 +`, + }, + // Numbers + { + content: `data: !!float -1`, + expected: `data: -1 +`, + }, + { + content: `data: !!float 0`, + expected: `data: 0 +`, + }, + { + content: `data: !!float 2.3e4`, + expected: `data: 23000 +`, + }, + { + content: `data: !!float .inf`, + expected: `data: .inf +`, + }, + { + content: `data: !!float .nan`, + expected: `data: .nan +`, + }, + // Booleans + { + content: `data: false`, + expected: `data: false +`, + }, + { + content: `data: true`, + expected: `data: true +`, + }, + { + content: `data: False`, + expected: `data: false +`, + }, + { + content: `data: !!bool "true"`, + expected: `data: true +`, + }, + // String do not have quotes + { + content: `data: "one"`, + expected: `data: one +`, + }, + { + content: `data: 'this is a string'`, + expected: `data: this is a string +`, + }, + // Multi-line + { + content: `plain: + This unquoted scalar + spans many lines.`, + expected: `plain: This unquoted scalar spans many lines. +`, + }, + { + content: `quoted: "So does this + quoted scalar.\n"`, + expected: `quoted: | + So does this quoted scalar. +`, + }, + { + content: `quoted: "So does this quoted scalar.\n"`, + expected: `quoted: | + So does this quoted scalar. +`, + }, + // Strings with escaped quote chars don't need to be quoted + { + content: `data: "Hello \"World\""`, + expected: `data: Hello "World" +`, + }, + { + content: `data: 'Hello "World"'`, + expected: `data: Hello "World" +`, + }, + { + content: `data: "Hello \'World\'"`, + expected: `data: Hello 'World' +`, + }, + // Unless starting with quote or containing special chars (with simple quotes) + { + content: `data: "\'Hello\' World"`, + expected: `data: '''Hello'' World' +`, + }, + { + content: `data: "\"Hello\" World"`, + expected: `data: '"Hello" World' +`, + }, + { + content: `data: "{"`, + expected: `data: '{' +`, + }, + { + content: `tie-fighter: '|\-*-/|'`, + expected: `tie-fighter: '|\-*-/|' +`, + }, + { + content: `tie-fighter: "|\\-*-/|"`, + expected: `tie-fighter: '|\-*-/|' +`, + }, + { + content: `not-a-comment: '# Not a ''comment''.'`, + expected: `not-a-comment: '# Not a ''comment''.' +`, + }, + // Unicode is formatted + { + content: `unicode: "Sosa did fine.\u263A"`, + expected: `unicode: Sosa did fine.☺ +`, + }, + // Strings containing data that otherwise will be another type do need to be quoted with double quotes + { + content: `data: "null"`, + expected: `data: "null" +`, + }, + { + content: `data: 'null'`, + expected: `data: "null" +`, + }, + { + content: `data: !!str null`, + expected: `data: "null" +`, + }, + { + content: `data: "123"`, + expected: `data: "123" +`, + }, + { + content: `data: '123'`, + expected: `data: "123" +`, + }, + { + content: `data: !!str 123`, + expected: `data: "123" +`, + }, + { + content: `data: '.nan'`, + expected: `data: ".nan" +`, + }, + { + content: `data: !!str .nan`, + expected: `data: ".nan" +`, + }, + { + content: `data: '.inf'`, + expected: `data: ".inf" +`, + }, + { + content: `data: !!str .inf`, + expected: `data: ".inf" +`, + }, + { + content: `data: "false"`, + expected: `data: "false" +`, + }, + { + content: `data: 'false'`, + expected: `data: "false" +`, + }, + { + content: `data: 'False'`, + expected: `data: "False" +`, + }, + { + content: `data: !!str false`, + expected: `data: "false" +`, + }, + // Arrays are not reordered + { + content: `data: [b, c, a]`, + expected: `data: +- b +- c +- a +`, + }, + } { + formatted, err := FormatContent(i.content) + if err != nil { + t.Fatal(err) + } + assertEqual(t, i.expected, formatted) + } +} + +func assertEqual(t *testing.T, expected interface{}, got interface{}) { + if expected == got { + return + } + t.Errorf("Received %v (type %v), expected %v (type %v)", got, reflect.TypeOf(got), expected, reflect.TypeOf(expected)) +} diff --git a/pkg/format/files.go b/pkg/format/files.go new file mode 100644 index 0000000..154797f --- /dev/null +++ b/pkg/format/files.go @@ -0,0 +1,71 @@ +/* +Copyright (c) 2021 amplia-iiot + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ +package format + +import ( + "github.com/amplia-iiot/yutil/internal/io" +) + +func FormatFile(file string) (string, error) { + content, err := io.ReadAsString(file) + if err != nil { + return "", err + } + return FormatContent(content) +} + +func FormatFileInPlace(file string) error { + formatted, err := FormatFile(file) + if err != nil { + return err + } + return io.WriteToFile(file, formatted) +} + +func FormatFileInPlaceB(file, backupSuffix string) error { + if backupSuffix != "" { + err := io.Copy(file, file+backupSuffix) + if err != nil { + return err + } + } + return FormatFileInPlace(file) +} + +func FormatFilesInPlace(files []string) error { + for _, file := range files { + err := FormatFileInPlace(file) + if err != nil { + return err + } + } + return nil +} +func FormatFilesInPlaceB(files []string, backupSuffix string) error { + for _, file := range files { + err := FormatFileInPlaceB(file, backupSuffix) + if err != nil { + return err + } + } + return nil +} diff --git a/pkg/format/files_test.go b/pkg/format/files_test.go new file mode 100644 index 0000000..b0b707b --- /dev/null +++ b/pkg/format/files_test.go @@ -0,0 +1,69 @@ +/* +Copyright (c) 2021 amplia-iiot + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ +package format + +import ( + "fmt" + "os" + "path" + "runtime" + "testing" + + "github.com/amplia-iiot/yutil/internal/io" +) + +func init() { + // Go to root folder to access testdata/ + _, filename, _, _ := runtime.Caller(0) + dir := path.Join(path.Dir(filename), "..", "..") + err := os.Chdir(dir) + if err != nil { + panic(err) + } +} + +func TestFormatFiles(t *testing.T) { + for _, file := range []string{ + "base", + "dev", + "docker", + "prod", + } { + formatted, err := FormatFile(fileToBeFormatted(file)) + if err != nil { + t.Fatal(err) + } + expectedContent, err := io.ReadAsString(expectedFile(file)) + if err != nil { + t.Fatal(err) + } + assertEqual(t, expectedContent, formatted) + } +} + +func expectedFile(file string) string { + return fmt.Sprintf("testdata/formatted/%s.yml", file) +} + +func fileToBeFormatted(file string) string { + return fmt.Sprintf("testdata/%s.yml", file) +} diff --git a/testdata/formatted/base.yml b/testdata/formatted/base.yml new file mode 100644 index 0000000..5afd0ab --- /dev/null +++ b/testdata/formatted/base.yml @@ -0,0 +1,12 @@ +app: + api: + url: http://example.com + version: v1 + cluster: + hosts: + - http://one.example.com + - http://two.example.com + description: YAML utils + long-description: Common functionality for working with YAML files + name: yutil + version: 1.0.0 diff --git a/testdata/formatted/dev.yml b/testdata/formatted/dev.yml new file mode 100644 index 0000000..60ec2d7 --- /dev/null +++ b/testdata/formatted/dev.yml @@ -0,0 +1,11 @@ +app: + api: + url: http://localhost:8080 + version: v1-dev + cluster: + hosts: + - http://localhost:8081 + - http://localhost:8082 + env: + dev: true + version: 1.0.0-alpha diff --git a/testdata/formatted/docker.yml b/testdata/formatted/docker.yml new file mode 100644 index 0000000..acd2574 --- /dev/null +++ b/testdata/formatted/docker.yml @@ -0,0 +1,9 @@ +app: + api: + url: http://service + cluster: + hosts: + - http://service-1 + - http://service-2 + env: + docker: true diff --git a/testdata/formatted/prod.yml b/testdata/formatted/prod.yml new file mode 100644 index 0000000..be20b5a --- /dev/null +++ b/testdata/formatted/prod.yml @@ -0,0 +1,11 @@ +app: + api: + url: http://prod.com/service + version: v1 + cluster: + hosts: + - http://prod.com/service-1 + - http://prod.com/service-2 + env: + prod: true + version: 1.0.0