yeptris 0.6.50.0 → 0.6.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9dd7fea5eee3b55407e185d6ee5ae07d92227ec414b1c65f03105ea07ae9a57b
4
- data.tar.gz: 2f53a679f84c2994e4c475fe6918b9e2b9d73d6ce07ba9dc07fb38d23df8caa8
3
+ metadata.gz: 39841fd37a06d428edeecabd2ec9b89ebbf3b0576e3a113a43b36eca0a0c67d0
4
+ data.tar.gz: 3fae8c03db5dbb3ddc31f0711a36cf6851b3abb7883e334dc161105070c6f6df
5
5
  SHA512:
6
- metadata.gz: d16ae490733628f1447a87dfa949becf1e0b6f31335ca6102f85caaf5409b6d045e3f8e7714202c846c53f5becd607b3be150d7332ddec9ac92ba824448031f0
7
- data.tar.gz: b425e3591dac1c44a23ed296c4f0e14d1b8e49d414c2fdcf935dfc7888ad144d91a7402a5d7c0575fd44f4cd6cd8827e8f0e9222cc58f17c309e0888b2ded15a
6
+ metadata.gz: 288cf0885ea87923b8974793617a8420ac6115df05b03615170b6d4abb7d779227412904d324951dcd81009e99a3240b9cc0bb77be2087fd6d4b03d43f0c572c
7
+ data.tar.gz: 1556db96baf38b002a65a0b79e0dc112de10a5bddd808e66600ada366c33c6fd408590da118f7d2d47d2f605c427bd2ae886b0d0246b2785bea7eaab2882e836
@@ -483,7 +483,17 @@ RUBY_FUNC_EXPORTED void Init_native(void) {
483
483
  rb_define_singleton_method(mNat, "shape_mode", native_shape_mode, 0);
484
484
  rb_define_singleton_method(mNat, "shape_mode=", native_shape_mode_set, 1);
485
485
  rb_define_singleton_method(mNat, "scan_time", native_scan_time, 2);
486
- rb_define_const(mNat, "AVAILABLE", Qtrue);
486
+ /* the leptris bridge vendors this same ext (leptris's
487
+ * lib/leptris/xml/native_3_3.so), so both gems can co-initialize
488
+ * in one process: the second init re-ran rb_define_const and
489
+ * warned "already initialized constant Yeptris::Native::AVAILABLE"
490
+ * — which moxml treated as an adapter load failure and fell back
491
+ * to nokogiri for the rest of the process (yeptris-ruby#336).
492
+ * Re-registration is idempotent: skip the const when the first
493
+ * init (either gem's copy) already defined it. */
494
+ if (!rb_const_defined_at(mNat, rb_intern("AVAILABLE"))) {
495
+ rb_define_const(mNat, "AVAILABLE", Qtrue);
496
+ }
487
497
  const char* env = getenv("YEPTRIS_NATIVE_GC");
488
498
  if (env != NULL && strcmp(env, "none") == 0) yep_rb_set_gc_mode(1);
489
499
  else if (env != NULL && strcmp(env, "start") == 0) yep_rb_set_gc_mode(2);
@@ -16,7 +16,7 @@ class Yeptris::Document
16
16
  # The schema this document was parsed with (a parse property).
17
17
  attr_reader :parse_schema
18
18
 
19
- def initialize(c_ptr = nil, freed = Freed.new(:alive), parse_schema = :core_12)
19
+ def initialize(c_ptr = nil, freed = Freed.new(:alive), parse_schema = :core_12, source = nil)
20
20
  # The pointer is produced by the FFI parse/create calls; a nil (or
21
21
  # non-pointer) argument is an API misuse — Document.parse and
22
22
  # Document.create are the entry points. Fail with that guidance
@@ -30,6 +30,13 @@ class Yeptris::Document
30
30
  @c_ptr = c_ptr
31
31
  @freed = freed
32
32
  @parse_schema = parse_schema
33
+ # the C document BORROWS the input: string values are zero-copy
34
+ # views over the caller's bytes (parse.h/doc.h) — the FFI call only
35
+ # pins the String for the call's duration, so the document must
36
+ # retain it or the next GC frees memory every later node.value/
37
+ # tag read still points into (audit 2026-10-31: parse(io.read) was
38
+ # collectable immediately)
39
+ @source = source
33
40
  @readonly = false
34
41
  # Strong wrapper cache keyed on the C node address: the same C node
35
42
  # always yields the same Ruby object (identity for aliases/eql?),
@@ -61,7 +68,7 @@ class Yeptris::Document
61
68
  raise Yeptris::ParseError,
62
69
  "parse failed: #{Yeptris::FFI.last_error_message}"
63
70
  end
64
- wrap_schema(doc, schema)
71
+ wrap_schema(doc, schema, yaml)
65
72
  end
66
73
 
67
74
  def self.parse_json(json)
@@ -71,7 +78,7 @@ class Yeptris::Document
71
78
  raise Yeptris::ParseError,
72
79
  "json parse failed: #{Yeptris::FFI.last_error_message}" if doc.null?
73
80
 
74
- wrap_schema(doc, :core_12) # strict JSON is core by construction
81
+ wrap_schema(doc, :core_12, json) # strict JSON is core by construction
75
82
  end
76
83
 
77
84
  # An empty document for from-scratch construction (TODO.impl/11 p3).
@@ -99,8 +106,8 @@ class Yeptris::Document
99
106
  # host scalar policies (Psych's dot-required float) are conditioned
100
107
  # on it. The parse constructors set the real one; plain wrap keeps
101
108
  # the core_12 default (yeptris_parse's default).
102
- def self.wrap_schema(c_ptr, schema)
103
- new(c_ptr, Freed.new(:alive), schema)
109
+ def self.wrap_schema(c_ptr, schema, source = nil)
110
+ new(c_ptr, Freed.new(:alive), schema, source)
104
111
  end
105
112
 
106
113
  # #182: the stream-children wrapper owns NOTHING (the parent
data/lib/yeptris/json.rb CHANGED
@@ -34,12 +34,21 @@ module Yeptris
34
34
 
35
35
  module_function
36
36
 
37
- def load(source)
37
+ # allow_duplicate_key: opts out of the resolved json gem's strict
38
+ # duplicate-key contract (json >= 3 rejects by default) — last value
39
+ # wins, exactly the historical JSON.parse contract. System-boundary
40
+ # parsers need it: third-party feeds (Crossref book records keyed by
41
+ # ISBN, NIST's pubs export keyed by "&nbsp", the UN search API's blank
42
+ # keys) genuinely carry duplicates, and rejecting them breaks the fetch
43
+ # rather than flagging the data (relaton/relaton#413, the ask in
44
+ # yeptris-ruby#335). Default stays the json-gem-parity strictness.
45
+ def load(source, allow_duplicate_key: false)
46
+ strict = STRICT_DUPLICATE_KEYS && !allow_duplicate_key
38
47
  source = ::Yeptris.read_input(source)
39
48
  source = source.to_s
40
49
  if defined?(::Yeptris::Native)
41
50
  begin
42
- return ::Yeptris::Native.load_json(source, STRICT_DUPLICATE_KEYS)
51
+ return ::Yeptris::Native.load_json(source, strict)
43
52
  rescue ::Yeptris::ParseError => e
44
53
  raise ParseError, e.message
45
54
  end
@@ -53,9 +62,9 @@ module Yeptris
53
62
  # none) answered last-wins under json >= 3 — found by canon's
54
63
  # dependent CI, whose parity pin now names the lane.
55
64
  return marshal_engine(
56
- source, STRICT_DUPLICATE_KEYS ? ::Yeptris::FFI::MARSHAL_STRICT_DUP_KEYS : 0
65
+ source, strict ? ::Yeptris::FFI::MARSHAL_STRICT_DUP_KEYS : 0
57
66
  ) if ::Yeptris::FFI::MARSHAL_STRICT_DUP
58
- tape_engine(source)
67
+ tape_engine(source, strict)
59
68
  end
60
69
 
61
70
  # The marshal engine (#278): yeptris_parse_json gates the strict
@@ -156,19 +165,19 @@ module Yeptris
156
165
  out.force_encoding(Encoding::UTF_8)
157
166
  end
158
167
 
159
- def tape_engine(source)
168
+ def tape_engine(source, strict_dup = STRICT_DUPLICATE_KEYS)
160
169
  tape = ::Yeptris::FFI::JsonTape.new
161
170
  rc = ::Yeptris::FFI.yeptris_parse_json_tape(source, source.bytesize, tape)
162
171
  raise ParseError, ::Yeptris::FFI.last_error_message if rc != ::Yeptris::FFI::OK
163
172
 
164
173
  begin
165
- walk_tape(source, tape)
174
+ walk_tape(source, tape, strict_dup)
166
175
  ensure
167
176
  ::Yeptris::FFI.yeptris_tape_free(tape)
168
177
  end
169
178
  end
170
179
 
171
- def walk_tape(src, tape, strict_dup = STRICT_DUPLICATE_KEYS)
180
+ def walk_tape(src, tape, strict_dup)
172
181
  n = tape[:count]
173
182
  kinds = tape[:kinds].read_bytes(n).unpack("C*")
174
183
  offs = tape[:offs].read_bytes(n * 4).unpack("V*")
@@ -25,6 +25,10 @@ module Yeptris
25
25
  yaml = yaml.to_s
26
26
  @filename = filename
27
27
  flat, arena = Materializer.drain(yaml, schema: :compat_11)
28
+ # the engine's arena is UTF-8 bytes; without the tag every
29
+ # scalar reached handlers as ASCII-8BIT (stdlib Psych hands
30
+ # UTF-8; TreeBuilder then propagated binary into node values)
31
+ arena.force_encoding(Encoding::UTF_8)
28
32
  dispatch(flat, arena)
29
33
  self
30
34
  rescue ParseError => e
data/lib/yeptris/psych.rb CHANGED
@@ -143,8 +143,21 @@ module Yeptris
143
143
  # Psych 5: load is safe — plain data structures only. Tagged
144
144
  # nodes raise DisallowedClass unless their class is permitted
145
145
  # (Date/Time/Symbol are built in; they are plain data here).
146
- def load(yaml, permitted_classes: [::Date, ::Time], aliases: false, **)
147
- safe_load(yaml, permitted_classes: permitted_classes, aliases: aliases)
146
+ def load(yaml, permitted_classes: [::Date, ::Time], aliases: false,
147
+ symbolize_names: false, fallback: nil, filename: nil, **)
148
+ res = safe_load(yaml, permitted_classes: permitted_classes, aliases: aliases,
149
+ symbolize_names: symbolize_names)
150
+ if res.nil? && !fallback.nil?
151
+ # stdlib's fallback: answers when the stream is empty or has
152
+ # NO documents — a document whose content IS nil still loads
153
+ # as nil. The safe path cannot tell those two apart, so the
154
+ # corner re-checks the count (only when a fallback rides).
155
+ doc = ::Yeptris::Document.parse(yaml, schema: :compat_11)
156
+ empty = doc.nil? || doc.document_count.zero?
157
+ doc&.free
158
+ return fallback if empty
159
+ end
160
+ res
148
161
  end
149
162
 
150
163
  # Full revival over the Nodes tree: !ruby/object, !ruby/struct,
@@ -20,6 +20,25 @@ module Yeptris
20
20
  # int64 the C resolver leaves the scalar a string; Psych
21
21
  # materializes Integer (issue #31) — every walk rebuilds it.
22
22
  PSYCH_INT_SHAPE = /\A[-+]?(?:0|[1-9](?:[0-9]|,[0-9]|_[0-9])*)\z/.freeze
23
+
24
+ # The typed-string conversion law, ONE place: a big-int-shaped
25
+ # string rebuilds to Integer, an implicit-plain ':name' scans to a
26
+ # Symbol (Psych's ScalarScanner; quoted ':x' stays a String).
27
+ # Every str-column conversion rides this — the pair fast path
28
+ # drifted once and handed bignums/symbol-keys through as Strings
29
+ # (audit 2026-10-31).
30
+ def self.str_to_value(text, typed)
31
+ return text unless typed
32
+
33
+ if text.length > 18 && PSYCH_INT_SHAPE.match?(text)
34
+ text.delete(",_").to_i
35
+ elsif text.length > 1 && text.start_with?(":") && !text.start_with?("::")
36
+ text[1..].to_sym
37
+ else
38
+ text
39
+ end
40
+ end
41
+
23
42
  V_TS = 6
24
43
  SEQ_OPEN = 7
25
44
  MAP_OPEN = 8
@@ -155,15 +174,7 @@ module Yeptris
155
174
  text = arena.byteslice(flat[i + OFF], flat[i + LEN])
156
175
  # implicit-plain ':name' scans to a Symbol (Psych's
157
176
  # ScalarScanner); quoted ':x' stays a String
158
- v = if flat[i + B] == 1 && text.length > 18 &&
159
- PSYCH_INT_SHAPE.match?(text)
160
- text.delete(",_").to_i
161
- elsif flat[i + B] == 1 && text.length > 1 &&
162
- text.start_with?(":") && !text.start_with?("::")
163
- text[1..].to_sym
164
- else
165
- text
166
- end
177
+ v = str_to_value(text, flat[i + B] == 1)
167
178
  if pending_anchor
168
179
  anchors[pending_anchor] = v
169
180
  pending_anchor = nil
@@ -284,20 +295,9 @@ module Yeptris
284
295
  pending_anchor.nil? && !stack.empty? && stack.last.is_a?(Hash)
285
296
  kt = arena.byteslice(offs[i], lens[i])
286
297
  if kt != "<<"
287
- key = if bools[i] == 1 && kt.length > 1 && kt.start_with?(":") &&
288
- !kt.start_with?("::")
289
- kt[1..].to_sym
290
- else
291
- kt
292
- end
298
+ key = str_to_value(kt, bools[i] == 1)
293
299
  vt = arena.byteslice(offs[i + 1], lens[i + 1])
294
- stack.last[key] =
295
- if bools[i + 1] == 1 && vt.length > 1 && vt.start_with?(":") &&
296
- !vt.start_with?("::")
297
- vt[1..].to_sym
298
- else
299
- vt
300
- end
300
+ stack.last[key] = str_to_value(vt, bools[i + 1] == 1)
301
301
  i += 2
302
302
  next
303
303
  end
@@ -305,15 +305,7 @@ module Yeptris
305
305
  case kinds[i]
306
306
  when V_STR
307
307
  text = arena.byteslice(offs[i], lens[i])
308
- v = if bools[i] == 1 && text.length > 18 &&
309
- PSYCH_INT_SHAPE.match?(text)
310
- text.delete(",_").to_i
311
- elsif bools[i] == 1 && text.length > 1 &&
312
- text.start_with?(":") && !text.start_with?("::")
313
- text[1..].to_sym
314
- else
315
- text
316
- end
308
+ v = str_to_value(text, bools[i] == 1)
317
309
  if pending_anchor
318
310
  anchors[pending_anchor] = v
319
311
  pending_anchor = nil
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.50.0".freeze
6
+ VERSION = "0.6.52.0".freeze
7
7
 
8
8
  # Informational notices are OPT-IN (yeptris-ruby#217): consumers
9
9
  # embed yeptris inside CLIs that assert clean stderr, and a
@@ -3,7 +3,7 @@
3
3
  cmake_minimum_required(VERSION 3.20)
4
4
 
5
5
  project(yeptris
6
- VERSION 0.6.50
6
+ VERSION 0.6.51
7
7
  DESCRIPTION "Ultra-fast YAML 1.2 parser, emitter and streamer in C"
8
8
  LANGUAGES C CXX
9
9
  )
@@ -53,6 +53,55 @@ extern "C" {
53
53
  YEPTRIS_API YeptrisDocument yeptris_cbor_decode(const void* buf, size_t len, uint32_t opts,
54
54
  YeptrisStatus* status);
55
55
 
56
+ /* The SAX decode face (#742): yeptris_cbor_decode's grammar driving
57
+ * consumer callbacks — items land as they parse, no document, no
58
+ * nodes. Measured (perf ledger 2026-10-29): the scan-only route reads
59
+ * ~2.1x the DOM entry on the same corpus (DOM construction is over
60
+ * half the decode lane), so validation, counting, and streaming
61
+ * transforms belong here. Items arrive in encoding order; map items
62
+ * set is_key on the key half. Strings with borrowed=1 point INTO buf —
63
+ * keep it valid while you hold them; borrowed=0 is decoder scratch.
64
+ * tag ("N N N", outermost first; NULL when none) and tag_len are
65
+ * callback-call-local. open's cap is the expected item count (maps
66
+ * count pairs*2; UINT64_MAX = indefinite); close fires for both the
67
+ * definite-complete and the break path, after the last child. The
68
+ * rendered text forms match the DOM ledger exactly (numbers-as-text,
69
+ * false/true/null/undefined, simple(N)).
70
+ *
71
+ * Every callback returns 0 on sink failure — the decode then reports
72
+ * YEPTRIS_ERROR_MEMORY — and may raise through host languages; the
73
+ * decoder owns no host state across them except the indefinite buffer,
74
+ * which its own exit paths free. All seven callbacks and sax itself
75
+ * must be non-NULL (YEPTRIS_ERROR_ARG otherwise): the loop dispatches
76
+ * unconditionally.
77
+ *
78
+ * consumed == NULL: trailing bytes after the one data item fail
79
+ * YEPTRIS_ERROR_PARSE. consumed != NULL: on success it receives the
80
+ * byte count of the items decoded — walk a CBOR Sequence (RFC 8742)
81
+ * by advancing buf by it. On failure *status (may be NULL) carries the
82
+ * code and yeptris_last_error() the message with the byte offset.
83
+ *
84
+ * Memory: none — the decoder allocates only its indefinite-chunk
85
+ * scratch and frees it on every exit. */
86
+ typedef struct yeptris_cbor_sax {
87
+ void* ctx;
88
+ int (*text)(void* ctx, const char* s, uint32_t n, uint8_t tag_id, int is_key, const char* tag,
89
+ uint32_t tag_len);
90
+ int (*int_val)(void* ctx, int negative, uint64_t mag, int is_key, const char* tag,
91
+ uint32_t tag_len);
92
+ int (*float_val)(void* ctx, double dv, int is_key, const char* tag, uint32_t tag_len);
93
+ int (*str_val)(void* ctx, const unsigned char* p, uint32_t n, int borrowed, int is_key,
94
+ const char* tag, uint32_t tag_len);
95
+ int (*bytes_val)(void* ctx, const unsigned char* p, uint32_t n, int borrowed, int is_key,
96
+ const char* tag, uint32_t tag_len);
97
+ int (*open)(void* ctx, int is_map, uint64_t cap, const char* tag, uint32_t tag_len);
98
+ int (*close)(void* ctx, int is_map);
99
+ } yeptris_cbor_sax;
100
+
101
+ YEPTRIS_API YeptrisStatus yeptris_cbor_decode_sax(const void* buf, size_t len, uint32_t opts,
102
+ const yeptris_cbor_sax* sax, size_t* consumed,
103
+ YeptrisStatus* status);
104
+
56
105
  /* CBOR Sequences (RFC 8742): top-level data items, concatenated —
57
106
  * no framing bytes. Iterates the buffer, one document per item; the
58
107
  * callback OWNS each item (free it with yeptris_document_free) and
@@ -1032,3 +1032,25 @@ YEPTRIS_API YeptrisDocument yeptris_cbor_decode(const void* buf, size_t len, uin
1032
1032
  }
1033
1033
  return (YeptrisDocument)doc;
1034
1034
  }
1035
+
1036
+ YEPTRIS_API YeptrisStatus yeptris_cbor_decode_sax(const void* buf, size_t len, uint32_t opts,
1037
+ const yeptris_cbor_sax* sax, size_t* consumed,
1038
+ YeptrisStatus* status) {
1039
+ if ((buf == NULL && len != 0) || sax == NULL || (opts & ~YEPTRIS_CBOR_STRICT) != 0 ||
1040
+ sax->text == NULL || sax->int_val == NULL || sax->float_val == NULL ||
1041
+ sax->str_val == NULL || sax->bytes_val == NULL || sax->open == NULL || sax->close == NULL) {
1042
+ if (status != NULL) {
1043
+ *status = YEPTRIS_ERROR_ARG;
1044
+ }
1045
+ return YEPTRIS_ERROR_ARG;
1046
+ }
1047
+ /* yeptris_cbor_sax IS yep_cbor_sink (sink.h aliases the public
1048
+ * face): the decoder drives the caller's vtable directly. */
1049
+ YeptrisStatus st = (YeptrisStatus)yep_cbor_decode_gen((const unsigned char*)buf, len,
1050
+ (opts & YEPTRIS_CBOR_STRICT) != 0,
1051
+ consumed, (const yep_cbor_sink*)sax);
1052
+ if (status != NULL) {
1053
+ *status = st;
1054
+ }
1055
+ return st;
1056
+ }
@@ -4,7 +4,11 @@
4
4
  * source of CBOR semantics; a sink implements the item constructors
5
5
  * on top of it. Two sinks exist: the DOM builder (the default, the
6
6
  * yeptris_cbor_decode public path) and the native ext's VALUE builder
7
- * (one pass, no intermediate). Sinks receive the resolver's tag_id
7
+ * (one pass, no intermediate). The sink vtable IS the public SAX face
8
+ * (yeptris_cbor_sax, <yeptris/cbor.h> — #742): the alias keeps the
9
+ * decoder driving the same layout the public entry hands over, so
10
+ * yeptris_cbor_decode_sax is a straight passthrough. Callback/field
11
+ * contracts are documented there; sinks receive the resolver's tag_id
8
12
  * and the pending semantic-tag chain in its text form ("N N N",
9
13
  * outermost first — NULL when the item carries no chain).
10
14
  */
@@ -15,35 +19,9 @@
15
19
  #include <stdint.h>
16
20
 
17
21
  #include <yeptris/api.h> /* YEPTRIS_API (the ext links these) */
22
+ #include <yeptris/cbor.h>
18
23
 
19
- typedef struct yep_cbor_sink {
20
- void* ctx;
21
- /* All callbacks return 0 on sink failure (the decoder reports
22
- * MEMORY). They may raise through host languages (longjmp): the
23
- * decoder owns no host-managed state across them except the
24
- * indefinite buffer, which its own exit paths free. */
25
- /* is_key: 1 = the item lands in the enclosing map's key slot
26
- * (sinks key-intern strings; the DOM ignores it). Rendered text:
27
- * numbers-as-text, false/true/null/undefined, simple(N) — the
28
- * exact bytes the DOM path stores. */
29
- int (*text)(void* ctx, const char* s, uint32_t n, uint8_t tag_id, int is_key, const char* tag,
30
- uint32_t tag_len);
31
- /* Known integers: value = negative ? -(mag) : mag, mag <= 2^63. */
32
- int (*int_val)(void* ctx, int negative, uint64_t mag, int is_key, const char* tag,
33
- uint32_t tag_len);
34
- int (*float_val)(void* ctx, double dv, int is_key, const char* tag, uint32_t tag_len);
35
- /* Strings. borrowed: 1 = p is the input (a sink may record the
36
- * offset), 0 = the decoder's scratch buffer (copy or take it). */
37
- int (*str_val)(void* ctx, const unsigned char* p, uint32_t n, int borrowed, int is_key,
38
- const char* tag, uint32_t tag_len);
39
- int (*bytes_val)(void* ctx, const unsigned char* p, uint32_t n, int borrowed, int is_key,
40
- const char* tag, uint32_t tag_len);
41
- /* Containers. cap: expected item count (maps: pairs*2), UINT64_MAX
42
- * for indefinite. close fires for BOTH the definite-complete and
43
- * the break paths, after the last child. */
44
- int (*open)(void* ctx, int is_map, uint64_t cap, const char* tag, uint32_t tag_len);
45
- int (*close)(void* ctx, int is_map);
46
- } yep_cbor_sink;
24
+ typedef yeptris_cbor_sax yep_cbor_sink;
47
25
 
48
26
  /* The generic engine: the grammar, driving `sink`. Same reject/
49
27
  * encoding/depth semantics as the DOM entry. */
@@ -72,6 +72,16 @@ void yep_val_put(yep_value_ctx* c, const YeptrisValue* v) {
72
72
  void yep_val_slot(yep_value_ctx* c, YeptrisValue* v) {
73
73
  if (c->depth > 0 && c->depth <= YEP_V_MAX_DEPTH) {
74
74
  int di = c->depth - 1;
75
+ if (c->is_seq[di]) {
76
+ /* a sequence entry never takes a key slot — the toggle
77
+ * flagged every ODD-position entry is_key=1, and the
78
+ * marshal strict-dup scan then false-flagged arrays with a
79
+ * repeated value at two odd positions ("[\"a\",\"b\",\"a\"]"
80
+ * = "duplicate key in object"; yeptris-ruby#333, the
81
+ * yeptris#680 class resurfacing through the value
82
+ * stream's own parity) */
83
+ return;
84
+ }
75
85
  if (c->key_pend[di] == 0) {
76
86
  v->is_key = 1;
77
87
  c->key_pend[di] = 1;
@@ -169,6 +179,7 @@ static int transform(yep_value_ctx* c, const yep_rec_store* store) {
169
179
  yep_val_put(c, &v);
170
180
  if (c->depth < YEP_V_MAX_DEPTH) {
171
181
  c->key_pend[c->depth] = 0;
182
+ c->is_seq[c->depth] = 1;
172
183
  }
173
184
  c->depth++;
174
185
  continue;
@@ -179,6 +190,7 @@ static int transform(yep_value_ctx* c, const yep_rec_store* store) {
179
190
  yep_val_put(c, &v);
180
191
  if (c->depth < YEP_V_MAX_DEPTH) {
181
192
  c->key_pend[c->depth] = 0;
193
+ c->is_seq[c->depth] = 0;
182
194
  }
183
195
  c->depth++;
184
196
  continue;
@@ -285,6 +297,7 @@ static int lin_node(yep_value_ctx* c, const yep_dom* d, uint32_t id) {
285
297
  yep_val_put(c, &v);
286
298
  if (c->depth < YEP_V_MAX_DEPTH) {
287
299
  c->key_pend[c->depth] = 0;
300
+ c->is_seq[c->depth] = (n->kind == YEP_DOM_SEQUENCE);
288
301
  }
289
302
  c->depth++;
290
303
  if (lin_children(c, d, n) != 0) {
@@ -24,6 +24,8 @@ typedef struct yep_value_ctx {
24
24
  int oom;
25
25
  int unsupported; /* linearize: construct the emitter cannot express */
26
26
  uint8_t key_pend[YEP_V_MAX_DEPTH]; /* per open map: key slot taken */
27
+ uint8_t is_seq[YEP_V_MAX_DEPTH]; /* per open frame: 1 = sequence —
28
+ * entries never take key slots */
27
29
  int depth;
28
30
  } yep_value_ctx;
29
31
 
@@ -379,7 +379,8 @@ static int e_comment_at(const yep_engine* e) {
379
379
  }
380
380
 
381
381
  static int e_at_eol(yep_engine* e) {
382
- return e->pos >= e->len || e->p[e->pos] == '\n' || e->p[e->pos] == '\r' || e_comment_at(e);
382
+ return e->pos >= e->len || e->p[e->pos] == '\n' || e->p[e->pos] == '\r' || e_comment_at(e) ||
383
+ yep_scan_mb_break_len(e->p, e->len, e->pos) > 0;
383
384
  }
384
385
 
385
386
  static int e_skip_to_eol(yep_engine* e) {
@@ -3890,6 +3891,16 @@ static int e_parse_value(yep_engine* e, yep_ctx ctx, uint16_t floor_col) {
3890
3891
  if (e->pos >= e->len) {
3891
3892
  goto empty_value;
3892
3893
  }
3894
+ /* a raw NEL/LS/PS here is this line's break: consume it exactly
3895
+ * as the BLANK branch below consumes an LF-ended empty line —
3896
+ * yep_scan_line's line model does not treat a break byte at the
3897
+ * loop head as a line end, so without this the props-only line
3898
+ * re-entered e_node on the break byte (rows 1-2) and the marker
3899
+ * became the scalar's value (row 5's consume-first form) */
3900
+ if (yep_scan_mb_break_len(e->p, e->len, e->pos) > 0) {
3901
+ e_line_done(e, e->pos);
3902
+ continue;
3903
+ }
3893
3904
  yep_line_info li;
3894
3905
  if (e->pos == e->line_start) {
3895
3906
  /* seed the memo: the fast arms re-read this line's facts —
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: yeptris
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.50.0
4
+ version: 0.6.52.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-29 00:00:00.000000000 Z
11
+ date: 2026-10-31 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ffi