├── spec ├── other_file2.cr ├── run.sh ├── subdir │ └── bug_keyword.cr ├── template.cr ├── test.cr ├── other_file1.cr ├── main.cr └── other_file3.cr ├── .gitignore ├── src └── coverage │ ├── version.cr │ ├── inject.cr │ ├── runtime.cr │ ├── cli.cr │ ├── inject │ ├── extensions.cr │ ├── macro_utils.cr │ ├── cli.cr │ └── source_file.cr │ └── runtime │ ├── coverage.cr │ └── outputters │ ├── coveralls.cr │ └── html_report.cr ├── shard.lock ├── .editorconfig ├── .travis.yml ├── shard.yml ├── Makefile ├── .ameba.yml ├── template ├── index.html.ecr ├── summary.html.ecr └── cover.html.ecr ├── LICENSE └── README.md /spec/other_file2.cr: -------------------------------------------------------------------------------- 1 | # This file is empty ! 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .shards 2 | /lib/ 3 | /bin/ 4 | /coverage/ 5 | -------------------------------------------------------------------------------- /src/coverage/version.cr: -------------------------------------------------------------------------------- 1 | module Coverage 2 | VERSION = "v0.1" 3 | end 4 | -------------------------------------------------------------------------------- /src/coverage/inject.cr: -------------------------------------------------------------------------------- 1 | module Coverage; end 2 | 3 | require "./version" 4 | require "./inject/**" 5 | -------------------------------------------------------------------------------- /src/coverage/runtime.cr: -------------------------------------------------------------------------------- 1 | module Coverage; end 2 | 3 | require "./version" 4 | require "./runtime/**" 5 | -------------------------------------------------------------------------------- /shard.lock: -------------------------------------------------------------------------------- 1 | version: 1.0 2 | shards: 3 | ameba: 4 | github: veelenga/ameba 5 | version: 0.10.0 6 | 7 | -------------------------------------------------------------------------------- /spec/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | crystal src/coverage/cli.cr -- spec/template.cr --use-require="./src/coverage/runtime" -p -------------------------------------------------------------------------------- /src/coverage/cli.cr: -------------------------------------------------------------------------------- 1 | module Coverage; end 2 | 3 | require "./version" 4 | require "./inject/**" 5 | 6 | Coverage::CLI.run 7 | -------------------------------------------------------------------------------- /spec/subdir/bug_keyword.cr: -------------------------------------------------------------------------------- 1 | puts "This is required in a subdirectory" 2 | 3 | class BugKeyword 4 | def method(@select) 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.cr] 2 | charset = utf-8 3 | end_of_line = lf 4 | insert_final_newline = true 5 | indent_style = space 6 | indent_size = 2 7 | trim_trailing_whitespace = true 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: required 2 | 3 | language: crystal 4 | 5 | crystal: 6 | - latest 7 | 8 | script: 9 | - crystal spec spec/main.cr 10 | - ./bin/ameba 11 | -------------------------------------------------------------------------------- /src/coverage/inject/extensions.cr: -------------------------------------------------------------------------------- 1 | class Crystal::Location 2 | def clone(filename = nil, line_number = nil, column_number = nil) 3 | Crystal::Location.new(filename || @filename, line_number || @line_number, column_number || @column_number) 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /spec/template.cr: -------------------------------------------------------------------------------- 1 | def code 2 | {% unless true %} 3 | {% if false %} 4 | puts "This will not be called" 5 | {% else %} 6 | puts "This will be called" 7 | {% end %} 8 | {% end %} 9 | end 10 | 11 | def for_loop 12 | {% for x in ["a", "b", "c"] %} 13 | puts {{x}} 14 | {% end %} 15 | end 16 | -------------------------------------------------------------------------------- /spec/test.cr: -------------------------------------------------------------------------------- 1 | class Test 2 | def foo 3 | n = 1 4 | while (n < 10) 5 | n += 1 6 | puts "foo!" 7 | end 8 | end 9 | 10 | def bar 11 | begin 12 | x = 1 13 | 14 | x = x + 1 15 | raise "Code below will never be covered" 16 | 17 | puts "Some code below" 18 | rescue 19 | end 20 | end 21 | end 22 | 23 | test = Test.new 24 | 25 | test.foo 26 | -------------------------------------------------------------------------------- /shard.yml: -------------------------------------------------------------------------------- 1 | name: coverage 2 | version: 0.1.0 3 | 4 | authors: 5 | - Yacine Petitprez 6 | 7 | description: | 8 | Get coverage rate of your crystal app ! Proof of concept, beware ! 9 | 10 | targets: 11 | crystal-coverage: 12 | main: src/coverage/cli.cr 13 | 14 | scripts: 15 | postinstall: make bin 16 | 17 | development_dependencies: 18 | ameba: 19 | github: veelenga/ameba 20 | 21 | license: MIT 22 | -------------------------------------------------------------------------------- /spec/other_file1.cr: -------------------------------------------------------------------------------- 1 | require "./**" # Just to make it loop 2 | 3 | module SomeModule 4 | class SomeClass 5 | def some_method 6 | x = 1 7 | x <<= 3 8 | 9 | x = (x * x + 4_123) % 156 10 | end 11 | 12 | def self.some_class_method 13 | begin 14 | raise "Oops" 15 | 16 | puts "Never get called." 17 | rescue 18 | puts "Rescue from raise" 19 | end 20 | end 21 | end 22 | end 23 | 24 | {% for x in ["a", "b", "c"] %} 25 | def {{x.id}} 26 | puts {{x}} 27 | end 28 | {% end %} 29 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | CRYSTAL_BIN ?= $(shell which crystal) 2 | SHARDS_BIN ?= $(shell which shards) 3 | PREFIX ?= /usr/local 4 | SHARD_BIN ?= ../../bin 5 | 6 | build: bin/crystal-coverage 7 | bin/crystal-coverage: 8 | $(SHARDS_BIN) build $(CRFLAGS) 9 | clean: 10 | rm -f .bin/crystal-coverage .bin/crystal-coverage.dwarf 11 | install: build 12 | mkdir -p $(PREFIX)/bin 13 | cp ./bin/crystal-coverage $(PREFIX)/bin 14 | bin: build 15 | mkdir -p $(SHARD_BIN) 16 | cp ./bin/crystal-coverage $(SHARD_BIN) 17 | # test: build 18 | # $(CRYSTAL_BIN) spec 19 | # ./bin/crystal-coverage 20 | -------------------------------------------------------------------------------- /spec/main.cr: -------------------------------------------------------------------------------- 1 | require "./other_file2" 2 | 3 | def main 4 | if_then_else 5 | case_when 6 | 7 | k = SomeModule::SomeClass.new 8 | k.some_method 9 | SomeModule::SomeClass.some_class_method 10 | 11 | n = 0 12 | while n < 10 13 | n += 1 14 | end 15 | end 16 | 17 | require "./**" # Require other files 18 | require "./subdir/**" # Require other files 19 | 20 | def if_then_else 21 | x = "SomeVariable" 22 | y = 2 23 | 24 | if x == "SomeVariable" 25 | y = 1 26 | else 27 | 2 * y 28 | end 29 | end 30 | 31 | def case_when 32 | x = 2 33 | case x 34 | when 1 35 | "Case 1" 36 | when 2 37 | x = "Case 2" 38 | when x >= 3 39 | puts "Else cases" 40 | else 41 | raise "woops" 42 | end 43 | end 44 | 45 | # Call main 46 | main 47 | -------------------------------------------------------------------------------- /.ameba.yml: -------------------------------------------------------------------------------- 1 | # This configuration file was generated by `ameba --gen-config` 2 | # on 2019-06-07 14:36:17 UTC using Ameba version 0.10.0. 3 | # The point is for the user to remove these configuration records 4 | # one by one as the reported problems are removed from the code base. 5 | 6 | # Problems found: 2 7 | # Run `ameba --only Lint/UnreachableCode` for details 8 | Lint/UnreachableCode: 9 | Description: Reports unreachable code 10 | Enabled: true 11 | Severity: Warning 12 | Excluded: 13 | - spec/test.cr 14 | - spec/other_file1.cr 15 | 16 | # Problems found: 2 17 | # Run `ameba --only Lint/UselessAssign` for details 18 | Lint/UselessAssign: 19 | Description: Disallows useless variable assignments 20 | Enabled: true 21 | Severity: Warning 22 | Excluded: 23 | - spec/test.cr 24 | - spec/other_file1.cr 25 | 26 | # Problems found: 2 27 | # Run `ameba --only Style/RedundantBegin` for details 28 | Style/RedundantBegin: 29 | Description: Disallows redundant begin blocks 30 | Enabled: true 31 | Severity: Convention 32 | Excluded: 33 | - spec/test.cr 34 | - spec/other_file1.cr 35 | -------------------------------------------------------------------------------- /template/index.html.ecr: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 34 | 35 | 36 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Yacine Petitprez 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/coverage/runtime/coverage.cr: -------------------------------------------------------------------------------- 1 | require "json" 2 | 3 | module Coverage 4 | @@files = [] of File 5 | # Number per file path 6 | @@reverse_file_index = {} of String => Int32 7 | class_property file_count : Int32 = 0 8 | 9 | class File 10 | property path : String 11 | property source_map : Slice(Int32) 12 | property access_map : Slice(Int32) 13 | property! id : Int32 14 | property md5 : String 15 | 16 | def initialize(@path, @md5, source_map) 17 | @source_map = Slice(Int32).new(source_map.size) { |x| source_map[x] } 18 | @access_map = Slice(Int32).new(source_map.size, 0) 19 | Coverage.add_file(self) 20 | end 21 | 22 | @[AlwaysInline] 23 | def [](line_id) 24 | @access_map.to_unsafe[line_id] += 1 25 | end 26 | end 27 | 28 | abstract class Outputter 29 | abstract def output(files : Array(Coverage::File)) 30 | end 31 | 32 | def self.add_file(file) 33 | file.id = @@files.size 34 | @@files << file 35 | file 36 | end 37 | 38 | @[AlwaysInline] 39 | def self.[](file_id, line_id) 40 | @@files.unsafe_fetch(file_id)[line_id] 41 | end 42 | 43 | # Return results of the coverage in JSON 44 | def self.get_results(outputter : Outputter = Outputter::Text.new) 45 | outputter.output(@@files) 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /template/summary.html.ecr: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Crystal Coverage 5 | 6 | 28 | 29 | 30 |

Covering report

31 |
32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | <%- @covered_files.each do |file| -%> 41 | 42 | 43 | 44 | 45 | 46 | 47 | <%- end -%> 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 |
FileRelevant linesCovered linesPercentage covered
<%=file.filename%><%=file.relevant_lines%><%=file.covered_lines%><%=file.percent_coverage_str%>
TOTAL: <%= total_relevant %><%= total_covered %><%= total_percentage %>
56 | 57 | -------------------------------------------------------------------------------- /src/coverage/inject/macro_utils.cr: -------------------------------------------------------------------------------- 1 | module MacroUtils 2 | def propagate_location_in_macro(node : Crystal::ASTNode, location : Nil) 3 | nil 4 | end 5 | 6 | def propagate_location_in_macro(node : Crystal::Nop, location : Crystal::Location) 7 | location 8 | end 9 | 10 | def propagate_location_in_macro(node : Crystal::MacroIf, location : Crystal::Location) 11 | location = location.clone 12 | 13 | node.then.location = location 14 | location = propagate_location_in_macro(node.then, location) 15 | 16 | node.else.location = location 17 | propagate_location_in_macro(node.else, location) 18 | end 19 | 20 | def propagate_location_in_macro(node : Crystal::MacroFor, location : Crystal::Location) 21 | location = location.clone 22 | 23 | node.body.location = location 24 | propagate_location_in_macro(node.body, location) 25 | end 26 | 27 | def propagate_location_in_macro(node : Crystal::MacroLiteral, location : Crystal::Location) 28 | node.location = location 29 | 30 | location.clone line_number: location.line_number + node.to_s.count('\n') 31 | end 32 | 33 | def propagate_location_in_macro(node : Crystal::Expressions, location) 34 | new_loc = location.clone 35 | 36 | node.expressions.each do |e| 37 | e.location = new_loc 38 | new_loc = propagate_location_in_macro(e, new_loc) 39 | end 40 | 41 | new_loc 42 | end 43 | 44 | def propagate_location_in_macro(node : Crystal::ASTNode, location : Crystal::Location) 45 | location 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /src/coverage/runtime/outputters/coveralls.cr: -------------------------------------------------------------------------------- 1 | class Coverage::Outputter::Coveralls < Coverage::Outputter 2 | def initialize 3 | @service_job_id = (ENV["TRAVIS_JOB_ID"]? || Time.now.to_unix.to_s) 4 | @service_name = ENV["TRAVIS"]? ? "travis-ci" : "dev" 5 | end 6 | 7 | private def get_file_list(files, json) 8 | json.array do 9 | files.each do |file| 10 | json.object do 11 | json.field "name", file.path 12 | json.field "source_digest", file.md5 13 | json.field "coverage" do 14 | json.array do 15 | h = {} of Int32 => Int32? 16 | 17 | file.source_map.each_with_index { |line, idx| h[line] = file.access_map[idx] } 18 | 19 | max_line = file.source_map.max rescue 0 20 | (1...max_line).map { |x| h[x]? }.each { |x| 21 | x.nil? ? json.null : json.number(x) 22 | } 23 | end 24 | end 25 | end 26 | end 27 | end 28 | end 29 | 30 | def output(files : Array(Coverage::File)) 31 | o = JSON.build do |json| 32 | json.object do 33 | json.field "service_job_id", @service_job_id 34 | json.field "service_name", @service_name 35 | json.field "source_files" do 36 | get_file_list(files, json) 37 | end 38 | end 39 | end 40 | 41 | ::File.write("coveralls.json", o) 42 | if ENV["TRAVIS"]? 43 | system("curl -X POST https://coveralls.io/api/v1/jobs -H 'content-type: multipart/form-data' -F json_file=@coveralls.json") 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /src/coverage/inject/cli.cr: -------------------------------------------------------------------------------- 1 | require "option_parser" 2 | # require "tempfile" 3 | 4 | module Coverage 5 | module CLI 6 | def self.run 7 | output_format = "HtmlReport" 8 | filenames = [] of String 9 | print_only = false 10 | 11 | OptionParser.parse! do |parser| 12 | parser.banner = "Usage: crystal-cover [options] " 13 | parser.on("-o FORMAT", "--output-format=FORMAT", "The output format used (default: HtmlReport): HtmlReport, Coveralls ") { |f| output_format = f } 14 | parser.on("-p", "--print-only", "output the generated source code") { |_p| print_only = true } 15 | parser.on("--use-require=REQUIRE", "change the require of cover library in runtime") { |r| Coverage::SourceFile.use_require = r } 16 | parser.unknown_args do |args| 17 | args.each do 18 | filenames << ARGV.shift 19 | end 20 | end 21 | end 22 | 23 | raise "You must choose a file to compile" unless filenames.any? 24 | 25 | Coverage::SourceFile.outputter = "Coverage::Outputter::#{output_format.camelcase}" 26 | 27 | first = true 28 | output = String::Builder.new(capacity: 2**18) 29 | filenames.each do |f| 30 | v = Coverage::SourceFile.new(path: f, source: ::File.read(f)) 31 | output << v.to_covered_source 32 | output << "\n" 33 | first = false 34 | end 35 | 36 | final_output = [ 37 | Coverage::SourceFile.prelude_operations, 38 | output.to_s, 39 | Coverage::SourceFile.final_operations, 40 | ].join("") 41 | 42 | if print_only 43 | puts final_output 44 | else 45 | system("crystal", ["eval", final_output]) 46 | end 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /spec/other_file3.cr: -------------------------------------------------------------------------------- 1 | require "colorize" 2 | require "logger" 3 | require "benchmark" 4 | 5 | module Clear::SQL::Logger 6 | SQL_KEYWORDS = Set(String).new(%w( 7 | ADD ALL ALTER ANALYSE ANALYZE AND ANY ARRAY AS ASC ASYMMETRIC 8 | BEGIN BOTH CASE CAST CHECK COLLATE COLUMN COMMIT CONSTRAINT CREATE CROSS 9 | CURRENT_DATE CURRENT_ROLE CURRENT_TIME CURRENT_TIMESTAMP 10 | CURRENT_USER CURSOR DECLARE DEFAULT DELETE DEFERRABLE DESC 11 | DISTINCT DROP DO ELSE END EXCEPT EXISTS FALSE FETCH FULL FOR FOREIGN FROM GRANT 12 | GROUP HAVING IF IN INDEX INNER INSERT INITIALLY INTERSECT INTO JOIN LEADING 13 | LIMIT LEFT LOCALTIME LOCALTIMESTAMP NEW NOT NULL OFF OFFSET OLD ON ONLY OR 14 | ORDER OUTER PLACING PRIMARY REFERENCES RELEASE RETURNING RIGHT ROLLBACK 15 | SAVEPOINT SELECT SESSION_USER SET SOME SYMMETRIC TABLE THEN TO 16 | TRAILING TRUE UNION UNIQUE UPDATE USER USING VALUES WHEN WHERE 17 | )) 18 | 19 | def self.colorize_query(qry : String) 20 | o = qry.to_s.split(/([a-zA-Z0-9_]+)/).map do |word| 21 | if SQL_KEYWORDS.includes?(word.upcase) 22 | word.colorize.bold.blue.to_s 23 | elsif word =~ /\d+/ 24 | word.colorize.red 25 | else 26 | word.colorize.white 27 | end 28 | end.join("") 29 | o.gsub(/(--.*)$/) { |x| x.colorize.dark_gray } 30 | end 31 | 32 | def self.display_mn_sec(x) : String 33 | mn = x.to_i / 60 34 | sc = x.to_i % 60 35 | 36 | [mn > 9 ? mn : "0#{mn}", sc > 9 ? sc : "0#{sc}"].join("mn") + "s" 37 | end 38 | 39 | def self.display_time(x) : String 40 | if (x > 60) 41 | display_mn_sec(x) 42 | elsif (x > 1) 43 | ("%.2f" % x) + "s" 44 | elsif (x > 0.001) 45 | (1_000*x).to_i.to_s + "ms" 46 | else 47 | (1_000_000*x).to_i.to_s + "µs" 48 | end 49 | end 50 | 51 | def log_query(sql, &block) 52 | time = Time.now.epoch_f 53 | yield 54 | ensure 55 | time = Time.now.epoch_f - time.not_nil! 56 | Clear.logger.debug(("[" + Clear::SQL::Logger.display_time(time).colorize.bold.white.to_s + "] #{SQL::Logger.colorize_query(sql)}")) 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # crystal-coverage 2 | Coverage tool for Crystal lang 3 | 4 | ## Welcome 5 | 6 | Before you start, you must understand this is a proof of concept. I'm not happy 7 | with the current code implementation. 8 | 9 | Lot of features and options will change in the future. 10 | 11 | The code will probably be rebuilt almost from scratch 12 | 13 | Note also than it hasn't yet been properly tested. Ironic, for a cover tool, isn't it? :-) 14 | anyway, if you're bold enough to give a try, read the getting started below ! 15 | 16 | ## Installation 17 | 18 | Just add this line in your `shard.yml` file 19 | 20 | ```yaml 21 | development_dependencies: 22 | coverage: 23 | github: anykeyh/crystal-coverage 24 | ``` 25 | 26 | Wait for the binary to compile. The binary will be build in `bin/crystal-coverage` 27 | 28 | ## Usage 29 | 30 | ``` 31 | crystal-coverage spec/myfile_spec1.cr spec/myfile_spec2.cr 32 | ``` 33 | 34 | Coverage file will be recreated after your software run on `coverage/` folder. 35 | 36 | ## Bugs 37 | 38 | There's probably dozen of bugs. Please fill issues and PR are welcome. 39 | 40 | The library will evolve, so don't hesitate to `shards update` and test with the 41 | latest release before submitting an issue. 42 | 43 | Due to some limitation, there's probably still a non-zero chance your code will 44 | not compile with the coverage instrumentations. 45 | 46 | In this case, you can give a look to the generated output using the `-p` argument: 47 | 48 | ``` 49 | crystal-coverage src/main.cr -p 50 | ``` 51 | 52 | When you fill issues, would be great to isolate the code which fail to load, so I 53 | can add it to my code library and fix the library. 54 | 55 | ## Performances 56 | 57 | The performances will slightly degrade using the coverage tool. Note the 58 | software is executed without release flag. 59 | 60 | To test in `--release` mode, you can do: 61 | 62 | ``` 63 | crystal-coverage src/main.cr -p | crystal eval --release 64 | ``` 65 | 66 | ## How does it works? 67 | 68 | It uses ASTNode parsing to inject coverage instrumentations and reflag the lines 69 | of code using `#` directive 70 | 71 | It covers only the relative files (e.g. require starting with `.`) inside your 72 | project directory. 73 | 74 | It then generate a report in the directory `/coverage/` relative to your project 75 | 76 | ## Planned features 77 | 78 | - Binding with coveralls 79 | -------------------------------------------------------------------------------- /template/cover.html.ecr: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Coverage of <%=@file.filename%> 6 | 7 | 90 | 91 | 92 |
93 |

Cover for <%= @file.filename %>

94 |
95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 |
Hitted linesRelevant linesPercentage
<%=@file.relevant_lines%><%=@file.covered_lines%><%=@file.percent_coverage_str%>
109 |
110 |
111 |
112 |
113 | <%- @file.lines.each_with_index do | (line_content, hit_count), idx| -%> 114 |
115 |
<%=idx+1%>
116 | <%- unless hit_count.nil? -%> 117 |
<%= hit_count %>
118 | <%- end -%> 119 |
 <%= (!hit_count.nil? && hit_count > 0) ? "covered" : "" %>"><%= HTML.escape(line_content) %>
120 |
121 | <%- end -%> 122 |
123 |
124 | 125 | 126 | -------------------------------------------------------------------------------- /src/coverage/runtime/outputters/html_report.cr: -------------------------------------------------------------------------------- 1 | require "ecr" 2 | require "file_utils" 3 | require "html" 4 | 5 | class Coverage::Outputter::HtmlReport < Coverage::Outputter 6 | struct CoverageReport 7 | property filename : String 8 | property md5 : String 9 | property relevant_lines : Int32 = 0 10 | property covered_lines : Int32 = 0 11 | property lines : Array(Tuple(String, Int32?)) = [] of Tuple(String, Int32?) 12 | 13 | def initialize(@filename, @md5) 14 | end 15 | 16 | def percent_coverage 17 | if relevant_lines == 0 18 | 1.0 19 | else 20 | (covered_lines / relevant_lines.to_f) 21 | end 22 | end 23 | 24 | def percent_coverage_str 25 | "#{(100*percent_coverage).round(2)}%" 26 | end 27 | end 28 | 29 | class IndexFile 30 | def initialize(@covered_files : Array(CoverageReport)) 31 | end 32 | 33 | {% begin %} 34 | {% x = __DIR__ + "/../../../../template" %} 35 | ECR.def_to_s "{{x.id}}/index.html.ecr" 36 | {% end %} 37 | end 38 | 39 | class SummaryFile 40 | property total_relevant : Int32 41 | property total_covered : Int32 42 | 43 | def total_percentage 44 | if total_relevant == 0 45 | "100%" 46 | else 47 | (100.0*(total_covered / total_relevant.to_f)).round(2).to_s + "%" 48 | end 49 | end 50 | 51 | def initialize(@covered_files : Array(CoverageReport), @total_relevant, @total_covered) 52 | end 53 | 54 | {% begin %} 55 | {% x = __DIR__ + "/../../../../template" %} 56 | ECR.def_to_s "{{x.id}}/summary.html.ecr" 57 | {% end %} 58 | end 59 | 60 | class CoveredFile 61 | def initialize(@file : CoverageReport) 62 | end 63 | 64 | {% begin %} 65 | {% x = __DIR__ + "/../../../../template" %} 66 | ECR.def_to_s "{{x.id}}/cover.html.ecr" 67 | {% end %} 68 | end 69 | 70 | def initialize 71 | end 72 | 73 | def output(files : Array(Coverage::File)) 74 | puts "Generating coverage report, please wait..." 75 | 76 | system("rm -r coverage/") 77 | 78 | sum_lines = 0 79 | sum_covered = 0 80 | 81 | covered_files = files.map do |file| 82 | hit_counts = {} of Int32 => Int32? 83 | 84 | # Prepare the line hit count 85 | file.source_map.each_with_index do |line, idx| 86 | hit_counts[line] = file.access_map[idx] 87 | end 88 | 89 | # Prepare the coverage report 90 | f = ::File.read(file.path) 91 | cr = CoverageReport.new(file.path, file.md5) 92 | 93 | # Add the coverage info for each line of code... 94 | f.split("\n").each_with_index do |line, line_number| 95 | line_number = line_number + 1 96 | hitted = hit_counts[line_number]? 97 | 98 | unless hitted.nil? 99 | cr.relevant_lines += 1 100 | cr.covered_lines += hitted > 0 ? 1 : 0 101 | end 102 | 103 | cr.lines << {line, hitted} 104 | end 105 | 106 | sum_lines += cr.relevant_lines 107 | sum_covered += cr.covered_lines 108 | 109 | cr 110 | end 111 | 112 | # puts percent covered 113 | if sum_lines == 0 114 | puts "100% covered" 115 | else 116 | puts (100.0*(sum_covered / sum_lines.to_f)).round(2).to_s + "% covered" 117 | end 118 | 119 | # Generate the code 120 | FileUtils.mkdir_p("coverage") 121 | generate_index_file(covered_files) 122 | generate_summary_file(covered_files, sum_lines, sum_covered) 123 | covered_files.each do |file| 124 | generate_detail_file(file) 125 | end 126 | end 127 | 128 | private def generate_index_file(covered_files) 129 | ::File.write("coverage/index.html", IndexFile.new(covered_files).to_s) 130 | end 131 | 132 | private def generate_summary_file(covered_files, total_lines, covered_lines) 133 | ::File.write("coverage/summary.html", SummaryFile.new(covered_files, total_lines, covered_lines).to_s) 134 | end 135 | 136 | private def generate_detail_file(file) 137 | ::File.write("coverage/#{file.md5}.html", CoveredFile.new(file).to_s) 138 | end 139 | end 140 | -------------------------------------------------------------------------------- /src/coverage/inject/source_file.cr: -------------------------------------------------------------------------------- 1 | require "compiler/crystal/syntax/*" 2 | require "digest" 3 | require "file_utils" 4 | 5 | require "./extensions" 6 | require "./macro_utils" 7 | 8 | class Coverage::SourceFile < Crystal::Visitor 9 | # List of keywords which are trouble with variable 10 | # name. Some keywoards are not and won't be present in this 11 | # list. 12 | # Since this can break the code replacing the variable by a underscored 13 | # version of it, and I'm not sure about this list, we will need to add/remove 14 | # stuff to not break the code. 15 | CRYSTAL_KEYWORDS = %w( 16 | abstract do if nil? self unless 17 | alias else of sizeof until 18 | as elsif include struct when 19 | as? end instance_sizeof pointerof super while 20 | asm ensure is_a? private then with 21 | begin enum lib protected true yield 22 | break extend macro require 23 | case false module rescue typeof 24 | class for next return uninitialized 25 | def fun nil select union 26 | ) 27 | 28 | class_getter file_list = [] of Coverage::SourceFile 29 | class_getter already_covered_file_name = Set(String).new 30 | class_getter! project_path : String 31 | class_getter require_expanders = [] of Array(Coverage::SourceFile) 32 | 33 | class_property outputter : String = "Coverage::Outputter::HtmlReport" 34 | class_property use_require : String = "coverage/runtime" 35 | 36 | getter! astree : Crystal::ASTNode 37 | getter id : Int32 = 0 38 | getter path : String 39 | getter md5_signature : String 40 | 41 | getter lines = [] of Int32 42 | getter already_covered_locations = Set(Crystal::Location?).new 43 | 44 | getter source : String 45 | getter! enriched_source : String 46 | getter required_at : Int32 47 | 48 | include MacroUtils 49 | 50 | def self.register_file(f) 51 | @@already_covered_file_name.add(f.path) 52 | @@file_list << f 53 | @@file_list.size - 1 54 | end 55 | 56 | def self.relative_path_to_project(path) 57 | @@project_path ||= FileUtils.pwd 58 | path.gsub(/^#{Coverage::SourceFile.project_path}\//, "") 59 | end 60 | 61 | def self.cover_file(file) 62 | unless already_covered_file_name.includes?(relative_path_to_project(file)) 63 | already_covered_file_name.add(relative_path_to_project(file)) 64 | yield 65 | end 66 | end 67 | 68 | def initialize(@path, @source, @required_at = 0) 69 | @path = Coverage::SourceFile.relative_path_to_project(File.expand_path(@path, ".")) 70 | @md5_signature = Digest::MD5.hexdigest(@source) 71 | @id = Coverage::SourceFile.register_file(self) 72 | end 73 | 74 | # Inject in AST tree if required. 75 | def process 76 | unless @astree 77 | @astree = Crystal::Parser.parse(self.source) 78 | astree.accept(self) 79 | end 80 | end 81 | 82 | def to_covered_source 83 | if @enriched_source.nil? 84 | io = String::Builder.new(capacity: 32_768) 85 | 86 | # call process to enrich AST before 87 | # injection of cover head dependencies 88 | process 89 | 90 | # Inject the location of the zero line of current file 91 | io << inject_location << "\n" 92 | io << unfold_required(inject_line_traces(astree.to_s)) 93 | 94 | @enriched_source = io.to_s 95 | else 96 | @enriched_source.not_nil! 97 | end 98 | end 99 | 100 | private def unfold_required(output) 101 | output.gsub(/require[ \t]+\"\$([0-9]+)\"/) do |_str, matcher| 102 | expansion_id = matcher[1].to_i 103 | file_list = @@require_expanders[expansion_id] 104 | 105 | if file_list.any? 106 | io = String::Builder.new(capacity: (2 ** 20)) 107 | file_list.each do |file| 108 | io << "#" << "require of `" << file.path 109 | io << "` from `" << self.path << ":#{file.required_at}" << "`" << "\n" 110 | io << file.to_covered_source 111 | io << "\n" 112 | io << inject_location(self.path, file.required_at) 113 | io << "\n" 114 | end 115 | io.to_s 116 | else 117 | "" 118 | end 119 | end 120 | end 121 | 122 | private def inject_location(file = @path, line = 0, column = 0) 123 | %(#) 124 | end 125 | 126 | def self.prelude_operations 127 | file_maps = @@file_list.map do |f| 128 | if f.lines.any? 129 | "::Coverage::File.new(\"#{f.path}\", \"#{f.md5_signature}\",[#{f.lines.join(", ")}])" 130 | else 131 | "::Coverage::File.new(\"#{f.path}\", \"#{f.md5_signature}\",[] of Int32)" 132 | end 133 | end.join("\n") 134 | 135 | <<-RAW 136 | require "#{Coverage::SourceFile.use_require}" 137 | #{file_maps} 138 | RAW 139 | end 140 | 141 | def self.final_operations 142 | "\n::Coverage.get_results(#{@@outputter}.new)" 143 | end 144 | 145 | # Inject line tracer for easy debugging. 146 | # add `;` after the Coverage instrumentation 147 | # to avoid some with macros 148 | private def inject_line_traces(output) 149 | output.gsub(/\:\:Coverage\[([0-9]+),[ ]*([0-9]+)\](.*)/) do |_str, match| 150 | [ 151 | "::Coverage[", match[1], 152 | ", ", match[2], "]; ", 153 | match[3], 154 | inject_location(@path, @lines[match[2].to_i] - 1), 155 | ].join("") 156 | end 157 | end 158 | 159 | private def source_map_index(line_number) 160 | @lines << line_number 161 | @lines.size - 1 162 | end 163 | 164 | private def inject_coverage_tracker(node) 165 | if location = node.location 166 | lnum = location.line_number 167 | lidx = source_map_index(lnum) 168 | 169 | n = Crystal::Call.new(Crystal::Global.new("::Coverage"), "[]", 170 | [Crystal::NumberLiteral.new(@id), 171 | Crystal::NumberLiteral.new(lidx)].unsafe_as(Array(Crystal::ASTNode))) 172 | n 173 | else 174 | node 175 | end 176 | end 177 | 178 | private def force_inject_cover(node : Crystal::ASTNode, location = nil) 179 | location ||= node.location 180 | return node if @already_covered_locations.includes?(location) 181 | already_covered_locations << location 182 | Crystal::Expressions.from([inject_coverage_tracker(node), node].unsafe_as(Array(Crystal::ASTNode))) 183 | end 184 | 185 | def inject_cover(node : Crystal::ASTNode) 186 | return node if already_covered_locations.includes?(node.location) 187 | 188 | case node 189 | when Crystal::OpAssign, Crystal::Assign, Crystal::BinaryOp 190 | # We cover assignment 191 | force_inject_cover(node) 192 | when Crystal::Call 193 | # Ignore call to COVERAGE_DOT_CR 194 | obj = node.obj 195 | if (node.obj && obj.is_a?(Crystal::Global) && obj.name == "::Coverage") 196 | return node 197 | end 198 | 199 | # Be ready to cover the calls 200 | force_inject_cover(node) 201 | when Crystal::Break 202 | force_inject_cover(node) 203 | else 204 | node 205 | end 206 | end 207 | 208 | # Management of required file is nasty and should be improved 209 | # Since I've hard time to replace node on visit, 210 | # I change the file argument to a number linked to an array of files 211 | # Then on finalization, we replace each require "xxx" by the proper file. 212 | def visit(node : Crystal::Require) 213 | file = node.string 214 | # we cover only files which are relative to current file 215 | if file[0] == '.' 216 | current_directory = Coverage::SourceFile.relative_path_to_project(File.dirname(@path)) 217 | 218 | files_to_load = File.expand_path(file, current_directory) 219 | 220 | if files_to_load =~ /\*$/ 221 | # Case when we want to require a directory and subdirectories 222 | if files_to_load.size > 1 && files_to_load[-2..-1] == "**" 223 | files_to_load += "/*.cr" 224 | else 225 | files_to_load += ".cr" 226 | end 227 | elsif files_to_load !~ /\.cr$/ 228 | files_to_load = files_to_load + ".cr" # << Add the extension for the crystal file. 229 | end 230 | 231 | idx = Coverage::SourceFile.require_expanders.size 232 | list_of_required_file = [] of Coverage::SourceFile 233 | Coverage::SourceFile.require_expanders << list_of_required_file 234 | 235 | Dir[files_to_load].sort.each do |file_load| 236 | next if file_load !~ /\.cr$/ 237 | 238 | Coverage::SourceFile.cover_file(file_load) do 239 | line_number = node.location.not_nil!.line_number 240 | 241 | required_file = Coverage::SourceFile.new(path: file_load, source: ::File.read(file_load), 242 | required_at: line_number) 243 | 244 | required_file.process # Process on load, since it can change the requirement order 245 | 246 | list_of_required_file << required_file 247 | end 248 | end 249 | 250 | node.string = "$#{idx}" 251 | end 252 | 253 | false 254 | end 255 | 256 | # Do not visit sub elements of inlined computations 257 | def visit(node : Crystal::OpAssign | Crystal::BinaryOp) 258 | true 259 | end 260 | 261 | def visit(node : Crystal::Arg) 262 | name = node.name 263 | if CRYSTAL_KEYWORDS.includes?(name) 264 | node.external_name = node.name = "_#{name}" 265 | end 266 | 267 | true 268 | end 269 | 270 | # Placeholder for bug #XXX 271 | def visit(node : Crystal::Assign) 272 | target = node.target 273 | value = node.value 274 | 275 | if target.is_a?(Crystal::InstanceVar) && 276 | value.is_a?(Crystal::Var) 277 | if CRYSTAL_KEYWORDS.includes?(value.name) 278 | value.name = "_#{value.name}" 279 | end 280 | end 281 | 282 | true 283 | end 284 | 285 | def visit(node : Macro) 286 | node.body.accept(self) 287 | false 288 | end 289 | 290 | def visit(node : Crystal::Expressions) 291 | node.expressions = node.expressions.map { |elm| inject_cover(elm) }.flatten 292 | true 293 | end 294 | 295 | def visit(node : Crystal::Block | Crystal::While) 296 | node.body = force_inject_cover(node.body) 297 | true 298 | end 299 | 300 | def visit(node : Crystal::MacroExpression) 301 | false 302 | end 303 | 304 | def visit(node : Crystal::MacroLiteral) 305 | false 306 | end 307 | 308 | def visit(node : Crystal::MacroIf) 309 | # Fix the non-location issue on macro. 310 | return false if node.location.nil? 311 | 312 | propagate_location_in_macro(node, node.location.not_nil!) 313 | 314 | node.then = force_inject_cover(node.then) 315 | node.else = force_inject_cover(node.else) 316 | true 317 | end 318 | 319 | def visit(node : Crystal::MacroFor) 320 | # Fix the non-location issue on macro. 321 | return false if node.location.nil? 322 | 323 | propagate_location_in_macro(node, node.location.not_nil!) 324 | 325 | node.body = force_inject_cover(node.body) 326 | false 327 | end 328 | 329 | def visit(node : Crystal::MacroVar) 330 | false 331 | end 332 | 333 | def visit(node : Crystal::Asm) 334 | false 335 | end 336 | 337 | def visit(node : Crystal::Def) 338 | unless node.macro_def? 339 | node.body = force_inject_cover(node.body) 340 | end 341 | true 342 | end 343 | 344 | def visit(node : Crystal::Select) 345 | node.whens = node.whens.map { |w| Crystal::Select::When.new(body: force_inject_cover(w.body), condition: w.condition) } 346 | true 347 | end 348 | 349 | def visit(node : Crystal::Case) 350 | node.whens = node.whens.map { |w| Crystal::When.new(w.conds, force_inject_cover(w.body)) } 351 | node.else = force_inject_cover(node.else.not_nil!) if node.else 352 | true 353 | end 354 | 355 | def visit(node : Crystal::If) 356 | unless node.ternary? 357 | node.then = force_inject_cover(node.then) 358 | node.else = force_inject_cover(node.else) 359 | end 360 | 361 | true 362 | end 363 | 364 | def visit(node : Crystal::Unless) 365 | node.then = force_inject_cover(node.then) 366 | node.else = force_inject_cover(node.else) 367 | true 368 | end 369 | 370 | # Ignore other nodes for now 371 | def visit(node : Crystal::ASTNode) 372 | # puts "#{node.class.name} => " + node.inspect 373 | true 374 | end 375 | end 376 | --------------------------------------------------------------------------------