Files
dataxl/cmd/dataxl/structured.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
}
}