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.
- checksums.yaml +7 -0
- data/HISTORY.txt +98 -0
- data/LICENSE +23 -0
- data/README.adoc +905 -0
- data/Rakefile +141 -0
- data/lib/parsanol/3.2/parsanol_native.so +0 -0
- data/lib/parsanol/3.3/parsanol_native.so +0 -0
- data/lib/parsanol/3.4/parsanol_native.so +0 -0
- data/lib/parsanol/4.0/parsanol_native.so +0 -0
- data/lib/parsanol/ast_visitor.rb +122 -0
- data/lib/parsanol/atoms/alternative.rb +123 -0
- data/lib/parsanol/atoms/base.rb +208 -0
- data/lib/parsanol/atoms/can_flatten.rb +194 -0
- data/lib/parsanol/atoms/capture.rb +38 -0
- data/lib/parsanol/atoms/context.rb +357 -0
- data/lib/parsanol/atoms/context_optimized.rb +38 -0
- data/lib/parsanol/atoms/custom.rb +110 -0
- data/lib/parsanol/atoms/cut.rb +66 -0
- data/lib/parsanol/atoms/dsl.rb +96 -0
- data/lib/parsanol/atoms/dynamic.rb +39 -0
- data/lib/parsanol/atoms/entity.rb +75 -0
- data/lib/parsanol/atoms/ignored.rb +37 -0
- data/lib/parsanol/atoms/infix.rb +167 -0
- data/lib/parsanol/atoms/lookahead.rb +85 -0
- data/lib/parsanol/atoms/named.rb +74 -0
- data/lib/parsanol/atoms/re.rb +83 -0
- data/lib/parsanol/atoms/repetition.rb +277 -0
- data/lib/parsanol/atoms/scope.rb +35 -0
- data/lib/parsanol/atoms/sequence.rb +195 -0
- data/lib/parsanol/atoms/str.rb +109 -0
- data/lib/parsanol/atoms/visitor.rb +91 -0
- data/lib/parsanol/atoms.rb +46 -0
- data/lib/parsanol/buffer.rb +133 -0
- data/lib/parsanol/builder_callbacks.rb +353 -0
- data/lib/parsanol/cause.rb +122 -0
- data/lib/parsanol/context.rb +39 -0
- data/lib/parsanol/convenience.rb +36 -0
- data/lib/parsanol/edit_tracker.rb +111 -0
- data/lib/parsanol/error_reporter/contextual.rb +99 -0
- data/lib/parsanol/error_reporter/deepest.rb +120 -0
- data/lib/parsanol/error_reporter/tree.rb +63 -0
- data/lib/parsanol/error_reporter.rb +100 -0
- data/lib/parsanol/expression/treetop.rb +154 -0
- data/lib/parsanol/expression.rb +106 -0
- data/lib/parsanol/fast_mode.rb +187 -0
- data/lib/parsanol/first_set.rb +79 -0
- data/lib/parsanol/grammar_builder.rb +179 -0
- data/lib/parsanol/incremental_parser.rb +182 -0
- data/lib/parsanol/interval_tree.rb +226 -0
- data/lib/parsanol/lazy_result.rb +179 -0
- data/lib/parsanol/mermaid.rb +142 -0
- data/lib/parsanol/native/batch_decoder.rb +255 -0
- data/lib/parsanol/native/dynamic.rb +238 -0
- data/lib/parsanol/native/parser.rb +102 -0
- data/lib/parsanol/native/serializer.rb +252 -0
- data/lib/parsanol/native/transformer.rb +604 -0
- data/lib/parsanol/native/types.rb +29 -0
- data/lib/parsanol/native.rb +223 -0
- data/lib/parsanol/optimizer.rb +85 -0
- data/lib/parsanol/optimizers/choice_optimizer.rb +78 -0
- data/lib/parsanol/optimizers/cut_inserter.rb +182 -0
- data/lib/parsanol/optimizers/lookahead_optimizer.rb +56 -0
- data/lib/parsanol/optimizers/quantifier_optimizer.rb +60 -0
- data/lib/parsanol/optimizers/sequence_optimizer.rb +97 -0
- data/lib/parsanol/options/zero_copy.rb +127 -0
- data/lib/parsanol/options.rb +21 -0
- data/lib/parsanol/parallel.rb +128 -0
- data/lib/parsanol/parser.rb +242 -0
- data/lib/parsanol/parslet.rb +151 -0
- data/lib/parsanol/pattern/binding.rb +91 -0
- data/lib/parsanol/pattern.rb +162 -0
- data/lib/parsanol/pool.rb +219 -0
- data/lib/parsanol/pools/array_pool.rb +75 -0
- data/lib/parsanol/pools/buffer_pool.rb +182 -0
- data/lib/parsanol/pools/position_pool.rb +92 -0
- data/lib/parsanol/pools/slice_pool.rb +64 -0
- data/lib/parsanol/position.rb +94 -0
- data/lib/parsanol/resettable.rb +29 -0
- data/lib/parsanol/result.rb +46 -0
- data/lib/parsanol/result_builder.rb +208 -0
- data/lib/parsanol/result_stream.rb +266 -0
- data/lib/parsanol/rig/rspec.rb +71 -0
- data/lib/parsanol/rope.rb +81 -0
- data/lib/parsanol/scope.rb +104 -0
- data/lib/parsanol/slice.rb +160 -0
- data/lib/parsanol/source/line_cache.rb +102 -0
- data/lib/parsanol/source.rb +185 -0
- data/lib/parsanol/source_location.rb +167 -0
- data/lib/parsanol/streaming_parser.rb +124 -0
- data/lib/parsanol/string_view.rb +198 -0
- data/lib/parsanol/transform.rb +226 -0
- data/lib/parsanol/version.rb +5 -0
- data/lib/parsanol/wasm/README.md +80 -0
- data/lib/parsanol/wasm/package.json +51 -0
- data/lib/parsanol/wasm/parsanol.js +252 -0
- data/lib/parsanol/wasm/parslet.d.ts +129 -0
- data/lib/parsanol/wasm_parser.rb +240 -0
- data/lib/parsanol.rb +278 -0
- data/parsanol.gemspec +67 -0
- 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
|