marshal-md 0.1.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,721 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+
5
+ module MarshalMd
6
+ class Dumper
7
+ UNDUMPABLE = [Proc, IO, Thread, Binding, Method, UnboundMethod].freeze
8
+ IMMEDIATE_CLASSES = [Integer, Symbol, TrueClass, FalseClass, NilClass].freeze
9
+
10
+ # Built-in types that can have subclasses with special handling
11
+ BUILTIN_TYPES = [Array, Hash, String, Regexp, Range, Time].freeze
12
+
13
+ def initialize(obj, limit: -1)
14
+ @root = obj
15
+ @limit = limit
16
+ @registry = ObjectRegistry.new
17
+ @needs_anchor = {} # object_id => true
18
+ @scan_stack = {} # object_id => true (cycle detection)
19
+ @seen = {} # object_id => true (multi-ref detection)
20
+ @emitted = {} # object_id => true (pass 2)
21
+ @marshal_dump_stack = {} # class => true (recursive marshal_dump detection)
22
+ end
23
+
24
+ def dump
25
+ check_dumpable!(@root)
26
+ scan(@root)
27
+ @depth_remaining = @limit
28
+ emit(@root, 0).rstrip
29
+ end
30
+
31
+ private
32
+
33
+ def check_dumpable!(obj)
34
+ UNDUMPABLE.each do |klass|
35
+ if obj.is_a?(klass)
36
+ raise TypeError, "no _dump_data is defined for class #{obj.class}"
37
+ end
38
+ end
39
+
40
+ # Singleton classes/methods
41
+ if obj.respond_to?(:singleton_methods) && !obj.singleton_methods(false).empty?
42
+ raise TypeError, "singleton can't be dumped"
43
+ end
44
+
45
+ # Singleton class with instance variables or constants
46
+ unless immediate?(obj) || obj.is_a?(Class) || obj.is_a?(Module)
47
+ begin
48
+ sc = obj.singleton_class
49
+ if !sc.instance_variables.empty? || sc.constants(false).any?
50
+ raise TypeError, "singleton can't be dumped"
51
+ end
52
+ rescue TypeError => e
53
+ raise if e.message.include?("singleton can't be dumped")
54
+ # can't define singleton on immediate values — that's fine
55
+ end
56
+ end
57
+
58
+ # Special undumpable objects
59
+ if obj.equal?(ARGF) || obj.equal?(ENV)
60
+ raise TypeError, "can't dump #{obj.class}"
61
+ end
62
+
63
+ # Anonymous classes and modules
64
+ if obj.is_a?(Class) || obj.is_a?(Module)
65
+ if obj.name.nil? || obj.name.empty?
66
+ raise TypeError, "can't dump anonymous #{obj.is_a?(Class) ? 'Class' : 'Module'} #{obj}"
67
+ end
68
+ # Classes/modules with non-portable names (e.g. defined in singleton classes)
69
+ if obj.name.include?("#<")
70
+ raise TypeError, "can't dump anonymous #{obj.is_a?(Class) ? 'Class' : 'Module'} #{obj}"
71
+ end
72
+ end
73
+
74
+ # Instances of anonymous classes
75
+ if obj.class.name.nil? || obj.class.name.empty?
76
+ raise TypeError, "can't dump anonymous class #{obj.class}"
77
+ end
78
+
79
+ # Hash with default proc
80
+ if obj.is_a?(Hash) && obj.default_proc
81
+ raise TypeError, "can't dump hash with default proc"
82
+ end
83
+ end
84
+
85
+ def immediate?(obj)
86
+ IMMEDIATE_CLASSES.any? { |k| obj.is_a?(k) }
87
+ end
88
+
89
+ def builtin_type(obj)
90
+ BUILTIN_TYPES.find { |t| obj.is_a?(t) }
91
+ end
92
+
93
+ def subclassed_builtin?(obj)
94
+ bt = builtin_type(obj)
95
+ bt && obj.class != bt
96
+ end
97
+
98
+ # Extra instance variables (beyond what the built-in type provides)
99
+ def extra_ivars(obj)
100
+ obj.instance_variables.sort
101
+ end
102
+
103
+ def has_extra_ivars?(obj)
104
+ !obj.instance_variables.empty?
105
+ end
106
+
107
+ def has_extensions?(obj)
108
+ return false if immediate?(obj)
109
+ begin
110
+ mods = obj.singleton_class.ancestors - obj.class.ancestors
111
+ mods.any? { |m| m.is_a?(Module) && !m.is_a?(Class) }
112
+ rescue TypeError
113
+ false
114
+ end
115
+ end
116
+
117
+ def needs_wrapped_format?(obj)
118
+ has_extra_ivars?(obj) || has_extensions?(obj) || subclassed_builtin?(obj)
119
+ end
120
+
121
+ # Pass 1: scan object graph to find shared/circular references
122
+ def scan(obj)
123
+ return if immediate?(obj)
124
+
125
+ oid = obj.object_id
126
+
127
+ if @scan_stack[oid]
128
+ @needs_anchor[oid] = true
129
+ return
130
+ end
131
+
132
+ if @seen[oid]
133
+ @needs_anchor[oid] = true
134
+ return
135
+ end
136
+
137
+ @seen[oid] = true
138
+ @scan_stack[oid] = true
139
+
140
+ case obj
141
+ when String, Float
142
+ # leaf-ish, but may have ivars
143
+ scan_ivars(obj)
144
+ when Regexp
145
+ scan_ivars(obj)
146
+ when Range
147
+ scan(obj.begin) if obj.begin
148
+ scan(obj.end) if obj.end
149
+ scan_ivars(obj)
150
+ when Time
151
+ scan_ivars(obj)
152
+ when Array
153
+ obj.each { |el| scan(el) }
154
+ scan_ivars(obj)
155
+ when Hash
156
+ obj.each { |k, v| scan(k); scan(v) }
157
+ scan_ivars(obj)
158
+ when Struct
159
+ obj.each_pair { |_, v| scan(v) }
160
+ scan_ivars(obj)
161
+ when Class, Module, Encoding
162
+ # leaf
163
+ else
164
+ if obj.respond_to?(:marshal_dump)
165
+ # Don't call marshal_dump during scan — it may have side effects.
166
+ # Just scan instance variables to detect shared/circular refs.
167
+ scan_ivars(obj)
168
+ elsif obj.respond_to?(:_dump)
169
+ # _dump returns a string
170
+ else
171
+ scan_ivars(obj)
172
+ end
173
+ end
174
+
175
+ @scan_stack.delete(oid)
176
+ end
177
+
178
+ def scan_ivars(obj)
179
+ obj.instance_variables.each do |ivar|
180
+ scan(obj.instance_variable_get(ivar))
181
+ end
182
+ end
183
+
184
+ # Pass 2: emit markdown
185
+ def emit(obj, indent)
186
+ check_dumpable!(obj)
187
+
188
+ # Depth limit check (Marshal compat: limit >= 0 means limited depth)
189
+ if @depth_remaining == 0 && @limit >= 0
190
+ raise ArgumentError, "exceed depth limit"
191
+ end
192
+
193
+ if !immediate?(obj) && @needs_anchor[obj.object_id]
194
+ if @emitted[obj.object_id]
195
+ anchor = @registry.anchor_for(obj) || @registry.register(obj)
196
+ return "#{" " * indent}*#{anchor} (ref)\n"
197
+ end
198
+ anchor = @registry.register(obj)
199
+ @emitted[obj.object_id] = true
200
+ return emit_anchored(obj, indent, anchor)
201
+ end
202
+
203
+ if !immediate?(obj)
204
+ @emitted[obj.object_id] = true
205
+ end
206
+
207
+ if @limit >= 0
208
+ @depth_remaining -= 1
209
+ result = emit_value(obj, indent)
210
+ @depth_remaining += 1
211
+ result
212
+ else
213
+ emit_value(obj, indent)
214
+ end
215
+ end
216
+
217
+ def emit_anchored(obj, indent, anchor)
218
+ prefix = " " * indent
219
+ lines = emit_value(obj, indent)
220
+ first_line = lines.lines.first
221
+ rest = lines.lines[1..].join
222
+ stripped = first_line.lstrip
223
+ "#{prefix}&#{anchor} #{stripped}#{rest}"
224
+ end
225
+
226
+ def emit_value(obj, indent)
227
+ prefix = " " * indent
228
+
229
+ case obj
230
+ when NilClass
231
+ "#{prefix}nil (NilClass)\n"
232
+ when TrueClass
233
+ "#{prefix}true (Boolean)\n"
234
+ when FalseClass
235
+ "#{prefix}false (Boolean)\n"
236
+ when Integer
237
+ "#{prefix}#{obj} (Integer)\n"
238
+ when Float
239
+ "#{prefix}#{format_float(obj)} (Float)\n"
240
+ when Complex
241
+ "#{prefix}(#{obj.real}+#{obj.imaginary}i) (Complex)\n"
242
+ when Rational
243
+ "#{prefix}#{obj.numerator}/#{obj.denominator} (Rational)\n"
244
+ when Symbol
245
+ "#{prefix}:#{obj} (Symbol)\n"
246
+ when Encoding
247
+ "#{prefix}#{obj.name} (Encoding)\n"
248
+ when Class
249
+ "#{prefix}#{obj.name} (Class)\n"
250
+ when Module
251
+ "#{prefix}#{obj.name} (Module)\n"
252
+ when Struct
253
+ emit_struct(obj, indent)
254
+ when Range
255
+ emit_range(obj, indent)
256
+ when Regexp
257
+ emit_regexp(obj, indent)
258
+ when Time
259
+ emit_time(obj, indent)
260
+ when String
261
+ emit_string(obj, indent)
262
+ when Array
263
+ emit_array(obj, indent)
264
+ when Hash
265
+ emit_hash(obj, indent)
266
+ else
267
+ emit_custom(obj, indent)
268
+ end
269
+ end
270
+
271
+ def format_float(f)
272
+ if f.infinite? == 1
273
+ "Infinity"
274
+ elsif f.infinite? == -1
275
+ "-Infinity"
276
+ elsif f.nan?
277
+ "NaN"
278
+ elsif f.zero? && (1.0 / f) == -Float::INFINITY
279
+ "-0.0"
280
+ else
281
+ # Use enough precision to round-trip
282
+ f.to_s
283
+ end
284
+ end
285
+
286
+ def format_string_value(str, bare: false)
287
+ if str.encoding == Encoding::ASCII_8BIT
288
+ encoded = Base64.strict_encode64(str)
289
+ "base64:#{encoded} (String, ASCII-8BIT, #{str.bytesize} bytes)"
290
+ elsif str.encoding != Encoding::UTF_8 && str.encoding != Encoding::US_ASCII
291
+ begin
292
+ utf8_str = str.encode(Encoding::UTF_8)
293
+ "\"#{escape_string(utf8_str)}\" (String, #{str.encoding})"
294
+ rescue Encoding::UndefinedConversionError, Encoding::InvalidByteSequenceError
295
+ encoded = Base64.strict_encode64(str)
296
+ "base64:#{encoded} (String, #{str.encoding}, #{str.bytesize} bytes)"
297
+ end
298
+ else
299
+ bare ? "\"#{escape_string(str)}\"" : "\"#{escape_string(str)}\" (String)"
300
+ end
301
+ end
302
+
303
+ def escape_string(str)
304
+ str.gsub("\\", "\\\\\\\\")
305
+ .gsub("\"", "\\\"")
306
+ .gsub("\n", "\\n")
307
+ .gsub("\r", "\\r")
308
+ .gsub("\t", "\\t")
309
+ .gsub(/[\x00-\x1f]/) { |c| "\\x#{c.ord.to_s(16).rjust(2, '0')}" }
310
+ end
311
+
312
+ # Bare inline representation for use inside [...] and {...} and after @ivar:
313
+ def inline_value(obj)
314
+ # Check for reference first (for any non-immediate that's already emitted)
315
+ if !immediate?(obj) && @needs_anchor[obj.object_id] && @emitted[obj.object_id]
316
+ anchor = @registry.anchor_for(obj)
317
+ return "*#{anchor} (ref)"
318
+ end
319
+
320
+ case obj
321
+ when NilClass then "nil"
322
+ when TrueClass then "true"
323
+ when FalseClass then "false"
324
+ when Integer then obj.to_s
325
+ when Float then format_float(obj)
326
+ when Symbol then ":#{obj}"
327
+ when String then format_string_value(obj, bare: true)
328
+ else
329
+ nil # not inlineable
330
+ end
331
+ end
332
+
333
+ def simple_value?(obj)
334
+ return false if !immediate?(obj) && has_extensions?(obj)
335
+ case obj
336
+ when NilClass, TrueClass, FalseClass, Integer, Float, Symbol
337
+ true
338
+ when String
339
+ !has_extra_ivars?(obj) && obj.encoding != Encoding::ASCII_8BIT && !obj.include?("\n")
340
+ else
341
+ false
342
+ end
343
+ end
344
+
345
+ def all_simple?(arr)
346
+ arr.all? { |el| simple_value?(el) || (el.is_a?(String) && !el.include?("\n")) }
347
+ end
348
+
349
+ # --- String ---
350
+ def emit_string(obj, indent)
351
+ prefix = " " * indent
352
+ class_name = obj.class.name
353
+ ivars = extra_ivars(obj)
354
+
355
+ if needs_wrapped_format?(obj)
356
+ result = "#{prefix}#<#{class_name}> (#{class_name})\n"
357
+ result += "#{prefix} __value__: #{format_string_value(obj)}\n"
358
+ result += emit_extensions_str(obj, indent + 1)
359
+ ivars.each do |ivar|
360
+ val = obj.instance_variable_get(ivar)
361
+ result += emit_ivar(prefix + " ", ivar, val, indent + 1)
362
+ end
363
+ result
364
+ else
365
+ "#{prefix}#{format_string_value(obj)}\n"
366
+ end
367
+ end
368
+
369
+ # --- Array ---
370
+ def emit_array(obj, indent)
371
+ prefix = " " * indent
372
+ class_name = obj.class.name
373
+ ivars = extra_ivars(obj)
374
+ is_subclass = class_name != "Array"
375
+
376
+ if needs_wrapped_format?(obj)
377
+ result = "#{prefix}#<#{class_name}> (#{class_name})\n"
378
+ result += "#{prefix} __elements__:\n"
379
+ if obj.empty?
380
+ result += "#{prefix} [] (Array)\n"
381
+ else
382
+ result += "#{prefix} (Array)\n"
383
+ obj.each { |el| result += emit(el, indent + 3) }
384
+ end
385
+ result += emit_extensions_str(obj, indent + 1)
386
+ ivars.each do |ivar|
387
+ val = obj.instance_variable_get(ivar)
388
+ result += emit_ivar(prefix + " ", ivar, val, indent + 1)
389
+ end
390
+ result
391
+ else
392
+ emit_simple_array(obj, indent)
393
+ end
394
+ end
395
+
396
+ def emit_simple_array(obj, indent)
397
+ prefix = " " * indent
398
+ if obj.empty?
399
+ return "#{prefix}[] (Array)\n"
400
+ end
401
+
402
+ if all_simple?(obj) && obj.sum { |el| (inline_value(el) || "").length + 2 } < 80
403
+ items = obj.map { |el| inline_value(el) }.join(", ")
404
+ "#{prefix}[#{items}] (Array)\n"
405
+ else
406
+ len_before = obj.length
407
+ result = "#{prefix}(Array)\n"
408
+ obj.each { |el| result += emit(el, indent + 1) }
409
+ if obj.length != len_before
410
+ raise RuntimeError, "array modified during dump"
411
+ end
412
+ result
413
+ end
414
+ end
415
+
416
+ # --- Hash ---
417
+ def emit_hash(obj, indent)
418
+ prefix = " " * indent
419
+ class_name = obj.class.name
420
+ ivars = extra_ivars(obj)
421
+ is_subclass = class_name != "Hash"
422
+ has_default = !obj.default.nil?
423
+
424
+ has_identity = obj.respond_to?(:compare_by_identity?) && obj.compare_by_identity?
425
+
426
+ if needs_wrapped_format?(obj) || has_default || has_identity
427
+ result = "#{prefix}#<#{class_name}> (#{class_name})\n"
428
+ if has_identity
429
+ result += "#{prefix} __compare_by_identity__: true\n"
430
+ end
431
+ if has_default
432
+ result += emit_ivar(prefix + " ", "__default__", obj.default, indent + 1)
433
+ end
434
+ result += "#{prefix} __entries__:\n"
435
+ if obj.empty?
436
+ result += "#{prefix} {} (Hash)\n"
437
+ else
438
+ result += "#{prefix} (Hash)\n"
439
+ obj.each do |k, v|
440
+ iv = inline_value(v)
441
+ ik = inline_value(k)
442
+ if ik && iv
443
+ result += "#{prefix} #{ik} => #{iv}\n"
444
+ elsif ik
445
+ result += "#{prefix} #{ik} =>\n"
446
+ result += emit(v, indent + 4)
447
+ else
448
+ result += "#{prefix} (entry)\n"
449
+ result += emit(k, indent + 4)
450
+ result += "#{prefix} =>\n"
451
+ result += emit(v, indent + 4)
452
+ end
453
+ end
454
+ end
455
+ result += emit_extensions_str(obj, indent + 1)
456
+ ivars.each do |ivar|
457
+ val = obj.instance_variable_get(ivar)
458
+ result += emit_ivar(prefix + " ", ivar, val, indent + 1)
459
+ end
460
+ result
461
+ else
462
+ emit_simple_hash(obj, indent)
463
+ end
464
+ end
465
+
466
+ def emit_simple_hash(obj, indent)
467
+ prefix = " " * indent
468
+ if obj.empty?
469
+ return "#{prefix}{} (Hash)\n"
470
+ end
471
+
472
+ if obj.all? { |k, v| k.is_a?(Symbol) && simple_value?(v) }
473
+ pairs = obj.map { |k, v| "#{k}: #{inline_value(v)}" }.join(", ")
474
+ candidate = "{#{pairs}} (Hash)"
475
+ if candidate.length < 80
476
+ return "#{prefix}#{candidate}\n"
477
+ end
478
+ end
479
+
480
+ result = "#{prefix}(Hash)\n"
481
+ obj.each do |k, v|
482
+ ik = inline_value(k)
483
+ iv = inline_value(v)
484
+ if ik && iv
485
+ result += "#{prefix} #{ik} => #{iv}\n"
486
+ elsif ik
487
+ result += "#{prefix} #{ik} =>\n"
488
+ result += emit(v, indent + 2)
489
+ else
490
+ result += "#{prefix} (entry)\n"
491
+ result += emit(k, indent + 2)
492
+ result += "#{prefix} =>\n"
493
+ result += emit(v, indent + 2)
494
+ end
495
+ end
496
+ result
497
+ end
498
+
499
+ # --- Range ---
500
+ def emit_range(obj, indent)
501
+ prefix = " " * indent
502
+ class_name = obj.class.name
503
+ ivars = extra_ivars(obj)
504
+ is_subclass = class_name != "Range"
505
+ dots = obj.exclude_end? ? "..." : ".."
506
+
507
+ if is_subclass || !ivars.empty?
508
+ result = "#{prefix}#<#{class_name}> (#{class_name})\n"
509
+ result += "#{prefix} __begin__:\n"
510
+ result += emit(obj.begin, indent + 2) if obj.begin
511
+ result += "#{prefix} __end__:\n"
512
+ result += emit(obj.end, indent + 2) if obj.end
513
+ result += "#{prefix} __exclude_end__: #{obj.exclude_end?}\n"
514
+ ivars.each do |ivar|
515
+ val = obj.instance_variable_get(ivar)
516
+ result += emit_ivar(prefix + " ", ivar, val, indent + 1)
517
+ end
518
+ result
519
+ else
520
+ ib = inline_value(obj.begin)
521
+ ie = inline_value(obj.end)
522
+ if ib && ie
523
+ "#{prefix}#{ib}#{dots}#{ie} (Range)\n"
524
+ else
525
+ result = "#{prefix}(Range)\n"
526
+ result += "#{prefix} __begin__:\n"
527
+ result += emit(obj.begin, indent + 2) if obj.begin
528
+ result += "#{prefix} __end__:\n"
529
+ result += emit(obj.end, indent + 2) if obj.end
530
+ result += "#{prefix} __exclude_end__: #{obj.exclude_end?}\n"
531
+ result
532
+ end
533
+ end
534
+ end
535
+
536
+ # --- Regexp ---
537
+ def emit_regexp(obj, indent)
538
+ prefix = " " * indent
539
+ class_name = obj.class.name
540
+ ivars = extra_ivars(obj)
541
+ is_subclass = class_name != "Regexp"
542
+
543
+ flags = ""
544
+ flags += "i" if (obj.options & Regexp::IGNORECASE) != 0
545
+ flags += "x" if (obj.options & Regexp::EXTENDED) != 0
546
+ flags += "m" if (obj.options & Regexp::MULTILINE) != 0
547
+
548
+ if is_subclass || !ivars.empty?
549
+ result = "#{prefix}#<#{class_name}> (#{class_name})\n"
550
+ result += "#{prefix} __pattern__: /#{obj.source}/#{flags} (Regexp)\n"
551
+ ivars.each do |ivar|
552
+ val = obj.instance_variable_get(ivar)
553
+ result += emit_ivar(prefix + " ", ivar, val, indent + 1)
554
+ end
555
+ result
556
+ else
557
+ "#{prefix}/#{obj.source}/#{flags} (Regexp)\n"
558
+ end
559
+ end
560
+
561
+ # --- Time ---
562
+ def emit_time(obj, indent)
563
+ prefix = " " * indent
564
+ class_name = obj.class.name
565
+ ivars = extra_ivars(obj)
566
+ is_subclass = class_name != "Time"
567
+
568
+ # Use enough precision for usec round-trip
569
+ time_str = "#{obj.strftime('%Y-%m-%d %H:%M:%S')}.#{obj.usec.to_s.rjust(6, '0')} #{obj.strftime('%z')}"
570
+
571
+ if is_subclass || !ivars.empty?
572
+ result = "#{prefix}#<#{class_name}> (#{class_name})\n"
573
+ result += "#{prefix} __time__: #{time_str} (Time)\n"
574
+ ivars.each do |ivar|
575
+ val = obj.instance_variable_get(ivar)
576
+ result += emit_ivar(prefix + " ", ivar, val, indent + 1)
577
+ end
578
+ result
579
+ else
580
+ "#{prefix}#{time_str} (Time)\n"
581
+ end
582
+ end
583
+
584
+ # --- Struct ---
585
+ def emit_struct(obj, indent)
586
+ # If struct has marshal_dump, use custom object path
587
+ if obj.respond_to?(:marshal_dump)
588
+ return emit_custom(obj, indent)
589
+ end
590
+
591
+ prefix = " " * indent
592
+ class_name = obj.class.name
593
+ ivars = extra_ivars(obj)
594
+
595
+ result = "#{prefix}#<#{class_name}> (#{class_name}, Struct)\n"
596
+ obj.each_pair do |member, val|
597
+ result += emit_ivar(prefix + " ", member.to_s, val, indent + 1)
598
+ end
599
+ result += emit_extensions_str(obj, indent + 1)
600
+ ivars.each do |ivar|
601
+ val = obj.instance_variable_get(ivar)
602
+ result += emit_ivar(prefix + " ", ivar, val, indent + 1)
603
+ end
604
+ result
605
+ end
606
+
607
+ # --- Custom object ---
608
+ def emit_custom(obj, indent)
609
+ prefix = " " * indent
610
+
611
+ if obj.respond_to?(:marshal_dump)
612
+ klass = obj.class
613
+ if @marshal_dump_stack[klass]
614
+ raise RuntimeError, "Marshal.dump reentered at marshal_dump for #{klass}: same class instance"
615
+ end
616
+ @marshal_dump_stack[klass] = true
617
+ data = obj.marshal_dump
618
+ result = "#{prefix}#<#{obj.class}> (#{obj.class}, marshal_dump)\n"
619
+ result += emit(data, indent + 1)
620
+ @marshal_dump_stack.delete(klass)
621
+ return result
622
+ end
623
+
624
+ if obj.respond_to?(:_dump)
625
+ data = obj._dump(-1)
626
+ raise TypeError, "_dump() must return string" unless data.is_a?(String)
627
+ result = "#{prefix}(#{obj.class}, _dump)\n"
628
+ result += "#{prefix} #{format_string_value(data)}\n"
629
+ return result
630
+ end
631
+
632
+ # Exception special handling: store message explicitly
633
+ if obj.is_a?(Exception)
634
+ result = "#{prefix}#<#{obj.class}> (#{obj.class})\n"
635
+ result += "#{prefix} __message__: #{format_string_value(obj.message, bare: true)}\n" if obj.message
636
+ result += "#{prefix} __backtrace__:\n"
637
+ if obj.backtrace
638
+ result += emit(obj.backtrace, indent + 2)
639
+ else
640
+ result += "#{prefix} nil (NilClass)\n"
641
+ end
642
+ result += emit_extensions_str(obj, indent + 1)
643
+ obj.instance_variables.sort.each do |ivar|
644
+ # Skip internal exception ivars that we handle specially
645
+ next if ivar == :@mesg || ivar == :@bt
646
+ val = obj.instance_variable_get(ivar)
647
+ result += emit_ivar(prefix + " ", ivar, val, indent + 1)
648
+ end
649
+ return result
650
+ end
651
+
652
+ ivars_before = obj.instance_variables.sort
653
+ result = "#{prefix}#<#{obj.class}> (#{obj.class})\n"
654
+ result += emit_extensions_str(obj, indent + 1)
655
+ ivars_before.each do |ivar|
656
+ val = obj.instance_variable_get(ivar)
657
+ result += emit_ivar(prefix + " ", ivar, val, indent + 1)
658
+ end
659
+ ivars_after = obj.instance_variables.sort
660
+ if ivars_after != ivars_before
661
+ added = ivars_after - ivars_before
662
+ removed = ivars_before - ivars_after
663
+ if added.any?
664
+ raise RuntimeError, "instance variable added to #{obj.class} during dump"
665
+ end
666
+ if removed.any?
667
+ raise RuntimeError, "instance variable removed from #{obj.class} during dump"
668
+ end
669
+ end
670
+ result
671
+ end
672
+
673
+ def emit_ivar(prefix, name, val, parent_indent)
674
+ if simple_value?(val)
675
+ iv = inline_value(val)
676
+ if iv
677
+ return "#{prefix}#{name}: #{iv}\n"
678
+ end
679
+ end
680
+ "#{prefix}#{name}:\n" + emit(val, parent_indent + 1)
681
+ end
682
+
683
+ def emit_extensions_str(obj, indent)
684
+ prefix = " " * indent
685
+ ext = +""
686
+ begin
687
+ sc = obj.singleton_class
688
+ sc_ancestors = sc.ancestors
689
+ class_ancestors = obj.class.ancestors
690
+ rescue TypeError
691
+ return +""
692
+ end
693
+
694
+ # Find modules added to singleton class
695
+ # Modules before singleton_class in ancestors are prepended
696
+ # Modules after singleton_class but not in class ancestors are extended
697
+ sc_idx = sc_ancestors.index(sc)
698
+ return +"" unless sc_idx
699
+
700
+ prepended = sc_ancestors[0...sc_idx].select { |m| m.is_a?(Module) && !m.is_a?(Class) && !class_ancestors.include?(m) }
701
+ extended = sc_ancestors[(sc_idx + 1)..].select { |m| m.is_a?(Module) && !m.is_a?(Class) && !class_ancestors.include?(m) }
702
+
703
+ # Emit in reverse so they apply in correct order during load
704
+ extended.reverse.each do |mod|
705
+ if mod.name.nil? || mod.name.empty?
706
+ raise TypeError, "can't dump anonymous module"
707
+ end
708
+ ext << "#{prefix}__extend__: #{mod.name}\n"
709
+ end
710
+
711
+ prepended.reverse.each do |mod|
712
+ if mod.name.nil? || mod.name.empty?
713
+ raise TypeError, "can't dump anonymous module"
714
+ end
715
+ ext << "#{prefix}__prepend__: #{mod.name}\n"
716
+ end
717
+
718
+ ext
719
+ end
720
+ end
721
+ end