From d696afb9dc031d291dbfc1776eba8f89615f02fb Mon Sep 17 00:00:00 2001 From: Luca Candela Date: Sun, 11 Oct 2026 10:56:35 -0700 Subject: [PATCH] fix: compare enum entries by JSON value --- utils/enum.go | 131 +++++++++++++++++++++++++++++++++++++++++++++ utils/enum_test.go | 87 ++++++++++++++++++++++++++++++ utils/utils.go | 15 ------ 3 files changed, 218 insertions(+), 15 deletions(-) create mode 100644 utils/enum.go create mode 100644 utils/enum_test.go diff --git a/utils/enum.go b/utils/enum.go new file mode 100644 index 00000000..119716de --- /dev/null +++ b/utils/enum.go @@ -0,0 +1,131 @@ +package utils + +import ( + "math/big" + "sort" + "strconv" + "strings" + + "github.com/pb33f/go-yaml" +) + +type enumValueIndex struct { + values map[string]int + nodes map[*yaml.Node]int + active map[*yaml.Node]bool +} + +// CheckEnumForDuplicates returns each occurrence after the first of an equal +// JSON value, preserving input order and node identity. Numbers compare by value, +// arrays by ordered elements, and objects by members irrespective of key order. +// YAML aliases are followed; cyclic or non-JSON values are ignored. +func CheckEnumForDuplicates(seq []*yaml.Node) []*yaml.Node { + var duplicates []*yaml.Node + seen := make(map[int]bool) + index := enumValueIndex{ + values: make(map[string]int), + nodes: make(map[*yaml.Node]int), + active: make(map[*yaml.Node]bool), + } + for _, node := range seq { + id := index.valueID(node) + if id == 0 { + continue + } + if seen[id] { + duplicates = append(duplicates, node) + } + seen[id] = true + } + return duplicates +} + +// valueID interns each value using its children's IDs, keeping repeated aliases +// compact. Zero denotes a value outside the JSON data model. +func (index *enumValueIndex) valueID(node *yaml.Node) int { + if node == nil || index.active[node] { + return 0 + } + if id, ok := index.nodes[node]; ok { + return id + } + index.active[node] = true + defer delete(index.active, node) + var id int + if node.Kind == yaml.AliasNode { + id = index.valueID(node.Alias) + } else if key, ok := index.valueKey(node); ok { + id = index.values[key] + if id == 0 { + id = len(index.values) + 1 + index.values[key] = id + } + } + index.nodes[node] = id + return id +} + +func (index *enumValueIndex) valueKey(node *yaml.Node) (string, bool) { + switch node.Kind { + case yaml.ScalarNode: + switch node.ShortTag() { + case "!!str": + return "s" + strconv.Quote(node.Value), true + case "!!null": + return "null", true + case "!!bool": + var value bool + if err := node.Decode(&value); err != nil { + return "", false + } + return strconv.FormatBool(value), true + case "!!int": + // YAML accepts base-prefixed integers and legacy octal (012 == 10). + value, ok := new(big.Int).SetString(node.Value, 0) + if !ok { + return "", false + } + return "n" + value.String(), true + case "!!float": + value, ok := new(big.Rat).SetString(node.Value) + if !ok { + return "", false + } + return "n" + value.RatString(), true + } + case yaml.SequenceNode: + if node.ShortTag() != "!!seq" { + return "", false + } + parts := make([]string, 0, len(node.Content)) + for _, child := range node.Content { + id := index.valueID(child) + if id == 0 { + return "", false + } + parts = append(parts, strconv.Itoa(id)) + } + return "[" + strings.Join(parts, ",") + "]", true + case yaml.MappingNode: + if node.ShortTag() != "!!map" || len(node.Content)%2 != 0 { + return "", false + } + parts := make([]string, 0, len(node.Content)/2) + keys := make(map[string]bool) + for i := 0; i < len(node.Content); i += 2 { + name := node.Content[i] + if name == nil || name.Kind != yaml.ScalarNode || name.ShortTag() != "!!str" || keys[name.Value] { + return "", false + } + keys[name.Value] = true + id := index.valueID(node.Content[i+1]) + if id == 0 { + return "", false + } + parts = append(parts, strconv.Quote(name.Value)+":"+strconv.Itoa(id)) + } + sort.Strings(parts) + return "{" + strings.Join(parts, ",") + "}", true + } + return "", false +} diff --git a/utils/enum_test.go b/utils/enum_test.go new file mode 100644 index 00000000..770ca0ad --- /dev/null +++ b/utils/enum_test.go @@ -0,0 +1,87 @@ +package utils + +import ( + "testing" + + "github.com/pb33f/go-yaml" + "github.com/pb33f/testify/require" +) + +func TestCheckEnumForDuplicates_Values(t *testing.T) { + for _, tc := range []struct { + name string + yaml string + duplicates []int + }{ + {"distinct arrays", `[[a], [b], [a, b], [b, a], []]`, nil}, + {"duplicate arrays", `[[a], [b], [a], [a]]`, []int{2, 3}}, + {"distinct objects", `[{a: 1}, {a: 2}, {b: 1}, {}]`, nil}, + {"object order", `[{a: 1, b: [2, 3]}, {b: [2.0, 3], a: 1.0}]`, []int{1}}, + {"nested types", `[{a: [1]}, {a: ["1"]}]`, nil}, + {"scalar types", `[1, "1", true, "true", null, "null", "", [], {}]`, nil}, + {"octal integers", `[012, 10, 12, -012, -10, 0o12, 10.0]`, []int{1, 4, 5, 6}}, + {"numbers", `[1, 1.0, 1e0, -0, 0.0, 0, 0.1, 1e-1]`, []int{1, 2, 4, 5, 7}}, + {"precision", `[9007199254740992, 9007199254740993, 9007199254740993.0]`, []int{2}}, + {"yaml scalars", `[true, TRUE, null, ~, 0x10, 16]`, []int{1, 3, 5}}, + {"aliases", `[&a {x: [1]}, *a, {x: [1.0]}]`, []int{1, 2}}, + {"repeated aliases", `[&a [1], [*a, *a], [[1], [1]]]`, []int{2}}, + {"delimiters", `[["a,b", c], [a, "b,c"], {"a:b": c}, {a: "b:c"}]`, nil}, + } { + t.Run(tc.name, func(t *testing.T) { + var root yaml.Node + require.NoError(t, yaml.Unmarshal([]byte(tc.yaml), &root)) + values := root.Content[0].Content + duplicates := CheckEnumForDuplicates(values) + require.Len(t, duplicates, len(tc.duplicates)) + for i, index := range tc.duplicates { + require.Same(t, values[index], duplicates[i]) + } + }) + } +} + +func TestCheckEnumForDuplicates_NonJSONValues(t *testing.T) { + for _, source := range []string{ + `[&a [*a], &b [*b]]`, + `[{1: a}, {1: a}]`, + `[{a: 1, a: 2}, {a: 1, a: 2}]`, + `[.inf, .inf, .nan, .nan]`, + `[!custom a, !custom a]`, + `[!custom [], []]`, + `[!custom {}, {}]`, + `[{x: !custom a}, {x: !custom a}]`, + } { + t.Run(source, func(t *testing.T) { + var root yaml.Node + require.NoError(t, yaml.Unmarshal([]byte(source), &root)) + require.Empty(t, CheckEnumForDuplicates(root.Content[0].Content)) + }) + } + for _, node := range []*yaml.Node{ + nil, + {Kind: yaml.AliasNode}, + {Kind: yaml.MappingNode, Content: []*yaml.Node{nil}}, + {Kind: yaml.MappingNode, Content: []*yaml.Node{nil, nil}}, + {Kind: yaml.SequenceNode, Content: []*yaml.Node{nil}}, + {Kind: yaml.ScalarNode, Tag: "!!bool", Value: "invalid"}, + {Kind: yaml.ScalarNode, Tag: "!!int", Value: "invalid"}, + {Kind: yaml.DocumentNode}, + } { + require.Empty(t, CheckEnumForDuplicates([]*yaml.Node{node, node})) + } +} + +func TestCheckEnumForDuplicates_SharedAliases(t *testing.T) { + value := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!int", Value: "1"} + // The graph is linear in size; expanding its aliases would create 2^128 leaves. + for range 128 { + value = &yaml.Node{Kind: yaml.SequenceNode, Content: []*yaml.Node{ + {Kind: yaml.AliasNode, Alias: value}, + {Kind: yaml.AliasNode, Alias: value}, + }} + } + alias := &yaml.Node{Kind: yaml.AliasNode, Alias: value} + duplicates := CheckEnumForDuplicates([]*yaml.Node{value, alias}) + require.Len(t, duplicates, 1) + require.Same(t, alias, duplicates[0]) +} diff --git a/utils/utils.go b/utils/utils.go index 10624ac8..3aeab601 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -1218,21 +1218,6 @@ func DetectCase(input string) Case { return RegularCase } -// CheckEnumForDuplicates will check an array of nodes to check if there are any duplicate values. -func CheckEnumForDuplicates(seq []*yaml.Node) []*yaml.Node { - var res []*yaml.Node - seen := make(map[string]*yaml.Node) - - for _, enum := range seq { - if seen[enum.Value] != nil { - res = append(res, enum) - continue - } - seen[enum.Value] = enum - } - return res -} - // DetermineWhitespaceLengthBytes determines the minimum leading-space indentation // in the input, working directly on []byte without allocating strings or regex matches. // Matches the semantics of the regex `\n( +)`: only considers lines after a newline.