yeptris 0.1.13.3-x86_64-linux

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,347 @@
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
+ ts = v.sub(/ (\d)/, 'T\1').sub(/ ([+-]\d)/, '\1').sub(/ +Z\z/i, 'Z')
124
+ # Psych reads a NAIVE timestamp (no zone, no Z) as a UTC
125
+ # instant expressed in the local zone — one wall-clock hour
126
+ # off from reading it as local time (pinned by spec against
127
+ # Psych.unsafe_load across tz shapes)
128
+ if ts.match?(/(Z|[+-]\d\d:?\d\d)\z/i)
129
+ Time.xmlschema(ts)
130
+ else
131
+ Time.xmlschema(ts + "Z").getlocal
132
+ end
133
+ rescue ArgumentError
134
+ v
135
+ end
136
+
137
+ def scan_by_tag(value, tag_id, implicit)
138
+ case tag_id
139
+ when TAG_STR
140
+ return value unless implicit
141
+
142
+ value.start_with?(":") && !value.start_with?("::") &&
143
+ value.length > 1 ? value[1..].to_sym : value
144
+ when TAG_NULL then nil
145
+ when TAG_BOOL
146
+ return value if value.length == 1 # Psych: "y"/"n" stay Strings
147
+
148
+ PSYCH_TRUE.include?(value.downcase)
149
+ when TAG_INT
150
+ int_or_string(value)
151
+ when TAG_FLOAT
152
+ float_or_string(value)
153
+ when TAG_TIMESTAMP
154
+ parse_timestamp(value)
155
+ else
156
+ value
157
+ end
158
+ end
159
+
160
+ def int_or_string(value)
161
+ Integer(value.tr("_", ""))
162
+ rescue ArgumentError
163
+ # the resolver said INT but Kernel disagrees (mixed form):
164
+ # sexagesimal or back to String
165
+ return sexagesimal(value) if SEXAGESIMAL_INT.match?(value) ||
166
+ SEXAGESIMAL_FLOAT.match?(value)
167
+
168
+ value
169
+ end
170
+
171
+ def float_or_string(value)
172
+ return INF_WORDS[value] if INF_WORDS.key?(value)
173
+ return value unless value.include?(".")
174
+
175
+ # Psych's FLOAT: the exponent carries a mandatory sign —
176
+ # "1e3" and "1.5e3" are Strings (resolver quirk override)
177
+ return value if /[eE][^+-]/.match?(value)
178
+
179
+ Float(value.tr("_", ""))
180
+ rescue ArgumentError
181
+ return sexagesimal(value) if SEXAGESIMAL_FLOAT.match?(value)
182
+
183
+ value
184
+ end
185
+
186
+ private
187
+
188
+ # Psych's scanner IS Kernel#Integer on the plain digits: Ruby
189
+ # reads leading-zero strings as octal, rejects "018", takes
190
+ # 0x/0b/bases — verified case by case against Psych.
191
+ def sexagesimal(v)
192
+ is_float = v.include?(".")
193
+ total = 0
194
+ v.split(":").each_with_index do |n, e|
195
+ total += (is_float ? n.to_f : n.to_i) * (60**(e - 2).abs)
196
+ end
197
+ total
198
+ end
199
+
200
+
201
+ end
202
+
203
+ def initialize(schema: :compat_11)
204
+ @schema = schema
205
+ end
206
+
207
+ def materialize(yaml)
208
+ yaml = Yeptris.read_input(yaml)
209
+ yaml = yaml.to_s
210
+ flat, arena = Materializer.drain(yaml, schema: @schema)
211
+ walk(flat, arena)
212
+ end
213
+
214
+ private
215
+
216
+ # The stack machine: containers on a stack, a pending-key slot per
217
+ # open mapping, anchors by name (first definition wins; an alias
218
+ # yields the SAME Ruby object — identity preserved). Merge keys
219
+ # (<<) resolve inline: existing keys win, sequences merge in
220
+ # order — Psych load-time semantics.
221
+ #
222
+ # flat: one Integer per unpack field, 12 per record:
223
+ # 0 type, 1 style, 2 flags, 3 tag_id, 4 line, 5 col, 6 value_off,
224
+ # 7 value_len, 8 anchor_off, 9 anchor_len, 10 tag_off, 11 tag_len.
225
+ def walk(flat, arena)
226
+ docs = []
227
+ stack = []
228
+ anchors = {}
229
+ pending_key = []
230
+ pending_key_merge = []
231
+ # per open container: the map to merge into when this container
232
+ # closes (inline `<<:` values), nil otherwise
233
+ merge_target = []
234
+
235
+ # the arena is UTF-8 by construction (validated at parse), so
236
+ # forcing its encoding ONCE makes every slice UTF-8 for free —
237
+ # no per-scalar force_encoding
238
+ arena.force_encoding(Encoding::UTF_8)
239
+ # (each_slice DESTRUCTURING measured SLOWER than plain indexing
240
+ # here: it allocates a 12-slot Array per record. Index away.)
241
+ i = 0
242
+ n = flat.length
243
+ while i < n
244
+ type = flat[i]
245
+ case type
246
+ when SCALAR
247
+ # a plain, resolver-tagged '<<' (TAG_MERGE) merges; a
248
+ # QUOTED '<<' is a literal key — Psych merges on the tag,
249
+ # not the text
250
+ tag_id = flat[i + 3]
251
+ v = Materializer.scan_by_tag(
252
+ arena[flat[i + 6], flat[i + 7]], tag_id, (flat[i + 2] & EF_IMPLICIT) != 0
253
+ )
254
+ l = flat[i + 9]
255
+ anchors[arena[flat[i + 8], l]] = v if l != 0
256
+ # place(): the scalar fast path inlined — the overwhelming
257
+ # majority of events land here
258
+ if stack.empty?
259
+ docs[-1] = v
260
+ else
261
+ parent = stack.last
262
+ if parent.is_a?(Hash)
263
+ if (key = pending_key[-1]).nil?
264
+ pending_key[-1] = v
265
+ pending_key_merge[-1] = flat[i + 3] == TAG_MERGE
266
+ else
267
+ pending_key[-1] = nil
268
+ if pending_key_merge[-1]
269
+ merge_into(parent, v)
270
+ else
271
+ parent[key] = v
272
+ end
273
+ end
274
+ else
275
+ parent.push(v)
276
+ end
277
+ end
278
+ when MAPPING_START
279
+ h = {}
280
+ l = flat[i + 9]
281
+ anchors[arena[flat[i + 8], l]] = h if l != 0
282
+ merge_target.push(place(docs, stack, pending_key, pending_key_merge, h))
283
+ stack.push(h)
284
+ pending_key.push(nil)
285
+ pending_key_merge.push(nil)
286
+ when SEQUENCE_START
287
+ a = []
288
+ l = flat[i + 9]
289
+ anchors[arena[flat[i + 8], l]] = a if l != 0
290
+ merge_target.push(place(docs, stack, pending_key, pending_key_merge, a))
291
+ stack.push(a)
292
+ pending_key.push(nil)
293
+ pending_key_merge.push(nil)
294
+ when ALIAS
295
+ name = arena[flat[i + 6], flat[i + 7]]
296
+ raise Yeptris::ParseError, "unknown anchor: #{name.inspect}" unless anchors.key?(name)
297
+
298
+ place(docs, stack, pending_key, pending_key_merge, anchors[name])
299
+ when MAPPING_END, SEQUENCE_END
300
+ done = stack.pop
301
+ pending_key.pop
302
+ pending_key_merge.pop
303
+ target = merge_target.pop
304
+ merge_into(target, done) if target
305
+ when DOCUMENT_START
306
+ docs.push(nil)
307
+ end
308
+ i += FIELDS
309
+ end
310
+ docs
311
+ end
312
+
313
+ # Attaches obj: as the current document's root when nothing is
314
+ # open, as a sequence entry, or as the pending mapping key/value.
315
+ # Returns the merge TARGET when obj is a container placed under a
316
+ # "<<" key — the caller defers the merge to the container's END
317
+ # (an inline map's contents arrive after its start event); scalar
318
+ # and alias merges apply immediately.
319
+ def place(docs, stack, pending_key, pending_key_merge, obj)
320
+ if stack.empty?
321
+ docs[-1] = obj
322
+ return nil
323
+ end
324
+ parent = stack.last
325
+ if parent.is_a?(Hash)
326
+ if pending_key.last.nil?
327
+ pending_key[-1] = obj
328
+ pending_key_merge[-1] = false
329
+ return nil
330
+ end
331
+ key = pending_key.last
332
+ pending_key[-1] = nil
333
+ if pending_key_merge[-1]
334
+ merge_into(parent, obj)
335
+ parent
336
+ else
337
+ parent[key] = obj
338
+ nil
339
+ end
340
+ else # Array
341
+ parent.push(obj)
342
+ nil
343
+ end
344
+ end
345
+
346
+ end
347
+ end
Binary file
@@ -0,0 +1,354 @@
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: a second materialization
247
+ # returns the SAME object (the readonly cache is the ground truth
248
+ # for alias identity across calls).
249
+ if @document.readonly?
250
+ rm = @document.readonly_memo
251
+ cached = rm[node_id]
252
+ return cached if cached
253
+ end
254
+ # Marshal fast path (TODO.restructure/21): one C call turns the
255
+ # whole subtree into Ruby objects, preserving alias identity via
256
+ # the object's own object-link table. Only at the TOP-LEVEL entry
257
+ # (memo nil): inside a recursive walk a partial marshal would
258
+ # materialize outside the shared memo and break alias identity.
259
+ # Falls back to the per-node FFI walk on constructs the format
260
+ # cannot express (merge keys, timestamps) and on older builds.
261
+ if Yeptris::FFI::MARSHAL && memo.nil?
262
+ result =
263
+ begin
264
+ out_p = ::FFI::MemoryPointer.new(:pointer)
265
+ olen_p = ::FFI::MemoryPointer.new(:size_t)
266
+ st = Yeptris::FFI.yeptris_marshal_node(@c_ptr, out_p, olen_p)
267
+ if st == Yeptris::FFI::ERROR_UNSUPPORTED
268
+ nil
269
+ elsif st != Yeptris::FFI::OK
270
+ raise Yeptris::ParseError, Yeptris::FFI.last_error_message
271
+ else
272
+ buf = out_p.read_pointer
273
+ len = olen_p.read_uint64
274
+ bytes = buf.read_bytes(len)
275
+ bytes.force_encoding(Encoding::ASCII_8BIT)
276
+ ::Marshal.load(bytes)
277
+ end
278
+ ensure
279
+ Yeptris::FFI.yeptris_marshal_free(out_p.read_pointer) if out_p
280
+ end
281
+ unless result.nil?
282
+ @document.readonly_memo[node_id] = result if @document.readonly?
283
+ return result
284
+ end
285
+ end
286
+ if @document.readonly?
287
+ return @document.readonly_memo[node_id] = to_ruby_walk({})
288
+ end
289
+ to_ruby_walk(memo || {})
290
+ end
291
+
292
+ def to_ruby_walk(memo)
293
+ cached = memo[node_id]
294
+ return cached if cached
295
+
296
+ case kind
297
+ when :mapping
298
+ h = {}
299
+ memo[node_id] = h
300
+ each_pair do |k, v|
301
+ h[k.to_ruby_key] = v.to_ruby(memo)
302
+ end
303
+ h
304
+ when :sequence
305
+ a = []
306
+ memo[node_id] = a
307
+ each { |e| a << e.to_ruby(memo) }
308
+ a
309
+ when :alias
310
+ t = alias_target
311
+ t.nil? ? nil : t.to_ruby(memo)
312
+ else
313
+ scalar_to_ruby
314
+ end
315
+ end
316
+
317
+ # Mapping keys: Psych semantics — a plain scalar ":name" under the
318
+ # compat schema scans to a Symbol; everything else materializes as
319
+ # the scalar itself.
320
+ def to_ruby_key
321
+ symbol? ? symbolize : to_ruby
322
+ end
323
+
324
+ def scalar_to_ruby
325
+ return symbolize if symbol? && tag_id == :str
326
+
327
+ case tag_id
328
+ when :null then nil
329
+ when :bool then to_bool
330
+ when :int then to_i
331
+ when :float then to_f
332
+ when :timestamp then ::Yeptris::Materializer.parse_timestamp(value)
333
+ else value
334
+ end
335
+ end
336
+
337
+ # Psych's ScalarScanner: plain ":name" (and ":", the null Symbol)
338
+ # scans to a Symbol; quoting defeats it.
339
+ def symbol?
340
+ style == :plain && value&.start_with?(":") && !value.start_with?("::")
341
+ end
342
+
343
+ def symbolize
344
+ v = value
345
+ v.length <= 1 ? :"" : v[1..].to_sym
346
+ end
347
+
348
+
349
+
350
+ def alive
351
+ @document.ensure_alive!
352
+ yield
353
+ end
354
+ end