birman 0.2.1 → 0.3.0

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.
@@ -1,5 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # [file]
4
+ # purpose = "Makes a class (or a module of shared rules) a grammar"
5
+ # responsibility = "Settings, parse entry points, rule lookup and introspection"
6
+ # dependencies = ["Birman::Combinators", "Birman::RuleSet", "Birman::Nodes", "Birman::Context"]
7
+ # pattern = "Mixin with class-level DSL"
3
8
  module Birman
4
9
  # Include this to make a class a grammar. The class body also gets
5
10
  # `node` (see Nodes), so AST nodes can live beside their rules.
@@ -7,6 +12,10 @@ module Birman
7
12
  # Including it in a module makes a module of shared rules: whatever
8
13
  # includes that module (a class, or another such module) becomes a
9
14
  # grammar too.
15
+ # [module.Grammar]
16
+ # purpose = "include this in a class to make it a grammar"
17
+ # provides = ["rule / lexeme (RuleSet)", "node (Nodes)", "combinators", "settings and parse"]
18
+ # collaborators = ["Context", "Success", "Failure", "ErrorFormatter"]
10
19
  module Grammar
11
20
  include Combinators
12
21
 
@@ -26,18 +35,30 @@ module Birman
26
35
 
27
36
  # Settings are inherited by subclasses. Passing nil clears an
28
37
  # inherited one: `ignore nil`, `keywords nil`.
38
+ # [module.Grammar.ClassMethods]
39
+ # purpose = "Settings and parse entry points on a grammar class"
40
+ # notes = "Settings are inherited by subclasses; pass nil to clear one"
29
41
  module ClassMethods
30
42
  UNSET = Object.new.freeze
31
43
 
32
44
  # The rule a parse starts from.
45
+ # [method.root]
46
+ # params = ["name: Symbol | nil (omit to read)"]
47
+ # returns = "The start rule's name"
33
48
  def root(name = UNSET) = setting(:root, name)
34
49
 
35
50
  # A rule the parser matches and discards after every literal and
36
51
  # lexeme, and at the start of input. Usually :spacing (from
37
52
  # Birman::Rules::Whitespace or Comments).
53
+ # [method.ignore]
54
+ # params = ["name: Symbol | nil (omit to read)"]
55
+ # returns = "The ignore rule's name (matched and discarded after literals and lexemes)"
38
56
  def ignore(name = UNSET) = setting(:ignore, name)
39
57
 
40
58
  # Reserved words, which Birman::Rules::Identifiers won't accept.
59
+ # [method.keywords]
60
+ # params = ["*words: String (none to read; nil to clear)"]
61
+ # returns = "Array of reserved words (Rules::Identifiers rejects them as names)"
41
62
  def keywords(*words)
42
63
  return setting(:keywords) || [] if words.empty?
43
64
 
@@ -47,10 +68,40 @@ module Birman
47
68
  # These share one instance, so each rule's matcher is built once
48
69
  # rather than on every parse. Defining a rule or changing a
49
70
  # setting starts a fresh one.
50
- def parse(input) = instance.parse(input)
71
+ # [method."Grammar.parse"]
72
+ # params = ["input: String"]
73
+ # returns = "Success(value) or Failure; see Grammar#parse"
74
+ # notes = "Uses one cached instance; defining a rule or changing a setting resets it"
75
+ def parse(input) = instance.parse(input)
76
+
77
+ # [method."Grammar.parse!"]
78
+ # params = ["input: String", "formatter: #call(failure) = ErrorFormatter.new"]
79
+ # returns = "The parsed value"
80
+ # raises = ["Birman::ParseError"]
51
81
  def parse!(input, **) = instance.parse!(input, **)
52
- def to_ast = instance.to_ast
53
- def to_ebnf = EBNF.call(to_ast)
82
+
83
+ # [method."Grammar.to_ast"]
84
+ # returns = "Hash of rule name => AST, for every rule reachable from root and ignore"
85
+ def to_ast = instance.to_ast
86
+
87
+ # [method."Grammar.to_ebnf"]
88
+ # returns = "String: the grammar in PEG-flavored EBNF"
89
+ def to_ebnf = EBNF.call(to_ast)
90
+
91
+ # A standalone parser for this grammar, as Ruby source: the same
92
+ # results as parse, from code Spinel can compile to a native binary.
93
+ # [method."Grammar.to_ruby"]
94
+ # params = [
95
+ # "namespace: String, the module the parser is defined in (\"CalcParser\")",
96
+ # "lookahead_depth: Integer = 56, how deeply lookaheads may nest (see Compiler)"
97
+ # ]
98
+ # returns = "String: Ruby source defining <namespace>::Parser (new(input).parse => Success or Failure)"
99
+ # raises = [
100
+ # "ArgumentError for left-recursive cycles with no rule in common, or a cycle that can't grow, as parse",
101
+ # "Birman::Compiler::Error for what can't be compiled (.map blocks, custom matchers...)",
102
+ # "Birman::Compiler::Error for actions calling Integer() or to_i: use large_integer"
103
+ # ]
104
+ def to_ruby(namespace:, **) = Compiler.new(self, namespace:, **).to_ruby
54
105
 
55
106
  private
56
107
 
@@ -71,21 +122,31 @@ module Birman
71
122
  def keywords = self.class.keywords
72
123
 
73
124
  # Returns Success(value) or Failure(...). Never raises on bad input.
125
+ # [method.parse]
126
+ # params = ["input: String"]
127
+ # returns = "Birman::Success(value) when all of input parses, else Birman::Failure"
128
+ # raises = [
129
+ # "ArgumentError on the first parse, whatever the input, for no root rule",
130
+ # "ArgumentError on the first parse for left-recursive cycles with no rule in common",
131
+ # "ArgumentError on the first parse for a left-recursive cycle with no base case, or a base case first",
132
+ # "ArgumentError on the first parse for a reachable rule whose matcher can't be built (unnamed captures...)",
133
+ # "ArgumentError for an undefined rule, or left recursion through a custom matcher, when the parse reaches it"
134
+ # ]
135
+ # notes = "Bad input never raises: invalid UTF-8 and too-deep nesting are Failures with reason set"
74
136
  def parse(input)
75
- ctx = Context.new(input, self)
76
- start = (ignore_rule && Ref.new(ignore_rule).match(ctx, 0)&.pos) || 0
77
- result = Ref.new(root_rule).match(ctx, start)
78
- return Success.new(result.value) if result && result.pos == input.bytesize
137
+ left_recursion
138
+ if (bad = Birman.invalid_byte(input))
139
+ return build_failure(input, bad, [], reason: "invalid #{input.encoding}")
140
+ end
79
141
 
80
- ctx.fail_at(result.pos, "end of input") if result
81
- build_failure(input, ctx.furthest, ctx.expected)
82
- rescue Committed => e
83
- build_failure(input, e.pos, e.expected, committed: true)
84
- rescue SystemStackError
85
- build_failure(input, ctx.position, [], reason: "input is nested too deeply")
142
+ __match_all(input)
86
143
  end
87
144
 
88
145
  # Returns the value or raises ParseError, worded by formatter.
146
+ # [method."parse!"]
147
+ # params = ["input: String", "formatter: #call(failure) = ErrorFormatter.new"]
148
+ # returns = "The parsed value"
149
+ # raises = ["Birman::ParseError (message from formatter)"]
89
150
  def parse!(input, formatter: ErrorFormatter.new) = parse(input).value!(formatter)
90
151
 
91
152
  def matcher_for(name)
@@ -99,19 +160,80 @@ module Birman
99
160
 
100
161
  # { rule_name => ast } for every rule reachable from the root (and
101
162
  # the ignore rule), in first-use order.
102
- def to_ast
163
+ def to_ast = __reachable_asts { matcher_for(_1).to_ast }
164
+
165
+ # The grammar's left-call analysis (see LeftRecursion), built on the
166
+ # first parse or to_ruby. It raises then, and every time after, if
167
+ # the grammar has left-recursive cycles with no rule in common, or a
168
+ # cycle that can't grow (see LeftRecursion#check!); later parses only
169
+ # read the ivar. Context reads its leaders, to grow them.
170
+ def left_recursion = @left_recursion ||= LeftRecursion.new(__analyzable_asts, ignore: ignore_rule).check!
171
+
172
+ private
173
+
174
+ def __match_all(input)
175
+ ctx = Context.new(input, self, ignore_rule)
176
+ result = Ref.new(root_rule).match(ctx, ctx.start)
177
+ return Success.new(result.value) if result && result.pos == input.bytesize
178
+
179
+ ctx.fail_at(result.pos, "end of input") if result
180
+ build_failure(input, ctx.furthest, ctx.expected)
181
+ rescue Committed => e
182
+ build_failure(input, e.pos, e.expected, committed: true)
183
+ rescue SystemStackError
184
+ build_failure(input, ctx.position, [], reason: "input is nested too deeply")
185
+ end
186
+
187
+ # As to_ast, leaving out what it can't describe: undefined rules
188
+ # (the parse reports them if it gets there) and rules using a
189
+ # matcher with no to_ast. The analysis takes those to consume input
190
+ # and to call nothing, so a cycle through one is left to
191
+ # Context#memoize, and rules only it refers to aren't analyzed.
192
+ def __analyzable_asts
193
+ __reachable_asts do |name|
194
+ next unless respond_to?(:"__rule_#{name}", true)
195
+
196
+ matcher_for(name).to_ast
197
+ rescue NoMethodError => e
198
+ raise unless e.name == :to_ast && e.receiver.is_a?(Matcher)
199
+ end
200
+ end
201
+
202
+ # { rule_name => yield(rule_name) } for every rule reachable from the
203
+ # root and the ignore rule, in first-use order, skipping nil results.
204
+ def __reachable_asts
103
205
  rules = {}
104
206
  queue = [root_rule, ignore_rule].compact
105
207
  while (name = queue.shift)
106
208
  next if rules.key?(name)
107
209
 
108
- rules[name] = matcher_for(name).to_ast
210
+ rules[name] = yield(name)
109
211
  queue.concat(AST.refs(rules[name]))
110
212
  end
111
- rules
213
+ rules.compact
112
214
  end
113
215
 
114
- private
216
+ # Kernel#Integer(value, base), for actions: use it instead of Integer
217
+ # or String#to_i in a grammar you'll generate a parser from (see
218
+ # to_ruby). Past 63 bits, a Spinel binary's Integer and to_i raise or
219
+ # give 2**63 - 1; the generated parser's large_integer gives Bignums,
220
+ # as Ruby's does. Bases: Integers, or Floats truncated (Rational and
221
+ # NaN bases differ from Kernel#Integer in a generated parser). A base
222
+ # that isn't a number raises TypeError, as in Ruby 3.3+; Ruby 3.2's
223
+ # Integer ignores it.
224
+ # [method.large_integer]
225
+ # params = ["value: String or Numeric", "base: Integer = 0 (from the prefix, as Integer's)"]
226
+ # notes = "In a Spinel binary too, Floats past 63 bits convert exactly; negative bases raise there"
227
+ # returns = "Integer(value, base)"
228
+ # raises = ["ArgumentError or TypeError, as Kernel#Integer in Ruby 3.3+"]
229
+ def large_integer(value, base = 0)
230
+ raise TypeError, "no implicit conversion from nil to integer" if base.nil?
231
+ unless base.respond_to?(:to_int)
232
+ raise TypeError, "no implicit conversion of #{[true, false].include?(base) ? base : base.class} into Integer"
233
+ end
234
+
235
+ Integer(value, base)
236
+ end
115
237
 
116
238
  def build_failure(input, byte_pos, expected, committed: false, reason: nil)
117
239
  Failure.new(input:, pos: Birman.char_offset(input, byte_pos), expected: expected.uniq, committed:, reason:)
@@ -0,0 +1,409 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Birman
4
+ # Finds left recursion from a grammar's ASTs ({ rule_name => ast },
5
+ # as Grammar#to_ast returns), without parsing anything: a rule that
6
+ # can call itself, directly or through other rules, before consuming
7
+ # input would loop forever on some input. Grammar#left_recursion runs
8
+ # it on the first parse or to_ruby, and keeps it once it passes.
9
+ # Left recursion is supported, direct (a rule that calls itself) or
10
+ # indirect (through other rules), the way pegen does it: each cycle
11
+ # has a leader, a rule that every way round it passes through, and
12
+ # Context#memoize grows the leader's match from a seed. A cycle with
13
+ # no such rule, two cycles that share none, raises in check!.
14
+ class LeftRecursion
15
+ FLAGS = { "i" => Regexp::IGNORECASE, "x" => Regexp::EXTENDED, "m" => Regexp::MULTILINE,
16
+ "n" => Regexp::NOENCODING }.freeze
17
+
18
+ # Each cycle's rules (a strongly connected component of the left-call
19
+ # graph), in the order rules has them: first-use order, from
20
+ # Grammar#to_ast.
21
+ attr_reader :cycles
22
+
23
+ # { rule => its cycle's leader } for every rule in a cycle that has
24
+ # one. The leader is the first of its cycle's rules that every way
25
+ # round the cycle passes through: take it out and the rest can't
26
+ # reach each other in a loop. Only it grows; see Context#memoize.
27
+ attr_reader :leaders
28
+
29
+ # ignore: the grammar's ignore rule, which a lexeme calls after its
30
+ # token; at its start, if the token was empty.
31
+ def initialize(rules, ignore: nil)
32
+ @rules = rules
33
+ @ignore = ignore
34
+ @patterns = {}
35
+ @nullable = nullable_rules
36
+ @calls = rules.to_h { |name, ast| [name, calls(ast).uniq] }
37
+ @cycles = strongly_connected.select { cyclic?(_1) }
38
+ @cycle_of = @cycles.each_with_object({}) { |cycle, h| cycle.each { h[_1] = cycle } }
39
+ @leaders = @cycles.each_with_object({}) do |cycle, h|
40
+ leader = cycle.find { acyclic?(cycle - [_1]) } or next
41
+ cycle.each { h[_1] = leader }
42
+ end.freeze
43
+ end
44
+
45
+ # Raises ArgumentError naming the cycles of every component with no
46
+ # leader, or for the first cycle that could never grow (see
47
+ # check_growth!); returns self otherwise.
48
+ def check!
49
+ leaderless = cycles.reject { @leaders.key?(_1.first) }
50
+ unless leaderless.empty?
51
+ loops = leaderless.flat_map { cycles_with_no_rule_in_common(_1) }
52
+ names = loops.map { _1.map(&:inspect).join(" -> ") }.join("; ")
53
+ raise ArgumentError, "left recursion through cycles with no rule in common isn't supported: #{names} " \
54
+ "(each rule can call the next without consuming input)"
55
+ end
56
+ cycles.each { check_growth!(_1) }
57
+ self
58
+ end
59
+
60
+ # Can the rule match without consuming input? Undefined rules can't.
61
+ def nullable?(name) = @nullable.key?(name)
62
+
63
+ # The rules name can call at the position it starts at, before it
64
+ # consumes anything, in the order its AST mentions them.
65
+ def left_calls(name) = @calls.fetch(name, [])
66
+
67
+ def left_recursive?(name) = @cycle_of.key?(name)
68
+ def cycle(name) = @cycle_of[name]
69
+ def leader(name) = @leaders[name]
70
+
71
+ # Can node match without consuming input? Every node type gets an
72
+ # answer; anything else (a custom matcher's own AST) is assumed to
73
+ # consume, which can only hide left recursion, never invent it.
74
+ def nullable_node?(node) # rubocop:disable Metrics
75
+ case node
76
+ in [:str, s] then s.empty? # a literal's ignored spacing comes after the literal
77
+ in [:pat, source] then nullable_pattern?(source)
78
+ in [:ref, name] then nullable?(name)
79
+ in [:seq, _, parts] then parts.all? { nullable_node?(_1) }
80
+ in [:choice, alts] then alts.any? { nullable_node?(_1) }
81
+ in [:rep, min, _, inner] then min.zero? || nullable_node?(inner)
82
+ in [:not, _] | [:and, _] then true
83
+ in [:both, _, right] then nullable_node?(right) # the guard consumes nothing
84
+ in [:action, inner] then nullable_node?(inner)
85
+ in [:quiet, inner] then nullable_node?(inner)
86
+ in [:label, _, inner] then nullable_node?(inner)
87
+ in [:check, _, inner] then nullable_node?(inner) # it may reject, but it can match empty
88
+ in [:lexeme, inner] then nullable_node?(inner)
89
+ else false
90
+ end
91
+ end
92
+
93
+ private
94
+
95
+ # Least fixpoint: start with no rule nullable and add rules until
96
+ # nothing changes, so a rule that only calls itself stays non-nullable.
97
+ def nullable_rules
98
+ @nullable = {}
99
+ loop do
100
+ grown = @rules.reject { |name, ast| @nullable.key?(name) || !nullable_node?(ast) }
101
+ return @nullable if grown.empty?
102
+
103
+ grown.each_key { @nullable[_1] = true }
104
+ end
105
+ end
106
+
107
+ # Rules node calls at its start position. A seq's parts run there
108
+ # until one consumes input; lookaheads count too, since re-entering
109
+ # a rule at the same position loops just the same.
110
+ def calls(node) # rubocop:disable Metrics
111
+ case node
112
+ in [:ref, name] then [name]
113
+ in [:seq, _, parts] then leading(parts).flat_map { calls(_1) }
114
+ in [:choice, alts] then alts.flat_map { calls(_1) }
115
+ in [:both, left, right] then calls(left) + calls(right)
116
+ in [:rep, _, 0, _] then [] # at most none: inner never runs
117
+ in [:rep, _, _, inner] then calls(inner)
118
+ in [:not, inner] then calls(inner)
119
+ in [:and, inner] then calls(inner)
120
+ in [:action, inner] then calls(inner)
121
+ in [:quiet, inner] then calls(inner)
122
+ in [:label, _, inner] then calls(inner)
123
+ in [:check, _, inner] then calls(inner)
124
+ in [:lexeme, inner] then calls(inner) + (@ignore && nullable_node?(inner) ? [@ignore] : [])
125
+ else []
126
+ end
127
+ end
128
+
129
+ # A cycle grows from a first match that didn't go round it (its
130
+ # seed), so one of its rules needs an alternative that doesn't start
131
+ # with any of them: without one it never matches. And in each of its
132
+ # rules, an alternative like that has to come after the ones that do
133
+ # start with one: tried first, it wins every round, so they never
134
+ # grow. Only each rule's own choice is checked, through the wrappers
135
+ # around it, not choices nested inside its parts.
136
+ def check_growth!(cycle) # rubocop:disable Metrics
137
+ raise ArgumentError, no_base_case(cycle) unless cycle.any? { seedable?(@rules[_1], cycle) }
138
+
139
+ cycle.each do |name|
140
+ alts = alternatives(@rules[name])
141
+ base = alts.index { !enters?(_1, cycle) } or next
142
+ grows = alts.drop(base).find { enters?(_1, cycle) } or next
143
+ first, later = [alts[base], grows].map { EBNF.expr(_1, EBNF::CHOICE) }
144
+ raise ArgumentError, "in left-recursive rule :#{name}, the alternative `#{first}` comes before `#{later}`, " \
145
+ "so the rule can never grow past it; " \
146
+ "put the alternatives that start with #{names(cycle, "or")} first"
147
+ end
148
+ end
149
+
150
+ def no_base_case(cycle)
151
+ if cycle.one?
152
+ name = cycle.first
153
+ "left-recursive rule :#{name} has no alternative that doesn't start with :#{name}, " \
154
+ "so it can never match; add one that doesn't, as a base case"
155
+ else
156
+ "left-recursive rules #{names(cycle, "and")}, which can each reach the others without consuming " \
157
+ "input, have no alternative that doesn't start with one of them, so they can never match; " \
158
+ "add one that doesn't, as a base case"
159
+ end
160
+ end
161
+
162
+ def enters?(node, cycle) = calls(node).intersect?(cycle)
163
+
164
+ # ":a, :b and :c"
165
+ def names(rules, conjunction)
166
+ [rules[0...-1].map(&:inspect).join(", "), rules.last.inspect].reject(&:empty?).join(" #{conjunction} ")
167
+ end
168
+
169
+ # Can node match while its calls to the cycle's rules at its start
170
+ # fail?
171
+ def seedable?(node, cycle) # rubocop:disable Metrics
172
+ case node
173
+ in [:ref, name] then !cycle.include?(name)
174
+ in [:choice, alts] then alts.any? { seedable?(_1, cycle) }
175
+ in [:seq, _, parts] then leading(parts).all? { seedable?(_1, cycle) }
176
+ in [:rep, 0, _, _] | [:rep, _, 0, _] | [:not, _] then true
177
+ in [:rep | :label | :check, *] | [:and | :action | :quiet | :lexeme, _]
178
+ seedable?(node.last, cycle) # a wrapper: its inner node is its last element
179
+ in [:both, left, right] then seedable?(left, cycle) && seedable?(right, cycle)
180
+ else true
181
+ end
182
+ end
183
+
184
+ # The alternatives of node's choice, looking through wrappers; node
185
+ # itself if it isn't one.
186
+ def alternatives(node)
187
+ case node
188
+ in [:choice, alts] then alts
189
+ in [:action | :quiet, _] | [:label | :check, _, _] then alternatives(node.last)
190
+ else [node]
191
+ end
192
+ end
193
+
194
+ # The parts up to and including the first that consumes input.
195
+ def leading(parts) = parts.take((parts.index { !nullable_node?(_1) } || parts.size) + 1)
196
+
197
+ # The regexp is rebuilt from its inspect form and tried on "" with
198
+ # every assertion (see Assertions) taken to hold, since each holds
199
+ # somewhere: /(?=a)/ matches empty before an "a", though not in "".
200
+ # Assertions that contradict each other side by side are caught;
201
+ # other combinations that can't hold, like /(?=a)(?=b)/, still count
202
+ # as nullable. A pattern that can't be rebuilt is assumed to consume.
203
+ def nullable_pattern?(source)
204
+ @patterns.fetch(source) do
205
+ body, flags = %r{\A/(.*)/([a-z]*)\z}m.match(source)&.captures
206
+ @patterns[source] = body ? empty_match?(body, flags.chars.sum { FLAGS.fetch(_1, 0) }) : false
207
+ end
208
+ end
209
+
210
+ # Falls back to the regexp as written when taking out its assertions
211
+ # breaks it, or could make a backreference name another group: taking
212
+ # out (?=(a)) renumbers the groups after it.
213
+ def empty_match?(body, options)
214
+ stripped = Assertions.strip(body, options.anybits?(Regexp::EXTENDED))
215
+ stripped = body if stripped != body && body.match?(/\\(?:[1-9]|k[<'])/)
216
+ [stripped, body].each do |source|
217
+ return Regexp.new(source, options).match?("")
218
+ rescue RegexpError
219
+ next
220
+ end
221
+ false
222
+ end
223
+
224
+ def cyclic?(rules) = rules.size > 1 || left_calls(rules.first).include?(rules.first)
225
+
226
+ # Tarjan's algorithm over the left-call graph. Each component's rules,
227
+ # and the components, come back in rule order.
228
+ def strongly_connected
229
+ @index = {}
230
+ @low = {}
231
+ @stack = []
232
+ @components = []
233
+ @rules.each_key { visit(_1) unless @index.key?(_1) }
234
+ order = @rules.keys.each_with_index.to_h
235
+ @components.map { |c| c.sort_by { order[_1] } }.sort_by { order[_1.first] }
236
+ end
237
+
238
+ def visit(name)
239
+ @index[name] = @low[name] = @index.size
240
+ @stack.push(name)
241
+ left_calls(name).each do |callee|
242
+ next unless @rules.key?(callee)
243
+
244
+ visit(callee) unless @index.key?(callee)
245
+ @low[name] = [@low[name], @low[callee]].min if @stack.include?(callee)
246
+ end
247
+ @components << pop_component(name) if @low[name] == @index[name]
248
+ end
249
+
250
+ def pop_component(name)
251
+ component = []
252
+ component << @stack.pop until component.last == name
253
+ component
254
+ end
255
+
256
+ # Can none of names reach itself through the others' left calls?
257
+ def acyclic?(names)
258
+ done = {}
259
+ names.none? { looping?(_1, names, done, {}) }
260
+ end
261
+
262
+ def looping?(name, names, done, on_path)
263
+ return true if on_path[name]
264
+ return false if done[name]
265
+
266
+ on_path[name] = done[name] = true
267
+ left_calls(name).any? { names.include?(_1) && looping?(_1, names, done, on_path) }
268
+ ensure
269
+ on_path.delete(name)
270
+ end
271
+
272
+ # For a component with no leader, cycles no one rule is in all of
273
+ # (though two of them may share one): for each rule, the first cycle
274
+ # through the others that leaves it out.
275
+ def cycles_with_no_rule_in_common(component)
276
+ order = @rules.keys.each_with_index.to_h
277
+ cycles = component.filter_map do |left_out|
278
+ rest = component - [left_out]
279
+ rest.lazy.filter_map { path(_1, rest) }.first
280
+ end
281
+ cycles.uniq(&:to_set).each_with_index.sort_by { |cycle, i| [order[cycle.first], i] }.map(&:first)
282
+ end
283
+
284
+ # The shortest way round from start back to it through names, through
285
+ # another rule if there is one (a leader may also call itself); nil
286
+ # if there's none.
287
+ def path(start, names) # rubocop:disable Metrics/MethodLength
288
+ from = {}
289
+ queue = [start]
290
+ while (name = queue.shift)
291
+ left_calls(name).each do |callee|
292
+ if callee == start
293
+ next if name == start
294
+
295
+ return [*trail(from, name), start]
296
+ end
297
+ next if !names.include?(callee) || from.key?(callee)
298
+
299
+ from[callee] = name
300
+ queue << callee
301
+ end
302
+ end
303
+ [start, start] if left_calls(start).include?(start)
304
+ end
305
+
306
+ def trail(from, name)
307
+ names = [name]
308
+ names.unshift(from[names.first]) while from.key?(names.first)
309
+ names
310
+ end
311
+
312
+ # A regexp's source with each assertion, which matches only where the
313
+ # text around it allows, replaced by (?:), which matches anywhere:
314
+ # lookarounds, anchors (\A, ^, $, \z, \Z, \G) and \b, \B. Assertions
315
+ # side by side hold at one position, so a pair there that can't both
316
+ # hold, (?=X) with (?!X), (?<=X) with (?<!X), or \b with \B, becomes
317
+ # (?!), which matches nowhere. Only that shape: (?=a)(?=b) can't hold
318
+ # either, but stays (?:)(?:). So does a negative lookaround of what
319
+ # always matches empty, as never's (?!) or (?!a*).
320
+ # Escapes, character classes and comments are copied as they are.
321
+ # Only /x makes # start a comment; an inline (?x) doesn't, so a "[" or
322
+ # "(" in such a comment can hide an assertion after it (it's then
323
+ # taken as written, as before this rewrite).
324
+ module Assertions
325
+ module_function
326
+
327
+ # kind: "=", "!", "<=" or "<!" for a lookaround (body: its source),
328
+ # or the anchor itself ("^", "\\b"...).
329
+ Assertion = Data.define(:kind, :body)
330
+ OPPOSITE = { "=" => "!", "<=" => "<!", "\\b" => "\\B" }.freeze
331
+
332
+ def strip(source, extended)
333
+ scanner = StringScanner.new(source)
334
+ out = +""
335
+ run = []
336
+ until scanner.eos?
337
+ item = piece(scanner, extended)
338
+ next run << item if item.is_a?(Assertion)
339
+
340
+ flush(run, out, extended, quantified: item.match?(/\A[?*+{]/))
341
+ out << item
342
+ end
343
+ flush(run, out, extended, quantified: false)
344
+ out
345
+ end
346
+
347
+ # Writes out a run of assertions side by side. A quantifier after the
348
+ # last one may skip it, so then it can't fail the rest.
349
+ def flush(run, out, extended, quantified:)
350
+ held = quantified ? run[0...-1] : run
351
+ never = held.any? { |a| held.include?(Assertion.new(OPPOSITE[a.kind], a.body)) || never?(a, extended) }
352
+ out << "(?!)" if never
353
+ out << ("(?:)" * run.size)
354
+ run.clear
355
+ end
356
+
357
+ # A negative lookaround of something with no assertions of its own
358
+ # that matches "" never holds: that matches empty everywhere.
359
+ def never?(assertion, extended)
360
+ return false unless %w[! <!].include?(assertion.kind) && strip(assertion.body, extended) == assertion.body
361
+
362
+ Regexp.new(assertion.body, extended ? Regexp::EXTENDED : 0).match?("")
363
+ rescue RegexpError
364
+ false
365
+ end
366
+
367
+ # The next piece of the source: an Assertion, or text to copy.
368
+ def piece(s, extended) # rubocop:disable Metrics
369
+ if s.scan(/\\[bBAzZG]|[\^$]/) then Assertion.new(s.matched, nil)
370
+ elsif s.scan(/\(\?(<?[=!])/) then lookaround(s, s[1], extended)
371
+ elsif s.scan(/\\./m) || s.scan(/\(\?#[^)]*\)/) then s.matched
372
+ elsif s.skip(/\[/) then character_class(s)
373
+ elsif extended && s.scan(/#[^\n]*/) then s.matched
374
+ else s.getch
375
+ end
376
+ end
377
+
378
+ # The rest of a lookaround, after its "(?=" (or "(?!", "(?<=", "(?<!").
379
+ def lookaround(s, kind, extended)
380
+ start = finish = s.pos
381
+ depth = 1
382
+ until depth.zero? || s.eos?
383
+ finish = s.pos
384
+ case piece(s, extended)
385
+ when "(" then depth += 1
386
+ when ")" then depth -= 1
387
+ end
388
+ end
389
+ Assertion.new(kind, s.string.byteslice(start, finish - start))
390
+ end
391
+
392
+ # A character class (nested ones included), after its "[". A "]"
393
+ # right after "[" or "[^" is a character, not the end.
394
+ def character_class(s)
395
+ start = s.pos - 1
396
+ s.skip(/\^?\]?/)
397
+ depth = 1
398
+ while depth.positive? && !s.eos?
399
+ if s.skip(/\\./m) then next
400
+ elsif s.skip(/\[/) then depth += 1
401
+ elsif s.skip(/\]/) then depth -= 1
402
+ else s.getch
403
+ end
404
+ end
405
+ s.string.byteslice(start, s.pos - start)
406
+ end
407
+ end
408
+ end
409
+ end