Files
commandkit/report_value.go

230 lines
6.5 KiB
Go
Raw Normal View History

2026-11-07 03:38:22 +00:00
package commandkit
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"math/big"
"regexp"
"sort"
"strconv"
"strings"
"go.yaml.in/yaml/v4"
)
// normalizeReport applies the JSON representation selected by the report's
// author while retaining numeric tokens for subsequent rendering.
func normalizeReport(value any) (any, error) {
data, err := json.Marshal(value)
if err != nil {
return nil, err
}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.UseNumber()
var normalized any
if err := decoder.Decode(&normalized); err != nil {
return nil, err
}
return normalized, nil
}
// DecodeYAMLReport decodes one YAML document from safe inspection output into
// JSON-compatible values. Unsupported YAML meanings return an error; the result
// is not a lossless representation of the input document.
func DecodeYAMLReport(reader io.Reader) (any, error) {
decoder := yaml.NewDecoder(reader)
var doc yaml.Node
if err := decoder.Decode(&doc); err != nil {
return nil, err
}
var extra yaml.Node
if err := decoder.Decode(&extra); err != io.EOF {
if err != nil {
return nil, err
}
return nil, errors.New("multiple YAML documents")
}
return yamlReportValue(&doc, make(map[*yaml.Node]bool))
}
func yamlReportValue(node *yaml.Node, visiting map[*yaml.Node]bool) (any, error) {
if node == nil || visiting[node] {
return nil, errors.New("invalid or cyclic YAML alias")
}
visiting[node] = true
defer delete(visiting, node)
switch node.Kind {
case yaml.DocumentNode:
if len(node.Content) != 1 {
return nil, errors.New("invalid YAML document")
}
return yamlReportValue(node.Content[0], visiting)
case yaml.AliasNode:
return yamlReportValue(node.Alias, visiting)
case yaml.MappingNode:
if node.ShortTag() != "!!map" {
return nil, fmt.Errorf("unsupported YAML tag %s", node.Tag)
}
result := make(map[string]any, len(node.Content)/2)
for i := 0; i < len(node.Content); i += 2 {
key, err := yamlReportValue(node.Content[i], visiting)
if err != nil {
return nil, err
}
name, ok := key.(string)
if !ok {
return nil, errors.New("YAML mapping key is not a string")
}
if _, exists := result[name]; exists {
return nil, fmt.Errorf("duplicate YAML mapping key %q", name)
}
value, err := yamlReportValue(node.Content[i+1], visiting)
if err != nil {
return nil, err
}
result[name] = value
}
return result, nil
case yaml.SequenceNode:
if node.ShortTag() != "!!seq" {
return nil, fmt.Errorf("unsupported YAML tag %s", node.Tag)
}
result := make([]any, 0, len(node.Content))
for _, child := range node.Content {
value, err := yamlReportValue(child, visiting)
if err != nil {
return nil, err
}
result = append(result, value)
}
return result, nil
case yaml.ScalarNode:
switch node.ShortTag() {
case "!!str":
// The YAML resolver falls back to a string when a numeric scalar is
// outside its machine-number range. Preserve explicit and quoted
// strings, but do not silently turn a plain numeric scalar into text.
if node.Style == 0 {
plain := strings.ReplaceAll(node.Value, "_", "")
if yamlInteger.MatchString(plain) {
return yamlReportNumber(node.Value, "!!int")
}
if yamlNumericPlain.MatchString(plain) {
return yamlReportNumber(node.Value, "!!float")
}
}
return node.Value, nil
case "!!null":
return nil, nil
case "!!bool":
return strings.EqualFold(node.Value, "true"), nil
case "!!int", "!!float":
return yamlReportNumber(node.Value, node.ShortTag())
}
}
return nil, fmt.Errorf("unsupported YAML value with tag %s", node.Tag)
}
var yamlDecimal = regexp.MustCompile(`^([+-]?)([0-9]*)(?:\.([0-9]*))?([eE][+-]?[0-9]+)?$`)
var yamlInteger = regexp.MustCompile(`^[+-]?(?:0[bB][01]+|0[oO][0-7]+|0[xX][0-9a-fA-F]+|[0-9]+)$`)
var yamlNumericPlain = regexp.MustCompile(`^[+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?$`)
var jsonNumberPattern = regexp.MustCompile(`^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?$`)
func yamlReportNumber(value, tag string) (json.Number, error) {
original := value
value = strings.ReplaceAll(value, "_", "")
if tag == "!!int" {
n, ok := new(big.Int).SetString(value, 0)
if ok {
return json.Number(n.String()), nil
}
if !yamlNumericPlain.MatchString(value) {
return "", fmt.Errorf("unsupported YAML number %q", original)
}
}
match := yamlDecimal.FindStringSubmatch(value)
if match == nil || (match[2] == "" && match[3] == "") {
return "", fmt.Errorf("unsupported YAML number %q", original)
}
integer := strings.TrimLeft(match[2], "0")
if integer == "" {
integer = "0"
}
sign := match[1]
if sign == "+" {
sign = ""
}
result := sign + integer
if strings.Contains(value, ".") {
fraction := match[3]
if fraction == "" {
fraction = "0"
}
result += "." + fraction
}
result += match[4]
if !json.Valid([]byte(result)) {
return "", fmt.Errorf("unsupported YAML number %q", original)
}
return json.Number(result), nil
}
func reportYAMLNode(value any) (*yaml.Node, error) {
switch v := value.(type) {
case nil:
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!null", Value: "null"}, nil
case bool:
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!bool", Value: strconv.FormatBool(v)}, nil
case string:
return reportYAMLString(v), nil
case json.Number:
if !jsonNumberPattern.MatchString(string(v)) {
return nil, fmt.Errorf("invalid JSON number %q", v)
}
tag := "!!int"
if strings.ContainsAny(string(v), ".eE") {
tag = "!!float"
}
return &yaml.Node{Kind: yaml.ScalarNode, Tag: tag, Value: string(v)}, nil
case []any:
node := &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"}
for _, item := range v {
child, err := reportYAMLNode(item)
if err != nil {
return nil, err
}
node.Content = append(node.Content, child)
}
return node, nil
case map[string]any:
node := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
keys := make([]string, 0, len(v))
for key := range v {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
child, err := reportYAMLNode(v[key])
if err != nil {
return nil, err
}
node.Content = append(node.Content, reportYAMLString(key), child)
}
return node, nil
default:
return nil, fmt.Errorf("unsupported normalized report value %T", value)
}
}
func reportYAMLString(value string) *yaml.Node {
node := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: value}
plain := strings.ReplaceAll(value, "_", "")
if yamlInteger.MatchString(plain) || yamlNumericPlain.MatchString(plain) {
node.Style = yaml.DoubleQuotedStyle
}
return node
}