multi_json 1.19.1 → 1.20.0

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.
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Deprecated public API kept around for one major release
4
+ #
5
+ # Each method here emits a one-time deprecation warning on first call and
6
+ # delegates to its current-API counterpart. The whole file is loaded by
7
+ # {MultiJson} so the deprecation surface stays out of the main module
8
+ # definition.
9
+ #
10
+ # @api private
11
+ module MultiJson
12
+ class << self
13
+ private
14
+
15
+ # Define a deprecated alias that delegates to a new method name
16
+ #
17
+ # The generated singleton method emits a one-time deprecation
18
+ # warning naming the replacement, then forwards all positional and
19
+ # keyword arguments plus any block to ``replacement``. Used for the
20
+ # ``decode`` / ``encode`` / ``engine*`` / ``with_engine`` /
21
+ # ``default_engine`` aliases that are scheduled for removal in v2.0.
22
+ #
23
+ # @api private
24
+ # @param name [Symbol] deprecated method name
25
+ # @param replacement [Symbol] current-API method to delegate to
26
+ # @return [Symbol] the defined method name
27
+ # @example
28
+ # deprecate_alias :decode, :load
29
+ def deprecate_alias(name, replacement)
30
+ message = "MultiJson.#{name} is deprecated and will be removed in v2.0. Use MultiJson.#{replacement} instead."
31
+ define_singleton_method(name) do |*args, **kwargs, &block|
32
+ warn_deprecation_once(name, message)
33
+ public_send(replacement, *args, **kwargs, &block)
34
+ end
35
+ end
36
+
37
+ # Define a deprecated method whose body needs custom delegation
38
+ #
39
+ # Used for the ``default_options`` / ``default_options=`` pair
40
+ # whose body fans out to multiple replacement methods, and for the
41
+ # ``cached_options`` / ``reset_cached_options!`` no-op stubs that
42
+ # have no current-API counterpart at all. The block runs in its
43
+ # own lexical ``self``, which is the ``MultiJson`` module since
44
+ # every call site sits inside ``module MultiJson`` below.
45
+ #
46
+ # @api private
47
+ # @param name [Symbol] deprecated method name
48
+ # @param message [String] warning to emit on first call
49
+ # @yield body to evaluate after the warning
50
+ # @return [Symbol] the defined method name
51
+ # @example
52
+ # deprecate_method(:cached_options, "...") { nil }
53
+ def deprecate_method(name, message, &body)
54
+ define_singleton_method(name) do |*args, **kwargs|
55
+ warn_deprecation_once(name, message)
56
+ body.call(*args, **kwargs)
57
+ end
58
+ end
59
+ end
60
+
61
+ deprecate_alias :decode, :load
62
+ deprecate_alias :encode, :dump
63
+ deprecate_alias :engine, :adapter
64
+ deprecate_alias :engine=, :adapter=
65
+ deprecate_alias :default_engine, :default_adapter
66
+ deprecate_alias :with_engine, :with_adapter
67
+
68
+ deprecate_method(
69
+ :default_options=,
70
+ "MultiJson.default_options setter is deprecated\n" \
71
+ "Use MultiJson.load_options and MultiJson.dump_options instead"
72
+ ) { |value| self.load_options = self.dump_options = value }
73
+
74
+ deprecate_method(
75
+ :default_options,
76
+ "MultiJson.default_options is deprecated\n" \
77
+ "Use MultiJson.load_options or MultiJson.dump_options instead"
78
+ ) { load_options }
79
+
80
+ %i[cached_options reset_cached_options!].each do |name|
81
+ deprecate_method(name, "MultiJson.#{name} method is deprecated and no longer used.") { nil }
82
+ end
83
+
84
+ private
85
+
86
+ # Instance-method delegate for the deprecated default_options setter
87
+ #
88
+ # @api private
89
+ # @deprecated Use {MultiJson.load_options=} and {MultiJson.dump_options=} instead
90
+ # @param value [Hash] options hash
91
+ # @return [Hash] the options hash
92
+ # @example
93
+ # class Foo; include MultiJson; end
94
+ # Foo.new.send(:default_options=, symbolize_keys: true)
95
+ def default_options=(value)
96
+ MultiJson.default_options = value
97
+ end
98
+
99
+ # Instance-method delegate for the deprecated default_options getter
100
+ #
101
+ # @api private
102
+ # @deprecated Use {MultiJson.load_options} or {MultiJson.dump_options} instead
103
+ # @return [Hash] the current load options
104
+ # @example
105
+ # class Foo; include MultiJson; end
106
+ # Foo.new.send(:default_options)
107
+ def default_options
108
+ MultiJson.default_options
109
+ end
110
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module MultiJson
2
4
  # Mixin providing configurable load/dump options
3
5
  #
@@ -6,14 +8,20 @@ module MultiJson
6
8
  #
7
9
  # @api private
8
10
  module Options
9
- EMPTY_OPTIONS = {}.freeze
10
- private_constant :EMPTY_OPTIONS
11
+ # Steep needs an inline `#:` annotation here because `{}.freeze`
12
+ # would be inferred as `Hash[untyped, untyped]` and trip
13
+ # `UnannotatedEmptyCollection`. The annotation requires
14
+ # `Hash.new.freeze` (not the `{}.freeze` rubocop would prefer)
15
+ # because the `#:` cast only applies to method-call results.
16
+ EMPTY_OPTIONS = Hash.new.freeze #: options # rubocop:disable Style/EmptyLiteral
11
17
 
12
18
  # Set options for load operations
13
19
  #
14
- # @api private
20
+ # @api public
15
21
  # @param options [Hash, Proc] options hash or callable
16
22
  # @return [Hash, Proc] the options
23
+ # @example
24
+ # MultiJson.load_options = {symbolize_keys: true}
17
25
  def load_options=(options)
18
26
  OptionsCache.reset
19
27
  @load_options = options
@@ -21,9 +29,11 @@ module MultiJson
21
29
 
22
30
  # Set options for dump operations
23
31
  #
24
- # @api private
32
+ # @api public
25
33
  # @param options [Hash, Proc] options hash or callable
26
34
  # @return [Hash, Proc] the options
35
+ # @example
36
+ # MultiJson.dump_options = {pretty: true}
27
37
  def dump_options=(options)
28
38
  OptionsCache.reset
29
39
  @dump_options = options
@@ -31,18 +41,29 @@ module MultiJson
31
41
 
32
42
  # Get options for load operations
33
43
  #
34
- # @api private
44
+ # When `@load_options` is a callable (proc/lambda), it's invoked
45
+ # with `args` as positional arguments — typically the merged
46
+ # options hash from `Adapter.merged_load_options`. When it's a
47
+ # plain hash, `args` is ignored.
48
+ #
49
+ # @api public
50
+ # @param args [Array<Object>] forwarded to the callable, ignored otherwise
35
51
  # @return [Hash] resolved options hash
36
- def load_options(...)
37
- resolve_options(@load_options, ...) || default_load_options
52
+ # @example
53
+ # MultiJson.load_options #=> {}
54
+ def load_options(*args)
55
+ resolve_options(@load_options, *args) || default_load_options
38
56
  end
39
57
 
40
58
  # Get options for dump operations
41
59
  #
42
- # @api private
60
+ # @api public
61
+ # @param args [Array<Object>] forwarded to the callable, ignored otherwise
43
62
  # @return [Hash] resolved options hash
44
- def dump_options(...)
45
- resolve_options(@dump_options, ...) || default_dump_options
63
+ # @example
64
+ # MultiJson.dump_options #=> {}
65
+ def dump_options(*args)
66
+ resolve_options(@dump_options, *args) || default_dump_options
46
67
  end
47
68
 
48
69
  # Get default load options
@@ -50,7 +71,7 @@ module MultiJson
50
71
  # @api private
51
72
  # @return [Hash] frozen empty hash
52
73
  def default_load_options
53
- @default_load_options ||= EMPTY_OPTIONS
74
+ Concurrency.synchronize(:default_options) { @default_load_options ||= EMPTY_OPTIONS }
54
75
  end
55
76
 
56
77
  # Get default dump options
@@ -58,7 +79,7 @@ module MultiJson
58
79
  # @api private
59
80
  # @return [Hash] frozen empty hash
60
81
  def default_dump_options
61
- @default_dump_options ||= EMPTY_OPTIONS
82
+ Concurrency.synchronize(:default_options) { @default_dump_options ||= EMPTY_OPTIONS }
62
83
  end
63
84
 
64
85
  private
@@ -67,9 +88,13 @@ module MultiJson
67
88
  #
68
89
  # @api private
69
90
  # @param options [Hash, Proc, nil] options configuration
91
+ # @param args [Array<Object>] arguments forwarded to a callable provider
70
92
  # @return [Hash, nil] resolved options hash
71
- def resolve_options(options, ...)
72
- return invoke_callable(options, ...) if options.respond_to?(:call)
93
+ def resolve_options(options, *args)
94
+ if options.respond_to?(:call)
95
+ # @type var options: options_proc
96
+ return invoke_callable(options, *args)
97
+ end
73
98
 
74
99
  options.to_hash if options.respond_to?(:to_hash)
75
100
  end
@@ -78,9 +103,10 @@ module MultiJson
78
103
  #
79
104
  # @api private
80
105
  # @param callable [Proc] options provider
106
+ # @param args [Array<Object>] arguments forwarded when the callable is non-arity-zero
81
107
  # @return [Hash] options returned by the callable
82
- def invoke_callable(callable, ...)
83
- callable.arity.zero? ? callable.call : callable.call(...)
108
+ def invoke_callable(callable, *args)
109
+ callable.arity.zero? ? callable.call : callable.call(*args)
84
110
  end
85
111
  end
86
112
  end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MultiJson
4
+ module OptionsCache
5
+ # Thread-safe cache store backed by a Hash guarded by a Mutex
6
+ #
7
+ # Used on MRI and TruffleRuby, where a runtime dependency on
8
+ # concurrent-ruby would be overkill: the GVL on MRI makes single
9
+ # lookups atomic, and locking on both reads and writes keeps the
10
+ # small perf cost predictable across engines without adding a
11
+ # runtime dependency.
12
+ #
13
+ # @api private
14
+ class Store
15
+ # Create a new cache store
16
+ #
17
+ # @api private
18
+ # @return [Store] new store instance
19
+ def initialize
20
+ @cache = {}
21
+ @mutex = Mutex.new
22
+ end
23
+
24
+ # Clear all cached entries
25
+ #
26
+ # Held under the mutex because TruffleRuby (which also uses this
27
+ # backend via the ruby-platform gem) has true parallelism: a
28
+ # concurrent ``fetch`` racing with ``Hash#clear`` could corrupt
29
+ # iteration in a way that MRI's GVL would otherwise prevent.
30
+ #
31
+ # @api private
32
+ # @return [void]
33
+ def reset
34
+ @mutex.synchronize { @cache.clear }
35
+ end
36
+
37
+ # Fetch a value from cache or compute it
38
+ #
39
+ # When called with a block, returns the cached value or computes a
40
+ # new one. When called without a block, returns the cached value or
41
+ # the supplied default if the key is missing. Nil cached values are
42
+ # preserved because ``Hash#fetch`` only falls through to the default
43
+ # block when the key is truly missing. The ``block_given?`` check
44
+ # is hoisted out of the mutex so the no-block read path runs the
45
+ # check once per call instead of once inside the critical section.
46
+ #
47
+ # @api private
48
+ # @param key [Object] cache key
49
+ # @param default [Object] value to return when key is missing and no
50
+ # block is given
51
+ # @yield block to compute value if not cached
52
+ # @return [Object] cached, computed, or default value
53
+ def fetch(key, default = nil)
54
+ return @mutex.synchronize { @cache.fetch(key) { default } } unless block_given?
55
+
56
+ @mutex.synchronize do
57
+ @cache.fetch(key) do
58
+ @cache.shift if @cache.size >= OptionsCache.max_cache_size
59
+ @cache[key] = yield
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -1,74 +1,21 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module MultiJson
2
- # Thread-safe LRU-like cache for merged options hashes
4
+ # Thread-safe bounded cache for merged options hashes
3
5
  #
4
6
  # Caches are separated for load and dump operations. Each cache is
5
7
  # bounded to prevent unbounded memory growth when options are
6
- # generated dynamically.
8
+ # generated dynamically. The ``Store`` backend is chosen at load time
9
+ # based on ``RUBY_ENGINE``: JRuby uses Concurrent::Map (shipped as a
10
+ # runtime dependency of the java-platform gem); MRI and TruffleRuby
11
+ # use a Hash guarded by a Mutex.
7
12
  #
8
13
  # @api private
9
14
  module OptionsCache
10
- # Maximum entries before oldest entry is evicted
11
- MAX_CACHE_SIZE = 1000
12
-
13
- # Thread-safe cache store using double-checked locking pattern
14
- #
15
- # @api private
16
- class Store
17
- # Sentinel value to detect cache misses (unique object identity)
18
- NOT_FOUND = Object.new
19
-
20
- # Create a new cache store
21
- #
22
- # @api private
23
- # @return [Store] new store instance
24
- def initialize
25
- @cache = {}
26
- @mutex = Mutex.new
27
- end
28
-
29
- # Clear all cached entries
30
- #
31
- # @api private
32
- # @return [void]
33
- def reset
34
- @mutex.synchronize { @cache.clear }
35
- end
36
-
37
- # Fetch a value from cache or compute it
38
- #
39
- # @api private
40
- # @param key [Object] cache key
41
- # @param default [Object] default value if key not found
42
- # @yield block to compute value if not cached
43
- # @return [Object] cached or computed value
44
- def fetch(key, default = nil)
45
- # Fast path: check cache without lock (safe for reads)
46
- value = @cache.fetch(key, NOT_FOUND)
47
- return value unless value.equal?(NOT_FOUND)
48
-
49
- # Slow path: acquire lock and compute value
50
- @mutex.synchronize do
51
- @cache.fetch(key) { block_given? ? store(key, yield) : default }
52
- end
53
- end
54
-
55
- private
56
-
57
- # Stores a value in the cache with LRU eviction
58
- #
59
- # @api private
60
- # @param key [Object] cache key
61
- # @param value [Object] value to store
62
- # @return [Object] the stored value
63
- def store(key, value)
64
- # Double-check in case another thread computed while we waited
65
- @cache.fetch(key) do
66
- # Evict oldest entry if at capacity (Hash maintains insertion order)
67
- @cache.shift if @cache.size >= MAX_CACHE_SIZE
68
- @cache[key] = value
69
- end
70
- end
71
- end
15
+ # Default bound on the number of cached entries per store. Applications
16
+ # that dynamically generate many distinct option hashes can raise this
17
+ # via {.max_cache_size=}.
18
+ DEFAULT_MAX_CACHE_SIZE = 1000
72
19
 
73
20
  class << self
74
21
  # Get the dump options cache
@@ -83,6 +30,19 @@ module MultiJson
83
30
  # @return [Store] load cache store
84
31
  attr_reader :load
85
32
 
33
+ # Maximum number of entries per cache store
34
+ #
35
+ # Applies to both the dump and load caches. Existing entries are
36
+ # left in place until normal eviction trims them below a lowered
37
+ # limit; call {.reset} if you need to evict immediately.
38
+ #
39
+ # @api public
40
+ # @return [Integer] current cache size limit
41
+ # @example
42
+ # MultiJson::OptionsCache.max_cache_size = 5000
43
+ # MultiJson::OptionsCache.max_cache_size #=> 5000
44
+ attr_accessor :max_cache_size
45
+
86
46
  # Reset both caches
87
47
  #
88
48
  # @api private
@@ -93,6 +53,20 @@ module MultiJson
93
53
  end
94
54
  end
95
55
 
96
- reset
56
+ self.max_cache_size = DEFAULT_MAX_CACHE_SIZE
57
+ end
58
+ end
59
+
60
+ module MultiJson
61
+ module OptionsCache
62
+ # Dynamic require path so MRI (mutex_store) and JRuby
63
+ # (concurrent_store) execute the same physical line, avoiding a
64
+ # dead-branch ``require_relative`` that would otherwise drop
65
+ # JRuby's line coverage below 100%.
66
+ BACKENDS = {"jruby" => "concurrent_store"}.freeze
67
+ private_constant :BACKENDS
97
68
  end
98
69
  end
70
+
71
+ require_relative "options_cache/#{MultiJson::OptionsCache.send(:const_get, :BACKENDS).fetch(RUBY_ENGINE, "mutex_store")}"
72
+ MultiJson::OptionsCache.reset
@@ -1,10 +1,24 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module MultiJson
2
4
  # Raised when JSON parsing fails
3
5
  #
4
- # Wraps the underlying adapter's parse error with the original input data.
6
+ # Wraps the underlying adapter's parse error with the original input
7
+ # data, plus best-effort line and column extraction from the adapter's
8
+ # error message. Line/column are populated for adapters that include
9
+ # them in their messages (Oj, the json gem) and remain nil for
10
+ # adapters that don't (Yajl, fast_jsonparser).
5
11
  #
6
12
  # @api public
7
13
  class ParseError < StandardError
14
+ # Regex that matches the "line N[, ]column M" fragment inside an
15
+ # adapter error message. The separator between line and column is
16
+ # permissive — Oj emits ``"line 1, column 3"`` while the json gem
17
+ # emits ``"line 1 column 2"`` — so ``[,\s]+`` covers both. Column
18
+ # is optional so messages like ``"at line 5"`` still yield a line.
19
+ LOCATION_PATTERN = /line\s+(\d+)(?:[,\s]+column\s+(\d+))?/i
20
+ private_constant :LOCATION_PATTERN
21
+
8
22
  # The input string that failed to parse
9
23
  #
10
24
  # @api public
@@ -13,6 +27,24 @@ module MultiJson
13
27
  # error.data #=> "{invalid json}"
14
28
  attr_reader :data
15
29
 
30
+ # The 1-based line number reported by the adapter
31
+ #
32
+ # @api public
33
+ # @return [Integer, nil] line number, or nil if the adapter's message
34
+ # did not include one
35
+ # @example
36
+ # error.line #=> 1
37
+ attr_reader :line
38
+
39
+ # The 1-based column reported by the adapter
40
+ #
41
+ # @api public
42
+ # @return [Integer, nil] column number, or nil if the adapter's message
43
+ # did not include one
44
+ # @example
45
+ # error.column #=> 3
46
+ attr_reader :column
47
+
16
48
  # Create a new ParseError
17
49
  #
18
50
  # @api public
@@ -21,10 +53,13 @@ module MultiJson
21
53
  # @param cause [Exception, nil] the original exception
22
54
  # @return [ParseError] new error instance
23
55
  # @example
24
- # ParseError.new("unexpected token", data: "{invalid}", cause: err)
56
+ # ParseError.new("unexpected token at line 1 column 2", data: "{}")
25
57
  def initialize(message = nil, data: nil, cause: nil)
26
58
  super(message)
27
59
  @data = data
60
+ match = location_match(message)
61
+ @line = match && Integer(match[1])
62
+ @column = match && match[2] && Integer(match[2])
28
63
  set_backtrace(cause.backtrace) if cause
29
64
  end
30
65
 
@@ -39,6 +74,28 @@ module MultiJson
39
74
  def self.build(original_exception, data)
40
75
  new(original_exception.message, data: data, cause: original_exception)
41
76
  end
77
+
78
+ private
79
+
80
+ # Match an adapter error message against the line/column pattern
81
+ #
82
+ # Adapter error messages sometimes embed bytes from the failing
83
+ # input (e.g., the json gem's ``"invalid byte sequence in UTF-8"``
84
+ # error). The pattern is pure ASCII so it's compatible with any
85
+ # encoding, but a UTF-8 string with invalid bytes still trips the
86
+ # regex engine — ``String#scrub`` replaces those bytes so the
87
+ # match can proceed. Strings in binary (ASCII-8BIT) or any valid
88
+ # encoding pass through scrub untouched.
89
+ #
90
+ # @api private
91
+ # @param message [String, nil] the adapter's error message
92
+ # @return [MatchData, nil] the regex match, or nil if no message or
93
+ # no location fragment was found
94
+ def location_match(message)
95
+ return unless message
96
+
97
+ LOCATION_PATTERN.match(message.scrub)
98
+ end
42
99
  end
43
100
 
44
101
  # Legacy aliases for backward compatibility
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module MultiJson
2
4
  # Version information for MultiJson
3
5
  #
@@ -6,9 +8,9 @@ module MultiJson
6
8
  # Major version number
7
9
  MAJOR = 1 unless defined? MultiJson::Version::MAJOR
8
10
  # Minor version number
9
- MINOR = 19 unless defined? MultiJson::Version::MINOR
11
+ MINOR = 20 unless defined? MultiJson::Version::MINOR
10
12
  # Patch version number
11
- PATCH = 1 unless defined? MultiJson::Version::PATCH
13
+ PATCH = 0 unless defined? MultiJson::Version::PATCH
12
14
  # Pre-release version suffix
13
15
  PRE = nil unless defined? MultiJson::Version::PRE
14
16