parsanol 1.3.13-arm-linux

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.
Files changed (100) hide show
  1. checksums.yaml +7 -0
  2. data/HISTORY.txt +98 -0
  3. data/LICENSE +23 -0
  4. data/README.adoc +905 -0
  5. data/Rakefile +141 -0
  6. data/lib/parsanol/3.2/parsanol_native.so +0 -0
  7. data/lib/parsanol/3.3/parsanol_native.so +0 -0
  8. data/lib/parsanol/3.4/parsanol_native.so +0 -0
  9. data/lib/parsanol/4.0/parsanol_native.so +0 -0
  10. data/lib/parsanol/ast_visitor.rb +122 -0
  11. data/lib/parsanol/atoms/alternative.rb +123 -0
  12. data/lib/parsanol/atoms/base.rb +208 -0
  13. data/lib/parsanol/atoms/can_flatten.rb +194 -0
  14. data/lib/parsanol/atoms/capture.rb +38 -0
  15. data/lib/parsanol/atoms/context.rb +357 -0
  16. data/lib/parsanol/atoms/context_optimized.rb +38 -0
  17. data/lib/parsanol/atoms/custom.rb +110 -0
  18. data/lib/parsanol/atoms/cut.rb +66 -0
  19. data/lib/parsanol/atoms/dsl.rb +96 -0
  20. data/lib/parsanol/atoms/dynamic.rb +39 -0
  21. data/lib/parsanol/atoms/entity.rb +75 -0
  22. data/lib/parsanol/atoms/ignored.rb +37 -0
  23. data/lib/parsanol/atoms/infix.rb +167 -0
  24. data/lib/parsanol/atoms/lookahead.rb +85 -0
  25. data/lib/parsanol/atoms/named.rb +74 -0
  26. data/lib/parsanol/atoms/re.rb +83 -0
  27. data/lib/parsanol/atoms/repetition.rb +277 -0
  28. data/lib/parsanol/atoms/scope.rb +35 -0
  29. data/lib/parsanol/atoms/sequence.rb +195 -0
  30. data/lib/parsanol/atoms/str.rb +109 -0
  31. data/lib/parsanol/atoms/visitor.rb +91 -0
  32. data/lib/parsanol/atoms.rb +46 -0
  33. data/lib/parsanol/buffer.rb +133 -0
  34. data/lib/parsanol/builder_callbacks.rb +353 -0
  35. data/lib/parsanol/cause.rb +122 -0
  36. data/lib/parsanol/context.rb +39 -0
  37. data/lib/parsanol/convenience.rb +36 -0
  38. data/lib/parsanol/edit_tracker.rb +111 -0
  39. data/lib/parsanol/error_reporter/contextual.rb +99 -0
  40. data/lib/parsanol/error_reporter/deepest.rb +120 -0
  41. data/lib/parsanol/error_reporter/tree.rb +63 -0
  42. data/lib/parsanol/error_reporter.rb +100 -0
  43. data/lib/parsanol/expression/treetop.rb +154 -0
  44. data/lib/parsanol/expression.rb +106 -0
  45. data/lib/parsanol/fast_mode.rb +187 -0
  46. data/lib/parsanol/first_set.rb +79 -0
  47. data/lib/parsanol/grammar_builder.rb +179 -0
  48. data/lib/parsanol/incremental_parser.rb +182 -0
  49. data/lib/parsanol/interval_tree.rb +226 -0
  50. data/lib/parsanol/lazy_result.rb +179 -0
  51. data/lib/parsanol/mermaid.rb +142 -0
  52. data/lib/parsanol/native/batch_decoder.rb +255 -0
  53. data/lib/parsanol/native/dynamic.rb +238 -0
  54. data/lib/parsanol/native/parser.rb +102 -0
  55. data/lib/parsanol/native/serializer.rb +252 -0
  56. data/lib/parsanol/native/transformer.rb +604 -0
  57. data/lib/parsanol/native/types.rb +29 -0
  58. data/lib/parsanol/native.rb +223 -0
  59. data/lib/parsanol/optimizer.rb +85 -0
  60. data/lib/parsanol/optimizers/choice_optimizer.rb +78 -0
  61. data/lib/parsanol/optimizers/cut_inserter.rb +182 -0
  62. data/lib/parsanol/optimizers/lookahead_optimizer.rb +56 -0
  63. data/lib/parsanol/optimizers/quantifier_optimizer.rb +60 -0
  64. data/lib/parsanol/optimizers/sequence_optimizer.rb +97 -0
  65. data/lib/parsanol/options/zero_copy.rb +127 -0
  66. data/lib/parsanol/options.rb +21 -0
  67. data/lib/parsanol/parallel.rb +128 -0
  68. data/lib/parsanol/parser.rb +242 -0
  69. data/lib/parsanol/parslet.rb +151 -0
  70. data/lib/parsanol/pattern/binding.rb +91 -0
  71. data/lib/parsanol/pattern.rb +162 -0
  72. data/lib/parsanol/pool.rb +219 -0
  73. data/lib/parsanol/pools/array_pool.rb +75 -0
  74. data/lib/parsanol/pools/buffer_pool.rb +182 -0
  75. data/lib/parsanol/pools/position_pool.rb +92 -0
  76. data/lib/parsanol/pools/slice_pool.rb +64 -0
  77. data/lib/parsanol/position.rb +94 -0
  78. data/lib/parsanol/resettable.rb +29 -0
  79. data/lib/parsanol/result.rb +46 -0
  80. data/lib/parsanol/result_builder.rb +208 -0
  81. data/lib/parsanol/result_stream.rb +266 -0
  82. data/lib/parsanol/rig/rspec.rb +71 -0
  83. data/lib/parsanol/rope.rb +81 -0
  84. data/lib/parsanol/scope.rb +104 -0
  85. data/lib/parsanol/slice.rb +160 -0
  86. data/lib/parsanol/source/line_cache.rb +102 -0
  87. data/lib/parsanol/source.rb +185 -0
  88. data/lib/parsanol/source_location.rb +167 -0
  89. data/lib/parsanol/streaming_parser.rb +124 -0
  90. data/lib/parsanol/string_view.rb +198 -0
  91. data/lib/parsanol/transform.rb +226 -0
  92. data/lib/parsanol/version.rb +5 -0
  93. data/lib/parsanol/wasm/README.md +80 -0
  94. data/lib/parsanol/wasm/package.json +51 -0
  95. data/lib/parsanol/wasm/parsanol.js +252 -0
  96. data/lib/parsanol/wasm/parslet.d.ts +129 -0
  97. data/lib/parsanol/wasm_parser.rb +240 -0
  98. data/lib/parsanol.rb +278 -0
  99. data/parsanol.gemspec +67 -0
  100. metadata +279 -0
@@ -0,0 +1,255 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "parsanol/native/transformer"
4
+
5
+ module Parsanol
6
+ module Native
7
+ # Decodes flat u64 arrays from Rust batch parser into Ruby AST
8
+ #
9
+ # The batch format uses tagged u64 values:
10
+ # - 0x00 = nil
11
+ # - 0x01 + value = bool (0 or 1)
12
+ # - 0x02 + value = int
13
+ # - 0x03 + bits = float (IEEE 754 bits)
14
+ # - 0x04 + offset + length = input string reference
15
+ # - 0x05 ... 0x06 = array (start ... end)
16
+ # - 0x07 ... 0x08 = hash (start ... end)
17
+ # - 0x09 + len + data... = hash key
18
+ # - 0x0A + len + data... = inline string
19
+ module BatchDecoder
20
+ TAG_NIL = 0x00
21
+ TAG_BOOL = 0x01
22
+ TAG_INT = 0x02
23
+ TAG_FLOAT = 0x03
24
+ TAG_STRING = 0x04
25
+ TAG_ARRAY_START = 0x05
26
+ TAG_ARRAY_END = 0x06
27
+ TAG_HASH_START = 0x07
28
+ TAG_HASH_END = 0x08
29
+ TAG_HASH_KEY = 0x09
30
+ TAG_INLINE_STRING = 0x0A
31
+ TAG_SYMBOL = 0x0B
32
+ TAG_REPETITION = 0x0C
33
+ TAG_SEQUENCE = 0x0D
34
+
35
+ class << self
36
+ # Decode a flat u64 array into Ruby AST with Slice objects
37
+ #
38
+ # @param data [Array<Integer>] Flat u64 array from batch parser
39
+ # @param input [String] Original input string (for Slice references)
40
+ # @param slice_class [Class] The Slice class to use
41
+ # @return [Object] Ruby AST (Hash, Array, Slice, etc.)
42
+ def decode(data, input, slice_class)
43
+ @input = input
44
+ @input_bytes = input.b
45
+ @slice_class = slice_class
46
+ @pos = 0
47
+ @data = data
48
+ decode_value
49
+ end
50
+
51
+ # Decode batch format to Ruby AST and apply transformation.
52
+ #
53
+ # The Rust parser produces raw AST that needs transformation to match
54
+ # Ruby parser behavior (merging duplicate keys, etc.)
55
+ #
56
+ # @param data [Array<Integer>|Object] Either flat u64 array from batch parser OR
57
+ # pre-decoded Ruby value from _parse_raw
58
+ # @param input [String] Original input string (for Slice references)
59
+ # @param slice_class [Class] The Slice class to use
60
+ # @return [Object] Transformed Ruby AST
61
+ def decode_and_flatten(data, input, slice_class)
62
+ # Check if data is batch data (flat u64 array) or already a Ruby value
63
+ if data.is_a?(Integer) || (data.is_a?(Array) && data.first.is_a?(Integer))
64
+ # Batch data (flat u64 array) - decode first, then transform
65
+ raw_ast = decode(data, input, slice_class)
66
+ AstTransformer.transform(raw_ast)
67
+ else
68
+ # Already decoded Ruby value from _parse_raw - apply transformer directly
69
+ AstTransformer.transform(data)
70
+ end
71
+ end
72
+
73
+ # Join consecutive Slice objects in arrays into single Slices
74
+ # This matches what transform_ast does in Rust (join_slices_from_array)
75
+ #
76
+ # @param value [Object] AST value
77
+ # @param slice_class [Class] The Slice class to check for
78
+ # @param input [String] Original input string
79
+ # @return [Object] AST with joined slices
80
+ def join_consecutive_slices(value, slice_class, input)
81
+ input_bytes = input.b
82
+
83
+ case value
84
+ when Array
85
+ # Recursively process array elements
86
+ processed = value.map do |v|
87
+ join_consecutive_slices(v, slice_class, input)
88
+ end
89
+
90
+ # Check if all non-nil elements are Slices
91
+ non_nil = processed.compact
92
+ if non_nil.all?(slice_class)
93
+ # Check if slices are consecutive
94
+ if slices_consecutive?(non_nil)
95
+ # Join into single slice
96
+ join_slices(non_nil, slice_class, input_bytes, input)
97
+ else
98
+ processed
99
+ end
100
+ else
101
+ processed
102
+ end
103
+ when Hash
104
+ # Process hash values recursively
105
+ result = {}
106
+ value.each do |k, v|
107
+ result[k] = join_consecutive_slices(v, slice_class, input)
108
+ end
109
+ result
110
+ else
111
+ value
112
+ end
113
+ end
114
+
115
+ private
116
+
117
+ def slices_consecutive?(slices)
118
+ return true if slices.empty?
119
+
120
+ slices.each_cons(2).all? do |a, b|
121
+ a.offset + a.content.bytesize == b.offset
122
+ end
123
+ end
124
+
125
+ def join_slices(slices, slice_class, input_bytes, input)
126
+ return nil if slices.empty?
127
+ return slices.first if slices.length == 1
128
+
129
+ first = slices.first
130
+ last = slices.last
131
+ total_length = last.offset + last.content.bytesize - first.offset
132
+ content = input_bytes[first.offset, total_length]
133
+ content = content.force_encoding("UTF-8") if content
134
+ slice_class.new(first.offset, content, input)
135
+ end
136
+
137
+ def decode_value
138
+ tag = @data[@pos]
139
+ @pos += 1
140
+
141
+ case tag
142
+ when TAG_NIL
143
+ nil
144
+ when TAG_BOOL
145
+ val = @data[@pos]
146
+ @pos += 1
147
+ val != 0
148
+ when TAG_INT
149
+ val = @data[@pos]
150
+ @pos += 1
151
+ # Handle negative numbers (signed i64 stored as u64)
152
+ if val >= 0x8000_0000_0000_0000
153
+ val = val - 0x1_0000_0000_0000_0000
154
+ end
155
+ val
156
+ when TAG_FLOAT
157
+ bits = @data[@pos]
158
+ @pos += 1
159
+ # Convert IEEE 754 bits to float
160
+ [bits].pack("Q").unpack1("D")
161
+ when TAG_STRING
162
+ offset = @data[@pos]
163
+ length = @data[@pos + 1]
164
+ @pos += 2
165
+ create_slice(offset, length)
166
+ when TAG_SYMBOL
167
+ # Symbol is encoded like inline string: len, then u64 chunks
168
+ len = @data[@pos]
169
+ @pos += 1
170
+ str = decode_inline_string_bytes(len)
171
+ str.to_sym
172
+ when TAG_REPETITION
173
+ inner = decode_value
174
+ [:repetition, inner].compact
175
+ when TAG_SEQUENCE
176
+ inner = decode_value
177
+ [:sequence, inner].compact
178
+ when TAG_ARRAY_START
179
+ decode_array
180
+ when TAG_HASH_START
181
+ decode_hash
182
+ else
183
+ raise "Unknown tag: #{tag} at position #{@pos - 1}"
184
+ end
185
+ end
186
+
187
+ def decode_array
188
+ result = []
189
+ loop do
190
+ tag = @data[@pos]
191
+ break if tag == TAG_ARRAY_END
192
+
193
+ result << decode_value
194
+ end
195
+ @pos += 1 # consume TAG_ARRAY_END
196
+ result
197
+ end
198
+
199
+ def decode_hash
200
+ result = {}
201
+ loop do
202
+ tag = @data[@pos]
203
+ break if tag == TAG_HASH_END
204
+
205
+ # Read key
206
+ raise "Expected TAG_HASH_KEY, got #{tag}" unless tag == TAG_HASH_KEY
207
+
208
+ @pos += 1
209
+ key = decode_inline_string
210
+
211
+ # Read value
212
+ value = decode_value
213
+
214
+ # Keep original key format (camelCase) for Ruby parser compatibility
215
+ result[key.to_sym] = value
216
+ end
217
+ @pos += 1 # consume TAG_HASH_END
218
+ result
219
+ end
220
+
221
+ def decode_inline_string
222
+ len = @data[@pos]
223
+ @pos += 1
224
+ decode_inline_string_bytes(len)
225
+ end
226
+
227
+ # Decode inline string bytes given the length
228
+ # @param len [Integer] Length of the string in bytes
229
+ # @return [String] Decoded string
230
+ def decode_inline_string_bytes(len)
231
+ # Read u64 chunks
232
+ chunks = (len + 7) / 8
233
+ bytes = String.new(encoding: "ASCII-8BIT", capacity: len)
234
+ chunks.times do
235
+ chunk = @data[@pos]
236
+ @pos += 1
237
+ 8.times do |byte_idx|
238
+ break if bytes.bytesize >= len
239
+
240
+ bytes << ((chunk >> (byte_idx * 8)) & 0xFF)
241
+ end
242
+ end
243
+
244
+ bytes.force_encoding("UTF-8")
245
+ end
246
+
247
+ def create_slice(offset, length)
248
+ content = @input_bytes[offset, length]
249
+ content = content.force_encoding("UTF-8") if content
250
+ @slice_class.new(offset, content, @input)
251
+ end
252
+ end
253
+ end
254
+ end
255
+ end
@@ -0,0 +1,238 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Parsanol
6
+ module Native
7
+ # Manages Ruby callbacks for dynamic atoms
8
+ #
9
+ # Dynamic atoms allow runtime-determined parsing by invoking Ruby code
10
+ # during parsing. This module provides:
11
+ # - Registration of Ruby procs as callbacks
12
+ # - Thread-safe callback storage
13
+ # - GC-safe references (callbacks are kept alive while registered)
14
+ #
15
+ # @example Basic usage
16
+ # # Register a callback
17
+ # callback_id = Parsanol::Native::Dynamic.register(->(ctx) {
18
+ # ctx[:mode] == 'A' ? str('alpha') : str('beta')
19
+ # })
20
+ #
21
+ # # Use in grammar
22
+ # grammar = str('MODE:').capture(:mode) >> dynamic(callback_id)
23
+ #
24
+ # # Unregister when done
25
+ # Parsanol::Native::Dynamic.unregister(callback_id)
26
+ #
27
+ module Dynamic
28
+ # Callback storage (callback_id => block)
29
+ # This keeps strong references to prevent GC
30
+ @callbacks = {}
31
+ @mutex = Mutex.new
32
+ @next_id = 1_000_000 # Start high to avoid conflicts with Rust-side IDs
33
+
34
+ class << self
35
+ # Register a Ruby block as a dynamic callback
36
+ #
37
+ # @param block [Proc] The block to register (must accept a context hash)
38
+ # @param description [String, nil] Optional description for debugging
39
+ # @return [Integer] Unique callback ID for use in grammar
40
+ #
41
+ # @example
42
+ # id = Parsanol::Native::Dynamic.register(->(ctx) {
43
+ # case ctx[:type]
44
+ # when 'int' then str('integer')
45
+ # when 'str' then str('string')
46
+ # else nil
47
+ # end
48
+ # })
49
+ #
50
+ def register(block, description: nil)
51
+ # Register with Rust FFI
52
+ ffi_id = Native.register_callback(@next_id,
53
+ description || "Ruby callback ##{@next_id}")
54
+
55
+ # Also keep a Ruby-side reference for GC safety
56
+ @mutex.synchronize do
57
+ @callbacks[ffi_id] = {
58
+ block: block,
59
+ description: description || "Ruby callback ##{ffi_id}",
60
+ }
61
+ end
62
+
63
+ ffi_id
64
+ end
65
+
66
+ # Unregister a callback (free memory)
67
+ #
68
+ # @param callback_id [Integer] The callback ID to remove
69
+ # @return [Boolean] True if the callback was found and removed
70
+ #
71
+ def unregister(callback_id)
72
+ # Remove from Rust FFI
73
+ Native.unregister_callback(callback_id)
74
+
75
+ # Remove from Ruby storage
76
+ @mutex.synchronize do
77
+ @callbacks.delete(callback_id)
78
+ end
79
+ end
80
+
81
+ # Get the description of a registered callback
82
+ #
83
+ # @param callback_id [Integer] The callback ID
84
+ # @return [String, nil] The description or nil if not found
85
+ #
86
+ def description(callback_id)
87
+ # Try Ruby-side first
88
+ ruby_desc = @mutex.synchronize do
89
+ @callbacks[callback_id]&.dig(:description)
90
+ end
91
+
92
+ return ruby_desc if ruby_desc
93
+
94
+ # Fall back to FFI
95
+ Native.get_callback_description(callback_id)
96
+ end
97
+
98
+ # Get the number of registered callbacks
99
+ #
100
+ # @return [Integer] Number of registered callbacks
101
+ #
102
+ def count
103
+ Native.callback_count
104
+ end
105
+
106
+ # Clear all callbacks (for testing)
107
+ #
108
+ # WARNING: This clears all callbacks globally, including those
109
+ # registered by other code. Use with caution.
110
+ #
111
+ def clear
112
+ Native.clear_callbacks
113
+ @mutex.synchronize { @callbacks.clear }
114
+ end
115
+
116
+ # Check if a callback is registered
117
+ #
118
+ # @param callback_id [Integer] The callback ID
119
+ # @return [Boolean] True if registered
120
+ #
121
+ def registered?(callback_id)
122
+ @mutex.synchronize { @callbacks.key?(callback_id) } ||
123
+ Native.has_callback(callback_id)
124
+ end
125
+
126
+ # Invoke a callback from Rust (called via FFI)
127
+ #
128
+ # @param callback_id [Integer] The callback ID
129
+ # @param context [Hash] The context hash from Rust
130
+ # @return [Object, nil] The returned atom (parslet) or nil
131
+ #
132
+ def invoke_from_rust(callback_id, context)
133
+ block = @mutex.synchronize { @callbacks[callback_id]&.dig(:block) }
134
+ return nil unless block
135
+
136
+ # Build DynamicContext from hash
137
+ ctx = DynamicContext.new(
138
+ context[:input],
139
+ context[:pos],
140
+ context[:captures].transform_keys(&:to_sym),
141
+ )
142
+
143
+ # Call the block
144
+ result = block.call(ctx)
145
+
146
+ return nil unless result
147
+
148
+ # Return the result (should be a parslet/atom)
149
+ result
150
+ rescue StandardError => e
151
+ warn "[Parsanol::Native::Dynamic] Invoke error: #{e.message}"
152
+ nil
153
+ end
154
+ end
155
+ end
156
+
157
+ # Context object passed to dynamic callbacks
158
+ #
159
+ # Provides read-only access to the parsing context including
160
+ # input string, current position, and captured values.
161
+ #
162
+ # @example
163
+ # dynamic { |ctx|
164
+ # if ctx[:mode] == 'strict'
165
+ # str('strict_value')
166
+ # else
167
+ # str('relaxed_value')
168
+ # end
169
+ # }
170
+ #
171
+ class DynamicContext
172
+ # @return [String] The full input string being parsed
173
+ attr_reader :input
174
+
175
+ # @return [Integer] Current byte position in the input
176
+ attr_reader :pos
177
+
178
+ # @return [Hash<Symbol, String>] Captured values
179
+ attr_reader :captures
180
+
181
+ def initialize(input, pos, captures)
182
+ @input = input
183
+ @pos = pos
184
+ @captures = captures.transform_keys(&:to_sym)
185
+ end
186
+
187
+ # Get a captured value by name
188
+ #
189
+ # @param name [Symbol, String] The capture name
190
+ # @return [String, nil] The captured value or nil
191
+ #
192
+ def [](name)
193
+ @captures[name.to_sym]
194
+ end
195
+
196
+ # Check if a capture exists
197
+ #
198
+ # @param name [Symbol, String] The capture name
199
+ # @return [Boolean] True if the capture exists
200
+ #
201
+ def key?(name)
202
+ @captures.key?(name.to_sym)
203
+ end
204
+ alias has_key? key?
205
+
206
+ # Get the remaining input from the current position
207
+ #
208
+ # @return [String] The remaining input
209
+ #
210
+ def remaining
211
+ @input[@pos..] || ""
212
+ end
213
+
214
+ # Check if at end of input
215
+ #
216
+ # @return [Boolean] True if at end
217
+ #
218
+ def eos?
219
+ @pos >= @input.length
220
+ end
221
+ alias at_end? eos?
222
+
223
+ # Get a slice of the input
224
+ #
225
+ # @param start [Integer] Start position (relative to current pos if negative)
226
+ # @param length [Integer, nil] Length of slice (nil = to end)
227
+ # @return [String] The sliced input
228
+ #
229
+ def slice(start, length = nil)
230
+ if length
231
+ @input[@pos + start, length]
232
+ else
233
+ @input[(@pos + start)..]
234
+ end
235
+ end
236
+ end
237
+ end
238
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Parsanol
6
+ module Native
7
+ # Core parsing functionality using Rust native extension
8
+ module Parser
9
+ GRAMMAR_HASH_CACHE = Hash.new
10
+ GRAMMAR_CACHE = Hash.new
11
+
12
+ class << self
13
+ @cached_available = nil
14
+
15
+ def available?
16
+ return @cached_available unless @cached_available.nil?
17
+
18
+ @cached_available = begin
19
+ # Try versioned path first (released gem), then non-versioned (local dev)
20
+ ruby_version = RUBY_VERSION.split(".").take(2).join(".")
21
+ begin
22
+ require "parsanol/#{ruby_version}/parsanol_native"
23
+ rescue LoadError
24
+ require "parsanol/parsanol_native"
25
+ end
26
+ Parsanol::Native.is_available
27
+ rescue LoadError
28
+ false
29
+ end
30
+ end
31
+
32
+ # Parse input with a Ruby grammar, returning clean AST.
33
+ #
34
+ # @param grammar [Parsanol::Atoms::Base] Ruby grammar or JSON string
35
+ # @param input [String] Input string to parse
36
+ def parse(grammar, input)
37
+ # Delegate to Parsanol::Native.parse for consistency
38
+ Parsanol::Native.parse(grammar, input)
39
+ end
40
+
41
+ # Serialize a Ruby grammar to JSON (cached).
42
+ def serialize_grammar(root_atom)
43
+ root_atom = root_atom.root if root_atom.is_a?(::Parsanol::Parser)
44
+ obj_id = root_atom.object_id
45
+ cache_key = GRAMMAR_HASH_CACHE[obj_id] ||= grammar_structure_hash(root_atom)
46
+ GRAMMAR_CACHE[cache_key] ||= GrammarSerializer.serialize(root_atom)
47
+ end
48
+
49
+ def clear_cache
50
+ GRAMMAR_HASH_CACHE.clear
51
+ GRAMMAR_CACHE.clear
52
+ end
53
+
54
+ def cache_stats
55
+ {
56
+ hash_cache_size: GRAMMAR_HASH_CACHE.size,
57
+ grammar_cache_size: GRAMMAR_CACHE.size,
58
+ }
59
+ end
60
+
61
+ private
62
+
63
+ def grammar_structure_hash(atom)
64
+ Digest::MD5.hexdigest(atom_structure(atom).to_s)
65
+ end
66
+
67
+ def atom_structure(atom, visited = {})
68
+ # Cycle detection - return a placeholder if we've seen this atom before
69
+ obj_id = atom.object_id
70
+ if visited[obj_id]
71
+ return [:cycle, atom.class.name]
72
+ end
73
+
74
+ visited[obj_id] = true
75
+
76
+ case atom
77
+ when ::Parsanol::Atoms::Entity
78
+ # Recursively resolve entity to get actual structure for hash
79
+ atom_structure(atom.parslet, visited)
80
+ when ::Parsanol::Atoms::Str
81
+ [:str, atom.str]
82
+ when ::Parsanol::Atoms::Re
83
+ [:re, atom.match]
84
+ when ::Parsanol::Atoms::Sequence
85
+ [:seq, atom.parslets.map { |p| atom_structure(p, visited) }]
86
+ when ::Parsanol::Atoms::Alternative
87
+ [:alt, atom.alternatives.map { |p| atom_structure(p, visited) }]
88
+ when ::Parsanol::Atoms::Repetition
89
+ [:rep, atom.min, atom.max, atom_structure(atom.parslet, visited)]
90
+ when ::Parsanol::Atoms::Named
91
+ [:named, atom.name.to_s, atom_structure(atom.parslet, visited)]
92
+ when ::Parsanol::Atoms::Lookahead
93
+ [:lookahead, atom.positive,
94
+ atom_structure(atom.bound_parslet, visited)]
95
+ else
96
+ [:unknown, atom.class.name]
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end