parsanol 1.0.1-aarch64-linux → 1.0.2-aarch64-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.
@@ -5,15 +5,15 @@ module Parsanol
5
5
  # Transforms native AST format to Parslet-compatible format
6
6
  #
7
7
  # Native format from Rust parser:
8
- # - Strings: "text"
8
+ # - Slices: Parsanol::Slice objects (with position info)
9
9
  # - Sequences: [":sequence", item1, item2, ...]
10
10
  # - Repetitions: [":repetition", item1, item2, ...]
11
11
  # - Named captures: {"name" => value}
12
12
  #
13
13
  # Parslet format:
14
- # - Strings: "text" (with Parsanol::Slice for position info)
14
+ # - Slices: Preserved as Parsanol::Slice (with position info)
15
15
  # - Sequences: merged hash {:key1 => val1, :key2 => val2, ...}
16
- # - Repetitions: array of items (or "" if empty string-like)
16
+ # - Repetitions: array of items (or joined Slices if all are Slices)
17
17
  # - Named wrapping Repetition: {:name => [{:name => item1}, {:name => item2}, ...]}
18
18
  #
19
19
  class AstTransformer
@@ -128,10 +128,17 @@ module Parsanol
128
128
  transformed = transform(value)
129
129
 
130
130
  # Special handling for arrays that look like character repetitions
131
- # (arrays of single-character strings should be joined)
131
+ # (arrays of single-character Slices/strings should be joined)
132
132
  if transformed.is_a?(Array) && !transformed.empty? &&
133
- transformed.all? { |item| item.is_a?(String) && item.length == 1 }
134
- transformed = transformed.join
133
+ transformed.all? { |item| slice_or_string?(item) && item_length(item) == 1 }
134
+ # Join preserving position from first Slice
135
+ first_slice = transformed.find { |i| i.is_a?(::Parsanol::Slice) }
136
+ content = transformed.map { |i| slice_content(i) }.join
137
+ transformed = if first_slice
138
+ ::Parsanol::Slice.new(first_slice.offset, content, first_slice.position_cache)
139
+ else
140
+ content
141
+ end
135
142
  end
136
143
 
137
144
  # Check for UNTAGGED repetition pattern (native output):
@@ -151,11 +158,21 @@ module Parsanol
151
158
  elsif transformed.is_a?(Array)
152
159
  transform_array_value(sym_key, transformed)
153
160
  else
154
- # Simple value (string, nil, etc.) - most common case
161
+ # Simple value (Slice, string, nil, etc.) - most common case
155
162
  { sym_key => transformed }
156
163
  end
157
164
  end
158
165
 
166
+ # Get content from Slice or string
167
+ def self.slice_content(value)
168
+ value.is_a?(::Parsanol::Slice) ? value.content : value.to_s
169
+ end
170
+
171
+ # Get length of Slice or string
172
+ def self.item_length(value)
173
+ value.is_a?(::Parsanol::Slice) ? value.length : value.to_s.length
174
+ end
175
+
159
176
  # Handle repetition values (named wrapping repetition)
160
177
  def self.transform_repetition_value(sym_key, transformed)
161
178
  if transformed.is_a?(Array)
@@ -169,7 +186,9 @@ module Parsanol
169
186
  # Wrap each item with the name
170
187
  { sym_key => transformed.map { |item| { sym_key => item } } }
171
188
  end
172
- elsif transformed == EMPTY_STRING
189
+ elsif transformed.is_a?(::Parsanol::Slice) && transformed.empty?
190
+ { sym_key => EMPTY_ARRAY } # Empty repetition should be [], not empty Slice
191
+ elsif transformed.is_a?(String) && transformed == EMPTY_STRING
173
192
  { sym_key => EMPTY_ARRAY } # Empty repetition should be [], not ""
174
193
  else
175
194
  { sym_key => transformed }
@@ -180,10 +199,10 @@ module Parsanol
180
199
  def self.transform_array_value(sym_key, transformed)
181
200
  if transformed.empty?
182
201
  # For empty arrays, we need to determine if this is a repetition or sequence
183
- # Repetitions should return [], sequences should return ""
184
- # We can't tell from the value alone, so we return "" (sequence semantics)
202
+ # Repetitions should return [], sequences should return empty Slice
203
+ # We can't tell from the value alone, so we return empty Slice (sequence semantics)
185
204
  # The repetition detection in transform_single_key_hash will handle the other case
186
- { sym_key => EMPTY_STRING }
205
+ { sym_key => ::Parsanol::Slice.new(0, EMPTY_STRING, nil) }
187
206
  elsif transformed.all? { |v| v.is_a?(Hash) && v.keys.length == 1 && v.key?(sym_key) }
188
207
  # Items already have the parent key (repetition pattern) - keep as-is
189
208
  { sym_key => transformed }
@@ -242,8 +261,8 @@ module Parsanol
242
261
 
243
262
  # Flatten sequence items according to Parslet semantics:
244
263
  # 1. If ALL items are hashes, return as array (this is a repetition result)
245
- # 2. If there are named captures (hashes) among strings, return ONLY the merged hash (discard strings)
246
- # 3. If only strings, join them (or return single string)
264
+ # 2. If there are named captures (hashes) among Slices/strings, return ONLY the merged hash (discard Slices/strings)
265
+ # 3. If only Slices/strings, join them preserving position from first Slice
247
266
  # 4. Return single value if only one item
248
267
  #
249
268
  # This matches Parslet's behavior where:
@@ -264,7 +283,7 @@ module Parsanol
264
283
 
265
284
  # Single pass: categorize items
266
285
  merged_hash = {}
267
- string_parts = []
286
+ slice_or_string_parts = []
268
287
  hash_count = 0
269
288
  total_items = 0
270
289
  has_non_empty_array = false
@@ -275,8 +294,8 @@ module Parsanol
275
294
  merged_hash.merge!(item)
276
295
  hash_count += 1
277
296
  total_items += 1
278
- when String
279
- string_parts << item
297
+ when ::Parsanol::Slice, String
298
+ slice_or_string_parts << item
280
299
  total_items += 1
281
300
  when Array
282
301
  # Check if this is a non-empty array (repetition result with content)
@@ -292,8 +311,8 @@ module Parsanol
292
311
  case sub_item
293
312
  when Hash
294
313
  hash_count += 1
295
- when String
296
- string_parts << sub_item
314
+ when ::Parsanol::Slice, String
315
+ slice_or_string_parts << sub_item
297
316
  end
298
317
  end
299
318
  end
@@ -320,8 +339,8 @@ module Parsanol
320
339
  result << item
321
340
  when Array
322
341
  result.concat(item)
323
- when String
324
- # Skip unnamed strings when we have named captures
342
+ when ::Parsanol::Slice, String
343
+ # Skip unnamed Slices/strings when we have named captures
325
344
  end
326
345
  end
327
346
  return result.length == 1 ? result.first : result
@@ -382,12 +401,22 @@ module Parsanol
382
401
 
383
402
  # PARSLET SEQUENCE SEMANTICS:
384
403
  # If there are named captures (hashes) mixed with other things,
385
- # return ONLY the merged hash (discard unnamed strings)
404
+ # return ONLY the merged hash (discard unnamed Slices/strings)
386
405
  return merged_hash unless merged_hash.empty?
387
406
 
388
- # No named captures - handle strings and other items
389
- if string_parts.any?
390
- return string_parts.length == 1 ? string_parts.first : string_parts.join
407
+ # No named captures - handle Slices/strings and other items
408
+ if slice_or_string_parts.any?
409
+ # Join Slices/strings, preserving position from first Slice
410
+ first_slice = slice_or_string_parts.find { |i| i.is_a?(::Parsanol::Slice) }
411
+ content = slice_or_string_parts.map { |i| i.is_a?(::Parsanol::Slice) ? i.content : i }.join
412
+
413
+ if first_slice
414
+ # Create new Slice with combined content, preserving position from first
415
+ ::Parsanol::Slice.new(first_slice.offset, content, first_slice.position_cache)
416
+ else
417
+ # All plain strings (shouldn't happen with new decode_flat, but handle it)
418
+ slice_or_string_parts.length == 1 ? slice_or_string_parts.first : content
419
+ end
391
420
  end
392
421
 
393
422
  # Only other items (arrays, etc.)
@@ -398,36 +427,50 @@ module Parsanol
398
427
 
399
428
  # Parslet/Parsanol repetition semantics:
400
429
  # 1. Return [] for empty repetitions
401
- # 2. If all items are strings, join them
430
+ # 2. If all items are Slices (or strings), join them preserving position
402
431
  # 3. Otherwise return array
403
432
  def self.flatten_repetition(items)
404
433
  return EMPTY_ARRAY if items.empty?
405
434
 
406
435
  # Single-pass flatten and check
407
436
  flat_items = []
408
- all_strings = true
437
+ all_slices_or_strings = true
409
438
 
410
439
  items.each do |item|
411
440
  if item.is_a?(Array)
412
441
  item.each do |sub|
413
442
  flat_items << sub
414
- all_strings = false unless sub.is_a?(String)
443
+ all_slices_or_strings = false unless slice_or_string?(sub)
415
444
  end
416
445
  else
417
446
  flat_items << item
418
- all_strings = false unless item.is_a?(String)
447
+ all_slices_or_strings = false unless slice_or_string?(item)
419
448
  end
420
449
  end
421
450
 
422
451
  return EMPTY_ARRAY if flat_items.empty?
423
452
 
424
- # If all strings, join them (string-like repetition)
425
- if all_strings && flat_items.all?(String)
426
- flat_items.join
453
+ # If all Slices or strings, join them preserving position from first Slice
454
+ if all_slices_or_strings
455
+ first_slice = flat_items.find { |i| i.is_a?(::Parsanol::Slice) }
456
+ content = flat_items.map { |i| i.is_a?(::Parsanol::Slice) ? i.content : i }.join
457
+
458
+ if first_slice
459
+ # Create new Slice with combined content, preserving position from first
460
+ ::Parsanol::Slice.new(first_slice.offset, content, first_slice.position_cache)
461
+ else
462
+ # All plain strings (shouldn't happen with new decode_flat, but handle it)
463
+ content
464
+ end
427
465
  else
428
466
  flat_items
429
467
  end
430
468
  end
469
+
470
+ # Check if value is a Slice or String
471
+ def self.slice_or_string?(value)
472
+ value.is_a?(::Parsanol::Slice) || value.is_a?(String)
473
+ end
431
474
  end
432
475
 
433
476
  private_constant :AstTransformer
@@ -98,7 +98,7 @@ module Parsanol
98
98
  raise LoadError,
99
99
  "ZeroCopy mode requires native extension for direct FFI object construction. " \
100
100
  "Run `rake compile` to build the extension, or use " \
101
- "Parsanol::RubyTransform for Ruby-only parsing."
101
+ "parser.parse(input, mode: :ruby) for pure Ruby parsing."
102
102
  end
103
103
 
104
104
  grammar_json = Parsanol::Native.serialize_grammar(root)
@@ -2,19 +2,20 @@
2
2
 
3
3
  # Parsanol Transform Mode Options
4
4
  #
5
- # This module provides three transformation modes for parsing:
5
+ # This module provides the ZeroCopy transformation mode for maximum performance:
6
6
  #
7
- # 1. RubyTransform - Parse in Rust/Ruby, Transform in Ruby (default, most flexible)
8
- # 2. Serialized - Parse + Transform in Rust, JSON output (requires native extension)
9
- # 3. ZeroCopy - Direct FFI object construction (requires native extension, fastest)
7
+ # ZeroCopy - Direct FFI object construction (requires native extension, fastest)
10
8
  #
11
9
  # Usage:
12
10
  # class MyParser < Parsanol::Parser
13
- # include Parsanol::RubyTransform # or Serialized, or ZeroCopy
11
+ # include Parsanol::ZeroCopy
14
12
  # rule(:number) { match('[0-9]').repeat(1).as(:int) }
15
13
  # root(:number)
14
+ #
15
+ # output_types(number: MyNumberClass)
16
16
  # end
17
+ #
18
+ # For standard parsing, use the Parse Modes API instead:
19
+ # parser.parse(input, mode: :native) # or :ruby, :json
17
20
 
18
- require 'parsanol/options/ruby_transform'
19
- require 'parsanol/options/serialized'
20
21
  require 'parsanol/options/zero_copy'
@@ -82,22 +82,32 @@ module Parsanol
82
82
 
83
83
  # Unified parsing interface with mode selection support.
84
84
  #
85
+ # All parse modes return results with Slice objects that contain
86
+ # position information (offset, length, line, column). This enables
87
+ # source code extraction, error reporting, and remark attachment.
88
+ #
85
89
  # @param input [String] the string to parse
86
90
  # @param mode_or_opts [Symbol, Hash] parsing mode or options hash
87
91
  # @param kwargs [Hash] additional keyword options
88
92
  #
89
93
  # Modes:
90
- # - :ruby - Pure Ruby parsing (default, always available)
94
+ # - :ruby - Pure Ruby parsing (always available, returns Slices with position)
91
95
  # - :native - Use Rust extension if available, fallback to Ruby
92
- # - :json - Return JSON string representation of parse tree
96
+ # - :json - Return JSON string with position info for each value
93
97
  #
94
98
  # Options:
95
99
  # - :reporter - Custom error reporter instance
96
100
  # - :prefix - Allow partial matching (default: false)
97
101
  #
98
- # @return [Hash, Array, String, Parsanol::Slice] parsed result
102
+ # @return [Hash, Array, Parsanol::Slice] parsed result with position info
99
103
  # @raise [Parsanol::ParseFailed] when parsing fails
100
104
  #
105
+ # @example Parse and access position info
106
+ # result = parser.parse("hello")
107
+ # result[:name].offset # => 0
108
+ # result[:name].line_and_column # => [1, 1]
109
+ # result[:name].to_s # => "hello"
110
+ #
101
111
  def parse(input, mode_or_opts = {}, **kwargs)
102
112
  if mode_or_opts.is_a?(Hash)
103
113
  # Legacy API: parse(input, options={})
@@ -150,24 +160,31 @@ module Parsanol
150
160
  #
151
161
 
152
162
  # Native extension parsing with Ruby fallback.
163
+ # Returns results with Slice objects containing position info.
153
164
  #
154
165
  # @param input [String] input to parse
155
166
  # @param opts [Hash] parsing options
156
- # @return [Object] parse result
167
+ # @return [Object] parse result with Slice objects for position info
157
168
  #
158
169
  def parse_native(input, opts)
159
170
  if Parsanol::Native.available?
160
- Parsanol::Native.parse_parslet_compatible(root, input)
171
+ # Build line cache for position info
172
+ line_cache = Parsanol::Source::LineCache.new
173
+ line_cache.scan_for_line_endings(0, input)
174
+
175
+ # Parse with position info (now the default)
176
+ Parsanol::Native.parse_parslet_compatible(root, input, line_cache)
161
177
  else
162
178
  parse_ruby(input, opts)
163
179
  end
164
180
  end
165
181
 
166
- # JSON output mode - returns JSON string representation.
182
+ # JSON output mode - returns JSON with position info.
183
+ # All Slice values are serialized with their position information.
167
184
  #
168
185
  # @param input [String] input to parse
169
186
  # @param opts [Hash] parsing options
170
- # @return [String] JSON representation of parse tree
187
+ # @return [String] JSON representation with position info
171
188
  #
172
189
  def parse_json(input, opts)
173
190
  if Parsanol::Native.available?
@@ -142,5 +142,66 @@ module Parsanol
142
142
  def inspect
143
143
  "#{content.inspect}@#{offset}"
144
144
  end
145
+
146
+ # JSON serialization --------------------------------------------------------
147
+
148
+ # JSON serialization returns the full object with position info.
149
+ # This is the default behavior - position info is ALWAYS included.
150
+ #
151
+ # @return [String] JSON representation with value and position
152
+ def to_json(*)
153
+ as_json.to_json(*)
154
+ end
155
+
156
+ # Returns a hash with full position information for JSON serialization.
157
+ # Line and column are always included when a position cache is available.
158
+ #
159
+ # @return [Hash] hash with value, offset, length, and line/column
160
+ def as_json(_options = {})
161
+ result = {
162
+ 'value' => content,
163
+ 'offset' => offset,
164
+ 'length' => length
165
+ }
166
+
167
+ if position_cache
168
+ line, column = line_and_column
169
+ result['line'] = line
170
+ result['column'] = column
171
+ end
172
+
173
+ result
174
+ end
175
+
176
+ # Returns a SourceSpan representing this slice's position
177
+ #
178
+ # @param input [String, nil] the original input (needed for line/column)
179
+ # @return [Parsanol::SourceSpan] span object
180
+ def to_span(_input = nil)
181
+ start_pos = if position_cache
182
+ line, column = line_and_column
183
+ SourcePosition.new(offset: offset, line: line, column: column)
184
+ else
185
+ SourcePosition.new(offset: offset, line: 1, column: 1)
186
+ end
187
+
188
+ end_offset = offset + length
189
+ end_pos = if position_cache
190
+ line, column = position_cache.line_and_column(end_offset)
191
+ SourcePosition.new(offset: end_offset, line: line, column: column)
192
+ else
193
+ SourcePosition.new(offset: end_offset, line: 1, column: 1)
194
+ end
195
+
196
+ SourceSpan.new(start_pos: start_pos, end_pos: end_pos)
197
+ end
198
+
199
+ # Extract this slice's content from the original input string
200
+ #
201
+ # @param input [String] the original input string
202
+ # @return [String] the slice content extracted from input
203
+ def extract_from(input)
204
+ input.byteslice(offset, length)
205
+ end
145
206
  end
146
207
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Parsanol
4
- VERSION = '1.0.1'
4
+ VERSION = '1.0.2'
5
5
  end
@@ -34,7 +34,7 @@ Gem::Specification.new do |spec|
34
34
  LICENSE
35
35
  Rakefile
36
36
  README.adoc
37
- parsanol-ruby.gemspec
37
+ parsanol.gemspec
38
38
  Cargo.toml
39
39
  Cargo.lock
40
40
  ]
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: parsanol
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.1
4
+ version: 1.0.2
5
5
  platform: aarch64-linux
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-03-03 00:00:00.000000000 Z
11
+ date: 2026-03-05 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rake
@@ -196,7 +196,6 @@ files:
196
196
  - lib/parsanol/incremental_parser.rb
197
197
  - lib/parsanol/interval_tree.rb
198
198
  - lib/parsanol/lazy_result.rb
199
- - lib/parsanol/lexer.rb
200
199
  - lib/parsanol/mermaid.rb
201
200
  - lib/parsanol/native.rb
202
201
  - lib/parsanol/native/parser.rb
@@ -210,8 +209,6 @@ files:
210
209
  - lib/parsanol/optimizers/quantifier_optimizer.rb
211
210
  - lib/parsanol/optimizers/sequence_optimizer.rb
212
211
  - lib/parsanol/options.rb
213
- - lib/parsanol/options/ruby_transform.rb
214
- - lib/parsanol/options/serialized.rb
215
212
  - lib/parsanol/options/zero_copy.rb
216
213
  - lib/parsanol/parallel.rb
217
214
  - lib/parsanol/parser.rb
@@ -244,7 +241,7 @@ files:
244
241
  - lib/parsanol/wasm/parsanol.js
245
242
  - lib/parsanol/wasm/parslet.d.ts
246
243
  - lib/parsanol/wasm_parser.rb
247
- - parsanol-ruby.gemspec
244
+ - parsanol.gemspec
248
245
  homepage: https://github.com/parsanol/parsanol-ruby
249
246
  licenses:
250
247
  - MIT
@@ -1,144 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'parsanol/native'
4
-
5
- module Parsanol
6
- # Generic lexer for fast tokenization
7
- #
8
- # Create a lexer by subclassing and defining tokens:
9
- #
10
- # class JsonLexer < Parsanol::Lexer
11
- # token :string, /"[^"]*"/
12
- # token :number, /-?[0-9]+(\.[0-9]+)?/
13
- # token :true, /true/
14
- # token :false, /false/
15
- # token :null, /null/
16
- # token :lbrace, /\{/
17
- # token :rbrace, /\}/
18
- # token :lbracket, /\[/
19
- # token :rbracket, /\]/
20
- # token :colon, /:/
21
- # token :comma, /,/
22
- #
23
- # ignore /\s+/
24
- # end
25
- #
26
- # lexer = JsonLexer.new
27
- # tokens = lexer.tokenize('{"name": "test"}')
28
- #
29
- class Lexer
30
- class << self
31
- # Define a token pattern
32
- #
33
- # @param name [Symbol] Token type name
34
- # @param pattern [Regexp] Pattern to match
35
- # @param priority [Integer] Priority for conflict resolution (higher = preferred)
36
- # @param block [Proc] Optional block to transform the matched value
37
- def token(name, pattern, priority: 0, &block)
38
- token_definitions << Definition.new(
39
- name: name.to_s,
40
- pattern: pattern.source,
41
- priority: priority,
42
- ignore: false,
43
- transform: block
44
- )
45
- end
46
-
47
- # Define patterns to ignore (e.g., whitespace, comments)
48
- #
49
- # @param pattern [Regexp] Pattern to ignore
50
- def ignore(pattern)
51
- token_definitions << Definition.new(
52
- name: '__ignore__',
53
- pattern: pattern.source,
54
- priority: 0,
55
- ignore: true,
56
- transform: nil
57
- )
58
- end
59
-
60
- # Define keywords (identifiers with higher priority)
61
- #
62
- # @param keywords [Array<Symbol>] Keyword names
63
- # @param priority [Integer] Priority (default: 100)
64
- def keyword(*keywords, priority: 100)
65
- keywords.each do |kw|
66
- token_definitions << Definition.new(
67
- name: kw.to_s.upcase,
68
- pattern: Regexp.new(Regexp.escape(kw.to_s), Regexp::IGNORECASE).source,
69
- priority: priority,
70
- ignore: false,
71
- transform: nil
72
- )
73
- end
74
- end
75
-
76
- # Get token definitions for this lexer class
77
- #
78
- # @return [Array<Definition>] Token definitions
79
- def token_definitions
80
- @token_definitions ||= []
81
- end
82
-
83
- # Inherit token definitions from parent class
84
- def inherited(subclass)
85
- super
86
- subclass.instance_variable_set(:@token_definitions, token_definitions.dup)
87
- end
88
- end
89
-
90
- # Token definition
91
- Definition = Struct.new(:name, :pattern, :priority, :ignore, :transform)
92
-
93
- # Initialize the lexer
94
- def initialize
95
- @lexer_id = nil
96
- @transforms = build_transforms
97
- end
98
-
99
- # Tokenize input string
100
- #
101
- # @param input [String] Input to tokenize
102
- # @return [Array<Hash>] Array of tokens with type, value, and location
103
- def tokenize(input)
104
- ensure_lexer_created
105
-
106
- tokens = Native.tokenize_with_lexer(@lexer_id, input)
107
-
108
- # Apply any transforms
109
- tokens.map do |token|
110
- transform = @transforms[token['type']]
111
- if transform
112
- token = token.dup
113
- token['value'] = transform.call(token['value'])
114
- end
115
- token
116
- end
117
- end
118
-
119
- private
120
-
121
- def ensure_lexer_created
122
- return if @lexer_id
123
-
124
- definitions = self.class.token_definitions.map do |d|
125
- {
126
- 'name' => d.name,
127
- 'pattern' => d.pattern,
128
- 'priority' => d.priority,
129
- 'ignore' => d.ignore
130
- }
131
- end
132
-
133
- @lexer_id = Native.create_lexer(definitions)
134
- end
135
-
136
- def build_transforms
137
- transforms = {}
138
- self.class.token_definitions.each do |d|
139
- transforms[d.name] = d.transform if d.transform && !d.ignore
140
- end
141
- transforms
142
- end
143
- end
144
- end