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,400 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flexr
4
+ class Generator
5
+ attr_reader :diagnostics
6
+
7
+ INLINE_EMIT = /\Aemit(?:\s+(.+?))?\z/
8
+ INLINE_EMIT_ARGUMENTS = /\A(?::[A-Za-z_]\w*|true|false|nil|-?\d+(?:\.\d+)?|"(?:\\.|[^"])*"|'(?:\\.|[^'])*'|text(?:\.(?:to_f|to_i|bytesize|byteslice\([^()\n]+\)))?|lineno|line)(?:\s*,\s*(?::[A-Za-z_]\w*|true|false|nil|-?\d+(?:\.\d+)?|"(?:\\.|[^"])*"|'(?:\\.|[^'])*'|text(?:\.(?:to_f|to_i|bytesize|byteslice\([^()\n]+\)))?|lineno|line))*\z/
9
+
10
+ RUNTIME_SOURCES = %w[
11
+ version.rb errors.rb diagnostics.rb ir.rb
12
+ regexp/ast.rb regexp/parser.rb regexp/normalizer.rb regexp/unsupported.rb regexp/char_class.rb
13
+ unicode/utf8_splitter.rb unicode/data/properties.rb unicode/data/case_folding.rb unicode/property.rb unicode/reference_regexp.rb unicode/case_fold.rb
14
+ automaton/byte_class_set.rb automaton/nfa.rb automaton/dfa.rb automaton/compiler.rb
15
+ automaton/analysis.rb automaton/minimizer.rb automaton/accel.rb
16
+ runtime/location.rb runtime/token.rb runtime/buffer.rb runtime/errors.rb
17
+ runtime/interpreter.rb runtime/core.rb dsl.rb lexer.rb generated.rb
18
+ ].freeze
19
+
20
+ def initialize(path, output: nil, eval_mode: false, options: {})
21
+ @path = path
22
+ @output = output
23
+ @eval_mode = eval_mode
24
+ @options = options
25
+ @diagnostics = []
26
+ end
27
+
28
+ def generate
29
+ # Keep generated source stable when a checkout presents the spec with
30
+ # Windows line endings. All offsets consumed by the parser and
31
+ # passthrough writer are relative to this normalized source.
32
+ source = File.binread(@path).gsub(/\r\n?/, "\n").force_encoding(Encoding::UTF_8)
33
+ result = if @eval_mode
34
+ generate_from_runtime(source)
35
+ else
36
+ parsed = Source::PrismReader.new(source, path: @path).read
37
+ generate_static(parsed)
38
+ end
39
+ File.binwrite(@output, result) if @output
40
+ result
41
+ end
42
+
43
+ private
44
+
45
+ def generate_static(parsed)
46
+ compiled = compile_parsed(parsed)
47
+ @diagnostics = Array(compiled.diagnostics)
48
+ validate_diagnostics!(compiled)
49
+ payload = generated_payload(parsed, compiled)
50
+ indent = Source::Passthrough.indentation(parsed.source, parsed.first_dsl_offset)
51
+ install = "Flexr::Generated.install_compiled!(self, #{payload})\n"
52
+ install = "#{install}#{Codegen::Table.new(compiled).source(indent: indent)}"
53
+ install = "#{install}#{generated_action_source(parsed, indent)}"
54
+ install = "#{install}#{Codegen::Direct.new(compiled).source(indent: indent)}#{indent}" if
55
+ effective_backend(parsed) == :direct
56
+ result = Source::Passthrough.remove_spans(parsed.source, parsed.dsl_spans, insertion: parsed.first_dsl_offset, payload: install)
57
+ result = result.gsub(/^[ \t]+(?=\n)/, "")
58
+ result = result.gsub(/^\s*require ["']flexr["']\s*\n/, "") if standalone?(parsed)
59
+ digest = Digest::SHA256.hexdigest(payload)
60
+ header = [
61
+ "# Generated by flexr. DO NOT EDIT.",
62
+ "# source: #{parsed.path}",
63
+ "# spec-digest: sha256:#{digest}",
64
+ "# unicode: #{Unicode::VERSION}",
65
+ "# backend: #{effective_backend(parsed)}",
66
+ "# compiled: true",
67
+ "# eval: #{@eval_mode}",
68
+ "# standalone: #{standalone?(parsed)}"
69
+ ].join("\n")
70
+ prefix = standalone?(parsed) ? "require \"json\"\n#{embedded_runtime}\n" : ""
71
+ result.sub(/\A(# frozen_string_literal: true\n)/) { "#{::Regexp.last_match(1)}#{header}\n#{prefix}" }
72
+ end
73
+
74
+ def generated_payload(parsed, compiled)
75
+ definitions = parsed.rules.map do |rule|
76
+ conditions = Array(rule.pattern_conditions).map do |condition|
77
+ [condition.rule_index, condition.pattern_index, condition.bol_only, condition.end_anchor]
78
+ end
79
+ "{ index: #{rule.index}, patterns: #{ruby_literal(rule.patterns)}, pattern_conditions: #{ruby_literal(conditions)}, trailing: #{ruby_literal(rule.trailing)}, " \
80
+ "action: #{action_expression(rule.action)}, states: #{rule.states.inspect}, " \
81
+ "bol_only: #{ruby_literal(rule.bol_only)}, end_anchor: #{ruby_literal(rule.end_anchor)} }"
82
+ end
83
+ "{ rules: [#{definitions.join(', ')}], " \
84
+ "backend: #{ruby_literal(effective_backend(parsed))}, " \
85
+ "token_kind: #{ruby_literal(@options.fetch(:token_kind, parsed.config[:token_kind]))}, " \
86
+ "encoding: #{encoding_expression(parsed.config[:encoding])}, " \
87
+ "declared_tokens: #{ruby_literal(parsed.config[:declared_tokens])}, " \
88
+ "options: #{ruby_literal(effective_options(parsed))}, " \
89
+ "eof_rules: #{eof_rules_expression(parsed.config[:eof_rules] || {})}, " \
90
+ "states: #{ruby_literal(parsed.states.keys.reject { |name| name == :initial })}, " \
91
+ "inclusive_states: #{ruby_literal(parsed.states.transform_values { |value| value[:inclusive] })}, " \
92
+ "compiled: #{compiled_expression(compiled, compression: table_compression(parsed),
93
+ backend: effective_backend(parsed))} }"
94
+ end
95
+
96
+ def compile_parsed(parsed)
97
+ states = parsed.states.each_with_index.to_h do |(name, value), index|
98
+ [name.to_sym, IR::State.new(name: name.to_sym, inclusive: value[:inclusive], id: index)]
99
+ end
100
+ rules = parsed.rules.map do |rule|
101
+ IR::Rule.new(index: rule.index, patterns: rule.patterns, trailing: normalize_trailing(rule.trailing),
102
+ action: rule.action, states: rule.states, bol_only: rule.bol_only,
103
+ end_anchor: rule.end_anchor, location: rule.span)
104
+ end
105
+ spec = IR::Spec.new(
106
+ class_name: parsed.class_name, superclass: "Flexr::Lexer",
107
+ backend: @options.fetch(:backend, parsed.config[:backend]),
108
+ token_kind: @options.fetch(:token_kind, parsed.config[:token_kind]),
109
+ encoding: parsed.config[:encoding], options: effective_options(parsed),
110
+ declared_tokens: parsed.config[:declared_tokens], states: states, rules: rules,
111
+ eof_rules: parsed.config[:eof_rules] || {}, verbatim: parsed.source
112
+ )
113
+ compiled = Automaton::Compiler.new(spec).compile
114
+ validate_firstmatch_equivalence!(spec, compiled) if spec.backend == :firstmatch
115
+ @resolved_backend = resolve_backend(spec.backend, compiled)
116
+ parsed.rules.each do |rule|
117
+ compiled_rule = spec.rules.fetch(rule.index)
118
+ rule.pattern_conditions = compiled_rule.pattern_conditions
119
+ end
120
+ compiled
121
+ end
122
+
123
+ def effective_backend(parsed)
124
+ @resolved_backend || resolve_backend(@options.fetch(:backend, parsed.config[:backend]).to_sym, nil)
125
+ end
126
+
127
+ def resolve_backend(requested, compiled)
128
+ return requested unless requested == :auto
129
+ return :table unless compiled
130
+
131
+ cells = compiled.stats.values.map { |stats| stats[:states] * stats[:classes] }.max.to_i
132
+ cells > DSL::AUTO_DIRECT_CELL_THRESHOLD ? :direct : :table
133
+ end
134
+
135
+ def normalize_trailing(value)
136
+ case value
137
+ when nil, ::Regexp
138
+ value
139
+ when String
140
+ ::Regexp.new(::Regexp.escape(value))
141
+ else
142
+ diagnostic = Diagnostics.error("FLEXR-E018", "followed_by must be a Regexp or String")
143
+ raise CompileError.new(diagnostic.message, diagnostic: diagnostic)
144
+ end
145
+ end
146
+
147
+ def validate_firstmatch_equivalence!(spec, compiled)
148
+ return if spec.rules.any? do |rule|
149
+ rule.trailing || rule.patterns.any? { |pattern| reference_pattern?(pattern, unicode: spec.options[:unicode] == true) }
150
+ end
151
+
152
+ machine = compiled.machines.fetch(:initial).dfa
153
+ random = Random.new(17)
154
+ inputs = ["", "a", "aa", "aaa", "ba", "aab"]
155
+ inputs.concat(Array.new(9_994) do
156
+ Array.new(random.rand(10)) { random.rand(32..126) }.pack("C*")
157
+ end)
158
+ inputs.each do |input|
159
+ table = table_match(machine, input)
160
+ firstmatch = firstmatch_match(spec.rules, input)
161
+ next if table == firstmatch
162
+
163
+ raise CompileError, "firstmatch differs from table for #{input.inspect}: #{firstmatch.inspect} vs #{table.inspect}"
164
+ end
165
+ end
166
+
167
+ def reference_pattern?(pattern, unicode: false)
168
+ return false unless pattern.is_a?(::Regexp)
169
+ return true if pattern.source.match?(/\\[pP]\{/) || pattern.source.match?(/\[:(?:\^)?[a-z]+:\]/)
170
+
171
+ unicode && pattern.encoding != Encoding::BINARY && pattern.source.match?(/\\[dDwWsS]/)
172
+ end
173
+
174
+ def table_match(dfa, input)
175
+ state = dfa.start
176
+ best = nil
177
+ input.each_byte.with_index do |byte, index|
178
+ state = dfa.transition(state, byte)
179
+ break unless state
180
+
181
+ acceptance = dfa.accepts[state].select do |candidate|
182
+ !candidate.end_anchor || index + 1 == input.bytesize || input.getbyte(index + 1) == 0x0a
183
+ end.min_by(&:rule_index)
184
+ next unless acceptance
185
+
186
+ candidate = [acceptance.rule_index, index + 1]
187
+ best = candidate if best.nil? || candidate[1] > best[1] ||
188
+ (candidate[1] == best[1] && candidate[0] < best[0])
189
+ end
190
+ best
191
+ end
192
+
193
+ def firstmatch_match(rules, input)
194
+ rules.sort_by(&:index).each do |rule|
195
+ lengths = rule.patterns.filter_map do |pattern|
196
+ regexp = pattern.is_a?(::Regexp) ? pattern : ::Regexp.new(::Regexp.escape(pattern.to_s))
197
+ match = regexp.match(input, 0)
198
+ match&.begin(0)&.zero? ? match[0].bytesize : nil
199
+ rescue ArgumentError, RegexpError
200
+ nil
201
+ end
202
+ return [rule.index, lengths.max] unless lengths.empty?
203
+ end
204
+ nil
205
+ end
206
+
207
+ def compiled_expression(compiled, compression: :none, backend: :table)
208
+ machines = compiled.machines.map do |name, machine|
209
+ dfa = machine.dfa
210
+ data = {
211
+ accepts: dfa.accepts,
212
+ ec: dfa.ec,
213
+ class_count: dfa.class_count,
214
+ state_count: dfa.states,
215
+ start: dfa.start,
216
+ rule_ids: dfa.rule_ids
217
+ }
218
+ pack_tables = %i[rows full].include?(compression) || @options.fetch(:table_format, :literal).to_sym == :packed
219
+ if pack_tables
220
+ packed = Codegen::TablePacker.pack(dfa.transitions, compression: compression)
221
+ data[:packed] = if @options.fetch(:table_format, :literal).to_sym == :packed
222
+ Codegen::TablePacker.encode(packed)
223
+ else
224
+ packed
225
+ end
226
+ else
227
+ data[:transitions] = dfa.transitions
228
+ end
229
+ data[:direct] = Codegen::Direct.new(compiled).generate(state: name) if backend == :direct
230
+ "#{ruby_literal(name)} => { state_name: #{ruby_literal(machine.state_name)}, dfa: #{ruby_literal(data)} }"
231
+ end
232
+ # W016 is derived from wall-clock compilation time and must not make
233
+ # generated source (or its golden digest) vary between runs.
234
+ diagnostics = compiled.diagnostics.reject { |diagnostic| diagnostic.code == "FLEXR-W016" }.map(&:to_h)
235
+ "{ machines: { #{machines.join(', ')} }, states: #{ruby_literal(compiled.states)}, " \
236
+ "stats: #{ruby_literal(compiled.stats)}, diagnostics: #{ruby_literal(diagnostics)} }"
237
+ end
238
+
239
+ def standalone?(parsed)
240
+ @options.fetch(:standalone, parsed.config[:options]&.key?(:standalone) || false)
241
+ end
242
+
243
+ def effective_options(parsed)
244
+ options = (parsed.config[:options] || {}).dup
245
+ %i[accel max_dfa_states table_compression table_format].each do |name|
246
+ options[name] = @options[name] if @options.key?(name)
247
+ end
248
+ options[:standalone] = true if standalone?(parsed)
249
+ options
250
+ end
251
+
252
+ def table_compression(parsed)
253
+ @options.fetch(:table_compression, parsed.config[:options]&.fetch(:table_compression, :rows) || :rows).to_sym
254
+ end
255
+
256
+ def embedded_runtime
257
+ RUNTIME_SOURCES.map do |relative|
258
+ File.binread(File.expand_path(relative, __dir__))
259
+ end.join("\n")
260
+ end
261
+
262
+ def encoding_expression(encoding)
263
+ encoding == Encoding::BINARY ? "Encoding::BINARY" : "Encoding::UTF_8"
264
+ end
265
+
266
+ def eof_rules_expression(rules)
267
+ pairs = rules.map { |state, action| "#{ruby_literal(state)} => #{action_expression(action)}" }
268
+ "{ #{pairs.join(', ')} }"
269
+ end
270
+
271
+ def action_expression(action)
272
+ return action if action.is_a?(String) && action.start_with?("proc")
273
+ return ruby_literal(action) unless action.is_a?(Proc)
274
+
275
+ "proc { emit(nil, text) }"
276
+ end
277
+
278
+ def generated_action_source(parsed, indent)
279
+ lines = ["#{indent}def __flexr_generated_execute(rule)", "#{indent} case rule.index"]
280
+ parsed.rules.each do |rule|
281
+ lines << "#{indent} when #{rule.index}"
282
+ body = inline_action_body(rule)
283
+ if body.nil?
284
+ lines << "#{indent} instance_exec(&rule.action)"
285
+ else
286
+ body.lines.each { |line| lines << "#{indent} #{line.rstrip}" }
287
+ end
288
+ end
289
+ lines << "#{indent} else"
290
+ lines << "#{indent} instance_exec(&rule.action)"
291
+ lines << "#{indent} end"
292
+ lines << "#{indent}end\n"
293
+ lines.join("\n")
294
+ end
295
+
296
+ def inline_action_body(rule)
297
+ action = rule.action
298
+ case action
299
+ when :skip
300
+ ""
301
+ when Array
302
+ return "emit(#{ruby_literal(action.fetch(1))}, text)" if action.first == :emit
303
+
304
+ nil
305
+ when String
306
+ return inline_simple_action(rule.action_source) if rule.action_source
307
+ return unless action.start_with?("proc")
308
+
309
+ body = action.delete_prefix("proc").strip
310
+ if body.start_with?("{") && body.end_with?("}")
311
+ body = body[1...-1]
312
+ return nil if body.lstrip.start_with?("|")
313
+ return body.strip
314
+ end
315
+ return nil unless body.start_with?("do") && body.end_with?("end")
316
+
317
+ body.delete_prefix("do")[0...-3].strip
318
+ end
319
+ end
320
+
321
+ # Hash#inspect changed from hash rockets to keyword-style labels in Ruby
322
+ # 4. Generated source and its golden digests must be identical on every
323
+ # supported Ruby, so serialize the small set of Ruby literals used in the
324
+ # generated payload ourselves.
325
+ def ruby_literal(value)
326
+ case value
327
+ when Hash
328
+ return "{}" if value.empty?
329
+
330
+ entries = value.map do |key, nested|
331
+ if key.is_a?(Symbol) && key.to_s.match?(/\A[a-zA-Z_]\w*\z/)
332
+ "#{key}: #{ruby_literal(nested)}"
333
+ else
334
+ "#{ruby_literal(key)} => #{ruby_literal(nested)}"
335
+ end
336
+ end
337
+ "{#{entries.join(', ')}}"
338
+ when Array
339
+ "[#{value.map { |item| ruby_literal(item) }.join(', ')}]"
340
+ else
341
+ value.inspect
342
+ end
343
+ end
344
+
345
+ def inline_simple_action(source)
346
+ body = source.to_s.strip
347
+ body = body[1...-1].strip if body.start_with?("{") && body.end_with?("}")
348
+ match = INLINE_EMIT.match(body)
349
+ return unless match
350
+
351
+ arguments = match[1].to_s.strip
352
+ return unless arguments.empty? || INLINE_EMIT_ARGUMENTS.match?(arguments)
353
+
354
+ arguments.empty? ? "emit" : "emit #{arguments}"
355
+ end
356
+
357
+ def validate_diagnostics!(compiled)
358
+ return unless @options[:warn_as_error]
359
+
360
+ diagnostic = Array(compiled.diagnostics).find do |item|
361
+ item.warning? && (@options.fetch(:warn_level, :default) == :all || item.code != "FLEXR-W016")
362
+ end
363
+ return unless diagnostic
364
+
365
+ raise CompileError.new(diagnostic.message, diagnostic: diagnostic)
366
+ end
367
+
368
+ def generate_from_runtime(source)
369
+ loader = Object.new
370
+ loader.define_singleton_method(:load_spec) do
371
+ # --eval is opt-in and intentionally executes trusted specification code.
372
+ # rubocop:disable Security/Eval
373
+ eval(source, TOPLEVEL_BINDING, @path, 1)
374
+ # rubocop:enable Security/Eval
375
+ end
376
+ loader.instance_variable_set(:@path, @path)
377
+ loader.load_spec
378
+ passthrough = Source::PrismReader.new(source, path: @path).read(allow_dynamic: true)
379
+ classes = ObjectSpace.each_object(Class).select { |klass| klass.respond_to?(:__flexr_spec) }
380
+ classes.select! do |klass|
381
+ name = klass.name.to_s
382
+ (passthrough.class_name.nil? || name == passthrough.class_name || name.end_with?("::#{passthrough.class_name}")) &&
383
+ klass != Flexr::Lexer && klass.__flexr_rules.any?
384
+ end
385
+ klass = classes.last
386
+ raise CompileError, "--eval could not find a Flexr::Lexer class" unless klass
387
+
388
+ # --eval is intentionally explicit; the generated file still contains
389
+ # executable action source, but its metadata is deterministic for this run.
390
+ parsed = klass.__flexr_spec
391
+ runtime_rules = parsed.rules.to_h { |rule| [rule.index, rule] }
392
+ passthrough.rules.each do |rule|
393
+ runtime_rule = runtime_rules.fetch(rule.index)
394
+ rule.patterns = runtime_rule.patterns
395
+ rule.trailing = runtime_rule.trailing
396
+ end
397
+ generate_static(passthrough)
398
+ end
399
+ end
400
+ end