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,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Parsanol
4
+ module Atoms
5
+ # Base class for creating custom parser atoms.
6
+ #
7
+ # Custom atoms allow extending Parsanol with domain-specific matching logic
8
+ # that cannot be expressed with the built-in combinators.
9
+ #
10
+ # @example Custom atom for matching indentation-sensitive content
11
+ # class IndentAtom < Parsanol::Atoms::Custom
12
+ # def initialize(expected_indent)
13
+ # @expected_indent = expected_indent
14
+ # super()
15
+ # end
16
+ #
17
+ # # Required: Implement try_match
18
+ # def try_match(source, context, consume_all)
19
+ # pos = source.pos
20
+ # indent = count_indent(source)
21
+ #
22
+ # if indent == @expected_indent
23
+ # content = read_until_newline(source)
24
+ # [true, content]
25
+ # else
26
+ # source.pos = pos # Restore position on failure
27
+ # [false, nil]
28
+ # end
29
+ # end
30
+ #
31
+ # private
32
+ #
33
+ # def count_indent(source)
34
+ # # ... implementation ...
35
+ # end
36
+ # end
37
+ #
38
+ # # Usage in parser
39
+ # class MyParser < Parsanol::Parser
40
+ # rule(:indented_line) { IndentAtom.new(2) }
41
+ # end
42
+ #
43
+ class Custom < Base
44
+ # Required: Implement this method to define matching behavior
45
+ #
46
+ # @param source [Parsanol::Source] The input source with position tracking
47
+ # @param context [Parsanol::Atoms::Context] Parse context for memoization
48
+ # @param consume_all [Boolean] If true, must consume entire input
49
+ # @return [Array<Boolean, Object>] Tuple of [success, result]
50
+ # - success: true if match succeeded, false otherwise
51
+ # - result: matched value on success, nil on failure
52
+ #
53
+ # @note You MUST restore source.bytepos on failure for proper backtracking
54
+ #
55
+ def try_match(source, context, consume_all)
56
+ raise NotImplementedError,
57
+ "Custom atoms must implement #try_match(source, context, consume_all)"
58
+ end
59
+
60
+ # Override of Base#try that delegates to try_match
61
+ # Handles error reporting and result wrapping
62
+ #
63
+ # @api private
64
+ def try(source, context, consume_all)
65
+ success, result = try_match(source, context, consume_all)
66
+
67
+ if success
68
+ [true, result]
69
+ else
70
+ # Generate error cause for reporting
71
+ context.err(
72
+ self,
73
+ source,
74
+ "Failed to match custom atom: #{self.class.name}",
75
+ )
76
+ end
77
+ end
78
+
79
+ # Optional: Override to provide first set for optimization
80
+ # Returns the set of characters/strings this atom can match at start
81
+ #
82
+ # @return [Set<String>, nil] First set, or nil if not determinable
83
+ def first_set
84
+ nil # Unknown by default
85
+ end
86
+
87
+ # Optional: Override to enable caching for this atom
88
+ # Return false for context-dependent matching (e.g., indentation)
89
+ #
90
+ # @return [Boolean] true if atom can be cached
91
+ def cacheable?
92
+ true
93
+ end
94
+
95
+ # Optional: Override to provide custom serialization for native parser
96
+ # Return nil if atom cannot be serialized (must use pure Ruby mode)
97
+ #
98
+ # @return [Hash, nil] JSON-serializable representation
99
+ def to_native_format
100
+ nil # Not serializable by default
101
+ end
102
+
103
+ # Override to_s_inner for debug printing
104
+ # @api private
105
+ def to_s_inner(_prec = nil)
106
+ "custom(#{self.class.name})"
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Cut operator for PEG grammars
4
+ #
5
+ # A cut operator (↑) instructs the parser to discard backtrack information
6
+ # at a specific point. This enables more aggressive cache eviction and can
7
+ # reduce space complexity from O(n) to O(1).
8
+ #
9
+ # Reference: Mizushima et al. (2010) "Packrat Parsers Can Handle Practical
10
+ # Grammars in Mostly Constant Space"
11
+ #
12
+ # Example:
13
+ #
14
+ # rule(:statement) {
15
+ # str('if').cut >> condition >> then_clause |
16
+ # str('while').cut >> condition >> body |
17
+ # str('print').cut >> expression
18
+ # }
19
+ #
20
+ # After 'if' succeeds, the cut discards backtrack info for 'while' and 'print'.
21
+ # This means if the parse fails later in the 'if' branch, we won't try the
22
+ # other alternatives.
23
+ #
24
+ module Parsanol
25
+ module Atoms
26
+ class Cut < Parsanol::Atoms::Base
27
+ attr_reader :parslet
28
+
29
+ def initialize(parslet)
30
+ super()
31
+ @parslet = parslet
32
+ end
33
+
34
+ def try(source, context, consume_all)
35
+ # First, try to match the parslet
36
+ success, value = parslet.apply(source, context, consume_all)
37
+
38
+ return [success, value] unless success
39
+
40
+ # On success, signal to context that a cut has occurred
41
+ # This allows the context to:
42
+ # 1. Mark the current position as a cut point
43
+ # 2. Empty the backtrack stack (we won't backtrack past here)
44
+ # 3. Aggressively evict cache entries before this position
45
+ context.cut!(source.bytepos) if context.respond_to?(:cut!)
46
+
47
+ [success, value]
48
+ end
49
+
50
+ # Cut doesn't need caching - it's a thin wrapper
51
+ def cached?
52
+ false
53
+ end
54
+
55
+ def to_s_inner(prec)
56
+ "#{parslet.to_s(prec)}↑"
57
+ end
58
+
59
+ # FIRST set of cut is same as wrapped parslet
60
+ # Cut doesn't change matching behavior, only affects backtracking
61
+ def compute_first_set
62
+ parslet.first_set
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Parser composition DSL - chainable methods for building parser atoms.
4
+ # All atoms can use these methods to combine into larger parsers.
5
+ #
6
+ # Inspired by Parslet (MIT License).
7
+
8
+ module Parsanol
9
+ module Atoms
10
+ module DSL
11
+ # Repeats the current atom between min and max times.
12
+ # If max is nil, there is no upper limit.
13
+ #
14
+ # @example
15
+ # str('a').repeat # match zero or more 'a's
16
+ # str('a').repeat(1, 3) # match 1-3 `a`s
17
+ def repeat(min = 0, max = nil)
18
+ Parsanol::Atoms::Repetition.new(self, min, max)
19
+ end
20
+
21
+ # Matches atom optionally (0 or 1 times).
22
+ # Result is nil if not present, otherwise the matched value.
23
+ #
24
+ # @example
25
+ # str('foo').maybe # => nil or 'foo'
26
+ def maybe
27
+ Parsanol::Atoms::Repetition.new(self, 0, 1, :maybe)
28
+ end
29
+
30
+ # Ignores the result of a match - returns nil always.
31
+ #
32
+ # @example
33
+ # str('foo').ignore # => nil (not 'foo')
34
+ def ignore
35
+ Parsanol::Atoms::Ignored.new(self)
36
+ end
37
+
38
+ # Chains two atoms in sequence.
39
+ #
40
+ # @example
41
+ # str('a') >> str('b')
42
+ def >>(other)
43
+ Parsanol::Atoms::Sequence.new(self, other)
44
+ end
45
+
46
+ # Chains two atoms as alternatives (ordered choice).
47
+ #
48
+ # @example
49
+ # str('a') | str('b') # matches 'a' or `b`
50
+ def |(other)
51
+ Parsanol::Atoms::Alternative.new(self, other)
52
+ end
53
+
54
+ # Negative lookahead - succeeds only if atom is absent.
55
+ #
56
+ # @example
57
+ # str('a').absent?
58
+ def absent?
59
+ Parsanol::Atoms::Lookahead.new(self, false)
60
+ end
61
+
62
+ # Positive lookahead - succeeds only if atom is present.
63
+ #
64
+ # @example
65
+ # str('a').present?
66
+ def present?
67
+ Parsanol::Atoms::Lookahead.new(self, true)
68
+ end
69
+
70
+ # Labels a match for tree output.
71
+ #
72
+ # @example
73
+ # str('a').as(:b) # => {:b => 'a'}
74
+ def as(name)
75
+ Parsanol::Atoms::Named.new(self, name)
76
+ end
77
+
78
+ # Captures match result for later reference.
79
+ #
80
+ # @example
81
+ # str('a').capture(:first) >> dynamic { str(ctx.captures[:first]) }
82
+ def capture(name)
83
+ Parsanol::Atoms::Capture.new(self, name)
84
+ end
85
+
86
+ # Commit point - prevents backtracking after successful match.
87
+ # Use with caution: cuts prevent backtracking to alternatives.
88
+ #
89
+ # @example
90
+ # str('if').cut >> condition >> body |
91
+ def cut
92
+ Parsanol::Atoms::Cut.new(self)
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Evaluates a block at parse time. The result from the block must be a parser
4
+ # (something which implements #apply). In the first case, the parser will then
5
+ # be applied to the input, creating the result.
6
+ #
7
+ # Dynamic parses are never cached.
8
+ #
9
+ # Example:
10
+ # dynamic { rand < 0.5 ? str('a') : str('b') }
11
+ #
12
+ module Parsanol
13
+ module Atoms
14
+ class Dynamic < Parsanol::Atoms::Base
15
+ attr_reader :block
16
+
17
+ def initialize(block)
18
+ @block = block
19
+ end
20
+
21
+ def cached?
22
+ false
23
+ end
24
+
25
+ def try(source, context, consume_all)
26
+ # Phase 55: Cache @block ivar to reduce lookup overhead
27
+ block = @block
28
+ result = block.call(source, context)
29
+
30
+ # Result is a parslet atom.
31
+ result.apply(source, context, consume_all)
32
+ end
33
+
34
+ def to_s_inner(_prec)
35
+ "dynamic { ... }"
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Named rule wrapper that provides lazy evaluation and caching for grammar
4
+ # rules. Rules are defined as Entity atoms and named, and they can be
5
+ # referenced by other rules with automatic cycle detection.
6
+ #
7
+ # @example
8
+ # class MyParser < Parsanol::Parser
9
+ # rule(:expression) { str('a') >> str('b') }
10
+ # root(:expression)
11
+ # end
12
+ #
13
+ # MyParser.new.parse('ab') # => ["a", "b"]
14
+ #
15
+ module Parsanol
16
+ module Atoms
17
+ class Entity < Parsanol::Atoms::Base
18
+ attr_reader :rule_name, :block_definition
19
+
20
+ # Alias for backward compatibility
21
+ alias name rule_name
22
+
23
+ def initialize(name, label_or_opts = {}, &body)
24
+ super()
25
+ @rule_name = name
26
+ # Support both old API (label string) and new API (options hash)
27
+ @options = if label_or_opts.is_a?(Hash)
28
+ label_or_opts
29
+ else
30
+ { label: label_or_opts }
31
+ end
32
+ @body = body
33
+ @cached_atom = nil
34
+ # Set label on self for display purposes
35
+ self.label = @options[:label] if @options[:label]
36
+ end
37
+
38
+ # Evaluates the rule body, returns cached result.
39
+ def parslet
40
+ return @cached_atom unless @cached_atom.nil?
41
+
42
+ @cached_atom = @body.call
43
+
44
+ raise_not_implemented if @cached_atom.nil?
45
+
46
+ @cached_atom.label = @options[:label] if @options[:label]
47
+ @cached_atom
48
+ end
49
+
50
+ def try(source, context, consume_all)
51
+ atom = parslet
52
+ atom.apply(source, context, consume_all)
53
+ end
54
+
55
+ # Entities don't need caching since the underlying atom is already cached.
56
+ def cached?
57
+ false
58
+ end
59
+
60
+ def to_s_inner(_prec)
61
+ rule_name.to_s.upcase
62
+ end
63
+
64
+ private
65
+
66
+ def raise_not_implemented
67
+ trace_lines = caller.grep_v(/#{Regexp.escape(__FILE__)}/)
68
+ error_message = "rule '#{@rule_name}' has not been implemented, but already used?"
69
+ exception = NotImplementedError.new(error_message)
70
+ exception.set_backtrace(trace_lines)
71
+ raise exception
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Ignores the result of a match, Useful for cases where you want to match
4
+ # prefix or suffix without returning any.
5
+
6
+ #
7
+ # @example
8
+ # str('foo') # will return 'foo',
9
+ # str('foo').ignore # will return nil
10
+ #
11
+ # Inspired by Parslet (MIT License).
12
+
13
+ module Parsanol
14
+ module Atoms
15
+ class Ignored < Parsanol::Atoms::Base
16
+ attr_reader :wrapped_atom
17
+
18
+ def initialize(atom)
19
+ super()
20
+ @wrapped_atom = atom
21
+ end
22
+
23
+ def apply(source, context, consume_all)
24
+ ok, result = @wrapped_atom.apply(source, context, consume_all)
25
+
26
+ return [false, result] unless ok
27
+
28
+ # Success - return nil instead of the matched value
29
+ [true, nil]
30
+ end
31
+
32
+ def to_s_inner(prec)
33
+ "ignored(#{@wrapped_atom.to_s(prec)})"
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Infix expression parser using precedence climbing algorithm.
4
+ # Parses mathematical-style expressions with configurable operators.
5
+ #
6
+ # @example Basic usage
7
+ # element = match('[0-9]').repeat(1)
8
+ # operations = [
9
+ # [str('+'), 1, :left],
10
+ # [str('*'), 2, :left]
11
+ # ]
12
+ # infix = Parsanol::Atoms::Infix.new(element, *operations)
13
+ #
14
+ # Inspired by Parslet (MIT License).
15
+ # Algorithm reference: http://eli.thegreenplace.net/2012/08/02/parsing-expressions-by-precedence-climbing/
16
+
17
+ module Parsanol
18
+ module Atoms
19
+ class Infix < Parsanol::Atoms::Base
20
+ attr_reader :base_element, :operator_table, :result_combiner
21
+
22
+ # Creates a new infix expression parser.
23
+ #
24
+ # @param base_element [Parsanol::Atoms::Base] parser for atomic operands
25
+ # @param operations [Array<Array>] operator definitions [atom, precedence, associativity]
26
+ # @yield block to combine left, operator, right into result
27
+ def initialize(base_element, operations, &combiner)
28
+ super()
29
+ @base_element = base_element
30
+ @operator_table = operations
31
+ @result_combiner = combiner || default_combiner
32
+ end
33
+
34
+ # Attempts to parse an infix expression from the source.
35
+ #
36
+ # @param source [Parsanol::Source] input source
37
+ # @param context [Parsanol::Atoms::Context] parsing context
38
+ # @param consume_all [Boolean] whether to consume all input
39
+ # @return [Array] success/error tuple
40
+ def try(source, context, consume_all)
41
+ catch(:parse_error) do
42
+ raw_result = climb_precedence(source, context, consume_all)
43
+ structured_result = build_result_tree(raw_result)
44
+ return succ(structured_result)
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ # Default combiner creates nested hash structure
51
+ def default_combiner
52
+ lambda do |left_side, operator, right_side|
53
+ { left: left_side, op: operator, right: right_side }
54
+ end
55
+ end
56
+
57
+ # Converts flat array representation to nested structure.
58
+ # Input: ['1', '+', ['2', '*', '3']]
59
+ # Output: { left: '1', op: '+', right: { left: '2', op: '*', right: '3' } }
60
+ #
61
+ # @param expression [Object] array or leaf value
62
+ # @return [Object] structured result
63
+ def build_result_tree(expression)
64
+ return expression unless expression.is_a?(Array)
65
+
66
+ combiner = @result_combiner
67
+ accumulator = expression.shift
68
+
69
+ until expression.empty?
70
+ operator_token, right_operand = expression.shift(2)
71
+
72
+ if right_operand.is_a?(Array)
73
+ # Recursively process nested expressions
74
+ right_operand = build_result_tree(right_operand)
75
+ end
76
+
77
+ accumulator = combiner.call(accumulator, operator_token,
78
+ right_operand)
79
+ end
80
+
81
+ accumulator
82
+ end
83
+
84
+ # Main precedence climbing loop.
85
+ # Parses operands and operators, respecting precedence and associativity.
86
+ #
87
+ # @param source [Parsanol::Source] input source
88
+ # @param context [Parsanol::Atoms::Context] parsing context
89
+ # @param consume_all [Boolean] consume all flag
90
+ # @param min_precedence [Integer] minimum precedence to continue (default: 1)
91
+ # @return [Object] parsed expression
92
+ def climb_precedence(source, context, consume_all, min_precedence = 1)
93
+ element_parser = @base_element
94
+ expression_parts = []
95
+
96
+ # Must match at least one element to start
97
+ ok, first_value = element_parser.apply(source, context, false)
98
+ unless ok
99
+ throw :parse_error,
100
+ context.err(self, source, "Expected #{element_parser.inspect}",
101
+ [first_value])
102
+ end
103
+
104
+ expression_parts << flatten(first_value, true)
105
+
106
+ # Continue while operators match
107
+ loop do
108
+ saved_position = source.bytepos
109
+ operator_match, precedence, associativity = try_match_operator(
110
+ source, context, false
111
+ )
112
+
113
+ # No operator found - done with this level
114
+ break unless operator_match
115
+
116
+ if precedence >= min_precedence
117
+ # Calculate next minimum precedence based on associativity
118
+ next_min = associativity == :left ? precedence + 1 : precedence
119
+
120
+ expression_parts << operator_match
121
+ expression_parts << climb_precedence(source, context, consume_all,
122
+ next_min)
123
+ else
124
+ # Operator has lower precedence - backtrack and return
125
+ source.bytepos = saved_position
126
+ return simplify_result(expression_parts)
127
+ end
128
+ end
129
+
130
+ simplify_result(expression_parts)
131
+ end
132
+
133
+ # Attempts to match any operator from the operator table.
134
+ #
135
+ # @param source [Parsanol::Source] input source
136
+ # @param context [Parsanol::Atoms::Context] parsing context
137
+ # @param consume_all [Boolean] consume all flag
138
+ # @return [Array, nil] [matched_value, precedence, associativity] or nil
139
+ def try_match_operator(source, context, consume_all)
140
+ operators = @operator_table
141
+
142
+ operators.each do |op_parser, prec, assoc|
143
+ ok, value = op_parser.apply(source, context, consume_all)
144
+ return [flatten(value, true), prec, assoc] if ok
145
+ end
146
+
147
+ nil
148
+ end
149
+
150
+ # Simplifies single-element results to avoid unnecessary nesting.
151
+ #
152
+ # @param result [Array] expression parts
153
+ # @return [Object] simplified result
154
+ def simplify_result(result)
155
+ result.length == 1 ? result.first : result
156
+ end
157
+
158
+ public
159
+
160
+ # Returns string representation for debugging
161
+ def to_s_inner(_precedence)
162
+ op_list = @operator_table.map { |op, _, _| op.inspect }.join(", ")
163
+ "infix_expression(#{@base_element.inspect}, [#{op_list}])"
164
+ end
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Lookahead assertion - checks for pattern presence/absence without consuming.
4
+ # Position is always restored after the check.
5
+ #
6
+ # @example Positive lookahead (must be present)
7
+ # str('foo').present? # succeeds if 'foo' ahead
8
+ #
9
+ # @example Negative lookahead (must not be present)
10
+ # str('foo').absent? # succeeds if 'foo' not ahead
11
+ #
12
+ module Parsanol
13
+ module Atoms
14
+ class Lookahead < Parsanol::Atoms::Base
15
+ # @return [Boolean] true for positive, false for negative
16
+ attr_reader :positive
17
+
18
+ # @return [Parsanol::Atoms::Base] parser to check
19
+ attr_reader :bound_parslet
20
+
21
+ # Creates a new lookahead.
22
+ #
23
+ # @param parser [Parsanol::Atoms::Base] parser to check
24
+ # @param is_positive [Boolean] positive vs negative
25
+ def initialize(parser, is_positive = true)
26
+ super()
27
+ @positive = is_positive
28
+ @bound_parslet = parser
29
+
30
+ # Pre-built error components
31
+ @should_start = ["Input should start with ", parser].freeze
32
+ @should_not_start = ["Input should not start with ", parser].freeze
33
+ end
34
+
35
+ # Tests lookahead without consuming input.
36
+ #
37
+ # @param source [Parsanol::Source] input
38
+ # @param context [Parsanol::Atoms::Context] context
39
+ # @param consume_all [Boolean] ignored
40
+ # @return [Array(Boolean, Object)] result
41
+ def try(source, context, consume_all)
42
+ # Save position - never consume
43
+ saved = source.bytepos
44
+
45
+ matched, = @bound_parslet.apply(source, context, consume_all)
46
+
47
+ # Always restore
48
+ source.bytepos = saved
49
+
50
+ if @positive
51
+ # Positive: succeed if matched
52
+ return ok(nil) if matched
53
+
54
+ context.err_at(self, source, @should_start, source.bytepos)
55
+ else
56
+ # Negative: succeed if not matched
57
+ if matched
58
+ return context.err_at(self, source, @should_not_start,
59
+ source.bytepos)
60
+ end
61
+
62
+ ok(nil)
63
+ end
64
+ end
65
+
66
+ precedence LOOKAHEAD
67
+
68
+ # String representation.
69
+ #
70
+ # @param prec [Integer] precedence
71
+ # @return [String]
72
+ def to_s_inner(prec)
73
+ symbol = @positive ? "&" : "!"
74
+ "#{symbol}#{@bound_parslet.to_s(prec)}"
75
+ end
76
+
77
+ # FIRST set is always EPSILON (zero-width).
78
+ #
79
+ # @return [Set]
80
+ def compute_first_set
81
+ Set.new([Parsanol::FirstSet::EPSILON])
82
+ end
83
+ end
84
+ end
85
+ end