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,560 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flexr
4
+ class Importer
5
+ Result = Struct.new(:source, :warnings, :complete?, keyword_init: true)
6
+
7
+ class UnsupportedFormatError < CompileError; end
8
+
9
+ def self.import(path)
10
+ new(path, File.binread(path)).run
11
+ end
12
+
13
+ def initialize(path, source)
14
+ @path = path
15
+ @source = source.force_encoding(Encoding::UTF_8)
16
+ @warnings = []
17
+ @complete = true
18
+ @macros = {}
19
+ @states = {}
20
+ @rules = []
21
+ @eof_rules = []
22
+ @last_action = nil
23
+ end
24
+
25
+ def run
26
+ case File.extname(@path).downcase
27
+ when ".l", ".lex"
28
+ parse_flex
29
+ when ".rex"
30
+ parse_rexical
31
+ else
32
+ raise UnsupportedFormatError, "import expects a flex .l or rexical .rex file"
33
+ end
34
+
35
+ Result.new(source: render, warnings: @warnings.freeze, complete?: @complete)
36
+ end
37
+
38
+ private
39
+
40
+ def parse_flex
41
+ sections = @source.split(/^%%\s*$/)
42
+ raise CompileError, "flex specification must contain a %% rule separator" if sections.length < 2
43
+
44
+ declarations = sections.shift
45
+ rules = sections.shift
46
+ footer = sections.join("%%\n")
47
+ parse_declarations(declarations)
48
+ parse_rules(rules)
49
+ add_comment_block(footer, "footer") unless footer.strip.empty?
50
+ end
51
+
52
+ def parse_rexical
53
+ @warnings << "Rexical uses first-match semantics; imported rules will use flexr longest-match semantics"
54
+ @source.scan(/^\s*macro\s*\n(.*?)^\s*end\s*$/m) do |body|
55
+ body.first.each_line do |line|
56
+ name, expression = line.strip.split(/\s+/, 2)
57
+ @macros[name] = expression if name && expression
58
+ end
59
+ end
60
+ match = @source.match(/^\s*rule\s*\n(.*?)^\s*end\s*$/m)
61
+ raise CompileError, "rexical specification has no rule section" unless match
62
+
63
+ parse_rules(match[1].lines.map { |line| normalize_rexical_line(line) }.join)
64
+ warn_rexical_semantic_differences
65
+ header = @source.lines.take_while { |line| !line.match?(/^\s*(?:macro|rule)\b/) }.join
66
+ add_comment_block(header, "rexical header") unless header.strip.empty?
67
+ end
68
+
69
+ def parse_declarations(text)
70
+ text.each_line do |line|
71
+ stripped = line.strip
72
+ case stripped
73
+ when "", /^%\{/
74
+ next
75
+ when /^%x\s+(.+)/
76
+ ::Regexp.last_match(1).split.each { |name| @states[name] = false }
77
+ when /^%s\s+(.+)/
78
+ ::Regexp.last_match(1).split.each { |name| @states[name] = true }
79
+ when /^%token\s+(.+)/
80
+ @declared_tokens ||= []
81
+ @declared_tokens.concat(::Regexp.last_match(1).split.map(&:to_sym))
82
+ when /^%option\s+(.+)/
83
+ parse_flex_options(::Regexp.last_match(1))
84
+ when /^%[A-Za-z]/
85
+ warn_incomplete("unsupported flex declaration: #{stripped}")
86
+ when /^([A-Za-z_]\w*)\s+(.+)/
87
+ @macros[::Regexp.last_match(1)] = ::Regexp.last_match(2).strip
88
+ else
89
+ add_comment_block(line, "declaration")
90
+ end
91
+ end
92
+ end
93
+
94
+ def parse_rules(text)
95
+ lines = text.each_line.to_a
96
+ index = 0
97
+ pending_pattern = nil
98
+
99
+ while index < lines.length
100
+ line = lines[index]
101
+ index += 1
102
+ next if line.strip.empty? || line.lstrip.start_with?("/*")
103
+
104
+ pattern, action = split_rule_line(line)
105
+ if pattern && action
106
+ action, index = collect_action(action, lines, index)
107
+ elsif pattern
108
+ pending_pattern = pattern
109
+ next
110
+ elsif pending_pattern
111
+ action = line.strip
112
+ action, index = collect_action(action, lines, index)
113
+ pattern = pending_pattern
114
+ pending_pattern = nil
115
+ else
116
+ warn_incomplete("could not parse flex rule: #{line.strip}")
117
+ next
118
+ end
119
+
120
+ add_rule(pattern, action)
121
+ end
122
+ warn_incomplete("rule has no action: #{pending_pattern}") if pending_pattern
123
+ end
124
+
125
+ def collect_action(action, lines, index)
126
+ return [action, index] unless action.lstrip.start_with?("{")
127
+
128
+ depth = brace_delta(action)
129
+ while depth.positive? && index < lines.length
130
+ action = "#{action}#{lines[index]}"
131
+ depth += brace_delta(lines[index])
132
+ index += 1
133
+ end
134
+ warn_incomplete("unterminated flex action") if depth.positive?
135
+ [action, index]
136
+ end
137
+
138
+ def add_rule(pattern, action)
139
+ if pattern.strip == "<<EOF>>"
140
+ action_expression, complete = translate_action(action)
141
+ @complete = false unless complete
142
+ @eof_rules << [[], action_expression]
143
+ return
144
+ end
145
+
146
+ states, pattern = extract_states(pattern)
147
+ if pattern == "<<EOF>>"
148
+ action_expression, complete = translate_action(action)
149
+ @complete = false unless complete
150
+ @eof_rules << [states, action_expression]
151
+ return
152
+ end
153
+
154
+ expanded = expand_macros(pattern)
155
+ expression = normalize_pattern(expanded)
156
+ action_expression, complete = translate_action(action)
157
+ @complete = false unless complete
158
+ @rules << { states: states, pattern: expression, raw_pattern: expanded, action: action_expression }
159
+ @last_action = action_expression
160
+ end
161
+
162
+ def extract_states(pattern)
163
+ match = pattern.match(/\A<([^>]+)>/)
164
+ return [[], pattern] unless match
165
+
166
+ names = match[1].split(",").map(&:strip)
167
+ names = @states.keys if names.include?("*")
168
+ names = names.map { |name| name == "INITIAL" ? :initial : name.to_sym }
169
+ [names, pattern.delete_prefix(match[0]).strip]
170
+ end
171
+
172
+ def expand_macros(pattern)
173
+ result = pattern.dup
174
+ 10.times do
175
+ before = result
176
+ @macros.sort_by { |name, _| -name.length }.each do |name, expression|
177
+ result = result.gsub("{#{name}}", "(?:#{expression})")
178
+ end
179
+ break if before == result
180
+ end
181
+ warn_incomplete("unresolved flex macro in #{pattern}") if result.match?(/\{[A-Za-z_]\w*\}/)
182
+ result
183
+ end
184
+
185
+ def normalize_pattern(pattern)
186
+ return "" if pattern == ""
187
+
188
+ result = +""
189
+ index = 0
190
+ class_depth = 0
191
+ while index < pattern.length
192
+ if pattern[index] == '"' && class_depth.zero?
193
+ finish = index + 1
194
+ escaped = false
195
+ while finish < pattern.length
196
+ char = pattern[finish]
197
+ break if char == '"' && !escaped
198
+
199
+ escaped = char == "\\" && !escaped
200
+ escaped = false unless char == "\\"
201
+ finish += 1
202
+ end
203
+ if finish >= pattern.length
204
+ warn_incomplete("unterminated quoted flex pattern: #{pattern}")
205
+ return pattern.inspect
206
+ end
207
+ result << ::Regexp.escape(unescape_flex(pattern[(index + 1)...finish]))
208
+ index = finish + 1
209
+ else
210
+ class_depth += 1 if pattern[index] == "["
211
+ class_depth -= 1 if pattern[index] == "]" && class_depth.positive?
212
+ result << pattern[index]
213
+ index += 1
214
+ end
215
+ end
216
+ result.inspect
217
+ end
218
+
219
+ def translate_action(action)
220
+ value = action.to_s.strip
221
+ return ["skip: true", true] if value.empty? || value == ";"
222
+ return [@last_action || "skip: true", !@last_action.nil?] if value == "|"
223
+
224
+ body = if value.start_with?("{") && value.end_with?("}")
225
+ value[1...-1]
226
+ else
227
+ value
228
+ end
229
+ translated = body.dup
230
+ translated.gsub!(/\byytext\b/, "text")
231
+ translated.gsub!(/\byyleng\b/, "text.bytesize")
232
+ translated.gsub!(/\byylineno\b/, "lineno")
233
+ translated.gsub!(/\bBEGIN\s*\(\s*([A-Za-z_]\w*)\s*\)/, 'begin_state :\1')
234
+ translated.gsub!(/\byyless\s*\(\s*([^)]*)\)/, 'less(\1)')
235
+ translated.gsub!(/\byymore\s*\(\s*\)/, "more")
236
+ translated.gsub!(/\bECHO\b/, "echo")
237
+
238
+ returns = translated.scan(/\breturn\s+([^;]+);?/)
239
+ translated.gsub!(/\breturn\s+([^;]+);?\s*/) { "emit #{token_expression(::Regexp.last_match(1))}\n" }
240
+ translated.gsub!(/\breturn\s*;/, "skip\n")
241
+
242
+ complete = true
243
+ unless translated.match?(/\A\s*(?:emit\b|skip\b|echo\b|more\b|less\b|begin_state\b|[A-Za-z_]\w*\s*=|#|\z)/)
244
+ warn_incomplete("FLEXR-TODO: manual action translation required: #{body.strip}")
245
+ translated = "# FLEXR-TODO: translate imported action: #{body.strip.inspect}\n skip"
246
+ complete = false
247
+ end
248
+ if returns.any? && translated.scan(/\bemit\b/).empty?
249
+ warn_incomplete("FLEXR-TODO: could not translate flex return action: #{body.strip}")
250
+ translated = "# FLEXR-TODO: translate imported action: #{body.strip.inspect}\n skip"
251
+ complete = false
252
+ end
253
+ ["do\n #{translated.strip}\nend", complete]
254
+ end
255
+
256
+ def render
257
+ lines = ["# Generated by flexr import. Review semantic warnings before use.", "require \"flexr\"", "", "class Lexer < Flexr::Lexer"]
258
+ lines.concat(render_comments)
259
+ lines << " emits #{@declared_tokens.map(&:inspect).join(', ')}" if @declared_tokens&.any?
260
+ @states.each do |name, inclusive|
261
+ lines << " state #{name.inspect}, inclusive: #{inclusive} do" unless @rules.any? { |rule| rule[:states].include?(name.to_sym) }
262
+ lines << " end" unless @rules.any? { |rule| rule[:states].include?(name.to_sym) }
263
+ end
264
+ @rules.each do |rule|
265
+ lines.concat(render_rule(rule))
266
+ end
267
+ @eof_rules.each do |states, action|
268
+ states = [:initial] if states.empty?
269
+ states.each do |state|
270
+ if state == :initial
271
+ lines << " on_eof #{action}"
272
+ else
273
+ lines << " state #{state.inspect} do"
274
+ lines << " on_eof #{action}"
275
+ lines << " end"
276
+ end
277
+ end
278
+ end
279
+ lines << "end"
280
+ lines << ""
281
+ lines.concat(@comments_after_class || [])
282
+ "#{lines.join("\n")}\n"
283
+ end
284
+
285
+ def render_rule(rule)
286
+ action = rule[:action]
287
+ states = rule[:states]
288
+ if states.empty? || states == [:initial]
289
+ [render_rule_call(" ", rule[:pattern], action)]
290
+ else
291
+ states.map do |state|
292
+ if state == :initial
293
+ render_rule_call(" ", rule[:pattern], action)
294
+ else
295
+ inclusive = @states[state.to_s] == true ? ", inclusive: true" : ""
296
+ " state #{state.inspect}#{inclusive} do\n#{render_rule_call(' ', rule[:pattern], action)}\n end"
297
+ end
298
+ end
299
+ end
300
+ end
301
+
302
+ def render_rule_call(indent, pattern, action)
303
+ prefix = "#{indent}rule(Regexp.new(#{pattern})"
304
+ return "#{prefix}, #{action})" unless action.start_with?("do\n")
305
+
306
+ lines = action.lines.map(&:chomp)
307
+ body = lines.drop(1).map { |line| "#{indent}#{line}" }
308
+ "#{prefix}) #{([lines.first] + body).join("\n")}"
309
+ end
310
+
311
+ def token_expression(value)
312
+ value = value.strip
313
+ value.match?(/\A[A-Za-z_]\w*\z/) ? ":#{value}" : value
314
+ end
315
+
316
+ def render_comments
317
+ @comments || []
318
+ end
319
+
320
+ def add_comment_block(text, label)
321
+ @comments ||= []
322
+ @comments << " # Imported #{label}:"
323
+ text.each_line { |line| @comments << " # #{line.chomp}" }
324
+ end
325
+
326
+ def warn_incomplete(message)
327
+ @warnings << message
328
+ @complete = false
329
+ end
330
+
331
+ def parse_flex_options(text)
332
+ text.split(/[\s,]+/).reject(&:empty?).each do |option|
333
+ next if option == "yylineno"
334
+
335
+ warn_incomplete("unsupported flex option #{option}")
336
+ end
337
+ end
338
+
339
+ def warn_rexical_semantic_differences
340
+ @rules.each_index do |first_index|
341
+ ((first_index + 1)...@rules.length).each do |second_index|
342
+ first = @rules[first_index]
343
+ second = @rules[second_index]
344
+ next unless rexical_rules_overlap?(first, second)
345
+
346
+ counterexample = rexical_counterexample(first[:raw_pattern], second[:raw_pattern])
347
+ next unless counterexample
348
+
349
+ input, first_length, second_length = counterexample
350
+ @warnings << <<~WARNING.chomp
351
+ Rexical rules #{first_index} and #{second_index} differ under first-match vs longest-match: #{input.inspect} is a counterexample; first-match selects rule #{first_index} (#{first_length} bytes), while longest-match selects rule #{second_index} (#{second_length} bytes)
352
+ WARNING
353
+ end
354
+ end
355
+ end
356
+
357
+ def rexical_rules_overlap?(first, second)
358
+ first_states = first[:states].empty? ? [:initial] : first[:states]
359
+ second_states = second[:states].empty? ? [:initial] : second[:states]
360
+ first_states.intersect?(second_states)
361
+ end
362
+
363
+ def rexical_counterexample(first_pattern, second_pattern)
364
+ regexps = [first_pattern, second_pattern].map { |pattern| ::Regexp.new(pattern) }
365
+ if (automata = regexps.map { |regexp| rexical_dfa(regexp) }) && automata.all?
366
+ candidate = rexical_dfa_counterexample(regexps, automata)
367
+ return candidate if candidate
368
+ end
369
+
370
+ rexical_candidate_inputs([first_pattern, second_pattern]).each do |input|
371
+ lengths = rexical_match_lengths(regexps, input)
372
+ return lengths if lengths
373
+ end
374
+ nil
375
+ rescue RegexpError, ArgumentError
376
+ nil
377
+ end
378
+
379
+ def rexical_dfa(regexp)
380
+ dfa = Flexr.compile_pattern(regexp)
381
+ return unless dfa.respond_to?(:accepts) && dfa.respond_to?(:transition)
382
+
383
+ dfa
384
+ rescue Flexr::Error, RegexpError, ArgumentError
385
+ nil
386
+ end
387
+
388
+ def rexical_dfa_counterexample(regexps, automata)
389
+ first, second = automata
390
+ queue = [[first.start, second.start, false, +"".b]]
391
+ visited = { [first.start, second.start, false] => true }
392
+ bytes = rexical_byte_representatives(first, second)
393
+
394
+ until queue.empty?
395
+ first_state, second_state, first_seen, input = queue.shift
396
+ bytes.each do |byte|
397
+ next_first = first_state && first.transition(first_state, byte)
398
+ next_second = second.transition(second_state, byte)
399
+ next unless next_second
400
+
401
+ next_input = input + byte.chr(Encoding::BINARY)
402
+ next_first_seen = first_seen || rexical_accepting?(first, next_first)
403
+ if next_first_seen && rexical_accepting?(second, next_second) &&
404
+ !rexical_accepting?(first, next_first)
405
+ lengths = rexical_match_lengths(regexps, next_input)
406
+ return lengths if lengths
407
+ end
408
+
409
+ key = [next_first, next_second, next_first_seen]
410
+ next if visited[key]
411
+
412
+ visited[key] = true
413
+ queue << [next_first, next_second, next_first_seen, next_input]
414
+ end
415
+ end
416
+ nil
417
+ end
418
+
419
+ def rexical_byte_representatives(first, second)
420
+ representatives = {}
421
+ 256.times do |byte|
422
+ key = [first.ec[byte], second.ec[byte]]
423
+ representatives[key] ||= byte
424
+ end
425
+ representatives.values.sort_by { |byte| [byte.between?(32, 126) ? 0 : 1, byte] }
426
+ end
427
+
428
+ def rexical_accepting?(dfa, state)
429
+ state && !dfa.accepts.fetch(state).empty?
430
+ end
431
+
432
+ def rexical_match_lengths(regexps, input)
433
+ matches = regexps.map do |regexp|
434
+ regexp.match(input.dup.force_encoding(regexp.encoding))
435
+ end
436
+ return unless matches.all? { |match| match&.begin(0)&.zero? && !match[0].empty? }
437
+
438
+ lengths = matches.map { |match| match[0].bytesize }
439
+ lengths[0] < lengths[1] ? [input, *lengths] : nil
440
+ rescue ArgumentError, EncodingError
441
+ nil
442
+ end
443
+
444
+ def rexical_candidate_inputs(patterns)
445
+ characters = (%w[x a b 0 1 _] + patterns.flat_map { |pattern| rexical_candidate_characters(pattern) }).uniq.first(24)
446
+ fragments = patterns.flat_map { |pattern| rexical_literal_fragments(pattern) }
447
+ .reject(&:empty?).uniq.first(24)
448
+ seeds = (fragments + characters).uniq
449
+ candidates = []
450
+
451
+ seeds.each do |seed|
452
+ candidates.push(seed, seed * 2, seed * 3)
453
+ end
454
+ fragments.each do |fragment|
455
+ characters.each do |character|
456
+ candidates << "#{fragment}#{character}"
457
+ candidates << "#{character}#{fragment}"
458
+ end
459
+ end
460
+ fragments.combination(2) { |left, right| candidates << "#{left}#{right}" }
461
+
462
+ candidates.uniq.each_with_index
463
+ .sort_by { |candidate, index| [candidate.bytesize, index] }.map(&:first)
464
+ end
465
+
466
+ def rexical_candidate_characters(pattern)
467
+ escaped = pattern.scan(/\\x([0-9A-Fa-f]{2})|\\(.)/).filter_map do |hex, character|
468
+ if hex
469
+ byte = hex.to_i(16)
470
+ [byte].pack("C").force_encoding(Encoding::UTF_8) if byte < 0x80
471
+ else
472
+ { "n" => "\n", "r" => "\r", "t" => "\t", "f" => "\f", "v" => "\v" }[character] ||
473
+ (character unless %w[d D w W s S A Z b B p P G K].include?(character))
474
+ end
475
+ end
476
+ pattern.each_char.grep(/[A-Za-z0-9_]/) + escaped
477
+ end
478
+
479
+ def rexical_literal_fragments(pattern)
480
+ fragments = []
481
+ current = +""
482
+ in_class = false
483
+ escaped = false
484
+ flush = lambda do
485
+ fragments << current unless current.empty?
486
+ current = +""
487
+ end
488
+
489
+ pattern.each_char do |character|
490
+ if escaped
491
+ if { "n" => "\n", "r" => "\r", "t" => "\t", "f" => "\f", "v" => "\v" }.key?(character)
492
+ current << { "n" => "\n", "r" => "\r", "t" => "\t", "f" => "\f", "v" => "\v" }.fetch(character)
493
+ elsif character.match?(/[dDwWsSAZbBpPGK]/)
494
+ flush.call
495
+ else
496
+ current << character
497
+ end
498
+ escaped = false
499
+ elsif character == "\\"
500
+ escaped = true
501
+ elsif in_class
502
+ in_class = false if character == "]"
503
+ elsif character == "["
504
+ flush.call
505
+ in_class = true
506
+ elsif character.match?(/[A-Za-z0-9_]/)
507
+ current << character
508
+ else
509
+ flush.call
510
+ end
511
+ end
512
+ flush.call unless current.empty?
513
+ fragments
514
+ end
515
+
516
+ def split_rule_line(line)
517
+ source = line.strip
518
+ return [nil, nil] if source.empty?
519
+
520
+ quote = nil
521
+ class_depth = 0
522
+ escaped = false
523
+ source.each_char.with_index do |char, index|
524
+ if quote
525
+ quote = nil if char == quote && !escaped
526
+ elsif char == '"' && class_depth.zero?
527
+ quote = char
528
+ elsif char == "["
529
+ class_depth += 1
530
+ elsif char == "]" && class_depth.positive?
531
+ class_depth -= 1
532
+ elsif char.match?(/\s/) && class_depth.zero?
533
+ return [source[0...index], source[index..].strip]
534
+ end
535
+ escaped = char == "\\" && !escaped
536
+ escaped = false unless char == "\\"
537
+ end
538
+ [source, nil]
539
+ end
540
+
541
+ def brace_delta(text)
542
+ text.count("{") - text.count("}")
543
+ end
544
+
545
+ def unescape_flex(text)
546
+ text.gsub(/\\([\\"])/, '\\1')
547
+ end
548
+
549
+ def normalize_rexical_line(line)
550
+ stripped = line.strip
551
+ if stripped.match?(%r{\A/}) && (finish = stripped.rindex("/")) && finish.positive?
552
+ pattern = stripped[1...finish]
553
+ rest = stripped[(finish + 1)..].to_s
554
+ "#{pattern} #{rest}\n"
555
+ else
556
+ line
557
+ end
558
+ end
559
+ end
560
+ end
data/lib/flexr/ir.rb ADDED
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flexr
4
+ module IR
5
+ Rule = Struct.new(
6
+ :index, :patterns, :trailing, :action, :states, :bol_only, :end_anchor, :location,
7
+ :pattern_conditions,
8
+ keyword_init: true
9
+ ) do
10
+ def skip?
11
+ action == :skip
12
+ end
13
+
14
+ def emit?
15
+ action.is_a?(Array) && action.first == :emit
16
+ end
17
+ end
18
+
19
+ State = Struct.new(:name, :inclusive, :id, keyword_init: true)
20
+
21
+ Spec = Struct.new(
22
+ :class_name, :superclass, :backend, :token_kind, :encoding, :options,
23
+ :declared_tokens, :states, :rules, :eof_rules, :verbatim,
24
+ keyword_init: true
25
+ ) do
26
+ def initial_state
27
+ :initial
28
+ end
29
+ end
30
+
31
+ Config = Struct.new(
32
+ :backend, :token_kind, :encoding, :options, :declared_tokens, :states,
33
+ keyword_init: true
34
+ )
35
+ end
36
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flexr
4
+ class Lexer
5
+ extend DSL
6
+ include Runtime
7
+
8
+ __flexr_reset!
9
+ end
10
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flexr
4
+ Options = Struct.new(
5
+ :backend, :token_kind, :accel, :standalone, :eval_mode, :table_compression,
6
+ :table_format, :max_dfa_states, :warn_level, :warn_as_error, :color, :format, :overrides,
7
+ keyword_init: true
8
+ ) do
9
+ def self.default
10
+ new(backend: :table, token_kind: :array, accel: :auto, standalone: false,
11
+ eval_mode: false, table_compression: :rows, table_format: :literal,
12
+ max_dfa_states: 100_000, warn_level: :default, warn_as_error: false,
13
+ color: :auto, format: :human, overrides: {})
14
+ end
15
+
16
+ def set(name, value)
17
+ self[name] = value
18
+ self.overrides ||= {}
19
+ overrides[name] = value
20
+ end
21
+
22
+ def validate!
23
+ validate_value!(backend, %i[table direct firstmatch auto], :backend)
24
+ validate_value!(token_kind, %i[array struct yield], :token_kind)
25
+ validate_value!(accel, %i[auto strscan regexp none], :accel)
26
+ validate_value!(table_compression, %i[none rows full], :table_compression)
27
+ validate_value!(table_format, %i[literal packed], :table_format)
28
+ validate_value!(warn_level, %i[all default none], :warn_level)
29
+ validate_value!(color, %i[auto always never], :color)
30
+ validate_value!(format, %i[human json], :format)
31
+ raise ArgumentError, "unsupported max_dfa_states: #{max_dfa_states.inspect}" unless max_dfa_states.is_a?(Integer) && max_dfa_states.positive?
32
+ self
33
+ end
34
+
35
+ def generator_options
36
+ (overrides || {}).merge(warn_as_error: warn_as_error, color: color)
37
+ end
38
+
39
+ private
40
+
41
+ def validate_value!(value, allowed, name)
42
+ return if allowed.include?(value)
43
+
44
+ raise ArgumentError, "unsupported #{name}: #{value.inspect}"
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flexr
4
+ class RakeTask
5
+ attr_accessor :spec, :output, :warn_as_error
6
+
7
+ def initialize(name = :flexr)
8
+ @name = name
9
+ @warn_as_error = false
10
+ yield self if block_given?
11
+ define_task
12
+ end
13
+
14
+ private
15
+
16
+ def define_task
17
+ require "rake"
18
+ raise ArgumentError, "spec is required" unless @spec
19
+
20
+ output = @output || @spec.sub(/\.flexr\.rb\z/, ".rb")
21
+ Rake::FileTask.define_task(output => @spec) do
22
+ Generator.new(@spec, output: output, options: { warn_as_error: @warn_as_error }).generate
23
+ end
24
+ Rake::Task.define_task(@name => output)
25
+ end
26
+ end
27
+ end