yeptris 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,338 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "time"
5
+
6
+ module Yeptris
7
+ # Recorder-driven Ruby materialization (TODO.impl/15 phase B).
8
+ #
9
+ # One bulk drain: the record array and string arena are read in two
10
+ # calls, then a pure-Ruby stack machine walks fixed-layout records —
11
+ # the FFI tax is O(chunks), never O(events). The DOM-walk
12
+ # materializer (Node#to_ruby) stays for node-based use; this is the
13
+ # Yeptris::YAML fast path.
14
+ class Materializer
15
+ RECORD_SIZE = 36 # YeptrisEventRecord layout (events.h, ABI-pinned)
16
+ FIELDS = 12 # unpacked values per record
17
+ RECORD_UNPACK = "C4V8" # type/style/flags/tag_id, line..tag_len
18
+
19
+ STREAM_START = 1
20
+ STREAM_END = 2
21
+ DOCUMENT_START = 3
22
+ DOCUMENT_END = 4
23
+ SEQUENCE_START = 5
24
+ SEQUENCE_END = 6
25
+ MAPPING_START = 7
26
+ MAPPING_END = 8
27
+ SCALAR = 9
28
+ ALIAS = 10
29
+
30
+ EF_IMPLICIT = 1 << 2
31
+ STYLE_PLAIN = 1
32
+
33
+ # YeptrisTagId values (resolve.h; FFI mirrors them)
34
+ TAG_STR = Yeptris::FFI::TAG_STR
35
+ TAG_INT = Yeptris::FFI::TAG_INT
36
+ TAG_FLOAT = Yeptris::FFI::TAG_FLOAT
37
+ TAG_BOOL = Yeptris::FFI::TAG_BOOL
38
+ TAG_NULL = Yeptris::FFI::TAG_NULL
39
+ TAG_TIMESTAMP = Yeptris::FFI::TAG_TIMESTAMP
40
+ TAG_MERGE = 9 # YEPTRIS_TAG_MERGE (resolve.h): a plain '<<' key
41
+
42
+ INF_WORDS = {
43
+ ".inf" => Float::INFINITY, ".Inf" => Float::INFINITY, ".INF" => Float::INFINITY,
44
+ "+.inf" => Float::INFINITY, "+.Inf" => Float::INFINITY, "+.INF" => Float::INFINITY,
45
+ "-.inf" => -Float::INFINITY, "-.Inf" => -Float::INFINITY, "-.INF" => -Float::INFINITY,
46
+ ".nan" => Float::NAN, ".NaN" => Float::NAN, ".NAN" => Float::NAN,
47
+ }.freeze
48
+ SEXAGESIMAL_INT = /\A[-+]?[1-9][0-9_]*(:[0-5]?[0-9])+\z/
49
+ SEXAGESIMAL_FLOAT = /\A[-+]?[0-9][0-9_]*(:[0-5]?[0-9])+:[0-5]?[0-9]\.[0-9_]*\z/
50
+
51
+ class << self
52
+ # First document of the stream, or nil when the stream is empty.
53
+ def load(yaml, schema: :compat_11)
54
+ docs = load_stream(yaml, schema: schema)
55
+ docs.empty? ? nil : docs.first
56
+ end
57
+
58
+ # Every document in the stream, in order.
59
+ def load_stream(yaml, schema: :compat_11)
60
+ new(schema: schema).materialize(yaml)
61
+ end
62
+
63
+ # The shared drain seam: one parse, records + arena read once,
64
+ # ONE unpack into a flat Integer array (12 fields per record:
65
+ # type style flags tag_id line col v_off v_len a_off a_len
66
+ # t_off t_len). Both consumers ride it — the stack machine
67
+ # (materialize) and the Psych::Parser dispatch loop.
68
+ def drain(yaml, schema: :compat_11)
69
+ rec = Yeptris::FFI.yeptris_recorder_new_ex(
70
+ schema == :compat_11 ? Yeptris::FFI::SCHEMA_11_COMPAT : Yeptris::FFI::SCHEMA_12_CORE
71
+ )
72
+ begin
73
+ status = Yeptris::FFI.yeptris_recorder_feed(rec, yaml, yaml.bytesize, 1)
74
+ if status != Yeptris::FFI::OK
75
+ raise Yeptris::ParseError,
76
+ "parse failed: #{Yeptris::FFI.last_error_message}"
77
+ end
78
+ count_p = ::FFI::MemoryPointer.new(:size_t)
79
+ records = Yeptris::FFI.yeptris_recorder_records(rec, count_p)
80
+ count = count_p.read_uint64
81
+ arena_len = ::FFI::MemoryPointer.new(:size_t)
82
+ arena_ptr = Yeptris::FFI.yeptris_recorder_arena(rec, arena_len)
83
+ arena_len_v = arena_len.read_uint64
84
+ arena = arena_ptr.null? || arena_len_v.zero? ? +"" : arena_ptr.read_bytes(arena_len_v)
85
+ flat = records.read_bytes(count * RECORD_SIZE)
86
+ .unpack(RECORD_UNPACK * count)
87
+ [flat, arena]
88
+ ensure
89
+ Yeptris::FFI.yeptris_recorder_free(rec)
90
+ end
91
+ end
92
+
93
+ # The Ruby value of a scalar per schema — the ScalarScanner rule
94
+ # set, spec-verified against Psych case by case. Only implicit
95
+ # PLAIN scalars scan; quoting is the escape hatch.
96
+ # The record's tag_id IS the resolver's verdict (the typing
97
+ # SSOT): conversion by tag, Kernel#Integer/Float for the bytes —
98
+ # no host-side grammar. Two Psych-quirk overrides where Psych's
99
+ # scanner disagrees with the 1.1 resolver: single-char y/Y/n/N
100
+ # are STRINGS in Psych (libyaml says bool), and values the
101
+ # resolver tagged INT but Kernel rejects (mixed forms) fall back
102
+ # through sexagesimal to String.
103
+ PSYCH_TRUE = %w[y yes true on].freeze
104
+
105
+ # '<<' merge: existing keys win; sequences merge in order.
106
+ # Shared by the record walk and the value-stream walk.
107
+ def merge_into(map, obj)
108
+ case obj
109
+ when Hash
110
+ obj.each { |k, v| map[k] = v unless map.key?(k) }
111
+ when Array
112
+ obj.each { |e| merge_into(map, e) if e.is_a?(Hash) }
113
+ end
114
+ end
115
+
116
+ def parse_timestamp(v)
117
+ return Date.parse(v) unless v.match?(/[Tt ]\d/)
118
+
119
+ # normalize the YAML 1.1 space forms onto iso8601 for
120
+ # xmlschema: "2001-12-14 21:59:43.10 -05:00" ->
121
+ # "2001-12-14T21:59:43.10-05:00" (Psych's scanner does the
122
+ # same dance)
123
+ Time.xmlschema(v.sub(/ (\d)/, 'T\1').sub(/ ([+-]\d)/, '\1'))
124
+ rescue ArgumentError
125
+ v
126
+ end
127
+
128
+ def scan_by_tag(value, tag_id, implicit)
129
+ case tag_id
130
+ when TAG_STR
131
+ return value unless implicit
132
+
133
+ value.start_with?(":") && !value.start_with?("::") &&
134
+ value.length > 1 ? value[1..].to_sym : value
135
+ when TAG_NULL then nil
136
+ when TAG_BOOL
137
+ return value if value.length == 1 # Psych: "y"/"n" stay Strings
138
+
139
+ PSYCH_TRUE.include?(value.downcase)
140
+ when TAG_INT
141
+ int_or_string(value)
142
+ when TAG_FLOAT
143
+ float_or_string(value)
144
+ when TAG_TIMESTAMP
145
+ parse_timestamp(value)
146
+ else
147
+ value
148
+ end
149
+ end
150
+
151
+ def int_or_string(value)
152
+ Integer(value.tr("_", ""))
153
+ rescue ArgumentError
154
+ # the resolver said INT but Kernel disagrees (mixed form):
155
+ # sexagesimal or back to String
156
+ return sexagesimal(value) if SEXAGESIMAL_INT.match?(value) ||
157
+ SEXAGESIMAL_FLOAT.match?(value)
158
+
159
+ value
160
+ end
161
+
162
+ def float_or_string(value)
163
+ return INF_WORDS[value] if INF_WORDS.key?(value)
164
+ return value unless value.include?(".")
165
+
166
+ # Psych's FLOAT: the exponent carries a mandatory sign —
167
+ # "1e3" and "1.5e3" are Strings (resolver quirk override)
168
+ return value if /[eE][^+-]/.match?(value)
169
+
170
+ Float(value.tr("_", ""))
171
+ rescue ArgumentError
172
+ return sexagesimal(value) if SEXAGESIMAL_FLOAT.match?(value)
173
+
174
+ value
175
+ end
176
+
177
+ private
178
+
179
+ # Psych's scanner IS Kernel#Integer on the plain digits: Ruby
180
+ # reads leading-zero strings as octal, rejects "018", takes
181
+ # 0x/0b/bases — verified case by case against Psych.
182
+ def sexagesimal(v)
183
+ is_float = v.include?(".")
184
+ total = 0
185
+ v.split(":").each_with_index do |n, e|
186
+ total += (is_float ? n.to_f : n.to_i) * (60**(e - 2).abs)
187
+ end
188
+ total
189
+ end
190
+
191
+
192
+ end
193
+
194
+ def initialize(schema: :compat_11)
195
+ @schema = schema
196
+ end
197
+
198
+ def materialize(yaml)
199
+ yaml = yaml.read if yaml.respond_to?(:read)
200
+ yaml = yaml.to_s
201
+ flat, arena = Materializer.drain(yaml, schema: @schema)
202
+ walk(flat, arena)
203
+ end
204
+
205
+ private
206
+
207
+ # The stack machine: containers on a stack, a pending-key slot per
208
+ # open mapping, anchors by name (first definition wins; an alias
209
+ # yields the SAME Ruby object — identity preserved). Merge keys
210
+ # (<<) resolve inline: existing keys win, sequences merge in
211
+ # order — Psych load-time semantics.
212
+ #
213
+ # flat: one Integer per unpack field, 12 per record:
214
+ # 0 type, 1 style, 2 flags, 3 tag_id, 4 line, 5 col, 6 value_off,
215
+ # 7 value_len, 8 anchor_off, 9 anchor_len, 10 tag_off, 11 tag_len.
216
+ def walk(flat, arena)
217
+ docs = []
218
+ stack = []
219
+ anchors = {}
220
+ pending_key = []
221
+ pending_key_merge = []
222
+ # per open container: the map to merge into when this container
223
+ # closes (inline `<<:` values), nil otherwise
224
+ merge_target = []
225
+
226
+ # the arena is UTF-8 by construction (validated at parse), so
227
+ # forcing its encoding ONCE makes every slice UTF-8 for free —
228
+ # no per-scalar force_encoding
229
+ arena.force_encoding(Encoding::UTF_8)
230
+ # (each_slice DESTRUCTURING measured SLOWER than plain indexing
231
+ # here: it allocates a 12-slot Array per record. Index away.)
232
+ i = 0
233
+ n = flat.length
234
+ while i < n
235
+ type = flat[i]
236
+ case type
237
+ when SCALAR
238
+ # a plain, resolver-tagged '<<' (TAG_MERGE) merges; a
239
+ # QUOTED '<<' is a literal key — Psych merges on the tag,
240
+ # not the text
241
+ tag_id = flat[i + 3]
242
+ v = Materializer.scan_by_tag(
243
+ arena[flat[i + 6], flat[i + 7]], tag_id, (flat[i + 2] & EF_IMPLICIT) != 0
244
+ )
245
+ l = flat[i + 9]
246
+ anchors[arena[flat[i + 8], l]] = v if l != 0
247
+ # place(): the scalar fast path inlined — the overwhelming
248
+ # majority of events land here
249
+ if stack.empty?
250
+ docs[-1] = v
251
+ else
252
+ parent = stack.last
253
+ if parent.is_a?(Hash)
254
+ if (key = pending_key[-1]).nil?
255
+ pending_key[-1] = v
256
+ pending_key_merge[-1] = flat[i + 3] == TAG_MERGE
257
+ else
258
+ pending_key[-1] = nil
259
+ if pending_key_merge[-1]
260
+ merge_into(parent, v)
261
+ else
262
+ parent[key] = v
263
+ end
264
+ end
265
+ else
266
+ parent.push(v)
267
+ end
268
+ end
269
+ when MAPPING_START
270
+ h = {}
271
+ l = flat[i + 9]
272
+ anchors[arena[flat[i + 8], l]] = h if l != 0
273
+ merge_target.push(place(docs, stack, pending_key, pending_key_merge, h))
274
+ stack.push(h)
275
+ pending_key.push(nil)
276
+ pending_key_merge.push(nil)
277
+ when SEQUENCE_START
278
+ a = []
279
+ l = flat[i + 9]
280
+ anchors[arena[flat[i + 8], l]] = a if l != 0
281
+ merge_target.push(place(docs, stack, pending_key, pending_key_merge, a))
282
+ stack.push(a)
283
+ pending_key.push(nil)
284
+ pending_key_merge.push(nil)
285
+ when ALIAS
286
+ name = arena[flat[i + 6], flat[i + 7]]
287
+ raise Yeptris::ParseError, "unknown anchor: #{name.inspect}" unless anchors.key?(name)
288
+
289
+ place(docs, stack, pending_key, pending_key_merge, anchors[name])
290
+ when MAPPING_END, SEQUENCE_END
291
+ done = stack.pop
292
+ pending_key.pop
293
+ pending_key_merge.pop
294
+ target = merge_target.pop
295
+ merge_into(target, done) if target
296
+ when DOCUMENT_START
297
+ docs.push(nil)
298
+ end
299
+ i += FIELDS
300
+ end
301
+ docs
302
+ end
303
+
304
+ # Attaches obj: as the current document's root when nothing is
305
+ # open, as a sequence entry, or as the pending mapping key/value.
306
+ # Returns the merge TARGET when obj is a container placed under a
307
+ # "<<" key — the caller defers the merge to the container's END
308
+ # (an inline map's contents arrive after its start event); scalar
309
+ # and alias merges apply immediately.
310
+ def place(docs, stack, pending_key, pending_key_merge, obj)
311
+ if stack.empty?
312
+ docs[-1] = obj
313
+ return nil
314
+ end
315
+ parent = stack.last
316
+ if parent.is_a?(Hash)
317
+ if pending_key.last.nil?
318
+ pending_key[-1] = obj
319
+ pending_key_merge[-1] = false
320
+ return nil
321
+ end
322
+ key = pending_key.last
323
+ pending_key[-1] = nil
324
+ if pending_key_merge[-1]
325
+ merge_into(parent, obj)
326
+ parent
327
+ else
328
+ parent[key] = obj
329
+ nil
330
+ end
331
+ else # Array
332
+ parent.push(obj)
333
+ nil
334
+ end
335
+ end
336
+
337
+ end
338
+ end
@@ -0,0 +1,320 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "time"
5
+
6
+ # A node inside a document. Borrowed C memory: every call routes
7
+ # through the owning document's liveness check — use after free
8
+ # raises Yeptris::FreedError, never a segfault.
9
+ class Yeptris::Node
10
+ include Enumerable
11
+
12
+ attr_reader :c_ptr
13
+
14
+ # @api private — always constructed via Document#wrap_node (identity).
15
+ def initialize(c_ptr, document)
16
+ @c_ptr = c_ptr
17
+ @document = document
18
+ end
19
+
20
+ def document
21
+ @document
22
+ end
23
+
24
+ KINDS = {
25
+ Yeptris::FFI::NODE_SCALAR => :scalar,
26
+ Yeptris::FFI::NODE_SEQUENCE => :sequence,
27
+ Yeptris::FFI::NODE_MAPPING => :mapping,
28
+ Yeptris::FFI::NODE_ALIAS => :alias,
29
+ }.freeze
30
+
31
+ def kind
32
+ KINDS[alive { Yeptris::FFI.yeptris_node_kind(@c_ptr) }]
33
+ end
34
+
35
+ def scalar?
36
+ kind == :scalar
37
+ end
38
+
39
+ def sequence?
40
+ kind == :sequence
41
+ end
42
+
43
+ def mapping?
44
+ kind == :mapping
45
+ end
46
+
47
+ def alias?
48
+ kind == :alias
49
+ end
50
+
51
+ # Scalar content / alias name (UTF-8 String), nil for collections.
52
+ def value
53
+ len = ::FFI::MemoryPointer.new(:uint64)
54
+ ptr = alive { Yeptris::FFI.yeptris_node_value(@c_ptr, len) }
55
+ return nil if ptr.null?
56
+
57
+ ptr.read_bytes(len.read_uint64).force_encoding(Encoding::UTF_8)
58
+ end
59
+
60
+ STYLES = {
61
+ Yeptris::FFI::STYLE_PLAIN => :plain,
62
+ Yeptris::FFI::STYLE_SINGLE_QUOTED => :single_quoted,
63
+ Yeptris::FFI::STYLE_DOUBLE_QUOTED => :double_quoted,
64
+ Yeptris::FFI::STYLE_LITERAL => :literal,
65
+ Yeptris::FFI::STYLE_FOLDED => :folded,
66
+ }.freeze
67
+
68
+ def style
69
+ STYLES[alive { Yeptris::FFI.yeptris_node_style(@c_ptr) }]
70
+ end
71
+
72
+ # Explicit tag URI when present, else nil.
73
+ def tag
74
+ len = ::FFI::MemoryPointer.new(:uint64)
75
+ ptr = alive { Yeptris::FFI.yeptris_node_tag(@c_ptr, len) }
76
+ return nil if ptr.null?
77
+
78
+ ptr.read_bytes(len.read_uint64).force_encoding(Encoding::UTF_8)
79
+ end
80
+
81
+ def anchor
82
+ len = ::FFI::MemoryPointer.new(:uint64)
83
+ ptr = alive { Yeptris::FFI.yeptris_node_anchor(@c_ptr, len) }
84
+ return nil if ptr.null?
85
+
86
+ ptr.read_bytes(len.read_uint64).force_encoding(Encoding::UTF_8)
87
+ end
88
+
89
+ def alias_target
90
+ t = alive { Yeptris::FFI.yeptris_node_alias_target(@c_ptr) }
91
+ @document.wrap_node(t)
92
+ end
93
+
94
+ # ---- typed scalar reads (tag id decides eligibility) ----
95
+
96
+ def to_i
97
+ out = ::FFI::MemoryPointer.new(:int64)
98
+ status = alive { Yeptris::FFI.yeptris_node_int(@c_ptr, out) }
99
+ Yeptris::FFI.check_status(status, "yeptris_node_int")
100
+ out.read_int64
101
+ end
102
+
103
+ def to_f
104
+ out = ::FFI::MemoryPointer.new(:double)
105
+ status = alive { Yeptris::FFI.yeptris_node_float(@c_ptr, out) }
106
+ Yeptris::FFI.check_status(status, "yeptris_node_float")
107
+ out.read_double
108
+ end
109
+
110
+ def to_bool
111
+ out = ::FFI::MemoryPointer.new(:int)
112
+ status = alive { Yeptris::FFI.yeptris_node_bool(@c_ptr, out) }
113
+ Yeptris::FFI.check_status(status, "yeptris_node_bool")
114
+ out.read_int != 0
115
+ end
116
+
117
+ TAGS = {
118
+ Yeptris::FFI::TAG_STR => :str,
119
+ Yeptris::FFI::TAG_INT => :int,
120
+ Yeptris::FFI::TAG_FLOAT => :float,
121
+ Yeptris::FFI::TAG_BOOL => :bool,
122
+ Yeptris::FFI::TAG_NULL => :null,
123
+ Yeptris::FFI::TAG_TIMESTAMP => :timestamp,
124
+ }.freeze
125
+
126
+ def tag_id
127
+ TAGS[alive { Yeptris::FFI.yeptris_node_tag_id(@c_ptr) }]
128
+ end
129
+
130
+ # ---- sequence access ----
131
+
132
+ def size
133
+ case kind
134
+ when :sequence then seq_count
135
+ when :mapping then map_count
136
+ else 1
137
+ end
138
+ end
139
+
140
+ def seq_count
141
+ alive { Yeptris::FFI.yeptris_node_seq_count(@c_ptr) }
142
+ end
143
+
144
+ def seq_at(index)
145
+ @document.wrap_node(alive { Yeptris::FFI.yeptris_node_seq_at(@c_ptr, index) })
146
+ end
147
+
148
+ def each
149
+ return enum_for(:each) unless block_given?
150
+ raise Yeptris::Error, "#each is for sequences" unless sequence?
151
+
152
+ (0...seq_count).each { |i| yield seq_at(i) }
153
+ end
154
+
155
+ # ---- mapping access ----
156
+
157
+ def map_count
158
+ alive { Yeptris::FFI.yeptris_node_map_count(@c_ptr) }
159
+ end
160
+
161
+ def [](key)
162
+ raise Yeptris::Error, "#[] is for mappings" unless mapping?
163
+
164
+ key = key.to_s
165
+ @document.wrap_node(
166
+ alive { Yeptris::FFI.yeptris_node_map_get(@c_ptr, key, key.bytesize) }
167
+ )
168
+ end
169
+
170
+ def key?(key)
171
+ !self[key].nil?
172
+ end
173
+
174
+ # Ordered [key, value] pairs.
175
+ def each_pair
176
+ return enum_for(:each_pair) unless block_given?
177
+ raise Yeptris::Error, "#each_pair is for mappings" unless mapping?
178
+
179
+ k = ::FFI::MemoryPointer.new(:pointer)
180
+ v = ::FFI::MemoryPointer.new(:pointer)
181
+ (0...map_count).each do |i|
182
+ rc = alive { Yeptris::FFI.yeptris_node_map_at(@c_ptr, i, k, v) }
183
+ next unless rc.zero?
184
+
185
+ yield @document.wrap_node(k.read_pointer), @document.wrap_node(v.read_pointer)
186
+ end
187
+ end
188
+
189
+ def keys
190
+ # explicit block: two-arg yield + Symbol#to_proc would call
191
+ # key.first(value) instead of taking the pair
192
+ each_pair.map { |k, _v| k }
193
+ end
194
+
195
+ # ---- construction (TODO.impl/11 phase 3; errors raise) ----
196
+
197
+ def map_add(key, node)
198
+ key = key.to_s
199
+ rc = alive { Yeptris::FFI.yeptris_node_map_add(@c_ptr, key, key.bytesize, node.c_ptr) }
200
+ Yeptris::FFI.check_status(rc, "yeptris_node_map_add")
201
+ self
202
+ end
203
+
204
+ def map_set(key, node)
205
+ key = key.to_s
206
+ rc = alive { Yeptris::FFI.yeptris_node_map_set(@c_ptr, key, key.bytesize, node.c_ptr) }
207
+ Yeptris::FFI.check_status(rc, "yeptris_node_map_set")
208
+ self
209
+ end
210
+
211
+ def map_del(key)
212
+ key = key.to_s
213
+ alive { Yeptris::FFI.yeptris_node_map_del(@c_ptr, key, key.bytesize) }.zero?
214
+ end
215
+
216
+ def seq_add(node)
217
+ rc = alive { Yeptris::FFI.yeptris_node_seq_add(@c_ptr, node.c_ptr) }
218
+ Yeptris::FFI.check_status(rc, "yeptris_node_seq_add")
219
+ self
220
+ end
221
+
222
+ # Props on synthesized nodes (15's YAMLTree); values copied in.
223
+ def set_anchor(name)
224
+ alive { Yeptris::FFI.yeptris_node_set_anchor(@c_ptr, name, name.bytesize) }.zero?
225
+ end
226
+
227
+ def set_tag(tag)
228
+ alive { Yeptris::FFI.yeptris_node_set_tag(@c_ptr, tag, tag.bytesize) }.zero?
229
+ end
230
+
231
+ def seq_del(index)
232
+ alive { Yeptris::FFI.yeptris_node_seq_del(@c_ptr, index) }.zero?
233
+ end
234
+
235
+ # ---- Ruby materialization (Psych-compatible) ----
236
+
237
+ # Materializes the subtree as native Ruby objects. Aliases preserve
238
+ # object identity (the same Ruby object for every reference), keys
239
+ # materialize with Psych's implicit typing (":sym" -> Symbol under
240
+ # compat), and the anchor memo makes cycles defined by anchors safe.
241
+ def node_id
242
+ alive { Yeptris::FFI.yeptris_node_id(@c_ptr) }
243
+ end
244
+
245
+ def to_ruby(memo = nil)
246
+ # readonly documents memoize per node: repeated materialization
247
+ # of a shared subtree returns the SAME object at zero walk cost
248
+ if @document.readonly?
249
+ rm = @document.readonly_memo
250
+ cached = rm[node_id]
251
+ return cached if cached
252
+
253
+ return rm[node_id] = to_ruby_walk({})
254
+ end
255
+ to_ruby_walk(memo || {})
256
+ end
257
+
258
+ def to_ruby_walk(memo)
259
+ cached = memo[node_id]
260
+ return cached if cached
261
+
262
+ case kind
263
+ when :mapping
264
+ h = {}
265
+ memo[node_id] = h
266
+ each_pair do |k, v|
267
+ h[k.to_ruby_key] = v.to_ruby(memo)
268
+ end
269
+ h
270
+ when :sequence
271
+ a = []
272
+ memo[node_id] = a
273
+ each { |e| a << e.to_ruby(memo) }
274
+ a
275
+ when :alias
276
+ t = alias_target
277
+ t.nil? ? nil : t.to_ruby(memo)
278
+ else
279
+ scalar_to_ruby
280
+ end
281
+ end
282
+
283
+ # Mapping keys: Psych semantics — a plain scalar ":name" under the
284
+ # compat schema scans to a Symbol; everything else materializes as
285
+ # the scalar itself.
286
+ def to_ruby_key
287
+ symbol? ? symbolize : to_ruby
288
+ end
289
+
290
+ def scalar_to_ruby
291
+ return symbolize if symbol? && tag_id == :str
292
+
293
+ case tag_id
294
+ when :null then nil
295
+ when :bool then to_bool
296
+ when :int then to_i
297
+ when :float then to_f
298
+ when :timestamp then ::Yeptris::Materializer.parse_timestamp(value)
299
+ else value
300
+ end
301
+ end
302
+
303
+ # Psych's ScalarScanner: plain ":name" (and ":", the null Symbol)
304
+ # scans to a Symbol; quoting defeats it.
305
+ def symbol?
306
+ style == :plain && value&.start_with?(":") && !value.start_with?("::")
307
+ end
308
+
309
+ def symbolize
310
+ v = value
311
+ v.length <= 1 ? :"" : v[1..].to_sym
312
+ end
313
+
314
+
315
+
316
+ def alive
317
+ @document.ensure_alive!
318
+ yield
319
+ end
320
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yeptris
4
+ module Psych
5
+ # Minimal Psych::Coder stand-in (encode_with / init_with carry
6
+ # these): tag + one of scalar / seq / map. map is ordered.
7
+ class CoderShim
8
+ attr_reader :type, :tag, :scalar, :seq
9
+
10
+ def initialize(class_name = nil)
11
+ @type = :map
12
+ # Psych's default for encode_with objects: their own class tag
13
+ @tag = class_name ? "!ruby/object:#{class_name}" : nil
14
+ @scalar = nil
15
+ @seq = nil
16
+ @map = nil
17
+ @class_name = class_name
18
+ end
19
+
20
+ def tag=(t)
21
+ @tag = t
22
+ end
23
+
24
+ def scalar=(value)
25
+ @type = :scalar
26
+ @scalar = value
27
+ end
28
+
29
+ def seq=(list)
30
+ @type = :seq
31
+ @seq = list
32
+ end
33
+
34
+ def map=(hash)
35
+ @type = :map
36
+ @map = nil
37
+ hash&.each { |k, v| self[k] = v }
38
+ end
39
+
40
+ def []=(key, value)
41
+ @type = :map
42
+ @map ||= {}
43
+ @map[key.to_s] = value
44
+ end
45
+
46
+ def [](key)
47
+ @map&.[](key.to_s)
48
+ end
49
+
50
+ def each(&block)
51
+ @map&.each(&block)
52
+ end
53
+ end
54
+ end
55
+ end