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 +4 -4
- data/lib/yeptris/document.rb +5 -0
- data/lib/yeptris/ffi.rb +20 -0
- data/lib/yeptris/json.rb +221 -1
- data/lib/yeptris.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9bfaf328e06609c6e368624b78b6e474f90c7d082512cf0daf1a7d80f843a1fa
|
|
4
|
+
data.tar.gz: 907187b75e0f02aba15d0e1c0233e2bfd1a63424748d485c90021ef5982b178e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4db215da8852d32bff2177c20c0b2e56add31949806bff89dfb1bbc3e9de6566bcdaeaf6d88c8ee379b05dfb49a09514348943ddf6c0f038272db5dccf96e28c
|
|
7
|
+
data.tar.gz: 710e3e89f4463a49b8ff4e4e823f30b23be01c2951cc45711412fe88436df4dc597df2992121ca35e6a997171bf007a24d807181cad8a32c68d15f995726f7f4
|
data/lib/yeptris/document.rb
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
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-
|
|
11
|
+
date: 2026-09-15 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: ffi
|