rails_tracepoint_stack 0.3.4 → 0.4.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a3045ee154f570e6a780af9aff0edfcd18822b3394a0f268ee9a4e07e6d5769f
4
- data.tar.gz: 9865f094f0a70ffcb474148aaab7ee6712ac63450326bf7e3385dfc55e1c123f
3
+ metadata.gz: 612ce09f08d0dfebf77cfddc8dfbe9712598aaf324579f688bac6148354c92fb
4
+ data.tar.gz: aec94f6a1a148f16199b6835932a5b1fe91ff458a2915fab60efe937244b7cac
5
5
  SHA512:
6
- metadata.gz: 6c5482941d4e5119b88133eca986cdb87c490d0ed6e69ed0df5a09ebe1d2efebff0c4abbf9b554abf44cbab80038b96b3bf53cb8c86b1052b967d7ad307573d8
7
- data.tar.gz: 699757a6965a6101ee9b80e5039580840a5e34de1271538a286d95c6ea5d2f0e678e666c8f098b2a93df5896868fc821182258f4d07616684bace6857087e7b8
6
+ metadata.gz: de2e858cfad1406ee8becdb5d3be65b956286c802a1f95023d8698eaa80500569ee603be1b28e06404845a41eebb25e364ea936cdd886fcdab02b14113caaf10
7
+ data.tar.gz: 17b4beea42cb83b2058b32652acf0ddeec66a1b2fabe32e61a07fcf446738fd4c92b238dd6ce603d5426d68af4e1bbe397e23c7afd7c739c4385ed3d8cf61177
data/README.md ADDED
@@ -0,0 +1,174 @@
1
+ # rails_tracepoint_stack
2
+
3
+ **[carlosdanielpohlod.github.io/rails_tracepoint_stack](https://carlosdanielpohlod.github.io/rails_tracepoint_stack/)**
4
+
5
+ See what your Rails code actually did at runtime: which of *your* methods ran,
6
+ what arguments they got, what each one returned, and where an exception was
7
+ first raised — as a call tree, with gems, the framework and stdlib filtered out.
8
+
9
+ ```ruby
10
+ session = RailsTracepointStack.capture(max_depth: 4) do
11
+ Order.find(42).recalculate!
12
+ end
13
+
14
+ puts session.to_tree
15
+ ```
16
+
17
+ ```
18
+ Order#recalculate! (app/models/order.rb:88) {}
19
+ Order#apply_discount (app/models/order.rb:102) {"total":200.0}
20
+ Discount#rate_for (app/models/discount.rb:12) {"order":"#<Order id: 42>"}
21
+ -> null
22
+ -> 200.0
23
+ -> 200.0
24
+ 3 calls, 3 returns, 0 raises, 2 classes
25
+ ```
26
+
27
+ `rate_for` returned nil. No `puts`, no log file, no restart.
28
+
29
+ A whole Rails request comes out about this long, because everything the
30
+ framework runs underneath is filtered away:
31
+
32
+ ```
33
+ RealEstateAgenciesController#index (app/controllers/real_estate_agencies_controller.rb:14) {}
34
+ IpLocation.guess_state_uf_and_city_name (app/services/ip_location.rb:15) {"ip":"127.0.0.1"}
35
+ -> [null,null]
36
+ -> null
37
+ render app/views/real_estate_agencies/index.html.erb {}
38
+ -> "<!-- BEGIN app/views/real_estate_agencies/index.html.erb\n-->… (2507 chars)"
39
+ 4 calls, 4 returns, 0 raises, 3 classes
40
+ ```
41
+
42
+ That is 4 kept traces out of 13869 the tracer saw.
43
+
44
+ ## Install
45
+
46
+ ```ruby
47
+ # Gemfile
48
+ gem "rails_tracepoint_stack"
49
+ ```
50
+
51
+ ## Debugging with an AI agent
52
+
53
+ Agents reach for `puts` and log lines because they do not know this exists.
54
+ Install the packaged skill into your app and yours will use the gem instead:
55
+
56
+ ```bash
57
+ bin/rails generate rails_tracepoint_stack:install
58
+ ```
59
+
60
+ That writes `.claude/skills/debug-with-tracepoint/SKILL.md`, which tells the
61
+ agent when tracing beats reading code, how to keep the output inside its
62
+ context window, and how to read the tree. Pass `--force` to overwrite an
63
+ existing copy.
64
+
65
+ ## Capturing
66
+
67
+ `capture` traces the block, returns a session and re-raises anything the block
68
+ raised. It watches only the calling thread, so it stays clean under Puma.
69
+
70
+ ```ruby
71
+ session = RailsTracepointStack.capture { Order.find(42).recalculate! }
72
+
73
+ session.to_tree # the indented call tree above
74
+ session.as_json # the same data, structured, one entry per trace
75
+ session.summary # {calls:, returns:, raises:, classes:, truncated:}
76
+ session.result # what the block returned
77
+ session.error # the exception that escaped, if any
78
+ session.traces # the raw TraceRecord list
79
+ ```
80
+
81
+ To keep the traces when the block blows up, take the session from the block
82
+ argument:
83
+
84
+ ```ruby
85
+ session = nil
86
+ begin
87
+ RailsTracepointStack.capture { |s| session = s; thing_that_blows_up }
88
+ rescue => error
89
+ puts session.to_tree
90
+ end
91
+ ```
92
+
93
+ ### Keeping the output small
94
+
95
+ A real request produces tens of thousands of traces, so captures are bounded.
96
+
97
+ | Option | Default | What it does |
98
+ |---|---|---|
99
+ | `max_depth` | none | Drops traces nested deeper than this |
100
+ | `max_traces` | 5000 | Stops collecting; `session.truncated?` becomes true |
101
+ | `max_string_length` | 200 | Shortens long strings |
102
+ | `max_collection_size` | 20 | Shortens long arrays and hashes |
103
+ | `capture_params` | `true` | Set false to show only the flow |
104
+ | `capture_return` | `true` | Set false to show only the calls |
105
+ | `threads` | `:current` | `:all` also records background threads |
106
+
107
+ ```ruby
108
+ RailsTracepointStack.capture(max_depth: 3, capture_return: false) { ... }
109
+ ```
110
+
111
+ ## Tracing the whole process
112
+
113
+ For code you cannot wrap in a block — boot, a rake task, a request handled by a
114
+ running server — enable the tracer globally instead. Output goes to
115
+ `log/rails_tracepoint_stack.log`.
116
+
117
+ ```bash
118
+ RAILS_TRACEPOINT_STACK_ENABLED=true bin/rails server
119
+ ```
120
+
121
+ ```
122
+ called: Bar#perform in /path/to/app/services/bar.rb:12 with params: {}
123
+ ```
124
+
125
+ `RailsTracepointStack.enable_trace { ... }` does the same for one block, also
126
+ writing to the log rather than returning a session.
127
+
128
+ ## Configuration
129
+
130
+ ```ruby
131
+ # config/initializers/rails_tracepoint_stack.rb
132
+
133
+ RailsTracepointStack.configure do |config|
134
+ config.file_path_to_filter_patterns << %r{app/services/}
135
+ config.ignore_patterns << %r{app/models/concerns/}
136
+ config.log_format = :json
137
+ config.log_external_sources = false
138
+ config.logger = Rails.logger
139
+ end
140
+ ```
141
+
142
+ | Configuration | Description |
143
+ |---|---|
144
+ | `file_path_to_filter_patterns` | Trace **only** files whose path matches one of these patterns |
145
+ | `ignore_patterns` | Skip traces whose file path matches one of these patterns |
146
+ | `log_format` | `:text` (default) or `:json`. Applies to the log, not to `capture` |
147
+ | `log_external_sources` | Include gems, bundler and stdlib. Default `false` |
148
+ | `logger` | Your own logger. Defaults to `log/rails_tracepoint_stack.log` |
149
+
150
+ A method missing from a trace is usually one the filters dropped as external —
151
+ add its path to `file_path_to_filter_patterns` to force it in.
152
+
153
+ ## Worth knowing
154
+
155
+ - TracePoint makes the traced code noticeably slower — around 8x on a real
156
+ Rails request. Fine for an investigation, not for something left running in
157
+ production.
158
+ - Ruby >= 3.0.
159
+ - A `-> null` line directly under a `!!` line is the frame unwinding, not a
160
+ method that returned nil.
161
+ - An empty tree says `no app code ran` along with how much was filtered. That
162
+ usually means the block ran entirely inside gems — `Order.where(...).map(&:name)`
163
+ calls no method you wrote, since `name` is generated by ActiveRecord.
164
+
165
+ ## Links
166
+
167
+ - [Website](https://carlosdanielpohlod.github.io/rails_tracepoint_stack/) — the
168
+ same story with real captured output, including a whole Rails request
169
+ - [RubyGems](https://rubygems.org/gems/rails_tracepoint_stack)
170
+ - [Changelog](changelog.md)
171
+
172
+ ## License
173
+
174
+ MIT
data/changelog.md ADDED
@@ -0,0 +1,132 @@
1
+ # Changelog
2
+
3
+ ## 0.4.0
4
+
5
+ **Features**
6
+
7
+ - `RailsTracepointStack.capture` traces a block and returns the traces in
8
+ memory, with no env var, log file or restart involved
9
+ - Traces now carry return values and raised exceptions, not just calls and params
10
+ - Each trace knows its call depth, so a session renders as a call tree
11
+ - `session.to_tree`, `session.as_json` and `session.summary` render a capture
12
+ - Captures are bounded by `max_depth`, `max_traces`, `max_string_length` and
13
+ `max_collection_size`, and report `truncated?` when a limit cut them short
14
+ - Captures watch only the calling thread by default; `threads: :all` opts out
15
+ - `rails g rails_tracepoint_stack:install` writes an agent skill into the app,
16
+ so AI coding agents know when and how to trace it
17
+
18
+ **BugFix**
19
+
20
+ - Params reported every local the method body declared, not just the arguments
21
+ it received, so unassigned locals showed up as arguments passed as nil.
22
+ `TracePoint#parameters` now decides what is read from the binding
23
+ - `GemPath` referenced `Bundler` without requiring it, raising `NameError` on
24
+ the first trace outside a bundle
25
+ - A raise coming out of a C method landed one level too deep in the tree
26
+
27
+ **Changes**
28
+
29
+ - The tracer writes to a sink; the default one keeps the previous logging
30
+ behaviour, so global tracing is unchanged
31
+ - Class methods render as `Foo.bar` rather than `#<Class:Foo>#bar`
32
+ - Compiled templates render as `render app/views/…` with their locals, instead
33
+ of the generated method name and the rendering internals
34
+ - An empty capture reports how many traces the filters dropped, so "nothing
35
+ ran" is distinguishable from "nothing of yours ran"
36
+ - Serialized string values are frozen copies, so a snapshot no longer follows
37
+ later mutations of the traced object
38
+
39
+ ## 0.3.5
40
+
41
+ **BugFix**
42
+
43
+ - Sanitize traced params before formatting so JSON logging does not overflow on recursive or problematic objects [Issue #37](https://github.com/carlosdanielpohlod/rails_tracepoint_stack/issues/37)
44
+ - Stabilize text log param rendering across Ruby versions
45
+
46
+ ## 0.3.4
47
+
48
+ **Changes**
49
+
50
+ - Fix flaky tests by @danielmbrasil [PR #27](https://github.com/carlosdanielpohlod/rails_tracepoint_stack/pull/27)
51
+
52
+ - Huge refactor of filters class, separating in modules by filter type [PR #18](https://github.com/carlosdanielpohlod/rails_tracepoint_stack/pull/18)
53
+
54
+ - Some other code refactors
55
+
56
+ ## 0.3.3
57
+
58
+ **Changes**
59
+
60
+ - Add autoload of all lib files on test_helper
61
+ - Fix some tests
62
+
63
+ **BugFix**
64
+
65
+ - Fixed the Configuration module not loading the default value for the configuration attributes
66
+
67
+ ## 0.3.1
68
+
69
+ **Changes:**
70
+
71
+ - Add the ability to include the external sources to the log using `config.log_external_sources = true`
72
+
73
+ ## 0.3.0
74
+
75
+ **Changes:**
76
+
77
+ - Refactor classes, formatting a trace using a value object class `RailsTracepointStack::Trace`
78
+ - include configuration `log_format` option, allowing choose an output as `text` or `json`
79
+ - Include configuration `file_path_to_filter_patterns`, allowing filter traces only when the origin file path matches a pattern
80
+ - Improve test coverage
81
+
82
+ ## 0.2.1
83
+
84
+ **Changes:**
85
+
86
+ - Update the ENV enable to be more semantic
87
+ - Add the VERSION constant module
88
+ - Sorted the files inside of gemspec
89
+ - Fix the depencies on the gemspec
90
+ - Add the "log_format" configuration support for text and json formats
91
+
92
+ ## 0.2.0
93
+
94
+ **Changes:**
95
+
96
+ - Refactor by separating Logger and Filter into their own classes.
97
+
98
+ - Introduce `RailsTracepointStack.configure`, which allows ignoring traces with a custom pattern and customizing the logs output. Example:
99
+
100
+ ```ruby
101
+ RailsTracepointStack.configure do |config|
102
+ config.ignore_patterns << /services\/foo.rb/
103
+ config.logger = YourLogger
104
+ end
105
+ ```
106
+
107
+ The default log destination is a file located on `log/rails_tracepoint_stack.log`
108
+
109
+ - Add The possibility of enable the tracer locally, by calling:
110
+
111
+ ```ruby
112
+ class Foo
113
+ def bar
114
+ RailsTracepointStack.enable_trace do
115
+ p "your code"
116
+ end
117
+ end
118
+ end
119
+ ```
120
+
121
+ - Add Rspec and Rake development dependencies, and add partial test coverage.
122
+
123
+ ## 0.1.4
124
+
125
+ **Changes:**
126
+
127
+ - Ignore logs containing `gems/bundler`.
128
+ - Require ruby >= 3.0.
129
+
130
+ **Breaking Changes:**
131
+
132
+ - To enable logs catch, it is necessary to set `RAILS_TRACEPOINT_STACK` as `true`.
@@ -0,0 +1,29 @@
1
+ require "rails/generators/base"
2
+ require "rails_tracepoint_stack/skill_installer"
3
+
4
+ module RailsTracepointStack
5
+ module Generators
6
+ # Thin wrapper over SkillInstaller so the behaviour stays testable without
7
+ # booting Rails.
8
+ class InstallGenerator < Rails::Generators::Base
9
+ desc "Installs the debug-with-tracepoint skill so agents working in " \
10
+ "this app know how to trace it"
11
+
12
+ def install_agent_skill
13
+ installer = RailsTracepointStack::SkillInstaller.new(
14
+ destination: destination_root,
15
+ force: options[:force]
16
+ )
17
+ written = installer.install
18
+
19
+ if written
20
+ say_status :create, relative_to_original_destination_root(written), :green
21
+ else
22
+ say_status :skip,
23
+ "#{relative_to_original_destination_root(installer.target_path)} already exists (--force to replace)",
24
+ :yellow
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,54 @@
1
+ module RailsTracepointStack
2
+ # Turns raw stack positions into app-level nesting.
3
+ #
4
+ # Counting :call and :return events would not work here, for two reasons.
5
+ # Most frames between two traced methods belong to gems and get filtered out,
6
+ # so an event counter would indent app code by the depth of the framework
7
+ # underneath it. And the tracer does not always watch :return at all - the
8
+ # global tracer only subscribes to :call - so nothing would ever pop.
9
+ #
10
+ # Reading the real stack position of each kept trace avoids both. A frame
11
+ # recorded at an equal or deeper position has necessarily finished, whether
12
+ # it returned, raised, or was abandoned, so it gets dropped on the next
13
+ # event rather than waiting for one that may never arrive.
14
+ class DepthTracker
15
+ def initialize
16
+ @stack = []
17
+ end
18
+
19
+ def enter(raw_position)
20
+ drop_finished_frames(raw_position, inclusive: true)
21
+ depth = @stack.size
22
+ @stack.push(raw_position)
23
+ depth
24
+ end
25
+
26
+ def leave(raw_position)
27
+ drop_finished_frames(raw_position, inclusive: false)
28
+ return @stack.size unless @stack.last == raw_position
29
+
30
+ @stack.pop
31
+ @stack.size
32
+ end
33
+
34
+ # A raise belongs to the innermost frame being tracked. It may report a
35
+ # position deeper than that frame when the exception comes out of a C
36
+ # method the app called, such as an arithmetic coercion, and there is no
37
+ # tracked frame down there to attribute it to.
38
+ def raised(raw_position)
39
+ drop_finished_frames(raw_position, inclusive: false)
40
+
41
+ [@stack.size - 1, 0].max
42
+ end
43
+
44
+ private
45
+
46
+ def drop_finished_frames(raw_position, inclusive:)
47
+ if inclusive
48
+ @stack.pop while @stack.last && @stack.last >= raw_position
49
+ else
50
+ @stack.pop while @stack.last && @stack.last > raw_position
51
+ end
52
+ end
53
+ end
54
+ end
@@ -4,7 +4,7 @@ module RailsTracepointStack
4
4
  def is_a_trace_required_to_watch_by_the_custom_configs?(trace:)
5
5
  return false unless RailsTracepointStack.configuration.file_path_to_filter_patterns.any?
6
6
 
7
- !filter_match_a_custom_pattern_to_be_not_ignored?(trace)
7
+ filter_match_a_custom_pattern_to_be_not_ignored?(trace)
8
8
  end
9
9
 
10
10
  private
@@ -1,8 +1,27 @@
1
1
  module RailsTracepointStack
2
2
  module Filter
3
3
  class GemPath
4
+ # Bundler gives the tightest answer, but it is not always there: a plain
5
+ # `ruby -e` or an irb session against an installed gem has no bundle, and
6
+ # asking Bundler then raises instead of filtering. Falling back to every
7
+ # installed gem is wider than needed and still filters out the code the
8
+ # developer did not write.
4
9
  def self.full_gem_path
5
- @full_gem_path ||= Bundler.load.specs.map(&:full_gem_path)
10
+ @full_gem_path ||= bundled_gem_paths || gem_install_roots
11
+ end
12
+
13
+ def self.bundled_gem_paths
14
+ return nil unless defined?(::Bundler)
15
+
16
+ ::Bundler.load.specs.map(&:full_gem_path)
17
+ rescue
18
+ nil
19
+ end
20
+
21
+ # Without a bundle there is no resolved list to ask for, but everything
22
+ # installed lives under these roots, which is all the filter needs.
23
+ def self.gem_install_roots
24
+ Gem.path.map { |dir| File.join(dir, "gems") }
6
25
  end
7
26
  end
8
27
  end
@@ -2,7 +2,7 @@ module RailsTracepointStack
2
2
  module Filter
3
3
  class RbConfig
4
4
  def self.ruby_lib_path
5
- @ruby_lib_path ||= ::RbConfig::CONFIG['rubylibdir']
5
+ @ruby_lib_path ||= ::RbConfig::CONFIG["rubylibdir"]
6
6
  end
7
7
  end
8
8
  end
@@ -0,0 +1,41 @@
1
+ module RailsTracepointStack
2
+ # How much a capture is allowed to keep. The defaults aim at an output a
3
+ # person or an agent can read in one go rather than at completeness: a
4
+ # single Rails request can easily produce tens of thousands of traces.
5
+ class Limits
6
+ DEFAULT_MAX_TRACES = 5_000
7
+ DEFAULT_MAX_STRING_LENGTH = 200
8
+ DEFAULT_MAX_COLLECTION_SIZE = 20
9
+
10
+ attr_reader :max_depth,
11
+ :max_traces,
12
+ :max_string_length,
13
+ :max_collection_size,
14
+ :capture_params,
15
+ :capture_return
16
+
17
+ def initialize(
18
+ max_depth: nil,
19
+ max_traces: DEFAULT_MAX_TRACES,
20
+ max_string_length: DEFAULT_MAX_STRING_LENGTH,
21
+ max_collection_size: DEFAULT_MAX_COLLECTION_SIZE,
22
+ capture_params: true,
23
+ capture_return: true
24
+ )
25
+ @max_depth = max_depth
26
+ @max_traces = max_traces
27
+ @max_string_length = max_string_length
28
+ @max_collection_size = max_collection_size
29
+ @capture_params = capture_params
30
+ @capture_return = capture_return
31
+ end
32
+
33
+ def too_deep?(depth)
34
+ !max_depth.nil? && !depth.nil? && depth > max_depth
35
+ end
36
+
37
+ def room_for?(count)
38
+ max_traces.nil? || count < max_traces
39
+ end
40
+ end
41
+ end
@@ -1,3 +1,5 @@
1
+ require "json"
2
+
1
3
  module RailsTracepointStack
2
4
  module LogFormatter
3
5
  def self.message(trace)
@@ -10,17 +12,187 @@ module RailsTracepointStack
10
12
  end
11
13
 
12
14
  def self.text(trace)
13
- "called: #{trace.class_name}##{trace.method_name} in #{trace.file_path}:#{trace.line_number} with params: #{trace.params}"
15
+ "called: #{trace.class_name}##{trace.method_name} in #{trace.file_path}:#{trace.line_number} with params: #{text_value(trace.params)}"
14
16
  end
15
17
 
16
18
  def self.json(trace)
17
- {
18
- class: trace.class_name,
19
- method_name: trace.method_name,
20
- path: trace.file_path,
19
+ JSON.generate(
20
+ class: stringify(trace.class_name),
21
+ method_name: stringify(trace.method_name),
22
+ path: stringify(trace.file_path),
21
23
  line: trace.line_number,
22
- params: trace.params
23
- }.to_json
24
+ params: safe_value(trace.params)
25
+ )
26
+ end
27
+
28
+ def self.stringify(value)
29
+ return nil if value.nil?
30
+
31
+ value.to_s
32
+ rescue SystemStackError, StandardError => error
33
+ inspect_fallback(value, error)
34
+ end
35
+
36
+ def self.safe_value(value, ancestry = {})
37
+ case value
38
+ when nil, true, false, Numeric
39
+ value
40
+ when String
41
+ # Callers may keep the result around after the traced code moved on,
42
+ # so a live reference to a mutable string would drift.
43
+ value.frozen? ? value : value.dup.freeze
44
+ when Symbol
45
+ value.to_s
46
+ when Array
47
+ safe_array(value, ancestry)
48
+ when Hash
49
+ safe_hash(value, ancestry)
50
+ else
51
+ safe_json_object(value)
52
+ end
53
+ rescue SystemStackError, StandardError => error
54
+ inspect_fallback(value, error)
55
+ end
56
+
57
+ def self.safe_array(value, ancestry)
58
+ object_id = value.__id__
59
+ return recursive_placeholder(value) if ancestry.key?(object_id)
60
+
61
+ ancestry[object_id] = true
62
+ value.map { |item| safe_value(item, ancestry) }
63
+ ensure
64
+ ancestry.delete(object_id)
65
+ end
66
+
67
+ def self.safe_hash(value, ancestry)
68
+ object_id = value.__id__
69
+ return recursive_placeholder(value) if ancestry.key?(object_id)
70
+
71
+ ancestry[object_id] = true
72
+ value.each_with_object({}) do |(key, item), result|
73
+ result[safe_hash_key(key, ancestry)] = safe_value(item, ancestry)
74
+ end
75
+ ensure
76
+ ancestry.delete(object_id)
77
+ end
78
+
79
+ def self.safe_hash_key(key, ancestry = {})
80
+ case key
81
+ when String
82
+ key
83
+ when Symbol, Numeric
84
+ key.to_s
85
+ when true, false, nil
86
+ key.inspect
87
+ else
88
+ stringify_hash_key(key, ancestry)
89
+ end
90
+ end
91
+
92
+ def self.safe_json_object(value)
93
+ json_value = JSON.parse(JSON.generate(value))
94
+
95
+ return json_value unless default_string_representation?(value, json_value)
96
+
97
+ safe_object_string(value)
98
+ rescue SystemStackError, StandardError
99
+ safe_object_string(value)
100
+ end
101
+
102
+ def self.default_string_representation?(value, json_value)
103
+ json_value.is_a?(String) &&
104
+ value.method(:to_s).owner == Kernel &&
105
+ json_value == value.to_s
106
+ rescue SystemStackError, StandardError
107
+ false
108
+ end
109
+
110
+ def self.stringify_hash_key(key, ancestry)
111
+ normalized_key = safe_value(key, ancestry)
112
+
113
+ case normalized_key
114
+ when String
115
+ normalized_key
116
+ when nil
117
+ "null"
118
+ when true, false, Numeric
119
+ normalized_key.to_s
120
+ else
121
+ JSON.generate(normalized_key)
122
+ end
123
+ rescue SystemStackError, StandardError
124
+ safe_object_string(key)
125
+ end
126
+
127
+ def self.safe_object_string(value)
128
+ value.inspect
129
+ rescue SystemStackError, StandardError => error
130
+ inspect_fallback(value, error)
131
+ end
132
+
133
+ def self.inspect_fallback(value, error)
134
+ class_name = value.nil? ? "NilClass" : value.class.to_s
135
+ "#<#{class_name} unserializable: #{error.class}: #{error.message}>"
136
+ end
137
+
138
+ def self.recursive_placeholder(value)
139
+ "[recursive #{value.class}]"
140
+ end
141
+
142
+ def self.text_value(value, ancestry = {})
143
+ case value
144
+ when nil
145
+ "nil"
146
+ when true, false, Numeric
147
+ value.to_s
148
+ when String
149
+ value.inspect
150
+ when Symbol
151
+ ":#{value}"
152
+ when Array
153
+ text_array(value, ancestry)
154
+ when Hash
155
+ text_hash(value, ancestry)
156
+ else
157
+ safe_object_string(value)
158
+ end
159
+ rescue SystemStackError, StandardError => error
160
+ inspect_fallback(value, error)
161
+ end
162
+
163
+ def self.text_array(value, ancestry)
164
+ object_id = value.__id__
165
+ return recursive_placeholder(value) if ancestry.key?(object_id)
166
+
167
+ ancestry[object_id] = true
168
+ "[#{value.map { |item| text_value(item, ancestry) }.join(", ")}]"
169
+ ensure
170
+ ancestry.delete(object_id)
171
+ end
172
+
173
+ def self.text_hash(value, ancestry)
174
+ object_id = value.__id__
175
+ return recursive_placeholder(value) if ancestry.key?(object_id)
176
+
177
+ ancestry[object_id] = true
178
+ pairs = value.map do |key, item|
179
+ "#{text_hash_key(key, ancestry)}=>#{text_value(item, ancestry)}"
180
+ end
181
+
182
+ "{#{pairs.join(", ")}}"
183
+ ensure
184
+ ancestry.delete(object_id)
185
+ end
186
+
187
+ def self.text_hash_key(key, ancestry = {})
188
+ case key
189
+ when Symbol
190
+ ":#{key}"
191
+ when String
192
+ key.inspect
193
+ else
194
+ text_value(key, ancestry)
195
+ end
24
196
  end
25
197
  end
26
198
  end