yeptris 0.2.4.1 → 0.4.0.1

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: 17b8f09d9419550f61e2bd1b1c9afd7b3b0b9d4924ad9f294ef5c17a58bc89ce
4
- data.tar.gz: 47b03032432acdac62836c9cab157df1b6d6aee93c5390f05211956f1f987114
3
+ metadata.gz: 8d15146500e202e6c6ce0b97073284d0ac31f78df2b0260c2bac7b1e733747df
4
+ data.tar.gz: 8521281dbdaffdd88551de237741f773a02381613a4b9640c34d1b46dd1e9369
5
5
  SHA512:
6
- metadata.gz: 4f3cbf301048a1e1eff62fde861639feccb299ef2ea1a57952f379ed37d08d4c9e302afaae11cfc5c31a5168b709a76823de30d2a594052f5b5583bbd314caf7
7
- data.tar.gz: cc04e336c871400914dafc76e55eb76236ecf20c2ad7b831d5f93ec3af023069c1c5bb0fb967595a24268f6e9215590e80bc43e4c0bbe02ae79918d9756b63d8
6
+ metadata.gz: c2b9eaa728ead723567966836a16eee18a48f5b5bbf8c2d5f1f3dc4d7cdcee69ce237cd19c7eabf4fbb55abf1e72437697ec6f7fe96e3704b342fb7a8d3da8ff
7
+ data.tar.gz: a857fd5b3e2d86f2b8f142263cb609a00eb21458a5b035b681fa0c9f24097ed11f7162c69ad20b3eaf8faea47d49e03d999377f8bacde847416b79b202469e24
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Builds the native materializer DLL for the CURRENT Ruby and
4
+ # installs it under its minor-versioned name
5
+ # (lib/yeptris/native-<major.minor>.so).
6
+ #
7
+ # The leptris-ruby Windows lesson (#207/#227): a PE DLL cannot
8
+ # resolve Ruby imports lazily — it must bind the build Ruby's
9
+ # x64-ucrt-rubyNNN.dll. One DLL per supported minor therefore ships
10
+ # in the Windows platform gems, and the loader in lib/yeptris.rb
11
+ # picks by RUBY_VERSION at require. Ruby 3.3 has no arm64 build:
12
+ # that cell ships no artifact and falls back loudly to the FFI
13
+ # ladder.
14
+ #
15
+ # Standalone by design: the release workflow runs this under each
16
+ # Ruby minor (3.3/3.4/4.0) with no bundler context.
17
+
18
+ require "rbconfig"
19
+ require "fileutils"
20
+
21
+ root = File.expand_path("..", __dir__)
22
+ ext_dir = File.join(root, "ext", "yeptris_native")
23
+ minor = RUBY_VERSION[/\A\d+\.\d+/]
24
+
25
+ Dir.chdir(ext_dir) do
26
+ system(RbConfig.ruby, "extconf.rb") or abort "extconf failed under #{RUBY_VERSION}"
27
+ # mkmf's link binds the CURRENT Ruby's runtime DLL — exactly
28
+ # what the versioned naming is for.
29
+ success = system("make")
30
+ abort "make failed under #{RUBY_VERSION}" unless success
31
+ so = Dir.glob("native.{so,dll}").first
32
+ abort "native bundle not produced under #{RUBY_VERSION}" unless so
33
+ dest = File.join(root, "lib", "yeptris", "native-#{minor}.so")
34
+ # The artifact must reference only this minor's Ruby DLL.
35
+ imported = `strings #{so} 2>/dev/null`[/[a-z0-9-]*ruby\d{3,}\.dll/i]
36
+ if imported && !imported.include?("ruby#{minor.delete('.')}")
37
+ abort "#{so} imports #{imported} but was built under #{RUBY_VERSION} — refuse to mis-name it"
38
+ end
39
+ FileUtils.cp(so, dest)
40
+ puts "Installed native materializer for Ruby #{minor} -> #{dest} (#{imported || 'no ruby dll string found'})"
41
+ end
@@ -153,6 +153,11 @@ class Yeptris::Document
153
153
  Yeptris::FFI::Owned.string(ptr, len)
154
154
  end
155
155
 
156
+ def serialize_json_compact
157
+ len = ::FFI::MemoryPointer.new(:uint64)
158
+ Yeptris::FFI::Owned.string(Yeptris::FFI.yeptris_serialize_json_ex(@c_ptr, len, 1), len)
159
+ end
160
+
156
161
  def serialize_json
157
162
  ensure_alive!
158
163
  len = ::FFI::MemoryPointer.new(:uint64)
data/lib/yeptris/ffi.rb CHANGED
@@ -59,6 +59,21 @@ module Yeptris
59
59
  %i[pointer size_t pointer yeptris_status_out], :yeptris_document
60
60
  attach_function :yeptris_parse_json, %i[pointer size_t yeptris_status_out], :yeptris_document
61
61
 
62
+ # The JSON tape (TODO.restructure/85; the JSON surface's load
63
+ # engine — issue #81): ONE call, three bulk columns, spans borrow
64
+ # the caller's string (no arena copy, no second validating parse).
65
+ # v2 records: numbers are spans at parse; yeptris_tape_convert
66
+ # materializes them in one bulk call.
67
+ class JsonTape < ::FFI::Struct
68
+ layout :count, :size_t, :kinds, :pointer, :offs, :pointer,
69
+ :lens, :pointer, :int_min, :int64, :_src, :pointer, :_srclen, :size_t,
70
+ :_block, :pointer
71
+ end
72
+
73
+ attach_function :yeptris_parse_json_tape, %i[pointer size_t pointer], :int
74
+ attach_function :yeptris_tape_free, [:pointer], :void
75
+ attach_function :yeptris_tape_convert, %i[pointer size_t size_t pointer pointer], :size_t
76
+
62
77
  attach_function :yeptris_document_free, [:yeptris_document], :void
63
78
  attach_function :yeptris_document_count, [:yeptris_document], :size_t
64
79
  attach_function :yeptris_document_root, [:yeptris_document, :size_t], :yeptris_node
@@ -174,6 +189,15 @@ module Yeptris
174
189
  %i[yeptris_document pointer pointer], :pointer
175
190
  attach_function :yeptris_serialize_json, %i[yeptris_document pointer], :pointer
176
191
 
192
+ # compact JSON generation (JSON.generate's shape); absent on
193
+ # libraries before v0.2.5 — the JSON surface feature-detects
194
+ JSON_EX = begin
195
+ attach_function :yeptris_serialize_json_ex, %i[yeptris_document pointer int], :pointer
196
+ true
197
+ rescue ::FFI::NotFoundError
198
+ false
199
+ end
200
+
177
201
  # Owned char* results (serialize*): one reader, freed exactly once.
178
202
  # The buffers are plain malloc'd C memory (the header contract says
179
203
  # "caller frees"), so the release is libc free.
data/lib/yeptris/json.rb CHANGED
@@ -36,7 +36,157 @@ module Yeptris
36
36
  raise ParseError, e.message
37
37
  end
38
38
  end
39
- strict_fallback(source)
39
+ tape_engine(source)
40
+ end
41
+
42
+ # The tape engine (issue #81): ONE call validates and materializes
43
+ # the record stream; the walk reads four bulk columns and slices
44
+ # strings straight out of the caller's source — no gate document,
45
+ # no recorder transform, no arena copy (the drain path paid all
46
+ # three, which is where medium/large throughput went).
47
+ T_DOC = 0
48
+ T_NULL = 1
49
+ T_TRUE = 2
50
+ T_FALSE = 3
51
+ T_INT = 4
52
+ T_FLOAT = 5
53
+ T_STR = 6
54
+ T_SEQ_OPEN = 7
55
+ T_MAP_OPEN = 8
56
+ T_CLOSE = 9
57
+
58
+ # The tape keeps RAW string spans (escapes untouched — the C
59
+ # contract); the walk decodes them here. The hot path (no
60
+ # backslash) returns the slice untouched.
61
+ SIMPLE_ESCAPES = {
62
+ '"' => '"'.b, "\\" => "\\".b, "/" => "/".b, "b" => "\b".b, "f" => "\f".b,
63
+ "n" => "\n".b, "r" => "\r".b, "t" => "\t".b
64
+ }.freeze
65
+
66
+ def decode_span(src, off, len)
67
+ s = src.byteslice(off, len)
68
+ s.force_encoding(Encoding::UTF_8)
69
+ return s unless s.include?("\\")
70
+
71
+ b = s.b
72
+ out = +"".b
73
+ i = 0
74
+ n = b.bytesize
75
+ while i < n
76
+ c = b.getbyte(i)
77
+ if c != 0x5C || i + 1 >= n
78
+ out << c
79
+ i += 1
80
+ next
81
+ end
82
+ e = b.getbyte(i + 1)
83
+ if e != 0x75 # simple escape
84
+ ch = e.chr
85
+ out << (SIMPLE_ESCAPES[ch] || ch)
86
+ i += 2
87
+ next
88
+ end
89
+ cp = b.byteslice(i + 2, 4).to_i(16)
90
+ if cp >= 0xD800 && cp <= 0xDBFF && i + 12 <= n &&
91
+ b.getbyte(i + 6) == 0x5C && b.getbyte(i + 7) == 0x75
92
+ lo = b.byteslice(i + 8, 4).to_i(16)
93
+ if lo >= 0xDC00 && lo <= 0xDFFF
94
+ cp = 0x10000 + ((cp - 0xD800) << 10) + (lo - 0xDC00)
95
+ i += 12
96
+ else
97
+ i += 6
98
+ end
99
+ else
100
+ i += 6
101
+ end
102
+ out << [cp].pack("U").b
103
+ end
104
+ out.force_encoding(Encoding::UTF_8)
105
+ end
106
+
107
+ def tape_engine(source)
108
+ tape = ::Yeptris::FFI::JsonTape.new
109
+ rc = ::Yeptris::FFI.yeptris_parse_json_tape(source, source.bytesize, tape)
110
+ raise ParseError, ::Yeptris::FFI.last_error_message if rc != ::Yeptris::FFI::OK
111
+
112
+ begin
113
+ walk_tape(source, tape)
114
+ ensure
115
+ ::Yeptris::FFI.yeptris_tape_free(tape)
116
+ end
117
+ end
118
+
119
+ def walk_tape(src, tape)
120
+ n = tape[:count]
121
+ kinds = tape[:kinds].read_bytes(n).unpack("C*")
122
+ offs = tape[:offs].read_bytes(n * 4).unpack("V*")
123
+ lens = tape[:lens].read_bytes(n * 4).unpack("V*")
124
+ # v2: parse records number spans only — ONE bulk convert call
125
+ # materializes them (per-number FFI calls would sink the ladder)
126
+ ivp = ::FFI::MemoryPointer.new(:int64, n, true)
127
+ dvp = ::FFI::MemoryPointer.new(:double, n, true)
128
+ ::Yeptris::FFI.yeptris_tape_convert(tape, 0, n, ivp, dvp)
129
+ vals_i = ivp.read_bytes(n * 8).unpack("q<*")
130
+ vals_f = dvp.read_bytes(n * 8).unpack("E*")
131
+ # any integer text beyond int64: every INT rebuilds from its span
132
+ # (exact Bignum; JSON.parse parity) — int_min is set by the
133
+ # convert call above
134
+ exact_ints = !tape[:int_min].zero?
135
+ utf8 = src.encoding == Encoding::UTF_8
136
+
137
+ docs = [nil] # record 0 (DOC) pre-consumed — the root's slot
138
+ stack = []
139
+ key_sets = STRICT_DUPLICATE_KEYS ? [{}] : nil
140
+ pending_key = nil
141
+ i = 1
142
+ while i < n
143
+ case kinds[i]
144
+ when T_SEQ_OPEN
145
+ place(docs, stack, pending_key) { [] }
146
+ pending_key = nil
147
+ when T_MAP_OPEN
148
+ key_sets&.push({})
149
+ place(docs, stack, pending_key) { {} }
150
+ pending_key = nil
151
+ when T_CLOSE
152
+ stack.pop
153
+ key_sets&.pop
154
+ when T_STR
155
+ text = lens[i] == 0 ? +"".force_encoding(Encoding::UTF_8) : decode_span(src, offs[i], lens[i])
156
+ if pending_key.nil? && !stack.empty? && stack.last.is_a?(Hash)
157
+ if key_sets && key_sets.last.key?(text)
158
+ raise ParseError, %(duplicate key "#{text}" in JSON object)
159
+ end
160
+ key_sets&.last&.store(text, true)
161
+ pending_key = text
162
+ else
163
+ place(docs, stack, pending_key) { text }
164
+ pending_key = nil
165
+ end
166
+ when T_INT
167
+ if exact_ints
168
+ place(docs, stack, pending_key) { Integer(src.byteslice(offs[i], lens[i]), 10) }
169
+ else
170
+ v = vals_i[i]
171
+ place(docs, stack, pending_key) { v }
172
+ end
173
+ pending_key = nil
174
+ when T_FLOAT
175
+ v = vals_f[i]
176
+ place(docs, stack, pending_key) { v }
177
+ pending_key = nil
178
+ when T_TRUE, T_FALSE # v2: the kind IS the value
179
+ place(docs, stack, pending_key) { kinds[i] == T_TRUE }
180
+ pending_key = nil
181
+ when T_NULL
182
+ place(docs, stack, pending_key) { nil }
183
+ pending_key = nil
184
+ else
185
+ raise Error, "internal: impossible tape record #{kinds[i]}"
186
+ end
187
+ i += 1
188
+ end
189
+ docs.empty? ? nil : docs.first
40
190
  end
41
191
 
42
192
  # The always-available engine: the strict-JSON validator gates
@@ -143,6 +293,81 @@ module Yeptris
143
293
  docs.empty? ? nil : docs.first
144
294
  end
145
295
 
296
+ # ---- generation (issue #81) ------------------------------------------
297
+ #
298
+ # `dump` targets JSON.generate semantics for the core types: the
299
+ # object walk reuses YAML.dump's packed-entry bulk builder (one
300
+ # FFI build call), then the strict-JSON writer serializes —
301
+ # Strings ride DOUBLE_QUOTED (a "42" stays quoted; numbers, bools
302
+ # and null stay plain), Symbols become their name.
303
+ class DumpError < Error; end
304
+
305
+ def dump(obj)
306
+ parts = String.new(encoding: Encoding::BINARY,
307
+ capacity: 12 * 16) # 12-byte entries, appended in place
308
+ blob = String.new(encoding: Encoding::BINARY)
309
+ off = [0]
310
+ place_obj(obj, parts, blob, off)
311
+ doc = ::Yeptris::Document.create
312
+ # FFI passes String args BY REFERENCE — no MemoryPointer copies
313
+ rc = doc.build_entries(parts, parts.bytesize / 12, blob, blob.bytesize)
314
+ raise DumpError, "document_build failed: #{rc}" unless rc == ::Yeptris::FFI::OK
315
+ out = ::Yeptris::FFI::JSON_EX ? doc.serialize_json_compact : doc.serialize_json
316
+ # JSON.generate emits no trailing newline (the document writer does)
317
+ out.end_with?("\n") ? out[0, out.bytesize - 1] : out
318
+ ensure
319
+ doc&.free
320
+ end
321
+
322
+ F = ::Yeptris::FFI
323
+ MAP_E = [F::BUILD_MAP, 0, 0, 0].pack("CCx2VV").freeze
324
+ SEQ_E = [F::BUILD_SEQ, 0, 0, 0].pack("CCx2VV").freeze
325
+ END_E = [F::BUILD_END, 0, 0, 0].pack("CCx2VV").freeze
326
+ DQ = F::STYLE_DOUBLE_QUOTED
327
+ PL = F::STYLE_PLAIN
328
+ SC_DQ = [F::BUILD_SCALAR, DQ].pack("CCx2").freeze
329
+ SC_PL = [F::BUILD_SCALAR, PL].pack("CCx2").freeze
330
+
331
+ def place_obj(obj, parts, blob, off)
332
+ case obj
333
+ when Hash
334
+ parts << MAP_E
335
+ obj.each do |k, v|
336
+ key = k.is_a?(String) ? k : k.is_a?(Symbol) ? k.name : k.to_s
337
+ scalar_json(key, SC_DQ, parts, blob, off)
338
+ place_obj(v, parts, blob, off)
339
+ end
340
+ parts << END_E
341
+ when Array
342
+ parts << SEQ_E
343
+ obj.each { |e| place_obj(e, parts, blob, off) }
344
+ parts << END_E
345
+ when String
346
+ scalar_json(obj, SC_DQ, parts, blob, off)
347
+ when Symbol
348
+ scalar_json(obj.name, SC_DQ, parts, blob, off)
349
+ when Integer, Float
350
+ scalar_json(obj.to_s, SC_PL, parts, blob, off)
351
+ when true
352
+ scalar_json("true", SC_PL, parts, blob, off)
353
+ when false
354
+ scalar_json("false", SC_PL, parts, blob, off)
355
+ when nil
356
+ scalar_json("null", SC_PL, parts, blob, off)
357
+ when Date, Time
358
+ scalar_json(obj.iso8601, SC_DQ, parts, blob, off)
359
+ else
360
+ raise DumpError, "cannot dump #{obj.class}: unsupported object " \
361
+ "(JSON.generate calls to_json on custom types)"
362
+ end
363
+ end
364
+
365
+ def scalar_json(text, prefix, parts, blob, off)
366
+ parts << (prefix + [off[0], text.bytesize].pack("VV"))
367
+ blob << text
368
+ off[0] += text.bytesize
369
+ end
370
+
146
371
  def place(docs, stack, key)
147
372
  v = yield
148
373
  if stack.empty?
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.2.4.1".freeze
6
+ VERSION = "0.4.0.1".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
@@ -70,8 +70,21 @@ end
70
70
  # Optional C-API materializer (TODO.restructure/22): fused visit →
71
71
  # Ruby objects via the Ruby C API. Feature-detected — LoadError leaves
72
72
  # the FFI ladder (Marshal → columns → records) as the sole path.
73
+ # Windows names the artifact per Ruby minor (the leptris-ruby #207/
74
+ # #227 lesson): a PE DLL must bind its build Ruby's runtime, so one
75
+ # DLL per supported minor ships and RUBY_VERSION selects. A missing
76
+ # cell (Ruby 3.3 arm64 has no build) falls back LOUDLY to the ladder.
73
77
  begin
74
- require "yeptris/native"
75
- rescue LoadError
78
+ if Gem.win_platform?
79
+ require "yeptris/native-#{RUBY_VERSION[/\A\d+\.\d+/]}"
80
+ else
81
+ require "yeptris/native"
82
+ end
83
+ rescue LoadError => e
84
+ if Gem.win_platform?
85
+ warn "yeptris: no precompiled native materializer for Ruby " \
86
+ "#{RUBY_VERSION[/\A\d+\.\d+/]} on this platform " \
87
+ "(#{e.message}); the FFI ladder carries the load"
88
+ end
76
89
  # FFI ladder only
77
90
  end
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.2.4.1
4
+ version: 0.4.0.1
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-09-14 00:00:00.000000000 Z
11
+ date: 2026-09-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ffi
@@ -34,6 +34,7 @@ extensions: []
34
34
  extra_rdoc_files: []
35
35
  files:
36
36
  - README.adoc
37
+ - ext/build_windows_native.rb
37
38
  - ext/yeptris_native/extconf.rb
38
39
  - ext/yeptris_native/json_ruby.c
39
40
  - ext/yeptris_native/yeptris_native.c