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,347 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "time"
5
+
6
+ module Yeptris
7
+ # Recorder-driven Ruby materialization (TODO.impl/15 phase B).
8
+ #
9
+ # One bulk drain: the record array and string arena are read in two
10
+ # calls, then a pure-Ruby stack machine walks fixed-layout records —
11
+ # the FFI tax is O(chunks), never O(events). The DOM-walk
12
+ # materializer (Node#to_ruby) stays for node-based use; this is the
13
+ # Yeptris::YAML fast path.
14
+ class Materializer
15
+ RECORD_SIZE = 36 # YeptrisEventRecord layout (events.h, ABI-pinned)
16
+ FIELDS = 12 # unpacked values per record
17
+ RECORD_UNPACK = "C4V8" # type/style/flags/tag_id, line..tag_len
18
+
19
+ STREAM_START = 1
20
+ STREAM_END = 2
21
+ DOCUMENT_START = 3
22
+ DOCUMENT_END = 4
23
+ SEQUENCE_START = 5
24
+ SEQUENCE_END = 6
25
+ MAPPING_START = 7
26
+ MAPPING_END = 8
27
+ SCALAR = 9
28
+ ALIAS = 10
29
+
30
+ EF_IMPLICIT = 1 << 2
31
+ STYLE_PLAIN = 1
32
+
33
+ # YeptrisTagId values (resolve.h; FFI mirrors them)
34
+ TAG_STR = Yeptris::FFI::TAG_STR
35
+ TAG_INT = Yeptris::FFI::TAG_INT
36
+ TAG_FLOAT = Yeptris::FFI::TAG_FLOAT
37
+ TAG_BOOL = Yeptris::FFI::TAG_BOOL
38
+ TAG_NULL = Yeptris::FFI::TAG_NULL
39
+ TAG_TIMESTAMP = Yeptris::FFI::TAG_TIMESTAMP
40
+ TAG_MERGE = 9 # YEPTRIS_TAG_MERGE (resolve.h): a plain '<<' key
41
+
42
+ INF_WORDS = {
43
+ ".inf" => Float::INFINITY, ".Inf" => Float::INFINITY, ".INF" => Float::INFINITY,
44
+ "+.inf" => Float::INFINITY, "+.Inf" => Float::INFINITY, "+.INF" => Float::INFINITY,
45
+ "-.inf" => -Float::INFINITY, "-.Inf" => -Float::INFINITY, "-.INF" => -Float::INFINITY,
46
+ ".nan" => Float::NAN, ".NaN" => Float::NAN, ".NAN" => Float::NAN,
47
+ }.freeze
48
+ SEXAGESIMAL_INT = /\A[-+]?[1-9][0-9_]*(:[0-5]?[0-9])+\z/
49
+ SEXAGESIMAL_FLOAT = /\A[-+]?[0-9][0-9_]*(:[0-5]?[0-9])+:[0-5]?[0-9]\.[0-9_]*\z/
50
+
51
+ class << self
52
+ # First document of the stream, or nil when the stream is empty.
53
+ def load(yaml, schema: :compat_11)
54
+ docs = load_stream(yaml, schema: schema)
55
+ docs.empty? ? nil : docs.first
56
+ end
57
+
58
+ # Every document in the stream, in order.
59
+ def load_stream(yaml, schema: :compat_11)
60
+ new(schema: schema).materialize(yaml)
61
+ end
62
+
63
+ # The shared drain seam: one parse, records + arena read once,
64
+ # ONE unpack into a flat Integer array (12 fields per record:
65
+ # type style flags tag_id line col v_off v_len a_off a_len
66
+ # t_off t_len). Both consumers ride it — the stack machine
67
+ # (materialize) and the Psych::Parser dispatch loop.
68
+ def drain(yaml, schema: :compat_11)
69
+ rec = Yeptris::FFI.yeptris_recorder_new_ex(
70
+ schema == :compat_11 ? Yeptris::FFI::SCHEMA_11_COMPAT : Yeptris::FFI::SCHEMA_12_CORE
71
+ )
72
+ begin
73
+ status = Yeptris::FFI.yeptris_recorder_feed(rec, yaml, yaml.bytesize, 1)
74
+ if status != Yeptris::FFI::OK
75
+ raise Yeptris::ParseError,
76
+ "parse failed: #{Yeptris::FFI.last_error_message}"
77
+ end
78
+ count_p = ::FFI::MemoryPointer.new(:size_t)
79
+ records = Yeptris::FFI.yeptris_recorder_records(rec, count_p)
80
+ count = count_p.read_uint64
81
+ arena_len = ::FFI::MemoryPointer.new(:size_t)
82
+ arena_ptr = Yeptris::FFI.yeptris_recorder_arena(rec, arena_len)
83
+ arena_len_v = arena_len.read_uint64
84
+ arena = arena_ptr.null? || arena_len_v.zero? ? +"" : arena_ptr.read_bytes(arena_len_v)
85
+ flat = records.read_bytes(count * RECORD_SIZE)
86
+ .unpack(RECORD_UNPACK * count)
87
+ [flat, arena]
88
+ ensure
89
+ Yeptris::FFI.yeptris_recorder_free(rec)
90
+ end
91
+ end
92
+
93
+ # The Ruby value of a scalar per schema — the ScalarScanner rule
94
+ # set, spec-verified against Psych case by case. Only implicit
95
+ # PLAIN scalars scan; quoting is the escape hatch.
96
+ # The record's tag_id IS the resolver's verdict (the typing
97
+ # SSOT): conversion by tag, Kernel#Integer/Float for the bytes —
98
+ # no host-side grammar. Two Psych-quirk overrides where Psych's
99
+ # scanner disagrees with the 1.1 resolver: single-char y/Y/n/N
100
+ # are STRINGS in Psych (libyaml says bool), and values the
101
+ # resolver tagged INT but Kernel rejects (mixed forms) fall back
102
+ # through sexagesimal to String.
103
+ PSYCH_TRUE = %w[y yes true on].freeze
104
+
105
+ # '<<' merge: existing keys win; sequences merge in order.
106
+ # Shared by the record walk and the value-stream walk.
107
+ def merge_into(map, obj)
108
+ case obj
109
+ when Hash
110
+ obj.each { |k, v| map[k] = v unless map.key?(k) }
111
+ when Array
112
+ obj.each { |e| merge_into(map, e) if e.is_a?(Hash) }
113
+ end
114
+ end
115
+
116
+ def parse_timestamp(v)
117
+ return Date.parse(v) unless v.match?(/[Tt ]\d/)
118
+
119
+ # normalize the YAML 1.1 space forms onto iso8601 for
120
+ # xmlschema: "2001-12-14 21:59:43.10 -05:00" ->
121
+ # "2001-12-14T21:59:43.10-05:00" (Psych's scanner does the
122
+ # same dance)
123
+ ts = v.sub(/ (\d)/, 'T\1').sub(/ ([+-]\d)/, '\1').sub(/ +Z\z/i, 'Z')
124
+ # Psych reads a NAIVE timestamp (no zone, no Z) as a UTC
125
+ # instant expressed in the local zone — one wall-clock hour
126
+ # off from reading it as local time (pinned by spec against
127
+ # Psych.unsafe_load across tz shapes)
128
+ if ts.match?(/(Z|[+-]\d\d:?\d\d)\z/i)
129
+ Time.xmlschema(ts)
130
+ else
131
+ Time.xmlschema(ts + "Z").getlocal
132
+ end
133
+ rescue ArgumentError
134
+ v
135
+ end
136
+
137
+ def scan_by_tag(value, tag_id, implicit)
138
+ case tag_id
139
+ when TAG_STR
140
+ return value unless implicit
141
+
142
+ value.start_with?(":") && !value.start_with?("::") &&
143
+ value.length > 1 ? value[1..].to_sym : value
144
+ when TAG_NULL then nil
145
+ when TAG_BOOL
146
+ return value if value.length == 1 # Psych: "y"/"n" stay Strings
147
+
148
+ PSYCH_TRUE.include?(value.downcase)
149
+ when TAG_INT
150
+ int_or_string(value)
151
+ when TAG_FLOAT
152
+ float_or_string(value)
153
+ when TAG_TIMESTAMP
154
+ parse_timestamp(value)
155
+ else
156
+ value
157
+ end
158
+ end
159
+
160
+ def int_or_string(value)
161
+ Integer(value.tr("_", ""))
162
+ rescue ArgumentError
163
+ # the resolver said INT but Kernel disagrees (mixed form):
164
+ # sexagesimal or back to String
165
+ return sexagesimal(value) if SEXAGESIMAL_INT.match?(value) ||
166
+ SEXAGESIMAL_FLOAT.match?(value)
167
+
168
+ value
169
+ end
170
+
171
+ def float_or_string(value)
172
+ return INF_WORDS[value] if INF_WORDS.key?(value)
173
+ return value unless value.include?(".")
174
+
175
+ # Psych's FLOAT: the exponent carries a mandatory sign —
176
+ # "1e3" and "1.5e3" are Strings (resolver quirk override)
177
+ return value if /[eE][^+-]/.match?(value)
178
+
179
+ Float(value.tr("_", ""))
180
+ rescue ArgumentError
181
+ return sexagesimal(value) if SEXAGESIMAL_FLOAT.match?(value)
182
+
183
+ value
184
+ end
185
+
186
+ private
187
+
188
+ # Psych's scanner IS Kernel#Integer on the plain digits: Ruby
189
+ # reads leading-zero strings as octal, rejects "018", takes
190
+ # 0x/0b/bases — verified case by case against Psych.
191
+ def sexagesimal(v)
192
+ is_float = v.include?(".")
193
+ total = 0
194
+ v.split(":").each_with_index do |n, e|
195
+ total += (is_float ? n.to_f : n.to_i) * (60**(e - 2).abs)
196
+ end
197
+ total
198
+ end
199
+
200
+
201
+ end
202
+
203
+ def initialize(schema: :compat_11)
204
+ @schema = schema
205
+ end
206
+
207
+ def materialize(yaml)
208
+ yaml = Yeptris.read_input(yaml)
209
+ yaml = yaml.to_s
210
+ flat, arena = Materializer.drain(yaml, schema: @schema)
211
+ walk(flat, arena)
212
+ end
213
+
214
+ private
215
+
216
+ # The stack machine: containers on a stack, a pending-key slot per
217
+ # open mapping, anchors by name (first definition wins; an alias
218
+ # yields the SAME Ruby object — identity preserved). Merge keys
219
+ # (<<) resolve inline: existing keys win, sequences merge in
220
+ # order — Psych load-time semantics.
221
+ #
222
+ # flat: one Integer per unpack field, 12 per record:
223
+ # 0 type, 1 style, 2 flags, 3 tag_id, 4 line, 5 col, 6 value_off,
224
+ # 7 value_len, 8 anchor_off, 9 anchor_len, 10 tag_off, 11 tag_len.
225
+ def walk(flat, arena)
226
+ docs = []
227
+ stack = []
228
+ anchors = {}
229
+ pending_key = []
230
+ pending_key_merge = []
231
+ # per open container: the map to merge into when this container
232
+ # closes (inline `<<:` values), nil otherwise
233
+ merge_target = []
234
+
235
+ # the arena is UTF-8 by construction (validated at parse), so
236
+ # forcing its encoding ONCE makes every slice UTF-8 for free —
237
+ # no per-scalar force_encoding
238
+ arena.force_encoding(Encoding::UTF_8)
239
+ # (each_slice DESTRUCTURING measured SLOWER than plain indexing
240
+ # here: it allocates a 12-slot Array per record. Index away.)
241
+ i = 0
242
+ n = flat.length
243
+ while i < n
244
+ type = flat[i]
245
+ case type
246
+ when SCALAR
247
+ # a plain, resolver-tagged '<<' (TAG_MERGE) merges; a
248
+ # QUOTED '<<' is a literal key — Psych merges on the tag,
249
+ # not the text
250
+ tag_id = flat[i + 3]
251
+ v = Materializer.scan_by_tag(
252
+ arena[flat[i + 6], flat[i + 7]], tag_id, (flat[i + 2] & EF_IMPLICIT) != 0
253
+ )
254
+ l = flat[i + 9]
255
+ anchors[arena[flat[i + 8], l]] = v if l != 0
256
+ # place(): the scalar fast path inlined — the overwhelming
257
+ # majority of events land here
258
+ if stack.empty?
259
+ docs[-1] = v
260
+ else
261
+ parent = stack.last
262
+ if parent.is_a?(Hash)
263
+ if (key = pending_key[-1]).nil?
264
+ pending_key[-1] = v
265
+ pending_key_merge[-1] = flat[i + 3] == TAG_MERGE
266
+ else
267
+ pending_key[-1] = nil
268
+ if pending_key_merge[-1]
269
+ merge_into(parent, v)
270
+ else
271
+ parent[key] = v
272
+ end
273
+ end
274
+ else
275
+ parent.push(v)
276
+ end
277
+ end
278
+ when MAPPING_START
279
+ h = {}
280
+ l = flat[i + 9]
281
+ anchors[arena[flat[i + 8], l]] = h if l != 0
282
+ merge_target.push(place(docs, stack, pending_key, pending_key_merge, h))
283
+ stack.push(h)
284
+ pending_key.push(nil)
285
+ pending_key_merge.push(nil)
286
+ when SEQUENCE_START
287
+ a = []
288
+ l = flat[i + 9]
289
+ anchors[arena[flat[i + 8], l]] = a if l != 0
290
+ merge_target.push(place(docs, stack, pending_key, pending_key_merge, a))
291
+ stack.push(a)
292
+ pending_key.push(nil)
293
+ pending_key_merge.push(nil)
294
+ when ALIAS
295
+ name = arena[flat[i + 6], flat[i + 7]]
296
+ raise Yeptris::ParseError, "unknown anchor: #{name.inspect}" unless anchors.key?(name)
297
+
298
+ place(docs, stack, pending_key, pending_key_merge, anchors[name])
299
+ when MAPPING_END, SEQUENCE_END
300
+ done = stack.pop
301
+ pending_key.pop
302
+ pending_key_merge.pop
303
+ target = merge_target.pop
304
+ merge_into(target, done) if target
305
+ when DOCUMENT_START
306
+ docs.push(nil)
307
+ end
308
+ i += FIELDS
309
+ end
310
+ docs
311
+ end
312
+
313
+ # Attaches obj: as the current document's root when nothing is
314
+ # open, as a sequence entry, or as the pending mapping key/value.
315
+ # Returns the merge TARGET when obj is a container placed under a
316
+ # "<<" key — the caller defers the merge to the container's END
317
+ # (an inline map's contents arrive after its start event); scalar
318
+ # and alias merges apply immediately.
319
+ def place(docs, stack, pending_key, pending_key_merge, obj)
320
+ if stack.empty?
321
+ docs[-1] = obj
322
+ return nil
323
+ end
324
+ parent = stack.last
325
+ if parent.is_a?(Hash)
326
+ if pending_key.last.nil?
327
+ pending_key[-1] = obj
328
+ pending_key_merge[-1] = false
329
+ return nil
330
+ end
331
+ key = pending_key.last
332
+ pending_key[-1] = nil
333
+ if pending_key_merge[-1]
334
+ merge_into(parent, obj)
335
+ parent
336
+ else
337
+ parent[key] = obj
338
+ nil
339
+ end
340
+ else # Array
341
+ parent.push(obj)
342
+ nil
343
+ end
344
+ end
345
+
346
+ end
347
+ end
Binary file