parsanol 1.3.49 → 1.3.51

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: 4ba1ff94687c90184c892c44b7d301e66edb31a2d945323efd2a0ad82a26be21
4
- data.tar.gz: '05090e440ad8c351564c9effb8057f4bb4e96da3405eb3502eb72501328f8ff2'
3
+ metadata.gz: ee4e171a9217d66e90cc5c3cd51fb9af8d901ec7de75182e68e90caf3bbefb57
4
+ data.tar.gz: bb5e4f0391b98f79e86614f7a53158bdbe8cc12feb39a43ff94bcb553706bb75
5
5
  SHA512:
6
- metadata.gz: 9259f394a917e14cb96f56d9b4de1bb7427231abe232dec3e469cfb777cf01482cb0263647cb6a7e64e66b344a1852b48b3d6fed959f736fc591075b091042b7
7
- data.tar.gz: d094de19cb2781dcb69aecbbfd1c46d6c3307be63deb6f9979330427346e21b13573ee97ec2da04207f85c3b0027617e1cab4e94a46bfcaefa9644e18336058d
6
+ metadata.gz: c1cda8632038653dc853bece92e4f804c267fe43f8146cda3fc8481ad031c8087293f965274e12a173da881ad34ec370db71906ede1a1f89ebe52058e08440ba
7
+ data.tar.gz: f2f5c8f67bbd0c6d444593f41551c32f165d99725e17c37ee6475dfd858a4f6887f7185657f4c0c44b3a232a4b66795742a18ddd134b0afd757b0c7b6b0e181f
data/Cargo.lock CHANGED
@@ -139,9 +139,9 @@ checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
139
139
 
140
140
  [[package]]
141
141
  name = "hashbrown"
142
- version = "0.16.1"
142
+ version = "0.17.1"
143
143
  source = "registry+https://github.com/rust-lang/crates.io-index"
144
- checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
144
+ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
145
145
  dependencies = [
146
146
  "allocator-api2",
147
147
  "equivalent",
@@ -252,9 +252,9 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
252
252
 
253
253
  [[package]]
254
254
  name = "parsanol"
255
- version = "0.8.5"
255
+ version = "0.8.8"
256
256
  source = "registry+https://github.com/rust-lang/crates.io-index"
257
- checksum = "3a57413139c7aacd6343dbcd65985eceaebcf9d61dfa73222e5d0d7b527a581b"
257
+ checksum = "5709d5bc971fa4e28fccf401709ab1d275d180e447e3ab82fde7ebef2ecc787b"
258
258
  dependencies = [
259
259
  "ahash",
260
260
  "getrandom 0.3.4",
@@ -269,13 +269,20 @@ dependencies = [
269
269
 
270
270
  [[package]]
271
271
  name = "parsanol-derive"
272
- version = "0.8.5"
272
+ version = "0.8.8"
273
273
  source = "registry+https://github.com/rust-lang/crates.io-index"
274
- checksum = "5e33d7bd1f1644da4ee51892fc3289ec89826a59e692a4f7076a40dd8ba74672"
274
+ checksum = "93258404b7032b85dffd9a60e3fb1ed003a268282b4f3385f1ab1aba98db34e5"
275
275
  dependencies = [
276
276
  "proc-macro2",
277
277
  "quote",
278
- "syn 2.0.119",
278
+ "syn 3.0.6",
279
+ ]
280
+
281
+ [[package]]
282
+ name = "parsanol_cdylib"
283
+ version = "1.3.50"
284
+ dependencies = [
285
+ "parsanol",
279
286
  ]
280
287
 
281
288
  [[package]]
data/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [workspace]
2
2
  resolver = "2"
3
- members = ["ext/parsanol_native"]
3
+ members = ["ext/parsanol_native", "ext/parsanol_cdylib"]
4
4
 
5
5
  [profile.release]
6
6
  opt-level = 3
data/Rakefile CHANGED
@@ -64,6 +64,25 @@ namespace :gem do
64
64
  sh "rake gem:platform:any gem"
65
65
  end
66
66
 
67
+ # Vendor the pure-portable cdylib into PLATFORM gems (TODO.perf/9):
68
+ # the ruby (source) gem stays binary-free; each cross-gem carries its
69
+ # own triple's libparsanol next to the extension for the ffi tier
70
+ # (MRI resilience fallback, TruffleRuby native).
71
+ desc "Build ext/parsanol_cdylib for the active cross target and vendor it"
72
+ task "vendor_cdylib" do
73
+ triplet = ENV["CARGO_BUILD_TARGET"] || ENV.fetch("RUST_TARGET", nil)
74
+ args = ["cargo", "build", "--release", "-p", "parsanol_cdylib"]
75
+ args += ["--target", triplet] if triplet
76
+ sh(*args)
77
+ dir = triplet ? "target/#{triplet}/release" : "target/release"
78
+ name = %w[libparsanol.so libparsanol.dylib parsanol.dll]
79
+ .map { |n| File.join(dir, n) }
80
+ .find { |f| File.file?(f) }
81
+ raise "cdylib not found under #{dir}" unless name
82
+
83
+ cp name, "lib/parsanol/native/"
84
+ end
85
+
67
86
  desc "Define the gem task to build on any platform (compile on install)"
68
87
  task "platform:any" do
69
88
  spec = Gem::Specification.load("parsanol.gemspec").dup
@@ -0,0 +1,15 @@
1
+ [package]
2
+ name = "parsanol_cdylib"
3
+ version = "1.3.51"
4
+ edition = "2021"
5
+ description = "Vendored cdylib for parsanol platform gems: the pure-portable C ABI (no magnus) for the ffi tier"
6
+ license = "MIT"
7
+ publish = false
8
+
9
+ [lib]
10
+ name = "parsanol"
11
+ crate-type = ["cdylib"]
12
+ doc = false
13
+
14
+ [dependencies]
15
+ parsanol = { version = "0.8.8" }
@@ -0,0 +1,12 @@
1
+ //! Vendored cdylib twin of parsanol-rs's `parsanol-ffi` crate, living in
2
+ //! the gem's ext workspace so the cross-gem build matrix can compile it
3
+ //! per platform triple. Mirrors parsanol-ffi: linking the rlib publishes
4
+ //! the `#[no_mangle] parsanol_c_*` exports; no wrappers.
5
+
6
+ // Force the dependency into the link even under aggressive stripping.
7
+ // Keep in sync with parsanol-rs parsanol-ffi/src/lib.rs.
8
+ #[allow(unused_imports)]
9
+ use parsanol::ffi::c::{
10
+ parsanol_c_last_error, parsanol_c_parse, parsanol_c_parse_len, parsanol_c_register,
11
+ parsanol_c_release,
12
+ };
@@ -28,7 +28,7 @@ rb-sys = { version = "0.9.124", features = ["global-allocator"] }
28
28
  magnus = { version = "0.9" }
29
29
 
30
30
  # parsanol parser library (from git for latest features)
31
- parsanol = { version = "0.8.5", features = ["ruby"] }
31
+ parsanol = { version = "0.8.8", features = ["ruby"] }
32
32
 
33
33
  # Logging
34
34
  log = "0.4"
@@ -97,8 +97,13 @@ module Parsanol
97
97
  ffi_lib path
98
98
  attach_function :c_register, :parsanol_c_register,
99
99
  %i[string], :uint64
100
- attach_function :c_parse, :parsanol_c_parse,
101
- %i[uint64 string pointer size_t], :long_long
100
+ # Explicit-length entry point: interior NULs stay intact. The
101
+ # input crosses as a raw buffer (:string rejects NUL bytes by
102
+ # design); a cdylib without the symbol is stale relative to
103
+ # this binding and fails availability loudly (pure-Ruby
104
+ # fallback) instead of silently truncating at NULs.
105
+ attach_function :c_parse_len, :parsanol_c_parse_len,
106
+ %i[uint64 buffer_in size_t pointer size_t], :long_long
102
107
  attach_function :c_last_error, :parsanol_c_last_error,
103
108
  [], :string
104
109
  attach_function :c_release, :parsanol_c_release,
@@ -114,9 +119,10 @@ module Parsanol
114
119
 
115
120
  # Two-call buffer protocol: cap=0 asks for the needed size, then
116
121
  # the batch lands in a persistent buffer that only grows —
117
- # steady-state parses allocate nothing.
122
+ # steady-state parses allocate nothing. The explicit-length call
123
+ # keeps interior NULs intact.
118
124
  def parse_into(handle, input)
119
- needed = @binding.c_parse(handle, input, nil, 0)
125
+ needed = @binding.c_parse_len(handle, input, input.bytesize, nil, 0)
120
126
  return needed unless needed.negative?
121
127
 
122
128
  cap = -needed
@@ -124,7 +130,7 @@ module Parsanol
124
130
  @buffer&.free
125
131
  @buffer = FFI::MemoryPointer.new(:uint64, cap * 2)
126
132
  end
127
- @binding.c_parse(handle, input, @buffer, cap)
133
+ @binding.c_parse_len(handle, input, input.bytesize, @buffer, cap)
128
134
  end
129
135
  end
130
136
  end
@@ -18,8 +18,6 @@ module Parsanol
18
18
  #
19
19
  class AstTransformer
20
20
  # Frozen string constants for tag comparisons (avoid allocations)
21
- SEQUENCE_TAG = ":sequence"
22
- REPETITION_TAG = ":repetition"
23
21
  EMPTY_STRING = ""
24
22
  EMPTY_ARRAY = [].freeze
25
23
  EMPTY_HASH = {}.freeze
@@ -28,11 +26,12 @@ module Parsanol
28
26
  # This is a class variable to share across all transformations
29
27
  @@symbol_cache = {}
30
28
 
31
- # Symbol tags from native parser
29
+ # Envelope tags. Both tiers (extension objects and the flat-u64
30
+ # batch wire) deliver Symbols — the encoder writes ':'-prefixed
31
+ # strings as TAG_SYMBOL — so there is exactly one tag form.
32
32
  SEQUENCE_SYM = :sequence
33
33
  REPETITION_SYM = :repetition
34
34
  MAYBE_SYM = :maybe
35
- MAYBE_TAG = ":maybe"
36
35
 
37
36
  # `named` mirrors CanFlatten#flatten's named flag: inside a Named
38
37
  # result (.as), an absent maybe flattens to nil; unnamed it flattens
@@ -67,7 +66,8 @@ module Parsanol
67
66
  # Check if this is a tagged array from native parser
68
67
  # Native parser produces Symbol tags: [:sequence, item1, item2, ...]
69
68
  first = arr.first
70
- if [SEQUENCE_SYM, SEQUENCE_TAG].include?(first)
69
+ case first
70
+ when SEQUENCE_SYM
71
71
  # Optimized: transform items starting from index 1
72
72
  # Avoid creating arr[1..] slice
73
73
  len = arr.length
@@ -80,7 +80,7 @@ module Parsanol
80
80
  i += 1
81
81
  end
82
82
  flatten_sequence(items)
83
- elsif [REPETITION_SYM, REPETITION_TAG].include?(first)
83
+ when REPETITION_SYM
84
84
  # Optimized: transform items starting from index 1
85
85
  len = arr.length
86
86
  # Empty repetition: named → [], unnamed → "" (CanFlatten#flatten_repetition)
@@ -93,7 +93,7 @@ module Parsanol
93
93
  i += 1
94
94
  end
95
95
  flatten_repetition(items, named: named)
96
- elsif [MAYBE_SYM, MAYBE_TAG].include?(first)
96
+ when MAYBE_SYM
97
97
  # Maybe flattens to nil-or-value (named) or ""-or-value (unnamed),
98
98
  # never to an array
99
99
  len = arr.length
@@ -103,7 +103,7 @@ module Parsanol
103
103
  flattened = transform(arr[1])
104
104
  named ? flattened : (flattened || EMPTY_STRING)
105
105
  end
106
- elsif first.is_a?(Symbol) || (first.is_a?(String) && first.start_with?(":"))
106
+ when Symbol
107
107
  # Other tagged arrays - pass through
108
108
  arr.map { |item| transform(item) }
109
109
  else
@@ -123,59 +123,92 @@ module Parsanol
123
123
  transform_multi_key_hash(hash)
124
124
  end
125
125
 
126
- # Optimized handling for single-key hashes (the common case)
126
+ # Optimized handling for single-key hashes (the common case).
127
+ # The shape scans below are fused into as few passes as possible:
128
+ # each predicate was previously its own `all?` walk with a lambda
129
+ # dispatch, and `item.keys.length` allocated a keys array per
130
+ # element (Hash#length is O(1)).
127
131
  def self.transform_single_key_hash(hash)
128
- # Extract the single key-value pair without iteration
129
- key = hash.keys.first
132
+ # Extract the single key-value pair without iterating (the hash
133
+ # is guaranteed single-key by the caller).
134
+ key, = hash.first
130
135
  value = hash[key]
131
136
  sym_key = cached_symbol(key)
132
137
 
133
138
  # Transform the value
134
139
  transformed = transform(value, named: true)
135
140
 
136
- # Check if value is a tagged repetition from native parser.
137
- # The Rust handle path tags with Symbols, the batch decoder with
138
- # ":repetition" Strings — accept both.
141
+ # Tagged repetition from the native parser (Symbol tag; both
142
+ # tiers deliver the same form).
139
143
  is_tagged_repetition = value.is_a?(Array) && !value.empty? &&
140
- [REPETITION_SYM, REPETITION_TAG].include?(value.first)
144
+ value.first.equal?(REPETITION_SYM)
141
145
 
142
146
  # Check RAW value for repetition pattern BEFORE transformation
143
- # Array with items that all have the parent key
144
- # e.g., [{x: 1}, {x: 2}] where parent key is :x
145
- is_raw_array_repetition = value.is_a?(Array) && !value.empty? &&
146
- value.all? do |item|
147
- item.is_a?(Hash) && item.keys.length == 1 && item.key?(key)
148
- end
147
+ # (bare repeated sibling captures, #36): array items that all
148
+ # carry the parent key, e.g. [{x: 1}, {x: 2}].
149
+ is_raw_array_repetition = false
150
+ if value.is_a?(Array) && !value.empty? && !is_tagged_repetition
151
+ is_raw_array_repetition = raw_items_all_named?(value, key)
152
+ end
149
153
 
150
154
  # Empty array from native parser is a repetition result (not a sequence)
151
155
  # Sequences produce arrays of arrays like [[], []], not empty arrays
152
156
  is_empty_repetition = value.is_a?(Array) && value.empty?
153
157
 
154
- # Special handling for arrays that look like character repetitions
155
- # (arrays of single-character Slices/strings should be joined)
156
- if transformed.is_a?(Array) && !transformed.empty? &&
157
- transformed.all? do |item|
158
- slice_or_string?(item) && item_length(item) == 1
158
+ # Single fused pass over the transformed array: detect the
159
+ # single-character join shape AND the untagged repetition shape
160
+ # in one walk (an element cannot be both a Hash and a
161
+ # single-character stringlike, so the predicates share a loop).
162
+ joined = nil
163
+ is_transformed_repetition = false
164
+ if transformed.is_a?(Array) && !transformed.empty?
165
+ all_named = true
166
+ all_single_char = true
167
+ content = nil
168
+ first_slice = nil
169
+ transformed.each do |item|
170
+ if item.is_a?(Hash)
171
+ all_single_char = false
172
+ unless item.length == 1 && item.key?(sym_key)
173
+ all_named = false
174
+ break
175
+ end
176
+ elsif item.is_a?(::Parsanol::Slice) || item.is_a?(String)
177
+ all_named = false
178
+ all_single_char = false unless item.length == 1
179
+ break if !all_single_char && !all_named
180
+ else
181
+ all_named = false
182
+ all_single_char = false
183
+ break
159
184
  end
160
- # Join preserving position from first Slice
161
- first_slice = transformed.find { |i| i.is_a?(::Parsanol::Slice) }
162
- content = transformed.map { |i| slice_content(i) }.join
163
- transformed = if first_slice
164
- ::Parsanol::Slice.new(first_slice.offset, content,
165
- first_slice.input)
166
- else
167
- content
168
- end
169
- end
185
+ end
170
186
 
171
- # Check for UNTAGGED repetition pattern (native output):
172
- # If array items all have the same key as parent, it's a repetition
173
- is_transformed_repetition = transformed.is_a?(Array) && !transformed.empty? &&
174
- transformed.all? do |item|
175
- item.is_a?(Hash) && item.keys.length == 1 && item.key?(sym_key)
187
+ if all_single_char
188
+ # Join preserving position from the first Slice
189
+ content = +""
190
+ transformed.each do |item|
191
+ if item.is_a?(::Parsanol::Slice)
192
+ first_slice ||= item
193
+ content << item.content
194
+ else
195
+ content << item.to_s
196
+ end
197
+ end
198
+ joined = if first_slice
199
+ ::Parsanol::Slice.new(first_slice.offset,
200
+ content, first_slice.input)
201
+ else
202
+ content
203
+ end
204
+ elsif all_named
205
+ is_transformed_repetition = true
176
206
  end
207
+ end
177
208
 
178
- is_repetition = is_tagged_repetition || is_raw_array_repetition || is_transformed_repetition || is_empty_repetition
209
+ transformed = joined if joined
210
+ is_repetition = is_tagged_repetition || is_raw_array_repetition ||
211
+ is_transformed_repetition || is_empty_repetition
179
212
 
180
213
  # Handle based on type
181
214
  if is_repetition
@@ -190,6 +223,14 @@ module Parsanol
190
223
  end
191
224
  end
192
225
 
226
+ # Alloc-free raw repetition scan: Hash#length instead of
227
+ # item.keys.length (which allocated per element).
228
+ def self.raw_items_all_named?(value, key)
229
+ value.all? do |item|
230
+ item.is_a?(Hash) && item.length == 1 && item.key?(key)
231
+ end
232
+ end
233
+
193
234
  # Get content from Slice or string
194
235
  def self.slice_content(value)
195
236
  value.is_a?(::Parsanol::Slice) ? value.content : value.to_s
@@ -255,7 +296,7 @@ module Parsanol
255
296
  sym_key = cached_symbol(key)
256
297
 
257
298
  is_repetition = value.is_a?(Array) && !value.empty? &&
258
- value.first.is_a?(String) && value.first == REPETITION_TAG
299
+ value.first.equal?(REPETITION_SYM)
259
300
 
260
301
  transformed = transform(value, named: true)
261
302
 
@@ -313,12 +354,26 @@ module Parsanol
313
354
  return list.reduce { |acc, hash| acc.merge(hash) }
314
355
  end
315
356
 
316
- # Hot path: all stringlike (String/Slice). Build one Slice
317
- # from the first Slice's offset, joining contents in place.
318
- if list.all? { |x| x.is_a?(::Parsanol::Slice) || x.is_a?(String) }
319
- first_slice = list.find { |x| x.is_a?(::Parsanol::Slice) }
320
- content = list.map { |x| x.is_a?(::Parsanol::Slice) ? x.content : x.to_s }.join
321
- return first_slice ? ::Parsanol::Slice.new(first_slice.offset, content, first_slice.input) : content
357
+ # Hot path: all stringlike (String/Slice). One fused pass detects
358
+ # the shape and joins contents in place (no per-item lambda
359
+ # dispatch, no intermediate map array).
360
+ content = +""
361
+ first_slice = nil
362
+ all_stringlike = list.all? do |x|
363
+ if x.is_a?(::Parsanol::Slice)
364
+ first_slice ||= x
365
+ content << x.content
366
+ true
367
+ elsif x.is_a?(String)
368
+ content << x
369
+ true
370
+ else
371
+ false
372
+ end
373
+ end
374
+ if all_stringlike
375
+ return first_slice ? ::Parsanol::Slice.new(first_slice.offset, content,
376
+ first_slice.input) : content
322
377
  end
323
378
 
324
379
  # Cold path: parslet's exact fold (Hash/Slice/String/Array
@@ -372,14 +427,29 @@ module Parsanol
372
427
 
373
428
  return EMPTY_ARRAY if named && items.empty?
374
429
 
375
- # Hot path: all-stringlike repetition → foldl via concat.
430
+ # Hot path: all-stringlike repetition → fused single-pass concat.
376
431
  # Cold path: parslet's exact fold.
377
- if items.all? { |x| x.is_a?(::Parsanol::Slice) || x.is_a?(String) }
378
- return EMPTY_STRING if items.empty?
432
+ if items.empty?
433
+ return EMPTY_STRING
434
+ end
379
435
 
380
- first_slice = items.find { |x| x.is_a?(::Parsanol::Slice) }
381
- content = items.map { |x| x.is_a?(::Parsanol::Slice) ? x.content : x.to_s }.join
382
- return first_slice ? ::Parsanol::Slice.new(first_slice.offset, content, first_slice.input) : content
436
+ content = +""
437
+ first_slice = nil
438
+ all_stringlike = items.all? do |x|
439
+ if x.is_a?(::Parsanol::Slice)
440
+ first_slice ||= x
441
+ content << x.content
442
+ true
443
+ elsif x.is_a?(String)
444
+ content << x
445
+ true
446
+ else
447
+ false
448
+ end
449
+ end
450
+ if all_stringlike
451
+ return first_slice ? ::Parsanol::Slice.new(first_slice.offset, content,
452
+ first_slice.input) : content
383
453
  end
384
454
 
385
455
  foldl(items.compact) { |acc, item| acc + item }
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Parsanol
4
- VERSION = "1.3.49"
4
+ VERSION = "1.3.51"
5
5
  end
data/parsanol.gemspec CHANGED
@@ -39,7 +39,17 @@ Gem::Specification.new do |spec|
39
39
  Cargo.lock
40
40
  ]
41
41
  spec.files.reject! { |f| File.directory?(f) }
42
- spec.files.reject! { |f| f =~ /\.(dll|so|dylib|lib|bundle)\Z/ }
42
+ # Cargo build trees are never gem content (a dirty local
43
+ # ext/parsanol_native/target once inflated a build to 218 MB).
44
+ spec.files.reject! { |f| f.include?("/target/") || f.start_with?("target/") }
45
+ # Binaries are never tracked in git, and the ruby (source) gem stays
46
+ # slim: the pure-portable cdylib is vendored ONLY into platform gems
47
+ # by the cross-gem build (rake gem:vendor_cdylib), never by a plain
48
+ # `gem build`.
49
+ spec.files.reject! do |f|
50
+ f =~ /\.(dll|so|dylib|lib|bundle)\Z/ &&
51
+ !(ENV["PARSANOL_VENDOR_CDYLIB"] == "1" && f.start_with?("lib/parsanol/native/"))
52
+ end
43
53
  spec.require_paths = ["lib"]
44
54
 
45
55
  spec.required_ruby_version = ">= 3.2.0"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: parsanol
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.49
4
+ version: 1.3.51
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -40,6 +40,8 @@ files:
40
40
  - LICENSE
41
41
  - README.adoc
42
42
  - Rakefile
43
+ - ext/parsanol_cdylib/Cargo.toml
44
+ - ext/parsanol_cdylib/src/lib.rs
43
45
  - ext/parsanol_native/Cargo.toml
44
46
  - ext/parsanol_native/extconf.rb
45
47
  - ext/parsanol_native/src/lib.rs