Skip to content

Commit 9863b2c

Browse files
committed
jsonfmt --sort-keys
1 parent 8ecc109 commit 9863b2c

4 files changed

Lines changed: 98 additions & 6 deletions

File tree

cmd/jsonfmt/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ Default mode is `--fractured` when no mode flag is provided.
77
## Options
88

99
* `--canonical`: the same output as `jq . --sort-keys`
10+
* `--sort-keys`: sort object keys case-insensitively
1011
* `--line`: everything on a single line
1112
* `--trailing-newline`: if a trailing newline should be emitted
1213
* `--eol`: end-of-line character(s) `[ lf | cr' | crlf ]`. Default is `lf`.

cmd/jsonfmt/jsonfmt.go

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"fmt"
88
"io"
99
"os"
10+
"sort"
1011
"strings"
1112

1213
fracturedjson "github.com/FileFormatInfo/go-fractured-json"
@@ -57,7 +58,79 @@ func decodeJSON(input []byte) (any, error) {
5758
return v, nil
5859
}
5960

60-
func formatJSON(input []byte, canonical bool, line bool, fractured bool) (string, error) {
61+
func encodeSortedJSON(v any) ([]byte, error) {
62+
switch t := v.(type) {
63+
case map[string]any:
64+
keys := make([]string, 0, len(t))
65+
for k := range t {
66+
keys = append(keys, k)
67+
}
68+
sort.Slice(keys, func(i, j int) bool {
69+
li := strings.ToLower(keys[i])
70+
lj := strings.ToLower(keys[j])
71+
if li == lj {
72+
return keys[i] < keys[j]
73+
}
74+
return li < lj
75+
})
76+
77+
var buf bytes.Buffer
78+
buf.WriteByte('{')
79+
for i, k := range keys {
80+
if i > 0 {
81+
buf.WriteByte(',')
82+
}
83+
keyJSON, err := json.Marshal(k)
84+
if err != nil {
85+
return nil, err
86+
}
87+
buf.Write(keyJSON)
88+
buf.WriteByte(':')
89+
valJSON, err := encodeSortedJSON(t[k])
90+
if err != nil {
91+
return nil, err
92+
}
93+
buf.Write(valJSON)
94+
}
95+
buf.WriteByte('}')
96+
return buf.Bytes(), nil
97+
case []any:
98+
var buf bytes.Buffer
99+
buf.WriteByte('[')
100+
for i, item := range t {
101+
if i > 0 {
102+
buf.WriteByte(',')
103+
}
104+
itemJSON, err := encodeSortedJSON(item)
105+
if err != nil {
106+
return nil, err
107+
}
108+
buf.Write(itemJSON)
109+
}
110+
buf.WriteByte(']')
111+
return buf.Bytes(), nil
112+
default:
113+
return json.Marshal(t)
114+
}
115+
}
116+
117+
func maybeSortKeys(input []byte, sortKeys bool) ([]byte, error) {
118+
if !sortKeys {
119+
return input, nil
120+
}
121+
decoded, err := decodeJSON(input)
122+
if err != nil {
123+
return nil, err
124+
}
125+
return encodeSortedJSON(decoded)
126+
}
127+
128+
func formatJSON(input []byte, canonical bool, line bool, fractured bool, sortKeys bool) (string, error) {
129+
input, err := maybeSortKeys(input, sortKeys)
130+
if err != nil {
131+
return "", err
132+
}
133+
61134
if fractured {
62135
return fracturedjson.Reformat(string(input))
63136
}
@@ -116,6 +189,7 @@ func main() {
116189
var canonical = pflag.Bool("canonical", false, "Output canonical JSON (same as jq . --sort-keys)")
117190
var line = pflag.Bool("line", false, "Output JSON on a single line")
118191
var fractured = pflag.Bool("fractured", false, "Use fractured JSON formatting")
192+
var sortKeys = pflag.Bool("sort-keys", false, "Sort object keys case-insensitively")
119193
var trailingNewline = pflag.Bool("trailing-newline", false, "Emit a trailing newline")
120194
var eol = pflag.String("eol", "lf", "End-of-line style: lf, cr, or crlf")
121195

@@ -158,7 +232,7 @@ func main() {
158232
os.Exit(1)
159233
}
160234

161-
formatted, err := formatJSON(input, canonicalMode, lineMode, fracturedMode)
235+
formatted, err := formatJSON(input, canonicalMode, lineMode, fracturedMode, *sortKeys)
162236
if err != nil {
163237
fmt.Fprintf(os.Stderr, "ERROR: unable to format JSON: %v\n", err)
164238
os.Exit(1)

cmd/jsonfmt/jsonfmt_test.go

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ func TestApplyEOL(t *testing.T) {
5252
}
5353

5454
func TestFormatJSONLine(t *testing.T) {
55-
out, err := formatJSON([]byte("{\n \"b\": 2,\n \"a\": 1\n}\n"), false, true, false)
55+
out, err := formatJSON([]byte("{\n \"b\": 2,\n \"a\": 1\n}\n"), false, true, false, false)
5656
if err != nil {
5757
t.Fatalf("formatJSON line error: %v", err)
5858
}
@@ -62,7 +62,7 @@ func TestFormatJSONLine(t *testing.T) {
6262
}
6363

6464
func TestFormatJSONCanonicalSortsKeys(t *testing.T) {
65-
out, err := formatJSON([]byte("{\"b\":2,\"a\":1}"), true, false, false)
65+
out, err := formatJSON([]byte("{\"b\":2,\"a\":1}"), true, false, false, false)
6666
if err != nil {
6767
t.Fatalf("formatJSON canonical error: %v", err)
6868
}
@@ -75,7 +75,7 @@ func TestFormatJSONCanonicalSortsKeys(t *testing.T) {
7575
}
7676

7777
func TestFormatJSONExpanded(t *testing.T) {
78-
out, err := formatJSON([]byte("{\"k\":\"v\"}"), false, false, false)
78+
out, err := formatJSON([]byte("{\"k\":\"v\"}"), false, false, false, false)
7979
if err != nil {
8080
t.Fatalf("formatJSON expanded error: %v", err)
8181
}
@@ -85,11 +85,21 @@ func TestFormatJSONExpanded(t *testing.T) {
8585
}
8686

8787
func TestFormatJSONFractured(t *testing.T) {
88-
out, err := formatJSON([]byte("{\"a\":1,\"b\":2}"), false, false, true)
88+
out, err := formatJSON([]byte("{\"a\":1,\"b\":2}"), false, false, true, false)
8989
if err != nil {
9090
t.Fatalf("formatJSON fractured error: %v", err)
9191
}
9292
if !strings.Contains(out, "\"a\"") || !strings.Contains(out, "\"b\"") {
9393
t.Fatalf("fractured output missing keys: %q", out)
9494
}
9595
}
96+
97+
func TestFormatJSONLineSortKeysCaseInsensitive(t *testing.T) {
98+
out, err := formatJSON([]byte("{\"b\":1,\"A\":2,\"a\":3,\"B\":4}"), false, true, false, true)
99+
if err != nil {
100+
t.Fatalf("formatJSON line sort-keys error: %v", err)
101+
}
102+
if out != "{\"A\":2,\"a\":3,\"B\":4,\"b\":1}" {
103+
t.Fatalf("line sort-keys output = %q", out)
104+
}
105+
}

testdata/jsonfmt.txtar

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ stdout '^\{"z":3,"a":1,"b":\{"x":2,"y":1\}\}$'
66
exec jsonfmt --canonical jsonfmt_input_small.json
77
stdout '(?s)^\{\n "a": 1,\n "b": \{\n "x": 2,\n "y": 1\n \},\n "z": 3\n\}$'
88

9+
# explicit case-insensitive sort order
10+
exec jsonfmt --line --sort-keys jsonfmt_input_case.json
11+
stdout '^\{"A":2,"a":3,"B":4,"b":1\}$'
12+
913
# trailing newline and CRLF mode
1014
exec jsonfmt --line --trailing-newline --eol=crlf jsonfmt_input_array.json
1115
stdout '^\[\{"n":2,"s":"b"\},\{"n":1,"s":"a"\}\]\r\n$'
@@ -22,3 +26,6 @@ stderr 'use at most one of --canonical, --line, --fractured'
2226
{"n": 2, "s": "b"},
2327
{"n": 1, "s": "a"}
2428
]
29+
30+
-- jsonfmt_input_case.json --
31+
{"b":1,"A":2,"a":3,"B":4}

0 commit comments

Comments
 (0)