yeptris 0.2.3.1 → 0.3.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: 7e7e8a45fb4c7fd7af6bd778b16f1078495940f2c0d91e04c868108646cb6b77
4
- data.tar.gz: de46c9de2194fc0beeb732f3b6063552170a54fd3a0ec4cb2c9b6221e2b8b905
3
+ metadata.gz: 9bfaf328e06609c6e368624b78b6e474f90c7d082512cf0daf1a7d80f843a1fa
4
+ data.tar.gz: 907187b75e0f02aba15d0e1c0233e2bfd1a63424748d485c90021ef5982b178e
5
5
  SHA512:
6
- metadata.gz: 4e73dd947751f73310a9d7fc74aed2e2550fbd24fd6b01ceeff5f87cf18e29ab388bc7993b63b0c1edf65c19d5ec086f26a30360dec8debcb7dbfec65c2b86da
7
- data.tar.gz: a5acc0eb5856e874548186f0bcb2ae063d98693053f9c33904c9c337c3139e8be729bedc5cbb0b4997d63d815ace715777ca8d3fa245fc0a9342854e03379b99
6
+ metadata.gz: 4db215da8852d32bff2177c20c0b2e56add31949806bff89dfb1bbc3e9de6566bcdaeaf6d88c8ee379b05dfb49a09514348943ddf6c0f038272db5dccf96e28c
7
+ data.tar.gz: 710e3e89f4463a49b8ff4e4e823f30b23be01c2951cc45711412fe88436df4dc597df2992121ca35e6a997171bf007a24d807181cad8a32c68d15f995726f7f4
@@ -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,17 @@ 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, four bulk columns, spans borrow
64
+ # the caller's string (no arena copy, no second validating parse).
65
+ class JsonTape < ::FFI::Struct
66
+ layout :count, :size_t, :kinds, :pointer, :offs, :pointer,
67
+ :lens, :pointer, :vals, :pointer, :int_min, :int64, :_block, :pointer
68
+ end
69
+
70
+ attach_function :yeptris_parse_json_tape, %i[pointer size_t pointer], :int
71
+ attach_function :yeptris_tape_free, [:pointer], :void
72
+
62
73
  attach_function :yeptris_document_free, [:yeptris_document], :void
63
74
  attach_function :yeptris_document_count, [:yeptris_document], :size_t
64
75
  attach_function :yeptris_document_root, [:yeptris_document, :size_t], :yeptris_node
@@ -174,6 +185,15 @@ module Yeptris
174
185
  %i[yeptris_document pointer pointer], :pointer
175
186
  attach_function :yeptris_serialize_json, %i[yeptris_document pointer], :pointer
176
187
 
188
+ # compact JSON generation (JSON.generate's shape); absent on
189
+ # libraries before v0.2.5 — the JSON surface feature-detects
190
+ JSON_EX = begin
191
+ attach_function :yeptris_serialize_json_ex, %i[yeptris_document pointer int], :pointer
192
+ true
193
+ rescue ::FFI::NotFoundError
194
+ false
195
+ end
196
+
177
197
  # Owned char* results (serialize*): one reader, freed exactly once.
178
198
  # The buffers are plain malloc'd C memory (the header contract says
179
199
  # "caller frees"), so the release is libc free.
data/lib/yeptris/json.rb CHANGED
@@ -36,7 +36,152 @@ 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_BOOL = 2
50
+ T_INT = 3
51
+ T_FLOAT = 4
52
+ T_STR = 5
53
+ T_SEQ_OPEN = 6
54
+ T_MAP_OPEN = 7
55
+ T_CLOSE = 8
56
+
57
+ # The tape keeps RAW string spans (escapes untouched — the C
58
+ # contract); the walk decodes them here. The hot path (no
59
+ # backslash) returns the slice untouched.
60
+ SIMPLE_ESCAPES = {
61
+ '"' => '"'.b, "\\" => "\\".b, "/" => "/".b, "b" => "\b".b, "f" => "\f".b,
62
+ "n" => "\n".b, "r" => "\r".b, "t" => "\t".b
63
+ }.freeze
64
+
65
+ def decode_span(src, off, len)
66
+ s = src.byteslice(off, len)
67
+ s.force_encoding(Encoding::UTF_8)
68
+ return s unless s.include?("\\")
69
+
70
+ b = s.b
71
+ out = +"".b
72
+ i = 0
73
+ n = b.bytesize
74
+ while i < n
75
+ c = b.getbyte(i)
76
+ if c != 0x5C || i + 1 >= n
77
+ out << c
78
+ i += 1
79
+ next
80
+ end
81
+ e = b.getbyte(i + 1)
82
+ if e != 0x75 # simple escape
83
+ ch = e.chr
84
+ out << (SIMPLE_ESCAPES[ch] || ch)
85
+ i += 2
86
+ next
87
+ end
88
+ cp = b.byteslice(i + 2, 4).to_i(16)
89
+ if cp >= 0xD800 && cp <= 0xDBFF && i + 12 <= n &&
90
+ b.getbyte(i + 6) == 0x5C && b.getbyte(i + 7) == 0x75
91
+ lo = b.byteslice(i + 8, 4).to_i(16)
92
+ if lo >= 0xDC00 && lo <= 0xDFFF
93
+ cp = 0x10000 + ((cp - 0xD800) << 10) + (lo - 0xDC00)
94
+ i += 12
95
+ else
96
+ i += 6
97
+ end
98
+ else
99
+ i += 6
100
+ end
101
+ out << [cp].pack("U").b
102
+ end
103
+ out.force_encoding(Encoding::UTF_8)
104
+ end
105
+
106
+ def tape_engine(source)
107
+ tape = ::Yeptris::FFI::JsonTape.new
108
+ rc = ::Yeptris::FFI.yeptris_parse_json_tape(source, source.bytesize, tape)
109
+ raise ParseError, ::Yeptris::FFI.last_error_message if rc != ::Yeptris::FFI::OK
110
+
111
+ begin
112
+ walk_tape(source, tape)
113
+ ensure
114
+ ::Yeptris::FFI.yeptris_tape_free(tape)
115
+ end
116
+ end
117
+
118
+ def walk_tape(src, tape)
119
+ n = tape[:count]
120
+ kinds = tape[:kinds].read_bytes(n).unpack("C*")
121
+ offs = tape[:offs].read_bytes(n * 4).unpack("V*")
122
+ lens = tape[:lens].read_bytes(n * 4).unpack("V*")
123
+ raw = tape[:vals].read_bytes(n * 8)
124
+ vals_i = raw.unpack("q<*")
125
+ vals_f = raw.unpack("E*")
126
+ # any integer text beyond int64: every INT rebuilds from its span
127
+ # (exact Bignum; JSON.parse parity)
128
+ exact_ints = !tape[:int_min].zero?
129
+ utf8 = src.encoding == Encoding::UTF_8
130
+
131
+ docs = [nil] # record 0 (DOC) pre-consumed — the root's slot
132
+ stack = []
133
+ key_sets = STRICT_DUPLICATE_KEYS ? [{}] : nil
134
+ pending_key = nil
135
+ i = 1
136
+ while i < n
137
+ case kinds[i]
138
+ when T_SEQ_OPEN
139
+ place(docs, stack, pending_key) { [] }
140
+ pending_key = nil
141
+ when T_MAP_OPEN
142
+ key_sets&.push({})
143
+ place(docs, stack, pending_key) { {} }
144
+ pending_key = nil
145
+ when T_CLOSE
146
+ stack.pop
147
+ key_sets&.pop
148
+ when T_STR
149
+ text = lens[i] == 0 ? +"".force_encoding(Encoding::UTF_8) : decode_span(src, offs[i], lens[i])
150
+ if pending_key.nil? && !stack.empty? && stack.last.is_a?(Hash)
151
+ if key_sets && key_sets.last.key?(text)
152
+ raise ParseError, %(duplicate key "#{text}" in JSON object)
153
+ end
154
+ key_sets&.last&.store(text, true)
155
+ pending_key = text
156
+ else
157
+ place(docs, stack, pending_key) { text }
158
+ pending_key = nil
159
+ end
160
+ when T_INT
161
+ if exact_ints
162
+ place(docs, stack, pending_key) { Integer(src.byteslice(offs[i], lens[i]), 10) }
163
+ else
164
+ v = vals_i[i]
165
+ place(docs, stack, pending_key) { v }
166
+ end
167
+ pending_key = nil
168
+ when T_FLOAT
169
+ v = vals_f[i]
170
+ place(docs, stack, pending_key) { v }
171
+ pending_key = nil
172
+ when T_BOOL
173
+ v = vals_i[i] == 1
174
+ place(docs, stack, pending_key) { v }
175
+ pending_key = nil
176
+ when T_NULL
177
+ place(docs, stack, pending_key) { nil }
178
+ pending_key = nil
179
+ else
180
+ raise Error, "internal: impossible tape record #{kinds[i]}"
181
+ end
182
+ i += 1
183
+ end
184
+ docs.empty? ? nil : docs.first
40
185
  end
41
186
 
42
187
  # The always-available engine: the strict-JSON validator gates
@@ -143,6 +288,81 @@ module Yeptris
143
288
  docs.empty? ? nil : docs.first
144
289
  end
145
290
 
291
+ # ---- generation (issue #81) ------------------------------------------
292
+ #
293
+ # `dump` targets JSON.generate semantics for the core types: the
294
+ # object walk reuses YAML.dump's packed-entry bulk builder (one
295
+ # FFI build call), then the strict-JSON writer serializes —
296
+ # Strings ride DOUBLE_QUOTED (a "42" stays quoted; numbers, bools
297
+ # and null stay plain), Symbols become their name.
298
+ class DumpError < Error; end
299
+
300
+ def dump(obj)
301
+ parts = String.new(encoding: Encoding::BINARY,
302
+ capacity: 12 * 16) # 12-byte entries, appended in place
303
+ blob = String.new(encoding: Encoding::BINARY)
304
+ off = [0]
305
+ place_obj(obj, parts, blob, off)
306
+ doc = ::Yeptris::Document.create
307
+ # FFI passes String args BY REFERENCE — no MemoryPointer copies
308
+ rc = doc.build_entries(parts, parts.bytesize / 12, blob, blob.bytesize)
309
+ raise DumpError, "document_build failed: #{rc}" unless rc == ::Yeptris::FFI::OK
310
+ out = ::Yeptris::FFI::JSON_EX ? doc.serialize_json_compact : doc.serialize_json
311
+ # JSON.generate emits no trailing newline (the document writer does)
312
+ out.end_with?("\n") ? out[0, out.bytesize - 1] : out
313
+ ensure
314
+ doc&.free
315
+ end
316
+
317
+ F = ::Yeptris::FFI
318
+ MAP_E = [F::BUILD_MAP, 0, 0, 0].pack("CCx2VV").freeze
319
+ SEQ_E = [F::BUILD_SEQ, 0, 0, 0].pack("CCx2VV").freeze
320
+ END_E = [F::BUILD_END, 0, 0, 0].pack("CCx2VV").freeze
321
+ DQ = F::STYLE_DOUBLE_QUOTED
322
+ PL = F::STYLE_PLAIN
323
+ SC_DQ = [F::BUILD_SCALAR, DQ].pack("CCx2").freeze
324
+ SC_PL = [F::BUILD_SCALAR, PL].pack("CCx2").freeze
325
+
326
+ def place_obj(obj, parts, blob, off)
327
+ case obj
328
+ when Hash
329
+ parts << MAP_E
330
+ obj.each do |k, v|
331
+ key = k.is_a?(String) ? k : k.is_a?(Symbol) ? k.name : k.to_s
332
+ scalar_json(key, SC_DQ, parts, blob, off)
333
+ place_obj(v, parts, blob, off)
334
+ end
335
+ parts << END_E
336
+ when Array
337
+ parts << SEQ_E
338
+ obj.each { |e| place_obj(e, parts, blob, off) }
339
+ parts << END_E
340
+ when String
341
+ scalar_json(obj, SC_DQ, parts, blob, off)
342
+ when Symbol
343
+ scalar_json(obj.name, SC_DQ, parts, blob, off)
344
+ when Integer, Float
345
+ scalar_json(obj.to_s, SC_PL, parts, blob, off)
346
+ when true
347
+ scalar_json("true", SC_PL, parts, blob, off)
348
+ when false
349
+ scalar_json("false", SC_PL, parts, blob, off)
350
+ when nil
351
+ scalar_json("null", SC_PL, parts, blob, off)
352
+ when Date, Time
353
+ scalar_json(obj.iso8601, SC_DQ, parts, blob, off)
354
+ else
355
+ raise DumpError, "cannot dump #{obj.class}: unsupported object " \
356
+ "(JSON.generate calls to_json on custom types)"
357
+ end
358
+ end
359
+
360
+ def scalar_json(text, prefix, parts, blob, off)
361
+ parts << (prefix + [off[0], text.bytesize].pack("VV"))
362
+ blob << text
363
+ off[0] += text.bytesize
364
+ end
365
+
146
366
  def place(docs, stack, key)
147
367
  v = yield
148
368
  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.3.1".freeze
6
+ VERSION = "0.3.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
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.3.1
4
+ version: 0.3.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-13 00:00:00.000000000 Z
11
+ date: 2026-09-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ffi