Class: Ibex::GrammarTests::Runner

Inherits:
Object
  • Object
show all
Defined in:
lib/ibex/grammar_tests.rb,
sig/ibex/grammar_tests.rbs

Overview

Runs all examples in one isolated process while creating a fresh parser instance for every case.

Constant Summary collapse

CHILD_RUNNER =

Returns:

  • (::String)
<<~'RUBY'
  require "json"

  load ARGV.fetch(0)
  parser_class = ARGV.fetch(1).split("::").reject(&:empty?).reduce(Object) do |scope, name|
    scope.const_get(name, false)
  end
  tests = JSON.parse(ARGV.fetch(2))
  results = tests.map do |test|
    production_ids = []
    begin
      parser = parser_class.new
      raise NoMethodError, "#{parser_class} must define parse(source)" unless parser.respond_to?(:parse)

      parser.observe do |event|
        production_ids << event.data.fetch("production_id") if event.type == :reduce
      end
      parser.parse(test.fetch("source"))
      result = { "actual" => "accept", "error_class" => nil, "error_message" => nil }
    rescue Ibex::Runtime::ParseError => error
      result = { "actual" => "reject", "error_class" => error.class.name, "error_message" => error.message }
    rescue SystemExit, SignalException, StandardError => error
      result = { "actual" => "error", "error_class" => error.class.name, "error_message" => error.message }
    end
    result.merge("production_ids" => production_ids)
  end
  puts "IBEX_GRAMMAR_TEST_RESULT=#{JSON.generate(results)}"
RUBY

Instance Method Summary collapse

Constructor Details

#initialize(automaton, timeout: DEFAULT_TIMEOUT) ⇒ Runner

Returns a new instance of Runner.

RBS:

  • (IR::Automaton automaton, ?timeout: Integer) -> void

Parameters:

  • automaton (IR::Automaton)
  • timeout: (Integer) (defaults to: DEFAULT_TIMEOUT)


90
91
92
93
94
95
# File 'lib/ibex/grammar_tests.rb', line 90

def initialize(automaton, timeout: DEFAULT_TIMEOUT)
  raise ArgumentError, "timeout must be a positive Integer" unless timeout.is_a?(Integer) && timeout.positive?

  @automaton = automaton
  @timeout = timeout
end

Instance Method Details

#build_result(test, document) ⇒ Result

RBS:

  • (IR::grammar_test test, untyped document) -> Result

Parameters:

  • test (IR::grammar_test)
  • document (Object)

Returns:



186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
# File 'lib/ibex/grammar_tests.rb', line 186

def build_result(test, document)
  unless document.is_a?(Hash) && %w[accept reject error].include?(document["actual"])
    raise Ibex::Error, "(test):1:1: grammar test process returned an invalid case result"
  end

  production_ids = document["production_ids"]
  production_count = @automaton.grammar.productions.length
  unless production_ids.is_a?(Array) &&
         production_ids.all? { |id| id.is_a?(Integer) && id.between?(0, production_count - 1) }
    raise Ibex::Error, "(test):1:1: grammar test process returned invalid production coverage"
  end

  Result.new(
    expectation: test[:expectation], actual: document.fetch("actual").to_sym,
    error_class: document["error_class"], error_message: document["error_message"],
    location: test[:loc], production_ids: production_ids.freeze
  ).freeze
end

#decode_results(output) ⇒ Array[Result]

RBS:

  • (String output) -> Array[Result]

Parameters:

  • output (String)

Returns:



170
171
172
173
174
175
176
177
178
179
180
181
182
183
# File 'lib/ibex/grammar_tests.rb', line 170

def decode_results(output)
  line = output.lines.reverse.find { |candidate| candidate.start_with?(RESULT_MARKER) }
  raise Ibex::Error, "(test):1:1: grammar test process produced no result" unless line

  documents = JSON.parse(line.delete_prefix(RESULT_MARKER).strip)
  tests = @automaton.grammar.grammar_tests
  unless documents.is_a?(Array) && documents.length == tests.length
    raise Ibex::Error, "(test):1:1: grammar test process returned an invalid result count"
  end

  tests.zip(documents).map { |test, document| build_result(test, document) }
rescue JSON::ParserError, ArgumentError => e
  raise Ibex::Error, "(test):1:1: invalid grammar test process result: #{e.message}"
end

#execute_child(parser_path, runner_path) ⇒ Array[Result]

RBS:

  • (String parser_path, String runner_path) -> Array[Result]

Parameters:

  • parser_path (String)
  • runner_path (String)

Returns:



132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
# File 'lib/ibex/grammar_tests.rb', line 132

def execute_child(parser_path, runner_path)
  payload = JSON.generate(@automaton.grammar.grammar_tests)
  stdout = Tempfile.new("ibex-grammar-test-stdout")
  stderr = Tempfile.new("ibex-grammar-test-stderr")
  stdout_path = stdout.path || raise(Ibex::Error, "(test):1:1: missing temporary stdout path")
  stderr_path = stderr.path || raise(Ibex::Error, "(test):1:1: missing temporary stderr path")
  pid = spawn(
    RbConfig.ruby, runner_path, parser_path, @automaton.grammar.class_name, payload,
    out: stdout_path, err: stderr_path
  )
  status = wait_for_child(pid)
  output = File.binread(stdout_path)
  errors = File.binread(stderr_path)
  raise_child_failure(status, errors) unless status.success?

  decode_results(output)
ensure
  stdout&.close!
  stderr&.close!
end

#generated_parserString

RBS:

  • () -> String

Returns:

  • (String)


127
128
129
# File 'lib/ibex/grammar_tests.rb', line 127

def generated_parser
  Codegen::Ruby.new(@automaton, table: :compact, embedded: true, line_convert: true).generate
end

#production_coverage(results) ⇒ ProductionCoverage

RBS:

  • (Array[Result] results) -> ProductionCoverage

Parameters:

Returns:



115
116
117
118
119
120
121
122
# File 'lib/ibex/grammar_tests.rb', line 115

def production_coverage(results)
  production_count = @automaton.grammar.productions.length
  covered_ids = results.flat_map(&:production_ids).uniq.sort.freeze
  missing_ids = ((0...production_count).to_a - covered_ids).freeze
  ProductionCoverage.new(
    covered_ids: covered_ids, missing_ids: missing_ids, production_count: production_count
  ).freeze
end

#raise_child_failure(status, errors) ⇒ bot

RBS:

  • (Process::Status status, String errors) -> bot

Parameters:

  • status (Process::Status)
  • errors (String)

Returns:

  • (bot)


163
164
165
166
167
# File 'lib/ibex/grammar_tests.rb', line 163

def raise_child_failure(status, errors)
  detail = errors.lines.last&.strip
  suffix = detail && !detail.empty? ? ": #{detail}" : ""
  raise Ibex::Error, "(test):1:1: grammar test process exited with status #{status.exitstatus}#{suffix}"
end

#runArray[Result]

RBS:

  • () -> Array[Result]

Returns:



98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
# File 'lib/ibex/grammar_tests.rb', line 98

def run
  grammar = @automaton.grammar
  raise Ibex::Error, "(test):1:1: grammar declares no %test cases" if grammar.grammar_tests.empty?
  unless grammar.parser_parameters.empty?
    raise Ibex::Error, "(test):1:1: %test cannot instantiate a parser with required %param declarations"
  end

  Dir.mktmpdir("ibex-grammar-tests") do |directory|
    parser_path = File.join(directory, "parser.rb")
    runner_path = File.join(directory, "runner.rb")
    File.binwrite(parser_path, generated_parser)
    File.binwrite(runner_path, CHILD_RUNNER)
    execute_child(parser_path, runner_path)
  end
end

#wait_for_child(pid) ⇒ Process::Status

RBS:

  • (Integer pid) -> Process::Status

Parameters:

  • pid (Integer)

Returns:

  • (Process::Status)


154
155
156
157
158
159
160
# File 'lib/ibex/grammar_tests.rb', line 154

def wait_for_child(pid)
  Timeout.timeout(@timeout) { Process.wait2(pid).fetch(1) }
rescue Timeout::Error
  Process.kill("KILL", pid)
  Process.wait(pid)
  raise Ibex::Error, "(test):1:1: grammar tests exceeded #{@timeout} seconds"
end