207 lines
4.9 KiB
Go
207 lines
4.9 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"unicode/utf8"
|
|
|
|
"github.com/BurntSushi/toml"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// parseStructured decodes a format into maps, slices and scalar Go values.
|
|
// json.Number is retained so large JSON integers do not pass through float64.
|
|
func parseStructured(input []byte, format string) (any, error) {
|
|
var value any
|
|
switch format {
|
|
case "json":
|
|
// encoding/json replaces malformed UTF-8 inside strings with U+FFFD.
|
|
// Reject it up front so a successful conversion never changes bytes
|
|
// silently.
|
|
if !utf8.Valid(input) {
|
|
return nil, fmt.Errorf("JSON input is not valid UTF-8")
|
|
}
|
|
dec := json.NewDecoder(bytes.NewReader(input))
|
|
dec.UseNumber()
|
|
var err error
|
|
value, err = decodeJSONValue(dec)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
_, err = decodeJSONValue(dec)
|
|
switch {
|
|
case errors.Is(err, io.EOF):
|
|
case err == nil:
|
|
return nil, fmt.Errorf("multiple JSON values are not supported")
|
|
default:
|
|
return nil, fmt.Errorf("invalid trailing JSON data: %w", err)
|
|
}
|
|
case "yaml":
|
|
dec := yaml.NewDecoder(bytes.NewReader(input))
|
|
var documents []any
|
|
for {
|
|
var document any
|
|
err := dec.Decode(&document)
|
|
if errors.Is(err, io.EOF) {
|
|
break
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
documents = append(documents, normalizeYAML(document))
|
|
}
|
|
switch len(documents) {
|
|
case 0:
|
|
value = nil
|
|
case 1:
|
|
value = documents[0]
|
|
default:
|
|
// A YAML stream is an ordered sequence of documents. Representing it
|
|
// as a slice preserves every document for conversion to other formats.
|
|
value = documents
|
|
}
|
|
case "toml":
|
|
var m map[string]any
|
|
if err := toml.Unmarshal(input, &m); err != nil {
|
|
return nil, err
|
|
}
|
|
value = normalizeTOML(m)
|
|
default:
|
|
return nil, fmt.Errorf("format %q is not structured", format)
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
// decodeJSONValue builds a generic structured value directly from decoder
|
|
// tokens so duplicate object keys can be rejected before a map overwrites one.
|
|
func decodeJSONValue(dec *json.Decoder) (any, error) {
|
|
token, err := dec.Token()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
delimiter, isDelimiter := token.(json.Delim)
|
|
if !isDelimiter {
|
|
return token, nil
|
|
}
|
|
|
|
switch delimiter {
|
|
case '{':
|
|
object := make(map[string]any)
|
|
for dec.More() {
|
|
keyToken, err := dec.Token()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
key, ok := keyToken.(string)
|
|
if !ok {
|
|
return nil, fmt.Errorf("JSON object key has unexpected type %T", keyToken)
|
|
}
|
|
if _, exists := object[key]; exists {
|
|
return nil, fmt.Errorf("duplicate JSON object key %q", key)
|
|
}
|
|
child, err := decodeJSONValue(dec)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
object[key] = child
|
|
}
|
|
if _, err := dec.Token(); err != nil {
|
|
return nil, err
|
|
}
|
|
return object, nil
|
|
case '[':
|
|
array := make([]any, 0)
|
|
for dec.More() {
|
|
child, err := decodeJSONValue(dec)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
array = append(array, child)
|
|
}
|
|
if _, err := dec.Token(); err != nil {
|
|
return nil, err
|
|
}
|
|
return array, nil
|
|
default:
|
|
return nil, fmt.Errorf("unexpected JSON delimiter %q", delimiter)
|
|
}
|
|
}
|
|
|
|
func encodeStructured(value any, format string, pretty bool) ([]byte, error) {
|
|
switch format {
|
|
case "json":
|
|
if pretty {
|
|
return json.MarshalIndent(value, "", " ")
|
|
}
|
|
return json.Marshal(value)
|
|
case "yaml":
|
|
return yaml.Marshal(value)
|
|
case "toml":
|
|
m, ok := value.(map[string]any)
|
|
if !ok {
|
|
// TOML has no top-level array, so preserve it under a documented key.
|
|
m = map[string]any{"rows": value}
|
|
}
|
|
var b bytes.Buffer
|
|
err := toml.NewEncoder(&b).Encode(m)
|
|
return b.Bytes(), err
|
|
default:
|
|
return nil, fmt.Errorf("format %q is not structured", format)
|
|
}
|
|
}
|
|
|
|
// normalizeYAML converts yaml.v3's possible map[any]any values into the
|
|
// string-keyed maps used by the rest of the conversion pipeline.
|
|
func normalizeYAML(value any) any {
|
|
switch v := value.(type) {
|
|
case map[string]any:
|
|
out := make(map[string]any, len(v))
|
|
for key, child := range v {
|
|
out[key] = normalizeYAML(child)
|
|
}
|
|
return out
|
|
case map[any]any:
|
|
out := make(map[string]any, len(v))
|
|
for key, child := range v {
|
|
out[fmt.Sprint(key)] = normalizeYAML(child)
|
|
}
|
|
return out
|
|
case []any:
|
|
for i := range v {
|
|
v[i] = normalizeYAML(v[i])
|
|
}
|
|
}
|
|
return value
|
|
}
|
|
|
|
// BurntSushi/toml represents arrays of tables as []map[string]any instead of
|
|
// []any. Normalize that concrete container so wrapper detection, flattening,
|
|
// and format-to-format conversions use the same structured representation.
|
|
func normalizeTOML(value any) any {
|
|
switch v := value.(type) {
|
|
case map[string]any:
|
|
out := make(map[string]any, len(v))
|
|
for key, child := range v {
|
|
out[key] = normalizeTOML(child)
|
|
}
|
|
return out
|
|
case []map[string]any:
|
|
out := make([]any, len(v))
|
|
for i, child := range v {
|
|
out[i] = normalizeTOML(child)
|
|
}
|
|
return out
|
|
case []any:
|
|
out := make([]any, len(v))
|
|
for i, child := range v {
|
|
out[i] = normalizeTOML(child)
|
|
}
|
|
return out
|
|
default:
|
|
return value
|
|
}
|
|
}
|