diff --git a/doc/man/crystal-tool-macro_code_coverage.adoc b/doc/man/crystal-tool-macro_code_coverage.adoc new file mode 100644 index 000000000000..8aab8a240d3d --- /dev/null +++ b/doc/man/crystal-tool-macro_code_coverage.adoc @@ -0,0 +1,46 @@ += crystal-tool-macro_code_coverage(1) +:doctype: manpage +:date: {localdate} +:crystal_version: {crystal_version} +:man manual: Crystal Compiler Command Line Reference Guide +:man source: crystal {crystal_version} + +== Name +crystal-tool-macro_code_coverage - generate a macro code coverage report. + +== Synopsis +*crystal tool macro_code_coverage* [options] [programfile] + +== Description + +Generate and output a macro code coverage report to STDERR. +Any exception raised while computing the report is written to STDOUT. + +== Options + +*-D* _FLAG_, *--define*=_FLAG_:: + Define a compile-time flag. This is useful to con ditionally define types, methods, or commands based + on flags available at compile time. The default + flags are from the target triple given with *--tar* get-triple or the hosts default, if none is given. +*-f* _FORMAT_, *--format*=_FORMAT_:: + Output format 'codecov' (default). +*-i* _PATH_, *--include*=_PATH_:: + Include path in output. +*-e* _PATH_, *--exclude*=_PATH_:: + Exclude path in output (default: lib). +*--error-trace*:: + Show full error trace. +*--prelude*:: + Specify prelude to use. The default one initializes + the garbage collector. You can also use *--pre* lude=empty to use no preludes. This can be useful + for checking code generation for a specific source + code file. +*-s*, *--stats*:: +Print statistics about the different compiler stages for the current build. Output time and used memory for each compiler +process. +*-p*, *--progress*:: +Print statistics about the progress for the current build. +*-t*, *--time*:: +Print statistics about the execution time. +*--no-color*:: +Disable colored output. diff --git a/doc/man/crystal.adoc b/doc/man/crystal.adoc index 275b14c4b542..7465f4851ff5 100644 --- a/doc/man/crystal.adoc +++ b/doc/man/crystal.adoc @@ -104,6 +104,8 @@ regex by using the *-e* flag. Show implementations for a given call. Use *--cursor* to specify the cursor position. The format for the cursor position is file:line:column. +*crystal-tool-macro_code_coverage*:: Generate a macro code coverage report. + *crystal-tool-types*:: Show type of main variables of file. *crystal-tool-unreachable(1)*:: Identify methods that are never called diff --git a/etc/completion.bash b/etc/completion.bash index 9fab5e98b78d..1ba62b66cefa 100644 --- a/etc/completion.bash +++ b/etc/completion.bash @@ -66,7 +66,7 @@ _crystal() _crystal_compgen_options "${opts}" "${cur}" else if [[ "${prev}" == "tool" ]] ; then - local subcommands="context dependencies expand flags format hierarchy implementations types unreachable" + local subcommands="context dependencies expand flags format hierarchy implementations macro_code_coverage types unreachable" _crystal_compgen_options "${subcommands}" "${cur}" else _crystal_compgen_sources "${cur}" diff --git a/etc/completion.fish b/etc/completion.fish index bf7fbaee81a7..34185b6387e2 100644 --- a/etc/completion.fish +++ b/etc/completion.fish @@ -223,6 +223,19 @@ complete -c crystal -n "__fish_seen_subcommand_from unreachable" -s p -l progres complete -c crystal -n "__fish_seen_subcommand_from unreachable" -s t -l time -d "Enable execution time output" complete -c crystal -n "__fish_seen_subcommand_from unreachable" -l stdin-filename -d "Source file name to be read from STDIN" +complete -c crystal -n "__fish_seen_subcommand_from tool; and not __fish_seen_subcommand_from $tool_subcommands" -a "macro_code_coverage" -d "generate a macro code coverage report" -x +complete -c crystal -n "__fish_seen_subcommand_from macro_code_coverage" -s D -l define -d "Define a compile-time flag" +complete -c crystal -n "__fish_seen_subcommand_from macro_code_coverage" -s f -l format -d "Output format codecov (default)" -a "codecov" -f +complete -c crystal -n "__fish_seen_subcommand_from macro_code_coverage" -l error-trace -d "Show full error trace" +complete -c crystal -n "__fish_seen_subcommand_from macro_code_coverage" -s i -l include -d "Include path" +complete -c crystal -n "__fish_seen_subcommand_from macro_code_coverage" -s e -l exclude -d "Exclude path (default: lib)" +complete -c crystal -n "__fish_seen_subcommand_from macro_code_coverage" -l no-color -d "Disable colored output" +complete -c crystal -n "__fish_seen_subcommand_from macro_code_coverage" -l prelude -d "Use given file as prelude" +complete -c crystal -n "__fish_seen_subcommand_from macro_code_coverage" -s s -l stats -d "Enable statistics output" +complete -c crystal -n "__fish_seen_subcommand_from macro_code_coverage" -s p -l progress -d "Enable progress output" +complete -c crystal -n "__fish_seen_subcommand_from macro_code_coverage" -s t -l time -d "Enable execution time output" +complete -c crystal -n "__fish_seen_subcommand_from macro_code_coverage" -l stdin-filename -d "Source file name to be read from STDIN" + complete -c crystal -n "__fish_seen_subcommand_from tool; and not __fish_seen_subcommand_from $tool_subcommands" -a "types" -d "show type of main variables" -x complete -c crystal -n "__fish_seen_subcommand_from types" -s D -l define -d "Define a compile-time flag" complete -c crystal -n "__fish_seen_subcommand_from types" -s f -l format -d "Output format text (default) or json" -a "text json" -f diff --git a/etc/completion.zsh b/etc/completion.zsh index 920eddd3f396..0106ed013ad8 100644 --- a/etc/completion.zsh +++ b/etc/completion.zsh @@ -175,6 +175,7 @@ _crystal-tool() { "format:format project, directories and/or files" "hierarchy:show type hierarchy" "implementations:show implementations for given call in location" + "macro_code_coverage:generate a macro code coverage report" "types:show type of main variables" "unreachable:show methods that are never called" ) @@ -277,6 +278,18 @@ _crystal-tool() { $stdin_filename_args ;; + (macro_code_coverage) + _arguments \ + $programfile \ + $help_args \ + $no_color_args \ + $exec_args \ + $include_exclude_args \ + '(-f --format)'{-f,--format}'[output format: codecov (default)]:' \ + $prelude_args \ + $stdin_filename_args + ;; + (types) _arguments \ $programfile \ diff --git a/spec/compiler/crystal/tools/macro_code_coverage_spec.cr b/spec/compiler/crystal/tools/macro_code_coverage_spec.cr new file mode 100644 index 000000000000..2a36d0a14b84 --- /dev/null +++ b/spec/compiler/crystal/tools/macro_code_coverage_spec.cr @@ -0,0 +1,945 @@ +require "../../../spec_helper" +include Crystal + +private def assert_coverage(code, expected_coverage, *, expected_error : String? = nil, focus : Bool = false, spec_file = __FILE__, spec_line = __LINE__) + it focus: focus, file: spec_file, line: spec_line do + processor = MacroCoverageProcessor.new + + compiler = Compiler.new + compiler.prelude = "empty" + compiler.no_codegen = true + compiler.compile_configure_program(Compiler::Source.new(".", code), "fake-no-build") do |program| + processor.configure program + end + + processor.excludes << Path[Dir.current].to_posix.to_s + processor.includes << "." + + hits = processor.compute_coverage + + unless hits = hits["."]? + fail "Failed to generate coverage", file: spec_file, line: spec_line + end + + coverage_exception = processor.coverage_interrupt_exception + + if expected_error + err = coverage_exception.should_not be_nil, file: spec_file, line: spec_line + err.inspect_with_backtrace.should contain(expected_error), file: spec_file, line: spec_line + else + coverage_exception.should be_nil, file: spec_file, line: spec_line + end + + hits.should eq(expected_coverage), file: spec_file, line: spec_line + end +end + +describe "macro_code_coverage" do + assert_coverage <<-'CR', {1 => 1} + {{ "foo" }} + CR + + assert_coverage <<-'CR', {2 => 1, 3 => 1}, expected_error: "undefined macro method 'NumberLiteral#sdfds'" + {% + a = 1 + b = 2.sdfds + c = 3 + %} + CR + + assert_coverage <<-'CR', {6 => 1, 7 => 1, 9 => "2/2", 11 => 1, 12 => 1}, expected_error: "Class 'Foo' is missing its name." + annotation Name; end + + @[Name] + class Foo + def self.default_name + {% begin %} + {% if ann = @type.annotation Name %} + {% + name = (ann[0] || ann[:name]) + + unless name + ann.raise "Class '#{@type}' is missing its name." + end + %} + {% end %} + {% end %} + end + end + + Foo.default_name + CR + + assert_coverage <<-'CR', {1 => "1/2"}, expected_error: "oh noes im an error" + {{ true ? raise("oh noes im an error") : 0 }} + CR + + assert_coverage <<-'CR', {1 => "1/2"} + {{ true ? 1 : 0 }} + CR + + assert_coverage <<-'CR', {1 => 1, 2 => "1/2"} + {% begin %} + {{true ? 1 : 0}} + {{2}} + {% end %} + CR + + assert_coverage <<-'CR', {1 => "1/3"} + {{ true ? 1 : x == 2 ? 2 : 3 }} + CR + + assert_coverage <<-'CR', {2 => "2/3"} + macro test(x) + {{ x == 1 ? 1 : x == 2 ? 2 : 3 }} + end + + test(1) + test(2) + CR + + assert_coverage <<-'CR', {2 => "3/3"} + macro test(x) + {{ x == 1 ? 1 : x == 2 ? 2 : 3 }} + end + + test(1) + test(2) + test(3) + CR + + assert_coverage <<-'CR', {2 => "3/3"} + macro test(x) + {{ x == 1 ? 1 : x == 2 ? 2 : 3 }} + end + + test(1) + test(2) + test(3) + test(4) + CR + + # 1/2 since the raise would prevent the 2nd execution + assert_coverage <<-'CR', {2 => "1/2"}, expected_error: "oh noes im an error" + macro test(x) + {{ 1 == x ? raise("oh noes im an error") : 0 }} + end + + test(1) + test(2) + CR + + assert_coverage <<-'CR', {2 => "2/2"}, expected_error: "oh noes im an error" + macro test(x) + {{ 1 == x ? raise("oh noes im an error") : 0 }} + end + + test(2) + test(1) + CR + + assert_coverage <<-'CR', {1 => "1/2"} + {% tags = (tags = (1 + 1)) ? tags : nil %} + CR + + assert_coverage <<-'CR', {1 => 1, 2 => "1/2", 3 => 3} + {% for type in [1, 2, 3] %} + {% tags = (tags = type) ? tags : nil %} + {% tags %} + {% end %} + CR + + assert_coverage <<-'CR', {1 => "1/2"} + {% if true %}1{% else %}0{% end %} + CR + + assert_coverage <<-'CR', {1 => "1/3"} + {% if false %}1{% elsif false %}2{% else %}3{% end %} + CR + + assert_coverage <<-'CR', {1 => "1/6"} + {% if false %}{% if false %}1{% else %}2{% end %}{% elsif false %}3{% else %}{% if false %}4{% elsif false %}5{% else %}6{% end %}{% end %} + CR + + assert_coverage <<-'CR', {1 => "1/6"} + {% if false; if false; 1; else 2; end; elsif false; 3; else; if false; 4; elsif false; 5; else 6; end; end %} + CR + + assert_coverage <<-'CR', {1 => "1/5"} + {% unless false; if false; 1; else 2; end; else; if false; 4; elsif false; 5; else 6; end; end %} + CR + + assert_coverage <<-'CR', {1 => 1}, expected_error: "oh noes im an error" + {% raise "oh noes im an error" %} + {{ 2 }} + CR + + assert_coverage <<-'CR', {1 => 1, 2 => "1/2", 3 => 1} + {% 1 %} + {% 2 if false %} + {% 3 %} + CR + + assert_coverage <<-'CR', {1 => 1, 2 => "1/2", 3 => 1} + {% 1 %} + {% 2 if true %} + {% 3 %} + CR + + assert_coverage <<-'CR', {1 => 1, 2 => "1/2", 3 => 1} + {% 1 %} + {% 2 unless true %} + {% 3 %} + CR + + assert_coverage <<-'CR', {1 => 1, 2 => "1/2", 3 => 1} + {% 1 %} + {% 2 unless false %} + {% 3 %} + CR + + assert_coverage <<-'CR', {2 => "1/2"} + macro test(v) + {{3 if v == 1}} + end + + test 0 + test 2 + CR + + assert_coverage <<-'CR', {2 => "2/2"} + macro test(v) + {{3 if v == 1}} + end + + test 2 + test 1 + CR + + assert_coverage <<-'CR', {2 => "2/2"} + macro test(v) + {{3 if v == 1}} + end + + test 1 + test 2 + test 1 + CR + + assert_coverage <<-'CR', {2 => 1, 3 => "1/2"}, expected_error: "oh noes im an error" + {% + if true + raise "oh noes im an error" if Int32 <= Number + end + %} + CR + + assert_coverage <<-'CR', {4 => 1, 5 => "1/2"} + macro finished + {% verbatim do %} + {% + if true + a = 1 if true + end + %} + {% end %} + end + CR + + assert_coverage <<-'CR', {4 => 1, 5 => 1, 7 => 0}, expected_error: "oh noes im an error" + macro finished + {% verbatim do %} + {% + if true + raise "oh noes im an error" + else + 123 + end + %} + {% end %} + end + CR + + assert_coverage <<-'CR', {1 => 1, 2 => 0, 4 => 1} + {% unless true %} + {{0}} + {% else %} + {{1}} + {% end %} + CR + + assert_coverage <<-'CR', {1 => 1, 2 => 1, 4 => 0} + {% unless false %} + {{0}} + {% else %} + {{1}} + {% end %} + CR + + assert_coverage <<-'CR', {2 => 1, 4 => 1} + {% + a, b, c = {1, 2, 3} + + a + b + c + %} + CR + + assert_coverage <<-'CR', {2 => 2, 6 => 1, 10 => 1} + macro test(&) + {{yield}} + end + + test do + {{2 + 1}} + end + + test do + {{9 + 12}} + end + CR + + assert_coverage <<-'CR', {2 => 2, 3 => "1/2", 4 => 2, 8 => 0, 13 => 0} + macro test(&) + {{ 1 + 1 }} + {{yield if false}} + {{ 2 + 2 }} + end + + test do + {{2 + 1}} + {{1 + 2}} + end + + test do + {{4 + 5}} + {{5 + 4}} + end + CR + + assert_coverage <<-'CR', {1 => 1, 2 => 1, 3 => 3, 4 => 1, 5 => 2, 6 => 0, 8 => 2} + {% begin %} + {% for v in {1, 2, 3} %} + {% if v == 2 %} + {{v * 2}} + {% elsif v > 5 %} + {{v * 5}} + {% else %} + {{v}} + {% end %} + {% end %} + {% end %} + CR + + assert_coverage <<-'CR', {1 => 1, 2 => 1, 4 => 2, 5 => 2, 7 => 2} + {% begin %} + {% for v in [1, 2] %} + {% + 0 + (10 * 10) + 20 * 20 + %} + {% 30 * 30 %} + {% end %} + {% end %} + CR + + assert_coverage <<-'CR', {1 => 1, 2 => 1, 4 => "1/2", 5 => 2, 7 => 2} + {% begin %} + {% for v in [1, 2] %} + {% + 0 + (10 * 10) if false + 20 * 20 + %} + {% 30 * 30 %} + {% end %} + {% end %} + CR + + assert_coverage <<-'CR', {4 => 1, 5 => 1} + macro finished + {% verbatim do %} + {% + 10 * 10 + 10 * 20 + %} + {% end %} + end + CR + + assert_coverage <<-'CR', {1 => 1, 3 => 0} + {% if false %} + # foo + {% 1 + 1 %} + {% end %} + CR + + assert_coverage <<-'CR', {1 => 1, 2 => 1, 3 => 2, 4 => 1, 6 => 1} + {% begin %} + {% for vals in [[] of Int32, [1]] %} + {% if vals.empty? %} + {{1 + 1}} + {% else %} + {{2 + 2}} + {% end %} + {% end %} + {% end %} + CR + + assert_coverage <<-'CR', {3 => 1, 6 => 1, 7 => 1} + macro finished + {% verbatim do %} + {% 10 * 10 %} + + {% + 20 * 20 + 30 * 30 + %} + {% end %} + end + CR + + assert_coverage <<-'CR', {4 => 1, 7 => 1} + macro finished + {% verbatim do %} + {% + 10 * 10 + + # Foo + 10 * 20 + %} + {% end %} + end + CR + + assert_coverage <<-'CR', {4 => 1, 8 => 1, 10 => 1} + macro finished + {% verbatim do %} + {% + 10 * 10 + + # Foo + + 10 * 20 + + 10 * 10 + %} + {% end %} + end + CR + + assert_coverage <<-'CR', {4 => 1, 8 => 1, 9 => 1, 13 => 1, 16 => 1, 17 => 1, 22 => 1} + macro finished + {% verbatim do %} + {% + 10 + + # Foo + + 20 + 30 + + # Bar + + 40 + %} + {% + 50 + 60 + %} + + + {% + 70 + %} + {% end %} + end + CR + + assert_coverage <<-'CR', {4 => 1, 5 => 3, 6 => 1, 7 => 2, 8 => 1, 10 => 1, 13 => 3} + macro finished + {% verbatim do %} + {% + [0, 1, 2].each do |val| + str = if val >= 2 + "greater or equal to 2" + elsif val == 1 + "equals 1" + else + "other" + end + + "Got: " + str + end + %} + {% end %} + end + CR + + assert_coverage <<-'CR', {4 => 1, 6 => 1, 7 => 1, 8 => 1, 9 => 1} + macro finished + {% verbatim do %} + {% + data = {__nil: nil} + + data["foo"] = { + id: 1, active: true, + name: "foo".upcase, + pie: 3.14, + } + %} + {% end %} + end + CR + + assert_coverage <<-'CR', {4 => 1, 5 => 1, 7 => 1, 8 => 1, 9 => 1, 10 => 1, 11 => 1, 12 => 1} + macro finished + {% verbatim do %} + {% + data = {__nil: nil} + num = 4 + + data["foo"] = { + var: num, + hash_literal: {} of Nil => Nil, + named_tuple_literal: {id: 10}, + array_literal: [] of Nil, + tuple_literal: {1, 2, 3}, + } + %} + {% end %} + end + CR + + assert_coverage <<-'CR', {3 => 1} + macro finished + {% verbatim do %} + {% [1, 2, 3].find(&.+.==(2)) %} + {% end %} + end + CR + + assert_coverage <<-'CR', {3 => 1, 4 => 0} + macro finished + {% verbatim do %} + {% if false %} + {% raise "Oh noes" %} + {% end %} + {% end %} + end + CR + + assert_coverage <<-'CR', {4 => 1, 7 => 1} + macro finished + {% verbatim do %} + {% + if true + # Some comment + # Another comment + 10 + end + %} + {% end %} + end + CR + + assert_coverage <<-'CR', {4 => 1, 7 => 1} + macro finished + {% verbatim do %} + {% + unless false + # Some comment + # Another comment + 10 + end + %} + {% end %} + end + CR + + assert_coverage <<-'CR', {2 => 1, 5 => 1} + {% + if true + # Some comment + # Another comment + 10 + end + %} + CR + + assert_coverage <<-'CR', {2 => 1, 5 => 1} + {% + unless false + # Some comment + # Another comment + 10 + end + %} + CR + + assert_coverage <<-'CR', {4 => "1/2"} + macro finished + {% verbatim do %} + {% + pp 1 if false + %} + {% end %} + end + CR + + assert_coverage <<-'CR', {2 => 1, 4 => 0} + macro test(v) + {% if v > 1 %} + {% + pp v.stringify + %} + {% end %} + end + + test 1 + CR + + assert_coverage <<-'CR', {2 => 1, 4 => 0} + macro test(v) + {% if v > 1 %} + {% + val = v.stringify + + pp val + %} + {% end %} + end + + test 1 + CR + + assert_coverage <<-'CR', {2 => 1, 4 => 1, 6 => 1, 9 => 1, 10 => 1, 13 => 0} + macro test(v) + {% if v > 1 %} + {% + val = v.stringify + + val = "foo" + %} + + {% if v == 2 %} + {{v}} + {% else %} + {% + pp v * 2 + %} + {% end %} + {% end %} + end + + test 2 + CR + + assert_coverage <<-'CR', {1 => 1, 2 => 0} + {% for val in [] of Nil %} + {% pp 1 %} + {% end %} + CR + + assert_coverage <<-'CR', {1 => 1, 2 => 0} + {% for val in {} of Nil => Nil %} + {% pp 1 %} + {% end %} + CR + + assert_coverage <<-'CR', {1 => 1, 2 => 0} + {% for val in (0...0) %} + {% pp 1 %} + {% end %} + CR + + assert_coverage <<-'CR', {2 => 1, 3 => 0} + {% + ([] of Nil).each do |v| + pp v + pp 123 + end + %} + CR + + assert_coverage <<-'CR', {2 => 1, 3 => 0} + {% + ([] of Nil).each do |(a, b, c)| + pp v + pp 123 + end + %} + CR + + assert_coverage <<-'CR', {2 => 1, 3 => 0} + {% + ({} of Nil => Nil).each do |v| + pp v + pp 123 + end + %} + CR + + assert_coverage <<-'CR', {2 => 1, 3 => 0} + {% + (0...0).each do |v| + pp v + pp 123 + end + %} + CR + + assert_coverage <<-'CR', {2 => 1, 3 => 0} + {% + ([] of Nil).map do |v| + pp v + pp 123 + end + %} + CR + + assert_coverage <<-'CR', {2 => 1, 3 => 0} + {% + ([] of Nil).find do |v| + v > 1 + end + %} + CR + + assert_coverage <<-'CR', {2 => "1/3"} + {% + v = false || true || false + %} + CR + + assert_coverage <<-'CR', {2 => "2/2"} + {% + true && false + %} + CR + + assert_coverage <<-'CR', {2 => "1/3"} + {% + v = 1 || 2 || raise "Oh noes" + %} + CR + + assert_coverage <<-'CR', {2 => "1/3"} + macro test(one, two, three) + {{one || two || three}} + end + + test true, false, false + CR + + assert_coverage <<-'CR', {2 => "2/3"} + macro test(one, two, three) + {{one || two || three}} + end + + test true, false, false + test false, true, false + CR + + assert_coverage <<-'CR', {2 => "3/3"} + macro test(one, two, three) + {{one || two || three}} + end + + test true, false, false + test false, true, false + test false, false, true + CR + + assert_coverage <<-'CR', {2 => "3/3"} + {% + v = 1 && 2 && 3 + %} + CR + + assert_coverage <<-'CR', {2 => "3/3"}, expected_error: "oh noes im an error" + {% + v = 1 && 2 && raise "oh noes im an error" + %} + CR + + assert_coverage <<-'CR', {4 => 1, 6 => 1, 10 => 1} + macro finished + {% verbatim do %} + {% + ({"a" => "b"} of Nil => Nil).each do |k, v| + # stuff and things + k + v + + # foo bar + + k + v + end + %} + {% end %} + end + CR + + assert_coverage <<-'CR', {4 => 1, 6 => 1, 7 => 0, 11 => 1, 12 => 1, 15 => 1} + macro finished + {% verbatim do %} + {% + a = nil + + if false + a = 1 + + # Scalar value + else + a = 4 + b = 5 + end + + a + %} + {% end %} + end + CR + + assert_coverage <<-'CR', {5 => 1, 6 => 1, 7 => 1, 8 => 1} + macro finished + {% verbatim do %} + {% + { + type: String, + services: [4, 1, 12] + .sort_by { |v| v } + .map { |v| v }, + } + %} + {% end %} + end + CR + + assert_coverage <<-'CR', {4 => 1, 5 => 1, 6 => 1} + macro finished + {% verbatim do %} + {% + val = "foo" + .strip + .strip.strip + %} + {% end %} + end + CR + + assert_coverage <<-'CR', {4 => 1, 6 => 1, 7 => 0, 9 => 1} + macro finished + {% verbatim do %} + {% + if true && + ( + true || + false + ) + 1 + end + %} + {% end %} + end + CR + + assert_coverage <<-'CR', {4 => 1, 5 => 1, 6 => 0, 8 => 1, 9 => 1} + macro finished + {% verbatim do %} + {% + if ( + true || + false + ) && + true + 1 + end + %} + {% end %} + end + CR + + assert_coverage <<-'CR', {4 => 1, 5 => 1, 6 => 1, 8 => 0, 9 => 0} + macro finished + {% verbatim do %} + {% + if ( + false || + false + ) && + true + 1 + end + %} + {% end %} + end + CR + + assert_coverage <<-'CR', {4 => "1/2", 5 => 1, 6 => 1} + macro finished + {% verbatim do %} + {% + if (true || false) && + true + 1 + end + %} + {% end %} + end + CR + + assert_coverage <<-'CR', {4 => "2/2", 5 => 0, 6 => 0} + macro finished + {% verbatim do %} + {% + if (true && false) && + true + 1 + end + %} + {% end %} + end + CR + + assert_coverage <<-'CR', {4 => 1, 6 => 1, 7 => 0, 9 => 1} + macro finished + {% verbatim do %} + {% + if (type = Number) && + ( + Int32 <= type || + false + ) + 1 + end + %} + {% end %} + end + CR + + assert_coverage <<-'CR', {4 => 1, 6 => 1, 7 => 0, 9 => 1} + macro finished + {% verbatim do %} + {% + if (type = Number) && + ( + Int32 <= type || + (type < Int && Int8 <= Number) + ) + 1 + end + %} + {% end %} + end + CR + + assert_coverage <<-'CR', {2 => 1, 4 => 0, 7 => 0} + {% + if (type = nil) && + ( + Int32 <= type || + (type < Int && Int8 <= Number) + ) + id + end + %} + CR +end diff --git a/spec/compiler/semantic/restrictions_augmenter_spec.cr b/spec/compiler/semantic/restrictions_augmenter_spec.cr index d77b61327f82..c577bb2d3d04 100644 --- a/spec/compiler/semantic/restrictions_augmenter_spec.cr +++ b/spec/compiler/semantic/restrictions_augmenter_spec.cr @@ -1,8 +1,8 @@ require "../../spec_helper" -private def expect_augment(before : String, after : String) +private def expect_augment(before : String, after : String, *, file : String = __FILE__, line : Int32 = __LINE__) result = semantic(before) - result.node.to_s.chomp.should eq(after.chomp) + result.node.to_s.chomp.should eq(after.chomp), file: file, line: line end private def expect_no_augment(code : String, flags = nil) @@ -30,7 +30,7 @@ private def it_augments_for_ivar(ivar_type : String, expected_type : String, fil end CRYSTAL - expect_augment before, after + expect_augment before, after, file: file, line: line end end diff --git a/src/compiler/crystal/command.cr b/src/compiler/crystal/command.cr index ab0de5b5b2a5..a779da8168f1 100644 --- a/src/compiler/crystal/command.cr +++ b/src/compiler/crystal/command.cr @@ -46,6 +46,7 @@ class Crystal::Command format format project, directories and/or files hierarchy show type hierarchy implementations show implementations for given call in location + macro_code_coverage generate a macro code coverage report types show type of main variables unreachable show methods that are never called --help, -h show this help @@ -209,6 +210,9 @@ class Crystal::Command when "unreachable".starts_with?(tool) options.shift unreachable + when "macro_code_coverage".starts_with?(tool) + options.shift + macro_code_coverage when "--help" == tool, "-h" == tool puts COMMANDS_USAGE exit @@ -340,6 +344,13 @@ class Crystal::Command compiler.compile sources, output_filename end + def compile_configure_program(output_filename = self.output_filename, &) + compiler.emit_base_filename = emit_base_filename || output_filename.rchop(File.extname(output_filename)) + compiler.compile_configure_program sources, output_filename do |program| + yield program + end + end + def top_level_semantic compiler.top_level_semantic sources end diff --git a/src/compiler/crystal/compiler.cr b/src/compiler/crystal/compiler.cr index 0d275fe796a0..8022cbaf63e9 100644 --- a/src/compiler/crystal/compiler.cr +++ b/src/compiler/crystal/compiler.cr @@ -213,10 +213,24 @@ module Crystal # Raises `InvalidByteSequenceError` if the source code is not # valid UTF-8. def compile(source : Source | Array(Source), output_filename : String) : Result + compile_configure_program(source, output_filename) { } + end + + # :ditto: + # + # Yields a `Program` instance before compiling. + def compile_configure_program(source : Source | Array(Source), output_filename : String, & : Program -> Nil) : Result source = [source] unless source.is_a?(Array) program = new_program(source) + yield program node = parse program, source - node = program.semantic node, cleanup: !no_cleanup? + + begin + node = program.semantic node, cleanup: !no_cleanup? + rescue ex : SkipMacroCodeCoverageException + program.macro_expansion_error_hook.try &.call(ex.cause) + end + units = codegen program, node, source, output_filename unless @no_codegen @progress_tracker.clear diff --git a/src/compiler/crystal/macros/interpreter.cr b/src/compiler/crystal/macros/interpreter.cr index f0444e2f4864..c5a7fe713ade 100644 --- a/src/compiler/crystal/macros/interpreter.cr +++ b/src/compiler/crystal/macros/interpreter.cr @@ -86,6 +86,27 @@ module Crystal @vars[name] = value end + # Calls the program's `interpreted_node_hook` hook with the macro ASTNode that was interpreted. + def interpreted_hook(node : ASTNode, *, location custom_location : Location? = nil) : ASTNode + @program.interpreted_node_hook.try &.call(node, false, false, custom_location) + + node + end + + # Calls the program's `interpreted_node_hook` hook with the macro ASTNode that was _not_ interpreted. + def not_interpreted_hook(node : ASTNode, use_significant_node : Bool = false, *, location custom_location : Location? = nil) : ASTNode + return node unless interpreted_hook = @program.interpreted_node_hook + + interpreted_hook.call node, true, use_significant_node, custom_location + + # If a Yield was missed, also mark the code that would have ran as missed. + if node.is_a?(Yield) && (block = @block) + interpreted_hook.call block.body, true, false, nil + end + + node + end + def accept(node) node.accept self @last @@ -109,7 +130,7 @@ module Crystal @str << " end" if is_yield (macro_expansion_pragmas[@str.pos.to_i32] ||= [] of Lexer::LocPragma) << Lexer::LocPopPragma.new else - @last.to_s(@str) + @last.to_s(@str, emit_location_pragmas: !!@program.interpreted_node_hook) end end @@ -125,15 +146,17 @@ module Crystal exp = node.exp if exp.is_a?(Expressions) exp.expressions.each do |subexp| - subexp.to_s(@str) + subexp.to_s(@str, emit_location_pragmas: !!@program.interpreted_node_hook) end else - exp.to_s(@str) + exp.to_s(@str, emit_location_pragmas: !!@program.interpreted_node_hook) end false end def visit(node : Var) + self.interpreted_hook node + var = @vars[node.name]? if var @last = var @@ -174,15 +197,26 @@ module Crystal end def visit(node : MacroIf) + self.interpreted_hook node + node.cond.accept self - body = @last.truthy? ? node.then : node.else + body = if @last.truthy? + self.not_interpreted_hook node.else, use_significant_node: true + node.then + else + self.not_interpreted_hook node.then, use_significant_node: true + node.else + end + body.accept self false end def visit(node : MacroFor) + self.interpreted_hook node.exp + node.exp.accept self exp = @last @@ -205,6 +239,10 @@ module Crystal element_var = node.vars[0] index_var = node.vars[1]? + if range.empty? + self.not_interpreted_hook node.body, use_significant_node: true + end + range.each_with_index do |element, index| @vars[element_var.name] = NumberLiteral.new(element) if index_var @@ -245,6 +283,10 @@ module Crystal element_var = node.vars[0] index_var = node.vars[1]? + if entries.empty? + self.not_interpreted_hook node.body, use_significant_node: true + end + entries.each_with_index do |element, index| @vars[element_var.name] = yield element if index_var @@ -262,6 +304,10 @@ module Crystal value_var = node.vars[1]? index_var = node.vars[2]? + if entries.empty? + self.not_interpreted_hook node.body, use_significant_node: true + end + entries.each_with_index do |entry, i| key, value = yield entry, value_var @@ -278,6 +324,8 @@ module Crystal end def visit(node : MacroVar) + self.interpreted_hook node + if exps = node.exps exps = exps.map { |exp| accept exp } else @@ -293,6 +341,8 @@ module Crystal end def visit(node : Assign) + self.interpreted_hook node + case target = node.target when Var node.value.accept self @@ -317,18 +367,30 @@ module Crystal end def visit(node : And) + self.interpreted_hook node + node.left.accept self + if @last.truthy? node.right.accept self + else + self.not_interpreted_hook node.right, use_significant_node: true end + false end def visit(node : Or) + self.interpreted_hook node + node.left.accept self - unless @last.truthy? + + if !@last.truthy? node.right.accept self + else + self.not_interpreted_hook node.right, use_significant_node: true end + false end @@ -339,14 +401,34 @@ module Crystal end def visit(node : If) + self.interpreted_hook node + node.cond.accept self - (@last.truthy? ? node.then : node.else).accept self + + a_then, a_else = node.then, node.else + unless @last.truthy? + a_then, a_else = a_else, a_then + end + + self.not_interpreted_hook a_else + a_then.accept self + false end def visit(node : Unless) + self.interpreted_hook node + node.cond.accept self - (@last.truthy? ? node.else : node.then).accept self + + a_then, a_else = node.then, node.else + if @last.truthy? + a_then, a_else = a_else, a_then + end + + self.not_interpreted_hook a_else + a_then.accept self + false end @@ -360,6 +442,8 @@ module Crystal receiver = @last end + self.interpreted_hook obj, location: node.name_location + args = node.args.map { |arg| accept arg } named_args = node.named_args.try &.to_h { |arg| {arg.name, accept arg.value} } @@ -377,6 +461,8 @@ module Crystal node.raise ex.message end else + self.interpreted_hook node + # no receiver: special calls # may raise `Crystal::TopLevelMacroRaiseException` interpret_top_level_call node @@ -386,6 +472,8 @@ module Crystal end def visit(node : Yield) + self.interpreted_hook node + unless @in_macro node.raise "can't use `{{yield}}` outside a macro" end @@ -409,6 +497,8 @@ module Crystal end def visit(node : Path) + self.interpreted_hook node + @last = resolve(node) false end @@ -622,16 +712,22 @@ module Crystal end def visit(node : TupleLiteral) + self.interpreted_hook node + @last = TupleLiteral.map(node.elements) { |element| accept element } false end def visit(node : ArrayLiteral) + self.interpreted_hook node + @last = ArrayLiteral.map(node.elements) { |element| accept element } false end def visit(node : HashLiteral) + self.interpreted_hook node + @last = HashLiteral.new(node.entries.map do |entry| HashLiteral::Entry.new(accept(entry.key), accept(entry.value)) @@ -648,6 +744,8 @@ module Crystal end def visit(node : Nop | NilLiteral | BoolLiteral | NumberLiteral | CharLiteral | StringLiteral | SymbolLiteral | RangeLiteral | RegexLiteral | MacroId | TypeNode | Def) + self.interpreted_hook node + @last = node.clone_without_location false end diff --git a/src/compiler/crystal/macros/macros.cr b/src/compiler/crystal/macros/macros.cr index a5d5714f115b..c3dd69b55040 100644 --- a/src/compiler/crystal/macros/macros.cr +++ b/src/compiler/crystal/macros/macros.cr @@ -15,12 +15,23 @@ class Crystal::Program record CompiledMacroRun, filename : String, elapsed : Time::Span, reused : Bool property compiled_macros_cache = {} of String => CompiledMacroRun + property interpreted_node_hook : Proc(ASTNode, Bool, Bool, Location?, Nil)? = nil + property macro_expanded_hook : Proc(Nil)? = nil + property macro_expansion_error_hook : Proc(::Exception?, Nil)? = nil + def expand_macro(a_macro : Macro, call : Call, scope : Type, path_lookup : Type? = nil, a_def : Def? = nil) check_call_to_deprecated_macro a_macro, call interpreter = MacroInterpreter.new self, scope, path_lookup || scope, a_macro, call, a_def, in_macro: true a_macro.body.accept interpreter {interpreter.to_s, interpreter.macro_expansion_pragmas} + rescue ex + raise ex if @program.macro_expansion_error_hook.nil? + + # See SkipMacroCodeCoverageException's definition for more information. + raise SkipMacroCodeCoverageException.new ex + ensure + @program.macro_expanded_hook.try &.call end def expand_macro(node : ASTNode, scope : Type, path_lookup : Type? = nil, free_vars = nil, a_def : Def? = nil) @@ -28,6 +39,13 @@ class Crystal::Program interpreter.free_vars = free_vars node.accept interpreter {interpreter.to_s, interpreter.macro_expansion_pragmas} + rescue ex + raise ex if @program.macro_expansion_error_hook.nil? + + # See SkipMacroCodeCoverageException's definition for more information. + raise SkipMacroCodeCoverageException.new ex + ensure + @program.macro_expanded_hook.try &.call end def parse_macro_source(generated_source, macro_expansion_pragmas, the_macro, node, vars, current_def = nil, inside_type = false, inside_exp = false, mode : Parser::ParseMode = :normal, visibility : Visibility = :public) diff --git a/src/compiler/crystal/macros/methods.cr b/src/compiler/crystal/macros/methods.cr index 5d617a62f73a..49fca41fc9fa 100644 --- a/src/compiler/crystal/macros/methods.cr +++ b/src/compiler/crystal/macros/methods.cr @@ -958,6 +958,10 @@ module Crystal block_arg_key = block.args[0]? block_arg_value = block.args[1]? + if entries.empty? + interpreter.not_interpreted_hook block.body, use_significant_node: true + end + entries.each do |entry| interpreter.define_var(block_arg_key.name, entry.key) if block_arg_key interpreter.define_var(block_arg_value.name, entry.value) if block_arg_value @@ -971,6 +975,10 @@ module Crystal block_arg_key = block.args[0]? block_arg_value = block.args[1]? + if entries.empty? + interpreter.not_interpreted_hook block.body, use_significant_node: true + end + ArrayLiteral.map(entries) do |entry| interpreter.define_var(block_arg_key.name, entry.key) if block_arg_key interpreter.define_var(block_arg_value.name, entry.value) if block_arg_value @@ -1056,6 +1064,10 @@ module Crystal block_arg_key = block.args[0]? block_arg_value = block.args[1]? + if entries.empty? + interpreter.not_interpreted_hook block.body, use_significant_node: true + end + entries.each do |entry| interpreter.define_var(block_arg_key.name, MacroId.new(entry.key)) if block_arg_key interpreter.define_var(block_arg_value.name, entry.value) if block_arg_value @@ -1069,6 +1081,10 @@ module Crystal block_arg_key = block.args[0]? block_arg_value = block.args[1]? + if entries.empty? + interpreter.not_interpreted_hook block.body, use_significant_node: true + end + ArrayLiteral.map(entries) do |entry| interpreter.define_var(block_arg_key.name, MacroId.new(entry.key)) if block_arg_key interpreter.define_var(block_arg_value.name, entry.value) if block_arg_value @@ -1164,7 +1180,13 @@ module Crystal interpret_check_args(uses_block: true) do block_arg = block.args.first? - interpret_to_range(interpreter).each do |num| + range = interpret_to_range(interpreter) + + if range.empty? + interpreter.not_interpreted_hook block.body, use_significant_node: true + end + + range.each do |num| interpreter.define_var(block_arg.name, NumberLiteral.new(num)) if block_arg interpreter.accept block.body end @@ -1175,14 +1197,14 @@ module Crystal interpret_check_args(uses_block: true) do block_arg = block.args.first? - interpret_map(interpreter) do |num| + interpret_map(block, interpreter) do |num| interpreter.define_var(block_arg.name, NumberLiteral.new(num)) if block_arg interpreter.accept block.body end end when "to_a" interpret_check_args do - interpret_map(interpreter) do |num| + interpret_map(nil, interpreter) do |num| NumberLiteral.new(num) end end @@ -1191,8 +1213,14 @@ module Crystal end end - def interpret_map(interpreter, &) - ArrayLiteral.map(interpret_to_range(interpreter)) do |num| + def interpret_map(block, interpreter, &) + range = interpret_to_range(interpreter) + + if block && range.empty? + interpreter.not_interpreted_hook block.body, use_significant_node: true + end + + ArrayLiteral.map(range) do |num| yield num end end @@ -2887,6 +2915,10 @@ private def interpret_array_or_tuple_method(object, klass, method, args, named_a interpret_check_args(node: object, uses_block: true) do block_arg = block.args.first? + if object.elements.empty? + interpreter.not_interpreted_hook block.body, use_significant_node: true + end + Crystal::BoolLiteral.new(object.elements.any? do |elem| interpreter.define_var(block_arg.name, elem) if block_arg interpreter.accept(block.body).truthy? @@ -2896,6 +2928,10 @@ private def interpret_array_or_tuple_method(object, klass, method, args, named_a interpret_check_args(node: object, uses_block: true) do block_arg = block.args.first? + if object.elements.empty? + interpreter.not_interpreted_hook block.body, use_significant_node: true + end + Crystal::BoolLiteral.new(object.elements.all? do |elem| interpreter.define_var(block_arg.name, elem) if block_arg interpreter.accept(block.body).truthy? @@ -2923,6 +2959,10 @@ private def interpret_array_or_tuple_method(object, klass, method, args, named_a interpret_check_args(node: object, uses_block: true) do block_arg = block.args.first? + if object.elements.empty? + interpreter.not_interpreted_hook block.body, use_significant_node: true + end + found = object.elements.find do |elem| interpreter.define_var(block_arg.name, elem) if block_arg interpreter.accept(block.body).truthy? @@ -2947,6 +2987,10 @@ private def interpret_array_or_tuple_method(object, klass, method, args, named_a interpret_check_args(node: object, uses_block: true) do block_arg = block.args.first? + if object.elements.empty? + interpreter.not_interpreted_hook block.body, use_significant_node: true + end + object.elements.each do |elem| interpreter.define_var(block_arg.name, elem) if block_arg interpreter.accept block.body @@ -2959,6 +3003,10 @@ private def interpret_array_or_tuple_method(object, klass, method, args, named_a block_arg = block.args[0]? index_arg = block.args[1]? + if object.elements.empty? + interpreter.not_interpreted_hook block.body, use_significant_node: true + end + object.elements.each_with_index do |elem, idx| interpreter.define_var(block_arg.name, elem) if block_arg interpreter.define_var(index_arg.name, Crystal::NumberLiteral.new idx) if index_arg @@ -2971,6 +3019,10 @@ private def interpret_array_or_tuple_method(object, klass, method, args, named_a interpret_check_args(node: object, uses_block: true) do block_arg = block.args.first? + if object.elements.empty? + interpreter.not_interpreted_hook block.body, use_significant_node: true + end + klass.map(object.elements) do |elem| interpreter.define_var(block_arg.name, elem) if block_arg interpreter.accept block.body @@ -2981,6 +3033,10 @@ private def interpret_array_or_tuple_method(object, klass, method, args, named_a block_arg = block.args[0]? index_arg = block.args[1]? + if object.elements.empty? + interpreter.not_interpreted_hook block.body, use_significant_node: true + end + klass.map_with_index(object.elements) do |elem, idx| interpreter.define_var(block_arg.name, elem) if block_arg interpreter.define_var(index_arg.name, Crystal::NumberLiteral.new idx) if index_arg @@ -3000,6 +3056,10 @@ private def interpret_array_or_tuple_method(object, klass, method, args, named_a accumulate_arg = block.args.first? value_arg = block.args[1]? + if object.elements.empty? + interpreter.not_interpreted_hook block.body, use_significant_node: true + end + if memo object.elements.reduce(memo) do |accumulate, elem| interpreter.define_var(accumulate_arg.name, accumulate) if accumulate_arg @@ -3264,6 +3324,10 @@ end private def filter(object, klass, block, interpreter, keep = true) block_arg = block.args.first? + if object.elements.empty? + interpreter.not_interpreted_hook block.body, use_significant_node: true + end + klass.new(object.elements.select do |elem| interpreter.define_var(block_arg.name, elem) if block_arg block_result = interpreter.accept(block.body).truthy? @@ -3310,6 +3374,10 @@ end private def sort_by(object, klass, block, interpreter) block_arg = block.args.first? + if object.elements.empty? + interpreter.not_interpreted_hook block.body, use_significant_node: true + end + klass.new(object.elements.sort_by do |elem| block_arg.try { |arg| interpreter.define_var(arg.name, elem) } result = interpreter.accept(block.body) diff --git a/src/compiler/crystal/semantic/exception.cr b/src/compiler/crystal/semantic/exception.cr index 359f12ffb882..e60e98797aab 100644 --- a/src/compiler/crystal/semantic/exception.cr +++ b/src/compiler/crystal/semantic/exception.cr @@ -306,6 +306,22 @@ module Crystal end end + # Raised when another exception is raised when calculating macro code coverage. + # Extends `SkipMacroException` to ensure it's also treated as a somewhat non-failure exception. + # + # Rescuing this exception allows the macro code coverage tool to run on the code that was collected up to before the exception was raised, while not resulting in an error/running all the other code after it. + # The exception's message/trace is printed to STDOUT while the report is written to STDERR. + # This is especially useful for testing error flows of custom macro logic as it allows the code to assert the proper error was raised, while still allowing to save the coverage report. + # I.e. the report JSON/exception outputs are not co-mingled. + # The main benefit of this is preventing the need to run these kind of tests twice, once for the coverage report and once for the actual test assertions. + class SkipMacroCodeCoverageException < SkipMacroException + def initialize(exception : ::Exception) + super "", nil + @message = exception.message + @cause = exception + end + end + class Program def undefined_class_variable(node, owner, similar_name) common = String.build do |str| diff --git a/src/compiler/crystal/semantic/top_level_visitor.cr b/src/compiler/crystal/semantic/top_level_visitor.cr index 1abbf1d81d1b..864579d0ba97 100644 --- a/src/compiler/crystal/semantic/top_level_visitor.cr +++ b/src/compiler/crystal/semantic/top_level_visitor.cr @@ -851,7 +851,8 @@ class Crystal::TopLevelVisitor < Crystal::SemanticVisitor node.expressions.each_with_index do |child, i| begin child.accept self - rescue SkipMacroException + rescue ex : SkipMacroException + @program.macro_expansion_error_hook.try &.call(ex.cause) if ex.is_a? SkipMacroCodeCoverageException node.expressions.delete_at(i..-1) break end diff --git a/src/compiler/crystal/syntax/to_s.cr b/src/compiler/crystal/syntax/to_s.cr index 3e14525c4c1b..c3c1b442f018 100644 --- a/src/compiler/crystal/syntax/to_s.cr +++ b/src/compiler/crystal/syntax/to_s.cr @@ -7,8 +7,8 @@ module Crystal to_s(io) end - def to_s(io : IO, macro_expansion_pragmas = nil, emit_doc = false) : Nil - visitor = ToSVisitor.new(io, macro_expansion_pragmas: macro_expansion_pragmas, emit_doc: emit_doc) + def to_s(io : IO, macro_expansion_pragmas = nil, emit_doc = false, emit_location_pragmas : Bool = false) : Nil + visitor = ToSVisitor.new(io, macro_expansion_pragmas: macro_expansion_pragmas, emit_doc: emit_doc, emit_location_pragmas: emit_location_pragmas) self.accept visitor end end @@ -35,7 +35,7 @@ module Crystal BLOCK_ARG end - def initialize(@str = IO::Memory.new, @macro_expansion_pragmas = nil, @emit_doc = false) + def initialize(@str = IO::Memory.new, @macro_expansion_pragmas = nil, @emit_doc = false, @emit_location_pragmas : Bool = false) @indent = 0 @inside_macro = 0 end @@ -324,6 +324,12 @@ module Crystal false end + private def emit_loc_pragma(for location : Location?) : Nil + if @emit_location_pragmas && (loc = location) && (filename = loc.filename).is_a?(String) + @str << %(#) + end + end + def visit(node : If) if node.ternary? node.cond.accept self @@ -334,10 +340,15 @@ module Crystal return false end + self.emit_loc_pragma node.location + while true @str << "if " node.cond.accept self newline + + self.emit_loc_pragma node.then.location + accept_with_indent(node.then) append_indent @@ -353,27 +364,44 @@ module Crystal unless else_node.nop? @str << "else" newline + + self.emit_loc_pragma node.else.location + accept_with_indent(node.else) append_indent end + self.emit_loc_pragma node.end_location + @str << "end" false end def visit(node : Unless) + self.emit_loc_pragma node.location + @str << "unless " node.cond.accept self newline + + self.emit_loc_pragma node.then.location + accept_with_indent(node.then) unless node.else.nop? append_indent @str << "else" newline + + self.emit_loc_pragma node.else.location + accept_with_indent(node.else) end append_indent + + self.emit_loc_pragma node.end_location + @str << "end" + false end diff --git a/src/compiler/crystal/tools/macro_code_coverage.cr b/src/compiler/crystal/tools/macro_code_coverage.cr new file mode 100644 index 000000000000..1748f79a8996 --- /dev/null +++ b/src/compiler/crystal/tools/macro_code_coverage.cr @@ -0,0 +1,274 @@ +require "../syntax/ast" +require "../compiler" +require "json" + +module Crystal + class Command + private def macro_code_coverage + coverage_processor = MacroCoverageProcessor.new + + config = create_compiler "tool macro_code_coverage", path_filter: true, no_codegen: true, allowed_formats: ["codecov"] + config.compiler.no_codegen = true + + config.compile_configure_program do |program| + coverage_processor.configure program + end + + coverage_processor.includes.concat config.includes.map { |path| ::Path[path].expand.to_posix.to_s } + + coverage_processor.excludes.concat CrystalPath.default_paths.map { |path| ::Path[path].expand.to_posix.to_s } + coverage_processor.excludes.concat config.excludes.map { |path| ::Path[path].expand.to_posix.to_s } + + coverage_processor.process + end + end + + class MacroCoverageProcessor + private CURRENT_DIR = Dir.current + + @hits = Hash(String, Hash(Int32, Int32 | String)).new { |hash, key| hash[key] = Hash(Int32, Int32 | String).new(0) } + @conditional_hit_cache = Hash(String, Hash(Int32, Set({ASTNode, Bool}))).new { |hash, key| hash[key] = Hash(Int32, Set({ASTNode, Bool})).new { |h, k| h[k] = Set({ASTNode, Bool}).new } } + + @covered_macro_nodes = Array({ASTNode, Location, Bool}).new + @collected_covered_macro_nodes = Array(Array({ASTNode, Location, Bool})).new + getter coverage_interrupt_exception : ::Exception? = nil + + # :nodoc: + def configure(program : Program) : Nil + program.interpreted_node_hook = ->interpreted_node_hook(ASTNode, Bool, Bool, Location?) + program.macro_expanded_hook = ->macro_expanded_hook + program.macro_expansion_error_hook = ->macro_expansion_error_hook(::Exception?) + end + + protected def interpreted_node_hook(node : ASTNode, missed : Bool = false, use_significant_node : Bool = false, location custom_location : Location? = nil) : Nil + return unless location = (custom_location || node.location) + + # If desired, try to find a more significant node to use for a more accurate location. + if use_significant_node + node = self.find_first_significant_node node + location = node.try(&.location) || location + end + + unless location.filename.is_a? String + return node unless macro_location = location.macro_location + + location = Location.new( + macro_location.filename, + location.line_number + macro_location.line_number, + location.column_number + ) + end + + @covered_macro_nodes << {node, location, missed} + end + + protected def macro_expanded_hook : Nil + @collected_covered_macro_nodes << @covered_macro_nodes.dup + @covered_macro_nodes.clear + end + + protected def macro_expansion_error_hook(exception : ::Exception?) : Nil + @coverage_interrupt_exception = exception unless exception.is_a?(SkipMacroException) + end + + property includes = [] of String + property excludes = [] of String + + def process : Nil + @hits.clear + + self.compute_coverage + + if err = @coverage_interrupt_exception + err.inspect_with_backtrace STDERR + STDERR.puts + STDERR.puts + end + + self.write_output STDOUT + + exit 1 if err + end + + # See https://docs.codecov.com/docs/codecov-custom-coverage-format + private def write_output(io : IO) : Nil + JSON.build io, indent: " " do |builder| + builder.object do + builder.string "coverage" + builder.object do + @hits.each do |filename, line_coverage| + builder.field filename do + builder.object do + line_coverage.to_a.sort_by! { |(line, count)| line }.each do |line, count| + builder.field line, count + end + end + end + end + end + end + end + end + + # First filters the nodes to only those with locations we care about. + # The nodes are then chunked by line number, essentially grouping them. + # Each group is then processed to determine if that line is a hit or miss, but may also yield more than once, such as to mark an `If` conditional as a hit, but it's `else` block as a miss. + # + # The coverage information is stored in a similar way as the resulting output report: https://docs.codecov.com/docs/codecov-custom-coverage-format. + def compute_coverage + @collected_covered_macro_nodes + .select { |nodes| nodes.any? { |(_, location, _)| match_path? location.filename.as(String) } } + .each do |nodes| + nodes + .chunk { |(_, location, _)| location.line_number } + .each do |(line_number, nodes_by_line)| + self.process_line(line_number, nodes_by_line) do |(count, location, branches)| + next unless location.filename.is_a? String + + location = self.normalize_location(location) + + @hits[location.filename][location.line_number] = case existing_hits = @hits[location.filename][location.line_number]? + in String + hits, _, total = existing_hits.partition '/' + + "#{(hits.to_i + count).clamp(1, total.to_i)}/#{total}" + in Int32 then existing_hits + count + in Nil + branches && count >= 1 ? "#{count.clamp(1, branches)}/#{branches}" : count + end + end + end + end + + @hits + end + + # These overloads try to find a more significant node to mark as missed. + # This ensures the missed value in the report maps to an actual node + # instead of just `{%` in the context of a multi-line `MacroExpression`, + # or just some whitespace as part of a `MacroLiteral`. + + private def find_first_significant_node(node : MacroExpression) : ASTNode + self.find_first_significant_node node.exp + end + + private def find_first_significant_node(node : Expressions) : ASTNode + if n = node.expressions.reject(MacroLiteral).reject(MultiAssign).first? + return self.find_first_significant_node n + end + + node + end + + private def find_first_significant_node(node : _) : ASTNode + node + end + + private alias NodeTuple = {ASTNode, Location, Bool} + + private def process_line(line : Int32, nodes : Array(NodeTuple), & : {Int32, Location, Int32?} ->) : Nil + # It's safe to use the first location since they were chunked by line. + _, location, _ = nodes.first + + # Check for conditional hits first so that suffix conditionals are still treated as `1/2`. + if match = has_conditional_node?(nodes) + conditional_node, branches = match + + # Keep track of what specific conditional branches were hit and missed as to enure a proper partial count + # We'll use the last missed node, or the last one if none were missed. + node, _, missed = nodes.reverse.find(nodes.last) { |_, _, is_missed| !is_missed } + newly_hit = @conditional_hit_cache[location.filename][location.line_number].add?({node, missed}) + + hit_count = if newly_hit + if conditional_node.is_a?(If | Unless) && (loc = conditional_node.location) && (end_loc = conditional_node.end_location) && loc.line_number == end_loc.line_number + # Special case: Handle suffix `If` and `Unless` given there is no missed node in this context. + 1 + elsif nodes.all? { |(_, _, missed)| missed } + # If all nodes on this line were missed, it's a miss + 0 + else + # Otherwise, if no nodes were missed on this line, then all branches of this conditional were hit at once. + nodes.none? { |(_, _, missed)| missed } ? branches : 1 + end + else + 0 + end + + yield({hit_count, location, branches}) + return + end + + # If no nodes on this line were missed, we can be assured it was a hit + if nodes.none? { |(_, _, missed)| missed } + yield({1, location, nil}) + return + end + + yield({0, location, nil}) + end + + private def has_conditional_node?(nodes : Array(NodeTuple)) : {ASTNode, Int32}? + nodes.each do |(node, _, _)| + if (n = node).is_a?(If | Unless | MacroIf | Or | And) && (branches = self.conditional_statement_branches(n)) > 1 + return node, branches.not_nil! + end + end + end + + # Returns how many unique values a conditional statement could return on a single line. + private def conditional_statement_branches(node : If | Unless | MacroIf | Or | And) : Int32 + return 1 unless start_location = node.location + return 1 unless end_location = node.end_location + return 1 if end_location.line_number > start_location.line_number + + self.count_branches node + end + + # Workaround for a Crystal 1.0.0 compiler error + private def conditional_statement_branches(node : ASTNode) : Int32 + 1 + end + + private def count_branches(node : Or | And) : Int32 + self.count_branches node.left, node.right + end + + private def count_branches(node : MacroIf | If | Unless) : Int32 + self.count_branches node.then, node.else + end + + private def count_branches(left : ASTNode, right : ASTNode) : Int32 + then_depth = case n = left + when MacroIf, If, Unless, Or, And then self.count_branches n + else + 1 + end + + else_depth = case n = right + when MacroIf, If, Unless, Or, And then self.count_branches n + else + 1 + end + + then_depth + else_depth + end + + private def normalize_location(location : Location) : Location + Location.new( + ::Path[location.filename.as(String)].relative_to(CURRENT_DIR).to_s, + location.line_number, + location.column_number + ) + end + + private def match_path?(path) + paths = ::Path[path].parents << ::Path[path] + + match_any_pattern?(includes, paths) || !match_any_pattern?(excludes, paths) + end + + private def match_any_pattern?(patterns, paths) + patterns.any? { |pattern| paths.any? { |path| path == pattern || File.match?(pattern, path.to_posix) } } + end + end +end