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" } }