multi_json 1.15.0 → 1.21.2

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,113 @@
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
+ # ``load`` / ``dump`` / ``decode`` / ``encode`` / ``engine*`` /
21
+ # ``with_engine`` / ``default_engine`` aliases that are scheduled
22
+ # for removal in v2.0.
23
+ #
24
+ # @api private
25
+ # @param name [Symbol] deprecated method name
26
+ # @param replacement [Symbol] current-API method to delegate to
27
+ # @return [Symbol] the defined method name
28
+ # @example
29
+ # deprecate_alias :load, :parse
30
+ def deprecate_alias(name, replacement)
31
+ message = "MultiJSON.#{name} is deprecated and will be removed in v2.0. Use MultiJSON.#{replacement} instead."
32
+ define_singleton_method(name) do |*args, **kwargs, &block|
33
+ warn_deprecation_once(name, message)
34
+ public_send(replacement, *args, **kwargs, &block)
35
+ end
36
+ end
37
+
38
+ # Define a deprecated method whose body needs custom delegation
39
+ #
40
+ # Used for the ``default_options`` / ``default_options=`` pair
41
+ # whose body fans out to multiple replacement methods, and for the
42
+ # ``cached_options`` / ``reset_cached_options!`` no-op stubs that
43
+ # have no current-API counterpart at all. The block runs in its
44
+ # own lexical ``self``, which is the ``MultiJSON`` module since
45
+ # every call site sits inside ``module MultiJSON`` below.
46
+ #
47
+ # @api private
48
+ # @param name [Symbol] deprecated method name
49
+ # @param message [String] warning to emit on first call
50
+ # @yield body to evaluate after the warning
51
+ # @return [Symbol] the defined method name
52
+ # @example
53
+ # deprecate_method(:cached_options, "...") { nil }
54
+ def deprecate_method(name, message, &body)
55
+ define_singleton_method(name) do |*args, **kwargs|
56
+ warn_deprecation_once(name, message)
57
+ body.call(*args, **kwargs)
58
+ end
59
+ end
60
+ end
61
+
62
+ deprecate_alias :load, :parse
63
+ deprecate_alias :dump, :generate
64
+ deprecate_alias :decode, :parse
65
+ deprecate_alias :encode, :generate
66
+ deprecate_alias :engine, :adapter
67
+ deprecate_alias :engine=, :adapter=
68
+ deprecate_alias :default_engine, :default_adapter
69
+ deprecate_alias :with_engine, :with_adapter
70
+
71
+ deprecate_method(
72
+ :default_options=,
73
+ "MultiJSON.default_options setter is deprecated\n" \
74
+ "Use MultiJSON.parse_options and MultiJSON.generate_options instead"
75
+ ) { |value| self.parse_options = self.generate_options = value }
76
+
77
+ deprecate_method(
78
+ :default_options,
79
+ "MultiJSON.default_options is deprecated\n" \
80
+ "Use MultiJSON.parse_options or MultiJSON.generate_options instead"
81
+ ) { parse_options }
82
+
83
+ %i[cached_options reset_cached_options!].each do |name|
84
+ deprecate_method(name, "MultiJSON.#{name} method is deprecated and no longer used.") { nil }
85
+ end
86
+
87
+ private
88
+
89
+ # Instance-method delegate for the deprecated default_options setter
90
+ #
91
+ # @api private
92
+ # @deprecated Use {MultiJSON.load_options=} and {MultiJSON.dump_options=} instead
93
+ # @param value [Hash] options hash
94
+ # @return [Hash] the options hash
95
+ # @example
96
+ # class Foo; include MultiJSON; end
97
+ # Foo.new.send(:default_options=, symbolize_keys: true)
98
+ def default_options=(value)
99
+ MultiJSON.default_options = value
100
+ end
101
+
102
+ # Instance-method delegate for the deprecated default_options getter
103
+ #
104
+ # @api private
105
+ # @deprecated Use {MultiJSON.load_options} or {MultiJSON.dump_options} instead
106
+ # @return [Hash] the current load options
107
+ # @example
108
+ # class Foo; include MultiJSON; end
109
+ # Foo.new.send(:default_options)
110
+ def default_options
111
+ MultiJSON.default_options
112
+ end
113
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Backward-compatible alias for the legacy ``MultiJson`` constant name
4
+ #
5
+ # Downstream code that still writes ``MultiJson.parse(...)`` or
6
+ # ``rescue MultiJson::ParseError`` continues to work, but emits a
7
+ # one-time deprecation warning pointing at ``MultiJSON``. Each public
8
+ # method on {MultiJSON} gets an explicit forwarder defined on this
9
+ # module, and constant access resolves via {.const_missing}, so both
10
+ # dotted calls and ``::`` constant lookups (including rescue clauses)
11
+ # route through the canonical module.
12
+ #
13
+ # This file is loaded last by the ``multi_json`` entry point so the
14
+ # forwarder snapshot below sees the complete {MultiJSON} public API,
15
+ # including the deprecated aliases defined in ``deprecated.rb``.
16
+ #
17
+ # @api public
18
+ # @deprecated Use {MultiJSON} (all-caps) instead. Will be removed in v2.0.
19
+ module MultiJson
20
+ # Forward every public method MultiJSON exposes through an explicit
21
+ # singleton method on the legacy MultiJson module, so callers that
22
+ # capture the method as a Method object (``MultiJson.method(:load)``)
23
+ # find this forwarder instead of falling back to inherited methods like
24
+ # ``Kernel#load``. The earlier ``method_missing``-based shim left
25
+ # ``MultiJson.method(:load)`` resolving to ``Kernel#load`` (because
26
+ # ``Module#method`` doesn't consult ``method_missing``) and broke
27
+ # libraries (Sawyer, Octokit, Danger) that capture decoders as Method
28
+ # objects. Forwarding eagerly fixes the capture path while preserving
29
+ # the one-time deprecation warning each call emits.
30
+ (::MultiJSON.public_methods - ::Module.public_methods).each do |forwarded|
31
+ define_singleton_method(forwarded) do |*args, **kwargs, &block|
32
+ ::MultiJSON.warn_deprecation_once(:multi_json_constant,
33
+ "The MultiJson constant is deprecated and will be removed in v2.0. Use MultiJSON instead.")
34
+ ::MultiJSON.public_send(forwarded, *args, **kwargs, &block)
35
+ end
36
+ end
37
+
38
+ class << self
39
+ # Resolve missing constants to their {MultiJSON} counterparts
40
+ #
41
+ # Enables ``rescue MultiJson::ParseError`` and
42
+ # ``MultiJson::Adapters::Oj`` to keep working during the
43
+ # deprecation cycle.
44
+ #
45
+ # @api public
46
+ # @param name [Symbol] constant name
47
+ # @return [Object] the resolved constant from {MultiJSON}
48
+ # @example
49
+ # MultiJson::ParseError # returns MultiJSON::ParseError
50
+ def const_missing(name)
51
+ ::MultiJSON.warn_deprecation_once(:multi_json_constant,
52
+ "The MultiJson constant is deprecated and will be removed in v2.0. Use MultiJSON instead.")
53
+ ::MultiJSON.const_get(name)
54
+ end
55
+ end
56
+ end
@@ -1,39 +1,186 @@
1
- module MultiJson
1
+ # frozen_string_literal: true
2
+
3
+ module MultiJSON
4
+ # Mixin providing configurable parse/generate 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
3
- def load_options=(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 parse operations
19
+ #
20
+ # @api public
21
+ # @param options [Hash, Proc] options hash or callable
22
+ # @return [Hash, Proc] the options
23
+ # @example
24
+ # MultiJSON.parse_options = {symbolize_keys: true}
25
+ def parse_options=(options)
4
26
  OptionsCache.reset
5
- @load_options = options
27
+ @parse_options = options
6
28
  end
7
29
 
8
- def dump_options=(options)
30
+ # Set options for generate operations
31
+ #
32
+ # @api public
33
+ # @param options [Hash, Proc] options hash or callable
34
+ # @return [Hash, Proc] the options
35
+ # @example
36
+ # MultiJSON.generate_options = {pretty: true}
37
+ def generate_options=(options)
9
38
  OptionsCache.reset
10
- @dump_options = options
39
+ @generate_options = options
40
+ end
41
+
42
+ # Get options for parse operations
43
+ #
44
+ # When `@parse_options` is a callable (proc/lambda), it's invoked
45
+ # with `args` as positional arguments — typically the merged
46
+ # options hash from `Adapter.merged_parse_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.parse_options #=> {}
54
+ def parse_options(*args)
55
+ resolve_options(@parse_options, *args) || default_parse_options
11
56
  end
12
57
 
58
+ # Get options for generate 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.generate_options #=> {}
65
+ def generate_options(*args)
66
+ resolve_options(@generate_options, *args) || default_generate_options
67
+ end
68
+
69
+ # Get default parse options
70
+ #
71
+ # @api private
72
+ # @return [Hash] frozen empty hash
73
+ def default_parse_options
74
+ Concurrency.synchronize(:default_options) { @default_parse_options ||= EMPTY_OPTIONS }
75
+ end
76
+
77
+ # Get default generate options
78
+ #
79
+ # @api private
80
+ # @return [Hash] frozen empty hash
81
+ def default_generate_options
82
+ Concurrency.synchronize(:default_options) { @default_generate_options ||= EMPTY_OPTIONS }
83
+ end
84
+
85
+ # Set options for parse operations
86
+ #
87
+ # @api public
88
+ # @deprecated Use {#parse_options=} instead. Will be removed in v2.0.
89
+ # @param options [Hash, Proc] options hash or callable
90
+ # @return [Hash, Proc] the options
91
+ # @example
92
+ # MultiJSON.load_options = {symbolize_keys: true}
93
+ def load_options=(options)
94
+ MultiJSON.warn_deprecation_once(:load_options=,
95
+ "MultiJSON.load_options= is deprecated and will be removed in v2.0. Use MultiJSON.parse_options= instead.")
96
+ self.parse_options = options
97
+ end
98
+
99
+ # Set options for generate operations
100
+ #
101
+ # @api public
102
+ # @deprecated Use {#generate_options=} instead. Will be removed in v2.0.
103
+ # @param options [Hash, Proc] options hash or callable
104
+ # @return [Hash, Proc] the options
105
+ # @example
106
+ # MultiJSON.dump_options = {pretty: true}
107
+ def dump_options=(options)
108
+ MultiJSON.warn_deprecation_once(:dump_options=,
109
+ "MultiJSON.dump_options= is deprecated and will be removed in v2.0. Use MultiJSON.generate_options= instead.")
110
+ self.generate_options = options
111
+ end
112
+
113
+ # Get options for parse operations
114
+ #
115
+ # @api public
116
+ # @deprecated Use {#parse_options} instead. Will be removed in v2.0.
117
+ # @param args [Array<Object>] forwarded to the callable, ignored otherwise
118
+ # @return [Hash] resolved options hash
119
+ # @example
120
+ # MultiJSON.load_options #=> {}
13
121
  def load_options(*args)
14
- defined?(@load_options) && get_options(@load_options, *args) || default_load_options
122
+ MultiJSON.warn_deprecation_once(:load_options,
123
+ "MultiJSON.load_options is deprecated and will be removed in v2.0. Use MultiJSON.parse_options instead.")
124
+ parse_options(*args)
15
125
  end
16
126
 
127
+ # Get options for generate operations
128
+ #
129
+ # @api public
130
+ # @deprecated Use {#generate_options} instead. Will be removed in v2.0.
131
+ # @param args [Array<Object>] forwarded to the callable, ignored otherwise
132
+ # @return [Hash] resolved options hash
133
+ # @example
134
+ # MultiJSON.dump_options #=> {}
17
135
  def dump_options(*args)
18
- defined?(@dump_options) && get_options(@dump_options, *args) || default_dump_options
136
+ MultiJSON.warn_deprecation_once(:dump_options,
137
+ "MultiJSON.dump_options is deprecated and will be removed in v2.0. Use MultiJSON.generate_options instead.")
138
+ generate_options(*args)
19
139
  end
20
140
 
141
+ # Get default parse options
142
+ #
143
+ # @api private
144
+ # @deprecated Use {#default_parse_options} instead. Will be removed in v2.0.
145
+ # @return [Hash] frozen empty hash
21
146
  def default_load_options
22
- @default_load_options ||= {}
147
+ default_parse_options
23
148
  end
24
149
 
150
+ # Get default generate options
151
+ #
152
+ # @api private
153
+ # @deprecated Use {#default_generate_options} instead. Will be removed in v2.0.
154
+ # @return [Hash] frozen empty hash
25
155
  def default_dump_options
26
- @default_dump_options ||= {}
156
+ default_generate_options
27
157
  end
28
158
 
29
- private
159
+ private
30
160
 
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
161
+ # Resolves options from a hash or callable
162
+ #
163
+ # @api private
164
+ # @param options [Hash, Proc, nil] options configuration
165
+ # @param args [Array<Object>] arguments forwarded to a callable provider
166
+ # @return [Hash, nil] resolved options hash
167
+ def resolve_options(options, *args)
168
+ if options.respond_to?(:call)
169
+ # @type var options: options_proc
170
+ return invoke_callable(options, *args)
36
171
  end
172
+
173
+ options.to_hash if options.respond_to?(:to_hash)
174
+ end
175
+
176
+ # Invokes a callable options provider
177
+ #
178
+ # @api private
179
+ # @param callable [Proc] options provider
180
+ # @param args [Array<Object>] arguments forwarded when the callable is non-arity-zero
181
+ # @return [Hash] options returned by the callable
182
+ def invoke_callable(callable, *args)
183
+ callable.arity.zero? ? callable.call : callable.call(*args)
37
184
  end
38
185
  end
39
186
  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,82 @@
1
- module MultiJson
1
+ # frozen_string_literal: true
2
+
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
+ # Dynamic require path so MRI (mutex_store) and JRuby
21
+ # (concurrent_store) execute the same physical line, avoiding a
22
+ # dead-branch ``require_relative`` that would otherwise drop
23
+ # JRuby's line coverage below 100%.
24
+ BACKENDS = {"jruby" => "concurrent_store"}.freeze
25
+ private_constant :BACKENDS
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
+ class << self
28
+ # Get the dump options cache
29
+ #
30
+ # @api private
31
+ # @return [Store] dump cache store
32
+ attr_reader :dump
33
+
34
+ # Get the load options cache
35
+ #
36
+ # @api private
37
+ # @return [Store] load cache store
38
+ attr_reader :load
14
39
 
15
- private
40
+ # Maximum number of entries per cache store
41
+ #
42
+ # Applies to both the dump and load caches. Existing entries are
43
+ # left in place until normal eviction trims them below a lowered
44
+ # limit; call {.reset} if you need to evict immediately.
45
+ #
46
+ # @api public
47
+ # @return [Integer] current cache size limit
48
+ # @example
49
+ # MultiJSON::OptionsCache.max_cache_size = 5000
50
+ # MultiJSON::OptionsCache.max_cache_size #=> 5000
51
+ attr_reader :max_cache_size
16
52
 
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
53
+ # Set the maximum number of entries per cache store
54
+ #
55
+ # @api public
56
+ # @param value [Integer] positive entry cap
57
+ # @return [Integer] the validated value
58
+ # @raise [ArgumentError] when value is not a positive Integer
59
+ # @example
60
+ # MultiJSON::OptionsCache.max_cache_size = 5000
61
+ def max_cache_size=(value)
62
+ raise ArgumentError, "max_cache_size must be a positive Integer, got #{value.inspect}" unless Integer === value && value.positive? # rubocop:disable Style/CaseEquality
23
63
 
24
- def write(cache, key)
25
- cache.clear if cache.length >= MAX_CACHE_SIZE
26
- cache[key] = yield
64
+ @max_cache_size = value
65
+ end
66
+
67
+ # Reset both caches
68
+ #
69
+ # @api private
70
+ # @return [void]
71
+ def reset
72
+ @dump = Store.new
73
+ @load = Store.new
74
+ end
27
75
  end
76
+
77
+ self.max_cache_size = DEFAULT_MAX_CACHE_SIZE
28
78
  end
29
79
  end
80
+
81
+ require_relative "options_cache/#{MultiJSON::OptionsCache.send(:const_get, :BACKENDS).fetch(RUBY_ENGINE, "mutex_store")}"
82
+ MultiJSON::OptionsCache.reset
@@ -1,17 +1,108 @@
1
- module MultiJson
1
+ # frozen_string_literal: true
2
+
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
+ # The parameter is deliberately not named ``message``: that would
91
+ # shadow ``Exception#message``, which holds the same string once
92
+ # ``super`` has run, and mutation testing could not tell the two
93
+ # apart.
94
+ #
95
+ # @api private
96
+ # @param text [String, nil] the adapter's error message
97
+ # @return [MatchData, nil] the regex match, or nil if no message or
98
+ # no location fragment was found
99
+ def location_match(text)
100
+ return unless text
101
+
102
+ LOCATION_PATTERN.match(text.scrub)
13
103
  end
14
104
  end
15
105
 
16
- DecodeError = LoadError = ParseError # Legacy support
106
+ # Legacy aliases for backward compatibility
107
+ DecodeError = LoadError = ParseError
17
108
  end