yeptris 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,315 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yeptris"
4
+ require "yeptris/psych/handler"
5
+ require "yeptris/psych/parser"
6
+ require "yeptris/psych/coder_shim"
7
+ require "yeptris/psych/visitors"
8
+
9
+ # The Psych drop-in namespace (TODO.impl/15 phase C).
10
+ #
11
+ # `require "yeptris/psych"` rebinds the top-level Psych constant to
12
+ # this module (the original, if any, stays reachable as
13
+ # ::Psych::ORIGINAL). Semantics follow the Psych suite: load is
14
+ # SAFE by default (Psych 5 behavior — plain data only; anything
15
+ # tagged raises), unsafe_load materializes everything the yeptris
16
+ # loader understands, and parse returns the Nodes tree over the
17
+ # document without materializing.
18
+ module Yeptris
19
+ module Psych
20
+ class Error < StandardError; end
21
+ class SyntaxError < Error
22
+ attr_reader :line, :column
23
+
24
+ def initialize(message, line = 0, column = 0)
25
+ super(message)
26
+ @line = line
27
+ @column = column
28
+ end
29
+ end
30
+ class BadAlias < Error; end
31
+ class DisallowedClass < Error
32
+ attr_reader :name
33
+
34
+ def initialize(name)
35
+ super("Tried to load unspecified class: #{name}")
36
+ @name = name
37
+ end
38
+ end
39
+ class AliasNotEnabled < Error; end
40
+
41
+ class << self
42
+ # Psych 5: load is safe — plain data structures only. Tagged
43
+ # nodes raise DisallowedClass unless their class is permitted
44
+ # (Date/Time/Symbol are built in; they are plain data here).
45
+ def load(yaml, permitted_classes: [], aliases: false, **)
46
+ safe_load(yaml, permitted_classes: permitted_classes, aliases: aliases)
47
+ end
48
+
49
+ # Full revival over the Nodes tree: !ruby/object, !ruby/struct,
50
+ # !ruby/set, encode_with/init_with, alias identity. Plain-data
51
+ # loads should use load/safe_load (the Materializer fast path).
52
+ def unsafe_load(yaml, **)
53
+ tree = parse(yaml)
54
+ return nil if tree.nil?
55
+
56
+ Visitors::ToRuby.visit(tree.children.first)
57
+ end
58
+
59
+ def safe_load(yaml, permitted_classes: [], aliases: false, **)
60
+ doc = Yeptris::Document.parse(yaml, schema: :compat_11)
61
+ begin
62
+ walk_safe(doc.root(0), permitted_classes, aliases)
63
+ ensure
64
+ doc.free
65
+ end
66
+ end
67
+
68
+ # The first document's node tree (no Ruby materialization).
69
+ def parse(yaml)
70
+ doc = Yeptris::Document.parse(yaml, schema: :compat_11)
71
+ return nil if doc.document_count.zero?
72
+
73
+ Nodes::Builder.document(doc)
74
+ rescue Yeptris::ParseError => e
75
+ raise SyntaxError, e.message
76
+ end
77
+
78
+ def parse_stream(yaml)
79
+ doc = Yeptris::Document.parse(yaml, schema: :compat_11)
80
+ return nil if doc.document_count.zero?
81
+
82
+ stream = Nodes::Stream.new
83
+ (0...doc.document_count).each do |i|
84
+ stream.children << Nodes::Builder.document_stream_child(doc, i)
85
+ end
86
+ stream.instance_variable_set(:@owner, doc)
87
+ ObjectSpace.define_finalizer(
88
+ stream, proc { doc.free unless doc.freed? }
89
+ )
90
+ stream
91
+ rescue Yeptris::ParseError => e
92
+ raise SyntaxError, e.message
93
+ ensure
94
+ doc&.free if doc && !stream
95
+ end
96
+
97
+ # Arbitrary object graphs through the YAMLTree visitor
98
+ # (anchors, !ruby/ tags); plain data rides the fast builder.
99
+ def dump(obj, io = nil)
100
+ # scalars take the fast builder; EVERYTHING else (including
101
+ # plain containers — they may nest custom objects) goes
102
+ # through the visitor, which builds the same DOM for plain
103
+ # data anyway
104
+ out =
105
+ case obj
106
+ when nil, true, false, String, Integer, Float, Symbol, Date, Time
107
+ Yeptris::YAML.dump(obj)
108
+ else
109
+ Visitors::YAMLTree.new.push(obj).finish
110
+ end
111
+ return out unless io
112
+
113
+ io.write(out)
114
+ io
115
+ end
116
+
117
+ private
118
+
119
+ # yeptris materializes plain data only — there is nothing
120
+ # unsafe it COULD load. The safety walk enforces what Psych
121
+ # enforces on such documents: aliases need opt-in, and explicit
122
+ # non-core tags raise DisallowedClass (the only "classes" the
123
+ # loader can produce are core-schema types, all permitted).
124
+ def walk_safe(root, permitted, aliases_enabled)
125
+ check(root, aliases_enabled) if root
126
+ root.to_ruby
127
+ end
128
+
129
+ def check(node, aliases_enabled)
130
+ case node.kind
131
+ when :alias
132
+ raise AliasNotEnabled, "Unknown alias" unless aliases_enabled
133
+ when :scalar, :mapping, :sequence
134
+ tag = node.tag
135
+ unless tag.nil?
136
+ name = tag.split(":").last
137
+ unless %w[str int float bool null timestamp seq map merge value
138
+ binary].include?(name)
139
+ raise DisallowedClass, name
140
+ end
141
+ end
142
+ case node.kind
143
+ when :mapping
144
+ node.each_pair do |k, v|
145
+ check(k, aliases_enabled)
146
+ check(v, aliases_enabled)
147
+ end
148
+ when :sequence
149
+ node.each { |e| check(e, aliases_enabled) }
150
+ end
151
+ end
152
+ end
153
+ end
154
+
155
+ # Psych::Nodes over the yeptris document: the tree IS the parsed
156
+ # document (children are node handles, not copies) — parse cost
157
+ # is the parse, and to_ruby reuses the Materializer.
158
+ module Nodes
159
+ class Node
160
+ include Enumerable
161
+
162
+ attr_reader :children
163
+ attr_reader :handle # @api private — the Yeptris::Node
164
+
165
+ def initialize(handle = nil, children = [])
166
+ @handle = handle
167
+ @children = children
168
+ end
169
+
170
+ def each(&block)
171
+ @children.each(&block)
172
+ end
173
+
174
+ # The Ruby object for this subtree (Materializer semantics).
175
+ def to_ruby
176
+ @handle.to_ruby
177
+ end
178
+ end
179
+
180
+ class Stream < Node
181
+ def free
182
+ @owner&.free
183
+ end
184
+ end
185
+
186
+ # Owns the underlying Yeptris::Document: node handles in the
187
+ # tree stay valid while the tree is reachable; a GC finalizer
188
+ # releases the C memory when it is not.
189
+ class Document < Node
190
+ attr_reader :version, :tags
191
+
192
+ def initialize(version = [], tags = {})
193
+ super(nil)
194
+ @version = version
195
+ @tags = tags
196
+ end
197
+
198
+ # @api private — transfer of C ownership to this tree
199
+ def own(yeptris_doc)
200
+ @owner = yeptris_doc
201
+ ObjectSpace.define_finalizer(
202
+ self, proc { yeptris_doc.free unless yeptris_doc.freed? }
203
+ )
204
+ self
205
+ end
206
+
207
+ def free
208
+ @owner&.free
209
+ end
210
+ end
211
+
212
+ class Scalar < Node
213
+ attr_reader :value, :tag, :anchor, :plain, :quoted, :style
214
+
215
+ def initialize(value = nil, anchor: nil, tag: nil, plain: true,
216
+ quoted: false, style: :plain)
217
+ super(nil)
218
+ @value = value
219
+ @anchor = anchor
220
+ @tag = tag
221
+ @plain = plain
222
+ @quoted = quoted
223
+ @style = style
224
+ end
225
+ end
226
+
227
+ class Sequence < Node
228
+ attr_reader :anchor, :tag, :style
229
+
230
+ def initialize(anchor: nil, tag: nil, style: :block)
231
+ super(nil, [])
232
+ @anchor = anchor
233
+ @tag = tag
234
+ @style = style
235
+ end
236
+ end
237
+
238
+ class Mapping < Node
239
+ attr_reader :anchor, :tag, :style
240
+
241
+ def initialize(anchor: nil, tag: nil, style: :block)
242
+ super(nil, [])
243
+ @anchor = anchor
244
+ @tag = tag
245
+ @style = style
246
+ end
247
+ end
248
+
249
+ class Alias < Node
250
+ attr_reader :anchor
251
+
252
+ def initialize(anchor)
253
+ super(nil)
254
+ @anchor = anchor
255
+ end
256
+ end
257
+
258
+ # Builds the Nodes tree from a parsed document.
259
+ module Builder
260
+ module_function
261
+
262
+ # parse(): one document, owning the yeptris document
263
+ def document(doc, index = 0)
264
+ document_stream_child(doc, index).own(doc)
265
+ end
266
+
267
+ # parse_stream(): a child document sharing one owner (the
268
+ # stream owns the yeptris document)
269
+ def document_stream_child(doc, index)
270
+ root = doc.root(index)
271
+ d = Document.new
272
+ d.instance_variable_set(:@handle, root&.document)
273
+ d.children << node(root) if root
274
+ d
275
+ end
276
+
277
+ def node(n)
278
+ case n.kind
279
+ when :mapping
280
+ m = Mapping.new(anchor: n.anchor, tag: n.tag)
281
+ n.each_pair do |k, v|
282
+ m.children << node(k)
283
+ m.children << node(v)
284
+ end
285
+ m.instance_variable_set(:@handle, n)
286
+ m
287
+ when :sequence
288
+ s = Sequence.new(anchor: n.anchor, tag: n.tag)
289
+ n.each { |e| s.children << node(e) }
290
+ s.instance_variable_set(:@handle, n)
291
+ s
292
+ when :alias
293
+ a = Alias.new(n.value)
294
+ a.instance_variable_set(:@handle, n)
295
+ a
296
+ else
297
+ sc = Scalar.new(n.value, anchor: n.anchor, tag: n.tag,
298
+ plain: n.style == :plain, style: n.style)
299
+ sc.instance_variable_set(:@handle, n)
300
+ sc
301
+ end
302
+ end
303
+ end
304
+ end
305
+ end
306
+ end
307
+
308
+ # The drop-in: rebind the top-level constant ( Psych-stdlib, if
309
+ # already loaded, stays reachable as Yeptris::Psych::ORIGINAL).
310
+ if defined?(::Psych) && !::Psych.equal?(Yeptris::Psych) &&
311
+ !Yeptris::Psych.const_defined?(:ORIGINAL, false)
312
+ Yeptris::Psych.const_set(:ORIGINAL, ::Psych)
313
+ end
314
+ Object.send(:remove_const, :Psych) if defined?(::Psych)
315
+ ::Psych = Yeptris::Psych
@@ -0,0 +1,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yeptris
4
+ # Value-stream materialization (TODO.impl/15 phase F): one drain of
5
+ # PRE-CONVERTED typed values from the C side, then a minimal Ruby
6
+ # walk — no per-scalar parsing (the number kernels already ran),
7
+ # key/value pairing rides the entries' is_key bit, anchors arrive
8
+ # as uniform entries decorating the value they bind. The Psych
9
+ # quirks re-decide from tag_id + the raw bytes every entry carries.
10
+ module ValueML
11
+ DOC = 0
12
+ V_NULL = 1
13
+ V_BOOL = 2
14
+ V_INT = 3
15
+ V_FLOAT = 4
16
+ V_STR = 5
17
+ V_TS = 6
18
+ SEQ_OPEN = 7
19
+ MAP_OPEN = 8
20
+ CLOSE = 9
21
+ ALIAS = 10
22
+ ANCHOR = 11
23
+
24
+ FIELDS = 7
25
+ VALUE_SIZE = 24
26
+ # kind, tag, is_key, b | off, len | pad4 | payload (INT/FLOAT bits)
27
+ UNPACK = "C4V2x4q<"
28
+
29
+ module_function
30
+
31
+ def load_all(yaml, schema: :compat_11)
32
+ yaml = yaml.read if yaml.respond_to?(:read)
33
+ yaml = yaml.to_s
34
+ vals_p = ::FFI::MemoryPointer.new(:pointer)
35
+ count_p = ::FFI::MemoryPointer.new(:uint64)
36
+ arena_p = ::FFI::MemoryPointer.new(:pointer)
37
+ alen_p = ::FFI::MemoryPointer.new(:uint64)
38
+ st = FFI.yeptris_value_drain(
39
+ yaml, yaml.bytesize,
40
+ schema == :compat_11 ? FFI::SCHEMA_11_COMPAT : FFI::SCHEMA_12_CORE,
41
+ vals_p, count_p, arena_p, alen_p
42
+ )
43
+ raise ParseError, FFI.last_error_message if st != FFI::OK
44
+
45
+ vals = vals_p.read_pointer
46
+ arena = arena_p.read_pointer
47
+ begin
48
+ count = count_p.read_uint64
49
+ flat = vals.read_bytes(count * VALUE_SIZE).unpack(UNPACK * count)
50
+ arena_bytes = arena.read_bytes(alen_p.read_uint64)
51
+ arena_bytes.force_encoding(Encoding::UTF_8)
52
+ walk(flat, arena_bytes)
53
+ ensure
54
+ FFI.yeptris_value_free(vals, arena)
55
+ end
56
+ end
57
+
58
+ def load(yaml, schema: :compat_11)
59
+ docs = load_all(yaml, schema: schema)
60
+ docs.empty? ? nil : docs.first
61
+ end
62
+
63
+ # field offsets in the unpacked 7-tuple
64
+ KIND = 0
65
+ TAG = 1
66
+ IS_KEY = 2
67
+ B = 3
68
+ OFF = 4
69
+ LEN = 5
70
+ P64 = 6
71
+
72
+ def walk(flat, arena)
73
+ docs = []
74
+ stack = []
75
+ anchors = {}
76
+ pending_key = []
77
+ pending_key_tag = []
78
+ pending_anchor = nil
79
+ merge_target = []
80
+
81
+ i = 0
82
+ n = flat.length
83
+ while i < n
84
+ kind = flat[i + KIND]
85
+ case kind
86
+ when V_STR
87
+ text = arena.byteslice(flat[i + OFF], flat[i + LEN])
88
+ # implicit-plain ':name' scans to a Symbol (Psych's
89
+ # ScalarScanner); quoted ':x' stays a String
90
+ v = if flat[i + B] == 1 && text.length > 1 &&
91
+ text.start_with?(":") && !text.start_with?("::")
92
+ text[1..].to_sym
93
+ else
94
+ text
95
+ end
96
+ if pending_anchor
97
+ anchors[pending_anchor] = v
98
+ pending_anchor = nil
99
+ end
100
+ slot(docs, stack, pending_key, pending_key_tag, v, merge_target, flat, i)
101
+ when V_INT
102
+ v = flat[i + P64]
103
+ if pending_anchor
104
+ anchors[pending_anchor] = v
105
+ pending_anchor = nil
106
+ end
107
+ slot(docs, stack, pending_key, pending_key_tag, v, merge_target, flat, i)
108
+ when V_FLOAT
109
+ text = arena.byteslice(flat[i + OFF], flat[i + LEN])
110
+ # Psych's float grammar requires the dot (or an inf/nan
111
+ # word, or sexagesimal ':') — exponent-only forms are
112
+ # Strings even when the compat tag says FLOAT
113
+ v = if text.include?(".") || text.include?(":") || text.start_with?(".")
114
+ [flat[i + P64]].pack("q<").unpack1("E")
115
+ else
116
+ text
117
+ end
118
+ if pending_anchor
119
+ anchors[pending_anchor] = v
120
+ pending_anchor = nil
121
+ end
122
+ slot(docs, stack, pending_key, pending_key_tag, v, merge_target, flat, i)
123
+ when V_BOOL
124
+ text = arena.byteslice(flat[i + OFF], flat[i + LEN])
125
+ # Psych quirk: single-char y/n stay Strings
126
+ v = text.length == 1 ? text : flat[i + B] == 1
127
+ if pending_anchor
128
+ anchors[pending_anchor] = v
129
+ pending_anchor = nil
130
+ end
131
+ slot(docs, stack, pending_key, pending_key_tag, v, merge_target, flat, i)
132
+ when V_NULL
133
+ slot(docs, stack, pending_key, pending_key_tag, nil, merge_target, flat, i)
134
+ when V_TS
135
+ v = Materializer.parse_timestamp(arena.byteslice(flat[i + OFF], flat[i + LEN]))
136
+ if pending_anchor
137
+ anchors[pending_anchor] = v
138
+ pending_anchor = nil
139
+ end
140
+ slot(docs, stack, pending_key, pending_key_tag, v, merge_target, flat, i)
141
+ when MAP_OPEN
142
+ h = {}
143
+ if pending_anchor
144
+ anchors[pending_anchor] = h
145
+ pending_anchor = nil
146
+ end
147
+ merge_target.push(slot(docs, stack, pending_key, pending_key_tag, h, merge_target,
148
+ flat, i))
149
+ stack.push(h)
150
+ pending_key.push(nil)
151
+ pending_key_tag.push(nil)
152
+ when SEQ_OPEN
153
+ a = []
154
+ if pending_anchor
155
+ anchors[pending_anchor] = a
156
+ pending_anchor = nil
157
+ end
158
+ merge_target.push(slot(docs, stack, pending_key, pending_key_tag, a, merge_target,
159
+ flat, i))
160
+ stack.push(a)
161
+ pending_key.push(nil)
162
+ pending_key_tag.push(nil)
163
+ when CLOSE
164
+ closed = stack.pop
165
+ pending_key.pop
166
+ pending_key_tag.pop
167
+ target = merge_target.pop
168
+ Materializer.merge_into(target, closed) if target
169
+ when DOC
170
+ docs.push(nil)
171
+ when ALIAS
172
+ v = anchors[arena.byteslice(flat[i + OFF], flat[i + LEN])]
173
+ slot(docs, stack, pending_key, pending_key_tag, v, merge_target, flat, i)
174
+ when ANCHOR
175
+ pending_anchor = arena.byteslice(flat[i + OFF], flat[i + LEN])
176
+ end
177
+ i += FIELDS
178
+ end
179
+ docs
180
+ end
181
+
182
+ # Places a completed value: document root, sequence entry, or a
183
+ # map's key (is_key) / value (completing the pending pair).
184
+ # Returns the merge TARGET when the value is a still-empty
185
+ # container placed under a '<<' key — the open-site caller pushes
186
+ # it so the matching CLOSE merges into it (an inline map's
187
+ # contents arrive after its open); scalar merges apply now.
188
+ def slot(docs, stack, pending_key, pending_key_tag, v, _merge_target, flat, i)
189
+ if stack.empty?
190
+ docs[-1] = v
191
+ return nil
192
+ end
193
+ parent = stack.last
194
+ if parent.is_a?(Array)
195
+ parent.push(v)
196
+ return nil
197
+ end
198
+ if flat[i + IS_KEY] == 1
199
+ pending_key[-1] = v
200
+ pending_key_tag[-1] = flat[i + TAG]
201
+ return nil
202
+ end
203
+ key = pending_key[-1]
204
+ pending_key[-1] = nil
205
+ if key == "<<" && pending_key_tag[-1] == 9 # TAG_MERGE
206
+ if (v.is_a?(Hash) || v.is_a?(Array)) && v.empty?
207
+ parent # deferred: contents arrive after the open
208
+ else
209
+ Materializer.merge_into(parent, v)
210
+ nil
211
+ end
212
+ else
213
+ parent[key] = v
214
+ nil
215
+ end
216
+ end
217
+ end
218
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yeptris
4
+ VERSION = "0.1.0"
5
+ end