# frozen_string_literal: true require 'racc/grammarfileparser' # Unfortunately, Ruby's Coverage module ignores module_eval statements, # which Racc uses to map `parser.y` locations in the generated # `parser.rb`. module RaccCoverage @coverage = {} @base_path = nil @trace = nil def self.start(parsers, base_path) @base_path = base_path parsers.each do |parser| @coverage[parser] = extract_interesting_lines(parser, base_path) end @trace = TracePoint.new(:line) do |trace| lineno = trace.lineno - 1 if (line_coverage = @coverage[trace.path]) if line_coverage[lineno] line_coverage[lineno] += 1 end end end @trace.enable end def self.stop @trace.disable end # Ruby's TracePoint#lineno will point only on "interesting" lines, # i.e.: only code (no comments or empty lines), no `end` keywords, # and for multi-line statements, only the first line of the statement. # # This method implements a very dumb Ruby parser, which skips empty lines # or lines with just comments, `end` keywords, and correctly handles # multi-line statements of the following form: # # * All lines of the statement except the last must end with `,`, `.` or `(`. # # Coverage can be disabled for code regions with annotations :nocov: and :cov:. # # Also, for best results, all actions should be delimited by at least # one non-action line. # def self.extract_interesting_lines(parser, base_path) grammar_source = File.join(@base_path, parser) grammar_file = Racc::GrammarFileParser.parse_file(grammar_source) ruby_sources = [ # Header and footer aren't passed through module_eval # in Racc-generated file, so the location info is lost. *grammar_file.params.inner, ].compact grammar_file.grammar.each_rule do |rule| source = rule.action.source next if source.nil? ruby_sources << source end lines = [] ruby_sources.each do |source| first_line = source.lineno state = :first_line source.text.each_line.with_index do |line, index| line = line.strip continues = line.end_with?(',') || line.end_with?('(') || line.end_with?('.') case state when :first_line if line =~ /:nocov/ state = :nocov next elsif line.empty? || line == 'end' || line.start_with?('#') next elsif continues state = :mid_line end lines[first_line + index - 1] = 0 when :mid_line unless continues state = :first_line end when :nocov if line =~ /:cov:/ state = :first_line end end end end lines end def self.result result = @coverage.map do |parser, coverage| [File.join(@base_path, parser), coverage] end Hash[result] end end class << SimpleCov def result_with_racc_coverage @result ||= SimpleCov::Result.new( Coverage.result.merge(RaccCoverage.result)) result_without_racc_coverage end alias result_without_racc_coverage result alias result result_with_racc_coverage end