From e7882f9e105ac2a345ed21764bc876b75cdb0635 Mon Sep 17 00:00:00 2001 From: Scott Myron Date: Wed, 24 Jun 2026 21:29:24 -0500 Subject: [PATCH 1/8] Add the sort_keys option to the generator. --- ext/json/ext/generator/generator.c | 11 ++++++++- java/src/json/ext/Generator.java | 5 ++++ java/src/json/ext/GeneratorState.java | 17 +++++++++++++ lib/json.rb | 3 ++- test/json/json_generator_test.rb | 35 +++++++++++++++++++++++++++ 5 files changed, 69 insertions(+), 2 deletions(-) diff --git a/ext/json/ext/generator/generator.c b/ext/json/ext/generator/generator.c index 08c1fd0d..479b426b 100644 --- a/ext/json/ext/generator/generator.c +++ b/ext/json/ext/generator/generator.c @@ -34,13 +34,14 @@ typedef struct JSON_Generator_StateStruct { bool ascii_only; bool script_safe; bool strict; + bool sort_keys; } JSON_Generator_State; static VALUE mJSON, cState, cFragment, eGeneratorError, eNestingError, Encoding_UTF_8; static ID i_to_s, i_to_json, i_new, i_encode; static VALUE sym_indent, sym_space, sym_space_before, sym_object_nl, sym_array_nl, sym_max_nesting, sym_allow_nan, sym_allow_duplicate_key, - sym_ascii_only, sym_depth, sym_buffer_initial_length, sym_script_safe, sym_escape_slash, sym_strict, sym_as_json; + sym_ascii_only, sym_depth, sym_buffer_initial_length, sym_script_safe, sym_escape_slash, sym_strict, sym_as_json, sym_sort_keys; #define GET_STATE_TO(self, state) \ @@ -1050,6 +1051,12 @@ static inline long increase_depth(struct generate_json_data *data) static void generate_json_object(FBuffer *buffer, struct generate_json_data *data, VALUE obj) { + if (RB_UNLIKELY(data->state->sort_keys)) { + VALUE sorted_array = rb_funcall(obj, rb_intern("sort"), 0); + VALUE sorted_hash = rb_funcall(sorted_array, rb_intern("to_h"), 0); + obj = sorted_hash; + } + long depth = increase_depth(data); if (RHASH_SIZE(obj) == 0) { @@ -1832,6 +1839,7 @@ static int configure_state_i(VALUE key, VALUE val, VALUE _arg) state->as_json_single_arg = proc && rb_proc_arity(proc) == 1; state_write_value(data, &state->as_json, proc); } + else if (key == sym_sort_keys) { state->sort_keys = RTEST(val); } return ST_CONTINUE; } @@ -1986,6 +1994,7 @@ void Init_generator(void) sym_strict = ID2SYM(rb_intern("strict")); sym_as_json = ID2SYM(rb_intern("as_json")); sym_allow_duplicate_key = ID2SYM(rb_intern("allow_duplicate_key")); + sym_sort_keys = ID2SYM(rb_intern("sort_keys")); usascii_encindex = rb_usascii_encindex(); utf8_encindex = rb_utf8_encindex(); diff --git a/java/src/json/ext/Generator.java b/java/src/json/ext/Generator.java index ea9e6d08..32192e64 100644 --- a/java/src/json/ext/Generator.java +++ b/java/src/json/ext/Generator.java @@ -573,6 +573,11 @@ static void generateHash(ThreadContext context, Session session, RubyHash object return; } + if (state.sortKeys()) { + RubyArray sortedPairs = (RubyArray) object.callMethod(context, "sort"); + object = (RubyHash) sortedPairs.callMethod(context, "to_h"); + } + final ByteList objectNl = state.getObjectNl(); byte[] objectNLBytes = objectNl.unsafeBytes(); final byte[] indent = Utils.repeat(state.getIndent(), depth); diff --git a/java/src/json/ext/GeneratorState.java b/java/src/json/ext/GeneratorState.java index 11d55559..185be552 100644 --- a/java/src/json/ext/GeneratorState.java +++ b/java/src/json/ext/GeneratorState.java @@ -104,6 +104,12 @@ public class GeneratorState extends RubyObject { private int bufferInitialLength = DEFAULT_BUFFER_INITIAL_LENGTH; static final int DEFAULT_BUFFER_INITIAL_LENGTH = 1024; + /** + * If set to true object keys will be sorted when generating JSON. + */ + private boolean sortKeys = DEFAULT_SORT_KEYS; + static final boolean DEFAULT_SORT_KEYS = false; + /** * The current depth (inside a #to_json call) */ @@ -222,6 +228,7 @@ public IRubyObject initialize_copy(ThreadContext context, IRubyObject vOrig) { this.allowDuplicateKey = orig.allowDuplicateKey; this.deprecateDuplicateKey = orig.deprecateDuplicateKey; + this.sortKeys = orig.sortKeys; return this; } @@ -431,6 +438,13 @@ public boolean strict() { return strict; } + /** + * Returns true if object keys should be sorted. + */ + public boolean sortKeys() { + return sortKeys; + } + @JRubyMethod(name={"strict","strict?"}) public RubyBoolean strict_get(ThreadContext context) { return RubyBoolean.newBoolean(context, strict); @@ -568,6 +582,9 @@ public IRubyObject _configure(ThreadContext context, IRubyObject vOpts) { this.allowDuplicateKey = opts.getBool("allow_duplicate_key", false); this.deprecateDuplicateKey = false; } + + sortKeys = opts.getBool("sort_keys", DEFAULT_SORT_KEYS); + return this; } diff --git a/lib/json.rb b/lib/json.rb index f8dc4ccc..50fd1971 100644 --- a/lib/json.rb +++ b/lib/json.rb @@ -408,7 +408,6 @@ # to be inserted after each \JSON object; defaults to the empty \String, ''. # - Option +indent+ (\String) specifies the string (usually spaces) to be # used for indentation; defaults to the empty \String, ''; -# defaults to the empty \String, ''; # has no effect unless options +array_nl+ or +object_nl+ specify newlines. # - Option +space+ (\String) specifies a string (usually a space) to be # inserted after the colon in each \JSON object's pair; @@ -416,6 +415,8 @@ # - Option +space_before+ (\String) specifies a string (usually a space) to be # inserted before the colon in each \JSON object's pair; # defaults to the empty \String, ''. +# - Option +sort_keys+ (boolean) specifies whether the keys of a hash are to be +# sorted when generating the output; defaults to false. # # In this example, +obj+ is used first to generate the shortest # \JSON data (no whitespace), then again with all formatting options diff --git a/test/json/json_generator_test.rb b/test/json/json_generator_test.rb index 753ee0fb..51b69bc4 100755 --- a/test/json/json_generator_test.rb +++ b/test/json/json_generator_test.rb @@ -190,6 +190,41 @@ def test_generate_pretty_custom JSON end + def test_generate_sort_keys + json = generate({2=>"a", 1=>"b", 3=>"c"}, sort_keys: true) + assert_equal('{"1":"b","2":"a","3":"c"}', json) + + json = generate({2=>"a", 1=>"b", 3=>"c"}, sort_keys: false) + assert_equal('{"2":"a","1":"b","3":"c"}', json) + + json = pretty_generate({2=>"a", 1=>"b", 3=>"c"}, sort_keys: true) + assert_equal(<<~'JSON'.chomp, json) + { + "1": "b", + "2": "a", + "3": "c" + } + JSON + + json = pretty_generate({2=>"a", 1=>"b", 3=>"c"}, sort_keys: false) + assert_equal(<<~'JSON'.chomp, json) + { + "2": "a", + "1": "b", + "3": "c" + } + JSON + + json = pretty_generate({2=>"a", 1=>"b", 3=>"c"}) + assert_equal(<<~'JSON'.chomp, json) + { + "2": "a", + "1": "b", + "3": "c" + } + JSON + end + def test_generate_custom state = State.new(:space_before => " ", :space => " ", :indent => "", :object_nl => "\n", :array_nl => "") json = generate({1=>{2=>3,4=>[5,6]}}, state) From be438d090408b60984952436b9a70519b3ea5389 Mon Sep 17 00:00:00 2001 From: Scott Myron Date: Thu, 25 Jun 2026 08:06:36 -0500 Subject: [PATCH 2/8] Add sort_keys to truffleruby. --- lib/json/truffle_ruby/generator.rb | 10 +++++++++- test/json/json_generator_test.rb | 2 ++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/lib/json/truffle_ruby/generator.rb b/lib/json/truffle_ruby/generator.rb index 4fb1885e..5ab2bf66 100644 --- a/lib/json/truffle_ruby/generator.rb +++ b/lib/json/truffle_ruby/generator.rb @@ -164,6 +164,7 @@ def initialize(opts = nil) @script_safe = false @strict = false @max_nesting = 100 + @sort_keys = false configure(opts) if opts end @@ -199,6 +200,10 @@ def initialize(opts = nil) # supported by the JSON spec will raise a JSON::GeneratorError attr_accessor :strict + # If this attribute is set to true, object keys will be sorted in + # the generated JSON. + attr_accessor :sort_keys + # :stopdoc: attr_reader :buffer_initial_length @@ -285,6 +290,7 @@ def configure(opts) @allow_nan = !!opts[:allow_nan] if opts.key?(:allow_nan) @as_json = opts[:as_json].to_proc if opts[:as_json] @ascii_only = opts[:ascii_only] if opts.key?(:ascii_only) + @sort_keys = opts[:sort_keys] if opts.key?(:sort_keys) @depth = opts[:depth] || 0 @buffer_initial_length ||= opts[:buffer_initial_length] @@ -349,9 +355,11 @@ def generate(obj, anIO = nil) depth = @depth if @indent.empty? and @space.empty? and @space_before.empty? and @object_nl.empty? and @array_nl.empty? and - !@ascii_only and !@script_safe and @max_nesting == 0 and (!@strict || Symbol === obj) + !@ascii_only and !@script_safe and @max_nesting == 0 and (!@strict || Symbol === obj) and !@sort_keys result = generate_json(obj, ''.dup) else + obj = obj.sort.to_h if @sort_keys + result = obj.to_json(self) end JSON::TruffleRuby::Generator.valid_utf8?(result) or raise GeneratorError.new( diff --git a/test/json/json_generator_test.rb b/test/json/json_generator_test.rb index 51b69bc4..c9e607a3 100755 --- a/test/json/json_generator_test.rb +++ b/test/json/json_generator_test.rb @@ -324,6 +324,7 @@ def test_state_defaults :object_nl => "", :space => "", :space_before => "", + :sort_keys => false, }.sort_by { |n,| n.to_s }, state.to_h.sort_by { |n,| n.to_s }) state = JSON::State.new(allow_duplicate_key: true) @@ -342,6 +343,7 @@ def test_state_defaults :object_nl => "", :space => "", :space_before => "", + :sort_keys => false, }.sort_by { |n,| n.to_s }, state.to_h.sort_by { |n,| n.to_s }) end From 54482b9fdd4ea1a085505abcae20769332941e41 Mon Sep 17 00:00:00 2001 From: Scott Myron Date: Thu, 25 Jun 2026 08:15:19 -0500 Subject: [PATCH 3/8] Add sort_keys to the JSON::Ext::Generator::State. --- ext/json/ext/generator/generator.c | 28 ++++++++++++++++++++++++++++ lib/json/ext/generator/state.rb | 1 + 2 files changed, 29 insertions(+) diff --git a/ext/json/ext/generator/generator.c b/ext/json/ext/generator/generator.c index 479b426b..af79e9fa 100644 --- a/ext/json/ext/generator/generator.c +++ b/ext/json/ext/generator/generator.c @@ -1383,6 +1383,7 @@ static VALUE cState_init_copy(VALUE obj, VALUE orig) RB_OBJ_WRITTEN(obj, Qundef, objState->object_nl); RB_OBJ_WRITTEN(obj, Qundef, objState->array_nl); RB_OBJ_WRITTEN(obj, Qundef, objState->as_json); + RB_OBJ_WRITTEN(obj, Qundef, objState->sort_keys); return obj; } @@ -1729,6 +1730,31 @@ static VALUE cState_ascii_only_set(VALUE self, VALUE enable) return Qnil; } +/* + * call-seq: sort_keys? + * + * Returns true, if object keys should be sorted in the generated JSON. Otherwise + * returns false. + */ +static VALUE cState_sort_keys_p(VALUE self) +{ + GET_STATE(self); + return state->sort_keys ? Qtrue : Qfalse; +} + +/* + * call-seq: sort_keys=(enable) + * + * This sets whether object keys should be sorted in the generated JSON. + */ +static VALUE cState_sort_keys_set(VALUE self, VALUE enable) +{ + rb_check_frozen(self); + GET_STATE(self); + state->sort_keys = RTEST(enable); + return Qnil; +} + static VALUE cState_allow_duplicate_key_p(VALUE self) { GET_STATE(self); @@ -1965,6 +1991,8 @@ void Init_generator(void) rb_define_method(cState, "buffer_initial_length=", cState_buffer_initial_length_set, 1); rb_define_method(cState, "generate", cState_generate, -1); rb_define_method(cState, "_generate_no_fallback", cState_generate_no_fallback, -1); + rb_define_method(cState, "sort_keys?", cState_sort_keys_p, 0); + rb_define_method(cState, "sort_keys=", cState_sort_keys_set, 1); rb_define_private_method(cState, "allow_duplicate_key?", cState_allow_duplicate_key_p, 0); diff --git a/lib/json/ext/generator/state.rb b/lib/json/ext/generator/state.rb index e4f425af..89cd9dca 100644 --- a/lib/json/ext/generator/state.rb +++ b/lib/json/ext/generator/state.rb @@ -54,6 +54,7 @@ def to_h strict: strict?, depth: depth, buffer_initial_length: buffer_initial_length, + sort_keys: sort_keys? } allow_duplicate_key = allow_duplicate_key? From 012a5a40ba296e30f51d8c251616260375ee6c57 Mon Sep 17 00:00:00 2001 From: Scott Myron Date: Thu, 25 Jun 2026 08:19:47 -0500 Subject: [PATCH 4/8] Add sort_keys to the GeneratorState.java --- java/src/json/ext/GeneratorState.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/java/src/json/ext/GeneratorState.java b/java/src/json/ext/GeneratorState.java index 185be552..57588fdd 100644 --- a/java/src/json/ext/GeneratorState.java +++ b/java/src/json/ext/GeneratorState.java @@ -488,6 +488,11 @@ public IRubyObject buffer_initial_length_set(IRubyObject buffer_initial_length) return buffer_initial_length; } + @JRubyMethod(name="sort_keys?") + public RubyBoolean sort_keys_p(ThreadContext context) { + return RubyBoolean.newBoolean(context, sortKeys); + } + public int getDepth() { return depth; } @@ -613,6 +618,7 @@ public RubyHash to_h(ThreadContext context) { result.op_aset(context, runtime.newSymbol("strict"), strict_get(context)); result.op_aset(context, runtime.newSymbol("depth"), depth_get(context)); result.op_aset(context, runtime.newSymbol("buffer_initial_length"), buffer_initial_length_get(context)); + result.op_aset(context, runtime.newSymbol("sort_keys?"), ascii_only_p(context)); if (this.allowDuplicateKey) { if (!this.deprecateDuplicateKey) { From 3f63a2f7986e496517acca9b1ff7ddfc13371d9a Mon Sep 17 00:00:00 2001 From: Scott Myron Date: Thu, 25 Jun 2026 21:31:42 -0500 Subject: [PATCH 5/8] Let the sort_keys option be a proc or a boolean. --- ext/json/ext/generator/generator.c | 45 ++++++++++++++++++++---------- lib/json/ext/generator/state.rb | 2 +- test/json/json_generator_test.rb | 14 ++++++++++ 3 files changed, 45 insertions(+), 16 deletions(-) diff --git a/ext/json/ext/generator/generator.c b/ext/json/ext/generator/generator.c index af79e9fa..a8332c4c 100644 --- a/ext/json/ext/generator/generator.c +++ b/ext/json/ext/generator/generator.c @@ -34,7 +34,7 @@ typedef struct JSON_Generator_StateStruct { bool ascii_only; bool script_safe; bool strict; - bool sort_keys; + VALUE sort_keys; } JSON_Generator_State; static VALUE mJSON, cState, cFragment, eGeneratorError, eNestingError, Encoding_UTF_8; @@ -710,6 +710,7 @@ static void State_mark(void *ptr) rb_gc_mark_movable(state->object_nl); rb_gc_mark_movable(state->array_nl); rb_gc_mark_movable(state->as_json); + rb_gc_mark_movable(state->sort_keys); } static void State_compact(void *ptr) @@ -721,6 +722,7 @@ static void State_compact(void *ptr) state->object_nl = rb_gc_location(state->object_nl); state->array_nl = rb_gc_location(state->array_nl); state->as_json = rb_gc_location(state->as_json); + state->sort_keys = rb_gc_location(state->sort_keys); } static size_t State_memsize(const void *ptr) @@ -770,6 +772,7 @@ static void vstate_spill(struct generate_json_data *data) RB_OBJ_WRITTEN(vstate, Qundef, state->object_nl); RB_OBJ_WRITTEN(vstate, Qundef, state->array_nl); RB_OBJ_WRITTEN(vstate, Qundef, state->as_json); + RB_OBJ_WRITTEN(vstate, Qundef, state->sort_keys); } static inline VALUE json_call_to_json(struct generate_json_data *data, VALUE obj) @@ -1051,10 +1054,15 @@ static inline long increase_depth(struct generate_json_data *data) static void generate_json_object(FBuffer *buffer, struct generate_json_data *data, VALUE obj) { - if (RB_UNLIKELY(data->state->sort_keys)) { - VALUE sorted_array = rb_funcall(obj, rb_intern("sort"), 0); - VALUE sorted_hash = rb_funcall(sorted_array, rb_intern("to_h"), 0); - obj = sorted_hash; + if (RB_UNLIKELY(RTEST(data->state->sort_keys))) { + VALUE sort_keys = data->state->sort_keys; + VALUE sorted_array; + if (rb_obj_is_proc(sort_keys)) { + sorted_array = rb_funcall_with_block(obj, rb_intern("sort"), 0, NULL, sort_keys); + } else { + sorted_array = rb_funcall(obj, rb_intern("sort"), 0); + } + obj = rb_funcall(sorted_array, rb_intern("to_h"), 0); } long depth = increase_depth(data); @@ -1731,27 +1739,31 @@ static VALUE cState_ascii_only_set(VALUE self, VALUE enable) } /* - * call-seq: sort_keys? + * call-seq: sort_keys * - * Returns true, if object keys should be sorted in the generated JSON. Otherwise - * returns false. + * Get the value of sort_keys. */ static VALUE cState_sort_keys_p(VALUE self) { GET_STATE(self); - return state->sort_keys ? Qtrue : Qfalse; + return state->sort_keys; } /* - * call-seq: sort_keys=(enable) + * call-seq: sort_keys=(value) * - * This sets whether object keys should be sorted in the generated JSON. + * value is a boolean or proc. If the value is the boolean true, + * object keys will be sorted lexicographically in ascending order. + * + * If the value is a proc, it must be a comparator. It will receive two + * [key, value] pairs to allow for arbitrary sorting. */ -static VALUE cState_sort_keys_set(VALUE self, VALUE enable) +static VALUE cState_sort_keys_set(VALUE self, VALUE value) { rb_check_frozen(self); GET_STATE(self); - state->sort_keys = RTEST(enable); + VALUE sort_keys = rb_obj_is_proc(value) ? value : (RTEST(value) ? Qtrue : Qfalse); + RB_OBJ_WRITE(self, &state->sort_keys, sort_keys); return Qnil; } @@ -1865,7 +1877,10 @@ static int configure_state_i(VALUE key, VALUE val, VALUE _arg) state->as_json_single_arg = proc && rb_proc_arity(proc) == 1; state_write_value(data, &state->as_json, proc); } - else if (key == sym_sort_keys) { state->sort_keys = RTEST(val); } + else if (key == sym_sort_keys) { + VALUE sort_keys = rb_obj_is_proc(val) ? val : (RTEST(val) ? Qtrue : Qfalse); + state_write_value(data, &state->sort_keys, sort_keys); + } return ST_CONTINUE; } @@ -1991,7 +2006,7 @@ void Init_generator(void) rb_define_method(cState, "buffer_initial_length=", cState_buffer_initial_length_set, 1); rb_define_method(cState, "generate", cState_generate, -1); rb_define_method(cState, "_generate_no_fallback", cState_generate_no_fallback, -1); - rb_define_method(cState, "sort_keys?", cState_sort_keys_p, 0); + rb_define_method(cState, "sort_keys", cState_sort_keys_p, 0); rb_define_method(cState, "sort_keys=", cState_sort_keys_set, 1); rb_define_private_method(cState, "allow_duplicate_key?", cState_allow_duplicate_key_p, 0); diff --git a/lib/json/ext/generator/state.rb b/lib/json/ext/generator/state.rb index 89cd9dca..3c1d2fb9 100644 --- a/lib/json/ext/generator/state.rb +++ b/lib/json/ext/generator/state.rb @@ -54,7 +54,7 @@ def to_h strict: strict?, depth: depth, buffer_initial_length: buffer_initial_length, - sort_keys: sort_keys? + sort_keys: sort_keys } allow_duplicate_key = allow_duplicate_key? diff --git a/test/json/json_generator_test.rb b/test/json/json_generator_test.rb index c9e607a3..c011aee7 100755 --- a/test/json/json_generator_test.rb +++ b/test/json/json_generator_test.rb @@ -225,6 +225,20 @@ def test_generate_sort_keys JSON end + def test_generate_sort_keys_with_proc + reverse = ->(a, b) { b[0] <=> a[0] } + json = generate({2=>"a", 1=>"b", 3=>"c"}, sort_keys: reverse) + assert_equal('{"3":"c","2":"a","1":"b"}', json) + + by_value = ->(a, b) { a[1] <=> b[1] } + json = generate({2=>"c", 1=>"a", 3=>"b"}, sort_keys: by_value) + assert_equal('{"1":"a","3":"b","2":"c"}', json) + + state = State.new(sort_keys: reverse) + assert_same reverse, state.to_h[:sort_keys] + assert_equal('{"3":"c","2":"a","1":"b"}', state.generate({2=>"a", 1=>"b", 3=>"c"})) + end + def test_generate_custom state = State.new(:space_before => " ", :space => " ", :indent => "", :object_nl => "\n", :array_nl => "") json = generate({1=>{2=>3,4=>[5,6]}}, state) From 3e58f98a95f69602953ebb28c17be59f7586ff5a Mon Sep 17 00:00:00 2001 From: Scott Myron Date: Thu, 25 Jun 2026 21:41:11 -0500 Subject: [PATCH 6/8] Support sort_keys being a proc in the java extension. --- java/src/json/ext/Generator.java | 6 +++- java/src/json/ext/GeneratorState.java | 42 ++++++++++++++++++++------- 2 files changed, 37 insertions(+), 11 deletions(-) diff --git a/java/src/json/ext/Generator.java b/java/src/json/ext/Generator.java index 32192e64..b918895d 100644 --- a/java/src/json/ext/Generator.java +++ b/java/src/json/ext/Generator.java @@ -17,6 +17,7 @@ import org.jruby.RubyFloat; import org.jruby.RubyHash; import org.jruby.RubyIO; +import org.jruby.RubyProc; import org.jruby.RubyString; import org.jruby.RubySymbol; import org.jruby.runtime.Helpers; @@ -574,7 +575,10 @@ static void generateHash(ThreadContext context, Session session, RubyHash object } if (state.sortKeys()) { - RubyArray sortedPairs = (RubyArray) object.callMethod(context, "sort"); + RubyProc comparator = state.getSortKeysProc(); + RubyArray sortedPairs = comparator != null + ? (RubyArray) Helpers.invoke(context, object, "sort", comparator.getBlock()) + : (RubyArray) object.callMethod(context, "sort"); object = (RubyHash) sortedPairs.callMethod(context, "to_h"); } diff --git a/java/src/json/ext/GeneratorState.java b/java/src/json/ext/GeneratorState.java index 57588fdd..a51f8ff3 100644 --- a/java/src/json/ext/GeneratorState.java +++ b/java/src/json/ext/GeneratorState.java @@ -105,10 +105,11 @@ public class GeneratorState extends RubyObject { static final int DEFAULT_BUFFER_INITIAL_LENGTH = 1024; /** - * If set to true object keys will be sorted when generating JSON. + * Controls key sorting when generating JSON. null means keys + * are emitted in insertion order; a true value sorts keys lexicographically; + * a {@link RubyProc} is used as a comparator receiving two [key, value] pairs. */ - private boolean sortKeys = DEFAULT_SORT_KEYS; - static final boolean DEFAULT_SORT_KEYS = false; + private IRubyObject sortKeys; /** * The current depth (inside a #to_json call) @@ -439,10 +440,24 @@ public boolean strict() { } /** - * Returns true if object keys should be sorted. + * Returns true if object keys should be sorted (either lexicographically + * or with a custom comparator). */ public boolean sortKeys() { - return sortKeys; + return sortKeys != null && sortKeys.isTrue(); + } + + /** + * Returns the comparator proc used to sort keys, or null if + * keys should be sorted lexicographically. + */ + public RubyProc getSortKeysProc() { + return sortKeys instanceof RubyProc ? (RubyProc) sortKeys : null; + } + + private static IRubyObject normalizeSortKeys(ThreadContext context, IRubyObject value) { + if (value instanceof RubyProc) return value; + return (value != null && value.isTrue()) ? context.getRuntime().getTrue() : null; } @JRubyMethod(name={"strict","strict?"}) @@ -488,9 +503,16 @@ public IRubyObject buffer_initial_length_set(IRubyObject buffer_initial_length) return buffer_initial_length; } - @JRubyMethod(name="sort_keys?") - public RubyBoolean sort_keys_p(ThreadContext context) { - return RubyBoolean.newBoolean(context, sortKeys); + @JRubyMethod(name="sort_keys") + public IRubyObject sort_keys_get(ThreadContext context) { + return sortKeys == null ? context.getRuntime().getFalse() : sortKeys; + } + + @JRubyMethod(name="sort_keys=") + public IRubyObject sort_keys_set(ThreadContext context, IRubyObject sortKeys) { + checkFrozen(); + this.sortKeys = normalizeSortKeys(context, sortKeys); + return sortKeys; } public int getDepth() { @@ -588,7 +610,7 @@ public IRubyObject _configure(ThreadContext context, IRubyObject vOpts) { this.deprecateDuplicateKey = false; } - sortKeys = opts.getBool("sort_keys", DEFAULT_SORT_KEYS); + sortKeys = normalizeSortKeys(context, opts.get("sort_keys")); return this; } @@ -618,7 +640,7 @@ public RubyHash to_h(ThreadContext context) { result.op_aset(context, runtime.newSymbol("strict"), strict_get(context)); result.op_aset(context, runtime.newSymbol("depth"), depth_get(context)); result.op_aset(context, runtime.newSymbol("buffer_initial_length"), buffer_initial_length_get(context)); - result.op_aset(context, runtime.newSymbol("sort_keys?"), ascii_only_p(context)); + result.op_aset(context, runtime.newSymbol("sort_keys"), sort_keys_get(context)); if (this.allowDuplicateKey) { if (!this.deprecateDuplicateKey) { From c2bb4832f84a3a850d41104d36b53fe85dd63344 Mon Sep 17 00:00:00 2001 From: Scott Myron Date: Thu, 25 Jun 2026 21:47:34 -0500 Subject: [PATCH 7/8] Support sort_keys being a proc in truffleruby. --- lib/json/truffle_ruby/generator.rb | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/lib/json/truffle_ruby/generator.rb b/lib/json/truffle_ruby/generator.rb index 5ab2bf66..1d5ff102 100644 --- a/lib/json/truffle_ruby/generator.rb +++ b/lib/json/truffle_ruby/generator.rb @@ -200,8 +200,9 @@ def initialize(opts = nil) # supported by the JSON spec will raise a JSON::GeneratorError attr_accessor :strict - # If this attribute is set to true, object keys will be sorted in - # the generated JSON. + # Controls key sorting in the generated JSON. If set to +true+, object + # keys are sorted by key lexicographically. If set to a Proc, it is + # used as a comparator receiving two [key, value] pairs. attr_accessor :sort_keys # :stopdoc: @@ -358,8 +359,10 @@ def generate(obj, anIO = nil) !@ascii_only and !@script_safe and @max_nesting == 0 and (!@strict || Symbol === obj) and !@sort_keys result = generate_json(obj, ''.dup) else - obj = obj.sort.to_h if @sort_keys - + if @sort_keys + obj = (Proc === @sort_keys ? obj.sort(&@sort_keys) : obj.sort).to_h + end + result = obj.to_json(self) end JSON::TruffleRuby::Generator.valid_utf8?(result) or raise GeneratorError.new( From 8ce6dc4bd38dce561fafff06b0e305e861783441 Mon Sep 17 00:00:00 2001 From: Scott Myron Date: Thu, 25 Jun 2026 21:49:29 -0500 Subject: [PATCH 8/8] Update the documentation of sort_keys. --- lib/json.rb | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/json.rb b/lib/json.rb index 50fd1971..a5c065f0 100644 --- a/lib/json.rb +++ b/lib/json.rb @@ -415,8 +415,11 @@ # - Option +space_before+ (\String) specifies a string (usually a space) to be # inserted before the colon in each \JSON object's pair; # defaults to the empty \String, ''. -# - Option +sort_keys+ (boolean) specifies whether the keys of a hash are to be -# sorted when generating the output; defaults to false. +# - Option +sort_keys+ (boolean or \Proc) controls whether and how the keys of a +# hash are sorted when generating the output; defaults to false. +# When +true+, keys are sorted lexicographically. When a \Proc, +# it is used as a comparator and receives two [key, value] pairs, +# allowing for arbitrary sort orders. # # In this example, +obj+ is used first to generate the shortest # \JSON data (no whitespace), then again with all formatting options