multi_json 1.15.0 → 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.
@@ -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,39 +1,112 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module MultiJson
4
+ # Mixin providing configurable load/dump options
5
+ #
6
+ # Supports static hashes or dynamic callables (procs/lambdas).
7
+ # Extended by both MultiJson (global options) and Adapter classes.
8
+ #
9
+ # @api private
2
10
  module 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
17
+
18
+ # Set options for load operations
19
+ #
20
+ # @api public
21
+ # @param options [Hash, Proc] options hash or callable
22
+ # @return [Hash, Proc] the options
23
+ # @example
24
+ # MultiJson.load_options = {symbolize_keys: true}
3
25
  def load_options=(options)
4
26
  OptionsCache.reset
5
27
  @load_options = options
6
28
  end
7
29
 
30
+ # Set options for dump operations
31
+ #
32
+ # @api public
33
+ # @param options [Hash, Proc] options hash or callable
34
+ # @return [Hash, Proc] the options
35
+ # @example
36
+ # MultiJson.dump_options = {pretty: true}
8
37
  def dump_options=(options)
9
38
  OptionsCache.reset
10
39
  @dump_options = options
11
40
  end
12
41
 
42
+ # Get options for load operations
43
+ #
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
51
+ # @return [Hash] resolved options hash
52
+ # @example
53
+ # MultiJson.load_options #=> {}
13
54
  def load_options(*args)
14
- defined?(@load_options) && get_options(@load_options, *args) || default_load_options
55
+ resolve_options(@load_options, *args) || default_load_options
15
56
  end
16
57
 
58
+ # Get options for dump operations
59
+ #
60
+ # @api public
61
+ # @param args [Array<Object>] forwarded to the callable, ignored otherwise
62
+ # @return [Hash] resolved options hash
63
+ # @example
64
+ # MultiJson.dump_options #=> {}
17
65
  def dump_options(*args)
18
- defined?(@dump_options) && get_options(@dump_options, *args) || default_dump_options
66
+ resolve_options(@dump_options, *args) || default_dump_options
19
67
  end
20
68
 
69
+ # Get default load options
70
+ #
71
+ # @api private
72
+ # @return [Hash] frozen empty hash
21
73
  def default_load_options
22
- @default_load_options ||= {}
74
+ Concurrency.synchronize(:default_options) { @default_load_options ||= EMPTY_OPTIONS }
23
75
  end
24
76
 
77
+ # Get default dump options
78
+ #
79
+ # @api private
80
+ # @return [Hash] frozen empty hash
25
81
  def default_dump_options
26
- @default_dump_options ||= {}
82
+ Concurrency.synchronize(:default_options) { @default_dump_options ||= EMPTY_OPTIONS }
27
83
  end
28
84
 
29
- private
85
+ private
30
86
 
31
- def get_options(options, *args)
32
- if options.respond_to?(:call) && options.arity
33
- options.arity == 0 ? options[] : options[*args]
34
- elsif options.respond_to?(:to_hash)
35
- options.to_hash
87
+ # Resolves options from a hash or callable
88
+ #
89
+ # @api private
90
+ # @param options [Hash, Proc, nil] options configuration
91
+ # @param args [Array<Object>] arguments forwarded to a callable provider
92
+ # @return [Hash, nil] resolved options hash
93
+ def resolve_options(options, *args)
94
+ if options.respond_to?(:call)
95
+ # @type var options: options_proc
96
+ return invoke_callable(options, *args)
36
97
  end
98
+
99
+ options.to_hash if options.respond_to?(:to_hash)
100
+ end
101
+
102
+ # Invokes a callable options provider
103
+ #
104
+ # @api private
105
+ # @param callable [Proc] options provider
106
+ # @param args [Array<Object>] arguments forwarded when the callable is non-arity-zero
107
+ # @return [Hash] options returned by the callable
108
+ def invoke_callable(callable, *args)
109
+ callable.arity.zero? ? callable.call : callable.call(*args)
37
110
  end
38
111
  end
39
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,29 +1,86 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module MultiJson
4
+ # Thread-safe bounded cache for merged options hashes
5
+ #
6
+ # Caches are separated for load and dump operations. Each cache is
7
+ # bounded to prevent unbounded memory growth when options are
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.
12
+ #
13
+ # @api private
2
14
  module OptionsCache
3
- extend self
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
4
19
 
5
- def reset
6
- @dump_cache = {}
7
- @load_cache = {}
8
- end
20
+ class << self
21
+ # Get the dump options cache
22
+ #
23
+ # @api private
24
+ # @return [Store] dump cache store
25
+ attr_reader :dump
9
26
 
10
- def fetch(type, key, &block)
11
- cache = instance_variable_get("@#{type}_cache")
12
- cache.key?(key) ? cache[key] : write(cache, key, &block)
13
- end
27
+ # Get the load options cache
28
+ #
29
+ # @api private
30
+ # @return [Store] load cache store
31
+ attr_reader :load
14
32
 
15
- private
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_reader :max_cache_size
16
45
 
17
- # Normally MultiJson is used with a few option sets for both dump/load
18
- # methods. When options are generated dynamically though, every call would
19
- # cause a cache miss and the cache would grow indefinitely. To prevent
20
- # this, we just reset the cache every time the number of keys outgrows
21
- # 1000.
22
- MAX_CACHE_SIZE = 1000
46
+ # Set the maximum number of entries per cache store
47
+ #
48
+ # @api public
49
+ # @param value [Integer] positive entry cap
50
+ # @return [Integer] the validated value
51
+ # @raise [ArgumentError] when value is not a positive Integer
52
+ # @example
53
+ # MultiJson::OptionsCache.max_cache_size = 5000
54
+ def max_cache_size=(value)
55
+ raise ArgumentError, "max_cache_size must be a positive Integer, got #{value.inspect}" unless Integer === value && value.positive? # rubocop:disable Style/CaseEquality
23
56
 
24
- def write(cache, key)
25
- cache.clear if cache.length >= MAX_CACHE_SIZE
26
- cache[key] = yield
57
+ @max_cache_size = value
58
+ end
59
+
60
+ # Reset both caches
61
+ #
62
+ # @api private
63
+ # @return [void]
64
+ def reset
65
+ @dump = Store.new
66
+ @load = Store.new
67
+ end
27
68
  end
69
+
70
+ self.max_cache_size = DEFAULT_MAX_CACHE_SIZE
28
71
  end
29
72
  end
73
+
74
+ module MultiJson
75
+ module OptionsCache
76
+ # Dynamic require path so MRI (mutex_store) and JRuby
77
+ # (concurrent_store) execute the same physical line, avoiding a
78
+ # dead-branch ``require_relative`` that would otherwise drop
79
+ # JRuby's line coverage below 100%.
80
+ BACKENDS = {"jruby" => "concurrent_store"}.freeze
81
+ private_constant :BACKENDS
82
+ end
83
+ end
84
+
85
+ require_relative "options_cache/#{MultiJson::OptionsCache.send(:const_get, :BACKENDS).fetch(RUBY_ENGINE, "mutex_store")}"
86
+ MultiJson::OptionsCache.reset
@@ -1,17 +1,103 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module MultiJson
4
+ # Raised when JSON parsing fails
5
+ #
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).
11
+ #
12
+ # @api public
2
13
  class ParseError < StandardError
3
- attr_reader :data, :cause
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
+
22
+ # The input string that failed to parse
23
+ #
24
+ # @api public
25
+ # @return [String, nil] the original input data
26
+ # @example
27
+ # error.data #=> "{invalid json}"
28
+ attr_reader :data
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
+
48
+ # Create a new ParseError
49
+ #
50
+ # @api public
51
+ # @param message [String, nil] error message
52
+ # @param data [String, nil] the input that failed to parse
53
+ # @param cause [Exception, nil] the original exception
54
+ # @return [ParseError] new error instance
55
+ # @example
56
+ # ParseError.new("unexpected token at line 1 column 2", data: "{}")
57
+ def initialize(message = nil, data: nil, cause: nil)
58
+ super(message)
59
+ @data = data
60
+ match = location_match(message)
61
+ @line = match && Integer(match[1])
62
+ @column = match && match[2] && Integer(match[2])
63
+ set_backtrace(cause.backtrace) if cause
64
+ end
4
65
 
66
+ # Build a ParseError from an original exception
67
+ #
68
+ # @api public
69
+ # @param original_exception [Exception] the adapter's parse error
70
+ # @param data [String] the input that failed to parse
71
+ # @return [ParseError] new error with formatted message
72
+ # @example
73
+ # ParseError.build(JSON::ParserError.new("..."), "{bad json}")
5
74
  def self.build(original_exception, data)
6
- new(original_exception.message).tap do |exception|
7
- exception.instance_eval do
8
- @cause = original_exception
9
- set_backtrace original_exception.backtrace
10
- @data = data
11
- end
12
- end
75
+ new(original_exception.message, data: data, cause: original_exception)
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)
13
98
  end
14
99
  end
15
100
 
16
- DecodeError = LoadError = ParseError # Legacy support
101
+ # Legacy aliases for backward compatibility
102
+ DecodeError = LoadError = ParseError
17
103
  end
@@ -1,17 +1,30 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module MultiJson
4
+ # Version information for MultiJson
5
+ #
6
+ # @api private
2
7
  class Version
8
+ # Major version number
3
9
  MAJOR = 1 unless defined? MultiJson::Version::MAJOR
4
- MINOR = 15 unless defined? MultiJson::Version::MINOR
5
- PATCH = 0 unless defined? MultiJson::Version::PATCH
10
+ # Minor version number
11
+ MINOR = 20 unless defined? MultiJson::Version::MINOR
12
+ # Patch version number
13
+ PATCH = 1 unless defined? MultiJson::Version::PATCH
14
+ # Pre-release version suffix
6
15
  PRE = nil unless defined? MultiJson::Version::PRE
7
16
 
8
17
  class << self
9
- # @return [String]
18
+ # Return the version string
19
+ #
20
+ # @api private
21
+ # @return [String] version in semver format
10
22
  def to_s
11
- [MAJOR, MINOR, PATCH, PRE].compact.join('.')
23
+ [MAJOR, MINOR, PATCH, PRE].compact.join(".")
12
24
  end
13
25
  end
14
26
  end
15
27
 
28
+ # Current version string in semver format
16
29
  VERSION = Version.to_s.freeze
17
30
  end