This commit is contained in:
Robin H. Johnson 2026-01-30 20:40:05 -08:00 committed by GitHub
commit d26bca1a15
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 1779 additions and 4 deletions

View File

@ -415,7 +415,7 @@ Flags:
-h, --help help for yq
-I, --indent int sets indent level for output (default 2)
-i, --inplace update the file in place of first file given.
-p, --input-format string [auto|a|yaml|y|json|j|kyaml|ky|props|p|csv|c|tsv|t|xml|x|base64|uri|toml|hcl|h|lua|l|ini|i] parse format for input. (default "auto")
-p, --input-format string [auto|a|yaml|y|json|j|json5|j5|kyaml|ky|props|p|csv|c|tsv|t|xml|x|base64|uri|toml|hcl|h|lua|l|ini|i] parse format for input. (default "auto")
--lua-globals output keys as top-level global variables
--lua-prefix string prefix (default "return ")
--lua-suffix string suffix (default ";\n")
@ -424,7 +424,7 @@ Flags:
-N, --no-doc Don't print document separators (---)
-0, --nul-output Use NUL char to separate values. If unwrap scalar is also set, fail if unwrapped scalar contains NUL char.
-n, --null-input Don't read input, simply evaluate the expression given. Useful for creating docs from scratch.
-o, --output-format string [auto|a|yaml|y|json|j|kyaml|ky|props|p|csv|c|tsv|t|xml|x|base64|uri|toml|hcl|h|shell|s|lua|l|ini|i] output format type. (default "auto")
-o, --output-format string [auto|a|yaml|y|json|j|json5|j5|kyaml|ky|props|p|csv|c|tsv|t|xml|x|base64|uri|toml|hcl|h|shell|s|lua|l|ini|i] output format type. (default "auto")
-P, --prettyPrint pretty print, shorthand for '... style = ""'
--properties-array-brackets use [x] in array paths (e.g. for SpringBoot)
--properties-separator string separator to use between keys and values (default " = ")

View File

@ -5,6 +5,7 @@ setUp() {
rm test*.toml 2>/dev/null || true
rm test*.tfstate 2>/dev/null || true
rm test*.json 2>/dev/null || true
rm test*.json5 2>/dev/null || true
rm test*.properties 2>/dev/null || true
rm test*.csv 2>/dev/null || true
rm test*.tsv 2>/dev/null || true
@ -31,6 +32,29 @@ EOM
assertEquals "$expected" "$X"
}
testInputJson5() {
cat >test.json5 <<'EOL'
{ /* hello */
mike: { things: "cool", },
}
EOL
read -r -d '' expected << EOM
// hello
{
"mike": {
"things": "cool"
}
}
EOM
X=$(./yq test.json5)
assertEquals "$expected" "$X"
X=$(./yq ea test.json5)
assertEquals "$expected" "$X"
}
testInputToml() {
cat >test.toml <<EOL
[owner]
@ -84,6 +108,27 @@ EOM
assertEquals "$expected" "$X"
}
testInputJson5OutputYaml() {
cat >test.json5 <<'EOL'
{
// comment
mike: { things: "cool", },
}
EOL
read -r -d '' expected << EOM
# comment
mike:
things: cool
EOM
X=$(./yq test.json5 -oy)
assertEquals "$expected" "$X"
X=$(./yq ea test.json5 -oy)
assertEquals "$expected" "$X"
}
testInputProperties() {
cat >test.properties <<EOL
mike.things = hello

View File

@ -7,6 +7,8 @@ setUp() {
rm test*.tsv 2>/dev/null || true
rm test*.xml 2>/dev/null || true
rm test*.tf 2>/dev/null || true
rm test*.json5 2>/dev/null || true
rm test*.kyaml 2>/dev/null || true
}
testInputProperties() {
@ -185,6 +187,40 @@ EOM
assertEquals "$expected" "$X"
}
testInputJson5MultilineBlockComments() {
cat >test.json5 <<'EOL'
{
/*
multiline
block comment
*/
first: 1,
second/* inline block */: 2,
third: /* before value */ 3,
fourth: [1, /* between elements */ 2,],
}
EOL
read -r -d '' expected <<'EOM'
# multiline
# block comment
first: 1
second: 2 # inline block
third: 3
# before value
fourth:
- 1
# between elements
- 2
EOM
X=$(./yq e -p=json5 test.json5)
assertEquals "$expected" "$X"
X=$(./yq ea -p=json5 test.json5)
assertEquals "$expected" "$X"
}

View File

@ -48,6 +48,28 @@ EOM
assertEquals "$expected" "$X"
}
testOutputJson5() {
cat >test.yml <<EOL
a: {b: ["cat"]}
EOL
read -r -d '' expected << EOM
{
"a": {
"b": [
"cat"
]
}
}
EOM
X=$(./yq e --output-format=json5 test.yml)
assertEquals "$expected" "$X"
X=$(./yq ea --output-format=json5 test.yml)
assertEquals "$expected" "$X"
}
testOutputYamlRawDefault() {
cat >test.yml <<EOL
a: "cat"

View File

@ -220,6 +220,13 @@ func TestConfigureDecoder(t *testing.T) {
expectError: false,
expectType: "jsonDecoder",
},
{
name: "json5 format",
inputFormat: "json5",
evaluateTogether: true,
expectError: false,
expectType: "json5Decoder",
},
{
name: "xml format",
inputFormat: "xml",
@ -390,6 +397,12 @@ func TestConfigureEncoder(t *testing.T) {
expectError: false,
expectType: "jsonEncoder",
},
{
name: "json5 format",
outputFormat: "json5",
expectError: false,
expectType: "json5Encoder",
},
{
name: "xml format",
outputFormat: "xml",
@ -1265,6 +1278,14 @@ func TestConfigureInputFormat(t *testing.T) {
expectInput: "json",
expectOutput: "json",
},
{
name: "auto format with json5 file",
inputFilename: "file.json5",
inputFormat: "auto",
outputFormat: "auto",
expectInput: "json5",
expectOutput: "json5",
},
{
name: "auto format with unknown file",
inputFilename: "file.unknown",

View File

@ -0,0 +1,15 @@
{
/*
multiline block comment
spanning multiple lines
*/
first: 1,
second/* inline block comment */: 2,
third: /* before value */ 3,
fourth: [
1,
/* between elements */
2,
],
}

18
examples/sample.json5 Normal file
View File

@ -0,0 +1,18 @@
// JSON5 example file for yq
{
// comments
unquotedKey: 'single quoted string',
trailingCommaArray: [1, 2,],
// numbers
hexInt: 0x10,
posInf: Infinity,
negInf: -Infinity,
notANumber: NaN,
nested: {
a: 1,
b: 2,
},
}

725
pkg/yqlib/decoder_json5.go Normal file
View File

@ -0,0 +1,725 @@
//go:build !yq_nojson5
package yqlib
import (
"fmt"
"io"
"strconv"
"strings"
"unicode"
"unicode/utf16"
)
type json5Decoder struct {
parser *json5Parser
}
func NewJSON5Decoder() Decoder {
return &json5Decoder{}
}
func (dec *json5Decoder) Init(reader io.Reader) error {
data, err := io.ReadAll(reader)
if err != nil {
return err
}
dec.parser = newJSON5Parser(string(data))
return nil
}
func (dec *json5Decoder) Decode() (*CandidateNode, error) {
if dec.parser == nil {
return nil, io.EOF
}
if err := dec.parser.skipWhitespaceAndComments(); err != nil {
return nil, err
}
if dec.parser.eof() {
return nil, io.EOF
}
return dec.parser.parseValue()
}
type json5Parser struct {
input []rune
pos int
line int
col int
pendingComments []json5Comment
}
func newJSON5Parser(s string) *json5Parser {
return &json5Parser{
input: []rune(s),
pos: 0,
line: 1,
col: 1,
}
}
type json5Comment struct {
text string
startsOnNewLine bool
}
func (p *json5Parser) eof() bool {
return p.pos >= len(p.input)
}
func (p *json5Parser) peek() rune {
if p.eof() {
return 0
}
return p.input[p.pos]
}
func (p *json5Parser) peekNext() rune {
i := p.pos + 1
if i >= len(p.input) {
return 0
}
return p.input[i]
}
func (p *json5Parser) next() rune {
if p.eof() {
return 0
}
r := p.input[p.pos]
p.pos++
if r == '\n' {
p.line++
p.col = 1
} else {
p.col++
}
return r
}
func (p *json5Parser) errorf(format string, args ...interface{}) error {
return fmt.Errorf("json5: %s at line %d, column %d", fmt.Sprintf(format, args...), p.line, p.col)
}
func (p *json5Parser) skipWhitespaceAndComments() error {
sawNewline := false
for !p.eof() {
r := p.peek()
if unicode.IsSpace(r) {
if r == '\n' || r == '\r' || r == '\u2028' || r == '\u2029' {
sawNewline = true
}
p.next()
continue
}
if r == '/' && p.peekNext() == '/' {
startsOnNewLine := sawNewline
p.next() // /
p.next() // /
var sb strings.Builder
for !p.eof() && p.peek() != '\n' {
sb.WriteRune(p.next())
}
p.pendingComments = append(p.pendingComments, json5Comment{
text: strings.TrimSpace(sb.String()),
startsOnNewLine: startsOnNewLine,
})
continue
}
if r == '/' && p.peekNext() == '*' {
startsOnNewLine := sawNewline
p.next() // /
p.next() // *
var sb strings.Builder
for {
if p.eof() {
return p.errorf("unterminated block comment")
}
if p.peek() == '*' && p.peekNext() == '/' {
p.next()
p.next()
break
}
sb.WriteRune(p.next())
}
normalised := normaliseJSON5BlockComment(sb.String())
if strings.Contains(normalised, "\n") {
sawNewline = true
}
p.pendingComments = append(p.pendingComments, json5Comment{
text: normalised,
startsOnNewLine: startsOnNewLine,
})
continue
}
break
}
return nil
}
func (p *json5Parser) takePendingComments() []json5Comment {
if len(p.pendingComments) == 0 {
return nil
}
comments := p.pendingComments
p.pendingComments = nil
return comments
}
func commentsToText(comments []json5Comment) string {
if len(comments) == 0 {
return ""
}
parts := make([]string, 0, len(comments))
for _, c := range comments {
if strings.TrimSpace(c.text) == "" {
continue
}
parts = append(parts, c.text)
}
return strings.Join(parts, "\n")
}
func normaliseJSON5BlockComment(content string) string {
content = strings.ReplaceAll(content, "\r\n", "\n")
content = strings.Trim(content, "\n\r\t ")
if content == "" {
return ""
}
lines := strings.Split(content, "\n")
for i, line := range lines {
line = strings.TrimLeft(line, " \t")
if strings.HasPrefix(line, "*") {
line = strings.TrimPrefix(line, "*")
line = strings.TrimLeft(line, " \t")
}
lines[i] = line
}
return strings.TrimSpace(strings.Join(lines, "\n"))
}
func (p *json5Parser) parseValue() (*CandidateNode, error) {
if err := p.skipWhitespaceAndComments(); err != nil {
return nil, err
}
leading := commentsToText(p.takePendingComments())
if p.eof() {
return nil, io.EOF
}
var node *CandidateNode
var err error
switch r := p.peek(); r {
case '{':
node, err = p.parseObject()
case '[':
node, err = p.parseArray()
case '"', '\'':
s, err := p.parseString()
if err != nil {
return nil, err
}
node = createScalarNode(s, s)
case '-', '+', '.', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9':
node, err = p.parseNumber()
default:
if isIdentifierStart(r) {
ident, err := p.parseIdentifier()
if err != nil {
return nil, err
}
switch ident {
case "true":
node = createScalarNode(true, "true")
case "false":
node = createScalarNode(false, "false")
case "null":
node = createScalarNode(nil, "null")
case "Infinity":
node = &CandidateNode{Kind: ScalarNode, Tag: "!!float", Value: "+Inf"}
case "NaN":
node = &CandidateNode{Kind: ScalarNode, Tag: "!!float", Value: "NaN"}
default:
return nil, p.errorf("unexpected identifier %q", ident)
}
} else {
return nil, p.errorf("unexpected character %q", r)
}
}
if err != nil {
return nil, err
}
if node == nil {
return nil, p.errorf("invalid value")
}
if leading != "" {
if node.HeadComment != "" {
node.HeadComment = leading + "\n" + node.HeadComment
} else {
node.HeadComment = leading
}
}
return node, nil
}
func (p *json5Parser) parseObject() (*CandidateNode, error) {
if p.next() != '{' {
return nil, p.errorf("expected '{'")
}
node := &CandidateNode{Kind: MappingNode, Tag: "!!map"}
if err := p.skipWhitespaceAndComments(); err != nil {
return nil, err
}
node.HeadComment = commentsToText(p.takePendingComments())
if p.peek() == '}' {
p.next()
return node, nil
}
for {
if err := p.skipWhitespaceAndComments(); err != nil {
return nil, err
}
pendingBeforeKey := commentsToText(p.takePendingComments())
key, err := p.parseObjectKey()
if err != nil {
return nil, err
}
if err := p.skipWhitespaceAndComments(); err != nil {
return nil, err
}
pendingAfterKey := commentsToText(p.takePendingComments())
if p.next() != ':' {
return nil, p.errorf("expected ':' after object key")
}
if err := p.skipWhitespaceAndComments(); err != nil {
return nil, err
}
pendingAfterColon := commentsToText(p.takePendingComments())
value, err := p.parseValue()
if err != nil {
return nil, err
}
childKey := node.CreateChild()
childKey.IsMapKey = true
childKey.Value = key
childKey.Kind = ScalarNode
childKey.Tag = "!!str"
childKey.HeadComment = pendingBeforeKey
childKey.LineComment = pendingAfterKey
if pendingAfterColon != "" {
if value.HeadComment != "" {
value.HeadComment = pendingAfterColon + "\n" + value.HeadComment
} else {
value.HeadComment = pendingAfterColon
}
}
value.Parent = node
value.Key = childKey
node.Content = append(node.Content, childKey, value)
if err := p.skipWhitespaceAndComments(); err != nil {
return nil, err
}
value.LineComment = commentsToText(p.takePendingComments())
switch p.peek() {
case ',':
p.next()
if err := p.skipWhitespaceAndComments(); err != nil {
return nil, err
}
if p.peek() == '}' {
p.next()
return node, nil
}
case '}':
p.next()
return node, nil
default:
return nil, p.errorf("expected ',' or '}' after object entry")
}
}
}
func (p *json5Parser) parseObjectKey() (string, error) {
if err := p.skipWhitespaceAndComments(); err != nil {
return "", err
}
switch p.peek() {
case '"', '\'':
return p.parseString()
default:
r := p.peek()
if !isIdentifierStart(r) && (r != '\\' || p.peekNext() != 'u') {
return "", p.errorf("expected object key")
}
return p.parseIdentifier()
}
}
func (p *json5Parser) parseArray() (*CandidateNode, error) {
if p.next() != '[' {
return nil, p.errorf("expected '['")
}
node := &CandidateNode{Kind: SequenceNode, Tag: "!!seq"}
if err := p.skipWhitespaceAndComments(); err != nil {
return nil, err
}
node.HeadComment = commentsToText(p.takePendingComments())
if p.peek() == ']' {
p.next()
return node, nil
}
index := 0
for {
if err := p.skipWhitespaceAndComments(); err != nil {
return nil, err
}
pendingBeforeElement := commentsToText(p.takePendingComments())
value, err := p.parseValue()
if err != nil {
return nil, err
}
if pendingBeforeElement != "" {
if value.HeadComment != "" {
value.HeadComment = pendingBeforeElement + "\n" + value.HeadComment
} else {
value.HeadComment = pendingBeforeElement
}
}
childKey := node.CreateChild()
childKey.Kind = ScalarNode
childKey.Tag = "!!int"
childKey.Value = fmt.Sprintf("%v", index)
childKey.IsMapKey = true
value.Parent = node
value.Key = childKey
node.Content = append(node.Content, value)
index++
if err := p.skipWhitespaceAndComments(); err != nil {
return nil, err
}
value.LineComment = commentsToText(p.takePendingComments())
switch p.peek() {
case ',':
p.next()
if err := p.skipWhitespaceAndComments(); err != nil {
return nil, err
}
if p.peek() == ']' {
p.next()
return node, nil
}
case ']':
p.next()
return node, nil
default:
return nil, p.errorf("expected ',' or ']' after array element")
}
}
}
func (p *json5Parser) parseString() (string, error) {
quote := p.next()
var sb strings.Builder
for {
if p.eof() {
return "", p.errorf("unterminated string")
}
r := p.next()
if r == quote {
return sb.String(), nil
}
if r == '\n' || r == '\r' || r == '\u2028' || r == '\u2029' {
return "", p.errorf("unterminated string")
}
if r != '\\' {
sb.WriteRune(r)
continue
}
if p.eof() {
return "", p.errorf("unterminated escape sequence")
}
esc := p.next()
if esc == '\n' || esc == '\u2028' || esc == '\u2029' {
continue
}
if esc == '\r' {
if p.peek() == '\n' {
p.next()
}
continue
}
switch esc {
case '\\', '/', '"', '\'':
sb.WriteRune(esc)
case 'b':
sb.WriteByte('\b')
case 'f':
sb.WriteByte('\f')
case 'n':
sb.WriteByte('\n')
case 'r':
sb.WriteByte('\r')
case 't':
sb.WriteByte('\t')
case 'v':
sb.WriteByte('\v')
case '0':
if isDigit(p.peek()) {
return "", p.errorf("invalid escape sequence \\0 followed by digit")
}
sb.WriteByte(0)
case 'x':
r, err := p.parseHexEscape(2)
if err != nil {
return "", err
}
sb.WriteRune(r)
case 'u':
r, err := p.parseUnicodeEscape()
if err != nil {
return "", err
}
sb.WriteRune(r)
default:
sb.WriteRune(esc)
}
}
}
func (p *json5Parser) parseHexEscape(length int) (rune, error) {
if p.pos+length > len(p.input) {
return 0, p.errorf("invalid hex escape")
}
var value rune
for i := 0; i < length; i++ {
d := p.next()
h, ok := hexDigitValue(d)
if !ok {
return 0, p.errorf("invalid hex escape")
}
value = (value << 4) | h
}
return value, nil
}
func (p *json5Parser) parseUnicodeEscape() (rune, error) {
r, err := p.parseHexEscape(4)
if err != nil {
return 0, err
}
if utf16.IsSurrogate(r) {
originalPos, originalLine, originalCol := p.pos, p.line, p.col
if p.peek() == '\\' && p.peekNext() == 'u' {
p.next()
p.next()
r2, err := p.parseHexEscape(4)
if err == nil && utf16.IsSurrogate(r2) {
return utf16.DecodeRune(r, r2), nil
}
}
p.pos, p.line, p.col = originalPos, originalLine, originalCol
}
return r, nil
}
func (p *json5Parser) parseNumber() (*CandidateNode, error) {
startPos := p.pos
sign := rune(0)
if p.peek() == '+' || p.peek() == '-' {
sign = p.next()
}
if isIdentifierStart(p.peek()) {
ident, err := p.parseIdentifier()
if err != nil {
return nil, err
}
if ident != "Infinity" {
return nil, p.errorf("invalid number")
}
if sign == '-' {
return &CandidateNode{Kind: ScalarNode, Tag: "!!float", Value: "-Inf"}, nil
}
return &CandidateNode{Kind: ScalarNode, Tag: "!!float", Value: "+Inf"}, nil
}
posAfterSign := p.pos
isFloat := false
if p.peek() == '0' && (p.peekNext() == 'x' || p.peekNext() == 'X') {
p.next()
p.next()
if !isHexDigit(p.peek()) {
return nil, p.errorf("invalid hex number")
}
for isHexDigit(p.peek()) {
p.next()
}
lit := string(p.input[posAfterSign:p.pos])
if sign == '-' {
lit = "-" + lit
}
// if sign == '+' { } // drop explicit plus sign
return &CandidateNode{Kind: ScalarNode, Tag: "!!int", Value: lit}, nil
}
if p.peek() == '.' {
isFloat = true
p.next()
if !isDigit(p.peek()) {
return nil, p.errorf("invalid number")
}
for isDigit(p.peek()) {
p.next()
}
} else {
if !isDigit(p.peek()) {
return nil, p.errorf("invalid number")
}
for isDigit(p.peek()) {
p.next()
}
if p.peek() == '.' {
isFloat = true
p.next()
for isDigit(p.peek()) {
p.next()
}
}
}
if p.peek() == 'e' || p.peek() == 'E' {
isFloat = true
p.next()
if p.peek() == '+' || p.peek() == '-' {
p.next()
}
if !isDigit(p.peek()) {
return nil, p.errorf("invalid number exponent")
}
for isDigit(p.peek()) {
p.next()
}
}
lit := string(p.input[startPos:p.pos])
lit = strings.TrimPrefix(lit, "+")
if isFloat {
if _, err := strconv.ParseFloat(lit, 64); err != nil {
return nil, p.errorf("invalid float number %q", lit)
}
return &CandidateNode{Kind: ScalarNode, Tag: "!!float", Value: lit}, nil
}
if _, err := strconv.ParseInt(lit, 10, 64); err != nil {
return nil, p.errorf("invalid integer %q", lit)
}
return &CandidateNode{Kind: ScalarNode, Tag: "!!int", Value: lit}, nil
}
func (p *json5Parser) parseIdentifier() (string, error) {
var sb strings.Builder
r := p.peek()
if !isIdentifierStart(r) && (r != '\\' || p.peekNext() != 'u') {
return "", p.errorf("expected identifier")
}
for !p.eof() {
r := p.peek()
if r == '\\' && p.peekNext() == 'u' {
p.next()
p.next()
decoded, err := p.parseUnicodeEscape()
if err != nil {
return "", err
}
if sb.Len() == 0 {
if !isIdentifierStart(decoded) {
return "", p.errorf("invalid identifier start")
}
} else if !isIdentifierPart(decoded) {
return "", p.errorf("invalid identifier part")
}
sb.WriteRune(decoded)
continue
}
if sb.Len() == 0 {
if !isIdentifierStart(r) {
break
}
} else if !isIdentifierPart(r) {
break
}
sb.WriteRune(p.next())
}
return sb.String(), nil
}
func isDigit(r rune) bool {
return r >= '0' && r <= '9'
}
func isHexDigit(r rune) bool {
return (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')
}
func hexDigitValue(r rune) (rune, bool) {
switch {
case r >= '0' && r <= '9':
return r - '0', true
case r >= 'a' && r <= 'f':
return r - 'a' + 10, true
case r >= 'A' && r <= 'F':
return r - 'A' + 10, true
default:
return 0, false
}
}
func isIdentifierStart(r rune) bool {
return r == '$' || r == '_' || unicode.IsLetter(r)
}
func isIdentifierPart(r rune) bool {
return isIdentifierStart(r) || unicode.IsDigit(r)
}

View File

@ -17,6 +17,8 @@ type formatScenario struct {
skipDoc bool
scenarioType string
expectedError string
// expectedErrorContains allows less brittle error assertions (e.g. where line/column may change).
expectedErrorContains string
}
func processFormatScenario(s formatScenario, decoder Decoder, encoder Encoder) (string, error) {

View File

@ -0,0 +1,5 @@
# JSON5
JSON5 support in `yq` lets you parse JSON5 files (comments, trailing commas, single quotes, unquoted keys, hex numbers, `Infinity`, `NaN`) and convert them to other formats like YAML, or output JSON5.
Note: when converting JSON5 to YAML (or other formats), comments may move slightly because formats like YAML don't always have a distinct representation for certain JSON5 comment placements (e.g. `/* foo */ { ... }` vs `{ /* foo */ ... }`). When converting JSON5 back to JSON5, `yq` keeps comments as close as possible to their original location.

View File

@ -0,0 +1,29 @@
# JSON5
JSON5 support in `yq` lets you parse JSON5 files (comments, trailing commas, single quotes, unquoted keys, hex numbers, `Infinity`, `NaN`) and convert them to other formats like YAML, or output JSON5.
Note: when converting JSON5 to YAML (or other formats), comments may move slightly because formats like YAML don't always have a distinct representation for certain JSON5 comment placements (e.g. `/* foo */ { ... }` vs `{ /* foo */ ... }`). When converting JSON5 back to JSON5, `yq` keeps comments as close as possible to their original location.
## Parse json5: comments, trailing commas, single quotes
Given a sample.json5 file of:
```json5
{
// comment
unquoted: 'single quoted',
trailing: [1, 2,],
}
```
then
```bash
yq -P -p=json5 '.' sample.json5
```
will output
```yaml
# comment
unquoted: single quoted
trailing:
- 1
- 2
```

347
pkg/yqlib/encoder_json5.go Normal file
View File

@ -0,0 +1,347 @@
//go:build !yq_nojson5
package yqlib
import (
"bytes"
"io"
"math"
"strconv"
"strings"
"github.com/goccy/go-json"
)
type json5Encoder struct {
prefs JsonPreferences
indentString string
}
func NewJSON5Encoder(prefs JsonPreferences) Encoder {
indentString := ""
for i := 0; i < prefs.Indent; i++ {
indentString += " "
}
return &json5Encoder{prefs: prefs, indentString: indentString}
}
func (je *json5Encoder) CanHandleAliases() bool {
return false
}
func (je *json5Encoder) PrintDocumentSeparator(_ io.Writer) error {
return nil
}
func (je *json5Encoder) PrintLeadingContent(_ io.Writer, _ string) error {
return nil
}
func (je *json5Encoder) Encode(writer io.Writer, node *CandidateNode) error {
if node.Kind == ScalarNode && je.prefs.UnwrapScalar {
return writeString(writer, node.Value+"\n")
}
destination := writer
tempBuffer := bytes.NewBuffer(nil)
if je.prefs.ColorsEnabled {
destination = tempBuffer
}
if err := writeJSON5CommentBlock(destination, node.HeadComment, je.indentString, 0); err != nil {
return err
}
if err := encodeJSON5Node(destination, node, je.indentString, 0); err != nil {
return err
}
if err := writeJSON5InlineComment(destination, node.LineComment, true); err != nil {
return err
}
if err := writeString(destination, "\n"); err != nil {
return err
}
if err := writeJSON5CommentBlock(destination, node.FootComment, je.indentString, 0); err != nil {
return err
}
if je.prefs.ColorsEnabled {
return colorizeAndPrint(tempBuffer.Bytes(), writer)
}
return nil
}
func encodeJSON5Node(writer io.Writer, node *CandidateNode, indentString string, depth int) error {
if node == nil {
return writeString(writer, "null")
}
switch node.Kind {
case AliasNode:
return encodeJSON5Node(writer, node.Alias, indentString, depth)
case ScalarNode:
return writeString(writer, json5ScalarString(node))
case SequenceNode:
return encodeJSON5Sequence(writer, node, indentString, depth)
case MappingNode:
return encodeJSON5Mapping(writer, node, indentString, depth)
default:
return writeString(writer, "null")
}
}
func encodeJSON5Sequence(writer io.Writer, node *CandidateNode, indentString string, depth int) error {
if err := writeString(writer, "["); err != nil {
return err
}
if len(node.Content) == 0 {
return writeString(writer, "]")
}
pretty := indentString != ""
if pretty {
if err := writeString(writer, "\n"); err != nil {
return err
}
}
for i, child := range node.Content {
if pretty {
if err := writeJSON5CommentBlock(writer, child.HeadComment, indentString, depth+1); err != nil {
return err
}
if err := writeString(writer, strings.Repeat(indentString, depth+1)); err != nil {
return err
}
}
if err := encodeJSON5Node(writer, child, indentString, depth+1); err != nil {
return err
}
if err := writeJSON5InlineComment(writer, child.LineComment, true); err != nil {
return err
}
if i != len(node.Content)-1 {
if err := writeString(writer, ","); err != nil {
return err
}
}
if pretty {
if err := writeString(writer, "\n"); err != nil {
return err
}
}
}
if pretty {
if err := writeString(writer, strings.Repeat(indentString, depth)); err != nil {
return err
}
}
return writeString(writer, "]")
}
func encodeJSON5Mapping(writer io.Writer, node *CandidateNode, indentString string, depth int) error {
if err := writeString(writer, "{"); err != nil {
return err
}
if len(node.Content) == 0 {
return writeString(writer, "}")
}
pretty := indentString != ""
if pretty {
if err := writeString(writer, "\n"); err != nil {
return err
}
}
for i := 0; i < len(node.Content); i += 2 {
keyNode := node.Content[i]
valueNode := node.Content[i+1]
keyBytes, err := json.Marshal(keyNode.Value)
if err != nil {
return err
}
if pretty {
if err := writeJSON5CommentBlock(writer, keyNode.HeadComment, indentString, depth+1); err != nil {
return err
}
if err := writeString(writer, strings.Repeat(indentString, depth+1)); err != nil {
return err
}
}
if err := writeString(writer, string(keyBytes)); err != nil {
return err
}
if err := writeJSON5InlineComment(writer, keyNode.LineComment, true); err != nil {
return err
}
if err := writeString(writer, ":"); err != nil {
return err
}
if pretty && strings.TrimSpace(valueNode.HeadComment) != "" && strings.Contains(valueNode.HeadComment, "\n") {
if err := writeString(writer, "\n"); err != nil {
return err
}
if err := writeJSON5CommentBlock(writer, valueNode.HeadComment, indentString, depth+1); err != nil {
return err
}
if err := writeString(writer, strings.Repeat(indentString, depth+1)); err != nil {
return err
}
} else {
if pretty {
if err := writeString(writer, " "); err != nil {
return err
}
}
if err := writeJSON5InlineComment(writer, valueNode.HeadComment, false); err != nil {
return err
}
if strings.TrimSpace(valueNode.HeadComment) != "" {
if err := writeString(writer, " "); err != nil {
return err
}
}
}
if err := encodeJSON5Node(writer, valueNode, indentString, depth+1); err != nil {
return err
}
if err := writeJSON5InlineComment(writer, valueNode.LineComment, true); err != nil {
return err
}
if i != len(node.Content)-2 {
if err := writeString(writer, ","); err != nil {
return err
}
}
if pretty {
if err := writeString(writer, "\n"); err != nil {
return err
}
}
}
if pretty {
if err := writeString(writer, strings.Repeat(indentString, depth)); err != nil {
return err
}
}
return writeString(writer, "}")
}
func json5ScalarString(node *CandidateNode) string {
tag := node.guessTagFromCustomType()
switch tag {
case "!!null":
return "null"
case "!!bool":
if isTruthyNode(node) {
return "true"
}
return "false"
case "!!int":
value, err := node.GetValueRep()
if err != nil {
return "null"
}
intBytes, err := json.Marshal(value)
if err != nil {
return "null"
}
return string(intBytes)
case "!!float":
return json5FloatString(node.Value)
case "!!str":
stringBytes, err := json.Marshal(node.Value)
if err != nil {
return "null"
}
return string(stringBytes)
default:
stringBytes, err := json.Marshal(node.Value)
if err != nil {
return "null"
}
return string(stringBytes)
}
}
func json5FloatString(value string) string {
switch strings.ToLower(value) {
case ".inf", "+.inf", "inf", "+inf", "+infinity", "infinity":
return "Infinity"
case "-.inf", "-inf", "-infinity":
return "-Infinity"
case ".nan", "nan":
return "NaN"
}
parsed, err := strconv.ParseFloat(value, 64)
if err != nil {
return "null"
}
if math.IsNaN(parsed) {
return "NaN"
}
if math.IsInf(parsed, 1) {
return "Infinity"
}
if math.IsInf(parsed, -1) {
return "-Infinity"
}
floatBytes, err := json.Marshal(parsed)
if err != nil {
return "null"
}
return string(floatBytes)
}
func writeJSON5CommentBlock(writer io.Writer, comment string, indentString string, depth int) error {
comment = strings.ReplaceAll(comment, "\r\n", "\n")
comment = strings.TrimSpace(comment)
if comment == "" {
return nil
}
indent := strings.Repeat(indentString, depth)
lines := strings.Split(comment, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
line = strings.TrimSpace(strings.TrimPrefix(line, "#"))
line = strings.TrimSpace(strings.TrimPrefix(line, "//"))
if err := writeString(writer, indent); err != nil {
return err
}
if err := writeString(writer, "// "+line+"\n"); err != nil {
return err
}
}
return nil
}
func writeJSON5InlineComment(writer io.Writer, comment string, forceLeadingSpace bool) error {
comment = strings.ReplaceAll(comment, "\r\n", "\n")
comment = strings.TrimSpace(comment)
if comment == "" {
return nil
}
comment = strings.TrimSpace(strings.TrimPrefix(comment, "#"))
comment = strings.TrimSpace(strings.TrimPrefix(comment, "//"))
comment = strings.ReplaceAll(comment, "\n", " ")
comment = strings.Join(strings.Fields(comment), " ")
prefix := ""
if forceLeadingSpace {
prefix = " "
}
return writeString(writer, prefix+"/* "+comment+" */")
}

View File

@ -0,0 +1,171 @@
//go:build !yq_nojson5
package yqlib
import (
"bytes"
"testing"
)
func TestJSON5EncoderPrintDocumentSeparatorIsNoop(t *testing.T) {
prefs := ConfiguredJSONPreferences.Copy()
var buf bytes.Buffer
if err := NewJSON5Encoder(prefs).PrintDocumentSeparator(&buf); err != nil {
t.Fatalf("PrintDocumentSeparator returned error: %v", err)
}
if got := buf.String(); got != "" {
t.Fatalf("expected no output, got %q", got)
}
}
func TestJSON5EncoderUnwrapScalar(t *testing.T) {
prefs := ConfiguredJSONPreferences.Copy()
prefs.UnwrapScalar = true
node := createStringScalarNode("hi")
var buf bytes.Buffer
if err := NewJSON5Encoder(prefs).Encode(&buf, node); err != nil {
t.Fatalf("Encode returned error: %v", err)
}
if got, want := buf.String(), "hi\n"; got != want {
t.Fatalf("got %q, want %q", got, want)
}
}
func TestEncodeJSON5NodeNilAndUnknownKind(t *testing.T) {
var buf bytes.Buffer
if err := encodeJSON5Node(&buf, nil, " ", 0); err != nil {
t.Fatalf("encodeJSON5Node(nil) returned error: %v", err)
}
if got, want := buf.String(), "null"; got != want {
t.Fatalf("got %q, want %q", got, want)
}
buf.Reset()
if err := encodeJSON5Node(&buf, &CandidateNode{}, " ", 0); err != nil {
t.Fatalf("encodeJSON5Node(unknown kind) returned error: %v", err)
}
if got, want := buf.String(), "null"; got != want {
t.Fatalf("got %q, want %q", got, want)
}
}
func TestJSON5ScalarAndFloatFormatting(t *testing.T) {
if got, want := json5FloatString(".inf"), "Infinity"; got != want {
t.Fatalf("got %q, want %q", got, want)
}
if got, want := json5FloatString("1e309"), "null"; got != want {
t.Fatalf("got %q, want %q", got, want)
}
if got, want := json5FloatString(".nan"), "NaN"; got != want {
t.Fatalf("got %q, want %q", got, want)
}
if got, want := json5FloatString("definitely-not-a-float"), "null"; got != want {
t.Fatalf("got %q, want %q", got, want)
}
badInt := &CandidateNode{Kind: ScalarNode, Tag: "!!int", Value: "nope"}
if got, want := json5ScalarString(badInt), "null"; got != want {
t.Fatalf("got %q, want %q", got, want)
}
unknownTag := &CandidateNode{Kind: ScalarNode, Tag: "!!unknown", Value: "hi"}
if got, want := json5ScalarString(unknownTag), "\"hi\""; got != want {
t.Fatalf("got %q, want %q", got, want)
}
}
func TestJSON5EncoderEncodeSequenceMappingAndComments(t *testing.T) {
prefs := ConfiguredJSONPreferences.Copy()
prefs.Indent = 2
prefs.UnwrapScalar = false
prefs.ColorsEnabled = false
aliasTarget := createScalarNode(3, "3")
seq := &CandidateNode{
Kind: SequenceNode,
HeadComment: "# root head\n\n// root second\n",
LineComment: "# root inline",
FootComment: "# foot",
Content: []*CandidateNode{
{
Kind: ScalarNode,
Tag: "!!int",
Value: "1",
HeadComment: "# first",
LineComment: "// lc1",
},
{
Kind: AliasNode,
Alias: aliasTarget,
LineComment: "# alias",
},
{
Kind: MappingNode,
HeadComment: "# map head",
LineComment: "# map lc",
Content: []*CandidateNode{
{
Kind: ScalarNode,
Tag: "!!str",
Value: "a",
HeadComment: "# key a",
LineComment: "# key inline",
IsMapKey: true,
},
{
Kind: ScalarNode,
Tag: "!!float",
Value: ".inf",
HeadComment: "# before a line1\n# before a line2",
LineComment: "# value inline",
},
{
Kind: ScalarNode,
Tag: "!!str",
Value: "b",
IsMapKey: true,
},
{
Kind: ScalarNode,
Tag: "!!bool",
Value: "true",
HeadComment: "# before b",
},
},
},
createStringScalarNode("hi"),
},
}
var buf bytes.Buffer
if err := NewJSON5Encoder(prefs).Encode(&buf, seq); err != nil {
t.Fatalf("Encode returned error: %v", err)
}
const expected = `// root head
// root second
[
// first
1 /* lc1 */,
3 /* alias */,
// map head
{
// key a
"a" /* key inline */:
// before a line1
// before a line2
Infinity /* value inline */,
"b": /* before b */ true
} /* map lc */,
"hi"
] /* root inline */
// foot
`
if got := buf.String(); got != expected {
t.Fatalf("unexpected output:\n%s", got)
}
}

View File

@ -33,6 +33,11 @@ var JSONFormat = &Format{"json", []string{"j"},
func() Decoder { return NewJSONDecoder() },
}
var JSON5Format = &Format{"json5", []string{"j5"},
func() Encoder { return NewJSON5Encoder(ConfiguredJSONPreferences) },
func() Decoder { return NewJSON5Decoder() },
}
var PropertiesFormat = &Format{"props", []string{"p", "properties"},
func() Encoder { return NewPropertiesEncoder(ConfiguredPropertiesPreferences) },
func() Decoder { return NewPropertiesDecoder() },
@ -97,6 +102,7 @@ var Formats = []*Format{
YamlFormat,
KYamlFormat,
JSONFormat,
JSON5Format,
PropertiesFormat,
CSVFormat,
TSVFormat,

321
pkg/yqlib/json5_test.go Normal file
View File

@ -0,0 +1,321 @@
//go:build !yq_nojson5
package yqlib
import (
"bufio"
"fmt"
"strings"
"testing"
"github.com/mikefarah/yq/v4/test"
)
var json5Scenarios = []formatScenario{
{
description: "Parse json5: comments, trailing commas, single quotes",
input: `{
// comment
unquoted: 'single quoted',
trailing: [1, 2,],
}
`,
expected: `# comment
unquoted: single quoted
trailing:
- 1
- 2
`,
scenarioType: "decode",
},
{
description: "Parse json5: multiline block comments",
skipDoc: true,
scenarioType: "decode",
input: `{
/*
multiline
block comment
*/
first: 1,
second/* inline block */: 2,
third: /* before value */ 3,
fourth: [1, /* between elements */ 2,],
}
`,
expected: `# multiline
# block comment
first: 1
second: 2 # inline block
third: 3
# before value
fourth:
- 1
# between elements
- 2
`,
},
{
description: "Roundtrip json5: preserve comment placement",
skipDoc: true,
scenarioType: "roundtrip",
indent: 2,
input: `{
a/*k*/:/*v*/1/*after*/,
b: 2 // end
}`,
expected: `{
"a" /* k */: /* v */ 1 /* after */,
"b": 2 /* end */
}
`,
},
{
description: "Roundtrip json5: hex, Infinity, NaN",
skipDoc: true,
input: `{hex: 0x10, posInf: Infinity, negInf: -Infinity, not: NaN,}`,
expected: "{\"hex\":16,\"posInf\":Infinity,\"negInf\":-Infinity,\"not\":NaN}\n",
indent: 0,
scenarioType: "roundtrip",
},
{
description: "bad json5",
skipDoc: true,
input: `{a: 1,]`,
expectedError: `bad file 'sample.yml': json5: expected object key at line 1, column 7`,
scenarioType: "decode-error",
subdescription: "json5 supports more syntax than json, but it still needs to be well-formed.",
},
{
description: "Parse json5: block comments normalisation (stars, tabs, trailing spaces)",
skipDoc: true,
scenarioType: "decode",
input: "{\n /*\n\t * hello \t \n\t ** world\t\n */\n a: 1,\n}\n",
expected: "# hello \t \n# * world\na: 1\n",
subdescription: "Block comments are normalised and emitted as YAML comments.",
},
{
description: "Parse json5: scalars true/false/null with leading whitespace",
skipDoc: true,
scenarioType: "decode",
input: " \n\ttrue\n",
expected: "true\n",
},
{
description: "Parse json5: scalar false",
skipDoc: true,
scenarioType: "decode",
input: "false",
expected: "false\n",
},
{
description: "Parse json5: scalar null with extra newlines",
skipDoc: true,
scenarioType: "decode",
input: "\n\nnull\n\n",
expected: "null\n",
},
{
description: "Parse json5: object trailing comma",
skipDoc: true,
scenarioType: "decode",
input: "{a: 1,}\n",
expected: "a: 1\n",
},
{
description: "Parse json5: array trailing comma",
skipDoc: true,
scenarioType: "decode",
input: "[1, 2,]\n",
expected: "- 1\n- 2\n",
},
{
description: "Parse json5: unicode escapes in strings",
skipDoc: true,
scenarioType: "decode",
input: "{s: \"\\u0041\"}\n",
expected: "s: A\n",
},
{
description: "Parse json5: UTF-16 surrogate pair unicode escape in string",
skipDoc: true,
scenarioType: "decode",
input: "{s: \"\\uD83D\\uDE00\"}\n",
expected: "s: \"\\U0001F600\"\n",
},
{
description: "Parse json5: invalid unicode escape in string (lowercase g)",
skipDoc: true,
scenarioType: "decode-error",
input: "{s: \"\\u00ag\"}\n",
expectedErrorContains: "invalid hex escape",
},
{
description: "Parse json5: invalid float (overflow)",
skipDoc: true,
scenarioType: "decode-error",
input: "{a: 1e309}\n",
expectedErrorContains: "invalid float number",
},
{
description: "Parse json5: invalid float exponent",
skipDoc: true,
scenarioType: "decode-error",
input: "{a: 1e+}\n",
expectedErrorContains: "invalid number exponent",
},
{
description: "Parse json5: invalid number with multiple periods",
skipDoc: true,
scenarioType: "decode-error",
input: "{ip: 127.0.0.1}\n",
expectedErrorContains: "expected ',' or '}' after object entry",
},
{
description: "Parse json5: extra colon after key",
skipDoc: true,
scenarioType: "decode-error",
input: "{a:: 1}\n",
expectedErrorContains: "unexpected character ':'",
},
{
description: "Parse json5: trailing comma with extra closing brace",
skipDoc: true,
scenarioType: "decode-error",
input: "{a: 1,}}\n",
expectedErrorContains: "unexpected character '}'",
},
{
description: "Parse json5: unterminated block comment at EOF",
skipDoc: true,
scenarioType: "decode-error",
input: "{/* unterminated",
expectedErrorContains: "unterminated block comment",
},
{
description: "Parse json5: unterminated escape sequence at EOF",
skipDoc: true,
scenarioType: "decode-error",
input: "\"abc\\",
expectedErrorContains: "unterminated escape sequence",
},
{
description: "Parse json5: unterminated unicode escape",
skipDoc: true,
scenarioType: "decode-error",
input: "{s: \"\\u12\"}\n",
expectedErrorContains: "invalid hex escape",
},
{
description: "Parse json5: bad hex digits in string escape",
skipDoc: true,
scenarioType: "decode-error",
input: "{s: \"\\x0G\"}\n",
expectedErrorContains: "invalid hex escape",
},
{
description: "Parse json5: bad hex digits in string escape (lowercase g)",
skipDoc: true,
scenarioType: "decode-error",
input: "{s: \"\\xag\"}\n",
expectedErrorContains: "invalid hex escape",
},
{
description: "Parse json5: bad hex digits in hex number",
skipDoc: true,
scenarioType: "decode-error",
input: "{a: 0xG}\n",
expectedErrorContains: "invalid hex number",
},
{
description: "Parse json5: bad hex digits in hex number (lowercase g)",
skipDoc: true,
scenarioType: "decode-error",
input: "{a: 0xg}\n",
expectedErrorContains: "invalid hex number",
},
{
description: "Parse json5: invalid identifier start via unicode escape",
skipDoc: true,
scenarioType: "decode-error",
input: "{\\u0031abc: 1}\n",
expectedErrorContains: "invalid identifier start",
},
{
description: "Parse json5: invalid identifier part via unicode escape",
skipDoc: true,
scenarioType: "decode-error",
input: "{a\\u002D: 1}\n",
expectedErrorContains: "invalid identifier part",
},
{
description: "Parse json5: invalid unicode escape in identifier",
skipDoc: true,
scenarioType: "decode-error",
input: "{a\\u00ag: 1}\n",
expectedErrorContains: "invalid hex escape",
},
}
func testJSON5Scenario(t *testing.T, s formatScenario) {
prefs := ConfiguredJSONPreferences.Copy()
prefs.Indent = s.indent
prefs.UnwrapScalar = false
switch s.scenarioType {
case "encode":
test.AssertResultWithContext(t, s.expected, mustProcessFormatScenario(s, NewYamlDecoder(ConfiguredYamlPreferences), NewJSON5Encoder(prefs)), s.description)
case "decode":
test.AssertResultWithContext(t, s.expected, mustProcessFormatScenario(s, NewJSON5Decoder(), NewYamlEncoder(ConfiguredYamlPreferences)), s.description)
case "roundtrip":
test.AssertResultWithContext(t, s.expected, mustProcessFormatScenario(s, NewJSON5Decoder(), NewJSON5Encoder(prefs)), s.description)
case "decode-error":
result, err := processFormatScenario(s, NewJSON5Decoder(), NewJSON5Encoder(prefs))
if err == nil {
t.Errorf("Expected error '%v' but it worked: %v", s.expectedError, result)
} else {
if s.expectedErrorContains != "" {
if !strings.Contains(err.Error(), s.expectedErrorContains) {
t.Errorf("%s: expected error containing %q, got %q", s.description, s.expectedErrorContains, err.Error())
}
} else {
test.AssertResultComplexWithContext(t, s.expectedError, err.Error(), s.description)
}
}
default:
panic(fmt.Sprintf("unhandled scenario type %q", s.scenarioType))
}
}
func documentJSON5Scenario(_ *testing.T, w *bufio.Writer, i interface{}) {
s := i.(formatScenario)
if s.skipDoc {
return
}
writeOrPanic(w, fmt.Sprintf("## %v\n", s.description))
if s.subdescription != "" {
writeOrPanic(w, s.subdescription)
writeOrPanic(w, "\n\n")
}
writeOrPanic(w, "Given a sample.json5 file of:\n")
writeOrPanic(w, fmt.Sprintf("```json5\n%v\n```\n", s.input))
writeOrPanic(w, "then\n")
writeOrPanic(w, "```bash\nyq -P -p=json5 '.' sample.json5\n```\n")
writeOrPanic(w, "will output\n")
writeOrPanic(w, fmt.Sprintf("```yaml\n%v```\n\n", mustProcessFormatScenario(s, NewJSON5Decoder(), NewYamlEncoder(ConfiguredYamlPreferences))))
}
func TestJSON5Scenarios(t *testing.T) {
for _, tt := range json5Scenarios {
testJSON5Scenario(t, tt)
}
genericScenarios := make([]interface{}, len(json5Scenarios))
for i, s := range json5Scenarios {
genericScenarios[i] = s
}
documentScenarios(t, "usage", "json5", genericScenarios, documentJSON5Scenario)
}

11
pkg/yqlib/no_json5.go Normal file
View File

@ -0,0 +1,11 @@
//go:build yq_nojson5
package yqlib
func NewJSON5Decoder() Decoder {
return nil
}
func NewJSON5Encoder(prefs JsonPreferences) Encoder {
return nil
}

View File

@ -293,3 +293,4 @@ buildvcs
behaviour
GOFLAGS
gocache
json5

View File

@ -1,2 +1,2 @@
#!/bin/bash
go build -tags "yq_nolua yq_noini yq_notoml yq_noxml yq_nojson yq_nohcl yq_nokyaml" -ldflags "-s -w" .
go build -tags "yq_nolua yq_noini yq_notoml yq_noxml yq_nojson yq_nohcl yq_nokyaml yq_nojson5" -ldflags "-s -w" .

View File

@ -1,4 +1,4 @@
#!/bin/bash
# Currently, the `yq_nojson` feature must be enabled when using TinyGo.
tinygo build -no-debug -tags "yq_nolua yq_noini yq_notoml yq_noxml yq_nojson yq_nocsv yq_nobase64 yq_nouri yq_noprops yq_nosh yq_noshell yq_nohcl yq_nokyaml" .
tinygo build -no-debug -tags "yq_nolua yq_noini yq_notoml yq_noxml yq_nojson yq_nocsv yq_nobase64 yq_nouri yq_noprops yq_nosh yq_noshell yq_nohcl yq_nokyaml yq_nojson5" .