module Rcov # A FileStatistics object associates a filename to: # 1. its source code # 2. the per-line coverage information after correction using rcov's heuristics # 3. the per-line execution counts # # A FileStatistics object can be therefore be built given the filename, the # associated source code, and an array holding execution counts (i.e. how many # times each line has been executed). # # FileStatistics is relatively intelligent: it handles normal comments, # =begin/=end, heredocs, many multiline-expressions... It uses a # number of heuristics to determine what is code and what is a comment, and to # refine the initial (incomplete) coverage information. # # Basic usage is as follows: # sf = FileStatistics.new("foo.rb", ["puts 1", "if true &&", " false", # "puts 2", "end"], [1, 1, 0, 0, 0]) # sf.num_lines # => 5 # sf.num_code_lines # => 5 # sf.coverage[2] # => true # sf.coverage[3] # => :inferred # sf.code_coverage # => 0.6 # # The array of strings representing the source code and the array of execution # counts would normally be obtained from a Rcov::CodeCoverageAnalyzer. class FileStatistics attr_reader :name, :lines, :coverage, :counts def initialize(name, lines, counts, comments_run_by_default = false) @name = name @lines = lines initial_coverage = counts.map{|x| (x || 0) > 0 ? true : false } @coverage = CoverageInfo.new initial_coverage @counts = counts @is_begin_comment = nil # points to the line defining the heredoc identifier # but only if it was marked (we don't care otherwise) @heredoc_start = Array.new(lines.size, false) @multiline_string_start = Array.new(lines.size, false) extend_heredocs find_multiline_strings precompute_coverage comments_run_by_default end # Merge code coverage and execution count information. # As for code coverage, a line will be considered # * covered for sure (true) if it is covered in either +self+ or in the # +coverage+ array # * considered :inferred if the neither +self+ nor the +coverage+ array # indicate that it was definitely executed, but it was inferred # in either one # * not covered (false) if it was uncovered in both # # Execution counts are just summated on a per-line basis. def merge(lines, coverage, counts) coverage.each_with_index do |v, idx| case @coverage[idx] when :inferred @coverage[idx] = v || @coverage[idx] when false @coverage[idx] ||= v end end counts.each_with_index{|v, idx| @counts[idx] += v } precompute_coverage false end # Total coverage rate if comments are also considered "executable", given as # a fraction, i.e. from 0 to 1.0. # A comment is attached to the code following it (RDoc-style): it will be # considered executed if the the next statement was executed. def total_coverage return 0 if @coverage.size == 0 @coverage.inject(0.0) {|s,a| s + (a ? 1:0) } / @coverage.size end # Code coverage rate: fraction of lines of code executed, relative to the # total amount of lines of code (loc). Returns a float from 0 to 1.0. def code_coverage indices = (0...@lines.size).select{|i| is_code? i } return 0 if indices.size == 0 count = 0 indices.each {|i| count += 1 if @coverage[i] } 1.0 * count / indices.size end def code_coverage_for_report code_coverage * 100 end def total_coverage_for_report total_coverage * 100 end # Number of lines of code (loc). def num_code_lines (0...@lines.size).select{|i| is_code? i}.size end # Total number of lines. def num_lines @lines.size end # Returns true if the given line number corresponds to code, as opposed to a # comment (either # or =begin/=end blocks). def is_code?(lineno) unless @is_begin_comment @is_begin_comment = Array.new(@lines.size, false) pending = [] state = :code @lines.each_with_index do |line, index| case state when :code if /^=begin\b/ =~ line state = :comment pending << index end when :comment pending << index if /^=end\b/ =~ line state = :code pending.each{|idx| @is_begin_comment[idx] = true} pending.clear end end end end @lines[lineno] && !@is_begin_comment[lineno] && @lines[lineno] !~ /^\s*(#|$)/ end private def find_multiline_strings state = :awaiting_string wanted_delimiter = nil string_begin_line = 0 @lines.each_with_index do |line, i| matching_delimiters = Hash.new{|h,k| k} matching_delimiters.update("{" => "}", "[" => "]", "(" => ")") case state when :awaiting_string # very conservative, doesn't consider the last delimited string but # only the very first one if md = /^[^#]*%(?:[qQ])?(.)/.match(line) if !/"%"/.match(line) wanted_delimiter = /(?!\\).#{Regexp.escape(matching_delimiters[md[1]])}/ # check if closed on the very same line # conservative again, we might have several quoted strings with the # same delimiter on the same line, leaving the last one open unless wanted_delimiter.match(md.post_match) state = :want_end_delimiter string_begin_line = i end end end when :want_end_delimiter @multiline_string_start[i] = string_begin_line if wanted_delimiter.match(line) state = :awaiting_string end end end end def precompute_coverage(comments_run_by_default = true) changed = false lastidx = lines.size - 1 if (!is_code?(lastidx) || /^__END__$/ =~ @lines[-1]) && !@coverage[lastidx] # mark the last block of comments @coverage[lastidx] ||= :inferred (lastidx-1).downto(0) do |i| break if is_code?(i) @coverage[i] ||= :inferred end end (0...lines.size).each do |i| next if @coverage[i] line = @lines[i] if /^\s*(begin|ensure|else|case)\s*(?:#.*)?$/ =~ line && next_expr_marked?(i) or /^\s*(?:end|\})\s*(?:#.*)?$/ =~ line && prev_expr_marked?(i) or /^\s*(?:end\b|\})/ =~ line && prev_expr_marked?(i) && next_expr_marked?(i) or /^\s*rescue\b/ =~ line && next_expr_marked?(i) or /(do|\{)\s*(\|[^|]*\|\s*)?(?:#.*)?$/ =~ line && next_expr_marked?(i) or prev_expr_continued?(i) && prev_expr_marked?(i) or comments_run_by_default && !is_code?(i) or /^\s*((\)|\]|\})\s*)+(?:#.*)?$/ =~ line && prev_expr_marked?(i) or prev_expr_continued?(i+1) && next_expr_marked?(i) @coverage[i] ||= :inferred changed = true end end (@lines.size-1).downto(0) do |i| next if @coverage[i] if !is_code?(i) and @coverage[i+1] @coverage[i] = :inferred changed = true end end extend_heredocs if changed # if there was any change, we have to recompute; we'll eventually # reach a fixed point and stop there precompute_coverage(comments_run_by_default) if changed end require 'strscan' def extend_heredocs i = 0 while i < @lines.size unless is_code? i i += 1 next end #FIXME: using a restrictive regexp so that only <<[A-Z_a-z]\w* # matches when unquoted, so as to avoid problems with 1<<2 # (keep in mind that whereas puts <<2 is valid, puts 1<<2 is a # parse error, but a = 1<<2 is of course fine) scanner = StringScanner.new(@lines[i]) j = k = i loop do scanned_text = scanner.search_full(/<<(-?)(?:(['"`])((?:(?!\2).)+)\2|([A-Z_a-z]\w*))/, true, true) # k is the first line after the end delimiter for the last heredoc # scanned so far unless scanner.matched? i = k break end term = scanner[3] || scanner[4] # try to ignore symbolic bitshifts like 1<= @lines.size found = false idx = (lineno+1).upto(@lines.size-1) do |i| next unless is_code? i found = true break i end return false unless found @coverage[idx] end def prev_expr_marked?(lineno) return false if lineno <= 0 found = false idx = (lineno-1).downto(0) do |i| next unless is_code? i found = true break i end return false unless found @coverage[idx] end def prev_expr_continued?(lineno) return false if lineno <= 0 return false if lineno >= @lines.size found = false if @multiline_string_start[lineno] && @multiline_string_start[lineno] < lineno return true end # find index of previous code line idx = (lineno-1).downto(0) do |i| if @heredoc_start[i] found = true break @heredoc_start[i] end next unless is_code? i found = true break i end return false unless found #TODO: write a comprehensive list if is_code?(lineno) && /^\s*((\)|\]|\})\s*)+(?:#.*)?$/.match(@lines[lineno]) return true end #FIXME: / matches regexps too # the following regexp tries to reject #{interpolation} r = /(,|\.|\+|-|\*|\/|<|>|%|&&|\|\||<<|\(|\[|\{|=|and|or|\\)\s*(?:#(?![{$@]).*)?$/.match @lines[idx] # try to see if a multi-line expression with opening, closing delimiters # started on that line [%w!( )!].each do |opening_str, closing_str| # conservative: only consider nesting levels opened in that line, not # previous ones too. # next regexp considers interpolation too line = @lines[idx].gsub(/#(?![{$@]).*$/, "") opened = line.scan(/#{Regexp.escape(opening_str)}/).size closed = line.scan(/#{Regexp.escape(closing_str)}/).size return true if opened - closed > 0 end if /(do|\{)\s*\|[^|]*\|\s*(?:#.*)?$/.match @lines[idx] return false end r end end end