├── go.mod ├── .gitignore ├── .github └── workflows │ ├── ci.yaml │ └── release.yaml ├── CHANGELOG.md ├── LICENSE ├── Makefile ├── cmd └── json2yaml │ └── main.go ├── README.md ├── json2yaml.go └── json2yaml_test.go /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/itchyny/json2yaml 2 | 3 | go 1.18 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /json2yaml 2 | /goxz 3 | /CREDITS 4 | *.exe 5 | *.test 6 | *.out 7 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | test: 11 | name: Test 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout code 15 | uses: actions/checkout@v3 16 | - name: Setup Go 17 | uses: actions/setup-go@v3 18 | with: 19 | go-version: 1.x 20 | - name: Test 21 | run: make test 22 | - name: Test Coverage 23 | run: | 24 | go test -cover ./... | grep -F 100.0% || { 25 | go test -cover ./... 26 | echo Coverage decreased! 27 | exit 1 28 | } >&2 29 | - name: Lint 30 | run: make lint 31 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | ## [v0.1.4](https://github.com/itchyny/json2yaml/compare/v0.1.3..v0.1.4) (2022-11-17) 3 | * improve coding style and fix for linters 4 | 5 | ## [v0.1.3](https://github.com/itchyny/json2yaml/compare/v0.1.2..v0.1.3) (2022-09-06) 6 | * optimize writing escape sequences and indentations 7 | 8 | ## [v0.1.2](https://github.com/itchyny/json2yaml/compare/v0.1.1..v0.1.2) (2022-08-29) 9 | * quote multi-line string with leading tab character 10 | * quote the marker of end of document 11 | 12 | ## [v0.1.1](https://github.com/itchyny/json2yaml/compare/v0.1.0..v0.1.1) (2022-08-27) 13 | * improve performance by reducing write system calls 14 | 15 | ## [v0.1.0](https://github.com/itchyny/json2yaml/compare/ab3b812..v0.1.0) (2022-08-27) 16 | * initial implementation 17 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | release: 10 | name: Release 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v3 15 | - name: Setup Go 16 | uses: actions/setup-go@v3 17 | with: 18 | go-version: 1.x 19 | - name: Cross build 20 | run: make cross 21 | - name: Create Release 22 | id: create_release 23 | uses: actions/create-release@v1 24 | env: 25 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 26 | with: 27 | tag_name: ${{ github.ref }} 28 | release_name: Release ${{ github.ref }} 29 | - name: Upload 30 | run: make upload 31 | env: 32 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 itchyny 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | BIN := json2yaml 2 | VERSION := $$(make -s show-version) 3 | VERSION_PATH := cmd/$(BIN) 4 | CURRENT_REVISION = $(shell git rev-parse --short HEAD) 5 | BUILD_LDFLAGS = "-s -w -X main.revision=$(CURRENT_REVISION)" 6 | GOBIN ?= $(shell go env GOPATH)/bin 7 | 8 | .PHONY: all 9 | all: build 10 | 11 | .PHONY: build 12 | build: 13 | go build -ldflags=$(BUILD_LDFLAGS) -o $(BIN) ./cmd/$(BIN) 14 | 15 | .PHONY: install 16 | install: 17 | go install -ldflags=$(BUILD_LDFLAGS) ./cmd/$(BIN) 18 | 19 | .PHONY: show-version 20 | show-version: $(GOBIN)/gobump 21 | @gobump show -r "$(VERSION_PATH)" 22 | 23 | $(GOBIN)/gobump: 24 | @go install github.com/x-motemen/gobump/cmd/gobump@latest 25 | 26 | .PHONY: cross 27 | cross: $(GOBIN)/goxz CREDITS 28 | goxz -n $(BIN) -pv=v$(VERSION) -build-ldflags=$(BUILD_LDFLAGS) ./cmd/$(BIN) 29 | 30 | $(GOBIN)/goxz: 31 | go install github.com/Songmu/goxz/cmd/goxz@latest 32 | 33 | CREDITS: $(GOBIN)/gocredits 34 | go mod tidy 35 | gocredits -w . 36 | 37 | $(GOBIN)/gocredits: 38 | go install github.com/Songmu/gocredits/cmd/gocredits@latest 39 | 40 | .PHONY: test 41 | test: build 42 | go test -v -race ./... 43 | 44 | .PHONY: lint 45 | lint: $(GOBIN)/staticcheck 46 | go vet ./... 47 | staticcheck -checks all ./... 48 | 49 | $(GOBIN)/staticcheck: 50 | go install honnef.co/go/tools/cmd/staticcheck@latest 51 | 52 | .PHONY: clean 53 | clean: 54 | rm -rf $(BIN) goxz CREDITS 55 | go clean 56 | 57 | .PHONY: bump 58 | bump: $(GOBIN)/gobump 59 | test -z "$$(git status --porcelain || echo .)" 60 | test "$$(git branch --show-current)" = "main" 61 | @gobump up -w "$(VERSION_PATH)" 62 | git commit -am "bump up version to $(VERSION)" 63 | git tag "v$(VERSION)" 64 | git push --atomic origin main tag "v$(VERSION)" 65 | 66 | .PHONY: upload 67 | upload: $(GOBIN)/ghr 68 | ghr "v$(VERSION)" goxz 69 | 70 | $(GOBIN)/ghr: 71 | go install github.com/tcnksm/ghr@latest 72 | -------------------------------------------------------------------------------- /cmd/json2yaml/main.go: -------------------------------------------------------------------------------- 1 | // json2yaml - convert JSON to YAML 2 | package main 3 | 4 | import ( 5 | "flag" 6 | "fmt" 7 | "os" 8 | "path/filepath" 9 | "runtime" 10 | 11 | "github.com/itchyny/json2yaml" 12 | ) 13 | 14 | const name = "json2yaml" 15 | 16 | const version = "0.1.4" 17 | 18 | var revision = "HEAD" 19 | 20 | func main() { 21 | os.Exit(run(os.Args[1:])) 22 | } 23 | 24 | const ( 25 | exitCodeOK = iota 26 | exitCodeErr 27 | ) 28 | 29 | func run(args []string) (exitCode int) { 30 | fs := flag.NewFlagSet(name, flag.ContinueOnError) 31 | fs.SetOutput(os.Stderr) 32 | fs.Usage = func() { 33 | fs.SetOutput(os.Stdout) 34 | fmt.Printf(`%[1]s - convert JSON to YAML 35 | 36 | Version: %s (rev: %s/%s) 37 | 38 | Synopsis: 39 | %% %[1]s file ... 40 | 41 | Options: 42 | `, name, version, revision, runtime.Version()) 43 | fs.PrintDefaults() 44 | } 45 | var showVersion bool 46 | fs.BoolVar(&showVersion, "version", false, "print version") 47 | if err := fs.Parse(args); err != nil { 48 | if err == flag.ErrHelp { 49 | return exitCodeOK 50 | } 51 | return exitCodeErr 52 | } 53 | if showVersion { 54 | fmt.Printf("%s %s (rev: %s/%s)\n", name, version, revision, runtime.Version()) 55 | return exitCodeOK 56 | } 57 | if args = fs.Args(); len(args) == 0 { 58 | if err := convert("-"); err != nil { 59 | fmt.Fprintf(os.Stderr, "%s: %s\n", name, err) 60 | exitCode = exitCodeErr 61 | } 62 | } else { 63 | for i, arg := range args { 64 | if i > 0 { 65 | fmt.Fprintln(os.Stdout, "---") 66 | } 67 | if err := convert(arg); err != nil { 68 | fmt.Fprintf(os.Stderr, "%s: %s\n", name, err) 69 | exitCode = exitCodeErr 70 | } 71 | } 72 | } 73 | return 74 | } 75 | 76 | func convert(name string) (err error) { 77 | if name == "-" { 78 | if err := json2yaml.Convert(os.Stdout, os.Stdin); err != nil { 79 | return fmt.Errorf(": %w", err) 80 | } 81 | return nil 82 | } 83 | f, err := os.Open(filepath.Clean(name)) 84 | if err != nil { 85 | return err 86 | } 87 | defer func() { 88 | if cerr := f.Close(); err == nil { 89 | err = cerr 90 | } 91 | }() 92 | if err := json2yaml.Convert(os.Stdout, f); err != nil { 93 | return fmt.Errorf("%s: %w", name, err) 94 | } 95 | return nil 96 | } 97 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # json2yaml 2 | [![CI Status](https://github.com/itchyny/json2yaml/workflows/CI/badge.svg)](https://github.com/itchyny/json2yaml/actions) 3 | [![Go Report Card](https://goreportcard.com/badge/github.com/itchyny/json2yaml)](https://goreportcard.com/report/github.com/itchyny/json2yaml) 4 | [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/itchyny/json2yaml/blob/main/LICENSE) 5 | [![release](https://img.shields.io/github/release/itchyny/json2yaml/all.svg)](https://github.com/itchyny/json2yaml/releases) 6 | [![pkg.go.dev](https://pkg.go.dev/badge/github.com/itchyny/json2yaml)](https://pkg.go.dev/github.com/itchyny/json2yaml) 7 | 8 | This is an implementation of JSON to YAML converter written in Go language. 9 | This tool efficiently converts each JSON tokens in streaming fashion, so it avoids loading the entire JSON on the memory. 10 | Also, this tool preserves the order of mapping keys and the number representation. 11 | 12 | ## Usage as a command line tool 13 | ```bash 14 | json2yaml file.json ... 15 | json2yaml output.yaml 16 | ``` 17 | 18 | You can combine with other command line tools. 19 | ```bash 20 | gh api /meta | json2yaml | less 21 | ``` 22 | 23 | ## Usage as a library 24 | You can use the converter as a Go library. 25 | [`json2yaml.Convert(io.Writer, io.Reader) error`](https://pkg.go.dev/github.com/itchyny/json2yaml#Convert) is exported. 26 | 27 | ```go 28 | package main 29 | 30 | import ( 31 | "fmt" 32 | "log" 33 | "strings" 34 | 35 | "github.com/itchyny/json2yaml" 36 | ) 37 | 38 | func main() { 39 | input := strings.NewReader(`{"Hello": "world!"}`) 40 | var output strings.Builder 41 | if err := json2yaml.Convert(&output, input); err != nil { 42 | log.Fatalln(err) 43 | } 44 | fmt.Print(output.String()) // outputs Hello: world! 45 | } 46 | ``` 47 | 48 | ## Installation 49 | ### Homebrew 50 | ```sh 51 | brew install itchyny/tap/json2yaml 52 | ``` 53 | 54 | ### Build from source 55 | ```bash 56 | go install github.com/itchyny/json2yaml/cmd/json2yaml@latest 57 | ``` 58 | 59 | ## Bug Tracker 60 | Report bug at [Issues・itchyny/json2yaml - GitHub](https://github.com/itchyny/json2yaml/issues). 61 | 62 | ## Author 63 | itchyny (https://github.com/itchyny) 64 | 65 | ## License 66 | This software is released under the MIT License, see LICENSE. 67 | -------------------------------------------------------------------------------- /json2yaml.go: -------------------------------------------------------------------------------- 1 | // Package json2yaml implements a converter from JSON to YAML. 2 | package json2yaml 3 | 4 | import ( 5 | "bytes" 6 | "encoding/json" 7 | "io" 8 | "regexp" 9 | "strings" 10 | "unicode/utf8" 11 | ) 12 | 13 | // Convert reads JSON from r and writes YAML to w. 14 | func Convert(w io.Writer, r io.Reader) error { 15 | return (&converter{w, new(bytes.Buffer), []byte{'.'}, 0}).convert(r) 16 | } 17 | 18 | type converter struct { 19 | w io.Writer 20 | buf *bytes.Buffer 21 | stack []byte 22 | indent int 23 | } 24 | 25 | func (c *converter) flush() error { 26 | _, err := c.w.Write(c.buf.Bytes()) 27 | c.buf.Reset() 28 | return err 29 | } 30 | 31 | func (c *converter) convert(r io.Reader) error { 32 | c.buf.Grow(8 * 1024) 33 | dec := json.NewDecoder(r) 34 | dec.UseNumber() 35 | err := c.convertInternal(dec) 36 | if err != nil { 37 | if bs := c.buf.Bytes(); len(bs) > 0 && bs[len(bs)-1] != '\n' { 38 | c.buf.WriteByte('\n') 39 | } 40 | } 41 | if ferr := c.flush(); ferr != nil && err == nil { 42 | err = ferr 43 | } 44 | return err 45 | } 46 | 47 | func (c *converter) convertInternal(dec *json.Decoder) error { 48 | for { 49 | token, err := dec.Token() 50 | if err != nil { 51 | if err == io.EOF { 52 | if len(c.stack) == 1 { 53 | return nil 54 | } 55 | err = io.ErrUnexpectedEOF 56 | } 57 | return err 58 | } 59 | if delim, ok := token.(json.Delim); ok { 60 | switch delim { 61 | case '{', '[': 62 | if len(c.stack) > 1 { 63 | c.indent += 2 64 | } 65 | c.stack = append(c.stack, byte(delim)) 66 | if dec.More() { 67 | if c.stack[len(c.stack)-2] == ':' { 68 | c.buf.WriteByte('\n') 69 | c.writeIndent() 70 | } 71 | if c.stack[len(c.stack)-1] == '[' { 72 | c.buf.WriteString("- ") 73 | } 74 | } else { 75 | if c.stack[len(c.stack)-2] == ':' { 76 | c.buf.WriteByte(' ') 77 | } 78 | if c.stack[len(c.stack)-1] == '{' { 79 | c.buf.WriteString("{}\n") 80 | } else { 81 | c.buf.WriteString("[]\n") 82 | } 83 | } 84 | continue 85 | case '}', ']': 86 | c.stack = c.stack[:len(c.stack)-1] 87 | if len(c.stack) > 1 { 88 | c.indent -= 2 89 | } 90 | } 91 | } else { 92 | switch c.stack[len(c.stack)-1] { 93 | case '{': 94 | if err := c.writeValue(token); err != nil { 95 | return err 96 | } 97 | c.buf.WriteByte(':') 98 | c.stack[len(c.stack)-1] = ':' 99 | continue 100 | case ':': 101 | c.buf.WriteByte(' ') 102 | fallthrough 103 | default: 104 | if err := c.writeValue(token); err != nil { 105 | return err 106 | } 107 | c.buf.WriteByte('\n') 108 | } 109 | } 110 | if dec.More() { 111 | c.writeIndent() 112 | switch c.stack[len(c.stack)-1] { 113 | case ':': 114 | c.stack[len(c.stack)-1] = '{' 115 | case '[': 116 | c.buf.WriteString("- ") 117 | case '.': 118 | c.buf.WriteString("---\n") 119 | } 120 | } 121 | } 122 | } 123 | 124 | func (c *converter) writeIndent() { 125 | if n := c.indent; n > 0 { 126 | const spaces = " " 127 | if l := len(spaces); n <= l { 128 | c.buf.WriteString(spaces[:n]) 129 | } else { 130 | c.buf.WriteString(spaces) 131 | for n -= l; n > 0; n, l = n-l, l*2 { 132 | if n < l { 133 | l = n 134 | } 135 | c.buf.Write(c.buf.Bytes()[c.buf.Len()-l:]) 136 | } 137 | } 138 | } 139 | } 140 | 141 | func (c *converter) writeValue(v any) error { 142 | switch v := v.(type) { 143 | default: 144 | c.buf.WriteString("null") 145 | case bool: 146 | if v { 147 | c.buf.WriteString("true") 148 | } else { 149 | c.buf.WriteString("false") 150 | } 151 | case json.Number: 152 | c.buf.WriteString(string(v)) 153 | case string: 154 | c.writeString(v) 155 | } 156 | if c.buf.Len() > 4*1024 { 157 | return c.flush() 158 | } 159 | return nil 160 | } 161 | 162 | // These patterns match more than the specifications, 163 | // but it is okay to quote for parsers just in case. 164 | var ( 165 | quoteSingleLineStringPattern = regexp.MustCompile( 166 | `^(?:` + 167 | `(?i:` + 168 | // tag:yaml.org,2002:null 169 | `|~|null` + 170 | // tag:yaml.org,2002:bool 171 | `|true|false|y(?:es)?|no?|o(?:n|ff)` + 172 | // tag:yaml.org,2002:int, tag:yaml.org,2002:float 173 | `|[-+]?(?:0(?:b[01_]+|o[0-7_]+|x[0-9a-f_]+)` + // base 2, 8, 16 174 | `|(?:[0-9][0-9_]*(?::[0-5]?[0-9])*(?:\.[0-9_]*)?` + 175 | `|\.[0-9_]+)(?:E[-+]?[0-9]+)?` + // base 10, 60 176 | `|\.inf)|\.nan` + // infinities, not-a-number 177 | // tag:yaml.org,2002:timestamp 178 | `|\d\d\d\d-\d\d?-\d\d?` + // date 179 | `(?:(?:T|\s+)\d\d?:\d\d?:\d\d?(?:\.\d*)?` + // time 180 | `(?:\s*(?:Z|[-+]\d\d?(?::\d\d?)?))?)?` + // time zone 181 | `)$` + 182 | // c-indicator - '-' - '?' - ':', leading white space 183 | "|[,\\[\\]{}#&*!|>'\"%@` \\t]" + 184 | // sequence entry, document markers, mapping key 185 | `|(?:-(?:--)?|\.\.\.|\?)(?:[ \t]|$)` + 186 | `)` + 187 | // mapping value 188 | `|:(?:[ \t]|$)` + 189 | // trailing white space, comment 190 | `|[ \t](?:#|$)` + 191 | // C0 control codes - '\n', DEL 192 | "|[\u0000-\u0009\u000B-\u001F\u007F" + 193 | // C1 control codes, BOM, noncharacters 194 | "\u0080-\u009F\uFEFF\uFDD0-\uFDEF\uFFFE\uFFFF]", 195 | ) 196 | quoteMultiLineStringPattern = regexp.MustCompile( 197 | `` + 198 | // leading white space 199 | `^\n*(?:[ \t]|$)` + 200 | // C0 control codes - '\t' - '\n', DEL 201 | "|[\u0000-\u0008\u000B-\u001F\u007F" + 202 | // C1 control codes, BOM, noncharacters 203 | "\u0080-\u009F\uFEFF\uFDD0-\uFDEF\uFFFE\uFFFF]", 204 | ) 205 | ) 206 | 207 | func (c *converter) writeString(v string) { 208 | switch { 209 | default: 210 | c.buf.WriteString(v) 211 | case strings.ContainsRune(v, '\n'): 212 | if !quoteMultiLineStringPattern.MatchString(v) { 213 | c.writeBlockStyleString(v) 214 | break 215 | } 216 | fallthrough 217 | case quoteSingleLineStringPattern.MatchString(v): 218 | c.writeDoubleQuotedString(v) 219 | } 220 | } 221 | 222 | func (c *converter) writeBlockStyleString(v string) { 223 | if c.stack[len(c.stack)-1] == '{' { 224 | c.buf.WriteString("? ") 225 | } 226 | c.buf.WriteByte('|') 227 | if !strings.HasSuffix(v, "\n") { 228 | c.buf.WriteByte('-') 229 | } else if strings.HasSuffix(v, "\n\n") { 230 | c.buf.WriteByte('+') 231 | } 232 | c.indent += 2 233 | for s := ""; v != ""; { 234 | s, v, _ = strings.Cut(v, "\n") 235 | c.buf.WriteByte('\n') 236 | if s != "" { 237 | c.writeIndent() 238 | c.buf.WriteString(s) 239 | } 240 | } 241 | c.indent -= 2 242 | if c.stack[len(c.stack)-1] == '{' { 243 | c.buf.WriteByte('\n') 244 | c.writeIndent() 245 | } 246 | } 247 | 248 | // ref: encodeState#string in encoding/json 249 | func (c *converter) writeDoubleQuotedString(s string) { 250 | const hex = "0123456789ABCDEF" 251 | c.buf.WriteByte('"') 252 | start := 0 253 | for i := 0; i < len(s); { 254 | if b := s[i]; b < utf8.RuneSelf { 255 | if ' ' <= b && b <= '~' && b != '"' && b != '\\' { 256 | i++ 257 | continue 258 | } 259 | if start < i { 260 | c.buf.WriteString(s[start:i]) 261 | } 262 | switch b { 263 | case '"': 264 | c.buf.WriteString(`\"`) 265 | case '\\': 266 | c.buf.WriteString(`\\`) 267 | case '\b': 268 | c.buf.WriteString(`\b`) 269 | case '\f': 270 | c.buf.WriteString(`\f`) 271 | case '\n': 272 | c.buf.WriteString(`\n`) 273 | case '\r': 274 | c.buf.WriteString(`\r`) 275 | case '\t': 276 | c.buf.WriteString(`\t`) 277 | default: 278 | c.buf.Write([]byte{'\\', 'x', hex[b>>4], hex[b&0xF]}) 279 | } 280 | i++ 281 | start = i 282 | continue 283 | } 284 | r, size := utf8.DecodeRuneInString(s[i:]) 285 | if r <= '\u009F' || '\uFDD0' <= r && (r == '\uFEFF' || 286 | r <= '\uFDEF' || r == '\uFFFE' || r == '\uFFFF') { 287 | if start < i { 288 | c.buf.WriteString(s[start:i]) 289 | } 290 | if r <= '\u009F' { 291 | c.buf.Write([]byte{'\\', 'x', hex[r>>4], hex[r&0xF]}) 292 | } else { 293 | c.buf.Write([]byte{ 294 | '\\', 'u', hex[r>>12], hex[r>>8&0xF], hex[r>>4&0xF], hex[r&0xF], 295 | }) 296 | } 297 | i += size 298 | start = i 299 | continue 300 | } 301 | i += size 302 | } 303 | if start < len(s) { 304 | c.buf.WriteString(s[start:]) 305 | } 306 | c.buf.WriteByte('"') 307 | } 308 | -------------------------------------------------------------------------------- /json2yaml_test.go: -------------------------------------------------------------------------------- 1 | package json2yaml_test 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "log" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/itchyny/json2yaml" 11 | ) 12 | 13 | func TestConvert(t *testing.T) { 14 | testCases := []struct { 15 | name string 16 | src string 17 | want string 18 | err string 19 | }{ 20 | { 21 | name: "null", 22 | src: "null", 23 | want: join([]string{"null"}), 24 | }, 25 | { 26 | name: "boolean", 27 | src: "false true", 28 | want: join([]string{"false", "true"}), 29 | }, 30 | { 31 | name: "number", 32 | src: "0 128 -320 3.14 -6.63e-34", 33 | want: join([]string{"0", "128", "-320", "3.14", "-6.63e-34"}), 34 | }, 35 | { 36 | name: "string", 37 | src: `"" "foo" "null" "hello, world" "\"\\\b\f\r\t" "12345" " 12345 "`, 38 | want: join([]string{`""`, `foo`, `"null"`, `hello, world`, `"\"\\\b\f\r\t"`, `12345`, `" 12345 "`}), 39 | }, 40 | { 41 | name: "quote booleans", 42 | src: `"true" "False" "YES" "y" "no" "n" "oN" "Off" "truer" "oon" "f"`, 43 | want: join([]string{`"true"`, `"False"`, `"YES"`, `"y"`, `"no"`, `"n"`, `"oN"`, `"Off"`, `truer`, `oon`, `f`}), 44 | }, 45 | { 46 | name: "quote integers", 47 | src: `"0" "+42" "128" "900" "-1_234_567_890" "+ 1" "11:22" "+1:2" "-3:4" "0:1:02:1:0" "12:50" "12:60" 48 | "0b1" "0b11_00" "0b" "0b2" "0664" "0_1_2_3" "0_" "0678" "0123.456e789" "0o1_0" "0O0" "0o" 49 | "0x0" "0x09af" "0xFE_FF" "0x" "0xfg" "0x_F_"`, 50 | want: join([]string{ 51 | `"0"`, `"+42"`, `"128"`, `"900"`, `"-1_234_567_890"`, `+ 1`, `"11:22"`, `"+1:2"`, `"-3:4"`, `"0:1:02:1:0"`, `"12:50"`, `12:60`, 52 | `"0b1"`, `"0b11_00"`, `0b`, `0b2`, `"0664"`, `"0_1_2_3"`, `"0_"`, `"0678"`, `"0123.456e789"`, `"0o1_0"`, `"0O0"`, `0o`, 53 | `"0x0"`, `"0x09af"`, `"0xFE_FF"`, `0x`, `0xfg`, `"0x_F_"`, 54 | }), 55 | }, 56 | { 57 | name: "quote floating point numbers", 58 | src: `"0.1" "3.14156" "-42.195" "-.3" "+6." "-+1" "1E+9" "6.63e-34" "1e2" 59 | "1_2.3_4e56" "120:30:40.56" ".inf" "+.inf" "-.inf" ".infr" ".nan" "+.nan" "-.nan" ".nan."`, 60 | want: join([]string{ 61 | `"0.1"`, `"3.14156"`, `"-42.195"`, `"-.3"`, `"+6."`, `-+1`, `"1E+9"`, `"6.63e-34"`, `"1e2"`, 62 | `"1_2.3_4e56"`, `"120:30:40.56"`, `".inf"`, `"+.inf"`, `"-.inf"`, `.infr`, `".nan"`, `+.nan`, `-.nan`, `.nan.`, 63 | }), 64 | }, 65 | { 66 | name: "quote date time", 67 | src: `"2022-08-04" "1000-1-1" "9999-12-31" "1999-99-99" "999-9-9" "2000-08" "2000-08-" "2000-" 68 | "2022-01-01T12:13:14" "2022-02-02 12:13:14.567" "2022-03-03 1:2:3" "2022-03-04 15:16:17." "2022-03-04 15:16:" 69 | "2000-12-31T01:02:03-09:00" "2000-12-31t01:02:03Z" "2000-12-31 01:02:03 +7" "2222-22-22 22:22:22 +22:22"`, 70 | want: join([]string{ 71 | `"2022-08-04"`, `"1000-1-1"`, `"9999-12-31"`, `"1999-99-99"`, `999-9-9`, `2000-08`, `2000-08-`, `2000-`, 72 | `"2022-01-01T12:13:14"`, `"2022-02-02 12:13:14.567"`, `"2022-03-03 1:2:3"`, `"2022-03-04 15:16:17."`, `"2022-03-04 15:16:"`, 73 | `"2000-12-31T01:02:03-09:00"`, `"2000-12-31t01:02:03Z"`, `"2000-12-31 01:02:03 +7"`, `"2222-22-22 22:22:22 +22:22"`, 74 | }), 75 | }, 76 | { 77 | name: "quote indicators", 78 | src: `"!" "\"" "#" "$" "%" "&" "'" "(" ")" "*" "+" "," 79 | "-" "--" "---" "----" "- " "--- -" "- ---" "- --- -" "-- --" "?-" "-?" "?---" "---?" "--- ?" 80 | "." ".." "..." "...." "... ." ". ..." ". ... ." ".. .." "?..." "...?" "... ?" 81 | "." "/" ":" ";" "<" "=" ">" "?" "[" "\\" "]" "^" "_" "{" "|" "}" "~" "@" "\u0060" 82 | "%TAG" "!!str" "!<>" "&anchor" "*anchor" "https://example.com/?q=text#fragment" "%!/bin/sh" 83 | "- ." ". -" "-." ".-" "? ." ". ?" "?." ".?" "?\t." ": ." ". :" ":\t." ". :?" "?:" ":?" ". ? :." "[]" "{}" 84 | ". #" "# ." ".#." ". #." ".# ." ". # ." ". ! \" $ % & ' ( ) * + , - / ; < = > ? [ \\ ] ^ _ { | } ~"`, 85 | want: join([]string{ 86 | `"!"`, `"\""`, `"#"`, `$`, `"%"`, `"&"`, `"'"`, `(`, `)`, `"*"`, `+`, `","`, 87 | `"-"`, `--`, `"---"`, `----`, `"- "`, `"--- -"`, `"- ---"`, `"- --- -"`, `-- --`, `?-`, `-?`, `?---`, `---?`, `"--- ?"`, 88 | `.`, `..`, `"..."`, `....`, `"... ."`, `. ...`, `. ... .`, `.. ..`, `?...`, `...?`, `"... ?"`, 89 | `.`, `/`, `":"`, `;`, `<`, `=`, `">"`, `"?"`, `"["`, `\`, `"]"`, `^`, `_`, `"{"`, `"|"`, `"}"`, `"~"`, `"@"`, "\"`\"", 90 | `"%TAG"`, `"!!str"`, `"!<>"`, `"&anchor"`, `"*anchor"`, `https://example.com/?q=text#fragment`, `"%!/bin/sh"`, 91 | `"- ."`, `. -`, `-.`, `.-`, `"? ."`, `. ?`, `?.`, `.?`, `"?\t."`, `": ."`, `". :"`, `":\t."`, `. :?`, `"?:"`, `:?`, `. ? :.`, `"[]"`, `"{}"`, 92 | `". #"`, `"# ."`, `.#.`, `". #."`, `.# .`, `". # ."`, `. ! " $ % & ' ( ) * + , - / ; < = > ? [ \ ] ^ _ { | } ~`, 93 | }), 94 | }, 95 | { 96 | name: "quote white spaces", 97 | src: `" " "\t" " ." " .\n" ". " "\t." ".\t" ". ." ".\t."`, 98 | want: join([]string{`" "`, `"\t"`, `" ."`, `" .\n"`, `". "`, `"\t."`, `".\t"`, `. .`, `".\t."`}), 99 | }, 100 | { 101 | name: "quote and escape special characters", 102 | src: "\" \\\\ \" \"\\u001F\" \"\\u001F\\n\" \"\u007F\" \"\u007F\\n\" \"\u0080\" \".\u0089.\" \"\u009F\" \"\u009F\\n\"" + 103 | "\"\uFDCF\" \"\uFDD0\uFDD1\uFDD2\uFDD3\uFDD4\uFDD5\uFDD6\uFDD7\uFDD8\uFDD9\uFDDA\uFDDB\uFDDC\uFDDD\uFDDE\uFDDF\uFDE0\uFDEF\"" + 104 | "\"\uFDF0\" \"\uFEFE\" \"\uFEFF\" \"\uFFFD\" \"\uFFFE\" \"\uFFFF\" \"\uFFFF\\n\"", 105 | want: join([]string{ 106 | `" \\ "`, `"\x1F"`, `"\x1F\n"`, `"\x7F"`, `"\x7F\n"`, `"\x80"`, `".\x89."`, `"\x9F"`, `"\x9F\n"`, 107 | "\uFDCF", `"\uFDD0\uFDD1\uFDD2\uFDD3\uFDD4\uFDD5\uFDD6\uFDD7\uFDD8\uFDD9\uFDDA\uFDDB\uFDDC\uFDDD\uFDDE\uFDDF\uFDE0\uFDEF"`, 108 | "\uFDF0", "\uFEFE", `"\uFEFF"`, "\uFFFD", `"\uFFFE"`, `"\uFFFF"`, `"\uFFFF\n"`, 109 | }), 110 | }, 111 | { 112 | name: "empty object", 113 | src: "{}", 114 | want: `{} 115 | `, 116 | }, 117 | { 118 | name: "simple object", 119 | src: `{"foo": 128, "bar": null, "baz": false}`, 120 | want: `foo: 128 121 | bar: null 122 | baz: false 123 | `, 124 | }, 125 | { 126 | name: "nested object", 127 | src: `{ 128 | "foo": {"bar": {"baz": 128, "bar": null}, "baz": 0}, 129 | "bar": {"foo": {}, "bar": {"bar": {}}, "baz": {}}, 130 | "baz": {} 131 | }`, 132 | want: `foo: 133 | bar: 134 | baz: 128 135 | bar: null 136 | baz: 0 137 | bar: 138 | foo: {} 139 | bar: 140 | bar: {} 141 | baz: {} 142 | baz: {} 143 | `, 144 | }, 145 | { 146 | name: "multiple objects", 147 | src: `{}{"foo":128}{}`, 148 | want: join([]string{"{}", "foo: 128", "{}"}), 149 | }, 150 | { 151 | name: "unclosed object with no entries", 152 | src: "{", 153 | want: "{}\n", 154 | err: "unexpected EOF", 155 | }, 156 | { 157 | name: "unclosed object after object key", 158 | src: `{"foo"`, 159 | want: "foo:\n", 160 | err: "unexpected EOF", 161 | }, 162 | { 163 | name: "unclosed object after object value", 164 | src: `{"foo":128`, 165 | want: "foo: 128\n", 166 | err: "unexpected EOF", 167 | }, 168 | { 169 | name: "empty array", 170 | src: "[]", 171 | want: `[] 172 | `, 173 | }, 174 | { 175 | name: "simple array", 176 | src: `[null,false,true,-128,12345678901234567890,"foo bar baz"]`, 177 | want: `- null 178 | - false 179 | - true 180 | - -128 181 | - 12345678901234567890 182 | - foo bar baz 183 | `, 184 | }, 185 | { 186 | name: "nested array", 187 | src: "[0,[1],[2,3],[4,[5,[6,[],7],[]],[8]],[],9]", 188 | want: `- 0 189 | - - 1 190 | - - 2 191 | - 3 192 | - - 4 193 | - - 5 194 | - - 6 195 | - [] 196 | - 7 197 | - [] 198 | - - 8 199 | - [] 200 | - 9 201 | `, 202 | }, 203 | { 204 | name: "nested object and array", 205 | src: `{"foo":[0,{"bar":[],"foo":{}},[{"foo":[{"foo":[]}]}],[[[{}]]]],"bar":[{}]}`, 206 | want: `foo: 207 | - 0 208 | - bar: [] 209 | foo: {} 210 | - - foo: 211 | - foo: [] 212 | - - - - {} 213 | bar: 214 | - {} 215 | `, 216 | }, 217 | { 218 | name: "multiple arrays", 219 | src: `[][{"foo":128}][]`, 220 | want: join([]string{"[]", "- foo: 128", "[]"}), 221 | }, 222 | { 223 | name: "deeply nested object", 224 | src: strings.Repeat(`{"x":`, 100) + "{}" + strings.Repeat("}", 100), 225 | want: (func() string { 226 | var sb strings.Builder 227 | spaces := strings.Repeat(" ", 100) 228 | for i := 0; i < 100; i++ { 229 | if i > 0 { 230 | sb.WriteByte('\n') 231 | } 232 | sb.WriteString(spaces[:2*i]) 233 | sb.WriteString("x:") 234 | } 235 | sb.WriteString(" {}\n") 236 | return sb.String() 237 | })(), 238 | }, 239 | { 240 | name: "unclosed empty array", 241 | src: "[", 242 | want: "[]\n", 243 | err: "unexpected EOF", 244 | }, 245 | { 246 | name: "unclosed array after an element", 247 | src: "[1", 248 | want: "- 1\n", 249 | err: "unexpected EOF", 250 | }, 251 | { 252 | name: "unexpected closing bracket", 253 | src: `{"x":]`, 254 | want: "x:\n", 255 | err: "invalid character ']'", 256 | }, 257 | { 258 | name: "unexpected character in array", 259 | src: "[1,%", 260 | want: "- 1\n- \n", 261 | err: "invalid character '%'", 262 | }, 263 | { 264 | name: "block style string", 265 | src: `"\n" "\n\n" "a\n" "a\n\n" "a\n\n\n" "a \n" "a\t\n" "a\n " "a\n\t" "a\r\n" 266 | "a\nb" "a\r\nb" "a\n\nb" "a\nb\n" "a\n b\nc" "a\n b\nc\n" 267 | "\na" "\n a" "\n\na" "\na\n" "\na\nb\n" "\na\nb\n\n" "\n\ta\n" 268 | "# a\n" "# a\r" "[a]\n" "!\n#\n%\n- a\n" "- a\n- b\n" "---\n" "a: b # c\n"`, 269 | want: join([]string{ 270 | `"\n"`, `"\n\n"`, "|\n a", "|+\n a\n", "|+\n a\n\n", "|\n a ", "|\n a\t", "|-\n a\n ", "|-\n a\n \t", `"a\r\n"`, 271 | "|-\n a\n b", `"a\r\nb"`, "|-\n a\n\n b", "|\n a\n b", "|-\n a\n b\n c", "|\n a\n b\n c", 272 | "|-\n\n a", `"\n a"`, "|-\n\n\n a", "|\n\n a", "|\n\n a\n b", "|+\n\n a\n b\n", `"\n\ta\n"`, 273 | "|\n # a", `"# a\r"`, "|\n [a]", "|\n !\n #\n %\n - a", "|\n - a\n - b", "|\n ---", "|\n a: b # c", 274 | }), 275 | }, 276 | { 277 | name: "block style string in object and array", 278 | src: `{"x": "a\nb\n", "y": ["\na","\na\n"], "z": {"a\nb": {"a\nb\n": ["a\nb"]}}}{"w":"a\nb"}`, 279 | want: `x: | 280 | a 281 | b 282 | "y": 283 | - |- 284 | 285 | a 286 | - | 287 | 288 | a 289 | z: 290 | ? |- 291 | a 292 | b 293 | : 294 | ? | 295 | a 296 | b 297 | : 298 | - |- 299 | a 300 | b 301 | --- 302 | w: |- 303 | a 304 | b 305 | `, 306 | }, 307 | { 308 | name: "large array", 309 | src: "[" + strings.Repeat(`"test",`, 999) + `"test"]`, 310 | want: strings.Repeat("- test\n", 1000), 311 | }, 312 | } 313 | for _, tc := range testCases { 314 | t.Run(tc.name, func(t *testing.T) { 315 | var sb strings.Builder 316 | err := json2yaml.Convert(&sb, strings.NewReader(tc.src)) 317 | if got, want := diff(sb.String(), tc.want); got != want { 318 | t.Fatalf("should write\n %q\nbut got\n %q", want, got) 319 | } 320 | if tc.err == "" { 321 | if err != nil { 322 | t.Fatalf("should not raise an error but got: %s", err) 323 | } 324 | } else { 325 | if err == nil { 326 | t.Fatalf("should raise an error %q but got no error", tc.err) 327 | } 328 | if !strings.Contains(err.Error(), tc.err) { 329 | t.Fatalf("should raise an error %q but got error %q", tc.err, err) 330 | } 331 | } 332 | }) 333 | } 334 | } 335 | 336 | type errWriter struct{} 337 | 338 | func (w errWriter) Write(bs []byte) (int, error) { 339 | return 0, errors.New(fmt.Sprint(len(bs))) 340 | } 341 | 342 | func TestConvertError(t *testing.T) { 343 | testCases := []struct { 344 | name string 345 | src string 346 | err string 347 | }{ 348 | { 349 | name: "null", 350 | src: "null", 351 | err: fmt.Sprint(len("null\n")), 352 | }, 353 | { 354 | name: "large object key", 355 | src: `{"` + strings.Repeat("test", 1200) + `":0}`, 356 | err: fmt.Sprint(len("test") * 1200), 357 | }, 358 | { 359 | name: "large object value", 360 | src: `{"x":"` + strings.Repeat("test", 1200) + `"}`, 361 | err: fmt.Sprint(len("x: ") + len("test")*1200), 362 | }, 363 | { 364 | name: "large array", 365 | src: "[" + strings.Repeat(`"test",`, 1000) + `"test"]`, 366 | err: fmt.Sprint(len("- test\n")*(4*1024/len("- test\n")+1) - 1), 367 | }, 368 | } 369 | for _, tc := range testCases { 370 | t.Run(tc.name, func(t *testing.T) { 371 | err := json2yaml.Convert(errWriter{}, strings.NewReader(tc.src)) 372 | if err == nil { 373 | t.Fatalf("should raise an error %q but got no error", tc.err) 374 | } 375 | if err.Error() != tc.err { 376 | t.Fatalf("should raise an error %q but got error %q", tc.err, err) 377 | } 378 | }) 379 | } 380 | } 381 | 382 | func join(xs []string) string { 383 | var sb strings.Builder 384 | n := 5*(len(xs)-1) + 1 385 | for _, x := range xs { 386 | n += len(x) 387 | } 388 | sb.Grow(n) 389 | for i, x := range xs { 390 | if i > 0 { 391 | sb.WriteString("---\n") 392 | } 393 | sb.WriteString(x) 394 | sb.WriteString("\n") 395 | } 396 | return sb.String() 397 | } 398 | 399 | func diff(xs, ys string) (string, string) { 400 | if xs == ys { 401 | return "", "" 402 | } 403 | for { 404 | i := strings.IndexByte(xs, '\n') 405 | j := strings.IndexByte(ys, '\n') 406 | if i < 0 || j < 0 || xs[:i] != ys[:j] { 407 | break 408 | } 409 | xs, ys = xs[i+1:], ys[j+1:] 410 | } 411 | for { 412 | i := strings.LastIndexByte(xs, '\n') 413 | j := strings.LastIndexByte(ys, '\n') 414 | if i < 0 || j < 0 || xs[i:] != ys[j:] { 415 | break 416 | } 417 | xs, ys = xs[:i], ys[:j] 418 | } 419 | return xs, ys 420 | } 421 | 422 | func ExampleConvert() { 423 | input := strings.NewReader(`{"Hello": "world!"}`) 424 | var output strings.Builder 425 | if err := json2yaml.Convert(&output, input); err != nil { 426 | log.Fatalln(err) 427 | } 428 | fmt.Print(output.String()) 429 | // Output: 430 | // Hello: world! 431 | } 432 | --------------------------------------------------------------------------------