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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 1f9152c76936119e3fcc83cc8510e15debf741cc25d22229b225d1023f330cec
4
+ data.tar.gz: 6845a69bcb669246e5445601e117a9abfcd96200476fcfd1c41e9b5040775615
5
+ SHA512:
6
+ metadata.gz: 1927807c5fbb83fbc986d17a35ed35e6c888b52e045482e1cd96d4733b8aff7a9aa6238dd1b11c80c3f9e2da795b81eaa01c75b11861acffff64ca1bfbdb579b
7
+ data.tar.gz: b85ef72350379c4a6e1b5b9ca8fc76aaf0901ea533c05c2dca6223b1eb48679faefe94880bb3d2eb8df6f57dd584c82777af3cffaa37fe3274467b58d562c1b0
data/README.adoc ADDED
@@ -0,0 +1,59 @@
1
+ = yeptris — YAML for Ruby at libleptris speed
2
+
3
+ An FFI-based (no C extension) Ruby YAML library over
4
+ https://github.com/leptris/yeptris[libyeptris] — the YAML counterpart
5
+ of libleptris. Psych-compatible semantics, one shared library, zero
6
+ compilation at install.
7
+
8
+ == Install (development)
9
+
10
+ The native library is vendored in the platform gems (TODO.impl/15
11
+ phase D). For development against a local build:
12
+
13
+ ....
14
+ # the sibling C checkout: ~/src/leptris/yeptris
15
+ cmake -B build -S . -DCMAKE_BUILD_TYPE=Release -DYEPTRIS_BUILD_SHARED=ON
16
+ cmake --build build
17
+
18
+ cd ~/src/leptris/yeptris-ruby
19
+ YEPTRIS_LIB_PATH=../yeptris/build/src/libyeptris.dylib bundle exec rspec
20
+ ....
21
+
22
+ Without `YEPTRIS_LIB_PATH` the spec helper falls back to a vendored
23
+ `lib/platform/<tag>/` copy, then to the sibling checkout's
24
+ `build-validate` — any `libyeptris.{so,dylib,dll}` path works.
25
+
26
+ == Usage
27
+
28
+ [source,ruby]
29
+ ----
30
+ require "yeptris"
31
+
32
+ Yeptris::YAML.load("name: yeptris\nrating: 10\n")
33
+ # => {"name" => "yeptris", "rating" => 10}
34
+
35
+ Yeptris::YAML.dump("name" => "yeptris", tags: [:yaml, :fast])
36
+
37
+ doc = Yeptris::Document.parse(config_yaml)
38
+ doc.root["server"]["port"].to_i
39
+ doc.serialize
40
+ ----
41
+
42
+ `Yeptris::YAML.load` defaults to Psych's YAML 1.1 implicit typing
43
+ (`yes` is `true`, `017` is octal); pass `schema: :core_12` for YAML
44
+ 1.2 core semantics. Dump builds through the library's DOM mutation
45
+ API, so the writer's sizing/escape machinery applies to synthesized
46
+ trees unchanged.
47
+
48
+ Handles are document-scoped: `Document#free` releases everything
49
+ (one C call), a GC finalizer backs it up, and any use after free
50
+ raises `Yeptris::FreedError` — never a segfault.
51
+
52
+ == Roadmap
53
+
54
+ * Phase B: recorder-driven Ruby materialization (bulk event records,
55
+ the FFI tax paid per chunk, not per node) + the `Yeptris::Psych`
56
+ drop-in namespace and the ported Psych suite.
57
+ * Phase C: `.tml` corpora, readonly mode, YAMLTree dumps.
58
+ * Phase D: vendored precompiled platform gems, lockstep versioning
59
+ with the C library, symbol audit task.
@@ -0,0 +1,209 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Yeptris::Document
4
+ attr_reader :c_ptr
5
+
6
+ # Shared between the instance and its GC finalizer (Procs close over
7
+ # variables by reference) — the leptris-ruby double-free fix: the
8
+ # explicit free path and the finalizer both flip the same flag.
9
+ Freed = Struct.new(:state) # :alive | :freed
10
+
11
+ def initialize(c_ptr = nil, freed = Freed.new(:alive))
12
+ @c_ptr = c_ptr
13
+ @freed = freed
14
+ @readonly = false
15
+ # Strong wrapper cache keyed on the C node address: the same C node
16
+ # always yields the same Ruby object (identity for aliases/eql?),
17
+ # cleared at free — no stale entries, no GC-race weak maps.
18
+ @wrapper_cache = {}
19
+ ObjectSpace.define_finalizer(self, self.class.finalize(c_ptr, freed)) unless c_ptr.null?
20
+ end
21
+
22
+ def self.parse(yaml, schema: :core_12, max_depth: 0)
23
+ yaml = yaml.read if yaml.respond_to?(:read)
24
+ yaml = yaml.to_s
25
+ doc =
26
+ if schema == :core_12 && max_depth.zero?
27
+ Yeptris::FFI.yeptris_parse(yaml, yaml.bytesize, nil)
28
+ else
29
+ opts = Yeptris::FFI::ParseOptions.new
30
+ opts[:schema] = schema == :compat_11 ? Yeptris::FFI::SCHEMA_11_COMPAT : Yeptris::FFI::SCHEMA_12_CORE
31
+ opts[:max_depth] = max_depth
32
+ Yeptris::FFI.yeptris_parse_ex(yaml, yaml.bytesize, opts, nil)
33
+ end
34
+ if doc.null?
35
+ # the status out-param is skipped: the thread-local error
36
+ # channel carries the failure detail (measurable on small docs)
37
+ raise Yeptris::ParseError,
38
+ "parse failed: #{Yeptris::FFI.last_error_message}"
39
+ end
40
+ wrap(doc)
41
+ end
42
+
43
+ def self.parse_json(json)
44
+ json = json.read if json.respond_to?(:read)
45
+ json = json.to_s
46
+ doc = Yeptris::FFI.yeptris_parse_json(json, json.bytesize, nil)
47
+ raise Yeptris::ParseError,
48
+ "json parse failed: #{Yeptris::FFI.last_error_message}" if doc.null?
49
+
50
+ wrap(doc)
51
+ end
52
+
53
+ # An empty document for from-scratch construction (TODO.impl/11 p3).
54
+ def self.create
55
+ doc = Yeptris::FFI.yeptris_document_new
56
+ raise Yeptris::Error, "yeptris_document_new failed" if doc.null?
57
+
58
+ wrap(doc)
59
+ end
60
+
61
+ # @api private
62
+ def self.wrap(c_ptr)
63
+ new(c_ptr)
64
+ end
65
+
66
+ def ensure_alive!
67
+ raise Yeptris::FreedError, "document is freed" if @freed.state == :freed
68
+ end
69
+
70
+ def free
71
+ return if @freed.state == :freed
72
+
73
+ @freed.state = :freed
74
+ @wrapper_cache.clear
75
+ Yeptris::FFI.yeptris_document_free(@c_ptr)
76
+ end
77
+
78
+ def freed?
79
+ @freed.state == :freed
80
+ end
81
+
82
+ def readonly!
83
+ @readonly = true
84
+ self
85
+ end
86
+
87
+ def readonly?
88
+ @readonly
89
+ end
90
+
91
+ # @api private — memo table for readonly materialization: node ids
92
+ # that already produced their Ruby object keep it (leptris pattern:
93
+ # readonly documents never change, so the memo is forever valid).
94
+ def readonly_memo
95
+ @readonly_memo ||= {}
96
+ end
97
+
98
+ # @api private — the single Node construction path. Query handles
99
+ # are transient C allocations; the wrapper cache is keyed on the
100
+ # STABLE node id (yeptris_node_id), so the same node always yields
101
+ # the same Ruby object no matter which query produced the handle.
102
+ def wrap_node(c_ptr)
103
+ ensure_alive!
104
+ return nil if c_ptr.null?
105
+
106
+ id = Yeptris::FFI.yeptris_node_id(c_ptr)
107
+ @wrapper_cache[id] ||= Yeptris::Node.new(c_ptr, self)
108
+ end
109
+
110
+ def document_count
111
+ ensure_alive!
112
+ Yeptris::FFI.yeptris_document_count(@c_ptr)
113
+ end
114
+
115
+ # Root node of stream document i (0-based).
116
+ def root(index = 0)
117
+ ensure_alive!
118
+ wrap_node(Yeptris::FFI.yeptris_document_root(@c_ptr, index))
119
+ end
120
+
121
+ # Bulk build (TODO.impl/15 phase D): one call raises the whole
122
+ # tree from a flat entry array + blob (see YAML::BulkBuilder).
123
+ def build_entries(entries, count, blob, blob_len)
124
+ ensure_alive!
125
+ Yeptris::FFI.yeptris_document_build(@c_ptr, entries, count, blob, blob_len)
126
+ end
127
+
128
+ def serialize(canonical: false, best_width: 0)
129
+ ensure_alive!
130
+ len = ::FFI::MemoryPointer.new(:uint64)
131
+ ptr =
132
+ if canonical || best_width.positive?
133
+ opts = Yeptris::FFI::EmitOptions.new
134
+ opts[:size] = Yeptris::FFI::EmitOptions.size
135
+ opts[:canonical] = canonical ? 1 : 0
136
+ opts[:best_width] = best_width
137
+ Yeptris::FFI.yeptris_serialize_ex(@c_ptr, opts, len)
138
+ else
139
+ Yeptris::FFI.yeptris_serialize(@c_ptr, len)
140
+ end
141
+ Yeptris::FFI::Owned.string(ptr, len)
142
+ end
143
+
144
+ def serialize_json
145
+ ensure_alive!
146
+ len = ::FFI::MemoryPointer.new(:uint64)
147
+ Yeptris::FFI::Owned.string(Yeptris::FFI.yeptris_serialize_json(@c_ptr, len), len)
148
+ end
149
+
150
+ def to_s
151
+ serialize
152
+ end
153
+
154
+ # The Ruby object graph of stream document i (Psych-compatible
155
+ # materialization; alias identity preserved via the memo).
156
+ def to_ruby(index = 0)
157
+ ensure_alive!
158
+ r = root(index)
159
+ r.nil? ? nil : r.to_ruby
160
+ end
161
+
162
+ # ---- construction conveniences (TODO.impl/11 phase 3) ----
163
+
164
+ def new_mapping
165
+ ensure_alive!
166
+ wrap_node(Yeptris::FFI.yeptris_node_new_mapping(@c_ptr)) or
167
+ raise Yeptris::Error, "yeptris_node_new_mapping failed"
168
+ end
169
+
170
+ def new_sequence
171
+ ensure_alive!
172
+ wrap_node(Yeptris::FFI.yeptris_node_new_sequence(@c_ptr)) or
173
+ raise Yeptris::Error, "yeptris_node_new_sequence failed"
174
+ end
175
+
176
+ # style: :plain / :single_quoted / :double_quoted / :literal / :folded.
177
+ # The value is copied into the document (nothing is borrowed).
178
+ def new_scalar(text, style = :plain)
179
+ ensure_alive!
180
+ code = Yeptris::Node::STYLES.key(style) or
181
+ raise ArgumentError, "unknown scalar style #{style.inspect}"
182
+ text = text.to_s
183
+ n = Yeptris::FFI.yeptris_node_new_scalar(@c_ptr, text, text.bytesize, code)
184
+ wrap_node(n) or raise Yeptris::Error, "yeptris_node_new_scalar failed"
185
+ end
186
+
187
+ # An alias node: display name + the target it resolves to.
188
+ def new_alias(target, name)
189
+ ensure_alive!
190
+ n = Yeptris::FFI.yeptris_node_new_alias(@c_ptr, target.c_ptr, name, name.bytesize)
191
+ wrap_node(n) or raise Yeptris::Error, "yeptris_node_new_alias failed"
192
+ end
193
+
194
+ def set_root(node)
195
+ ensure_alive!
196
+ rc = Yeptris::FFI.yeptris_document_set_root(@c_ptr, node.c_ptr)
197
+ Yeptris::FFI.check_status(rc, "yeptris_document_set_root")
198
+ self
199
+ end
200
+
201
+ # GC safety net: an explicit #free already ran is fine; a miss here
202
+ # frees C memory that would otherwise leak.
203
+ def self.finalize(c_ptr, freed)
204
+ proc do
205
+ Yeptris::FFI.yeptris_document_free(c_ptr) if freed.state == :alive
206
+ freed.state = :freed
207
+ end
208
+ end
209
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yeptris
4
+ # The base error for everything this library raises deliberately.
5
+ class Error < StandardError; end
6
+
7
+ # The input is not valid YAML (or valid for the requested mode).
8
+ # message carries the C parser's line/column detail.
9
+ class ParseError < Error; end
10
+
11
+ # A handle was used after its document was freed. Raised, never a
12
+ # segfault: the Document is the sole C-memory owner and every Node
13
+ # checks liveness through it.
14
+ class FreedError < Error; end
15
+
16
+ # Building a document from a Ruby object graph hit something the
17
+ # builder refuses (cycles, unsupported objects).
18
+ class DumpError < Error; end
19
+ end
@@ -0,0 +1,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ffi"
4
+
5
+ module Yeptris
6
+ # Every public C declaration, attached exactly once (the leptris-ruby
7
+ # seam discipline: status checking and owned-pointer reading live in
8
+ # check_status / read_owned_string, never hand-rolled at call sites).
9
+ module FFI
10
+ extend ::FFI::Library
11
+
12
+ begin
13
+ ffi_lib [
14
+ ENV["YEPTRIS_LIB_PATH"],
15
+ File.expand_path("../../libyeptris.dylib", __dir__),
16
+ File.expand_path("../../libyeptris.so", __dir__),
17
+ File.expand_path("../../libyeptris.dll", __dir__),
18
+ "/usr/local/lib/libyeptris.dylib",
19
+ "/usr/local/lib/libyeptris.so",
20
+ "yeptris",
21
+ ].compact
22
+ rescue LoadError => e
23
+ raise LoadError, <<~MSG
24
+ yeptris: cannot load the libyeptris library.
25
+ Set YEPTRIS_LIB_PATH to a libyeptris.{so,dylib,dll}, or vendor
26
+ the library next to the gem's lib/ directory.
27
+ (Underlying error: #{e.message})
28
+ MSG
29
+ end
30
+
31
+ typedef :pointer, :yeptris_document
32
+ typedef :pointer, :yeptris_node
33
+ typedef :pointer, :yeptris_status_out
34
+ typedef :int, :yeptris_status
35
+
36
+ # YeptrisParseOptions (parse.h): schema, max_depth, strict,
37
+ # tab_policy, recover. ABI-frozen field order.
38
+ class ParseOptions < ::FFI::Struct
39
+ layout :schema, :int,
40
+ :max_depth, :int,
41
+ :strict, :int,
42
+ :tab_policy, :int,
43
+ :recover, :int
44
+ end
45
+
46
+ # yeptris_emit_options (emit.h): versioned by size.
47
+ class EmitOptions < ::FFI::Struct
48
+ layout :size, :uint32,
49
+ :canonical, :int,
50
+ :best_width, :int
51
+ end
52
+
53
+ attach_function :yeptris_version, [], :string
54
+
55
+ attach_function :yeptris_last_error, [:pointer, :pointer], :string
56
+
57
+ attach_function :yeptris_parse, %i[pointer size_t yeptris_status_out], :yeptris_document
58
+ attach_function :yeptris_parse_ex,
59
+ %i[pointer size_t pointer yeptris_status_out], :yeptris_document
60
+ attach_function :yeptris_parse_json, %i[pointer size_t yeptris_status_out], :yeptris_document
61
+
62
+ attach_function :yeptris_document_free, [:yeptris_document], :void
63
+ attach_function :yeptris_document_count, [:yeptris_document], :size_t
64
+ attach_function :yeptris_document_root, [:yeptris_document, :size_t], :yeptris_node
65
+
66
+ # construction (TODO.impl/11 phase 3)
67
+ attach_function :yeptris_document_new, [], :yeptris_document
68
+ attach_function :yeptris_document_set_root,
69
+ %i[yeptris_document yeptris_node], :int
70
+ attach_function :yeptris_node_new_mapping, [:yeptris_document], :yeptris_node
71
+ attach_function :yeptris_node_new_sequence, [:yeptris_document], :yeptris_node
72
+ attach_function :yeptris_node_new_scalar,
73
+ %i[yeptris_document pointer size_t int], :yeptris_node
74
+ attach_function :yeptris_node_map_add,
75
+ %i[yeptris_node pointer size_t yeptris_node], :int
76
+ attach_function :yeptris_node_map_set,
77
+ %i[yeptris_node pointer size_t yeptris_node], :int
78
+ attach_function :yeptris_node_map_del, %i[yeptris_node pointer size_t], :int
79
+ attach_function :yeptris_node_seq_add, %i[yeptris_node yeptris_node], :int
80
+ attach_function :yeptris_node_seq_del, %i[yeptris_node size_t], :int
81
+ attach_function :yeptris_node_set_anchor, %i[yeptris_node pointer size_t], :int
82
+ attach_function :yeptris_node_set_tag, %i[yeptris_node pointer size_t], :int
83
+ attach_function :yeptris_node_new_alias,
84
+ %i[yeptris_document yeptris_node pointer size_t], :yeptris_node
85
+
86
+ attach_function :yeptris_node_kind, [:yeptris_node], :int
87
+ attach_function :yeptris_node_id, [:yeptris_node], :uint32
88
+ attach_function :yeptris_node_value, %i[yeptris_node pointer], :pointer
89
+ attach_function :yeptris_node_style, [:yeptris_node], :int
90
+ attach_function :yeptris_node_tag, %i[yeptris_node pointer], :pointer
91
+ attach_function :yeptris_node_anchor, %i[yeptris_node pointer], :pointer
92
+ attach_function :yeptris_node_alias_target, [:yeptris_node], :yeptris_node
93
+ attach_function :yeptris_node_tag_id, [:yeptris_node], :int
94
+ attach_function :yeptris_node_int, %i[yeptris_node pointer], :yeptris_status
95
+ attach_function :yeptris_node_float, %i[yeptris_node pointer], :yeptris_status
96
+ attach_function :yeptris_node_bool, %i[yeptris_node pointer], :yeptris_status
97
+ attach_function :yeptris_node_seq_count, [:yeptris_node], :size_t
98
+ attach_function :yeptris_node_seq_at, %i[yeptris_node size_t], :yeptris_node
99
+ attach_function :yeptris_node_map_count, [:yeptris_node], :size_t
100
+ attach_function :yeptris_node_map_get, %i[yeptris_node pointer size_t], :yeptris_node
101
+ attach_function :yeptris_node_map_at,
102
+ %i[yeptris_node size_t pointer pointer], :int
103
+
104
+ attach_function :yeptris_tag_uri, [:int], :string
105
+
106
+ # recorder (TODO.impl/12): bulk records + string arena, one drain
107
+ attach_function :yeptris_recorder_new, [], :pointer
108
+ attach_function :yeptris_recorder_new_ex, [:int], :pointer
109
+ attach_function :yeptris_recorder_feed,
110
+ %i[pointer pointer size_t int], :int
111
+ attach_function :yeptris_recorder_records, %i[pointer pointer], :pointer
112
+ attach_function :yeptris_recorder_arena, %i[pointer pointer], :pointer
113
+ attach_function :yeptris_recorder_free, [:pointer], :void
114
+
115
+ # value stream (TODO.impl/15 phase F): one drain of pre-converted
116
+ # typed values — the materialization fast path
117
+ attach_function :yeptris_value_drain,
118
+ %i[pointer size_t int pointer pointer pointer pointer], :int
119
+ attach_function :yeptris_value_free, %i[pointer pointer], :void
120
+
121
+ # bulk build (TODO.impl/15 phase D): one call raises a document
122
+ BUILD_SCALAR = 1
123
+ BUILD_SEQ = 2
124
+ BUILD_MAP = 3
125
+ BUILD_END = 4
126
+
127
+ # the ABI-pinned shape; the bulk builder packs these bytes
128
+ # directly (12 per entry)
129
+ class BuildEntry < ::FFI::Struct
130
+ layout op: :uint8, style: :uint8, reserved: :uint16, off: :uint32, len: :uint32
131
+ end
132
+ attach_function :yeptris_document_build,
133
+ %i[yeptris_document pointer size_t pointer size_t], :int
134
+
135
+ attach_function :yeptris_serialize, %i[yeptris_document pointer], :pointer
136
+ attach_function :yeptris_serialize_ex,
137
+ %i[yeptris_document pointer pointer], :pointer
138
+ attach_function :yeptris_serialize_json, %i[yeptris_document pointer], :pointer
139
+
140
+ # Owned char* results (serialize*): one reader, freed exactly once.
141
+ # The buffers are plain malloc'd C memory (the header contract says
142
+ # "caller frees"), so the release is libc free.
143
+ attach_function :c_free, :free, [:pointer], :void
144
+
145
+ module Owned
146
+ module_function
147
+
148
+ # Reads a NUL-terminated malloc'd C string into an Encoding
149
+ # UTF_8 String, then frees the buffer. len_out (nullable) is a
150
+ # MemoryPointer carrying the byte length from the producing call.
151
+ def string(ptr, len_out = nil)
152
+ return nil if ptr.null?
153
+
154
+ len = len_out&.read_uint64
155
+ s = if len && len > 0
156
+ ptr.read_bytes(len).force_encoding(Encoding::UTF_8)
157
+ else
158
+ ptr.read_string.force_encoding(Encoding::UTF_8)
159
+ end
160
+ ::Yeptris::FFI.c_free(ptr)
161
+ s
162
+ end
163
+ end
164
+
165
+ module_function
166
+
167
+ # Non-OK status -> ParseError carrying the C error channel's
168
+ # message with line/column. NULL-document failures route through
169
+ # here too (parse detail lives on the same channel).
170
+ def check_status(status, action)
171
+ return if status.zero?
172
+
173
+ raise Yeptris::ParseError, "#{action} failed: #{last_error_message}"
174
+ end
175
+
176
+ def last_error_message
177
+ line = ::FFI::MemoryPointer.new(:uint32)
178
+ col = ::FFI::MemoryPointer.new(:uint32)
179
+ msg = yeptris_last_error(line, col)
180
+ detail = msg.to_s
181
+ l = line.read_uint32
182
+ c = col.read_uint32
183
+ l.positive? ? "#{detail} at line #{l}, column #{c}" : detail
184
+ end
185
+
186
+ # Pinned enum values (test_abi): the constants the binding relies
187
+ # on without a C header at runtime.
188
+ NODE_SCALAR = 0
189
+ NODE_SEQUENCE = 1
190
+ NODE_MAPPING = 2
191
+ NODE_ALIAS = 3
192
+
193
+ STYLE_PLAIN = 1
194
+ STYLE_SINGLE_QUOTED = 2
195
+ STYLE_DOUBLE_QUOTED = 3
196
+ STYLE_LITERAL = 4
197
+ STYLE_FOLDED = 5
198
+
199
+ TAG_STR = 0
200
+ TAG_INT = 1
201
+ TAG_FLOAT = 2
202
+ TAG_BOOL = 3
203
+ TAG_NULL = 4
204
+ TAG_TIMESTAMP = 5
205
+
206
+ SCHEMA_12_CORE = 0
207
+ SCHEMA_11_COMPAT = 1
208
+
209
+ OK = 0
210
+ ERROR_PARSE = 1
211
+ ERROR_MEMORY = 2
212
+ ERROR_DEPTH = 3
213
+ ERROR_ENCODING = 4
214
+ ERROR_ARG = 6
215
+ end
216
+ end