parsanol 1.3.54 → 1.3.56

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: ea29107bfe3e1b3ce6b66701239beffe98c16a2cb7207da64e81b43ea179544d
4
- data.tar.gz: 6c31ecaaa82edb959a17b8a43ce5ac7d3fa3819c175e28637efac3a35e215760
3
+ metadata.gz: 9fd20e8200bbd5f3eadcab0100d7854cd97cafdea7a2a04c4b6fda000c3a46c5
4
+ data.tar.gz: d35a9d04dbb252eb567ddd4a267d1603a2e3ec538f32e866ad32bb6adcaf11a8
5
5
  SHA512:
6
- metadata.gz: f4e3f11ff5bf640b96c2ed79bee2db2d2ef3d68efc409a8da5acb684c9b366d4af902ededc6f6e115163a491c27297027c8d5c9fd10975d43b458ccea2b3548a
7
- data.tar.gz: f5ae7316b64a7979558eed0d592e13e3cb5edb79644d1d90c9f997bb34689e71075022f4e8b89dc0c86d5c8e678997eae532e086f36e1bd20c5e70f3408fb9aa
6
+ metadata.gz: 7432ac5ea256ecc15bc615dd342c38bf0912fca1cda343b7dcbeafb05e636b9461f110e0d15830f81434e426eccd62c353fc786eee21f1b51e94372ed3adf051
7
+ data.tar.gz: 609460b69aee699d6fd03d7ee90047f22f8d990e6abcd60e3bd8ebf48c99c853a94fd4d9290823069a517741fb3d8f7ff57c86d73a26746809eb35d1d223727b
data/Cargo.lock CHANGED
@@ -204,7 +204,8 @@ checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
204
204
  [[package]]
205
205
  name = "magnus"
206
206
  version = "0.9.0"
207
- source = "git+https://github.com/matsadler/magnus?rev=9407bd9#9407bd98afef6692edca706426c15ea6bef60d0e"
207
+ source = "registry+https://github.com/rust-lang/crates.io-index"
208
+ checksum = "379d50b6eea4ca84074ccd3e551eb9b07f7865194d5b3198325317e9ef78e5e9"
208
209
  dependencies = [
209
210
  "magnus-macros",
210
211
  "rb-sys",
@@ -215,7 +216,8 @@ dependencies = [
215
216
  [[package]]
216
217
  name = "magnus-macros"
217
218
  version = "0.9.0"
218
- source = "git+https://github.com/matsadler/magnus?rev=9407bd9#9407bd98afef6692edca706426c15ea6bef60d0e"
219
+ source = "registry+https://github.com/rust-lang/crates.io-index"
220
+ checksum = "4ca70566af98184bb92d4d9853daaf533c1a3418b9570d0547ea44970035f3fe"
219
221
  dependencies = [
220
222
  "proc-macro2",
221
223
  "quote",
@@ -252,9 +254,9 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
252
254
 
253
255
  [[package]]
254
256
  name = "parsanol"
255
- version = "0.8.8"
257
+ version = "0.8.11"
256
258
  source = "registry+https://github.com/rust-lang/crates.io-index"
257
- checksum = "5709d5bc971fa4e28fccf401709ab1d275d180e447e3ab82fde7ebef2ecc787b"
259
+ checksum = "c28a0cd3b3e2491ef72c99f3dc59ef25ea2e0f13b8cd6ea31a05eb085a33f801"
258
260
  dependencies = [
259
261
  "ahash",
260
262
  "getrandom 0.3.4",
@@ -269,9 +271,9 @@ dependencies = [
269
271
 
270
272
  [[package]]
271
273
  name = "parsanol-derive"
272
- version = "0.8.8"
274
+ version = "0.8.11"
273
275
  source = "registry+https://github.com/rust-lang/crates.io-index"
274
- checksum = "93258404b7032b85dffd9a60e3fb1ed003a268282b4f3385f1ab1aba98db34e5"
276
+ checksum = "0ccfdf8325c4890930bbc1510bc9dfc929d485d1aa6c29cb563c5dc77d7a0080"
275
277
  dependencies = [
276
278
  "proc-macro2",
277
279
  "quote",
@@ -280,7 +282,7 @@ dependencies = [
280
282
 
281
283
  [[package]]
282
284
  name = "parsanol_cdylib"
283
- version = "1.3.50"
285
+ version = "1.3.52"
284
286
  dependencies = [
285
287
  "parsanol",
286
288
  ]
@@ -208,7 +208,7 @@ module Parsanol
208
208
 
209
209
  # Converts raw input to Source if needed.
210
210
  def normalize_input(source)
211
- source.respond_to?(:line_and_column) ? source : Parsanol::Source.new(source)
211
+ source.is_a?(Parsanol::Source) ? source : Parsanol::Source.new(source)
212
212
  end
213
213
 
214
214
  # Detects if we're in a Parser context.
@@ -42,7 +42,7 @@ module Parsanol
42
42
  # 1. Mark the current position as a cut point
43
43
  # 2. Empty the backtrack stack (we won't backtrack past here)
44
44
  # 3. Aggressively evict cache entries before this position
45
- context.cut!(source.bytepos) if context.respond_to?(:cut!)
45
+ context.cut!(source.bytepos)
46
46
 
47
47
  [success, value]
48
48
  end
@@ -66,7 +66,7 @@ module Parsanol
66
66
  # @return [Array(Boolean, Object)] result
67
67
  def try(source, context, consume_all)
68
68
  # Check for tree memoization support
69
- if context.respond_to?(:use_tree_memoization?) && context.use_tree_memoization?
69
+ if context.use_tree_memoization?
70
70
  return with_tree_cache(source, context, consume_all)
71
71
  end
72
72
 
@@ -183,6 +183,7 @@ module Parsanol
183
183
  last_failure = nil
184
184
 
185
185
  loop do
186
+ iteration_start = source.bytepos
186
187
  success, value = @parslet.apply(source, context, false)
187
188
 
188
189
  unless success
@@ -193,6 +194,11 @@ module Parsanol
193
194
  occurrence += 1
194
195
  result[occurrence] = value
195
196
 
197
+ # A body match that consumes nothing cannot make progress;
198
+ # looping again would re-match empty forever. Count the empty
199
+ # match, then stop — the min check below applies as usual.
200
+ break if source.bytepos == iteration_start
201
+
196
202
  break if @max && occurrence >= @max
197
203
  end
198
204
 
@@ -254,6 +260,7 @@ module Parsanol
254
260
  last_failure = nil
255
261
 
256
262
  loop do
263
+ iteration_start = source.bytepos
257
264
  success, value = @parslet.apply(source, context, false)
258
265
 
259
266
  unless success
@@ -265,6 +272,10 @@ module Parsanol
265
272
  result[occurrence] = value
266
273
  positions << source.bytepos
267
274
 
275
+ # Zero-width body match: count it, stop iterating (the min
276
+ # check below applies as usual) — mirrors try_general.
277
+ break if source.bytepos == iteration_start
278
+
268
279
  break if @max && occurrence >= @max
269
280
  end
270
281
 
@@ -287,7 +298,7 @@ module Parsanol
287
298
  context.store_tree_memo(cache_key, start_pos,
288
299
  result[1, occurrence], end_pos)
289
300
  end
290
- context.release_array(positions) if context.respond_to?(:release_array)
301
+ context.release_array(positions)
291
302
 
292
303
  # Check minimum
293
304
  if occurrence < @min
@@ -74,7 +74,7 @@ module Parsanol
74
74
  line_num, col_num = @source.line_and_column(@position)
75
75
 
76
76
  formatted_msg = @message.map do |msg|
77
- msg.respond_to?(:to_slice) ? msg.content.inspect : msg.to_s
77
+ msg.is_a?(Parsanol::Slice) ? msg.content.inspect : msg.to_s
78
78
  end.join
79
79
 
80
80
  "#{formatted_msg} at line #{line_num} char #{col_num}#{@parsing_label}."
@@ -72,7 +72,7 @@ module Parsanol
72
72
  cause = super
73
73
 
74
74
  # Apply label if the atom has one
75
- if atom.respond_to?(:label) && (lbl = atom.label)
75
+ if atom.is_a?(Parsanol::Atoms::Base) && (lbl = atom.label)
76
76
  maybe_update_label(lbl, src.pos.bytepos)
77
77
  cause.set_label(@active_label_text)
78
78
  end
@@ -103,6 +103,15 @@ module Parsanol
103
103
 
104
104
  alias kind_of? is_a?
105
105
 
106
+ # The delegated interface: Array's full class contract (including
107
+ # Enumerable), computed once against the type rather than probed on
108
+ # instances.
109
+ ARRAY_METHODS = (
110
+ Array.instance_methods(true) + Enumerable.instance_methods + [:to_ary]
111
+ ).freeze
112
+
113
+ private_constant :ARRAY_METHODS
114
+
106
115
  # Respond to array methods.
107
116
  #
108
117
  # @param method [Symbol] Method name
@@ -110,7 +119,7 @@ module Parsanol
110
119
  # @return [Boolean] true if responds
111
120
  #
112
121
  def respond_to?(method, include_private = false)
113
- super || to_a.respond_to?(method, include_private)
122
+ super || ARRAY_METHODS.include?(method)
114
123
  end
115
124
 
116
125
  # Delegate unknown methods to materialized array.
@@ -121,7 +130,7 @@ module Parsanol
121
130
  # @return [Object] Result of method call
122
131
  #
123
132
  def method_missing(method, ...)
124
- if to_a.respond_to?(method)
133
+ if ARRAY_METHODS.include?(method)
125
134
  to_a.public_send(method, ...)
126
135
  else
127
136
  super
@@ -135,7 +144,7 @@ module Parsanol
135
144
  # @return [Boolean] true if method is supported
136
145
  #
137
146
  def respond_to_missing?(method, include_private = false)
138
- to_a.respond_to?(method, include_private) || super
147
+ ARRAY_METHODS.include?(method) || super
139
148
  end
140
149
 
141
150
  # Compare with another object.
@@ -391,15 +391,19 @@ module Parsanol
391
391
  return left.is_a?(Hash) ? left.merge(right) : left + right
392
392
  end
393
393
 
394
- if left.respond_to?(:to_str) && right.respond_to?(:to_str)
395
- return right if right.respond_to?(:to_slice)
396
- return left if left.respond_to?(:to_slice)
397
-
398
- return left.to_str + right.to_str
394
+ # String-like = String or Slice (both implement to_str). When
395
+ # only ONE side is string-like, the OTHER side wins — the
396
+ # original duck-typed priority, preserved exactly.
397
+ left_stringlike = left.is_a?(String) || left.is_a?(Parsanol::Slice)
398
+ right_stringlike = right.is_a?(String) || right.is_a?(Parsanol::Slice)
399
+ if left_stringlike && right_stringlike
400
+ return right if right.is_a?(Parsanol::Slice)
401
+ return left if left.is_a?(Parsanol::Slice)
402
+
403
+ return left + right
399
404
  end
400
-
401
- return left if right.respond_to?(:to_str)
402
- return right if left.respond_to?(:to_str)
405
+ return left if right_stringlike
406
+ return right if left_stringlike
403
407
 
404
408
  return left + [right] if right.is_a?(Hash)
405
409
  return [left] + right if left.is_a?(Hash)
@@ -216,7 +216,9 @@ module Parsanol
216
216
  # cause from that directly — the failure path never needs a
217
217
  # reporter reparse. The single interpreter pass below stays only
218
218
  # to recover inputs from grammars native cannot express.
219
- if grammar.respond_to?(:parse) && (m = error.message.match(NATIVE_POS_MARKER))
219
+ parsanol_grammar =
220
+ grammar.is_a?(Parsanol::Atoms::Base) || grammar.is_a?(Parsanol::Parser)
221
+ if parsanol_grammar && (m = error.message.match(NATIVE_POS_MARKER))
220
222
  source = Parsanol::Source.new(input)
221
223
  success, value = grammar.run_with_context(source, nil, true)
222
224
  return grammar.finalize_result(value) if success
@@ -227,7 +229,7 @@ module Parsanol
227
229
  raise Parsanol::ParseFailed.new(cause.to_s, cause)
228
230
  end
229
231
 
230
- if grammar.respond_to?(:parse)
232
+ if parsanol_grammar
231
233
  # No native diagnostics (e.g. incomplete-input errors): one
232
234
  # interpreter pass with the reporter attached — a success
233
235
  # recovers the tree, a failure raises the cause tree.
@@ -255,7 +255,7 @@ module Parsanol
255
255
  # already produced. The full per-atom event stream of the Ruby
256
256
  # engine remains available via an explicit `mode: :ruby`.
257
257
  def feed_native_reporter(reporter, input, error)
258
- return unless reporter.respond_to?(:err_at)
258
+ return unless reporter.is_a?(Parsanol::ErrorReporter::Base)
259
259
 
260
260
  source = Parsanol::Source.new(input)
261
261
  cause = error.parse_failure_cause
@@ -90,7 +90,7 @@ module Parsanol
90
90
  return true if pattern_val === target
91
91
 
92
92
  # Check if pattern is a binding expression (like simple(:x))
93
- if pattern_val.respond_to?(:can_bind?) && pattern_val.can_bind?(target)
93
+ if pattern_val.is_a?(Parsanol::Pattern::SubtreeBind) && pattern_val.can_bind?(target)
94
94
  return capture_binding(target, pattern_val, captured)
95
95
  end
96
96
 
data/lib/parsanol/pool.rb CHANGED
@@ -119,7 +119,7 @@ module Parsanol
119
119
  end
120
120
 
121
121
  # Reset object state if it supports the protocol
122
- obj.reset! if obj.respond_to?(:reset!)
122
+ obj.reset! if obj.is_a?(Parsanol::Resettable)
123
123
 
124
124
  @stats[:released] += 1
125
125
  @available.push(obj)
data/lib/parsanol/rope.rb CHANGED
@@ -67,7 +67,7 @@ module Parsanol
67
67
  #
68
68
  # @return [Integer] The sum of all segment sizes
69
69
  def size
70
- @segments.sum { |s| s.respond_to?(:size) ? s.size : s.to_s.size }
70
+ @segments.sum(&:size)
71
71
  end
72
72
 
73
73
  # Creates a rope from an existing string.
@@ -142,18 +142,16 @@ module Parsanol
142
142
 
143
143
  # Unified line/column computation:
144
144
  # - String input: compute from input string
145
- # - LineCache: delegate to cache
145
+ # - Source / LineCache: delegate
146
146
  def line_and_column_at(pos)
147
- if @input.respond_to?(:line_and_column)
148
- # LineCache or duck-typed object
149
- @input.line_and_column(pos)
150
- else
151
- # String input
147
+ if @input.is_a?(String)
152
148
  prefix = @input.byteslice(0, pos) || ""
153
149
  line = 1 + prefix.count("\n")
154
150
  last_nl = prefix.rindex("\n")
155
151
  column = last_nl ? pos - last_nl : pos + 1
156
152
  [line, column]
153
+ else
154
+ @input.line_and_column(pos)
157
155
  end
158
156
  end
159
157
  end
@@ -34,7 +34,7 @@ module Parsanol
34
34
  def line_and_column(position)
35
35
  scan_buffer_once
36
36
 
37
- position = position.bytepos if position.respond_to?(:bytepos)
37
+ position = position.bytepos if position.is_a?(Parsanol::Source)
38
38
 
39
39
  line_idx = @breaks.lower_bound_index(position)
40
40
 
@@ -33,9 +33,9 @@ module Parsanol
33
33
  # @raise [ArgumentError] if input doesn't respond to to_str
34
34
  #
35
35
  def initialize(input)
36
- unless input.respond_to?(:to_str)
36
+ unless input.is_a?(String)
37
37
  raise ArgumentError,
38
- "Source requires a string-like object (responds to to_str)"
38
+ "Source requires a String (got #{input.class})"
39
39
  end
40
40
 
41
41
  @raw_string = input.to_str
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Parsanol
4
- VERSION = "1.3.54"
4
+ VERSION = "1.3.56"
5
5
  end
data/lib/parsanol/vm.rb CHANGED
@@ -149,6 +149,7 @@ module Parsanol
149
149
  def compile_with_inline(atom, inline)
150
150
  root = atom.is_a?(Parsanol::Parser) ? atom.root : atom
151
151
  compiler = Compiler.new(inline: inline)
152
+ validate_alternatives(root)
152
153
  return nil unless compiler.compile_atom(root, true)
153
154
 
154
155
  # HALT terminates the MAIN program; subroutines follow it so the
@@ -174,6 +175,81 @@ module Parsanol
174
175
  compiler.to_program
175
176
  end
176
177
 
178
+ def validate_choice_branches(atom)
179
+ atom.alternatives.each_with_index do |branch, idx|
180
+ walk_branch = branch
181
+ next if idx == atom.alternatives.size - 1
182
+
183
+ # Entities resolve lazily and may be recursive; resolve
184
+ # through the wrapper for the nullability check only.
185
+ while walk_branch.is_a?(Parsanol::Atoms::Entity) ||
186
+ walk_branch.is_a?(Parsanol::Atoms::Named)
187
+ walk_branch = begin
188
+ walk_branch.parslet
189
+ rescue StandardError
190
+ nil
191
+ end
192
+ break if walk_branch.nil?
193
+ end
194
+ next if walk_branch.nil?
195
+ next unless Compiler.new.nullable?(walk_branch)
196
+
197
+ raise Parsanol::GrammarError,
198
+ "wrong grammar: alternative branch #{idx} " \
199
+ "(#{branch.inspect}) can match empty input and is not " \
200
+ "the last branch — ordered choice commits to its empty " \
201
+ "match, so #{atom.alternatives.size - idx - 1} later " \
202
+ "branch(es) can never match at positions where it " \
203
+ "matches empty. Reorder branches so empty-matchable " \
204
+ "ones come last, or make the branch non-empty."
205
+ end
206
+ end
207
+
208
+ # Grammar validation, run once per compile: an ordered-choice
209
+ # branch that can match empty input while not being last commits
210
+ # to its empty match and permanently shadows every later branch —
211
+ # the grammar is wrong, and inputs it would mis-parse fail with a
212
+ # baffling cause. Raise before any input is seen.
213
+ def validate_alternatives(root)
214
+ visited = {}.compare_by_identity
215
+ walk = lambda do |atom, depth|
216
+ next if atom.nil? || depth > 20
217
+ next if visited[atom]
218
+
219
+ visited[atom] = true
220
+ case atom
221
+ when Parsanol::Atoms::Alternative
222
+ validate_choice_branches(atom)
223
+ atom.alternatives.each { |c| walk.call(c, depth + 1) }
224
+ when Parsanol::Atoms::Sequence
225
+ atom.parslets.each { |c| walk.call(c, depth + 1) }
226
+ when Parsanol::Atoms::Repetition, Parsanol::Atoms::Named
227
+ walk.call(atom.parslet, depth + 1)
228
+ when Parsanol::Atoms::Lookahead
229
+ walk.call(atom.bound_parslet, depth + 1)
230
+ when Parsanol::Atoms::Ignored
231
+ walk.call(atom.wrapped_atom, depth + 1)
232
+ when Parsanol::Atoms::Entity
233
+ begin
234
+ walk.call(atom.parslet, depth + 1)
235
+ rescue Parsanol::GrammarError
236
+ raise
237
+ rescue StandardError
238
+ # Lazily resolved / recursive entity that cannot resolve
239
+ # outside a parse: skip this branch of the walk.
240
+ nil
241
+ end
242
+ when Parsanol::Atoms::Scope
243
+ begin
244
+ walk.call(atom.block.call, depth + 1)
245
+ rescue StandardError
246
+ nil
247
+ end
248
+ end
249
+ end
250
+ walk.call(root, 0)
251
+ end
252
+
177
253
  # Executes a compiled program. Returns [true, value] on success;
178
254
  # [false, nil] on failure or budget exhaustion — callers fall back
179
255
  # to the interpreter, which reproduces exact diagnostics.
@@ -977,12 +1053,24 @@ module Parsanol
977
1053
  count = frames[cbase]
978
1054
  return BAIL if count.nil?
979
1055
 
1056
+ old_end = frames[cbase + 1]
980
1057
  count += 1
981
1058
  frames[cbase] = count
982
1059
  frames[cbase + 1] = pos
983
1060
  max = ops[pc + 3]
984
1061
  pc = if max && count >= max
985
1062
  ops[pc + 2] # exit
1063
+ elsif pos == old_end
1064
+ # Zero-width body match: count it, stop iterating.
1065
+ # Mirrors Repetition#try_general: success with the
1066
+ # prefix when min is met, failure of the repetition
1067
+ # otherwise.
1068
+ if count >= frames[cbase + 3]
1069
+ ops[pc + 2] # exit
1070
+ else
1071
+ frames.slice!(cbase..)
1072
+ fail_pc
1073
+ end
986
1074
  else
987
1075
  ops[pc + 1] # test
988
1076
  end
data/lib/parsanol.rb CHANGED
@@ -44,6 +44,12 @@ module Parsanol
44
44
  base.extend(ClassMethods)
45
45
  end
46
46
 
47
+ # Raised when the GRAMMAR itself is malformed — detected before any
48
+ # input is parsed. The canonical case: an ordered-choice branch that
49
+ # can match empty input while not being last, which commits to its
50
+ # empty match and permanently shadows every later branch.
51
+ class GrammarError < StandardError; end
52
+
47
53
  # Exception raised when parsing fails. Contains detailed error information
48
54
  # in the #parse_failure_cause attribute.
49
55
  class ParseFailed < StandardError
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.54
4
+ version: 1.3.56
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.