flexr 1.0.0 → 1.1.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.
Files changed (75) hide show
  1. checksums.yaml +4 -4
  2. data/Rakefile +74 -28
  3. data/benchmark/golden/calculator_lexer.sha256 +1 -1
  4. data/benchmark/golden/json_lexer.sha256 +1 -1
  5. data/benchmark/golden/regexp_tokenizer.sha256 +1 -1
  6. data/benchmark/golden/ruby_subset_lexer.sha256 +1 -1
  7. data/benchmark/golden/toy_lang_lexer.sha256 +1 -1
  8. data/benchmark/golden/with_lrama_lexer.sha256 +1 -1
  9. data/benchmark/golden/with_racc_lexer.sha256 +1 -1
  10. data/docs/README.md +2 -1
  11. data/docs/explanation/backends.md +13 -10
  12. data/docs/explanation/matching-semantics.md +4 -2
  13. data/docs/explanation/security-model.md +10 -3
  14. data/docs/explanation/unicode-and-encoding.md +3 -1
  15. data/docs/how-to/deploy-a-standalone-lexer.md +5 -3
  16. data/docs/how-to/generate-a-lexer.md +3 -3
  17. data/docs/how-to/handle-errors.md +4 -1
  18. data/docs/how-to/run-a-lexer-at-runtime.md +5 -0
  19. data/docs/how-to/track-token-locations.md +2 -1
  20. data/docs/how-to/tune-performance.md +3 -3
  21. data/docs/perf-log.md +16 -0
  22. data/docs/reference/actions.md +5 -2
  23. data/docs/reference/diagnostics.md +8 -0
  24. data/docs/reference/dsl.md +19 -5
  25. data/docs/reference/errors.md +6 -3
  26. data/docs/reference/generated-artifacts.md +29 -2
  27. data/docs/reference/public-api.md +7 -2
  28. data/docs/reference/regexp.md +8 -6
  29. data/docs/reference/runtime.md +20 -3
  30. data/docs/reference/tokens-and-locations.md +6 -4
  31. data/docs/releases/v1.1.0.md +44 -0
  32. data/lib/flexr/action_resolver.rb +15 -0
  33. data/lib/flexr/artifact_writer.rb +61 -0
  34. data/lib/flexr/automaton/accel.rb +9 -4
  35. data/lib/flexr/automaton/analysis.rb +47 -6
  36. data/lib/flexr/automaton/backend_cost_model.rb +40 -0
  37. data/lib/flexr/automaton/compiler.rb +50 -71
  38. data/lib/flexr/automaton/dfa.rb +106 -19
  39. data/lib/flexr/automaton/minimizer.rb +79 -31
  40. data/lib/flexr/automaton/nfa.rb +45 -13
  41. data/lib/flexr/automaton/types.rb +14 -0
  42. data/lib/flexr/cli.rb +11 -5
  43. data/lib/flexr/codegen/direct.rb +5 -41
  44. data/lib/flexr/codegen/table.rb +175 -69
  45. data/lib/flexr/codegen.rb +8 -0
  46. data/lib/flexr/configuration.rb +37 -0
  47. data/lib/flexr/dsl.rb +105 -39
  48. data/lib/flexr/errors.rb +1 -0
  49. data/lib/flexr/generated.rb +61 -35
  50. data/lib/flexr/generator.rb +47 -82
  51. data/lib/flexr/importer.rb +4 -40
  52. data/lib/flexr/options.rb +5 -3
  53. data/lib/flexr/rake_task.rb +6 -1
  54. data/lib/flexr/regexp/ast.rb +5 -2
  55. data/lib/flexr/regexp/normalizer.rb +62 -16
  56. data/lib/flexr/regexp/parser.rb +121 -46
  57. data/lib/flexr/regexp/tokenizer.rb +192 -67
  58. data/lib/flexr/runtime/buffer.rb +115 -9
  59. data/lib/flexr/runtime/core.rb +214 -47
  60. data/lib/flexr/runtime/errors.rb +64 -4
  61. data/lib/flexr/runtime/interpreter.rb +178 -71
  62. data/lib/flexr/runtime.rb +70 -0
  63. data/lib/flexr/source/passthrough.rb +62 -9
  64. data/lib/flexr/source/prism_reader.rb +175 -46
  65. data/lib/flexr/source/static_eval.rb +40 -4
  66. data/lib/flexr/source.rb +6 -0
  67. data/lib/flexr/unicode/data/properties.rb +1 -1
  68. data/lib/flexr/unicode/data.rb +11 -0
  69. data/lib/flexr/unicode/property.rb +3 -5
  70. data/lib/flexr/unicode/reference_regexp.rb +4 -0
  71. data/lib/flexr/unicode/version.rb +7 -0
  72. data/lib/flexr/version.rb +1 -1
  73. data/lib/flexr.rb +9 -76
  74. data/tools/coverage.rb +6 -1
  75. metadata +12 -1
@@ -10,14 +10,22 @@ Lexer.new(input,
10
10
  filename: nil,
11
11
  error_mode: :raise,
12
12
  max_token_size: 16 * 1024 * 1024,
13
+ max_lookahead_size: nil,
14
+ max_buffer_size: 64 * 1024 * 1024,
13
15
  max_state_stack: 1024,
16
+ max_steps: nil,
17
+ cancellation: nil,
18
+ retain_input: true,
14
19
  chunk_size: 64 * 1024)
15
20
  ```
16
21
 
17
22
  `input` must be a `String` or an object responding to `read`. `filename` is
18
23
  copied into locations and lexical errors. `error_mode` is `:raise`, `:token`,
19
24
  or `:panic`; unknown values fall back to raising when an unmatched byte is
20
- handled. Size limits are non-negative, and `chunk_size` must be positive.
25
+ handled. `max_lookahead_size` defaults to `max_token_size`. `max_steps: nil`
26
+ leaves the step count unbounded; `cancellation` may be a callable accepting
27
+ zero arguments or the lexer. Size limits are non-negative, and `chunk_size`
28
+ must be positive.
21
29
 
22
30
  ## Consumption
23
31
 
@@ -27,11 +35,13 @@ handled. Size limits are non-negative, and `chunk_size` must be positive.
27
35
  | `tokens` | Array containing every token until EOF |
28
36
  | `each_token` | Enumerator without a block; otherwise yields tokens and returns `self` |
29
37
  | `racc_next_token` | `[type, value]`, or `[false, "$end"]` at EOF |
30
- | `input` | The buffered input string |
38
+ | `input` | The currently retained input string |
31
39
  | `buffer` | Internal buffered input object; use only for integrations that need it |
32
40
  | `filename` | The configured filename |
33
41
  | `error_mode` | The configured default input error mode |
34
42
  | `max_token_size` | The configured token-size limit |
43
+ | `max_lookahead_size` | The configured trailing-context limit |
44
+ | `steps` | Work units consumed by scanning |
35
45
 
36
46
  `each_token` yields two arguments instead of one when `token_kind :yield` is
37
47
  selected. `tokens` always returns an array of token values.
@@ -46,4 +56,11 @@ class or another lexer instance.
46
56
 
47
57
  IO input is read lazily in chunks. UTF-8 input is checked at codepoint
48
58
  boundaries, while binary input is consumed byte by byte. Invalid UTF-8 becomes
49
- an unmatched-byte error rather than being silently normalized.
59
+ an unmatched-byte error rather than being silently normalized. With
60
+ `retain_input: false`, bytes before the current token are released after each
61
+ match; `input` and `binary_input` then expose only the retained window.
62
+
63
+ `max_token_size` is checked before an action can shorten or materialize an
64
+ oversized match. `max_lookahead_size` separately bounds trailing context, and
65
+ `max_buffer_size` bounds bytes retained at one time. `max_steps` and
66
+ `cancellation` bound CPU work cooperatively.
@@ -24,10 +24,12 @@ token.
24
24
  | `line_begin` / `line_end` | One-based line range |
25
25
  | `column_begin` / `column_end` | One-based character columns for UTF-8, byte columns for binary input |
26
26
 
27
- Columns are computed lazily by default. `option :eager_columns` fills them while
28
- the location is created. The end line and column identify the position after
29
- the token's last byte, so a token ending at the start of the next line may have
30
- an end column of 1.
27
+ Line and column positions are tracked incrementally while input is consumed,
28
+ so creating locations does not rescan the input prefix. Column members are
29
+ filled lazily by default; `option :eager_columns` fills them while the location
30
+ is created. The end line and column identify the position after the token's
31
+ last byte, so a token ending at the start of the next line may have an end
32
+ column of 1.
31
33
 
32
34
  `more` extends the location from the first match to the final match. Trailing
33
35
  context is not part of the location because it is not consumed.
@@ -0,0 +1,44 @@
1
+ # flexr v1.1.0
2
+
3
+ Release date: 2026-08-13
4
+
5
+ v1.1.0 strengthens the runtime, generated, and standalone lexer contract while
6
+ keeping the stable public API focused on deterministic, byte-oriented lexing.
7
+
8
+ ## Highlights
9
+
10
+ - Runtime, generated, and standalone modes now share action precedence,
11
+ nested-state behavior, static-evaluation rules, immutable compiled
12
+ specifications, and structured error outcomes.
13
+ - Runtime resource usage is bounded with token, lookahead, buffer, step, state
14
+ stack, cancellation, and non-progress guards. Streaming input supports
15
+ sliding-buffer retention and incremental location tracking.
16
+ - Unicode properties and POSIX classes use the vendored Unicode 15.1.0 data
17
+ through the DFA path, including property-aware longest-match behavior and
18
+ Unicode-safe empty-match progress.
19
+ - Regexp parsing and normalization now fail closed for unsupported syntax and
20
+ cover empty-language AST nodes, symbolic repetition, class intersections,
21
+ trailing hyphens, shorthand classes, and exact first-match counterexamples.
22
+ - Compiler and generated-runtime paths use indexed construction, Hopcroft
23
+ minimization, lazy transition representations, backend cost selection, and
24
+ state-specific scanner fast paths.
25
+ - Generated artifact writes are collision-safe, atomic, no-op aware, and carry
26
+ schema, runtime ABI, compiler, and Unicode metadata.
27
+
28
+ ## Compatibility notes
29
+
30
+ - Stable backends remain `table`, `direct`, and `auto`.
31
+ - `firstmatch` remains experimental and intentionally does not provide the
32
+ longest-match guarantee.
33
+ - Generated files should be regenerated with flexr v1.1.0 when upgrading from
34
+ v1.0.x. Older generated artifacts remain loadable when their artifact
35
+ metadata is compatible.
36
+ - Unicode behavior is pinned to the vendored Unicode 15.1.0 snapshot rather
37
+ than the host Ruby version.
38
+
39
+ ## Verification
40
+
41
+ The release candidate passed the repository's RSpec, RuboCop, documentation,
42
+ generation, parity, differential, fuzz, Unicode, coverage, benchmark, and gem
43
+ build gates on the release environment. See [the release procedure](../RELEASING.md)
44
+ for the commands to repeat before tagging and publishing.
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flexr
4
+ module ActionResolver
5
+ module_function
6
+
7
+ def resolve(skip:, emit:, block:, default:)
8
+ return :skip if skip
9
+ return [:emit, emit.to_sym] if emit
10
+ return block if block
11
+
12
+ default
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tempfile"
4
+
5
+ module Flexr
6
+ module ArtifactWriter
7
+ module_function
8
+
9
+ def default_generated_path(source_path)
10
+ return source_path.sub(/\.flexr\.rb\z/, ".rb") if source_path.end_with?(".flexr.rb")
11
+ return source_path.sub(/\.rb\z/, ".generated.rb") if source_path.end_with?(".rb")
12
+
13
+ "#{source_path}.generated.rb"
14
+ end
15
+
16
+ def write!(path, content, source_path: nil)
17
+ ensure_distinct!(source_path, path) if source_path
18
+ return :unchanged if File.file?(path) && File.binread(path) == content.b
19
+
20
+ atomic_replace(path, content)
21
+ :written
22
+ end
23
+
24
+ def ensure_distinct!(source_path, output_path)
25
+ same_path = File.expand_path(source_path) == File.expand_path(output_path)
26
+ same_file = File.exist?(source_path) && File.exist?(output_path) && File.identical?(source_path, output_path)
27
+ return unless same_path || same_file
28
+
29
+ raise ArgumentError, "refusing to overwrite input file: #{source_path}"
30
+ end
31
+
32
+ def atomic_replace(path, content)
33
+ expanded = File.expand_path(path)
34
+ directory = File.dirname(expanded)
35
+ basename = File.basename(expanded)
36
+ mode = File.exist?(expanded) ? File.stat(expanded).mode & 0o7777 : 0o666 & ~File.umask
37
+ temporary_path = nil
38
+
39
+ Tempfile.create([".#{basename}.", ".tmp"], directory, binmode: true) do |temporary|
40
+ temporary_path = temporary.path
41
+ temporary.write(content)
42
+ temporary.flush
43
+ temporary.fsync
44
+ temporary.chmod(mode)
45
+ temporary.close
46
+ File.rename(temporary_path, expanded)
47
+ temporary_path = nil
48
+ end
49
+ sync_directory(directory)
50
+ ensure
51
+ File.unlink(temporary_path) if temporary_path && File.exist?(temporary_path)
52
+ end
53
+
54
+ def sync_directory(directory)
55
+ File.open(directory, File::RDONLY, &:fsync)
56
+ rescue SystemCallError, IOError
57
+ # Some supported filesystems do not allow fsync on directories. The file
58
+ # itself has already been synced before the atomic rename.
59
+ end
60
+ end
61
+ end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Flexr
4
4
  module Automaton
5
- Region = Struct.new(:state, :bytes, :regexp, keyword_init: true)
5
+ Region = Struct.new(:state, :bytes, :regexp, :utf8_regexp, keyword_init: true)
6
6
 
7
7
  module Accel
8
8
  module_function
@@ -12,13 +12,18 @@ module Flexr
12
12
  bytes = Analysis.self_loop_set(dfa, state)
13
13
  next if bytes.empty?
14
14
 
15
- Region.new(state: state, bytes: bytes.freeze, regexp: regexp_for(bytes))
15
+ Region.new(
16
+ state: state, bytes: bytes.freeze,
17
+ regexp: regexp_for(bytes, binary: true),
18
+ utf8_regexp: bytes.all? { |byte| byte < 128 } ? regexp_for(bytes, binary: false) : nil
19
+ )
16
20
  end
17
21
  end
18
22
 
19
- def regexp_for(bytes)
23
+ def regexp_for(bytes, binary: true)
20
24
  source = bytes_to_source(bytes)
21
- ::Regexp.new("(?:[#{source}])+", ::Regexp::NOENCODING)
25
+ options = binary ? ::Regexp::NOENCODING : 0
26
+ ::Regexp.new("(?:[#{source}])+", options)
22
27
  end
23
28
 
24
29
  def bytes_to_source(bytes)
@@ -19,13 +19,13 @@ module Flexr
19
19
  end
20
20
 
21
21
  def self_loop_set(dfa, state)
22
- dfa.transitions[state].each_with_index.with_object([]) do |(destination, class_id), result|
23
- next unless destination == state
24
-
25
- dfa.ec.each_with_index do |value, byte|
26
- result << byte if value == class_id
27
- end
22
+ representatives = Array.new(dfa.class_count)
23
+ dfa.ec.each_with_index { |class_id, byte| representatives[class_id] ||= byte }
24
+ self_loops = representatives.each_index.map do |class_id|
25
+ byte = representatives.fetch(class_id)
26
+ byte && dfa.transition(state, byte) == state
28
27
  end
28
+ dfa.ec.each_index.select { |byte| self_loops.fetch(dfa.ec.fetch(byte)) }
29
29
  end
30
30
 
31
31
  def dead_states(dfa)
@@ -33,6 +33,47 @@ module Flexr
33
33
  dfa.accepts[state].empty? && dfa.transitions[state].compact.all? { |destination| destination == state }
34
34
  end
35
35
  end
36
+
37
+ def firstmatch_counterexample(first, second)
38
+ queue = [[first.start, second.start, false, +"".b]]
39
+ visited = { [first.start, second.start, false] => true }
40
+ bytes = joint_byte_representatives(first, second)
41
+ cursor = 0
42
+
43
+ while cursor < queue.length
44
+ first_state, second_state, first_seen, input = queue.fetch(cursor)
45
+ cursor += 1
46
+ bytes.each do |byte|
47
+ next_first = first_state && first.transition(first_state, byte)
48
+ next_second = second_state && second.transition(second_state, byte)
49
+ next unless next_second
50
+
51
+ next_input = input + byte.chr(Encoding::BINARY)
52
+ next_first_seen = first_seen || accepting?(first, next_first)
53
+ return next_input if next_first_seen && accepting?(second, next_second) && !accepting?(first, next_first)
54
+
55
+ key = [next_first, next_second, next_first_seen]
56
+ next if visited[key]
57
+
58
+ visited[key] = true
59
+ queue << [next_first, next_second, next_first_seen, next_input]
60
+ end
61
+ end
62
+ nil
63
+ end
64
+
65
+ def joint_byte_representatives(first, second)
66
+ representatives = {}
67
+ 256.times do |byte|
68
+ key = [first.ec[byte], second.ec[byte]]
69
+ representatives[key] ||= byte
70
+ end
71
+ representatives.values.sort_by { |byte| [byte.between?(32, 126) ? 0 : 1, byte] }
72
+ end
73
+
74
+ def accepting?(dfa, state)
75
+ state && !dfa.accepts.fetch(state).empty?
76
+ end
36
77
  end
37
78
  end
38
79
  end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flexr
4
+ module Automaton
5
+ module BackendCostModel
6
+ Metrics = Struct.new(:dense_bytes, :packed_bytes, :lookup_samples, keyword_init: true) do
7
+ def direct_score
8
+ dense_bytes + lookup_samples
9
+ end
10
+
11
+ def table_score
12
+ packed_bytes + (lookup_samples * 3)
13
+ end
14
+ end
15
+
16
+ module_function
17
+
18
+ def choose(compiled)
19
+ metrics = compiled.machines.values.map { |machine| metrics_for(machine.dfa) }
20
+ direct_score = metrics.sum(&:direct_score)
21
+ table_score = metrics.sum(&:table_score)
22
+ direct_score < table_score ? :direct : :table
23
+ end
24
+
25
+ def metrics_for(dfa)
26
+ cells = dfa.states * dfa.class_count
27
+ overrides = dfa.transitions.sum do |row|
28
+ default = row.tally.max_by { |_value, count| count }&.first
29
+ row.count { |value| value != default }
30
+ end
31
+ packed_integers = (overrides * 2) + (dfa.states * 2)
32
+ Metrics.new(
33
+ dense_bytes: cells * 4,
34
+ packed_bytes: packed_integers * 4,
35
+ lookup_samples: dfa.states * [dfa.class_count, 16].min
36
+ ).freeze
37
+ end
38
+ end
39
+ end
40
+ end
@@ -2,9 +2,6 @@
2
2
 
3
3
  module Flexr
4
4
  module Automaton
5
- CompiledSpec = Struct.new(:machines, :rules, :states, :stats, :diagnostics, keyword_init: true)
6
- Machine = Struct.new(:dfa, :state_name, keyword_init: true)
7
-
8
5
  class Compiler
9
6
  def initialize(spec)
10
7
  @spec = spec
@@ -48,11 +45,6 @@ module Flexr
48
45
  normalized = []
49
46
  rules.each do |rule|
50
47
  rule.pattern_conditions = []
51
- if reference_rule?(rule)
52
- validate_reference_patterns(rule)
53
- next
54
- end
55
-
56
48
  rule.patterns.each_with_index do |pattern, pattern_index|
57
49
  regexp = pattern.is_a?(::Regexp) ? pattern : ::Regexp.new(::Regexp.escape(pattern.to_s))
58
50
  encoding = regexp.encoding == Encoding::BINARY ? Encoding::BINARY : @spec.encoding
@@ -78,32 +70,6 @@ module Flexr
78
70
  DFA.new(transitions: [[nil]], accepts: [[]], ec: Array.new(256, 0), class_count: 1, start: 0, rule_ids: [])
79
71
  end
80
72
 
81
- def reference_rule?(rule)
82
- rule.patterns.any? { |pattern| reference_pattern?(pattern) }
83
- end
84
-
85
- def reference_pattern?(pattern)
86
- return false unless pattern.is_a?(::Regexp)
87
- return true if pattern.source.match?(/\\[pP]\{/) || pattern.source.match?(/\[:(?:\^)?[a-z]+:\]/)
88
-
89
- @spec.options[:unicode] == true && @spec.encoding != Encoding::BINARY &&
90
- pattern.source.match?(/\\[dDwWsS]/)
91
- end
92
-
93
- def validate_reference_patterns(rule)
94
- rule.patterns.each_with_index do |pattern, pattern_index|
95
- regexp = pattern.is_a?(::Regexp) ? pattern : ::Regexp.new(::Regexp.escape(pattern.to_s))
96
- encoding = regexp.encoding == Encoding::BINARY ? Encoding::BINARY : @spec.encoding
97
- parser = Regexp::Parser.new(regexp.source, options: regexp.options, encoding: encoding,
98
- unicode: @spec.options[:unicode] == true)
99
- ast = parser.parse
100
- _body, bol_only, end_anchor = strip_anchors(ast)
101
- rule.pattern_conditions[pattern_index] = Acceptance.new(
102
- rule_index: rule.index, pattern_index: pattern_index, bol_only: bol_only, end_anchor: end_anchor
103
- )
104
- end
105
- end
106
-
107
73
  def strip_anchors(ast)
108
74
  children = ast.is_a?(Regexp::AST::Seq) ? ast.children.dup : [ast]
109
75
  children.shift while children.first.is_a?(Regexp::AST::Empty)
@@ -131,35 +97,38 @@ module Flexr
131
97
 
132
98
  def contains_anchor?(node)
133
99
  return true if node.is_a?(Regexp::AST::Anchor)
100
+ return contains_anchor?(node.child) if node.is_a?(Regexp::AST::Repeat) || node.is_a?(Regexp::AST::Star)
134
101
  return false unless node.respond_to?(:children)
135
102
 
136
103
  node.children.any? { |child| contains_anchor?(child) }
137
104
  end
138
105
 
139
106
  def subset_construction(nfa, ec, class_count)
140
- representatives = Array.new(class_count)
141
- ec.each_with_index { |class_id, byte| representatives[class_id] ||= byte }
142
- start_set = epsilon_closure(nfa, 1 << nfa.start)
107
+ closures = {}
108
+ closure_for = lambda do |state|
109
+ closures[state] ||= epsilon_closure_for(nfa, state)
110
+ end
111
+ transition_closures = indexed_transition_closures(nfa, ec, closure_for)
112
+ start_set = closure_for.call(nfa.start)
143
113
  sets = [start_set]
144
114
  ids = { start_set => 0 }
145
115
  transitions = []
146
116
  accepts = []
147
117
  queue = [start_set]
118
+ queue_index = 0
119
+ limit = @spec.options.fetch(:max_dfa_states, 100_000)
120
+ limit = 100_000 unless limit.is_a?(Integer) && limit.positive?
148
121
 
149
- until queue.empty?
150
- set = queue.shift
122
+ while queue_index < queue.length
123
+ set = queue.fetch(queue_index)
124
+ queue_index += 1
151
125
  state_id = ids.fetch(set)
152
126
  transitions[state_id] ||= Array.new(class_count)
153
127
  accepts[state_id] = accepting_rules(nfa, set)
154
- class_count.times do |class_id|
155
- moved = move(nfa, set, representatives[class_id])
156
- next if moved.zero?
157
- closure = epsilon_closure(nfa, moved)
128
+ move_closures(set, transition_closures).each do |class_id, closure|
158
129
  destination = ids[closure]
159
130
  unless destination
160
131
  destination = sets.length
161
- limit = @spec.options.fetch(:max_dfa_states, 100_000)
162
- limit = 100_000 unless limit.is_a?(Integer) && limit.positive?
163
132
  if destination >= limit
164
133
  message = "DFA state limit exceeded while compiling rules #{@active_rule_ids.join(', ')}"
165
134
  diagnostic = Diagnostics.error("FLEXR-E006", message,
@@ -180,13 +149,12 @@ module Flexr
180
149
  Minimizer.minimize(dfa)
181
150
  end
182
151
 
183
- def epsilon_closure(nfa, set)
184
- closure = set
185
- stack = []
186
- nfa.states.each_index { |id| stack << id if set.anybits?(1 << id) }
152
+ def epsilon_closure_for(nfa, state)
153
+ closure = 1 << state
154
+ stack = [state]
187
155
  until stack.empty?
188
- state = stack.pop
189
- nfa.states[state].epsilon.each do |target|
156
+ current = stack.pop
157
+ nfa.states[current].epsilon.each do |target|
190
158
  next if closure.anybits?(1 << target)
191
159
 
192
160
  closure |= 1 << target
@@ -196,35 +164,52 @@ module Flexr
196
164
  closure
197
165
  end
198
166
 
199
- def move(nfa, set, byte)
200
- moved = 0
201
- nfa.states.each_index do |state|
202
- next if set.nobits?(1 << state)
203
-
204
- nfa.states[state].transitions.each do |transition|
205
- next unless byte.between?(transition.lo, transition.hi)
167
+ def indexed_transition_closures(nfa, ec, closure_for)
168
+ nfa.states.map do |state|
169
+ raw = {}
170
+ state.transitions.each do |transition|
171
+ transition.lo.upto(transition.hi).map { |byte| ec.fetch(byte) }.uniq.each do |class_id|
172
+ raw[class_id] = raw.fetch(class_id, 0) | (1 << transition.to)
173
+ end
174
+ end
175
+ raw.transform_values do |targets|
176
+ each_state_id(targets).reduce(0) { |closure, target| closure | closure_for.call(target) }
177
+ end
178
+ end
179
+ end
206
180
 
207
- moved |= 1 << transition.to
181
+ def move_closures(set, transition_closures)
182
+ each_state_id(set).with_object({}) do |state, moves|
183
+ transition_closures.fetch(state).each do |class_id, closure|
184
+ moves[class_id] = moves.fetch(class_id, 0) | closure
208
185
  end
209
186
  end
210
- moved
211
187
  end
212
188
 
213
189
  def accepting_rules(nfa, set)
214
- rules = []
215
- nfa.states.each_index do |state|
216
- next if set.nobits?(1 << state)
217
-
218
- rules.concat(nfa.states[state].accepts)
190
+ rules = each_state_id(set).with_object([]) do |state, result|
191
+ result.concat(nfa.states[state].accepts)
219
192
  end
220
193
  rules.uniq.sort_by { |acceptance| [acceptance.rule_index, acceptance.pattern_index] }
221
194
  end
222
195
 
196
+ def each_state_id(set)
197
+ return enum_for(__method__, set) unless block_given?
198
+
199
+ remaining = set
200
+ until remaining.zero?
201
+ bit = remaining & -remaining
202
+ yield bit.bit_length - 1
203
+ remaining ^= bit
204
+ end
205
+ end
206
+
223
207
  def validate_rules
224
208
  raise CompileError, "firstmatch requires option :experimental" if @spec.backend == :firstmatch && !@spec.options[:experimental]
225
209
 
226
210
  @spec.rules.each do |rule|
227
211
  raise CompileError, "rule #{rule.index} has no pattern" if rule.patterns.empty?
212
+ parse_regexp(rule.trailing) if rule.trailing.is_a?(::Regexp)
228
213
  next if @spec.options[:allow_empty_match]
229
214
 
230
215
  rule.patterns.each do |pattern|
@@ -249,8 +234,6 @@ module Flexr
249
234
  present = compiled.machines.values.flat_map do |machine|
250
235
  machine.dfa.accepts.filter_map { |acceptances| acceptances.min_by(&:rule_index)&.rule_index }
251
236
  end
252
- reference_rules = @spec.rules.select { |rule| reference_rule?(rule) && rule_active_anywhere?(rule) }
253
- present.concat(reference_rules.map(&:index))
254
237
  shadowers = shadowed_rules(compiled)
255
238
  diagnostics.concat(@spec.rules.reject { |rule| present.include?(rule.index) }.map do |rule|
256
239
  winners = shadowers.fetch(rule.index, []).uniq.sort
@@ -344,10 +327,6 @@ module Flexr
344
327
  rules.combination(2).to_a
345
328
  end
346
329
 
347
- def rule_active_anywhere?(rule)
348
- @spec.states.keys.any? { |state_name| rules_for(state_name).include?(rule) }
349
- end
350
-
351
330
  def capture_rules
352
331
  @spec.rules.select do |rule|
353
332
  rule.patterns.any? do |pattern|