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
@@ -4,12 +4,12 @@ module Flexr
4
4
  module Source
5
5
  RuleDefinition = Struct.new(
6
6
  :index, :patterns, :trailing, :action_source, :action, :states, :bol_only, :end_anchor,
7
- :pattern_conditions,
7
+ :pattern_conditions, :bind_action,
8
8
  :span, keyword_init: true
9
9
  )
10
10
  SpecSource = Struct.new(
11
11
  :source, :path, :class_name, :rules, :config, :dsl_spans, :first_dsl_offset, :constants,
12
- :states, keyword_init: true
12
+ :states, :flexr_require_spans, :dsl_edits, keyword_init: true
13
13
  )
14
14
 
15
15
  class PrismReader
@@ -19,9 +19,11 @@ module Flexr
19
19
  @source = source
20
20
  @path = path
21
21
  @constants = {}
22
- @constant_definitions = {}
22
+ @constant_definitions = []
23
+ @resolved_constant_definitions = {}
23
24
  @rules = []
24
25
  @spans = []
26
+ @edits = []
25
27
  @states = { initial: { inclusive: true } }
26
28
  @eof_rules = {}
27
29
  @config = { backend: :table, token_kind: :array, encoding: Encoding::UTF_8, declared_tokens: [] }
@@ -47,20 +49,24 @@ module Flexr
47
49
 
48
50
  @lexer_scope = class_name.to_s.delete_prefix("::").split("::").reject(&:empty?)
49
51
  collect_constants(prism.value)
50
- resolve_constants
51
52
  collect_body(klass.body, [:initial])
53
+ require_spans = flexr_require_spans(prism.value)
52
54
  first_dsl_offset = @spans.map(&:first).min || class_body_offset(klass)
53
55
  @config[:eof_rules] = @eof_rules
54
56
  SpecSource.new(source: @source, path: @path, class_name: class_name, rules: @rules,
55
57
  config: @config, dsl_spans: @spans.uniq(&:first),
56
- first_dsl_offset: first_dsl_offset, constants: @constants, states: @states)
58
+ first_dsl_offset: first_dsl_offset, constants: @constants, states: @states,
59
+ flexr_require_spans: require_spans, dsl_edits: @edits)
57
60
  end
58
61
 
59
62
  private
60
63
 
61
64
  def find_lexer_class(node, namespace = [])
62
65
  candidates = lexer_class_candidates(node, namespace)
63
- candidates.find { |candidate, _name| lexer_has_dsl?(candidate.body) } || candidates.first
66
+ dsl_candidates = candidates.select { |candidate, _name| lexer_has_dsl?(candidate.body) }
67
+ unsupported_static!("multiple lexer classes require an explicit selection") if dsl_candidates.length > 1
68
+
69
+ dsl_candidates.first || candidates.first
64
70
  end
65
71
 
66
72
  def lexer_class_candidates(node, namespace = [])
@@ -99,6 +105,11 @@ module Flexr
99
105
  return unless node
100
106
 
101
107
  kind = node.class.name.split("::").last
108
+ if kind == "ProgramNode"
109
+ collect_constants(node.statements, scope: scope)
110
+ return
111
+ end
112
+
102
113
  if %w[ClassNode ModuleNode].include?(kind)
103
114
  nested_scope = scope + namespace_parts(node.constant_path)
104
115
  collect_constants(node.body, scope: nested_scope)
@@ -107,28 +118,30 @@ module Flexr
107
118
 
108
119
  if kind.end_with?("ConstantWriteNode")
109
120
  name = qualify_constant(scope, node.name)
110
- @constant_definitions[name] = [node.value, scope.dup]
121
+ @constant_definitions << [name, node.value, scope.dup, node.location.start_offset]
122
+ return
123
+ end
124
+
125
+ if kind == "StatementsNode"
126
+ node.body.each { |child| collect_constants(child, scope: scope) }
127
+ return
111
128
  end
112
129
 
113
- node.child_nodes.compact.each { |child| collect_constants(child, scope: scope) }
130
+ return unless kind == "CallNode" && node.receiver.nil? && %i[state all_states].include?(node.name.to_sym)
131
+
132
+ collect_constants(node.block&.body, scope: scope)
114
133
  end
115
134
 
116
- def resolve_constants
117
- pending = @constant_definitions.dup
118
-
119
- loop do
120
- resolved = false
121
- pending.each_key do |name|
122
- value_node, scope = pending.fetch(name)
123
- value = StaticEval.new(@source, constants: @constants, scope: scope).call(value_node)
124
- @constants[name] = value
125
- pending.delete(name)
126
- resolved = true
127
- rescue StaticResolutionError
128
- # Keep unresolved definitions out of the table. If a DSL expression
129
- # refers to one later, StaticEval emits the normal FLEXR-E017.
130
- end
131
- break unless resolved
135
+ def resolve_constants(before_offset:)
136
+ @constant_definitions.sort_by(&:last).each do |name, value_node, scope, offset|
137
+ next if offset >= before_offset || @resolved_constant_definitions[offset]
138
+
139
+ @resolved_constant_definitions[offset] = true
140
+ value = StaticEval.new(@source, constants: @constants, scope: scope).call(value_node)
141
+ @constants[name] = value
142
+ rescue StaticResolutionError
143
+ # Preserve dynamic constants as ordinary Ruby. A later DSL reference
144
+ # still receives FLEXR-E017 instead of seeing a forward value.
132
145
  end
133
146
  end
134
147
 
@@ -143,6 +156,7 @@ module Flexr
143
156
  def collect_body(node, states)
144
157
  body = node.respond_to?(:body) ? node.body : []
145
158
  Array(body).each do |child|
159
+ resolve_constants(before_offset: child.location.start_offset)
146
160
  collect_call(child, states)
147
161
  end
148
162
  end
@@ -150,19 +164,24 @@ module Flexr
150
164
  def collect_call(node, states)
151
165
  return unless node
152
166
  return collect_body(node, states) if node.class.name.end_with?("StatementsNode")
153
- return unless node.class.name.end_with?("CallNode")
154
- return if node.receiver
167
+ unless node.class.name.end_with?("CallNode") && node.receiver.nil?
168
+ reject_unhandled_dsl!(node)
169
+ return
170
+ end
155
171
 
156
172
  name = node.name.to_sym
157
- return unless DSL_NAMES.include?(name)
158
- @spans << [node.location.start_offset, node.location.end_offset]
173
+ unless DSL_NAMES.include?(name)
174
+ reject_unhandled_dsl!(node)
175
+ return
176
+ end
177
+ record_deletion(node) unless %i[rule state all_states].include?(name)
159
178
  case name
160
179
  when :rule
161
180
  collect_rule(node, states)
162
181
  when :state
163
182
  collect_state(node, states)
164
183
  when :all_states
165
- collect_state(node, @states.keys)
184
+ collect_all_states(node, states)
166
185
  when :on_eof
167
186
  action_source = node.block && source_slice(node.block)
168
187
  action = if action_source
@@ -173,28 +192,50 @@ module Flexr
173
192
  values = positional(node).map { |item| static(item) }.compact.flatten
174
193
  @config[:declared_tokens].concat(values.map(&:to_sym))
175
194
  when :backend
176
- @config[:backend] = static(positional(node).first).to_sym
195
+ @config[:backend] = Configuration.backend!(static(positional(node).first))
177
196
  when :token_kind
178
- @config[:token_kind] = static(positional(node).first).to_sym
197
+ @config[:token_kind] = Configuration.token_kind!(static(positional(node).first))
179
198
  when :encoding
180
199
  @config[:encoding] = static(positional(node).first)
181
200
  when :option
182
201
  @config[:options] ||= {}
183
- positional(node).each { |item| @config[:options][static(item).to_sym] = true }
202
+ positional(node).each { |item| @config[:options][Configuration.option!(static(item))] = true }
184
203
  when :accel
185
204
  @config[:options] ||= {}
186
- @config[:options][:accel] = static(positional(node).first).to_sym
205
+ @config[:options][:accel] = Configuration.accelerator!(static(positional(node).first))
187
206
  end
188
207
  end
189
208
 
190
209
  def collect_state(node, parent_states)
210
+ block = node.block
211
+ raise ArgumentError, "state requires a block" unless block
212
+
191
213
  args = positional(node)
192
214
  names = args.map { |argument| static(argument, scope: @lexer_scope).to_sym }
193
215
  names = parent_states if node.name.to_sym == :all_states
216
+ raise ArgumentError, "state requires a name" if names.empty?
217
+
218
+ validate_keywords!(node, %i[inclusive])
194
219
  inclusive = keyword(node, :inclusive) ? static(keyword(node, :inclusive), scope: @lexer_scope) : false
195
- names.each { |name| @states[name] = { inclusive: inclusive } }
220
+ names.each do |name|
221
+ existing = @states[name]
222
+ raise ArgumentError, "state #{name.inspect} already declared with inclusive: #{existing[:inclusive]}" if
223
+ existing && existing[:inclusive] != inclusive
224
+ @states[name] ||= { inclusive: inclusive }
225
+ end
226
+ record_state_wrapper(node, block)
227
+ active_states = parent_states == [:initial] ? names : (parent_states + names).uniq
228
+ collect_body(block.body, active_states)
229
+ end
230
+
231
+ def collect_all_states(node, parent_states)
196
232
  block = node.block
197
- collect_body(block&.body, names)
233
+ raise ArgumentError, "all_states requires a block" unless block
234
+
235
+ validate_keywords!(node, [])
236
+ unsupported_static!("all_states does not accept arguments") unless positional(node).empty?
237
+ record_state_wrapper(node, block)
238
+ collect_body(block.body, (parent_states + @states.keys).uniq)
198
239
  end
199
240
 
200
241
  def collect_rule(node, states)
@@ -206,6 +247,7 @@ module Flexr
206
247
  raise CompileError.new(diagnostic.message, diagnostic: diagnostic)
207
248
  end
208
249
  options = keywords(node)
250
+ validate_keywords!(node, %i[skip emit followed_by])
209
251
  action_source = node.block && source_slice(node.block)
210
252
  if action_source&.match?(/\breject\b/)
211
253
  diagnostic = Diagnostics.error(
@@ -214,25 +256,26 @@ module Flexr
214
256
  )
215
257
  raise CompileError.new(diagnostic.message, diagnostic: diagnostic)
216
258
  end
217
- action = if action_source
259
+ block_action = if action_source
218
260
  action_source.start_with?("do") ? "proc #{action_source}" : "proc#{action_source}"
219
261
  end
262
+ skip = options.key?(:skip) ? static(options[:skip], scope: @lexer_scope) : false
263
+ emit = options.key?(:emit) ? static(options[:emit], scope: @lexer_scope) : nil
264
+ action = ActionResolver.resolve(
265
+ skip: skip, emit: emit, block: block_action, default: "proc { emit(nil, text) }"
266
+ )
267
+ bind_action = !block_action.nil? && action.equal?(block_action)
268
+ rule_index = @rules.length
269
+ record_rule_edit(node, rule_index, bind_action ? block_action : nil)
220
270
  @rules << RuleDefinition.new(
221
- index: @rules.length, patterns: patterns,
271
+ index: rule_index, patterns: patterns,
222
272
  trailing: options[:followed_by] && static(options[:followed_by], scope: @lexer_scope),
223
- action_source: action_source, action: action || action_for(options), states: states.dup,
273
+ action_source: action_source, action: action, states: states.dup,
224
274
  bol_only: false, end_anchor: nil,
225
- span: [node.location.start_offset, node.location.end_offset]
275
+ span: [node.location.start_offset, node.location.end_offset], bind_action: bind_action
226
276
  )
227
277
  end
228
278
 
229
- def action_for(options)
230
- return :skip if options[:skip] && static(options[:skip])
231
- return [:emit, static(options[:emit]).to_sym] if options[:emit]
232
-
233
- "proc { emit(nil, text) }"
234
- end
235
-
236
279
  def static(node, scope: @lexer_scope)
237
280
  StaticEval.new(@source, constants: @constants, scope: scope).call(node)
238
281
  rescue StaticResolutionError
@@ -254,6 +297,68 @@ module Flexr
254
297
  keywords(node)[key]
255
298
  end
256
299
 
300
+ def validate_keywords!(node, allowed)
301
+ unknown = keywords(node).keys - allowed
302
+ unsupported_static!("unknown keyword #{unknown.first.inspect} for #{node.name}") unless unknown.empty?
303
+ end
304
+
305
+ def record_deletion(node)
306
+ span = [node.location.start_offset, node.location.end_offset]
307
+ @spans << span
308
+ @edits << [*span, ""]
309
+ end
310
+
311
+ def record_rule_edit(node, rule_index, block_action)
312
+ span = [node.location.start_offset, node.location.end_offset]
313
+ replacement = block_action ? "__flexr_bind_generated_action(#{rule_index}, #{block_action})" : ""
314
+ @spans << span
315
+ @edits << [*span, replacement]
316
+ end
317
+
318
+ def record_state_wrapper(node, block)
319
+ body = block.body
320
+ return record_deletion(node) unless body
321
+
322
+ body_start = Source::Passthrough.line_start_offset(@source, body.location.start_offset)
323
+ opening = [node.location.start_offset, body_start]
324
+ closing = [body.location.end_offset, node.location.end_offset]
325
+ opening_source = @source.byteslice(block.location.start_offset...body_start)
326
+ closing_source = @source.byteslice(body.location.end_offset...block.location.end_offset)
327
+ @spans.push(opening, closing)
328
+ @edits << [*opening, "(::Kernel.proc #{opening_source}"]
329
+ @edits << [*closing, "#{closing_source}).call"]
330
+ end
331
+
332
+ def reject_unhandled_dsl!(node)
333
+ each_node_including_self(node) do |child|
334
+ next unless child.class.name.end_with?("CallNode")
335
+ if %i[send public_send].include?(child.name.to_sym)
336
+ dynamic_name = dynamically_dispatched_dsl(child)
337
+ unsupported_static!("dynamic dispatch of #{dynamic_name} is not statically supported") if dynamic_name
338
+ end
339
+ next unless DSL_NAMES.include?(child.name.to_sym)
340
+ next unless child.receiver.nil? || child.receiver.class.name.end_with?("SelfNode")
341
+
342
+ unsupported_static!("#{child.name} must be a direct receiverless statement in a lexer or state body")
343
+ end
344
+ end
345
+
346
+ def dynamically_dispatched_dsl(node)
347
+ first_argument = Array(node.arguments&.arguments).first
348
+ return unless first_argument && first_argument.class.name.end_with?("SymbolNode")
349
+
350
+ name = first_argument.unescaped.to_sym
351
+ name if DSL_NAMES.include?(name)
352
+ end
353
+
354
+ def unsupported_static!(reason)
355
+ diagnostic = Diagnostics.error(
356
+ "FLEXR-E017", "DSL structure is not statically supported",
357
+ help: "move the DSL call to the lexer or state body, or use --eval", note: reason
358
+ )
359
+ raise StaticResolutionError.new(diagnostic.message, diagnostic: diagnostic)
360
+ end
361
+
257
362
  def each_node(node, &block)
258
363
  return unless node
259
364
 
@@ -263,6 +368,30 @@ module Flexr
263
368
  end
264
369
  end
265
370
 
371
+ def flexr_require_spans(node)
372
+ spans = []
373
+ each_node_including_self(node) do |child|
374
+ next unless child.class.name.end_with?("CallNode")
375
+ next unless child.receiver.nil? && child.name.to_sym == :require
376
+
377
+ arguments = Array(child.arguments&.arguments)
378
+ next unless arguments.one?
379
+
380
+ argument = arguments.first
381
+ next unless argument.class.name.end_with?("StringNode") && argument.unescaped == "flexr"
382
+
383
+ spans << [child.location.start_offset, child.location.end_offset]
384
+ end
385
+ spans
386
+ end
387
+
388
+ def each_node_including_self(node, &block)
389
+ return unless node
390
+
391
+ block.call(node)
392
+ node.child_nodes.compact.each { |child| each_node_including_self(child, &block) }
393
+ end
394
+
266
395
  def source_slice(node)
267
396
  return "" unless node
268
397
 
@@ -45,7 +45,15 @@ module Flexr
45
45
  when "NilNode"
46
46
  nil
47
47
  when "ArrayNode"
48
- node.elements.map { |element| evaluate(element) }
48
+ node.elements.flat_map do |element|
49
+ if element.class.name.end_with?("SplatNode")
50
+ Array(evaluate(element.expression))
51
+ else
52
+ [evaluate(element)]
53
+ end
54
+ end
55
+ when "HashNode"
56
+ evaluate_hash(node)
49
57
  when "ConstantReadNode"
50
58
  evaluate_constant([node.name.to_s], absolute: false)
51
59
  when "ConstantPathNode"
@@ -66,8 +74,10 @@ module Flexr
66
74
  kind = part.class.name.split("::").last
67
75
  if kind == "EmbeddedStatementsNode"
68
76
  body = part.statements&.body || []
69
- value = evaluate(body.last)
70
- value.is_a?(::Regexp) ? "(?:#{value.source})" : value.to_s
77
+ raise_resolution("interpolation must contain exactly one expression") unless body.one?
78
+
79
+ value = evaluate(body.first)
80
+ value.is_a?(::Regexp) ? regexp_fragment(value) : value.to_s
71
81
  else
72
82
  evaluate(part).to_s
73
83
  end
@@ -95,7 +105,7 @@ module Flexr
95
105
  return value unless value.equal?(MISSING)
96
106
  end
97
107
 
98
- return ::Encoding.const_get(parts.last) if !absolute && parts.length == 2 && parts.first == "Encoding"
108
+ return ::Encoding.const_get(parts.last) if parts.length == 2 && parts.first == "Encoding"
99
109
 
100
110
  raise_resolution("constant #{parts.join('::')} is not statically known")
101
111
  rescue NameError
@@ -122,6 +132,32 @@ module Flexr
122
132
  raise_resolution("method call #{node.name} is not statically supported")
123
133
  end
124
134
 
135
+ def evaluate_hash(node)
136
+ node.elements.each_with_object({}) do |element, result|
137
+ kind = element.class.name.split("::").last
138
+ if kind == "AssocNode"
139
+ result[evaluate(element.key)] = evaluate(element.value)
140
+ elsif kind == "AssocSplatNode"
141
+ value = evaluate(element.value)
142
+ raise_resolution("hash splat value must be a Hash") unless value.is_a?(Hash)
143
+
144
+ result.merge!(value)
145
+ else
146
+ raise_resolution("hash element #{kind} is not statically supported")
147
+ end
148
+ end
149
+ end
150
+
151
+ def regexp_fragment(regexp)
152
+ flags = {
153
+ "i" => ::Regexp::IGNORECASE,
154
+ "m" => ::Regexp::MULTILINE,
155
+ "x" => ::Regexp::EXTENDED
156
+ }
157
+ enabled, disabled = flags.partition { |_name, bit| regexp.options.anybits?(bit) }
158
+ "(?#{enabled.to_h.keys.join}-#{disabled.to_h.keys.join}:#{regexp.source})"
159
+ end
160
+
125
161
  def positional_arguments(node)
126
162
  Array(node.arguments&.arguments).reject { |argument| argument.class.name.end_with?("KeywordHashNode") }
127
163
  end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "runtime" unless defined?(Flexr::Lexer)
4
+ require_relative "source/static_eval"
5
+ require_relative "source/prism_reader"
6
+ require_relative "source/passthrough"