multi_json 1.19.1 → 1.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0d32c6ecf7a376a7280adac3692fd9b256d0934b1cff4724955b815a76a8b8d1
4
- data.tar.gz: f273e11d29ac7ec97954410f1b9af76d4e3f43a299c0507583371e15d01af0cd
3
+ metadata.gz: fbba8097011550d30c5962bdd828a95b431ab54cc9a6d831844e78c78f2ddc74
4
+ data.tar.gz: 767ba488de4f71c7503d69126130b88d904f656ec12cd2b0c039a1d66c39e594
5
5
  SHA512:
6
- metadata.gz: 01ae1a673f2ccb2bbe25ee7e960f1e2984859495c50bdeeab4e1c5aa0315915f2b8f815b88a23bd5de781fed52d35b0ab8f0986a1aad8d74a3e055d554347107
7
- data.tar.gz: 69f7aa6acd371a2852f12a616d0501f33acd85c01ac0204669ef4c30ebb058b7399cccb4b0fa830c41ec0a4cdd2ad8273ca134d9915b59dc53c4f5c4f6bf664c
6
+ metadata.gz: a0e46b45475da5fa524fdfc00c02b10bf9a3c677d3702bf81fa1bfaef278dc219a7720f082cc1a8edbc5fe07e2aa83e92a87ec2a7057a42edabd90a211fde34e
7
+ data.tar.gz: 650953b9efc5ba89c84cb751c206972e8d95923c5b466558a97c340666a31b71645943e83b0186076cbf9a843da3dd306c3fdb654911873797bedf89e763e7d7
data/CHANGELOG.md CHANGED
@@ -1,5 +1,89 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.20.1
4
+ * Fix `JsonGem#load` raising `ParseError` on ASCII-8BIT strings that contain valid UTF-8 bytes ([#64](https://github.com/sferik/multi_json/issues/64)). Ruby HTTP clients tag response bodies as ASCII-8BIT by default; the 1.20.0 change from `force_encoding` to `encode` broke the dominant real-world case by trying to transcode each byte individually. Switch back to `force_encoding` followed by a `valid_encoding?` guard so genuinely invalid byte sequences still surface as `ParseError`.
5
+ * Validate custom adapters during `MultiJson.use` and `MultiJson.load`/`dump` with an `:adapter` option, raising `MultiJson::AdapterError` immediately if the adapter does not respond to `.load`, `.dump`, or define a `ParseError` constant.
6
+ * Validate `OptionsCache.max_cache_size=` to reject `nil`, zero, negative, and non-integer values with a clear `ArgumentError`.
7
+
8
+ ## 1.20.0
9
+ * Drop the `UnannotatedEmptyCollection` Steep diagnostic override by inline-annotating `Options::EMPTY_OPTIONS` with `#: options` and routing `MultiJson.current_adapter`'s `||=` fallback through that constant. Also enable rubocop's `Layout/LeadingCommentSpace` `AllowSteepAnnotation` / `AllowRBSInlineAnnotation` so future inline `#:` casts don't need a per-line disable.
10
+ * Hoist the `block_given?` check in `MutexStore#fetch` outside `@mutex.synchronize` so the no-block read path runs the check once per call instead of inside the critical section.
11
+ * Short-circuit `Adapter.blank?` on inputs that start with `{` or `[` so the dominant JSON object and array load paths skip the blank-pattern regex entirely.
12
+ * Drop the `(...)` argument forwarding in `MultiJson::Options#load_options`, `dump_options`, `resolve_options`, and `invoke_callable` in favor of explicit `*args` so the signatures document that they forward positional arguments to a callable provider and nothing else.
13
+ * Collapse the five `MultiJson::Concurrency.synchronize_*` wrapper methods into a single `Concurrency.synchronize(name, &block)` keyed by symbol, with the mutex catalog in a `MUTEXES` hash. The synchronization surface is now one method instead of five and adding a new mutex is a one-line entry.
14
+ * Walk the superclass chain manually in `Adapter.walk_default_options` instead of allocating an `ancestors` array on every call. The dump/load hot path no longer pays for an iteration over the (mostly module) ancestor list when looking up an adapter's defaults.
15
+ * Add a `# frozen_string_literal: true` magic comment to every Ruby file in `lib/` and `test/`, and flip the `Style/FrozenStringLiteralComment` rubocop cop to `EnforcedStyle: always` so future files inherit the freeze.
16
+ * Include the original exception's class name in `MultiJson::AdapterError.build`'s formatted message so a downstream consumer reading just the wrapped error can distinguish a `LoadError` from a validator `ArgumentError` without having to inspect `error.cause` separately.
17
+ * Mark the five `MultiJson::Concurrency` mutex constants as `private_constant` and add matching `synchronize_*` wrapper methods so callers don't reach into the module's internals.
18
+ * DRY up `lib/multi_json/deprecated.rb` with a small `deprecate_alias` / `deprecate_method` DSL so adding or removing a deprecation is a one-liner instead of a 4-line copy of the warn-then-delegate template.
19
+ * Hoist a shared `Gson::Decoder` and `Gson::Encoder` to handle the empty-options case in the JRuby `Gson` adapter so the dominant `MultiJson.load(json)` / `MultiJson.dump(obj)` call path no longer allocates a fresh decoder/encoder per call.
20
+ * Memoize the per-adapter `ParseError` lookup in `MultiJson.parse_error_class_for` so the constant resolution runs at most once per adapter, instead of on every `MultiJson.load` call.
21
+ * Walk the superclass chain in `Adapter.default_load_options` / `default_dump_options` instead of copying the parent's defaults into the subclass at inheritance time, so a parent calling `defaults :load, ...` after a subclass has been defined now propagates to the subclass.
22
+ * Hold `@eviction_mutex` around `ConcurrentStore#reset`'s `@cache.clear` so a JRuby fetcher in the middle of its evict-then-insert sequence cannot interleave with a concurrent reset, mirroring `MutexStore#reset`'s mutex usage.
23
+ * Collect the five process-wide mutexes that protect MultiJson's lazy initializers and adapter swap into a new `MultiJson::Concurrency` module so the library's concurrency surface is documented in one place.
24
+ * Replace the per-adapter `loaded` lambdas in `AdapterSelector::ADAPTERS` with constant name strings, walked through `Object.const_defined?` directly. The lookup table is half as large and no longer holds six closure objects whose only job was to call `defined?`.
25
+ * Wrap `AdapterSelector#default_adapter_excluding` in `DEFAULT_ADAPTER_MUTEX` so concurrent callers can't both walk the detection chain and double-fire `fallback_adapter`'s one-time warning.
26
+ * Raise a clear `MultiJson::AdapterError` when a custom adapter passed to `MultiJson.load` does not define a `ParseError` constant, instead of letting the bare `NameError` from the rescue clause propagate.
27
+ * Drop the duplicate `Adapter::EMPTY_OPTIONS` constant in favor of the `MultiJson::Options::EMPTY_OPTIONS` it was shadowing.
28
+ * Defer the `fast_jsonparser` adapter's dump-delegate resolution until the first `dump` call instead of locking it in at file load time. The adapter no longer inherits from another adapter, so loading `multi_json/adapters/fast_jsonparser` before `oj` no longer locks the dump path to whichever adapter happened to be available at that moment.
29
+ * Make the lazy `default_load_options` and `default_dump_options` initializers in `MultiJson::Options` thread-safe so two threads accessing an adapter's defaults for the first time can't both run the `||=` initializer.
30
+ * Make `AdapterSelector#default_adapter`'s lazy `||=` initializer thread-safe so two threads racing past the unset `@default_adapter` ivar can't both run detection (and double-emit the fallback warning in the no-adapters-installed branch).
31
+ * Wrap `MultiJson.use`'s `OptionsCache.reset` and `@adapter` swap in a mutex so two threads calling `use` concurrently can't interleave their cache reset and adapter assignment.
32
+ * Stop relying on `Oj::ParseError`'s `::SyntaxError` ancestor when matching exceptions in `Oj::ParseError.===`. Walk the exception's ancestor chain by class name instead, so a future Oj release that re-parents its error class doesn't silently break our rescue clauses.
33
+ * Improve `AdapterSelector#load_adapter`'s error message for unrecognized adapter specs so it names the expected types and shows the offender's `inspect` output instead of just `to_s`.
34
+ * Validate the `value` argument in `Adapter.defaults` so a non-Hash (e.g. `defaults :load, "oops"`) raises `ArgumentError` at definition time instead of crashing later in the merge path.
35
+ * Skip `String#scrub` in `Adapter.blank?` when the input is already valid UTF-8 so the common load path no longer allocates a scrubbed copy on every call.
36
+ * Move `Oj#load`'s `:symbolize_keys` translation into a private `translate_load_options` helper that drops the redundant `:symbolize_keys` passthrough alongside `:symbol_keys`, mirroring the cleanup already in `JsonGem#load`.
37
+ * Skip the per-call hash merge in `JsonGem#dump` when `pretty: true` is the only option, passing `PRETTY_STATE_PROTOTYPE` directly.
38
+ * Type-check the `Yajl`, `JrJackson`, and `Gson` adapter wrappers under Steep, with stubbed RBS sigs for the underlying libraries living in `sig/external_libraries.rbs`.
39
+ * Unify `LOADED_ADAPTER_DETECTORS` and `REQUIREMENT_MAP` in `AdapterSelector` into a single `ADAPTERS` source-of-truth so the require path and detection lambda for each adapter live in one place.
40
+ * Extract deprecated public API (`decode`, `encode`, `engine`, `engine=`, `default_engine`, `with_engine`, `default_options`, `default_options=`, `cached_options`, `reset_cached_options!`) into `lib/multi_json/deprecated.rb` and drop the matching `Style/Documentation`, `Style/ModuleFunction`, and `Style/OpenStructUse` rubocop opt-outs.
41
+ * Validate the `action` argument in `Adapter.defaults` so a typo (e.g. `defaults :encode, ...`) raises `ArgumentError` at definition time instead of silently producing a no-op default.
42
+ * Drop the stale `ok_json` reference from the `fast_jsonparser` adapter's docstring.
43
+ * Remove the `MultiJson::REQUIREMENT_MAP` legacy alias; the canonical map already lives on `MultiJson::AdapterSelector`.
44
+ * Drop the dead `JrJackson` dump arity branch (and its SimpleCov filter). JrJackson 0.4.18+ accepts an options hash as the second argument to `Json.dump`.
45
+ * Drop the redundant `options.except(:adapter)` allocation in `JsonGem#dump`; `Adapter.merged_dump_options` already strips `:adapter` before the cached hash reaches the adapter.
46
+ * Forward all merged options through `Yajl#load` instead of honoring only `:symbolize_keys`.
47
+ * Tighten `Adapter.blank?` so it scrubs invalid UTF-8 bytes up front instead of swallowing every `ArgumentError` from the underlying `String` calls.
48
+ * Guard `ConcurrentStore` eviction against a TOCTOU race so two concurrent JRuby threads cannot both pass the size check and briefly exceed `OptionsCache.max_cache_size`.
49
+ * Synchronize `warn_deprecation_once` so concurrent fibers and threads cannot race past the membership check and emit the same one-time deprecation warning twice.
50
+ * Stop resetting `OptionsCache` when `MultiJson.use` raises so a failed `use(:nonexistent)` no longer discards the cached entries belonging to the still-active previous adapter.
51
+ * Stop mutating cached options in `JsonGem#load`, mirroring the cache-pollution fix already in place for `Oj#load`.
52
+ * Empty the mutant ignore list. The `Gson` and `JrJackson` ignores were dead — those adapters ship in the java-platform gem and aren't present when mutant runs on MRI — and `Store#reset`'s mutex wrapper is now directly tested by stubbing `Mutex#synchronize`.
53
+ * Remove the vendored `ok_json` adapter. The json gem has been a Ruby default gem since 1.9, so an external pure-Ruby fallback is no longer needed on any supported Ruby version. The last-resort fallback when no other JSON library can be loaded is now `json_gem`. The `ConvertibleHashKeys` helper module, which only `ok_json` used, is also removed.
54
+ * Surface parse error locations as `error.line` and `error.column` on `MultiJson::ParseError`, extracted from the underlying adapter's message for adapters that include one (Oj, the json gem).
55
+ * Make `MultiJson::OptionsCache.max_cache_size` configurable so applications that generate many distinct option hashes can raise the cache ceiling at runtime.
56
+ * Reorganize `lib/multi_json.rb` into clearer sections and document why both the `module_function` and singleton-only definition patterns coexist.
57
+ * Restructure `OptionsCache` backend selection so MRI and JRuby execute the same physical `require_relative` line, restoring JRuby's line coverage threshold to 100%.
58
+ * Drop the `ALIASES` constant in `AdapterSelector` in favor of an inline check; the only entry, `jrjackson` → `jr_jackson`, is now inlined into `load_adapter_by_name`.
59
+ * Document the `fast_jsonparser` adapter's parent class freeze at file load time.
60
+ * Stop mass-requiring adapter gems at the top of `adapter_selection_test.rb`, which polluted the global require cache and let later tests silently depend on adapters they had not explicitly loaded.
61
+ * Restore the mutex around `MutexStore#reset` for TruffleRuby, where the unguarded clear could race with concurrent fetches in a way the MRI GVL otherwise prevents.
62
+ * Fix `TestHelpers.yajl?` to check the actual `yajl-ruby` gem name.
63
+ * [Stop requiring the `oj` gem from the `fast_jsonparser` adapter](https://github.com/sferik/multi_json/issues/63): `fast_jsonparser` only implements parsing, so the adapter's `dump` side now inherits from whichever adapter MultiJson would otherwise pick (oj → yajl → jr_jackson → json_gem → gson → ok_json). Users who install `fast_jsonparser` no longer need to also install `oj`.
64
+ * [Split the gem into `ruby` and `java` platform variants](https://github.com/sferik/multi_json/commit/ca2c747570335f8d3b6b0904aae6ace41329aedd): the `java` variant adds `concurrent-ruby ~> 1.2` as a runtime dependency and ships the `gson` and `jr_jackson` adapters; the `ruby` variant has no runtime dependencies and ships the MRI-only adapters. Bundler selects the correct variant automatically.
65
+ * [Drop Oj 2.x compatibility branch](https://github.com/sferik/multi_json/commit/93897a45e2b2f3f6fa047ee00fc1e879ae137ec1): the Oj adapter now requires Oj `~> 3.0`.
66
+ * [Drop support for Ruby 3.0, Ruby 3.1, and JRuby 9.4](https://github.com/sferik/multi_json/commit/bc4547a5cee4d66294f2a1be04fe61f9d49235cd).
67
+ * [Add Ruby 4.0 to the CI matrix](https://github.com/sferik/multi_json/commit/bdf4999ea0c81f79c208e5fafb63f7474571b687).
68
+ * [Make `with_adapter` overrides fiber-local](https://github.com/sferik/multi_json/commit/7f7ce0e68f094bb9a26bf37a950c4794dc8e7292) so concurrent fibers and threads each observe their own adapter without racing on a shared module variable.
69
+ * [Raise `MultiJson::ParseError` on invalid UTF-8 in the `json_gem` adapter](https://github.com/sferik/multi_json/commit/2b5d14548fc67c5fdcaaee9b14d9f3eefe1f3493) instead of silently reinterpreting bytes with `force_encoding`.
70
+ * [Warn once for deprecated method aliases](https://github.com/sferik/multi_json/commit/5390bf311567388056724743121a665adab8ae8d): `decode`, `encode`, `engine`, `engine=`, `default_engine`, and `with_engine` now emit a one-time deprecation warning on first call and are scheduled for removal in a future major release.
71
+ * [Emit deprecation warnings only once per process](https://github.com/sferik/multi_json/commit/118f608c43aacb2ad36aa5f70b9084d48a9877c9) for `default_options`, `default_options=`, `cached_options`, and `reset_cached_options!` instead of on every call.
72
+ * [Document public API methods as `@api public`](https://github.com/sferik/multi_json/commit/5f3bd5397800cbf4b8f3a522e91364de1ad9079d) so `load`, `dump`, `use`, `with_adapter`, `current_adapter`, `adapter`, `load_options`, and `dump_options` appear in generated docs.
73
+ * [Add YARD documentation for the `Adapters` module and `ParseError` constants](https://github.com/sferik/multi_json/commit/3bc3beb76987a5711bf6c94ab176d5a84a42b063).
74
+ * [Stop mutating cached options in `Oj#load`](https://github.com/sferik/multi_json/commit/091d4f046dfb1d85816b04ef68c0850e5a97acdf): the adapter previously assigned `options[:symbol_keys]` on the shared cached hash, slowly polluting it with extra keys.
75
+ * [Stop mutating cached options in `OjCommon#prepare_dump_options`](https://github.com/sferik/multi_json/commit/089892e387b56036840b58b61593ce2b80fd72d6): `merge!(PRETTY_STATE_PROTOTYPE)` on the cached options hash removed `:pretty` and added prototype keys on every call, producing accidentally-correct results through cache reuse.
76
+ * [Call `to_h` on options to properly handle `JSON::State` objects](https://github.com/sferik/multi_json/commit/821ea32d5cafc223983b24b3260a1d4112aefab9).
77
+ * [Avoid allocating an options hash on the `dump`/`load` hot path](https://github.com/sferik/multi_json/commit/89a397718fff9e6cc5af8b7ef9fa19494894e6ce) by reusing a shared frozen empty hash for the no-options case.
78
+ * [Short-circuit empty input in `Adapter.blank?`](https://github.com/sferik/multi_json/commit/d3081a64eaf7755610a29c602dc6f0c5678643c6) before falling back to the regex match.
79
+ * [Replace the `LOADERS` strategy table with a `case` statement](https://github.com/sferik/multi_json/commit/562331a002dc87052797c53769610a719699c33c) in `AdapterSelector#load_adapter`.
80
+ * [Move `REQUIREMENT_MAP` from `MultiJson` into `AdapterSelector`](https://github.com/sferik/multi_json/commit/ab371e70d63b840386a3cf264611c2298c7c8250); `MultiJson::REQUIREMENT_MAP` remains as a deprecated alias.
81
+ * [Fix Bundler 4.0 permission error in CI](https://github.com/sferik/multi_json/commit/1fe4514e641e34dcf3ec9b62a2a76bfe0120c708).
82
+ * [Revert the Steep removal](https://github.com/sferik/multi_json/commit/883be03219d5178f83381333c3a354f59b4c8117) and restore the Steepfile, sig directory, and typecheck workflow.
83
+ * [Add workflow badges for linter, mutant, steep, and docs](https://github.com/sferik/multi_json/commit/88cf1bea1fb3056ad3a7c0f8ca828e194ee895dd).
84
+ * [Bump `actions/checkout` from 4 to 6](https://github.com/sferik/multi_json/commit/587f246d9ffd6991417af771fa0ce7059b337c40).
85
+ * [Update copyright year and alphabetize contributors by last name](https://github.com/sferik/multi_json/commit/233fb0ee1a375279d83c06ff6f702ec17d695b88).
86
+
3
87
  ## 1.19.1
4
88
  * [Restore deprecated encode/decode methods](https://github.com/sferik/multi_json/commit/c5bf2fc95dfdde6b30d63fefb0b2f4aa29633969)
5
89
 
data/CONTRIBUTING.md CHANGED
@@ -41,8 +41,9 @@ system. Ideally, a bug report should include a pull request with failing tests.
41
41
  8. Run `bundle exec rake mutant` and kill any surviving mutants.
42
42
  9. Run `bundle exec rake lint` and fix any offenses.
43
43
  10. Run `bundle exec rake yardstick` and add any missing documentation.
44
- 11. Add, commit, and push your changes.
45
- 12. [Submit a pull request][pr] that summarizes *what* changes you made and
44
+ 11. Run `bundle exec rake steep` and ensure that the code typechecks.
45
+ 12. Add, commit, and push your changes.
46
+ 13. [Submit a pull request][pr] that summarizes *what* changes you made and
46
47
  *why* you made them. If you made any significant decisions along the way,
47
48
  describe the options you considered and how you thought about the
48
49
  tradeoffs.
data/LICENSE.md CHANGED
@@ -1,4 +1,4 @@
1
- Copyright (c) 2010-2025 Michael Bleigh, Josh Kalderimis, Erik Berlin, Pavel Pravosud
1
+ Copyright (c) 2010-2026 Erik Berlin, Michael Bleigh, Josh Kalderimis, Pavel Pravosud
2
2
 
3
3
  Permission is hereby granted, free of charge, to any person obtaining
4
4
  a copy of this software and associated documentation files (the
data/README.md CHANGED
@@ -1,8 +1,12 @@
1
1
  # MultiJSON
2
2
 
3
- [![Gem Version](http://img.shields.io/gem/v/multi_json.svg)][gem]
4
- [![Build Status](https://github.com/sferik/multi_json/actions/workflows/tests.yml/badge.svg)][build]
3
+ [![Tests](https://github.com/sferik/multi_json/actions/workflows/tests.yml/badge.svg)][tests]
4
+ [![Linter](https://github.com/sferik/multi_json/actions/workflows/linter.yml/badge.svg)][linter]
5
+ [![Mutant](https://github.com/sferik/multi_json/actions/workflows/mutant.yml/badge.svg)][mutant]
6
+ [![Typecheck](https://github.com/sferik/multi_json/actions/workflows/typecheck.yml/badge.svg)][typecheck]
7
+ [![Docs](https://github.com/sferik/multi_json/actions/workflows/docs.yml/badge.svg)][docs]
5
8
  [![Maintainability](https://qlty.sh/badges/fde3f4a8-c331-44be-b1e6-45842137def9/maintainability.svg)][qlty]
9
+ [![Gem Version](https://badge.fury.io/rb/multi_json.svg)][gem]
6
10
 
7
11
  Lots of Ruby libraries parse JSON and everyone has their favorite JSON coder.
8
12
  Instead of choosing a single JSON coder and forcing users of your library to be
@@ -10,27 +14,54 @@ stuck with it, you can use MultiJSON instead, which will simply choose the
10
14
  fastest available JSON coder. Here's how to use it:
11
15
 
12
16
  ```ruby
13
- require 'multi_json'
17
+ require "multi_json"
14
18
 
15
- MultiJson.load('{"abc":"def"}') #=> {"abc" => "def"}
16
- MultiJson.load('{"abc":"def"}', :symbolize_keys => true) #=> {:abc => "def"}
17
- MultiJson.dump({:abc => 'def'}) # convert Ruby back to JSON
18
- MultiJson.dump({:abc => 'def'}, :pretty => true) # encoded in a pretty form (if supported by the coder)
19
+ MultiJson.load('{"abc":"def"}') #=> {"abc" => "def"}
20
+ MultiJson.load('{"abc":"def"}', symbolize_keys: true) #=> {abc: "def"}
21
+ MultiJson.dump({abc: "def"}) # convert Ruby back to JSON
22
+ MultiJson.dump({abc: "def"}, pretty: true) # encoded in a pretty form (if supported by the coder)
19
23
  ```
20
24
 
21
- When loading invalid JSON, MultiJSON will throw a `MultiJson::ParseError`. `MultiJson::DecodeError` and `MultiJson::LoadError` are aliases for backwards compatibility.
25
+ `MultiJson.load` returns `nil` for `nil`, empty, and whitespace-only inputs
26
+ instead of raising, so a missing or blank payload is observable as a `nil`
27
+ return value rather than an exception. When loading invalid JSON, MultiJSON
28
+ will throw a `MultiJson::ParseError`. `MultiJson::DecodeError` and
29
+ `MultiJson::LoadError` are aliases for backwards compatibility.
22
30
 
23
31
  ```ruby
24
32
  begin
25
- MultiJson.load('{invalid json}')
33
+ MultiJson.load("{invalid json}")
26
34
  rescue MultiJson::ParseError => exception
27
- exception.data # => "{invalid json}"
28
- exception.cause # => JSON::ParserError: 795: unexpected token at '{invalid json}'
35
+ exception.data #=> "{invalid json}"
36
+ exception.cause #=> JSON::ParserError: ...
37
+ exception.line #=> 1 (for adapters that report a location, e.g. Oj or the json gem)
38
+ exception.column #=> 2
29
39
  end
30
40
  ```
31
41
 
32
42
  `ParseError` instance has `cause` reader which contains the original exception.
33
- It also has `data` reader with the input that caused the problem.
43
+ It also has `data` reader with the input that caused the problem, and `line`/`column`
44
+ readers populated for adapters whose error messages include a location (Oj and the
45
+ json gem). Adapters that don't include one (Yajl, fast_jsonparser) leave both nil.
46
+
47
+ ### Tuning the options cache
48
+
49
+ MultiJSON memoizes the merged option hash for each `load`/`dump` call so identical
50
+ option hashes don't trigger repeated work. The cache is bounded — defaulting to 1000
51
+ entries per direction — and applications that generate many distinct option hashes
52
+ can raise the ceiling at runtime:
53
+
54
+ ```ruby
55
+ MultiJson::OptionsCache.max_cache_size = 5000
56
+ ```
57
+
58
+ `max_cache_size` must be a positive integer; `0`, negative values, and
59
+ non-integers raise `ArgumentError`.
60
+
61
+ Lowering the limit only takes effect for *new* inserts; existing cache
62
+ entries are left in place until normal eviction trims them below the
63
+ new ceiling. Call `MultiJson::OptionsCache.reset` if you want to evict
64
+ immediately.
34
65
 
35
66
  The `use` method, which sets the MultiJSON adapter, takes either a symbol or a
36
67
  class (to allow for custom JSON parsers) that responds to both `.load` and `.dump`
@@ -39,34 +70,84 @@ at the class level.
39
70
  When MultiJSON fails to load the specified adapter, it'll throw `MultiJson::AdapterError`
40
71
  which inherits from `ArgumentError`.
41
72
 
42
- MultiJSON tries to have intelligent defaulting. That is, if you have any of the
43
- supported engines already loaded, it will utilize them before attempting to
44
- load any. When loading, libraries are ordered by speed. First fast_jsonparser,
45
- then Oj, then Yajl, then the JSON gem. If no other JSON library is available,
46
- MultiJSON falls back to [OkJson][], a simple, vendorable JSON parser.
73
+ ### Writing a custom adapter
74
+
75
+ A custom adapter is any class that responds to two class methods plus
76
+ defines a `ParseError` constant:
47
77
 
48
- ## Supported JSON Engines
78
+ ```ruby
79
+ class MyAdapter
80
+ ParseError = Class.new(StandardError)
81
+
82
+ def self.load(string, options)
83
+ # parse string into a Ruby object, raising ParseError on failure
84
+ end
85
+
86
+ def self.dump(object, options)
87
+ # serialize object to a JSON string
88
+ end
89
+ end
49
90
 
50
- - [fast_jsonparser][fast_jsonparser] Fast JSON parser by Anil Maurya
51
- - [Oj][oj] Optimized JSON by Peter Ohler
52
- - [Yajl][yajl] Yet Another JSON Library by Brian Lopez
53
- - [JSON][json-gem] The default JSON gem with C-extensions (ships with Ruby 1.9+)
54
- - [gson.rb][gson] A Ruby wrapper for google-gson library (JRuby only)
55
- - [JrJackson][jrjackson] JRuby wrapper for Jackson (JRuby only)
56
- - [OkJson][okjson] A simple, vendorable JSON parser
91
+ MultiJson.use(MyAdapter)
92
+ ```
93
+
94
+ `ParseError` is required: `MultiJson.load` rescues `MyAdapter::ParseError`
95
+ to wrap parse failures in `MultiJson::ParseError`, and an adapter that
96
+ omits the constant raises `MultiJson::AdapterError` on the first parse
97
+ attempt instead of producing a confusing `NameError`.
98
+
99
+ For more, inherit from `MultiJson::Adapter` to pick up shared option
100
+ merging, the `defaults :load, ...` / `defaults :dump, ...` DSL, and the
101
+ blank-input short-circuit. The built-in adapters in
102
+ `lib/multi_json/adapters/` are working examples.
103
+
104
+ MultiJSON tries to have intelligent defaulting. If any supported library is
105
+ already loaded, MultiJSON uses it before attempting to load others. When no
106
+ backend is preloaded, MultiJSON walks its preference list and uses the first
107
+ one that loads successfully:
108
+
109
+ 1. `fast_jsonparser`
110
+ 2. `oj`
111
+ 3. `yajl-ruby`
112
+ 4. `jrjackson`
113
+ 5. The JSON gem
114
+ 6. `gson`
115
+
116
+ This order is a best-effort historical ranking by typical parse/dump
117
+ throughput on representative workloads, not a guaranteed benchmark. Real-world
118
+ performance depends on the document shape, the Ruby implementation, and
119
+ whether you're calling `load` or `dump`. The JSON gem is a Ruby default gem,
120
+ so it's always available as a last-resort fallback on any supported Ruby. If
121
+ you have a workload where a different backend is faster, set it explicitly
122
+ with `MultiJson.use(:your_adapter)`.
123
+
124
+ ## Gem Variants
125
+
126
+ MultiJSON ships as two platform-specific gems. Bundler and RubyGems
127
+ automatically select the correct variant for your Ruby implementation:
128
+
129
+ | | `ruby` platform (MRI) | `java` platform (JRuby) |
130
+ | ---------------------------------------------- | :---: | :---: |
131
+ | Runtime dependency | none | [concurrent-ruby][concurrent-ruby] `~> 1.2` |
132
+ | [`fast_jsonparser`][fast_jsonparser] adapter | ✓ | |
133
+ | [`oj`][oj] adapter | ✓ | |
134
+ | [`yajl`][yajl] adapter | ✓ | |
135
+ | [`json_gem`][json-gem] adapter | ✓ | ✓ |
136
+ | [`gson`][gson] adapter | | ✓ |
137
+ | [`jr_jackson`][jrjackson] adapter | | ✓ |
138
+ | `OptionsCache` thread-safe store | `Hash` + `Mutex` | `Concurrent::Map` |
57
139
 
58
140
  ## Supported Ruby Versions
59
141
 
60
- This library aims to support and is [tested against](https://github.com/sferik/multi_json/actions/workflows/ci.yml) the following Ruby
142
+ This library aims to support and is [tested against](https://github.com/sferik/multi_json/actions/workflows/tests.yml) the following Ruby
61
143
  implementations:
62
144
 
63
- - Ruby 3.0
64
- - Ruby 3.1
65
145
  - Ruby 3.2
66
146
  - Ruby 3.3
67
147
  - Ruby 3.4
68
- - [JRuby][jruby] 9.4 (targets Ruby 3.1 compatibility)
148
+ - Ruby 4.0
69
149
  - [JRuby][jruby] 10.0 (targets Ruby 3.4 compatibility)
150
+ - [TruffleRuby][truffleruby] 33.0 (native and JVM)
70
151
 
71
152
  If something doesn't work in one of these implementations, it's a bug.
72
153
 
@@ -98,21 +179,26 @@ spec.add_dependency 'multi_json', '~> 1.0'
98
179
 
99
180
  ## Copyright
100
181
 
101
- Copyright (c) 2010-2025 Michael Bleigh, Josh Kalderimis, Erik Berlin,
102
- and Pavel Pravosud. See [LICENSE][license] for details.
182
+ Copyright (c) 2010-2026 Erik Berlin, Michael Bleigh, Josh Kalderimis, and Pavel
183
+ Pravosud. See [LICENSE][license] for details.
103
184
 
104
- [build]: https://github.com/sferik/multi_json/actions/workflows/tests.yml
185
+ [concurrent-ruby]: https://github.com/ruby-concurrency/concurrent-ruby
186
+ [docs]: https://github.com/sferik/multi_json/actions/workflows/docs.yml
187
+ [fast_jsonparser]: https://github.com/anilmaurya/fast_jsonparser
105
188
  [gem]: https://rubygems.org/gems/multi_json
106
189
  [gson]: https://github.com/avsej/gson.rb
107
190
  [jrjackson]: https://github.com/guyboertje/jrjackson
108
191
  [jruby]: http://www.jruby.org/
109
192
  [json-gem]: https://github.com/flori/json
110
193
  [license]: LICENSE.md
194
+ [linter]: https://github.com/sferik/multi_json/actions/workflows/linter.yml
111
195
  [macruby]: http://www.macruby.org/
196
+ [mutant]: https://github.com/sferik/multi_json/actions/workflows/mutant.yml
112
197
  [oj]: https://github.com/ohler55/oj
113
- [okjson]: https://github.com/kr/okjson
114
- [fast_jsonparser]: https://github.com/anilmaurya/fast_jsonparser
115
198
  [pvc]: http://docs.rubygems.org/read/chapter/16#page74
116
199
  [qlty]: https://qlty.sh/gh/sferik/projects/multi_json
117
200
  [semver]: http://semver.org/
201
+ [tests]: https://github.com/sferik/multi_json/actions/workflows/tests.yml
202
+ [truffleruby]: https://www.graalvm.org/ruby/
203
+ [typecheck]: https://github.com/sferik/multi_json/actions/workflows/typecheck.yml
118
204
  [yajl]: https://github.com/brianmario/yajl-ruby
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "singleton"
2
4
  require_relative "options"
3
5
 
@@ -19,27 +21,51 @@ module MultiJson
19
21
 
20
22
  class << self
21
23
  BLANK_PATTERN = /\A\s*\z/
22
- private_constant :BLANK_PATTERN
24
+ VALID_DEFAULTS_ACTIONS = %i[load dump].freeze
25
+ private_constant :BLANK_PATTERN, :VALID_DEFAULTS_ACTIONS
26
+
27
+ # Get default load options, walking the superclass chain
28
+ #
29
+ # Returns the closest ancestor's `@default_load_options` ivar so a
30
+ # parent class calling {.defaults} after a subclass has been
31
+ # defined still propagates to the subclass. Falls back to the
32
+ # shared frozen empty hash when no ancestor has defaults set.
33
+ #
34
+ # @api private
35
+ # @return [Hash] frozen options hash
36
+ def default_load_options
37
+ walk_default_options(:@default_load_options)
38
+ end
23
39
 
24
- # Hook called when a subclass is created
40
+ # Get default dump options, walking the superclass chain
25
41
  #
26
42
  # @api private
27
- # @param subclass [Class] the new subclass
28
- # @return [void]
29
- def inherited(subclass)
30
- super
31
- # Propagate default options to subclasses
32
- subclass.instance_variable_set(:@default_load_options, @default_load_options) if defined?(@default_load_options)
33
- subclass.instance_variable_set(:@default_dump_options, @default_dump_options) if defined?(@default_dump_options)
43
+ # @return [Hash] frozen options hash
44
+ def default_dump_options
45
+ walk_default_options(:@default_dump_options)
34
46
  end
35
47
 
36
48
  # DSL for setting adapter-specific default options
37
49
  #
50
+ # ``action`` must be ``:load`` or ``:dump``; ``value`` must be a
51
+ # Hash. Both arguments are validated up front so a typo at the
52
+ # adapter's class definition fails fast instead of producing a
53
+ # silent no-op default that crashes later in the merge path.
54
+ #
38
55
  # @api private
39
56
  # @param action [Symbol] :load or :dump
40
57
  # @param value [Hash] default options for the action
41
58
  # @return [Hash] the frozen options hash
59
+ # @raise [ArgumentError] when action is anything other than :load
60
+ # or :dump, or when value isn't a Hash
61
+ # @example Set load defaults for an adapter
62
+ # class MyAdapter < MultiJson::Adapter
63
+ # defaults :load, symbolize_keys: false
64
+ # end
42
65
  def defaults(action, value)
66
+ raise ArgumentError, "expected action to be :load or :dump, got #{action.inspect}" unless VALID_DEFAULTS_ACTIONS.include?(action)
67
+ raise ArgumentError, "expected value to be a Hash, got #{value.class}" unless value.is_a?(Hash)
68
+
43
69
  instance_variable_set(:"@default_#{action}_options", value.freeze)
44
70
  end
45
71
 
@@ -68,16 +94,49 @@ module MultiJson
68
94
 
69
95
  private
70
96
 
71
- # Checks if the input is blank (nil or whitespace-only)
97
+ # Walk the superclass chain looking for a default options ivar
98
+ #
99
+ # Stops at the first ancestor whose ``ivar`` is set and returns
100
+ # that value. Returns {Options::EMPTY_OPTIONS} when no ancestor
101
+ # has the ivar set, so adapters without defaults always observe a
102
+ # frozen empty hash instead of nil.
103
+ #
104
+ # @api private
105
+ # @param ivar [Symbol] ivar name (`:@default_load_options` or `:@default_dump_options`)
106
+ # @return [Hash] frozen options hash
107
+ def walk_default_options(ivar)
108
+ # @type var klass: Class?
109
+ klass = self
110
+ while klass
111
+ return klass.instance_variable_get(ivar) if klass.instance_variable_defined?(ivar)
112
+
113
+ klass = klass.superclass
114
+ end
115
+ Options::EMPTY_OPTIONS
116
+ end
117
+
118
+ # Checks if the input is blank (nil, empty, or whitespace-only)
119
+ #
120
+ # The dominant call path arrives with a non-blank string starting
121
+ # with ``{`` or ``[`` (the JSON object/array sigils), so a
122
+ # ``start_with?`` short-circuit skips the regex entirely on the
123
+ # hot path. Falls through to the full check for everything else
124
+ # — strings, numbers, booleans, ``null``, whitespace-prefixed
125
+ # input — at which point ``String#scrub`` is only invoked when
126
+ # the input has invalid encoding so the common valid-UTF-8 path
127
+ # doesn't allocate a scrubbed copy on every call. Scrubbing
128
+ # replaces invalid bytes with U+FFFD before the regex runs so a
129
+ # string with bad bytes is still treated as non-blank without a
130
+ # broad rescue.
72
131
  #
73
132
  # @api private
74
133
  # @param input [String, nil] input to check
75
134
  # @return [Boolean] true if input is blank
76
135
  def blank?(input)
77
- input.nil? || BLANK_PATTERN.match?(input)
78
- rescue ArgumentError
79
- # Invalid byte sequence in UTF-8 - treat as non-blank
80
- false
136
+ return true if input.nil? || input.empty?
137
+ return false if input.start_with?("{", "[")
138
+
139
+ BLANK_PATTERN.match?(input.valid_encoding? ? input : input.scrub)
81
140
  end
82
141
 
83
142
  # Merges dump options from adapter, global, and call-site
@@ -106,10 +165,16 @@ module MultiJson
106
165
 
107
166
  # Removes the :adapter key from options for cache key
108
167
  #
168
+ # Returns a shared frozen empty hash for the common no-options call
169
+ # path so the hot path avoids allocating a fresh hash on every call.
170
+ #
109
171
  # @api private
110
- # @param options [Hash] original options
172
+ # @param options [Hash, #to_h] original options (may be JSON::State or similar)
111
173
  # @return [Hash] frozen options without :adapter key
112
174
  def strip_adapter_key(options)
175
+ options = options.to_h unless options.is_a?(Hash)
176
+ return Options::EMPTY_OPTIONS if options.empty? || (options.size == 1 && options.key?(:adapter))
177
+
113
178
  options.except(:adapter).freeze
114
179
  end
115
180
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module MultiJson
2
4
  # Raised when an adapter cannot be loaded or is not recognized
3
5
  #
@@ -18,6 +20,12 @@ module MultiJson
18
20
 
19
21
  # Build an AdapterError from an original exception
20
22
  #
23
+ # The original exception's class name is included in the message
24
+ # so a downstream consumer reading just the AdapterError can tell
25
+ # whether the underlying failure was a `LoadError`, an
26
+ # `ArgumentError` from the spec validator, or some other class
27
+ # without having to look at `error.cause` separately.
28
+ #
21
29
  # @api public
22
30
  # @param original_exception [Exception] the original load error
23
31
  # @return [AdapterError] new error with formatted message
@@ -25,7 +33,8 @@ module MultiJson
25
33
  # AdapterError.build(LoadError.new("cannot load such file"))
26
34
  def self.build(original_exception)
27
35
  new(
28
- "Did not recognize your adapter specification (#{original_exception.message}).",
36
+ "Did not recognize your adapter specification " \
37
+ "(#{original_exception.class}: #{original_exception.message}).",
29
38
  cause: original_exception
30
39
  )
31
40
  end