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 }