multi_json 1.15.0 → 1.21.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: ba3f1870ebd467a8356a8138d7bba83f224659a5cea2d4f9a845fa88e8d4934f
4
- data.tar.gz: 89051067684a2f7f101dbc1b00e8cc4a417fdb9b829e476db6d90ea2cc4cf37b
3
+ metadata.gz: 7cffb1170fe976efac86851b47b4db7208fb1eefc745ea73e6cf4aaeb4aaa281
4
+ data.tar.gz: d5b3f218ad72f318eb5c86152c87005f27f7fc3b4b265561f1156092cbbd6c4b
5
5
  SHA512:
6
- metadata.gz: 3f317d2719838c2466a2ca066b25259a2653cf639198a9e47fc0c0365a8a02e8307feaa4392b445ae1f918c92c4cc7deb833e1c1945c4682cd3dac3d91af9789
7
- data.tar.gz: 81b37aeb82f9744a93eec867a54e3a83537bedb6f29747818187b6fc27d599a6ee88f239db382f85ed840319e19452f43f09c29655ac664e9a4680bc33c335a8
6
+ metadata.gz: a61eb7a6f29720708c0944b8288ed8a9ce1917f76d9934f3f417935a606fa3da0c8e6e1b8158a57420c49b97ba617222ff3a5e1ed28bbfc23c7146f46e32d0ce
7
+ data.tar.gz: fc6f0c205a0c6a05740a08de6c3a27d59e482e64f179591b47a718a84d02b7d509e1ffd368d28c6d5edc88a454bb8ce4c64cd784d7283cc4353c82f7d1f2919d
data/LICENSE.md CHANGED
@@ -1,4 +1,4 @@
1
- Copyright (c) 2010-2013 Michael Bleigh, Josh Kalderimis, Erik Michaels-Ober, 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](http://travis-ci.org/intridea/multi_json.svg)][travis]
5
- [![Code Climate](https://codeclimate.com/github/intridea/multi_json.svg)][codeclimate]
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]
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,66 +14,217 @@ 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.parse('{"abc":"def"}') #=> {"abc" => "def"}
20
+ MultiJSON.parse('{"abc":"def"}', symbolize_names: true) #=> {abc: "def"}
21
+ MultiJSON.generate({abc: "def"}) # convert Ruby back to JSON
22
+ MultiJSON.generate({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
+ > [!IMPORTANT]
26
+ > **1.21.0 renames the public API to match Ruby stdlib `JSON`.** The canonical
27
+ > verbs are now `MultiJSON.parse` / `MultiJSON.generate`, and the canonical
28
+ > module is `MultiJSON` (all-caps). The legacy `MultiJson` constant,
29
+ > `MultiJSON.load` / `MultiJSON.dump`, `:symbolize_keys`, and friends still
30
+ > work but emit one-time deprecation warnings and **will be removed in 2.0.0**.
31
+ > Run your app with `ruby -W:deprecated` to surface them; the warnings are
32
+ > tagged with the `:deprecated` category so you can silence the whole set with
33
+ > `Warning[:deprecated] = false`. See [Deprecated in 1.21.0](#deprecated-in-1210)
34
+ > for the full list.
35
+
36
+ `MultiJSON.parse` returns `nil` for `nil`, empty, and whitespace-only inputs
37
+ instead of raising, so a missing or blank payload is observable as a `nil`
38
+ return value rather than an exception. When parsing invalid JSON, MultiJSON
39
+ will throw a `MultiJSON::ParseError`. `MultiJSON::DecodeError` and
40
+ `MultiJSON::LoadError` are aliases for backwards compatibility.
22
41
 
23
42
  ```ruby
24
43
  begin
25
- MultiJson.load('{invalid json}')
26
- rescue MultiJson::ParseError => exception
27
- exception.data # => "{invalid json}"
28
- exception.cause # => JSON::ParserError: 795: unexpected token at '{invalid json}'
44
+ MultiJSON.parse("{invalid json}")
45
+ rescue MultiJSON::ParseError => exception
46
+ exception.data #=> "{invalid json}"
47
+ exception.cause #=> JSON::ParserError: ...
48
+ exception.line #=> 1 (for adapters that report a location, e.g. Oj or the json gem)
49
+ exception.column #=> 2
29
50
  end
30
51
  ```
31
52
 
53
+ ### Drop-in replacement for stdlib `JSON`
54
+
55
+ MultiJSON mirrors the surface of Ruby's stdlib [`JSON`][json-gem] so
56
+ most call sites swap in with a one-line change:
57
+
58
+ ```diff
59
+ - require "json"
60
+ + require "multi_json"
61
+
62
+ - JSON.parse(text, symbolize_names: true)
63
+ + MultiJSON.parse(text, symbolize_names: true)
64
+
65
+ - JSON.generate(object, pretty: true)
66
+ + MultiJSON.generate(object, pretty: true)
67
+ ```
68
+
69
+ Method names and the common options line up with stdlib so existing
70
+ pretty-print calls and option keys keep working without changes:
71
+
72
+ | stdlib `JSON` | `MultiJSON` | Status |
73
+ | ---------------------- | -------------------------- | :---: |
74
+ | `JSON.parse(str)` | `MultiJSON.parse(str)` | ✓ |
75
+ | `JSON.generate(obj)` | `MultiJSON.generate(obj)` | ✓ |
76
+ | `pretty: true` | `pretty: true` | ✓ |
77
+ | `symbolize_names: true` | `symbolize_names: true` | ✓ |
78
+
79
+ ### Deprecated in 1.21.0
80
+
81
+ The module constant and primary verbs were renamed to match Ruby
82
+ stdlib `JSON.parse` / `JSON.generate` and the JSON spec (RFC 8259).
83
+ The old names still work in 1.x but now emit a one-time deprecation
84
+ warning; **they will be removed in 2.0.0**.
85
+
86
+ | Deprecated | Use instead |
87
+ | ----------------------------- | ------------------------------- |
88
+ | `MultiJson` (constant) | `MultiJSON` (all-caps) |
89
+ | `MultiJSON.load(str)` | `MultiJSON.parse(str)` |
90
+ | `MultiJSON.dump(obj)` | `MultiJSON.generate(obj)` |
91
+ | `MultiJSON.load_options=` | `MultiJSON.parse_options=` |
92
+ | `MultiJSON.load_options` | `MultiJSON.parse_options` |
93
+ | `MultiJSON.dump_options=` | `MultiJSON.generate_options=` |
94
+ | `MultiJSON.dump_options` | `MultiJSON.generate_options` |
95
+ | `symbolize_keys:` option | `symbolize_names:` option |
96
+
97
+ The `MultiJson` constant (CamelCase) continues to work as a thin
98
+ delegator; every method call, constant lookup, and rescue clause
99
+ routes through `MultiJSON` transparently.
100
+
101
+ > [!TIP]
102
+ > The recommended upgrade path to 2.0 is: pin `~> 1.21` first, run
103
+ > `ruby -W:deprecated` against your app or test suite to surface every
104
+ > deprecation, migrate each call site to the canonical name, then bump to
105
+ > `~> 2.0`. The 2.0 release deletes the deprecated aliases entirely, so the
106
+ > warnings during 1.21.x are your map.
107
+
32
108
  `ParseError` instance has `cause` reader which contains the original exception.
33
- It also has `data` reader with the input that caused the problem.
109
+ It also has `data` reader with the input that caused the problem, and `line`/`column`
110
+ readers populated for adapters whose error messages include a location (Oj and the
111
+ json gem). Adapters that don't include one (Yajl, fast_jsonparser) leave both nil.
112
+
113
+ ### Tuning the options cache
114
+
115
+ MultiJSON memoizes the merged option hash for each `parse`/`generate` call so
116
+ identical option hashes don't trigger repeated work. The cache is bounded —
117
+ defaulting to 1000 entries per direction — and applications that generate many
118
+ distinct option hashes can raise the ceiling at runtime:
119
+
120
+ ```ruby
121
+ MultiJSON::OptionsCache.max_cache_size = 5000
122
+ ```
123
+
124
+ `max_cache_size` must be a positive integer; `0`, negative values, and
125
+ non-integers raise `ArgumentError`.
126
+
127
+ Lowering the limit only takes effect for *new* inserts; existing cache
128
+ entries are left in place until normal eviction trims them below the
129
+ new ceiling. Call `MultiJSON::OptionsCache.reset` if you want to evict
130
+ immediately.
34
131
 
35
132
  The `use` method, which sets the MultiJSON adapter, takes either a symbol or a
36
133
  class (to allow for custom JSON parsers) that responds to both `.load` and `.dump`
37
134
  at the class level.
38
135
 
39
- When MultiJSON fails to load the specified adapter, it'll throw `MultiJson::AdapterError`
136
+ When MultiJSON fails to load the specified adapter, it'll throw `MultiJSON::AdapterError`
40
137
  which inherits from `ArgumentError`.
41
138
 
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 Oj, then Yajl,
45
- then the JSON gem, then JSON pure. If no other JSON library is available,
46
- MultiJSON falls back to [OkJson][], a simple, vendorable JSON parser.
139
+ ### Writing a custom adapter
140
+
141
+ A custom adapter is any class that responds to two class methods plus
142
+ defines a `ParseError` constant:
143
+
144
+ ```ruby
145
+ class MyAdapter
146
+ ParseError = Class.new(StandardError)
147
+
148
+ def self.load(string, options)
149
+ # parse string into a Ruby object, raising ParseError on failure
150
+ end
151
+
152
+ def self.dump(object, options)
153
+ # serialize object to a JSON string
154
+ end
155
+ end
156
+
157
+ MultiJSON.use(MyAdapter)
158
+ ```
47
159
 
48
- ## Supported JSON Engines
160
+ `ParseError` is required: `MultiJSON.parse` rescues `MyAdapter::ParseError`
161
+ to wrap parse failures in `MultiJSON::ParseError`, and an adapter that
162
+ omits the constant raises `MultiJSON::AdapterError` on the first parse
163
+ attempt instead of producing a confusing `NameError`.
49
164
 
50
- * [Oj][oj] Optimized JSON by Peter Ohler
51
- * [Yajl][yajl] Yet Another JSON Library by Brian Lopez
52
- * [JSON][json-gem] The default JSON gem with C-extensions (ships with Ruby 1.9+)
53
- * [JSON Pure][json-gem] A Ruby variant of the JSON gem
54
- * [NSJSONSerialization][nsjson] Wrapper for Apple's NSJSONSerialization in the Cocoa Framework (MacRuby only)
55
- * [gson.rb][gson] A Ruby wrapper for google-gson library (JRuby only)
56
- * [JrJackson][jrjackson] JRuby wrapper for Jackson (JRuby only)
57
- * [OkJson][okjson] A simple, vendorable JSON parser
165
+ For more, inherit from `MultiJSON::Adapter` to pick up shared option
166
+ merging, the `defaults :load, ...` / `defaults :dump, ...` DSL, and the
167
+ blank-input short-circuit. The built-in adapters in
168
+ `lib/multi_json/adapters/` are working examples.
169
+
170
+ > [!NOTE]
171
+ > The adapter contract methods on the adapter class itself stay named
172
+ > `.load` / `.dump` in 1.21.x (and the `defaults :load, ...` / `defaults
173
+ > :dump, ...` DSL keys match). The 2.0 release renames them to `.parse` /
174
+ > `.generate` to align with the public API; if you ship a custom adapter,
175
+ > you'll need to rename those methods (and the `defaults` keys) when you
176
+ > upgrade.
177
+
178
+ MultiJSON tries to have intelligent defaulting. If any supported library is
179
+ already loaded, MultiJSON uses it before attempting to load others. When no
180
+ backend is preloaded, MultiJSON walks its preference list and uses the first
181
+ one that loads successfully. The list is split per platform — JRuby's
182
+ available adapter set differs from MRI's, and the bundled benchmark suite
183
+ ranks `json_gem` ahead of `fast_jsonparser`/`oj`/`yajl` on Ruby 3.4+. CI
184
+ re-runs the benchmark and fails if the observed ranking diverges from the
185
+ table below.
186
+
187
+ | rank | MRI / TruffleRuby | JRuby |
188
+ | ---- | ----------------- | --------------- |
189
+ | 1 | The JSON gem | `jrjackson` |
190
+ | 2 | `fast_jsonparser` | The JSON gem |
191
+ | 3 | `oj` | `gson` |
192
+ | 4 | `yajl-ruby` | — |
193
+
194
+ A dash means the adapter isn't usable on that runtime: `fast_jsonparser`,
195
+ `oj`, and `yajl-ruby` are MRI/TruffleRuby C extensions with no JRuby builds;
196
+ `jrjackson` and `gson` are JRuby-only. The JSON gem is a Ruby default gem,
197
+ so it's always available as a last-resort fallback on any supported Ruby.
198
+ If you have a workload where a different backend is faster, set it
199
+ explicitly with `MultiJSON.use(:your_adapter)`.
200
+
201
+ ## Gem Variants
202
+
203
+ MultiJSON ships as two platform-specific gems. Bundler and RubyGems
204
+ automatically select the correct variant for your Ruby implementation:
205
+
206
+ | | `ruby` platform (MRI) | `java` platform (JRuby) |
207
+ | ---------------------------------------------- | :---: | :---: |
208
+ | Runtime dependency | none | [concurrent-ruby][concurrent-ruby] `~> 1.2` |
209
+ | [`fast_jsonparser`][fast_jsonparser] adapter | ✓ | |
210
+ | [`oj`][oj] adapter | ✓ | |
211
+ | [`yajl`][yajl] adapter | ✓ | |
212
+ | [`json_gem`][json-gem] adapter | ✓ | ✓ |
213
+ | [`gson`][gson] adapter | | ✓ |
214
+ | [`jr_jackson`][jrjackson] adapter | | ✓ |
215
+ | `OptionsCache` thread-safe store | `Hash` + `Mutex` | `Concurrent::Map` |
58
216
 
59
217
  ## Supported Ruby Versions
60
- This library aims to support and is [tested against][travis] the following Ruby
218
+
219
+ This library aims to support and is [tested against](https://github.com/sferik/multi_json/actions/workflows/tests.yml) the following Ruby
61
220
  implementations:
62
221
 
63
- * Ruby 1.8
64
- * Ruby 1.9
65
- * Ruby 2.0
66
- * Ruby 2.1
67
- * Ruby 2.2
68
- * Ruby 2.4
69
- * Ruby 2.5
70
- * Ruby 2.6
71
- * Ruby 2.7
72
- * [JRuby][]
222
+ - Ruby 3.2
223
+ - Ruby 3.3
224
+ - Ruby 3.4
225
+ - Ruby 4.0
226
+ - [JRuby][jruby] 10.0 (targets Ruby 3.4 compatibility)
227
+ - [TruffleRuby][truffleruby] 33.0 (native and JVM)
73
228
 
74
229
  If something doesn't work in one of these implementations, it's a bug.
75
230
 
@@ -100,22 +255,27 @@ spec.add_dependency 'multi_json', '~> 1.0'
100
255
  ```
101
256
 
102
257
  ## Copyright
103
- Copyright (c) 2010-2018 Michael Bleigh, Josh Kalderimis, Erik Michaels-Ober,
104
- and Pavel Pravosud. See [LICENSE][] for details.
105
258
 
106
- [codeclimate]: https://codeclimate.com/github/intridea/multi_json
259
+ Copyright (c) 2010-2026 Erik Berlin, Michael Bleigh, Josh Kalderimis, and Pavel
260
+ Pravosud. See [LICENSE][license] for details.
261
+
262
+ [concurrent-ruby]: https://github.com/ruby-concurrency/concurrent-ruby
263
+ [docs]: https://github.com/sferik/multi_json/actions/workflows/docs.yml
264
+ [fast_jsonparser]: https://github.com/anilmaurya/fast_jsonparser
107
265
  [gem]: https://rubygems.org/gems/multi_json
108
266
  [gson]: https://github.com/avsej/gson.rb
109
267
  [jrjackson]: https://github.com/guyboertje/jrjackson
110
268
  [jruby]: http://www.jruby.org/
111
269
  [json-gem]: https://github.com/flori/json
112
- [json-pure]: https://github.com/flori/json
113
270
  [license]: LICENSE.md
271
+ [linter]: https://github.com/sferik/multi_json/actions/workflows/linter.yml
114
272
  [macruby]: http://www.macruby.org/
115
- [nsjson]: https://developer.apple.com/library/ios/#documentation/Foundation/Reference/NSJSONSerialization_Class/Reference/Reference.html
273
+ [mutant]: https://github.com/sferik/multi_json/actions/workflows/mutant.yml
116
274
  [oj]: https://github.com/ohler55/oj
117
- [okjson]: https://github.com/kr/okjson
118
275
  [pvc]: http://docs.rubygems.org/read/chapter/16#page74
276
+ [qlty]: https://qlty.sh/gh/sferik/projects/multi_json
119
277
  [semver]: http://semver.org/
120
- [travis]: http://travis-ci.org/intridea/multi_json
278
+ [tests]: https://github.com/sferik/multi_json/actions/workflows/tests.yml
279
+ [truffleruby]: https://www.graalvm.org/ruby/
280
+ [typecheck]: https://github.com/sferik/multi_json/actions/workflows/typecheck.yml
121
281
  [yajl]: https://github.com/brianmario/yajl-ruby
@@ -1,49 +1,234 @@
1
- require 'singleton'
2
- require 'multi_json/options'
1
+ # frozen_string_literal: true
3
2
 
4
- module MultiJson
3
+ require "singleton"
4
+ require_relative "options"
5
+
6
+ module MultiJSON
7
+ # Base class for JSON adapter implementations
8
+ #
9
+ # Each adapter wraps a specific JSON library (Oj, JSON gem, etc.) and
10
+ # provides a consistent interface. Uses Singleton pattern so each adapter
11
+ # class has exactly one instance.
12
+ #
13
+ # Subclasses must implement:
14
+ # - #load(string, options) -> parsed object
15
+ # - #dump(object, options) -> JSON string
16
+ #
17
+ # @api private
5
18
  class Adapter
6
19
  extend Options
7
20
  include Singleton
8
21
 
9
22
  class << self
23
+ BLANK_PATTERN = /\A\s*\z/
24
+ VALID_DEFAULTS_ACTIONS = %i[load dump].freeze
25
+ private_constant :BLANK_PATTERN, :VALID_DEFAULTS_ACTIONS
26
+
27
+ # Get default parse 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_parse_options
37
+ walk_default_options(:@default_load_options)
38
+ end
39
+
40
+ # Get default generate options, walking the superclass chain
41
+ #
42
+ # @api private
43
+ # @return [Hash] frozen options hash
44
+ def default_generate_options
45
+ walk_default_options(:@default_dump_options)
46
+ end
47
+
48
+ # Get default parse options, walking the superclass chain
49
+ #
50
+ # @api private
51
+ # @deprecated Use {.default_parse_options} instead. Will be removed in v2.0.
52
+ # @return [Hash] frozen options hash
53
+ def default_load_options
54
+ default_parse_options
55
+ end
56
+
57
+ # Get default generate options, walking the superclass chain
58
+ #
59
+ # @api private
60
+ # @deprecated Use {.default_generate_options} instead. Will be removed in v2.0.
61
+ # @return [Hash] frozen options hash
62
+ def default_dump_options
63
+ default_generate_options
64
+ end
65
+
66
+ # DSL for setting adapter-specific default options
67
+ #
68
+ # ``action`` must be ``:load`` or ``:dump``; ``value`` must be a
69
+ # Hash. Both arguments are validated up front so a typo at the
70
+ # adapter's class definition fails fast instead of producing a
71
+ # silent no-op default that crashes later in the merge path.
72
+ #
73
+ # @api private
74
+ # @param action [Symbol] :load or :dump
75
+ # @param value [Hash] default options for the action
76
+ # @return [Hash] the frozen options hash
77
+ # @raise [ArgumentError] when action is anything other than :load
78
+ # or :dump, or when value isn't a Hash
79
+ # @example Set load defaults for an adapter
80
+ # class MyAdapter < MultiJSON::Adapter
81
+ # defaults :load, symbolize_keys: false
82
+ # end
10
83
  def defaults(action, value)
11
- metaclass = class << self; self; end
84
+ raise ArgumentError, "expected action to be :load or :dump, got #{action.inspect}" unless VALID_DEFAULTS_ACTIONS.include?(action)
85
+ raise ArgumentError, "expected value to be a Hash, got #{value.class}" unless value.is_a?(Hash)
12
86
 
13
- metaclass.instance_eval do
14
- define_method("default_#{action}_options") { value }
15
- end
87
+ instance_variable_set(:"@default_#{action}_options", value.freeze)
16
88
  end
17
89
 
90
+ # Parse a JSON string into a Ruby object
91
+ #
92
+ # @api private
93
+ # @param string [String, #read] JSON string or IO-like object
94
+ # @param options [Hash] parsing options
95
+ # @return [Object, nil] parsed object or nil for blank input
18
96
  def load(string, options = {})
19
97
  string = string.read if string.respond_to?(:read)
20
- fail self::ParseError if blank?(string)
21
- instance.load(string, cached_load_options(options))
98
+ return nil if blank?(string)
99
+
100
+ instance.load(string, merged_load_options(options))
22
101
  end
23
102
 
103
+ # Serialize a Ruby object to JSON
104
+ #
105
+ # @api private
106
+ # @param object [Object] object to serialize
107
+ # @param options [Hash] serialization options
108
+ # @return [String] JSON string
24
109
  def dump(object, options = {})
25
- instance.dump(object, cached_dump_options(options))
110
+ instance.dump(object, merged_dump_options(options))
26
111
  end
27
112
 
28
- private
113
+ private
29
114
 
115
+ # Walk the superclass chain looking for a default options ivar
116
+ #
117
+ # Stops at the first ancestor whose ``ivar`` is set and returns
118
+ # that value. Returns {Options::EMPTY_OPTIONS} when no ancestor
119
+ # has the ivar set, so adapters without defaults always observe a
120
+ # frozen empty hash instead of nil.
121
+ #
122
+ # @api private
123
+ # @param ivar [Symbol] ivar name (`:@default_load_options` or `:@default_dump_options`)
124
+ # @return [Hash] frozen options hash
125
+ def walk_default_options(ivar)
126
+ # @type var klass: Class?
127
+ klass = self
128
+ while klass
129
+ return klass.instance_variable_get(ivar) if klass.instance_variable_defined?(ivar)
130
+
131
+ klass = klass.superclass
132
+ end
133
+ Options::EMPTY_OPTIONS
134
+ end
135
+
136
+ # Checks if the input is blank (nil, empty, or whitespace-only)
137
+ #
138
+ # The dominant call path arrives with a non-blank string starting
139
+ # with ``{`` or ``[`` (the JSON object/array sigils), so a
140
+ # ``start_with?`` short-circuit skips the regex entirely on the
141
+ # hot path. Falls through to the full check for everything else
142
+ # — strings, numbers, booleans, ``null``, whitespace-prefixed
143
+ # input — at which point ``String#scrub`` is only invoked when
144
+ # the input has invalid encoding so the common valid-UTF-8 path
145
+ # doesn't allocate a scrubbed copy on every call. Scrubbing
146
+ # replaces invalid bytes with U+FFFD before the regex runs so a
147
+ # string with bad bytes is still treated as non-blank without a
148
+ # broad rescue.
149
+ #
150
+ # @api private
151
+ # @param input [String, nil] input to check
152
+ # @return [Boolean] true if input is blank
30
153
  def blank?(input)
31
- input.nil? || /\A\s*\z/ === input
32
- rescue ArgumentError # invalid byte sequence in UTF-8
33
- false
154
+ return true if input.nil? || input.empty?
155
+ return false if input.start_with?("{", "[")
156
+
157
+ BLANK_PATTERN.match?(input.valid_encoding? ? input : input.scrub)
34
158
  end
35
159
 
36
- def cached_dump_options(options)
37
- OptionsCache.fetch(:dump, options) do
38
- dump_options(options).merge(MultiJson.dump_options(options)).merge!(options)
160
+ # Merges generate options from adapter, global, and call-site
161
+ #
162
+ # @api private
163
+ # @param options [Hash] call-site options
164
+ # @return [Hash] merged options hash
165
+ def merged_dump_options(options)
166
+ cache_key = strip_adapter_key(options)
167
+ OptionsCache.dump.fetch(cache_key) do
168
+ generate_options(cache_key).merge(MultiJSON.generate_options(cache_key)).merge!(cache_key)
39
169
  end
40
170
  end
41
171
 
42
- def cached_load_options(options)
43
- OptionsCache.fetch(:load, options) do
44
- load_options(options).merge(MultiJson.load_options(options)).merge!(options)
172
+ # Merges parse options from adapter, global, and call-site
173
+ #
174
+ # Each layer is normalized first so a deprecated ``:symbolize_keys``
175
+ # key in any source becomes the canonical ``:symbolize_names`` —
176
+ # done per-layer rather than post-merge so the expected override
177
+ # semantics (call-site > global > adapter default) still apply
178
+ # when a caller mixes the deprecated and canonical names across
179
+ # layers.
180
+ #
181
+ # @api private
182
+ # @param options [Hash] call-site options
183
+ # @return [Hash] merged options hash
184
+ def merged_load_options(options)
185
+ cache_key = strip_adapter_key(options)
186
+ OptionsCache.load.fetch(cache_key) do
187
+ adapter = normalize_symbolize_option(parse_options(cache_key))
188
+ global = normalize_symbolize_option(MultiJSON.parse_options(cache_key))
189
+ call_site = normalize_symbolize_option(cache_key)
190
+ adapter.merge(global).merge!(call_site)
45
191
  end
46
192
  end
193
+
194
+ # Translate the deprecated ``:symbolize_keys`` option to ``:symbolize_names``
195
+ #
196
+ # Matches Ruby stdlib's ``JSON.parse`` naming. Emits a one-time
197
+ # deprecation warning on first encounter of ``:symbolize_keys``.
198
+ # When both names appear in the same layer (unusual — only
199
+ # possible if the caller explicitly set both), the canonical
200
+ # ``:symbolize_names`` value wins and ``:symbolize_keys`` is
201
+ # silently dropped.
202
+ #
203
+ # @api private
204
+ # @param options [Hash] options layer to normalize
205
+ # @return [Hash] hash with ``:symbolize_keys`` translated, or the
206
+ # original hash when no translation is needed
207
+ def normalize_symbolize_option(options)
208
+ return options unless options.key?(:symbolize_keys)
209
+
210
+ MultiJSON.warn_deprecation_once(:symbolize_keys_option,
211
+ "The :symbolize_keys option is deprecated and will be removed in v2.0. Use :symbolize_names instead.")
212
+
213
+ new_opts = options.except(:symbolize_keys)
214
+ new_opts[:symbolize_names] = options[:symbolize_keys] unless new_opts.key?(:symbolize_names)
215
+ new_opts
216
+ end
217
+
218
+ # Removes the :adapter key from options for cache key
219
+ #
220
+ # Returns a shared frozen empty hash for the common no-options call
221
+ # path so the hot path avoids allocating a fresh hash on every call.
222
+ #
223
+ # @api private
224
+ # @param options [Hash, #to_h] original options (may be JSON::State or similar)
225
+ # @return [Hash] frozen options without :adapter key
226
+ def strip_adapter_key(options)
227
+ options = options.to_h unless options.is_a?(Hash)
228
+ return Options::EMPTY_OPTIONS if options.empty? || (options.size == 1 && options.key?(:adapter))
229
+
230
+ options.except(:adapter).freeze
231
+ end
47
232
  end
48
233
  end
49
234
  end
@@ -1,15 +1,42 @@
1
- module MultiJson
1
+ # frozen_string_literal: true
2
+
3
+ module MultiJSON
4
+ # Raised when an adapter cannot be loaded or is not recognized
5
+ #
6
+ # @api public
2
7
  class AdapterError < ArgumentError
3
- attr_reader :cause
8
+ # Create a new AdapterError
9
+ #
10
+ # @api public
11
+ # @param message [String, nil] error message
12
+ # @param cause [Exception, nil] the original exception
13
+ # @return [AdapterError] new error instance
14
+ # @example
15
+ # AdapterError.new("Unknown adapter", cause: original_error)
16
+ def initialize(message = nil, cause: nil)
17
+ super(message)
18
+ set_backtrace(cause.backtrace) if cause
19
+ end
4
20
 
21
+ # Build an AdapterError from an original exception
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
+ #
29
+ # @api public
30
+ # @param original_exception [Exception] the original load error
31
+ # @return [AdapterError] new error with formatted message
32
+ # @example
33
+ # AdapterError.build(LoadError.new("cannot load such file"))
5
34
  def self.build(original_exception)
6
- message = "Did not recognize your adapter specification (#{original_exception.message})."
7
- new(message).tap do |exception|
8
- exception.instance_eval do
9
- @cause = original_exception
10
- set_backtrace original_exception.backtrace
11
- end
12
- end
35
+ new(
36
+ "Did not recognize your adapter specification " \
37
+ "(#{original_exception.class}: #{original_exception.message}).",
38
+ cause: original_exception
39
+ )
13
40
  end
14
41
  end
15
42
  end