flexr 1.0.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 (161) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +33 -0
  3. data/CONTRIBUTING.md +39 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +116 -0
  6. data/Rakefile +468 -0
  7. data/benchmark/baselines/json.json +34 -0
  8. data/benchmark/baselines/json_handwritten.rb +43 -0
  9. data/benchmark/baselines/json_rexical.rex +25 -0
  10. data/benchmark/corpora/README.md +11 -0
  11. data/benchmark/corpora/generate_json.rb +26 -0
  12. data/benchmark/golden/calculator_lexer.sha256 +1 -0
  13. data/benchmark/golden/json_lexer.sha256 +1 -0
  14. data/benchmark/golden/regexp_tokenizer.sha256 +1 -0
  15. data/benchmark/golden/ruby_subset_lexer.sha256 +1 -0
  16. data/benchmark/golden/toy_lang_lexer.sha256 +1 -0
  17. data/benchmark/golden/with_lrama_lexer.sha256 +1 -0
  18. data/benchmark/golden/with_racc_lexer.sha256 +1 -0
  19. data/benchmark/run.rb +254 -0
  20. data/docs/README.md +64 -0
  21. data/docs/RELEASING.md +30 -0
  22. data/docs/adr/0001-byte-level-dfa.md +5 -0
  23. data/docs/adr/0003-leftmost-longest.md +4 -0
  24. data/docs/adr/0006-accel-not-scanner.md +4 -0
  25. data/docs/adr/0008-what-pure-ruby-means.md +5 -0
  26. data/docs/adr/0016-spec-is-plain-ruby.md +4 -0
  27. data/docs/adr/0017-static-analysis-by-default.md +5 -0
  28. data/docs/adr/0018-prism-for-generator-only.md +4 -0
  29. data/docs/adr/0019-measured-performance-floor.md +26 -0
  30. data/docs/adr/0020-vendored-unicode-contract.md +21 -0
  31. data/docs/explanation/backends.md +33 -0
  32. data/docs/explanation/matching-semantics.md +20 -0
  33. data/docs/explanation/runtime-vs-generated.md +22 -0
  34. data/docs/explanation/security-model.md +18 -0
  35. data/docs/explanation/unicode-and-encoding.md +20 -0
  36. data/docs/how-to/deploy-a-standalone-lexer.md +23 -0
  37. data/docs/how-to/generate-a-lexer.md +39 -0
  38. data/docs/how-to/handle-errors.md +32 -0
  39. data/docs/how-to/integrate-with-lrama.md +21 -0
  40. data/docs/how-to/integrate-with-racc.md +25 -0
  41. data/docs/how-to/migrate-from-flex.md +21 -0
  42. data/docs/how-to/migrate-from-rexical.md +23 -0
  43. data/docs/how-to/run-a-lexer-at-runtime.md +29 -0
  44. data/docs/how-to/track-token-locations.md +27 -0
  45. data/docs/how-to/tune-performance.md +23 -0
  46. data/docs/how-to/use-states.md +36 -0
  47. data/docs/how-to/use-trailing-context.md +22 -0
  48. data/docs/internals/README.md +14 -0
  49. data/docs/perf-log.md +56 -0
  50. data/docs/reference/README.md +23 -0
  51. data/docs/reference/actions.md +47 -0
  52. data/docs/reference/cli.md +80 -0
  53. data/docs/reference/compatibility.md +38 -0
  54. data/docs/reference/diagnostics.md +41 -0
  55. data/docs/reference/dsl.md +81 -0
  56. data/docs/reference/errors.md +27 -0
  57. data/docs/reference/generated-artifacts.md +50 -0
  58. data/docs/reference/public-api.md +42 -0
  59. data/docs/reference/regexp.md +39 -0
  60. data/docs/reference/runtime.md +49 -0
  61. data/docs/reference/tokens-and-locations.md +33 -0
  62. data/docs/tutorial/build-a-calculator-lexer.md +96 -0
  63. data/examples/calculator/README.md +27 -0
  64. data/examples/calculator/lexer.flexr.rb +17 -0
  65. data/examples/json/README.md +30 -0
  66. data/examples/json/lexer.flexr.rb +24 -0
  67. data/examples/ruby_subset/README.md +17 -0
  68. data/examples/ruby_subset/lexer.flexr.rb +22 -0
  69. data/examples/toy_lang/README.md +17 -0
  70. data/examples/toy_lang/lexer.flexr.rb +18 -0
  71. data/examples/with_lrama/README.md +17 -0
  72. data/examples/with_lrama/lexer.flexr.rb +13 -0
  73. data/examples/with_racc/README.md +17 -0
  74. data/examples/with_racc/lexer.flexr.rb +13 -0
  75. data/exe/flexr +7 -0
  76. data/lib/flexr/automaton/accel.rb +39 -0
  77. data/lib/flexr/automaton/analysis.rb +38 -0
  78. data/lib/flexr/automaton/byte_class_set.rb +29 -0
  79. data/lib/flexr/automaton/compiler.rb +413 -0
  80. data/lib/flexr/automaton/dfa.rb +103 -0
  81. data/lib/flexr/automaton/minimizer.rb +70 -0
  82. data/lib/flexr/automaton/nfa.rb +92 -0
  83. data/lib/flexr/cli.rb +342 -0
  84. data/lib/flexr/codegen/base.rb +17 -0
  85. data/lib/flexr/codegen/direct.rb +52 -0
  86. data/lib/flexr/codegen/firstmatch.rb +17 -0
  87. data/lib/flexr/codegen/table.rb +158 -0
  88. data/lib/flexr/codegen/table_packer.rb +61 -0
  89. data/lib/flexr/diagnostics.rb +94 -0
  90. data/lib/flexr/dsl.rb +182 -0
  91. data/lib/flexr/errors.rb +28 -0
  92. data/lib/flexr/generated.rb +125 -0
  93. data/lib/flexr/generator.rb +400 -0
  94. data/lib/flexr/importer.rb +560 -0
  95. data/lib/flexr/ir.rb +36 -0
  96. data/lib/flexr/lexer.rb +10 -0
  97. data/lib/flexr/options.rb +47 -0
  98. data/lib/flexr/rake_task.rb +27 -0
  99. data/lib/flexr/regexp/ast.rb +45 -0
  100. data/lib/flexr/regexp/char_class.rb +7 -0
  101. data/lib/flexr/regexp/normalizer.rb +117 -0
  102. data/lib/flexr/regexp/parser.rb +517 -0
  103. data/lib/flexr/regexp/tokenizer.flexr.rb +27 -0
  104. data/lib/flexr/regexp/tokenizer.rb +168 -0
  105. data/lib/flexr/regexp/unsupported.rb +7 -0
  106. data/lib/flexr/runtime/buffer.rb +112 -0
  107. data/lib/flexr/runtime/core.rb +388 -0
  108. data/lib/flexr/runtime/errors.rb +22 -0
  109. data/lib/flexr/runtime/interpreter.rb +505 -0
  110. data/lib/flexr/runtime/location.rb +26 -0
  111. data/lib/flexr/runtime/token.rb +7 -0
  112. data/lib/flexr/source/passthrough.rb +31 -0
  113. data/lib/flexr/source/prism_reader.rb +283 -0
  114. data/lib/flexr/source/static_eval.rb +145 -0
  115. data/lib/flexr/unicode/case_fold.rb +45 -0
  116. data/lib/flexr/unicode/data/LICENSE-UNICODE.txt +5 -0
  117. data/lib/flexr/unicode/data/UNICODE_VERSION +1 -0
  118. data/lib/flexr/unicode/data/case_folding.rb +9 -0
  119. data/lib/flexr/unicode/data/properties.rb +10 -0
  120. data/lib/flexr/unicode/property.rb +107 -0
  121. data/lib/flexr/unicode/reference_regexp.rb +102 -0
  122. data/lib/flexr/unicode/utf8_splitter.rb +109 -0
  123. data/lib/flexr/version.rb +5 -0
  124. data/lib/flexr.rb +81 -0
  125. data/site/README.md +22 -0
  126. data/site/astro.config.mjs +57 -0
  127. data/site/package.json +19 -0
  128. data/site/pnpm-lock.yaml +5029 -0
  129. data/site/pnpm-workspace.yaml +6 -0
  130. data/site/public/playground.js +189 -0
  131. data/site/scripts/verify-site.mjs +42 -0
  132. data/site/src/content/docs/benchmarks.md +8 -0
  133. data/site/src/content/docs/concepts/matching-semantics.md +15 -0
  134. data/site/src/content/docs/concepts/regexp-model.md +18 -0
  135. data/site/src/content/docs/concepts/runtime-vs-generated.md +15 -0
  136. data/site/src/content/docs/concepts/security-model.md +15 -0
  137. data/site/src/content/docs/examples.md +17 -0
  138. data/site/src/content/docs/learn/generation.md +29 -0
  139. data/site/src/content/docs/learn/getting-started.md +56 -0
  140. data/site/src/content/docs/learn/parser-integration.md +27 -0
  141. data/site/src/content/docs/learn/runtime-mode.md +32 -0
  142. data/site/src/content/docs/reference/action-context.md +20 -0
  143. data/site/src/content/docs/reference/cli.md +22 -0
  144. data/site/src/content/docs/reference/diagnostics.md +16 -0
  145. data/site/src/content/docs/reference/dsl.md +19 -0
  146. data/site/src/content/docs/reference/public-api.md +18 -0
  147. data/site/src/content/docs/reference/regexp.md +16 -0
  148. data/site/src/content/docs/reference/runtime.md +16 -0
  149. data/site/src/content/docs/reference/tokens-and-locations.md +16 -0
  150. data/site/src/content.config.ts +12 -0
  151. data/site/src/env.d.ts +1 -0
  152. data/site/src/layouts/SiteLayout.astro +39 -0
  153. data/site/src/pages/index.astro +174 -0
  154. data/site/src/pages/playground.astro +64 -0
  155. data/site/src/styles/custom.css +711 -0
  156. data/site/tsconfig.json +5 -0
  157. data/tools/coverage.rb +32 -0
  158. data/tools/docs_verify.rb +116 -0
  159. data/tools/gen_unicode_tables.rb +202 -0
  160. data/tools/regexp_tokenizer_reference.rb +60 -0
  161. metadata +205 -0
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "flexr"
4
+
5
+ module WithLrama
6
+ class LramaLexer < Flexr::Lexer
7
+ emits :INTEGER, :MINUS
8
+
9
+ rule(/[ \t\r\n]+/, skip: true)
10
+ rule(/[0-9]+/, emit: :INTEGER)
11
+ rule(/-/, emit: :MINUS)
12
+ end
13
+ end
@@ -0,0 +1,17 @@
1
+ # flexr with Racc
2
+
3
+ ## What this example demonstrates
4
+
5
+ `RaccLexer` exposes the two-element `racc_next_token` protocol expected by Racc.
6
+ The parser dependency is intentionally optional in this repository.
7
+
8
+ ## Run and inspect
9
+
10
+ ```sh
11
+ ruby -Ilib -e 'load "examples/with_racc/lexer.flexr.rb"; lexer = WithRacc::RaccLexer.new("12 + 3"); p lexer.racc_next_token; p lexer.racc_next_token; p lexer.racc_next_token; p lexer.racc_next_token'
12
+ flexr tokens examples/with_racc/lexer.flexr.rb
13
+ flexr check examples/with_racc/lexer.flexr.rb --format json
14
+ ```
15
+
16
+ Compare the names printed by `flexr tokens` with the grammar's `%token` list.
17
+ See the [Racc integration guide](../../docs/how-to/integrate-with-racc.md).
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "flexr"
4
+
5
+ module WithRacc
6
+ class RaccLexer < Flexr::Lexer
7
+ emits :INTEGER, :PLUS
8
+
9
+ rule(/[ \t\r\n]+/, skip: true)
10
+ rule(/[0-9]+/, emit: :INTEGER)
11
+ rule(/\+/, emit: :PLUS)
12
+ end
13
+ end
data/exe/flexr ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
5
+ require "flexr"
6
+
7
+ exit Flexr::CLI.run(ARGV)
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flexr
4
+ module Automaton
5
+ Region = Struct.new(:state, :bytes, :regexp, keyword_init: true)
6
+
7
+ module Accel
8
+ module_function
9
+
10
+ def extract(dfa)
11
+ dfa.transitions.each_index.filter_map do |state|
12
+ bytes = Analysis.self_loop_set(dfa, state)
13
+ next if bytes.empty?
14
+
15
+ Region.new(state: state, bytes: bytes.freeze, regexp: regexp_for(bytes))
16
+ end
17
+ end
18
+
19
+ def regexp_for(bytes)
20
+ source = bytes_to_source(bytes)
21
+ ::Regexp.new("(?:[#{source}])+", ::Regexp::NOENCODING)
22
+ end
23
+
24
+ def bytes_to_source(bytes)
25
+ ranges = []
26
+ bytes.sort.each do |byte|
27
+ if ranges.empty? || byte > ranges.last.last + 1
28
+ ranges << [byte, byte]
29
+ else
30
+ ranges.last[1] = byte
31
+ end
32
+ end
33
+ ranges.map do |lo, hi|
34
+ lo == hi ? format("\\x%<byte>02X", byte: lo) : format("\\x%<lo>02X-\\x%<hi>02X", lo: lo, hi: hi)
35
+ end.join
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flexr
4
+ module Automaton
5
+ module Analysis
6
+ module_function
7
+
8
+ def unreachable_rules(compiled)
9
+ present = compiled.machines.values.flat_map { |machine| machine.dfa.rule_ids }.uniq
10
+ compiled.rules.reject { |rule| present.include?(rule.index) }
11
+ end
12
+
13
+ def needs_backup?(dfa)
14
+ dfa.transitions.each_index.any? do |state|
15
+ next false if dfa.accepts[state].empty?
16
+
17
+ dfa.transitions[state].compact.any? { |destination| dfa.accepts[destination].empty? }
18
+ end
19
+ end
20
+
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
28
+ end
29
+ end
30
+
31
+ def dead_states(dfa)
32
+ dfa.transitions.each_index.select do |state|
33
+ dfa.accepts[state].empty? && dfa.transitions[state].compact.all? { |destination| destination == state }
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flexr
4
+ module Automaton
5
+ class ByteClassSet
6
+ def initialize
7
+ @boundaries = Array.new(257, false)
8
+ @boundaries[0] = true
9
+ end
10
+
11
+ def add_range(lo, hi)
12
+ raise ArgumentError, "invalid byte range" unless lo.between?(0, 255) && hi.between?(lo, 255)
13
+
14
+ @boundaries[lo] = true
15
+ @boundaries[hi + 1] = true if hi < 255
16
+ end
17
+
18
+ def build
19
+ ec = Array.new(256)
20
+ class_id = -1
21
+ 256.times do |byte|
22
+ class_id += 1 if @boundaries[byte]
23
+ ec[byte] = class_id
24
+ end
25
+ [ec.freeze, class_id + 1]
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,413 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flexr
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
+ class Compiler
9
+ def initialize(spec)
10
+ @spec = spec
11
+ end
12
+
13
+ def compile
14
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
15
+ validate_rules
16
+ state_names = effective_states
17
+ machines = state_names.to_h do |state_name|
18
+ rules = rules_for(state_name)
19
+ [state_name, Machine.new(dfa: compile_machine(rules), state_name: state_name)]
20
+ end
21
+ stats = machines.transform_values { |machine| machine.dfa.stats }
22
+ compiled = CompiledSpec.new(machines: machines, rules: @spec.rules, states: state_names, stats: stats)
23
+ compiled.diagnostics = diagnostics_for(compiled, Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at)
24
+ compiled
25
+ end
26
+
27
+ private
28
+
29
+ def effective_states
30
+ names = [:initial]
31
+ @spec.states.each_key { |name| names << name unless names.include?(name) }
32
+ names
33
+ end
34
+
35
+ def rules_for(state_name)
36
+ state = @spec.states.fetch(state_name)
37
+ @spec.rules.select do |rule|
38
+ next true if state_name == :initial && rule.states.include?(:initial)
39
+ next true if state.inclusive && rule.states.include?(:initial)
40
+ next false unless rule.states.include?(state_name)
41
+
42
+ !rule.states.empty?
43
+ end
44
+ end
45
+
46
+ def compile_machine(rules)
47
+ @active_rule_ids = rules.map(&:index)
48
+ normalized = []
49
+ rules.each do |rule|
50
+ rule.pattern_conditions = []
51
+ if reference_rule?(rule)
52
+ validate_reference_patterns(rule)
53
+ next
54
+ end
55
+
56
+ rule.patterns.each_with_index do |pattern, pattern_index|
57
+ regexp = pattern.is_a?(::Regexp) ? pattern : ::Regexp.new(::Regexp.escape(pattern.to_s))
58
+ encoding = regexp.encoding == Encoding::BINARY ? Encoding::BINARY : @spec.encoding
59
+ parser = Regexp::Parser.new(regexp.source, options: regexp.options, encoding: encoding,
60
+ unicode: @spec.options[:unicode] == true)
61
+ ast = parser.parse
62
+ ast, bol_only, end_anchor = strip_anchors(ast)
63
+ condition = Acceptance.new(rule_index: rule.index, pattern_index: pattern_index,
64
+ bol_only: bol_only, end_anchor: end_anchor)
65
+ rule.pattern_conditions[pattern_index] = condition
66
+ normalized_ast = Regexp::Normalizer.new(ast, encoding: encoding, options: regexp.options).normalize
67
+ normalized << [normalized_ast, condition]
68
+ end
69
+ end
70
+ return empty_dfa if normalized.empty?
71
+
72
+ nfa = NFABuilder.new.build(normalized)
73
+ ec, class_count = nfa.byte_classes.build
74
+ subset_construction(nfa, ec, class_count)
75
+ end
76
+
77
+ def empty_dfa
78
+ DFA.new(transitions: [[nil]], accepts: [[]], ec: Array.new(256, 0), class_count: 1, start: 0, rule_ids: [])
79
+ end
80
+
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
+ def strip_anchors(ast)
108
+ children = ast.is_a?(Regexp::AST::Seq) ? ast.children.dup : [ast]
109
+ children.shift while children.first.is_a?(Regexp::AST::Empty)
110
+ children.pop while children.last.is_a?(Regexp::AST::Empty)
111
+ bol_only = children.first.is_a?(Regexp::AST::Anchor) && children.first.kind == :bol
112
+ end_anchor = children.last.is_a?(Regexp::AST::Anchor) && children.last.kind == :eol
113
+ children.shift if bol_only
114
+ children.pop if end_anchor
115
+ body = if children.empty?
116
+ Regexp::AST::Empty.new(loc: nil)
117
+ elsif children.length == 1
118
+ children.first
119
+ else
120
+ Regexp::AST::Seq.new(children: children, loc: nil)
121
+ end
122
+ if contains_anchor?(body)
123
+ diagnostic = Diagnostics.error(
124
+ "FLEXR-E009", "anchors are only valid at the outermost pattern boundaries",
125
+ help: "split alternatives into separate rules or move ^/$ outside the alternation"
126
+ )
127
+ raise CompileError.new(diagnostic.message, diagnostic: diagnostic)
128
+ end
129
+ [body, bol_only, end_anchor]
130
+ end
131
+
132
+ def contains_anchor?(node)
133
+ return true if node.is_a?(Regexp::AST::Anchor)
134
+ return false unless node.respond_to?(:children)
135
+
136
+ node.children.any? { |child| contains_anchor?(child) }
137
+ end
138
+
139
+ 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)
143
+ sets = [start_set]
144
+ ids = { start_set => 0 }
145
+ transitions = []
146
+ accepts = []
147
+ queue = [start_set]
148
+
149
+ until queue.empty?
150
+ set = queue.shift
151
+ state_id = ids.fetch(set)
152
+ transitions[state_id] ||= Array.new(class_count)
153
+ 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)
158
+ destination = ids[closure]
159
+ unless destination
160
+ 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
+ if destination >= limit
164
+ message = "DFA state limit exceeded while compiling rules #{@active_rule_ids.join(', ')}"
165
+ diagnostic = Diagnostics.error("FLEXR-E006", message,
166
+ help: "raise max_dfa_states or split the listed rules")
167
+ raise CompileError.new(diagnostic.message, diagnostic: diagnostic)
168
+ end
169
+ ids[closure] = destination
170
+ sets << closure
171
+ queue << closure
172
+ end
173
+ transitions[state_id][class_id] = destination
174
+ end
175
+ end
176
+ transitions.each { |row| row.map! { |value| value } }
177
+ rule_ids = accepts.flatten.map(&:rule_index).uniq.sort
178
+ dfa = DFA.new(transitions: transitions, accepts: accepts, ec: ec, class_count: class_count, start: 0,
179
+ rule_ids: rule_ids)
180
+ Minimizer.minimize(dfa)
181
+ end
182
+
183
+ def epsilon_closure(nfa, set)
184
+ closure = set
185
+ stack = []
186
+ nfa.states.each_index { |id| stack << id if set.anybits?(1 << id) }
187
+ until stack.empty?
188
+ state = stack.pop
189
+ nfa.states[state].epsilon.each do |target|
190
+ next if closure.anybits?(1 << target)
191
+
192
+ closure |= 1 << target
193
+ stack << target
194
+ end
195
+ end
196
+ closure
197
+ end
198
+
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)
206
+
207
+ moved |= 1 << transition.to
208
+ end
209
+ end
210
+ moved
211
+ end
212
+
213
+ 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)
219
+ end
220
+ rules.uniq.sort_by { |acceptance| [acceptance.rule_index, acceptance.pattern_index] }
221
+ end
222
+
223
+ def validate_rules
224
+ raise CompileError, "firstmatch requires option :experimental" if @spec.backend == :firstmatch && !@spec.options[:experimental]
225
+
226
+ @spec.rules.each do |rule|
227
+ raise CompileError, "rule #{rule.index} has no pattern" if rule.patterns.empty?
228
+ next if @spec.options[:allow_empty_match]
229
+
230
+ rule.patterns.each do |pattern|
231
+ regexp = pattern.is_a?(String) ? ::Regexp.new(::Regexp.escape(pattern)) : pattern
232
+ parse_regexp(regexp) if regexp.is_a?(::Regexp)
233
+ next unless regexp.is_a?(::Regexp) && regexp.match?("")
234
+
235
+ diagnostic = Diagnostics.error("FLEXR-E005", "rule #{rule.index} can match an empty string")
236
+ raise CompileError.new(diagnostic.message, diagnostic: diagnostic)
237
+ end
238
+ end
239
+ end
240
+
241
+ def parse_regexp(regexp)
242
+ encoding = regexp.encoding == Encoding::BINARY ? Encoding::BINARY : @spec.encoding
243
+ Regexp::Parser.new(regexp.source, options: regexp.options, encoding: encoding,
244
+ unicode: @spec.options[:unicode] == true).parse
245
+ end
246
+
247
+ def diagnostics_for(compiled, elapsed)
248
+ diagnostics = []
249
+ present = compiled.machines.values.flat_map do |machine|
250
+ machine.dfa.accepts.filter_map { |acceptances| acceptances.min_by(&:rule_index)&.rule_index }
251
+ end
252
+ reference_rules = @spec.rules.select { |rule| reference_rule?(rule) && rule_active_anywhere?(rule) }
253
+ present.concat(reference_rules.map(&:index))
254
+ shadowers = shadowed_rules(compiled)
255
+ diagnostics.concat(@spec.rules.reject { |rule| present.include?(rule.index) }.map do |rule|
256
+ winners = shadowers.fetch(rule.index, []).uniq.sort
257
+ suffix = winners.empty? ? "" : " (shadowed by rule #{winners.join(', ')})"
258
+ Diagnostics.warning("FLEXR-W001", "rule #{rule.index} is unreachable#{suffix}", location: rule.location,
259
+ help: "remove it, reorder the rules, or make its language distinct")
260
+ end)
261
+
262
+ @spec.states.each_key do |state_name|
263
+ next if state_name == :initial
264
+ next unless rules_for(state_name).empty?
265
+
266
+ diagnostics << Diagnostics.warning("FLEXR-W002", "state #{state_name.inspect} has no rules",
267
+ help: "add a rule to the state or remove the unused state")
268
+ end
269
+
270
+ firstmatch_conflicts(@spec.rules).each do |left, right|
271
+ diagnostics << Diagnostics.warning(
272
+ "FLEXR-W010", "firstmatch rules #{left.index} and #{right.index} may change longest-match semantics",
273
+ help: "use backend :table unless first-match compatibility is required"
274
+ )
275
+ end
276
+
277
+ max_cells = compiled.stats.values.map { |stat| stat[:states] * stat[:classes] }.max.to_i
278
+ if max_cells > 1_000_000
279
+ diagnostics << Diagnostics.warning(
280
+ "FLEXR-W011",
281
+ "generated transition table is large",
282
+ help: "use backend :direct, table compression, or split the specification"
283
+ )
284
+ end
285
+
286
+ capture_rules.each do |rule|
287
+ diagnostics << Diagnostics.warning(
288
+ "FLEXR-W013",
289
+ "rule #{rule.index} uses a capturing group; flexr treats it as non-capturing",
290
+ help: "rewrite capturing groups as (?:...) and extract text in the action"
291
+ )
292
+ end
293
+
294
+ undeclared_tokens.each do |token|
295
+ diagnostics << Diagnostics.warning(
296
+ "FLEXR-W014",
297
+ "token #{token.inspect} is not declared by emits",
298
+ help: "add the token to emits or remove the declaration if it is intentionally private"
299
+ )
300
+ end
301
+
302
+ diagnostics.concat(variable_trailing_rules.map do |rule|
303
+ Diagnostics.warning("FLEXR-W003", "rule #{rule.index} uses variable-length trailing context",
304
+ help: "make the body or followed_by expression fixed length when possible")
305
+ end)
306
+
307
+ if @spec.options.fetch(:accel, :auto) != :none
308
+ diagnostics.concat(@spec.rules.select(&:trailing).map do |rule|
309
+ Diagnostics.warning(
310
+ "FLEXR-W012",
311
+ "rule #{rule.index} cannot use region acceleration with trailing context",
312
+ help: "remove trailing context or set accel: :none when the trade-off is intentional"
313
+ )
314
+ end)
315
+ end
316
+
317
+ if elapsed > 0.5
318
+ diagnostics << Diagnostics.warning("FLEXR-W016", format("DFA construction took %.3fs", elapsed),
319
+ help: "use generated mode for production startup")
320
+ end
321
+ diagnostics
322
+ end
323
+
324
+ def shadowed_rules(compiled)
325
+ shadowers = Hash.new { |hash, key| hash[key] = [] }
326
+ compiled.machines.each_value do |machine|
327
+ machine.dfa.accepts.each do |acceptances|
328
+ winner = acceptances.min_by(&:rule_index)&.rule_index
329
+ next unless winner
330
+
331
+ acceptances.each do |acceptance|
332
+ next if acceptance.rule_index == winner
333
+
334
+ shadowers[acceptance.rule_index] << winner
335
+ end
336
+ end
337
+ end
338
+ shadowers
339
+ end
340
+
341
+ def firstmatch_conflicts(rules)
342
+ return [] unless @spec.backend == :firstmatch
343
+
344
+ rules.combination(2).to_a
345
+ end
346
+
347
+ def rule_active_anywhere?(rule)
348
+ @spec.states.keys.any? { |state_name| rules_for(state_name).include?(rule) }
349
+ end
350
+
351
+ def capture_rules
352
+ @spec.rules.select do |rule|
353
+ rule.patterns.any? do |pattern|
354
+ source = pattern.respond_to?(:source) ? pattern.source : pattern.to_s
355
+ capturing_group?(source)
356
+ end
357
+ end
358
+ end
359
+
360
+ def capturing_group?(source)
361
+ escaped = false
362
+ in_class = false
363
+ source.each_char.with_index do |character, index|
364
+ if escaped
365
+ escaped = false
366
+ next
367
+ end
368
+ if character == "\\"
369
+ escaped = true
370
+ elsif character == "["
371
+ in_class = true
372
+ elsif character == "]"
373
+ in_class = false
374
+ elsif character == "(" && !in_class
375
+ return true unless source[index + 1] == "?"
376
+ end
377
+ end
378
+ false
379
+ end
380
+
381
+ def undeclared_tokens
382
+ return [] if Array(@spec.declared_tokens).empty?
383
+
384
+ emitted = @spec.rules.flat_map do |rule|
385
+ action = rule.action
386
+ if action.is_a?(Array) && action.first == :emit
387
+ [action.last]
388
+ elsif action.is_a?(String)
389
+ action.scan(/\bemit\s*\(?\s*:([A-Za-z_]\w*)/).flatten.map(&:to_sym)
390
+ else
391
+ []
392
+ end
393
+ end
394
+ declared = Array(@spec.declared_tokens)
395
+ emitted.uniq.reject { |token| declared.include?(token) }
396
+ end
397
+
398
+ def variable_trailing_rules
399
+ @spec.rules.select do |rule|
400
+ next false unless rule.trailing
401
+
402
+ rule.patterns.any? { |pattern| variable_pattern?(pattern) } && variable_pattern?(rule.trailing)
403
+ end
404
+ end
405
+
406
+ def variable_pattern?(pattern)
407
+ source = pattern.respond_to?(:source) ? pattern.source : pattern.to_s
408
+ source = source.gsub(/\\./, "")
409
+ source.match?(/[+*]|\{\d+,\d*\}/)
410
+ end
411
+ end
412
+ end
413
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flexr
4
+ module Automaton
5
+ class DFA
6
+ attr_reader :transitions, :accepts, :ec, :class_count, :start, :states, :rule_ids, :packed, :direct
7
+
8
+ def initialize(transitions:, accepts:, ec:, class_count:, start:, rule_ids:, packed: nil, direct: nil)
9
+ @transitions = transitions.freeze
10
+ @accepts = accepts.map do |rules|
11
+ rules.map do |acceptance|
12
+ next acceptance if acceptance.is_a?(Acceptance)
13
+
14
+ Acceptance.new(rule_index: acceptance[0], pattern_index: acceptance[1],
15
+ bol_only: acceptance[2], end_anchor: acceptance[3])
16
+ end.freeze
17
+ end.freeze
18
+ @ec = ec.freeze
19
+ @class_count = class_count
20
+ @start = start
21
+ @states = transitions.length
22
+ @rule_ids = rule_ids.map { |id| id.respond_to?(:rule_index) ? id.rule_index : id }.uniq.sort.freeze
23
+ @packed = packed
24
+ @direct = direct
25
+ end
26
+
27
+ def transition(state, byte)
28
+ class_id = @ec[byte]
29
+ return @transitions[state][class_id] unless @packed
30
+
31
+ cursor = state
32
+ loop do
33
+ index = @packed.fetch(:base).fetch(cursor) + class_id
34
+ return @packed.fetch(:next).fetch(index) if @packed.fetch(:check)[index] == cursor
35
+
36
+ fallback = @packed[:fallback]&.fetch(cursor)
37
+ return @packed.fetch(:default).fetch(cursor) unless fallback
38
+
39
+ cursor = fallback
40
+ end
41
+ end
42
+
43
+ # Generated direct lexers use a flattened dispatch representation. The
44
+ # interpreter keeps this route separate from packed/table equivalence.
45
+ def transition_direct(state, byte)
46
+ if @direct
47
+ class_id = @ec[byte]
48
+ value = @direct.fetch(:nxt).fetch((state * @direct.fetch(:classes)) + class_id)
49
+ return value >= 0 ? value : nil
50
+ end
51
+
52
+ @transitions[state][@ec[byte]]
53
+ end
54
+
55
+ def accept?(bytes)
56
+ data = bytes.dup.force_encoding(Encoding::BINARY)
57
+ state = @start
58
+ data.each_byte do |byte|
59
+ state = transition(state, byte)
60
+ return false unless state
61
+ end
62
+ !@accepts[state].empty?
63
+ end
64
+
65
+ def stats
66
+ { states: states, classes: class_count, accepting_states: accepts.count { |rules| !rules.empty? } }
67
+ end
68
+ end
69
+
70
+ class ReferenceDFA
71
+ def initialize(regexp, unicode: false)
72
+ source, options = if reference_pattern?(regexp, unicode: unicode)
73
+ converted = Unicode::ReferenceRegexp.compiled(
74
+ regexp, encoding: regexp.encoding, options: regexp.options, unicode: unicode
75
+ )
76
+ [converted.source, converted.options]
77
+ else
78
+ [regexp.source, regexp.options]
79
+ end
80
+ @regexp = ::Regexp.new("\\A(?:#{source})\\z", options)
81
+ end
82
+
83
+ def accept?(bytes)
84
+ data = bytes.dup.force_encoding(@regexp.encoding)
85
+ @regexp.match?(data)
86
+ rescue ArgumentError, EncodingError
87
+ false
88
+ end
89
+
90
+ def stats
91
+ { states: 0, classes: 0, accepting_states: 0, reference: true }
92
+ end
93
+
94
+ private
95
+
96
+ def reference_pattern?(regexp, unicode: false)
97
+ return true if regexp.source.match?(/\\[pP]\{/) || regexp.source.match?(/\[:(?:\^)?[a-z]+:\]/)
98
+
99
+ unicode && regexp.encoding != Encoding::BINARY && regexp.source.match?(/\\[dDwWsS]/)
100
+ end
101
+ end
102
+ end
103
+ end