yeptris 0.2.0.1-aarch64-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,440 @@
1
+ /* yeptris_native.c — Ruby C-API materializer over libyeptris visit
2
+ * (TODO.restructure/22). One Ruby→C call builds the whole object
3
+ * graph via rb_hash_new / rb_ary_push / rb_str_new_len — the same
4
+ * shape as JSON.parse, so the binding can beat it on the fused JSON
5
+ * path. The extension is optional: LoadError falls back to the FFI
6
+ * Marshal ladder. */
7
+
8
+ #include <ruby.h>
9
+ #include <ruby/encoding.h>
10
+
11
+ #include <stdlib.h>
12
+ #include <string.h>
13
+
14
+ #include <yeptris/api.h>
15
+ #include <yeptris/error.h>
16
+ #include <yeptris/resolve.h>
17
+ #include <yeptris/visit.h>
18
+
19
+ #define YEP_RB_MAX_DEPTH 1024
20
+
21
+ static rb_encoding* utf8_enc;
22
+
23
+ typedef struct {
24
+ VALUE stack[YEP_RB_MAX_DEPTH];
25
+ VALUE keys[YEP_RB_MAX_DEPTH]; /* pending map key at this depth */
26
+ int is_map[YEP_RB_MAX_DEPTH];
27
+ int sp;
28
+ VALUE root;
29
+ VALUE anchors; /* Hash name=>object for YAML identity */
30
+ VALUE pending_anchor; /* String name awaiting the next value */
31
+ int failed;
32
+ } rb_ctx;
33
+
34
+ static void rb_fail(rb_ctx* c) {
35
+ c->failed = 1;
36
+ }
37
+
38
+ static int rb_push_value(rb_ctx* c, VALUE v) {
39
+ if (c->failed) {
40
+ return -1;
41
+ }
42
+ /* bind pending anchor */
43
+ if (!NIL_P(c->pending_anchor) && !NIL_P(c->anchors)) {
44
+ rb_hash_aset(c->anchors, c->pending_anchor, v);
45
+ c->pending_anchor = Qnil;
46
+ }
47
+ if (c->sp == 0) {
48
+ c->root = v;
49
+ return 0;
50
+ }
51
+ VALUE parent = c->stack[c->sp - 1];
52
+ if (c->is_map[c->sp - 1]) {
53
+ VALUE key = c->keys[c->sp - 1];
54
+ if (NIL_P(key)) {
55
+ rb_fail(c);
56
+ return -1;
57
+ }
58
+ rb_hash_aset(parent, key, v);
59
+ c->keys[c->sp - 1] = Qnil;
60
+ } else {
61
+ rb_ary_push(parent, v);
62
+ }
63
+ return 0;
64
+ }
65
+
66
+ static int on_null(void* ctx) {
67
+ return rb_push_value((rb_ctx*)ctx, Qnil);
68
+ }
69
+
70
+ static int on_bool(void* ctx, int truthy) {
71
+ return rb_push_value((rb_ctx*)ctx, truthy ? Qtrue : Qfalse);
72
+ }
73
+
74
+ static int on_int(void* ctx, int64_t v) {
75
+ return rb_push_value((rb_ctx*)ctx, LL2NUM(v));
76
+ }
77
+
78
+ static int on_float(void* ctx, double v) {
79
+ return rb_push_value((rb_ctx*)ctx, DBL2NUM(v));
80
+ }
81
+
82
+ static rb_encoding* utf8_enc;
83
+
84
+ static VALUE rb_utf8_str(const char* p, size_t len) {
85
+ return rb_enc_str_new(p, (long)len, utf8_enc);
86
+ }
87
+
88
+ /* One-shot interned string (no intermediate alloc) — keys and the
89
+ * short repeated values ("a"/"b"/…) share one VALUE. */
90
+ static VALUE rb_utf8_interned(const char* p, size_t len) {
91
+ return rb_enc_interned_str(p, (long)len, utf8_enc);
92
+ }
93
+
94
+ static int on_string(void* ctx, const char* p, size_t len) {
95
+ rb_ctx* c = (rb_ctx*)ctx;
96
+ /* short strings are almost always repeated tokens in JSON corpora;
97
+ * intern them. Longer payloads stay unique. */
98
+ VALUE s = (len <= 16) ? rb_utf8_interned(p, len) : rb_utf8_str(p, len);
99
+ return rb_push_value(c, s);
100
+ }
101
+
102
+ static int on_key(void* ctx, const char* p, size_t len) {
103
+ rb_ctx* c = (rb_ctx*)ctx;
104
+ if (c->sp == 0 || !c->is_map[c->sp - 1]) {
105
+ rb_fail(c);
106
+ return -1;
107
+ }
108
+ c->keys[c->sp - 1] = rb_utf8_interned(p, len);
109
+ return 0;
110
+ }
111
+
112
+ static int on_seq_start(void* ctx) {
113
+ rb_ctx* c = (rb_ctx*)ctx;
114
+ if (c->failed || c->sp >= YEP_RB_MAX_DEPTH) {
115
+ rb_fail(c);
116
+ return -1;
117
+ }
118
+ VALUE a = rb_ary_new();
119
+ /* bind anchor to the container before placing it */
120
+ if (!NIL_P(c->pending_anchor) && !NIL_P(c->anchors)) {
121
+ rb_hash_aset(c->anchors, c->pending_anchor, a);
122
+ c->pending_anchor = Qnil;
123
+ }
124
+ if (c->sp == 0) {
125
+ c->root = a;
126
+ } else {
127
+ VALUE parent = c->stack[c->sp - 1];
128
+ if (c->is_map[c->sp - 1]) {
129
+ VALUE key = c->keys[c->sp - 1];
130
+ if (NIL_P(key)) {
131
+ rb_fail(c);
132
+ return -1;
133
+ }
134
+ rb_hash_aset(parent, key, a);
135
+ c->keys[c->sp - 1] = Qnil;
136
+ } else {
137
+ rb_ary_push(parent, a);
138
+ }
139
+ }
140
+ c->stack[c->sp] = a;
141
+ c->is_map[c->sp] = 0;
142
+ c->keys[c->sp] = Qnil;
143
+ c->sp++;
144
+ return 0;
145
+ }
146
+
147
+ static int on_seq_end(void* ctx) {
148
+ rb_ctx* c = (rb_ctx*)ctx;
149
+ if (c->sp <= 0) {
150
+ rb_fail(c);
151
+ return -1;
152
+ }
153
+ c->sp--;
154
+ return 0;
155
+ }
156
+
157
+ static int on_map_start(void* ctx) {
158
+ rb_ctx* c = (rb_ctx*)ctx;
159
+ if (c->failed || c->sp >= YEP_RB_MAX_DEPTH) {
160
+ rb_fail(c);
161
+ return -1;
162
+ }
163
+ VALUE h = rb_hash_new();
164
+ if (!NIL_P(c->pending_anchor) && !NIL_P(c->anchors)) {
165
+ rb_hash_aset(c->anchors, c->pending_anchor, h);
166
+ c->pending_anchor = Qnil;
167
+ }
168
+ if (c->sp == 0) {
169
+ c->root = h;
170
+ } else {
171
+ VALUE parent = c->stack[c->sp - 1];
172
+ if (c->is_map[c->sp - 1]) {
173
+ VALUE key = c->keys[c->sp - 1];
174
+ if (NIL_P(key)) {
175
+ rb_fail(c);
176
+ return -1;
177
+ }
178
+ rb_hash_aset(parent, key, h);
179
+ c->keys[c->sp - 1] = Qnil;
180
+ } else {
181
+ rb_ary_push(parent, h);
182
+ }
183
+ }
184
+ c->stack[c->sp] = h;
185
+ c->is_map[c->sp] = 1;
186
+ c->keys[c->sp] = Qnil;
187
+ c->sp++;
188
+ return 0;
189
+ }
190
+
191
+ static int on_map_end(void* ctx) {
192
+ rb_ctx* c = (rb_ctx*)ctx;
193
+ if (c->sp <= 0) {
194
+ rb_fail(c);
195
+ return -1;
196
+ }
197
+ c->sp--;
198
+ return 0;
199
+ }
200
+
201
+ static int on_anchor(void* ctx, const char* name, size_t len) {
202
+ rb_ctx* c = (rb_ctx*)ctx;
203
+ c->pending_anchor = rb_utf8_str(name, len);
204
+ return 0;
205
+ }
206
+
207
+ static int on_alias(void* ctx, const char* name, size_t len) {
208
+ rb_ctx* c = (rb_ctx*)ctx;
209
+ VALUE key = rb_utf8_str(name, len);
210
+ VALUE v = rb_hash_lookup2(c->anchors, key, Qundef);
211
+ if (v == Qundef) {
212
+ v = Qnil;
213
+ }
214
+ return rb_push_value(c, v);
215
+ }
216
+
217
+ static int on_doc(void* ctx) {
218
+ /* multi-doc: for load_all we'd collect; single-load takes first.
219
+ * reset root so subsequent docs replace — load_stream uses a
220
+ * different entry that accumulates. */
221
+ (void)ctx;
222
+ return 0;
223
+ }
224
+
225
+ static const YeptrisVisitVTable k_vt = {
226
+ on_null, on_bool, on_int, on_float, on_string,
227
+ on_seq_start, on_seq_end, on_map_start, on_map_end,
228
+ on_key, on_doc, on_anchor, on_alias,
229
+ };
230
+
231
+ /* fused JSON→Ruby (json_ruby.c) — no vtable, beats JSON.parse */
232
+ VALUE yep_rb_parse_json(const char* p, size_t len, int strict_dup);
233
+
234
+ static VALUE ctx_result(rb_ctx* c, YeptrisStatus st) {
235
+ if (st != YEPTRIS_OK || c->failed) {
236
+ if (st == YEPTRIS_ERROR_PARSE) {
237
+ rb_raise(rb_path2class("Yeptris::ParseError"), "native parse failed");
238
+ }
239
+ if (st == YEPTRIS_ERROR_MEMORY) {
240
+ rb_raise(rb_eNoMemError, "yeptris native");
241
+ }
242
+ rb_raise(rb_path2class("Yeptris::Error"), "native materialize failed (%d)", (int)st);
243
+ }
244
+ return c->root;
245
+ }
246
+
247
+ static VALUE native_load_json(int argc, VALUE* argv, VALUE self) {
248
+ (void)self;
249
+ VALUE input, strict;
250
+ rb_scan_args(argc, argv, "11", &input, &strict);
251
+ StringValue(input);
252
+ return yep_rb_parse_json(RSTRING_PTR(input), (size_t)RSTRING_LEN(input), RTEST(strict));
253
+ }
254
+
255
+ static VALUE native_load(VALUE self, VALUE input, VALUE schema) {
256
+ (void)self;
257
+ StringValue(input);
258
+ int sch = YEPTRIS_SCHEMA_11_COMPAT;
259
+ if (!NIL_P(schema)) {
260
+ Check_Type(schema, T_SYMBOL);
261
+ if (rb_sym2id(schema) == rb_intern("core_12")) {
262
+ sch = YEPTRIS_SCHEMA_12_CORE;
263
+ }
264
+ }
265
+ rb_ctx c;
266
+ memset(&c, 0, sizeof(c));
267
+ c.root = Qnil;
268
+ c.pending_anchor = Qnil;
269
+ c.anchors = rb_hash_new();
270
+ VALUE already = rb_gc_disable();
271
+ YeptrisStatus st = yeptris_visit(RSTRING_PTR(input), (size_t)RSTRING_LEN(input),
272
+ (YeptrisSchema)sch, &k_vt, &c);
273
+ if (already == Qfalse) {
274
+ rb_gc_enable();
275
+ }
276
+ return ctx_result(&c, st);
277
+ }
278
+
279
+ /* load_stream: accumulate documents into an Array. */
280
+ typedef struct {
281
+ rb_ctx inner;
282
+ VALUE docs;
283
+ int in_doc;
284
+ } rb_stream_ctx;
285
+
286
+ static int stream_on_doc(void* ctx) {
287
+ rb_stream_ctx* s = (rb_stream_ctx*)ctx;
288
+ if (s->in_doc && !NIL_P(s->inner.root)) {
289
+ rb_ary_push(s->docs, s->inner.root);
290
+ }
291
+ s->inner.root = Qnil;
292
+ s->inner.sp = 0;
293
+ s->in_doc = 1;
294
+ return 0;
295
+ }
296
+
297
+ static VALUE native_load_stream(VALUE self, VALUE input, VALUE schema) {
298
+ (void)self;
299
+ StringValue(input);
300
+ int sch = YEPTRIS_SCHEMA_11_COMPAT;
301
+ if (!NIL_P(schema) && rb_sym2id(schema) == rb_intern("core_12")) {
302
+ sch = YEPTRIS_SCHEMA_12_CORE;
303
+ }
304
+ rb_stream_ctx s;
305
+ memset(&s, 0, sizeof(s));
306
+ s.inner.root = Qnil;
307
+ s.inner.pending_anchor = Qnil;
308
+ s.inner.anchors = rb_hash_new();
309
+ s.docs = rb_ary_new();
310
+ YeptrisVisitVTable vt = k_vt;
311
+ vt.on_doc = stream_on_doc;
312
+ /* trick: the ctx for scalar callbacks is &s.inner, but on_doc needs
313
+ * &s. Use a unified ctx — rebind all callbacks to take stream ctx
314
+ * by making inner the first field (already is). on_doc uses outer;
315
+ * others use inner via same pointer since inner is first field. */
316
+ YeptrisStatus st = yeptris_visit(RSTRING_PTR(input), (size_t)RSTRING_LEN(input),
317
+ (YeptrisSchema)sch, &vt, &s);
318
+ if (st == YEPTRIS_OK && !s.inner.failed) {
319
+ if (!NIL_P(s.inner.root) || s.in_doc) {
320
+ rb_ary_push(s.docs, s.inner.root);
321
+ }
322
+ return s.docs;
323
+ }
324
+ return ctx_result(&s.inner, st == YEPTRIS_OK ? YEPTRIS_ERROR_INTERNAL : st);
325
+ }
326
+
327
+ /* GC-strategy surface (TODO.restructure/34): ENV at load sets the
328
+ * default; the setter re-picks at runtime so the CI referee can A/B
329
+ * in-process. Symbols: :disable, :none, :start. */
330
+ extern int yep_rb_gc_mode(void);
331
+ extern void yep_rb_set_gc_mode(int mode);
332
+ extern int yep_rb_ins_mode(void);
333
+ extern void yep_rb_set_ins_mode(int mode);
334
+ extern int yep_rb_cache_mode(void);
335
+ extern void yep_rb_set_cache_mode(int mode);
336
+ extern int yep_rb_shape_mode(void);
337
+ extern void yep_rb_set_shape_mode(int mode);
338
+ extern double yep_rb_scan_time(const char* p, size_t len, int n);
339
+
340
+ static VALUE native_gc_mode(VALUE self) {
341
+ (void)self;
342
+ switch (yep_rb_gc_mode()) {
343
+ case 1: return ID2SYM(rb_intern("none"));
344
+ case 2: return ID2SYM(rb_intern("start"));
345
+ default: return ID2SYM(rb_intern("disable"));
346
+ }
347
+ }
348
+
349
+ static VALUE native_ins_mode(VALUE self) {
350
+ (void)self;
351
+ return yep_rb_ins_mode() == 1 ? ID2SYM(rb_intern("aset")) : ID2SYM(rb_intern("bulk"));
352
+ }
353
+
354
+ static VALUE native_ins_mode_set(VALUE self, VALUE mode) {
355
+ (void)self;
356
+ Check_Type(mode, T_SYMBOL);
357
+ ID id = rb_sym2id(mode);
358
+ if (id == rb_intern("bulk")) yep_rb_set_ins_mode(0);
359
+ else if (id == rb_intern("aset")) yep_rb_set_ins_mode(1);
360
+ else rb_raise(rb_eArgError, "ins_mode must be :bulk or :aset");
361
+ return mode;
362
+ }
363
+
364
+ static VALUE native_cache_mode(VALUE self) {
365
+ (void)self;
366
+ return yep_rb_cache_mode() == 1 ? ID2SYM(rb_intern("off")) : ID2SYM(rb_intern("on"));
367
+ }
368
+
369
+ static VALUE native_cache_mode_set(VALUE self, VALUE mode) {
370
+ (void)self;
371
+ Check_Type(mode, T_SYMBOL);
372
+ ID id = rb_sym2id(mode);
373
+ if (id == rb_intern("on")) yep_rb_set_cache_mode(0);
374
+ else if (id == rb_intern("off")) yep_rb_set_cache_mode(1);
375
+ else rb_raise(rb_eArgError, "cache_mode must be :on or :off");
376
+ return mode;
377
+ }
378
+
379
+ static VALUE native_scan_time(VALUE self, VALUE input, VALUE count) {
380
+ (void)self;
381
+ StringValue(input);
382
+ int n = NUM2INT(count);
383
+ double secs = yep_rb_scan_time(RSTRING_PTR(input), (size_t)RSTRING_LEN(input), n);
384
+ return DBL2NUM(secs / (double)n);
385
+ }
386
+
387
+ static VALUE native_shape_mode(VALUE self) {
388
+ (void)self;
389
+ return yep_rb_shape_mode() == 1 ? ID2SYM(rb_intern("natural")) : ID2SYM(rb_intern("pre"));
390
+ }
391
+
392
+ static VALUE native_shape_mode_set(VALUE self, VALUE mode) {
393
+ (void)self;
394
+ Check_Type(mode, T_SYMBOL);
395
+ ID id = rb_sym2id(mode);
396
+ if (id == rb_intern("pre")) yep_rb_set_shape_mode(0);
397
+ else if (id == rb_intern("natural")) yep_rb_set_shape_mode(1);
398
+ else rb_raise(rb_eArgError, "shape_mode must be :pre or :natural");
399
+ return mode;
400
+ }
401
+
402
+ static VALUE native_gc_mode_set(VALUE self, VALUE mode) {
403
+ (void)self;
404
+ Check_Type(mode, T_SYMBOL);
405
+ ID id = rb_sym2id(mode);
406
+ if (id == rb_intern("disable")) yep_rb_set_gc_mode(0);
407
+ else if (id == rb_intern("none")) yep_rb_set_gc_mode(1);
408
+ else if (id == rb_intern("start")) yep_rb_set_gc_mode(2);
409
+ else rb_raise(rb_eArgError, "gc_mode must be :disable, :none, or :start");
410
+ return mode;
411
+ }
412
+
413
+ RUBY_FUNC_EXPORTED void Init_native(void) {
414
+ utf8_enc = rb_utf8_encoding();
415
+ VALUE mYep = rb_define_module("Yeptris");
416
+ VALUE mNat = rb_define_module_under(mYep, "Native");
417
+ rb_define_singleton_method(mNat, "load_json", native_load_json, -1);
418
+ rb_define_singleton_method(mNat, "load", native_load, 2);
419
+ rb_define_singleton_method(mNat, "load_stream", native_load_stream, 2);
420
+ rb_define_singleton_method(mNat, "gc_mode", native_gc_mode, 0);
421
+ rb_define_singleton_method(mNat, "gc_mode=", native_gc_mode_set, 1);
422
+ rb_define_singleton_method(mNat, "ins_mode", native_ins_mode, 0);
423
+ rb_define_singleton_method(mNat, "ins_mode=", native_ins_mode_set, 1);
424
+ rb_define_singleton_method(mNat, "cache_mode", native_cache_mode, 0);
425
+ rb_define_singleton_method(mNat, "cache_mode=", native_cache_mode_set, 1);
426
+ rb_define_singleton_method(mNat, "shape_mode", native_shape_mode, 0);
427
+ rb_define_singleton_method(mNat, "shape_mode=", native_shape_mode_set, 1);
428
+ rb_define_singleton_method(mNat, "scan_time", native_scan_time, 2);
429
+ rb_define_const(mNat, "AVAILABLE", Qtrue);
430
+ const char* env = getenv("YEPTRIS_NATIVE_GC");
431
+ if (env != NULL && strcmp(env, "none") == 0) yep_rb_set_gc_mode(1);
432
+ else if (env != NULL && strcmp(env, "start") == 0) yep_rb_set_gc_mode(2);
433
+ else if (env != NULL && strcmp(env, "disable") == 0) yep_rb_set_gc_mode(0);
434
+ const char* ins = getenv("YEPTRIS_NATIVE_INSERT");
435
+ if (ins != NULL && strcmp(ins, "aset") == 0) yep_rb_set_ins_mode(1);
436
+ const char* cache = getenv("YEPTRIS_NATIVE_CACHE");
437
+ if (cache != NULL && strcmp(cache, "off") == 0) yep_rb_set_cache_mode(1);
438
+ const char* shape = getenv("YEPTRIS_NATIVE_SHAPE");
439
+ if (shape != NULL && strcmp(shape, "natural") == 0) yep_rb_set_shape_mode(1);
440
+ }
@@ -0,0 +1,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Yeptris::Document
4
+ attr_reader :c_ptr
5
+
6
+ # Shared between the instance and its GC finalizer (Procs close over
7
+ # variables by reference) — the leptris-ruby double-free fix: the
8
+ # explicit free path and the finalizer both flip the same flag.
9
+ Freed = Struct.new(:state) # :alive | :freed
10
+
11
+ # The schema this document was parsed with (a parse property).
12
+ attr_reader :parse_schema
13
+
14
+ def initialize(c_ptr = nil, freed = Freed.new(:alive), parse_schema = :core_12)
15
+ @c_ptr = c_ptr
16
+ @freed = freed
17
+ @parse_schema = parse_schema
18
+ @readonly = false
19
+ # Strong wrapper cache keyed on the C node address: the same C node
20
+ # always yields the same Ruby object (identity for aliases/eql?),
21
+ # cleared at free — no stale entries, no GC-race weak maps.
22
+ @wrapper_cache = {}
23
+ ObjectSpace.define_finalizer(self, self.class.finalize(c_ptr, freed)) unless c_ptr.null?
24
+ end
25
+
26
+ def self.parse(yaml, schema: :core_12, max_depth: 0)
27
+ yaml = Yeptris.read_input(yaml)
28
+ yaml = yaml.to_s
29
+ doc =
30
+ if schema == :core_12 && max_depth.zero?
31
+ Yeptris::FFI.yeptris_parse(yaml, yaml.bytesize, nil)
32
+ else
33
+ opts = Yeptris::FFI::ParseOptions.new
34
+ opts[:schema] = schema == :compat_11 ? Yeptris::FFI::SCHEMA_11_COMPAT : Yeptris::FFI::SCHEMA_12_CORE
35
+ opts[:max_depth] = max_depth
36
+ Yeptris::FFI.yeptris_parse_ex(yaml, yaml.bytesize, opts, nil)
37
+ end
38
+ if doc.null?
39
+ # the status out-param is skipped: the thread-local error
40
+ # channel carries the failure detail (measurable on small docs)
41
+ raise Yeptris::ParseError,
42
+ "parse failed: #{Yeptris::FFI.last_error_message}"
43
+ end
44
+ wrap_schema(doc, schema)
45
+ end
46
+
47
+ def self.parse_json(json)
48
+ json = Yeptris.read_input(json)
49
+ json = json.to_s
50
+ doc = Yeptris::FFI.yeptris_parse_json(json, json.bytesize, nil)
51
+ raise Yeptris::ParseError,
52
+ "json parse failed: #{Yeptris::FFI.last_error_message}" if doc.null?
53
+
54
+ wrap_schema(doc, :core_12) # strict JSON is core by construction
55
+ end
56
+
57
+ # An empty document for from-scratch construction (TODO.impl/11 p3).
58
+ def self.create
59
+ doc = Yeptris::FFI.yeptris_document_new
60
+ raise Yeptris::Error, "yeptris_document_new failed" if doc.null?
61
+
62
+ wrap(doc)
63
+ end
64
+
65
+ # @api private
66
+ def self.wrap(c_ptr)
67
+ new(c_ptr)
68
+ end
69
+
70
+ # The schema the document was parsed with (:core_12 / :compat_11) —
71
+ # host scalar policies (Psych's dot-required float) are conditioned
72
+ # on it. The parse constructors set the real one; plain wrap keeps
73
+ # the core_12 default (yeptris_parse's default).
74
+ def self.wrap_schema(c_ptr, schema)
75
+ new(c_ptr, Freed.new(:alive), schema)
76
+ end
77
+
78
+ def ensure_alive!
79
+ raise Yeptris::FreedError, "document is freed" if @freed.state == :freed
80
+ end
81
+
82
+ def free
83
+ return if @freed.state == :freed
84
+
85
+ @freed.state = :freed
86
+ @wrapper_cache.clear
87
+ Yeptris::FFI.yeptris_document_free(@c_ptr)
88
+ end
89
+
90
+ def freed?
91
+ @freed.state == :freed
92
+ end
93
+
94
+ def readonly!
95
+ @readonly = true
96
+ self
97
+ end
98
+
99
+ def readonly?
100
+ @readonly
101
+ end
102
+
103
+ # @api private — memo table for readonly materialization: node ids
104
+ # that already produced their Ruby object keep it (leptris pattern:
105
+ # readonly documents never change, so the memo is forever valid).
106
+ def readonly_memo
107
+ @readonly_memo ||= {}
108
+ end
109
+
110
+ # @api private — the single Node construction path. Query handles
111
+ # are transient C allocations; the wrapper cache is keyed on the
112
+ # STABLE node id (yeptris_node_id), so the same node always yields
113
+ # the same Ruby object no matter which query produced the handle.
114
+ def wrap_node(c_ptr)
115
+ ensure_alive!
116
+ return nil if c_ptr.null?
117
+
118
+ id = Yeptris::FFI.yeptris_node_id(c_ptr)
119
+ @wrapper_cache[id] ||= Yeptris::Node.new(c_ptr, self)
120
+ end
121
+
122
+ def document_count
123
+ ensure_alive!
124
+ Yeptris::FFI.yeptris_document_count(@c_ptr)
125
+ end
126
+
127
+ # Root node of stream document i (0-based).
128
+ def root(index = 0)
129
+ ensure_alive!
130
+ wrap_node(Yeptris::FFI.yeptris_document_root(@c_ptr, index))
131
+ end
132
+
133
+ # Bulk build (TODO.impl/15 phase D): one call raises the whole
134
+ # tree from a flat entry array + blob (see YAML::BulkBuilder).
135
+ def build_entries(entries, count, blob, blob_len)
136
+ ensure_alive!
137
+ Yeptris::FFI.yeptris_document_build(@c_ptr, entries, count, blob, blob_len)
138
+ end
139
+
140
+ def serialize(canonical: false, best_width: 0)
141
+ ensure_alive!
142
+ len = ::FFI::MemoryPointer.new(:uint64)
143
+ ptr =
144
+ if canonical || best_width.positive?
145
+ opts = Yeptris::FFI::EmitOptions.new
146
+ opts[:size] = Yeptris::FFI::EmitOptions.size
147
+ opts[:canonical] = canonical ? 1 : 0
148
+ opts[:best_width] = best_width
149
+ Yeptris::FFI.yeptris_serialize_ex(@c_ptr, opts, len)
150
+ else
151
+ Yeptris::FFI.yeptris_serialize(@c_ptr, len)
152
+ end
153
+ Yeptris::FFI::Owned.string(ptr, len)
154
+ end
155
+
156
+ def serialize_json
157
+ ensure_alive!
158
+ len = ::FFI::MemoryPointer.new(:uint64)
159
+ Yeptris::FFI::Owned.string(Yeptris::FFI.yeptris_serialize_json(@c_ptr, len), len)
160
+ end
161
+
162
+ def to_s
163
+ serialize
164
+ end
165
+
166
+ # The Ruby object graph of stream document i (Psych-compatible
167
+ # materialization; alias identity preserved via the memo).
168
+ def to_ruby(index = 0)
169
+ ensure_alive!
170
+ r = root(index)
171
+ r.nil? ? nil : r.to_ruby
172
+ end
173
+
174
+ # ---- construction conveniences (TODO.impl/11 phase 3) ----
175
+
176
+ def new_mapping
177
+ ensure_alive!
178
+ wrap_node(Yeptris::FFI.yeptris_node_new_mapping(@c_ptr)) or
179
+ raise Yeptris::Error, "yeptris_node_new_mapping failed"
180
+ end
181
+
182
+ def new_sequence
183
+ ensure_alive!
184
+ wrap_node(Yeptris::FFI.yeptris_node_new_sequence(@c_ptr)) or
185
+ raise Yeptris::Error, "yeptris_node_new_sequence failed"
186
+ end
187
+
188
+ # style: :plain / :single_quoted / :double_quoted / :literal / :folded.
189
+ # The value is copied into the document (nothing is borrowed).
190
+ def new_scalar(text, style = :plain)
191
+ ensure_alive!
192
+ code = Yeptris::Node::STYLES.key(style) or
193
+ raise ArgumentError, "unknown scalar style #{style.inspect}"
194
+ text = text.to_s
195
+ n = Yeptris::FFI.yeptris_node_new_scalar(@c_ptr, text, text.bytesize, code)
196
+ wrap_node(n) or raise Yeptris::Error, "yeptris_node_new_scalar failed"
197
+ end
198
+
199
+ # An alias node: display name + the target it resolves to.
200
+ def new_alias(target, name)
201
+ ensure_alive!
202
+ n = Yeptris::FFI.yeptris_node_new_alias(@c_ptr, target.c_ptr, name, name.bytesize)
203
+ wrap_node(n) or raise Yeptris::Error, "yeptris_node_new_alias failed"
204
+ end
205
+
206
+ def set_root(node)
207
+ ensure_alive!
208
+ rc = Yeptris::FFI.yeptris_document_set_root(@c_ptr, node.c_ptr)
209
+ Yeptris::FFI.check_status(rc, "yeptris_document_set_root")
210
+ self
211
+ end
212
+
213
+ # GC safety net: an explicit #free already ran is fine; a miss here
214
+ # frees C memory that would otherwise leak.
215
+ def self.finalize(c_ptr, freed)
216
+ proc do
217
+ Yeptris::FFI.yeptris_document_free(c_ptr) if freed.state == :alive
218
+ freed.state = :freed
219
+ end
220
+ end
221
+ end