parsanol 1.3.36 → 1.3.37

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: dbeca0f263a186fedfaebecb581dc25694d3c3d79e98898db861e1ec18a6296f
4
- data.tar.gz: d82324e707dcddd112f535d20988f144981fd3df9a3ff86b9cb812c3cab2816e
3
+ metadata.gz: f3ee6550442261f9f42d66c0b51e9b5fe5f186c54c9b36a2932abc0410608a42
4
+ data.tar.gz: 9f0476cfd0d65c1642c81bf86bd36a2a0a6627d1c2978ba65f00989e84e6bd44
5
5
  SHA512:
6
- metadata.gz: fbe5a790335f4f2fd4c9db8816ea9cf154c71c20bbcb233d8bc4f1665691bbd0fe5fbe00bf6f360afe815c3434ef0e8047f770244403dbc8655f0ab6d7d15a05
7
- data.tar.gz: 441073e36388645c1a429aa6c6918051607bdab1b4f00514b2b81f68614d6e33033ae1155efbb7a5d6082809166c3602347ac834505e78fbba22ff0784957c7b
6
+ metadata.gz: b1dbb402205ebfb234148f7f87b5d9bcf6cd201b7f72f3c73bd096c7a0c3ba092582ad1a37a543f1665fd2542d8b2c6841b5ca9e4f21525a2ecfac68e343bad7
7
+ data.tar.gz: dc490cc9e39a4feb101139c1fd9a2d47e5509d495cb99920f6856cf65eac3303147e8cb95c13b25f8086d2fc31cb77be5193438527e28d4d261f08f28a7842cb
data/Cargo.lock CHANGED
@@ -252,8 +252,9 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
252
252
 
253
253
  [[package]]
254
254
  name = "parsanol"
255
- version = "0.7.2"
256
- source = "git+https://github.com/parsanol/parsanol-rs?branch=main#5f04c5bda75c6f5f3f5d82bd18f30a36488ec1ad"
255
+ version = "0.7.3"
256
+ source = "registry+https://github.com/rust-lang/crates.io-index"
257
+ checksum = "1f87f23ea48eb961366d013b90eac82e8f1d6e129d102ab33a7d56f5ec26a883"
257
258
  dependencies = [
258
259
  "ahash",
259
260
  "getrandom 0.3.4",
@@ -269,8 +270,9 @@ dependencies = [
269
270
 
270
271
  [[package]]
271
272
  name = "parsanol-derive"
272
- version = "0.7.2"
273
- source = "git+https://github.com/parsanol/parsanol-rs?branch=main#5f04c5bda75c6f5f3f5d82bd18f30a36488ec1ad"
273
+ version = "0.7.3"
274
+ source = "registry+https://github.com/rust-lang/crates.io-index"
275
+ checksum = "a4043d26b37aa72b4a8efc02e6dc0a7225cb28e484b07bd290ede35c8a06a566"
274
276
  dependencies = [
275
277
  "proc-macro2",
276
278
  "quote",
@@ -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 = { git = "https://github.com/parsanol/parsanol-rs", branch = "main", features = ["ruby"] }
31
+ parsanol = { version = "0.7.3", features = ["ruby"] }
32
32
 
33
33
  # Logging
34
34
  log = "0.4"
@@ -39,7 +39,15 @@ module Parsanol
39
39
  # grammars transparently.
40
40
  if must_consume_all && source.is_a?(String) &&
41
41
  (program = VM.program_for(self))
42
- result = VM.run_for(self, program, source, true)
42
+ begin
43
+ result = VM.run_for(self, program, source, true)
44
+ rescue ArgumentError, TypeError
45
+ # Executor crash on a malformed program (GH-68): the
46
+ # interpreter is the source of truth — fall through and
47
+ # stop using the VM for this grammar.
48
+ VM.disable_for!(self)
49
+ result = VM::BAIL
50
+ end
43
51
  if result == VM::BAIL
44
52
  # Internal bail: fall back to the interpreter and skip the VM
45
53
  # for this grammar from now on.
@@ -216,7 +216,10 @@ module Parsanol
216
216
  end
217
217
  piece = e.instance_of?(Parsanol::Slice) ? e.content : e.to_s
218
218
  if content.nil?
219
- content = +piece
219
+ # Copy: unary + returns self for unfrozen strings, and
220
+ # appending into an input slice's buffer corrupts it when
221
+ # the same cached subtree is flattened again (GH-67).
222
+ content = String.new(piece)
220
223
  else
221
224
  content << piece
222
225
  end
@@ -1,11 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Regular expression matcher for single characters.
4
- # Matches one character against a character class pattern.
3
+ # Regular expression matcher.
4
+ #
5
+ # A plain character class matches one character. Like Parslet, a
6
+ # pattern with an embedded quantifier (match('a+') etc.) is a regex
7
+ # matched greedily at the current position: the whole regex match is
8
+ # consumed in one step.
5
9
  #
6
10
  # @example Character classes
7
- # match('[a-z]') # matches a-z
8
- # match('\d') # matches digits
11
+ # match('[a-z]') # matches one a-z character
12
+ # match('a+') # matches a maximal run of a (Parslet parity)
13
+ # match('\d') # matches one digit
9
14
  # any # matches any character
10
15
  #
11
16
  module Parsanol
@@ -20,10 +25,15 @@ module Parsanol
20
25
  # Creates a new regex matcher.
21
26
  #
22
27
  # @param pattern [String, Object] regex character class
28
+ QUANTIFIER_RE = /[+*?]|\{\d+(?:,\s*\d*)?\}/
29
+
23
30
  def initialize(pattern)
24
31
  super()
25
32
  @match = pattern.to_s
26
33
  @re = Regexp.new(@match, Regexp::MULTILINE)
34
+ # Embedded quantifier: one greedy regex match instead of a
35
+ # single character (Parslet parity, GH-69).
36
+ @quantified = QUANTIFIER_RE.match?(@match)
27
37
 
28
38
  # Extract pattern for display (strip delimiters)
29
39
  @display = @match.inspect[1..-2] || @match
@@ -40,7 +50,19 @@ module Parsanol
40
50
  # @param _consume_all [Boolean] ignored
41
51
  # @return [Array(Boolean, Object)] result
42
52
  def try(source, context, _consume_all)
43
- # Fast path: check if next char matches
53
+ if @quantified
54
+ # One greedy regex match (Parslet parity): consume the whole
55
+ # match. A zero-length match is treated as no match so
56
+ # repetitions always advance.
57
+ matched = source.match_bytes(@re)
58
+ return ok(source.consume_bytes(matched)) if matched&.positive?
59
+
60
+ return context.err(self, source, @eof_error) if source.chars_left < 1
61
+
62
+ return context.err(self, source, @no_match_error)
63
+ end
64
+
65
+ # Fast path: single-character class
44
66
  return ok(source.consume(1)) if source.matches?(@re)
45
67
 
46
68
  # No input left
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Parsanol
4
+ module Native
5
+ # Reference consumer for the flat event stream returned by
6
+ # {Parsanol::Native::Parser.parse_events}: replays the opcode
7
+ # stream into the same tree Parsanol::Native.parse produces.
8
+ #
9
+ # Domain builders (e.g. an EXPRESS model builder) should not
10
+ # rebuild this tree — they consume the events directly, which is
11
+ # the point of the format. This player exists to document the
12
+ # protocol and to conformance-test the stream against the shaped
13
+ # tree.
14
+ #
15
+ # Opcodes (see parsanol-rs portable::events):
16
+ # 0 NIL 4 BEGIN_ARR 6 BEGIN_HASH
17
+ # 1 STR <pool> 5 END_ARR 7 END_HASH
18
+ # 2 SLICE <offset> <len> 3 KEY <pool>
19
+ class EventPlayer
20
+ OP_NIL = 0
21
+ OP_STR = 1
22
+ OP_SLICE = 2
23
+ OP_KEY = 3
24
+ OP_BEGIN_ARR = 4
25
+ OP_END_ARR = 5
26
+ OP_BEGIN_HASH = 6
27
+ OP_END_HASH = 7
28
+
29
+ # One-shot replay: returns the tree for the stream.
30
+ def self.play(events, strings, input)
31
+ player = new(events, strings, input)
32
+ tree = player.read_value
33
+ unless player.pos == events.length
34
+ raise ArgumentError, "event stream not fully consumed"
35
+ end
36
+
37
+ tree
38
+ end
39
+
40
+ def initialize(events, strings, input)
41
+ @events = events
42
+ @strings = strings
43
+ @input = input
44
+ @pos = 0
45
+ end
46
+
47
+ attr_reader :pos
48
+
49
+ def read_value
50
+ op = @events[@pos]
51
+ @pos += 1
52
+ case op
53
+ when OP_NIL then nil
54
+ when OP_STR
55
+ value = @strings[@events[@pos]]
56
+ @pos += 1
57
+ value
58
+ when OP_SLICE
59
+ offset = @events[@pos]
60
+ length = @events[@pos + 1]
61
+ @pos += 2
62
+ ::Parsanol::Slice.new(offset, @input.byteslice(offset, length), @input)
63
+ when OP_BEGIN_HASH
64
+ hash = {}
65
+ until @events[@pos] == OP_END_HASH
66
+ @pos += 1 # KEY opcode
67
+ key = symbol(@strings[@events[@pos]])
68
+ @pos += 1
69
+ hash[key] = read_value
70
+ end
71
+ @pos += 1
72
+ hash
73
+ when OP_BEGIN_ARR
74
+ array = []
75
+ until @events[@pos] == OP_END_ARR
76
+ array << read_value
77
+ end
78
+ @pos += 1
79
+ array
80
+ else
81
+ raise ArgumentError, "unknown event opcode #{op} at #{@pos - 1}"
82
+ end
83
+ end
84
+
85
+ private
86
+
87
+ def symbol(key)
88
+ @@symbol_cache ||= {}
89
+ @@symbol_cache[key] ||= key.to_sym
90
+ end
91
+ end
92
+ end
93
+ end
@@ -48,6 +48,20 @@ module Parsanol
48
48
  Parsanol::Native.parse(grammar, input)
49
49
  end
50
50
 
51
+ # Parse input with a Ruby grammar, returning the parslet-shaped
52
+ # AST as a flat event stream [[events], [strings]] — one FFI
53
+ # return, no per-node Ruby objects. Replay with EventPlayer or
54
+ # consume the opcodes directly.
55
+ #
56
+ # @param grammar [Parsanol::Atoms::Base] Ruby grammar definition
57
+ # @param input [String] Input string to parse
58
+ # @return [Array<Array<Integer>, Array<String>>]
59
+ def parse_events(grammar, input)
60
+ handle = grammar_handle(grammar)
61
+ blob, strings = Native._parse_handle_events(handle, input)
62
+ [blob.unpack("q*"), strings]
63
+ end
64
+
51
65
  # Serialize a Ruby grammar to JSON (cached).
52
66
  def serialize_grammar(root_atom)
53
67
  grammar_json(root_atom)
@@ -5,6 +5,7 @@ require "digest"
5
5
 
6
6
  require "parsanol/native/types"
7
7
  require "parsanol/native/parser"
8
+ require "parsanol/native/event_player"
8
9
  require "parsanol/native/serializer"
9
10
  require "parsanol/native/batch_decoder"
10
11
 
@@ -230,22 +230,25 @@ module Parsanol
230
230
  raise
231
231
  end
232
232
 
233
- # Engine selection for the default mode, decided at registration
234
- # time (never mid-parse): grammars the Rust backend cannot express
235
- # run on the Ruby engine, announced once per grammar loud, not a
236
- # silent parse-time fallback. An explicit mode: :native still raises
237
- # UnsupportedGrammar from registration.
233
+ # Public expressibility check (GH-71): true when the Rust native
234
+ # backend can run this grammar, false when it must run on the Ruby
235
+ # engine (Dynamic/custom atoms). Use it to pick an engine
236
+ # explicitly via parse(input, mode: :native | :ruby).
237
+ #
238
+ # The degradation warning prints once per reason per process — not
239
+ # per parser instance and not per parse.
238
240
  def native_expressible?
239
241
  Parsanol::Native::Parser.grammar_handle(root)
240
242
  true
241
243
  rescue Parsanol::Native::UnsupportedGrammar => e
242
- warned = (@@unsupported_warned ||= {}.compare_by_identity)
243
- unless warned.key?(root)
244
- warned[root] = true
244
+ warned = (@@unsupported_warned ||= {})
245
+ unless warned.key?(e.message)
246
+ warned[e.message] = true
245
247
  warn "parsanol: parsing with the Ruby engine (#{e.message})"
246
248
  end
247
249
  false
248
250
  end
251
+ public :native_expressible?
249
252
 
250
253
  # Feed the native deepest-failure diagnostics to a user-supplied
251
254
  # reporter: one err_at event carrying the cause the native engine
@@ -52,6 +52,11 @@ module Parsanol
52
52
  end
53
53
  end
54
54
 
55
+ # Chain-aware membership test without raising.
56
+ def key?(key)
57
+ @bindings.key?(key) || (@parent_frame&.key?(key) || false)
58
+ end
59
+
55
60
  # Stores a value in the current frame.
56
61
  #
57
62
  # @param key [Symbol] the variable name
@@ -79,6 +84,15 @@ module Parsanol
79
84
  @active_frame.fetch(key)
80
85
  end
81
86
 
87
+ # Chain-aware membership test, mirroring Parslet's captures.key?.
88
+ # Checks the current frame and all parents without raising.
89
+ #
90
+ # @param key [Symbol] the variable name to look up
91
+ # @return [Boolean] whether any frame binds the key
92
+ def key?(key)
93
+ @active_frame.key?(key)
94
+ end
95
+
82
96
  # Stores a value in the current frame.
83
97
  #
84
98
  # @param key [Symbol] the variable name
@@ -69,6 +69,59 @@ module Parsanol
69
69
  end
70
70
  alias match matches?
71
71
 
72
+ # Byte length of the pattern's match at the current position
73
+ # (StringScanner#match? returns it), or nil when there is no match.
74
+ #
75
+ # @param pattern [Regexp] pattern to probe
76
+ # @return [Integer, nil] matched byte length
77
+ #
78
+ def match_bytes(pattern) # rubocop:disable Naming/PredicateMethod -- returns byte length or nil, not a boolean
79
+ @scanner.match?(pattern)
80
+ end
81
+
82
+ # Consumes n bytes from input and returns them as a pooled Slice.
83
+ # Companion to #match_bytes for atoms that measure their match in
84
+ # bytes (quantified regexes).
85
+ #
86
+ # @param count [Integer] number of bytes to consume
87
+ # @return [Parsanol::Slice] slice containing consumed bytes
88
+ #
89
+ # The underlying input string. Custom atoms doing position math
90
+ # (e.g. lookbehind rewinds) need direct access to it (GH-70).
91
+ #
92
+
93
+ # Rewinds n characters from the current position, walking back over
94
+ # UTF-8 continuation bytes so multibyte input stays aligned. Bounds:
95
+ # never rewinds past the start of the input.
96
+ #
97
+ # @param count [Integer] number of characters to move back
98
+ # @return [Integer] the new byte position
99
+ def rewind_chars(count)
100
+ pos = @scanner.pos
101
+ remaining = count
102
+ while remaining.positive? && pos.positive?
103
+ pos -= 1
104
+ remaining -= 1 unless (@raw_string.getbyte(pos) & 0xC0) == 0x80
105
+ end
106
+ @scanner.pos = pos
107
+ pos
108
+ end
109
+
110
+ # The underlying input string. Custom atoms doing position math
111
+ # (e.g. lookbehind rewinds) need direct access to it (GH-70).
112
+ #
113
+ # @return [String] the full input
114
+ def input
115
+ @raw_string
116
+ end
117
+
118
+ def consume_bytes(count)
119
+ current_pos = @scanner.pos
120
+ content = @raw_string.byteslice(current_pos, count)
121
+ @scanner.pos = current_pos + count
122
+ @slice_pool.acquire_with(current_pos, content, @line_data)
123
+ end
124
+
72
125
  # Consumes n characters from input and returns them as a pooled Slice.
73
126
  #
74
127
  # @param count [Integer] number of characters to consume
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Parsanol
4
- VERSION = "1.3.36"
4
+ VERSION = "1.3.37"
5
5
  end
data/lib/parsanol.rb CHANGED
@@ -129,31 +129,10 @@ module Parsanol
129
129
  def match(pattern = nil)
130
130
  return CharacterClassBuilder.new unless pattern
131
131
 
132
- match_quantifier_warning(pattern)
133
132
  Atoms::Re.new(pattern)
134
133
  end
135
134
  module_function :match
136
135
 
137
- # `match` consumes exactly one character (Parslet parity), so a regex
138
- # quantifier is silently truncated: match(/[0-9]+/) matches a single
139
- # digit. Warn once per offending pattern — the intended spelling is
140
- # `match("[0-9]").repeat(1)`.
141
- # Deliberately mutable: it accumulates warned patterns per process.
142
- QUANTIFIER_WARNED = {} # rubocop:disable Style/MutableConstant
143
- private_constant :QUANTIFIER_WARNED
144
-
145
- def match_quantifier_warning(pattern)
146
- return if QUANTIFIER_WARNED.key?(pattern)
147
- return unless pattern.is_a?(String)
148
- return unless quantified_pattern?(pattern)
149
-
150
- QUANTIFIER_WARNED[pattern] = true
151
- warn "Parsanol: match(#{pattern.inspect}) matches exactly one character " \
152
- "(Parslet parity); the quantifier is ignored. " \
153
- "Use match(...).repeat(1) to match repeatedly."
154
- end
155
- module_function :match_quantifier_warning
156
-
157
136
  # True when the pattern carries a top-level quantifier (+, *, ?, or
158
137
  # {n,m}) outside a character class.
159
138
  def quantified_pattern?(pattern)
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.36
4
+ version: 1.3.37
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -89,6 +89,7 @@ files:
89
89
  - lib/parsanol/native.rb
90
90
  - lib/parsanol/native/batch_decoder.rb
91
91
  - lib/parsanol/native/dynamic.rb
92
+ - lib/parsanol/native/event_player.rb
92
93
  - lib/parsanol/native/ffi.rb
93
94
  - lib/parsanol/native/parser.rb
94
95
  - lib/parsanol/native/serializer.rb