yeptris 0.6.14.1 → 0.6.15.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 60e8ec13d8221947494e0f163b57a619a3bb6d4f179f53d3f9b324e0ae0a4ffb
4
- data.tar.gz: 6eda638121e069206be5d900cc044c942b84e74989d6482255b0e9177aab13f4
3
+ metadata.gz: b1326388a6f59887d9b8f541ec6f4c835071ca05f4821d429a82b8acc1a990e2
4
+ data.tar.gz: 521f5abbcebb9611a0c67699b1952e72d3790d228fe606148f095d622f5b4387
5
5
  SHA512:
6
- metadata.gz: dbe32b83a5b017191c37a5978325516a5f99a7d85819ead1dcd97c030fec14f586acfb6d5965275cf1416c74f4dbeecb08abea77074ce769f51340ef3c1e9a7a
7
- data.tar.gz: 9b6db9635acbdc9e83017626594b8417fec8c2953e38d17878bf2e99e5a4e0976f4087862890f78a23155ea3eb34d8ca8afeb87e536991fa80091b626e40875e
6
+ metadata.gz: 7b6e36f35a32d480af8635023bbaf173194e8295e34851ae53ec8121cdf872e9d71c4877f70cc94d1ee62d0d22e4f74e868edc7746b2fbbd9d66af47f31daad0
7
+ data.tar.gz: 21a442ef4c99c57f46b837af822db3d98434c476b5a0b7c56d52f03bbfac0c8849be28b2a7fd2109a95b15a80ea6eda9116c3631ff5c6faff2d134e5b2a861fc
@@ -20,24 +20,29 @@ class Yeptris::Document
20
20
  # always yields the same Ruby object (identity for aliases/eql?),
21
21
  # cleared at free — no stale entries, no GC-race weak maps.
22
22
  @wrapper_cache = {}
23
- ObjectSpace.define_finalizer(self, self.class.finalize(c_ptr, freed)) unless c_ptr.null?
23
+ ObjectSpace.define_finalizer(self, self.class.finalize(c_ptr)) unless c_ptr.null?
24
24
  end
25
25
 
26
26
  def self.parse(yaml, schema: :core_12, max_depth: 0)
27
27
  yaml = Yeptris.read_input(yaml)
28
28
  yaml = yaml.to_s
29
+ status = ::FFI::MemoryPointer.new(:int)
29
30
  doc =
30
31
  if schema == :core_12 && max_depth.zero?
31
- Yeptris::FFI.yeptris_parse(yaml, yaml.bytesize, nil)
32
+ Yeptris::FFI.yeptris_parse(yaml, yaml.bytesize, status)
32
33
  else
33
34
  opts = Yeptris::FFI::ParseOptions.new
34
35
  opts[:schema] = schema == :compat_11 ? Yeptris::FFI::SCHEMA_11_COMPAT : Yeptris::FFI::SCHEMA_12_CORE
35
36
  opts[:max_depth] = max_depth
36
- Yeptris::FFI.yeptris_parse_ex(yaml, yaml.bytesize, opts, nil)
37
+ Yeptris::FFI.yeptris_parse_ex(yaml, yaml.bytesize, opts, status)
37
38
  end
38
39
  if doc.null?
39
- # the status out-param is skipped: the thread-local error
40
- # channel carries the failure detail (measurable on small docs)
40
+ # NULL + OK is the legal empty stream (no documents): nil, like
41
+ # stdlib. The status out-param is the discriminator — the TLS
42
+ # error message cannot serve (a stale error from an earlier
43
+ # parse persists on the thread).
44
+ return nil if status.read_int.zero?
45
+
41
46
  raise Yeptris::ParseError,
42
47
  "parse failed: #{Yeptris::FFI.last_error_message}"
43
48
  end
@@ -83,6 +88,9 @@ class Yeptris::Document
83
88
  return if @freed.state == :freed
84
89
 
85
90
  @freed.state = :freed
91
+ # stop the GC finalizer BEFORE the C free — it captures the same
92
+ # pointer, and its firing after this free is the double free
93
+ ObjectSpace.undefine_finalizer(self)
86
94
  @wrapper_cache.clear
87
95
  Yeptris::FFI.yeptris_document_free(@c_ptr)
88
96
  end
@@ -216,12 +224,15 @@ class Yeptris::Document
216
224
  self
217
225
  end
218
226
 
219
- # GC safety net: an explicit #free already ran is fine; a miss here
220
- # frees C memory that would otherwise leak.
221
- def self.finalize(c_ptr, freed)
222
- proc do
223
- Yeptris::FFI.yeptris_document_free(c_ptr) if freed.state == :alive
224
- freed.state = :freed
225
- end
227
+ # GC safety net (#182): the proc captures ONLY the raw pointer — no
228
+ # Ruby objects, no wrapper, no freed-flag struct. A finalizer that
229
+ # closes over the owning wrapper races that wrapper's own sweep
230
+ # (the classic use-after-free: freed?/free against half-collected
231
+ # ivars, then a garbage C pointer into yeptris_document_free →
232
+ # SIGABRT). Explicit #free undefines this finalizer BEFORE the C
233
+ # free, so the double free cannot happen; finalizers run on the
234
+ # main thread, so there is no cross-thread window.
235
+ def self.finalize(c_ptr)
236
+ proc { Yeptris::FFI.yeptris_document_free(c_ptr) }
226
237
  end
227
238
  end
@@ -115,7 +115,11 @@ module Yeptris
115
115
  end
116
116
 
117
117
  def parse_timestamp(v)
118
- return Date.parse(v) unless v.match?(/[Tt ]\d/)
118
+ # stdlib scalar_scanner: date-only strings parse with
119
+ # strptime('%F', Date::GREGORIAN) — the PROLEPTIC calendar —
120
+ # not Date.parse's default ITALY reform (a 1582 cycle fails
121
+ # the ported test_julian_date otherwise)
122
+ return ::Date.strptime(v, "%F", ::Date::GREGORIAN) unless v.match?(/[Tt ]\d/)
119
123
 
120
124
  # normalize the YAML 1.1 space forms onto iso8601 for
121
125
  # xmlschema: "2001-12-14 21:59:43.10 -05:00" ->
data/lib/yeptris/node.rb CHANGED
@@ -276,6 +276,31 @@ class Yeptris::Node
276
276
  alive { Yeptris::FFI.yeptris_node_id(@c_ptr) }
277
277
  end
278
278
 
279
+ # The marshal fast path (TODO.restructure/21; #178): one C call
280
+ # turns this subtree into Marshal 4.8 bytes — Marshal.load builds
281
+ # the Ruby objects in C, no per-node FFI. nil when the document
282
+ # carries constructs the format cannot express (merge keys,
283
+ # timestamps, tagged revivals): the caller walks instead. Raises
284
+ # ParseError on engine errors (not on the bail).
285
+ def marshal_fast
286
+ return nil unless Yeptris::FFI::MARSHAL
287
+
288
+ out_p = ::FFI::MemoryPointer.new(:pointer)
289
+ olen_p = ::FFI::MemoryPointer.new(:size_t)
290
+ st = alive { Yeptris::FFI.yeptris_marshal_node(@c_ptr, out_p, olen_p) }
291
+ if st == Yeptris::FFI::ERROR_UNSUPPORTED
292
+ nil
293
+ elsif st != Yeptris::FFI::OK
294
+ raise Yeptris::ParseError, Yeptris::FFI.last_error_message
295
+ else
296
+ bytes = out_p.read_pointer.read_bytes(olen_p.read_uint64)
297
+ bytes.force_encoding(Encoding::ASCII_8BIT)
298
+ ::Marshal.load(bytes)
299
+ end
300
+ ensure
301
+ Yeptris::FFI.yeptris_marshal_free(out_p.read_pointer) if out_p
302
+ end
303
+
279
304
  def to_ruby(memo = nil)
280
305
  # readonly documents memoize per node: a second materialization
281
306
  # returns the SAME object (the readonly cache is the ground truth
@@ -293,25 +318,7 @@ class Yeptris::Node
293
318
  # Falls back to the per-node FFI walk on constructs the format
294
319
  # cannot express (merge keys, timestamps) and on older builds.
295
320
  if Yeptris::FFI::MARSHAL && memo.nil?
296
- result =
297
- begin
298
- out_p = ::FFI::MemoryPointer.new(:pointer)
299
- olen_p = ::FFI::MemoryPointer.new(:size_t)
300
- st = Yeptris::FFI.yeptris_marshal_node(@c_ptr, out_p, olen_p)
301
- if st == Yeptris::FFI::ERROR_UNSUPPORTED
302
- nil
303
- elsif st != Yeptris::FFI::OK
304
- raise Yeptris::ParseError, Yeptris::FFI.last_error_message
305
- else
306
- buf = out_p.read_pointer
307
- len = olen_p.read_uint64
308
- bytes = buf.read_bytes(len)
309
- bytes.force_encoding(Encoding::ASCII_8BIT)
310
- ::Marshal.load(bytes)
311
- end
312
- ensure
313
- Yeptris::FFI.yeptris_marshal_free(out_p.read_pointer) if out_p
314
- end
321
+ result = marshal_fast
315
322
  unless result.nil?
316
323
  @document.readonly_memo[node_id] = result if @document.readonly?
317
324
  return result
@@ -332,7 +339,32 @@ class Yeptris::Node
332
339
  h = {}
333
340
  memo[node_id] = h
334
341
  each_pair do |k, v|
335
- h[k.to_ruby_key] = v.to_ruby(memo)
342
+ key = k.to_ruby_key
343
+ val = v.to_ruby(memo)
344
+ if key == "<<" && k.tag != "tag:yaml.org,2002:str"
345
+ # stdlib revive_hash's merge branch: the VALUE's node kind
346
+ # picks the arm; every merge is TypeError-guarded (a bad
347
+ # element keeps the whole '<<' pair literal)
348
+ if v.kind == :alias || v.mapping?
349
+ begin
350
+ h.merge!(val)
351
+ rescue ::TypeError
352
+ h[key] = val
353
+ end
354
+ elsif v.sequence?
355
+ begin
356
+ merged = {}
357
+ val.reverse_each { |e| merged.merge!(e) }
358
+ h.merge!(merged)
359
+ rescue ::TypeError
360
+ h[key] = val
361
+ end
362
+ else
363
+ h[key] = val
364
+ end
365
+ else
366
+ h[key] = val
367
+ end
336
368
  end
337
369
  h
338
370
  when :sequence
@@ -342,7 +374,11 @@ class Yeptris::Node
342
374
  a
343
375
  when :alias
344
376
  t = alias_target
345
- t.nil? ? nil : t.to_ruby(memo)
377
+ if t.nil?
378
+ raise ::Yeptris::Psych::AnchorNotDefined,
379
+ "Unknown anchor: #{anchor}"
380
+ end
381
+ t.to_ruby(memo)
346
382
  else
347
383
  scalar_to_ruby
348
384
  end
@@ -358,6 +394,15 @@ class Yeptris::Node
358
394
  def scalar_to_ruby
359
395
  return symbolize if symbol? && tag_id == :str
360
396
 
397
+ # stdlib deserialize's tagged-scalar arms: the class tag carries
398
+ # DateTime (dumped tagged — a plain timestamp re-loads as Time)
399
+ if tag == "!ruby/object:DateTime"
400
+ ts = value.to_s.sub(/ (\d)/, 'T\\1').sub(/ ([+-]\d)/, '\\1')
401
+ t = ::Time.xmlschema(ts)
402
+ return ::DateTime.civil(t.year, t.month, t.day, t.hour, t.min, t.sec,
403
+ Rational(t.utc_offset, 86_400)) + Rational(t.subsec, 86_400)
404
+ end
405
+
361
406
  case tag_id
362
407
  when :null then nil
363
408
  when :bool then to_bool
@@ -66,11 +66,34 @@ module Yeptris
66
66
  end
67
67
  end
68
68
 
69
+ # stdlib register()s every object — scalars included — so a
70
+ # repeated scalar dumps &name/*name (test_float_references,
71
+ # test_alias_with_time). Containers already route through
72
+ # anchor_for; this is the scalar arm of the same machinery.
73
+ def anchored_scalar(obj, text: nil, tag: nil)
74
+ state, name = anchor_for(obj)
75
+ return alias_of(obj, name) if state == :alias
76
+
77
+ n = scalar(text || obj, tag: tag)
78
+ n.set_anchor(name) if name
79
+ remember(obj, n)
80
+ n
81
+ end
82
+
69
83
  def visit(obj)
70
84
  case obj
85
+ # strings/floats/ints: stdlib yaml_tree does NOT anchor them
86
+ # (its float test is load-side only) — and CBOR encode
87
+ # rejects the anchors the cbor profile feeds it
71
88
  when nil, true, false, ::Integer, ::Float, ::String then scalar(obj)
72
89
  when ::Symbol then @tree.new_scalar(":#{obj}", :plain) # psych emits symbols bare, never through visit_String's quoting rules
73
- when ::Date, ::Time then scalar(obj) # timestamp text, never ivars
90
+ # stdlib visit_DateTime: the class tag carries the type (a
91
+ # plain timestamp re-loads as Time) — "!ruby/object:DateTime
92
+ # 2017-04-13 12:00:00.500000000 +09:00", to_s text, one line
93
+ when ::DateTime
94
+ anchored_scalar(obj, text: obj.strftime("%F %H:%M:%S.%9N %:z"),
95
+ tag: "!ruby/object:DateTime")
96
+ when ::Date, ::Time then anchored_scalar(obj) # timestamp text, never ivars
74
97
  when ::Hash then visit_hash(obj)
75
98
  when ::Array then visit_array(obj)
76
99
  when ::Struct then visit_struct(obj)
@@ -101,7 +124,7 @@ module Yeptris
101
124
  if @refs[oid] < 2
102
125
  return [:none, nil]
103
126
  end
104
- name = "o#{@counter += 1}"
127
+ name = (@counter += 1).to_s # stdlib: &1, *1
105
128
  @names[oid] = name
106
129
  [:new, name]
107
130
  end
@@ -156,7 +179,7 @@ module Yeptris
156
179
  # neutral surface alone was pinned)
157
180
  ::Yeptris::YAML::BulkBuilder.time_text(obj)
158
181
  elsif obj.is_a?(::Date)
159
- obj.iso8601 # canonical calendar form (DateTime rides here too)
182
+ obj.iso8601 # canonical calendar form
160
183
  elsif obj.nil?
161
184
  "" # libyaml's null rendering rides bare (issue #290)
162
185
  elsif obj.is_a?(::Float)
@@ -165,7 +188,7 @@ module Yeptris
165
188
  obj.to_s
166
189
  end
167
190
  n =
168
- if obj.is_a?(::String)
191
+ if obj.is_a?(::String) && !tag
169
192
  ::Yeptris::YAML::Builder.build_string(@tree, obj)
170
193
  else
171
194
  @tree.new_scalar(text, :plain)
@@ -188,6 +211,13 @@ module Yeptris
188
211
  key_node = @tree.new_scalar("", :single_quoted)
189
212
  key_node.set_tag("!")
190
213
  m.map_add_node(key_node, visit(v))
214
+ elsif k.is_a?(::String) && k == "<<"
215
+ # stdlib yaml_tree: a literal chevron key carries the
216
+ # explicit !!str tag (single-quoted) so it does not
217
+ # re-load as a merge
218
+ key_node = @tree.new_scalar("<<", :single_quoted)
219
+ key_node.set_tag("!!str")
220
+ m.map_add_node(key_node, visit(v))
191
221
  else
192
222
  m.map_add(key_text(k), visit(v))
193
223
  end
@@ -213,7 +243,7 @@ module Yeptris
213
243
  m = @tree.new_mapping
214
244
  remember(strct, m)
215
245
  m.set_anchor(name) if name
216
- m.set_tag(strct.class.name ? "!ruby/struct:#{strct.class.name}" : "!ruby/struct")
246
+ m.set_tag(strct.class.name.to_s.empty? ? "!ruby/struct" : "!ruby/struct:#{strct.class.name}")
217
247
  strct.each_pair { |k, v| m.map_add(key_text(k), visit(v)) }
218
248
  m
219
249
  end
@@ -319,6 +349,9 @@ module Yeptris
319
349
  when ::Yeptris::Psych::Nodes::Mapping then visit_mapping(node)
320
350
  else node&.to_ruby
321
351
  end
352
+ # stdlib register(): an anchored node's result is THE object
353
+ # for every alias to that anchor (scalars included)
354
+ anchors[node.anchor] = result if node.respond_to?(:anchor) && node.anchor
322
355
  # the domain-types post-pass (stdlib ToRuby#accept's tail):
323
356
  # registered blocks transform the revived result per node
324
357
  if node && !::Yeptris::Psych.domain_types.empty?
@@ -415,10 +448,38 @@ module Yeptris
415
448
  end
416
449
  end
417
450
 
451
+ # stdlib visit_hash's merge branch, verbatim in structure: the
452
+ # key's SHAPE decides (an explicit !!str '<<' is a literal key,
453
+ # not a merge), the VALUE's node kind picks the arm, and every
454
+ # merge is TypeError-guarded (a bad element keeps the whole
455
+ # '<<' pair literal — merges are all-or-nothing).
418
456
  def hash_into(h, node)
419
457
  anchors[node.anchor] = h if node.anchor
420
458
  node.children.each_slice(2) do |k, v|
421
- h[visit(k)] = visit(v)
459
+ key = visit(k)
460
+ val = visit(v)
461
+ if key == "<<" && k.tag != "tag:yaml.org,2002:str"
462
+ case v
463
+ when ::Yeptris::Psych::Nodes::Alias, ::Yeptris::Psych::Nodes::Mapping
464
+ begin
465
+ h.merge!(val)
466
+ rescue ::TypeError
467
+ h[key] = val
468
+ end
469
+ when ::Yeptris::Psych::Nodes::Sequence
470
+ begin
471
+ merged = {}
472
+ val.reverse_each { |value| merged.merge!(value) }
473
+ h.merge!(merged)
474
+ rescue ::TypeError
475
+ h[key] = val
476
+ end
477
+ else
478
+ h[key] = val
479
+ end
480
+ else
481
+ h[key] = val
482
+ end
422
483
  end
423
484
  h
424
485
  end
data/lib/yeptris/psych.rb CHANGED
@@ -91,6 +91,9 @@ module Yeptris
91
91
  # object instance exists.
92
92
  autoload :Encodable, "yeptris/psych/encodable"
93
93
  class Error < StandardError; end
94
+
95
+ # stdlib psych 5: a *foo with no matching &foo anchor
96
+ class AnchorNotDefined < Error; end
94
97
  # Psych's exact interface (issue #32): same constructor arity,
95
98
  # same reader set (file/line/column/offset/problem/context), same
96
99
  # message shape — drop-in consumers' rescues and constructors
@@ -143,6 +146,31 @@ module Yeptris
143
146
  # !ruby/set, encode_with/init_with, alias identity. Plain-data
144
147
  # loads should use load/safe_load (the Materializer fast path).
145
148
  def unsafe_load(yaml, **)
149
+ # #178: the marshal fast path. Nodes::Builder's tree is per-node
150
+ # FFI (kind/tag/anchor/value + a walk per container) — the whole
151
+ # 11 MB relaton index paid ~9.6 s there. When the document is
152
+ # plain data (no timestamps/merge keys/tagged revivals), ONE C
153
+ # call materializes the tree as Marshal bytes and Marshal.load
154
+ # builds the objects in C (~1 s end to end). Construct-heavy
155
+ # documents keep the full revival walk below, byte for byte.
156
+ if Yeptris::FFI::MARSHAL && ::Yeptris::Psych.domain_types.empty?
157
+ begin
158
+ doc = ::Yeptris::Document.parse(yaml, schema: :compat_11)
159
+ return nil if doc.nil? || doc.document_count.zero?
160
+
161
+ root = doc.root(0)
162
+ fast = root&.marshal_fast
163
+ unless fast.nil?
164
+ doc.free
165
+ return fast
166
+ end
167
+ tree = Nodes::Builder.document(doc) # ownership: the tree frees
168
+ return Visitors::ToRuby.visit(tree.children.first)
169
+ rescue ::Yeptris::ParseError => e
170
+ doc&.free unless doc&.freed?
171
+ translate_parse_error(e)
172
+ end
173
+ end
146
174
  tree = parse(yaml)
147
175
  return nil if tree.nil?
148
176
 
@@ -150,11 +178,16 @@ module Yeptris
150
178
  end
151
179
 
152
180
  def safe_load(yaml, permitted_classes: [::Date, ::Time], aliases: false, **)
153
- doc = Yeptris::Document.parse(yaml, schema: :compat_11)
154
181
  begin
182
+ doc = Yeptris::Document.parse(yaml, schema: :compat_11)
183
+ return nil if doc.nil? # the legal empty stream
184
+
155
185
  force_utf8_scalars(walk_safe(doc.root(0), permitted_classes, aliases))
186
+ rescue ::Yeptris::ParseError => e
187
+ doc&.free unless doc&.freed?
188
+ translate_parse_error(e)
156
189
  ensure
157
- doc.free
190
+ doc&.free
158
191
  end
159
192
  end
160
193
 
@@ -230,6 +263,8 @@ module Yeptris
230
263
  # children share one C document, so their handles would all
231
264
  # resolve to the first document's tree
232
265
  doc = Yeptris::Document.parse(yaml, schema: :compat_11)
266
+ return nil if doc.nil? # the legal empty stream
267
+
233
268
  begin
234
269
  (0...doc.document_count).map { |i| doc.root(i).to_ruby }
235
270
  ensure
@@ -238,9 +273,18 @@ module Yeptris
238
273
  end
239
274
 
240
275
  # The first document's node tree (no Ruby materialization).
276
+ def translate_parse_error(e)
277
+ # yeptris rejects *foo without &foo at PARSE time (stdlib
278
+ # raises at visit) — surface it as stdlib's class so
279
+ # consumers' rescues keep working
280
+ raise AnchorNotDefined, e.message if e.message.include?("undefined anchor")
281
+
282
+ raise SyntaxError.from_parse_error(e)
283
+ end
284
+
241
285
  def parse(yaml)
242
286
  doc = Yeptris::Document.parse(yaml, schema: :compat_11)
243
- return nil if doc.document_count.zero?
287
+ return nil if doc.nil? || doc.document_count.zero?
244
288
 
245
289
  Nodes::Builder.document(doc)
246
290
  rescue Yeptris::ParseError => e
@@ -249,16 +293,17 @@ module Yeptris
249
293
 
250
294
  def parse_stream(yaml)
251
295
  doc = Yeptris::Document.parse(yaml, schema: :compat_11)
252
- return nil if doc.document_count.zero?
296
+ return nil if doc.nil? || doc.document_count.zero?
253
297
 
254
298
  stream = Nodes::Stream.new
255
299
  (0...doc.document_count).each do |i|
256
300
  stream.children << Nodes::Builder.document_stream_child(doc, i)
257
301
  end
302
+ # ownership: the DOCUMENT wrapper is the sole owner — its own
303
+ # finalizer (pointer-only closure) frees the C memory; the
304
+ # stream merely references it. A tree-side finalizer closing
305
+ # over the wrapper raced the wrapper's sweep (#182's abort).
258
306
  stream.owner = doc
259
- ObjectSpace.define_finalizer(
260
- stream, proc { doc.free unless doc.freed? }
261
- )
262
307
  stream
263
308
  rescue Yeptris::ParseError => e
264
309
  raise SyntaxError.from_parse_error(e)
@@ -283,6 +328,11 @@ module Yeptris
283
328
  # data anyway
284
329
  out =
285
330
  case obj
331
+ # DateTime < Date in ruby's hierarchy — the fast scalar
332
+ # path would emit it tag-less iso8601 (re-loading as Time);
333
+ # the visitor carries stdlib's !ruby/object:DateTime tag
334
+ when ::DateTime
335
+ Visitors::YAMLTree.new.push(obj).finish
286
336
  when nil, true, false, ::String, ::Integer, ::Float, ::Symbol, ::Date, ::Time
287
337
  Yeptris::YAML.dump(obj, header: true)
288
338
  else
@@ -398,12 +448,13 @@ module Yeptris
398
448
  @tags = tags
399
449
  end
400
450
 
401
- # @api private — transfer of C ownership to this tree
451
+ # @api private — attach the document: the tree REFERENCES it
452
+ # (keeping the C memory alive while the tree is reachable);
453
+ # the wrapper's own finalizer is the sole freer (#182: a
454
+ # tree-side finalizer closing over the wrapper raced the
455
+ # wrapper's sweep — SIGABRT under document churn)
402
456
  def own(yeptris_doc)
403
457
  @owner = yeptris_doc
404
- ObjectSpace.define_finalizer(
405
- self, proc { yeptris_doc.free unless yeptris_doc.freed? }
406
- )
407
458
  self
408
459
  end
409
460
 
@@ -33,6 +33,8 @@ class Yeptris::YAML::Descriptor < ::Yeptris::JSON::Descriptor
33
33
  def walk(yaml)
34
34
  src = ::Yeptris.read_input(yaml).to_s
35
35
  doc = ::Yeptris::Document.parse(src, schema: @schema)
36
+ return [] if doc.nil? # the legal empty stream
37
+
36
38
  begin
37
39
  st = ::FFI::MemoryPointer.new(:int)
38
40
  raw = ::Yeptris::FFI.yeptris_document_plan_walk(doc.c_ptr, @handle, st)
data/lib/yeptris/yaml.rb CHANGED
@@ -73,7 +73,7 @@ module Yeptris
73
73
  # Parses without materializing: the first document's root Node.
74
74
  def parse(yaml, schema: :core_12)
75
75
  doc = Document.parse(yaml, schema: schema)
76
- return nil if doc.document_count.zero?
76
+ return nil if doc.nil? || doc.document_count.zero?
77
77
 
78
78
  doc.root(0)
79
79
  end
@@ -177,6 +177,14 @@ module Yeptris
177
177
  off[0] += 1
178
178
  elsif k.is_a?(Symbol)
179
179
  scalar(":#{k}", STYLE_PLAIN, emit, blob, off)
180
+ elsif k.is_a?(String) && k == "<<"
181
+ # stdlib yaml_tree: a literal chevron key carries the
182
+ # explicit !!str tag (single-quoted) so it does not
183
+ # re-load as a merge
184
+ scalar("<<", STYLE_SQ, emit, blob, off)
185
+ emit.call(BUILD_TAG, 0, off[0], 5)
186
+ blob << "!!str"
187
+ off[0] += 5
180
188
  elsif k.is_a?(String)
181
189
  place(k, emit, blob, off, seen)
182
190
  else
@@ -343,6 +351,12 @@ module Yeptris
343
351
  key_node = doc.new_scalar("", :single_quoted)
344
352
  key_node.set_tag("!")
345
353
  m.map_add_node(key_node, build(doc, v, seen))
354
+ elsif k.is_a?(::String) && k == "<<"
355
+ # stdlib yaml_tree: a literal chevron key carries the
356
+ # explicit !!str tag so it does not re-load as a merge
357
+ key_node = doc.new_scalar("<<", :single_quoted)
358
+ key_node.set_tag("!!str")
359
+ m.map_add_node(key_node, build(doc, v, seen))
346
360
  else
347
361
  m.map_add(key_text(k), build(doc, v, seen))
348
362
  end
data/lib/yeptris.rb CHANGED
@@ -3,7 +3,7 @@
3
3
  module Yeptris
4
4
  # The gem's version lives in the parent namespace's file — the last
5
5
  # internal require (yeptris/version) retired with it.
6
- VERSION = "0.6.14.1".freeze
6
+ VERSION = "0.6.15.2".freeze
7
7
  # The error hierarchy lives in THIS file (the parent namespace's
8
8
  # own file): nested constants do not trigger a parent-constant
9
9
  # autoload, and the law forbids internal requires — defining the
@@ -3,7 +3,7 @@
3
3
  cmake_minimum_required(VERSION 3.20)
4
4
 
5
5
  project(yeptris
6
- VERSION 0.6.14
6
+ VERSION 0.6.15
7
7
  DESCRIPTION "Ultra-fast YAML 1.2 parser, emitter and streamer in C"
8
8
  LANGUAGES C CXX
9
9
  )
@@ -241,6 +241,9 @@ void dom_link(yep_dom* d, uint32_t parent, uint32_t child) {
241
241
  }
242
242
  p->last_child = child;
243
243
  p->count++;
244
+ /* every link changes SOME container's child list (#377's cache) */
245
+ d->child_cache_id = UINT32_MAX;
246
+ d->child_cache_len = 0;
244
247
  }
245
248
 
246
249
  /* ---- mutation side tables (64-2a) ---- */
@@ -485,6 +488,9 @@ yep_dom* yep_dom_create(const yep_allocator* sys) {
485
488
  * document that decode-only consumers (CBOR load, serialize, free)
486
489
  * never touch; yep_dom_handles() materializes it on first use */
487
490
  d->handles = NULL;
491
+ d->child_cache = NULL;
492
+ d->child_cache_id = UINT32_MAX;
493
+ d->child_cache_len = 0;
488
494
  if (yep_mutex_init(&d->midx.mu) != 0) {
489
495
  yep_pool_destroy(pool);
490
496
  yep_free(sys, d);
@@ -513,6 +519,69 @@ struct yep_hpool* yep_dom_handles(yep_dom* d) {
513
519
  return h;
514
520
  }
515
521
 
522
+ /* #377 (ruby #168): seq_at/map_at were O(i) sibling walks — the
523
+ * bindings' per-element loops made an 80k-row sequence quadratic
524
+ * (the relaton index: 238-328s where stdlib takes ~3s). The bulk
525
+ * drain (yeptris_node_children) fixed the bindings; THIS fixes the
526
+ * accessor: the last indexed container's child ids cache under the
527
+ * lazy-init mutex, O(1) after the first call. Invalidated by every
528
+ * child-list mutation (dom_invalidate_child_cache). */
529
+ void dom_invalidate_child_cache(yep_dom* d) {
530
+ if (d == NULL) {
531
+ return;
532
+ }
533
+ d->child_cache_id = UINT32_MAX;
534
+ d->child_cache_len = 0;
535
+ }
536
+
537
+ /* Returns child id at index (UINT32_MAX when out of range); the
538
+ * container's child count rides *count_out. */
539
+ uint32_t dom_indexed_child(yep_dom* d, uint32_t cid, size_t index, uint32_t* count_out) {
540
+ const yep_dnode* n = yep_dom_node(d, cid);
541
+ if (n == NULL) {
542
+ *count_out = 0;
543
+ return UINT32_MAX;
544
+ }
545
+ *count_out = n->count;
546
+ if (d->child_cache_id == cid) {
547
+ if (index < d->child_cache_len) {
548
+ return d->child_cache[index];
549
+ }
550
+ return UINT32_MAX;
551
+ }
552
+ /* miss: build once under the lazy-init mutex (Threads.
553
+ * ReadOnlySharing — concurrent first calls race here exactly like
554
+ * the handle pool's) */
555
+ yep_mutex_lock(&d->midx.mu);
556
+ if (d->child_cache_id != cid) {
557
+ yep_free(d->sys, d->child_cache);
558
+ d->child_cache = NULL;
559
+ d->child_cache_len = 0;
560
+ if (n->count > 0) {
561
+ uint32_t* arr = yep_alloc(d->sys, n->count * sizeof(uint32_t));
562
+ if (arr != NULL) {
563
+ uint32_t k = 0;
564
+ for (uint32_t id = n->first_child; id != UINT32_MAX && k < n->count;) {
565
+ const yep_dnode* cn = yep_dom_node(d, id);
566
+ if (cn == NULL) {
567
+ break;
568
+ }
569
+ arr[k++] = id;
570
+ id = cn->next_sibling;
571
+ }
572
+ d->child_cache = arr;
573
+ d->child_cache_len = k;
574
+ }
575
+ }
576
+ d->child_cache_id = cid;
577
+ }
578
+ yep_mutex_unlock(&d->midx.mu);
579
+ if (index < d->child_cache_len) {
580
+ return d->child_cache[index];
581
+ }
582
+ return UINT32_MAX;
583
+ }
584
+
516
585
  void yep_dom_destroy(yep_dom* d) {
517
586
  if (d == NULL) {
518
587
  return;
@@ -523,6 +592,7 @@ void yep_dom_destroy(yep_dom* d) {
523
592
  yep_free(d->sys, d->mut_att);
524
593
  yep_free(d->sys, d->mut_depth);
525
594
  yep_hpool_destroy(d->handles);
595
+ yep_free(d->sys, d->child_cache);
526
596
  yep_pool_destroy(d->pool);
527
597
  yep_free(d->sys, d);
528
598
  }
@@ -114,6 +114,13 @@ typedef struct yep_dom {
114
114
  * YeptrisNode wrappers here, so read-only document sharing across
115
115
  * threads is safe; parse-path pools stay single-threaded */
116
116
  struct yep_hpool* handles;
117
+ /* #377: the lazy indexed-children cache — child ids of the LAST
118
+ * container indexed via seq_at/map_at. Built once per container
119
+ * under the lazy-init mutex (read-only sharing), O(1) after;
120
+ * invalidated by every child-list mutation. NULL when idle. */
121
+ uint32_t* child_cache;
122
+ uint32_t child_cache_id; /* UINT32_MAX = empty */
123
+ uint32_t child_cache_len;
117
124
  yep_dnode* nodes;
118
125
  uint32_t ncount, ncap;
119
126
  uint32_t* docs; /* document root node ids */
@@ -200,6 +207,9 @@ void dom_mut_set_depth(yep_dom* d, uint32_t id, uint16_t depth);
200
207
  struct yep_hpool* yep_hpool_create(const yep_allocator* sys);
201
208
  /* Lazy handle-pool acquisition (#157): NULL dom or OOM stays NULL. */
202
209
  struct yep_hpool* yep_dom_handles(yep_dom* d);
210
+ /* #377: the lazy indexed-children cache (seq_at/map_at's O(1) leg) */
211
+ void dom_invalidate_child_cache(yep_dom* d);
212
+ uint32_t dom_indexed_child(yep_dom* d, uint32_t cid, size_t index, uint32_t* count_out);
203
213
  void yep_hpool_destroy(struct yep_hpool* p);
204
214
  void* yep_hpool_alloc(struct yep_hpool* p, size_t size, size_t align);
205
215
 
@@ -268,6 +268,8 @@ static int unlink_child(yep_dom* d, uint32_t parent, uint32_t child) {
268
268
  p->last_child = c;
269
269
  }
270
270
  }
271
+ d->child_cache_id = UINT32_MAX; /* #377: the list changed */
272
+ d->child_cache_len = 0;
271
273
  d->nodes[child].next_sibling = UINT32_MAX;
272
274
  dom_mut_set_att(d, child, 0);
273
275
  p->count--;
@@ -721,6 +721,34 @@ YEPTRIS_API YeptrisStatus yeptris_marshal(const char* data, size_t len, YeptrisS
721
721
  return st;
722
722
  }
723
723
 
724
+ /* #178 (ruby #168's fast path): the value records carry the resolver's
725
+ * verdict, NOT the source's explicit tag — a `!ruby/object` mapping
726
+ * would marshal as a plain hash, silently dropping the tag (psych's
727
+ * visitor revives it). One cheap pre-walk: any explicitly tagged node
728
+ * in the subtree → UNSUPPORTED, the host walks. Explicit CORE tags
729
+ * (!!str and friends) ride the same bail — correct, just conservative;
730
+ * bulk data is untagged. */
731
+ static int dom_subtree_tagged(const yep_dom* d, uint32_t id) {
732
+ const yep_dnode* n = yep_dom_node(d, id);
733
+ if (n == NULL) {
734
+ return 0;
735
+ }
736
+ if (n->tag.len != 0) {
737
+ return 1;
738
+ }
739
+ for (uint32_t c = n->first_child; c != UINT32_MAX;) {
740
+ const yep_dnode* cn = yep_dom_node(d, c);
741
+ if (cn == NULL) {
742
+ break;
743
+ }
744
+ if (dom_subtree_tagged(d, c) != 0) {
745
+ return 1;
746
+ }
747
+ c = cn->next_sibling;
748
+ }
749
+ return 0;
750
+ }
751
+
724
752
  YEPTRIS_API YeptrisStatus yeptris_marshal_node(YeptrisNode node, char** out, size_t* out_len) {
725
753
  if (node == NULL || out == NULL || out_len == NULL) {
726
754
  return YEPTRIS_ERROR_ARG;
@@ -728,6 +756,12 @@ YEPTRIS_API YeptrisStatus yeptris_marshal_node(YeptrisNode node, char** out, siz
728
756
  *out = NULL;
729
757
  *out_len = 0;
730
758
  yeptris_node* h = (yeptris_node*)node;
759
+ if (dom_subtree_tagged(h->doc->dom, h->id) != 0) {
760
+ yep_error_set(yep_error_tls(), YEP_ERR_UNEXPECTED, 0, 0, 0,
761
+ "marshal: explicitly tagged node not expressible; "
762
+ "fall back to the value walk");
763
+ return YEPTRIS_ERROR_UNSUPPORTED;
764
+ }
731
765
  yep_value_ctx* c = NULL;
732
766
  if (yep_values_from_dom(h->doc->dom, h->id, 0, &c) != 0) {
733
767
  return YEPTRIS_ERROR_MEMORY;
@@ -646,11 +646,12 @@ YEPTRIS_API YeptrisNode yeptris_node_seq_at(YeptrisNode handle, size_t index) {
646
646
  if (n == NULL || n->kind != YEP_DOM_SEQUENCE || index >= n->count) {
647
647
  return NULL;
648
648
  }
649
- const yep_dom* dom = ((yeptris_node*)handle)->doc->dom;
650
- uint32_t id = n->first_child;
651
- for (size_t i = 0; i < index && id != UINT32_MAX; i++) {
652
- const yep_dnode* cur = yep_dom_node(dom, id);
653
- id = cur ? cur->next_sibling : UINT32_MAX;
649
+ yep_dom* dom = ((yeptris_node*)handle)->doc->dom;
650
+ uint32_t count = 0;
651
+ /* the node id IS its dnode index */
652
+ uint32_t id = dom_indexed_child(dom, ((yeptris_node*)handle)->id, index, &count); /* #377 */
653
+ if (id == UINT32_MAX) {
654
+ return NULL;
654
655
  }
655
656
  return wrap((yeptris_node*)handle, id);
656
657
  }
@@ -685,10 +686,11 @@ YEPTRIS_API int yeptris_node_map_at(YeptrisNode handle, size_t index, YeptrisNod
685
686
  return -1;
686
687
  }
687
688
  yeptris_node* h = (yeptris_node*)handle;
688
- const yep_dom* d = h->doc->dom;
689
- uint32_t child = n->first_child;
690
- for (size_t i = 0; i < index * 2; i++) {
691
- child = d->nodes[child].next_sibling; /* pairs are key,value,… */
689
+ yep_dom* d = h->doc->dom;
690
+ uint32_t count = 0;
691
+ uint32_t child = dom_indexed_child(d, h->id, (size_t)index * 2, &count); /* #377 */
692
+ if (child == UINT32_MAX) {
693
+ return -1;
692
694
  }
693
695
  if (key != NULL) {
694
696
  *key = wrap(h, child);
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: yeptris
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.14.1
4
+ version: 0.6.15.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.