From bc62ef3a5cca0b8da75377fdaa3bd35c30a3a267 Mon Sep 17 00:00:00 2001 From: Buck Evan Date: Fri, 14 Nov 2025 16:41:19 -0600 Subject: [PATCH] --json: preserve XML element order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit XML element order is semantically significant and should be preserved when converting to JSON. Previously, Go's map iteration caused keys to be alphabetically sorted in the JSON output, losing the document order. Changes: - Added OrderedMap type that implements json.Marshaler - Updated NodeToJSON to return OrderedMap instead of map[string]interface{} - Updated nodeToJSONInternal to use OrderedMap - Updated addToResult to work with OrderedMap - Added TestElementOrderInJSON to verify ordering is preserved - Updated TestCDATASupport to work with OrderedMap - Updated formatted3.json test fixture to match element order Example: Input: abc Before: {"root":{"args":"c","msg":"b","name":"a"}} (alphabetical) After: {"root":{"name":"a","msg":"b","args":"c"}} (document order) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- cmd/root_test.go | 12 ++++- internal/utils/jsonutil.go | 85 +++++++++++++++++++++++++----- internal/utils/jsonutil_test.go | 31 +++++++++++ test/data/xml2json/formatted3.json | 4 +- 4 files changed, 116 insertions(+), 16 deletions(-) diff --git a/cmd/root_test.go b/cmd/root_test.go index dccc2c7..773a0cc 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -109,9 +109,17 @@ func TestCDATASupport(t *testing.T) { assert.Nil(t, err) result := utils.NodeToJSON(doc, 10) - expected := map[string]interface{}{"root": "1 & 2"} - assert.Equal(t, expected, result) + // Marshal and unmarshal to compare structure + jsonData, err := json.Marshal(result) + assert.Nil(t, err) + + var resultMap map[string]interface{} + err = json.Unmarshal(jsonData, &resultMap) + assert.Nil(t, err) + + expected := map[string]interface{}{"root": "1 & 2"} + assert.Equal(t, expected, resultMap) } func TestProcessAsJSON(t *testing.T) { diff --git a/internal/utils/jsonutil.go b/internal/utils/jsonutil.go index 50b16ca..acbacd6 100644 --- a/internal/utils/jsonutil.go +++ b/internal/utils/jsonutil.go @@ -1,11 +1,72 @@ package utils import ( + "bytes" + "encoding/json" "strings" "github.com/antchfx/xmlquery" ) +// OrderedMap preserves insertion order for JSON output +type OrderedMap struct { + keys []string + values map[string]interface{} +} + +// NewOrderedMap creates a new OrderedMap +func NewOrderedMap() *OrderedMap { + return &OrderedMap{ + keys: make([]string, 0), + values: make(map[string]interface{}), + } +} + +// Set adds or updates a key-value pair +func (om *OrderedMap) Set(key string, value interface{}) { + if _, exists := om.values[key]; !exists { + om.keys = append(om.keys, key) + } + om.values[key] = value +} + +// Get retrieves a value by key +func (om *OrderedMap) Get(key string) (interface{}, bool) { + val, ok := om.values[key] + return val, ok +} + +// Len returns the number of entries +func (om *OrderedMap) Len() int { + return len(om.keys) +} + +// MarshalJSON implements json.Marshaler to preserve order +func (om *OrderedMap) MarshalJSON() ([]byte, error) { + buf := &bytes.Buffer{} + buf.WriteByte('{') + for i, key := range om.keys { + if i > 0 { + buf.WriteByte(',') + } + // Marshal the key + keyBytes, err := json.Marshal(key) + if err != nil { + return nil, err + } + buf.Write(keyBytes) + buf.WriteByte(':') + // Marshal the value + valBytes, err := json.Marshal(om.values[key]) + if err != nil { + return nil, err + } + buf.Write(valBytes) + } + buf.WriteByte('}') + return buf.Bytes(), nil +} + // NodeToJSON converts an xmlquery.Node to a JSON object. The depth parameter // specifies how many levels of children to include in the result. A depth of 0 means // only the text content of the node is included. A depth of 1 means the node's children @@ -17,7 +78,7 @@ func NodeToJSON(node *xmlquery.Node, depth int) interface{} { switch node.Type { case xmlquery.DocumentNode: - result := make(map[string]interface{}) + result := NewOrderedMap() var textParts []string // Process the next sibling of the document node first (if any) @@ -33,7 +94,7 @@ func NodeToJSON(node *xmlquery.Node, depth int) interface{} { switch child.Type { case xmlquery.ElementNode: childResult := nodeToJSONInternal(child, depth) - result[child.Data] = childResult + result.Set(child.Data, childResult) case xmlquery.TextNode, xmlquery.CharDataNode: text := strings.TrimSpace(child.Data) if text != "" { @@ -43,7 +104,7 @@ func NodeToJSON(node *xmlquery.Node, depth int) interface{} { } if len(textParts) > 0 { - result["#text"] = strings.Join(textParts, "\n") + result.Set("#text", strings.Join(textParts, "\n")) } return result @@ -63,9 +124,9 @@ func nodeToJSONInternal(node *xmlquery.Node, depth int) interface{} { return getTextContent(node) } - result := make(map[string]interface{}) + result := NewOrderedMap() for _, attr := range node.Attr { - result["@"+attr.Name.Local] = attr.Value + result.Set("@"+attr.Name.Local, attr.Value) } var textParts []string @@ -83,10 +144,10 @@ func nodeToJSONInternal(node *xmlquery.Node, depth int) interface{} { } if len(textParts) > 0 { - if len(result) == 0 { + if result.Len() == 0 { return strings.Join(textParts, "\n") } - result["#text"] = strings.Join(textParts, "\n") + result.Set("#text", strings.Join(textParts, "\n")) } return result @@ -108,18 +169,18 @@ func getTextContent(node *xmlquery.Node) string { return strings.Join(parts, "\n") } -func addToResult(result map[string]interface{}, key string, value interface{}) { +func addToResult(result *OrderedMap, key string, value interface{}) { if key == "" { return } - if existing, ok := result[key]; ok { + if existing, ok := result.Get(key); ok { switch existing := existing.(type) { case []interface{}: - result[key] = append(existing, value) + result.Set(key, append(existing, value)) default: - result[key] = []interface{}{existing, value} + result.Set(key, []interface{}{existing, value}) } } else { - result[key] = value + result.Set(key, value) } } diff --git a/internal/utils/jsonutil_test.go b/internal/utils/jsonutil_test.go index 22591a4..52d004a 100644 --- a/internal/utils/jsonutil_test.go +++ b/internal/utils/jsonutil_test.go @@ -45,3 +45,34 @@ func TestXmlToJSON(t *testing.T) { assert.Equal(t, expectedJson, output.String()) } } + +func TestElementOrderInJSON(t *testing.T) { + // Test that element order is preserved in JSON output + xmlInput := ` + /status + status + + ` + + node, err := xmlquery.Parse(strings.NewReader(xmlInput)) + assert.NoError(t, err) + + result := NodeToJSON(node, -1) + assert.NotNil(t, result) + + // Marshal to JSON and check order + jsonData, err := json.Marshal(result) + assert.NoError(t, err) + + jsonStr := string(jsonData) + t.Logf("JSON output: %s", jsonStr) + + // Find positions of each key in the JSON string + namePos := strings.Index(jsonStr, "command-name") + messagePos := strings.Index(jsonStr, "command-message") + argsPos := strings.Index(jsonStr, "command-args") + + // Check that they appear in the original order + assert.True(t, namePos < messagePos, "command-name should appear before command-message") + assert.True(t, messagePos < argsPos, "command-message should appear before command-args") +} diff --git a/test/data/xml2json/formatted3.json b/test/data/xml2json/formatted3.json index 9a72296..66bc67d 100644 --- a/test/data/xml2json/formatted3.json +++ b/test/data/xml2json/formatted3.json @@ -1,6 +1,6 @@ { "root": { - "#text": "text\nmore text", - "child": "value" + "child": "value", + "#text": "text\nmore text" } }