diff --git a/README.md b/README.md index 0abf6fd..d36b8a5 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Displays a coverage summary report in a pop-up window. Currently supports: -- C/C++ (lcov) +- C/C++ (lcov, cobertura) - C# (lcov - see wiki for details) - Dart (lcov) - Go (coverprofile) @@ -27,7 +27,7 @@ Branch (partial) coverage support: | Language | Supported | | --------------------- | ---------------------- | -| C/C++ | :x: | +| C/C++ | :x: (lcov), :heavy_check_mark: (cobertura) | | C# | :x: | | Dart | :heavy_check_mark: (untested) | | Go | :x: | @@ -36,7 +36,7 @@ Branch (partial) coverage support: | Python | :heavy_check_mark: | | Ruby | :x: | | Rust | :x: | -| PHP | :x: | +| PHP | :heavy_check_mark: (untested) | | Lua | :x: | *Note:* This plugin does not run tests. It justs loads/displays a coverage report generated by a test suite. diff --git a/lua/coverage/config.lua b/lua/coverage/config.lua index 268023b..9684fe9 100644 --- a/lua/coverage/config.lua +++ b/lua/coverage/config.lua @@ -78,6 +78,8 @@ local defaults = { lang = { cpp = { coverage_file = "report.info", + xml_coverage_file = "coverage/cobertura.xml", + path_mappings = {}, }, cs = { coverage_file = "TestResults/lcov.info", diff --git a/lua/coverage/languages/cpp.lua b/lua/coverage/languages/cpp.lua index 4f1a3e9..c5a5f66 100644 --- a/lua/coverage/languages/cpp.lua +++ b/lua/coverage/languages/cpp.lua @@ -15,13 +15,20 @@ M.summary = common.summary -- @param callback called with the results of the coverage report M.load = function(callback) local cpp_config = config.opts.lang.cpp + local p = Path:new(util.get_coverage_file(cpp_config.coverage_file)) - if not p:exists() then - vim.notify("No coverage file exists.", vim.log.levels.INFO) + if p:exists() then + callback(util.lcov_to_table(p)) + return + end + + local p = Path:new(util.get_coverage_file(cpp_config.xml_coverage_file)) + if p:exists() then + callback(util.cobertura_to_table(p, cpp_config.path_mappings or {})) return end - callback(util.lcov_to_table(p)) + vim.notify("No coverage file exists.", vim.log.levels.INFO) end return M diff --git a/lua/coverage/parsers/corbertura.lua b/lua/coverage/parsers/corbertura.lua index 754191a..0683e79 100644 --- a/lua/coverage/parsers/corbertura.lua +++ b/lua/coverage/parsers/corbertura.lua @@ -13,50 +13,12 @@ local function read_path(path) return assert(xmlreader.from_string(path:read())) end ---- Position the reader on the next element `name` found before the closing element for `parent` ---- ---- @param name string ---- @param parent string|nil -local function next_element_in(name, parent) - while nil ~= reader and reader:next_node() do - if "element" == reader:node_type() and name == reader:name() then - return true - end - if "end element" == reader:node_type() and parent == reader:name() then - return false - end - end - - return false -end - -local function enter_current_element() - reader:read() -end - ---- Enter in the next element `name` found before the closing element for `parent` ---- ---- @param name string ---- @param parent string|nil -local function enter_next_element_in(name, parent) - if false == next_element_in(name, parent) then - return false - end - - if reader:is_empty_element() then - return false - end - - enter_current_element() - - return true +local function is_element(name) + return "element" == reader:node_type() and name == reader:name() end ---- Enter in the next element `name`, will jump over any other element ---- ---- @param name string -local function enter_next_element(name) - return enter_next_element_in(name, nil) +local function is_end_element(name) + return "end element" == reader:node_type() and name == reader:name() end local function apply_path_mappings(source) @@ -69,72 +31,162 @@ local function apply_path_mappings(source) return source end -local function load_sources() - if enter_next_element_in("sources", "coverage") then - while enter_next_element_in("source", "sources") do - local source = apply_path_mappings(reader:value()) - table.insert(sources, source) +local function resolve_filename_from_sources(filename) + if filename == "" then + return "" + end + for _, source in pairs(sources) do + local filepath = Path:new({source, filename}) + if filepath:exists() then + return filepath.filename end end -end - -local function create_coverage_for_current_package() - local coverage = util.new_file_meta() - coverage.summary.percent_covered = tonumber(reader:get_attribute("line-rate")) * 100 - - return coverage + return filename end local function update_coverage_with_current_line(coverage) - local number = tonumber(reader:get_attribute("number"), 10) - local hits = tonumber(reader:get_attribute("hits"), 10) + if coverage == nil then + return + end + local linenr = tonumber(reader:get_attribute("number"), 10) + local count = tonumber(reader:get_attribute("hits"), 10) + local is_branch = reader:get_attribute("branch") == 'true' + + -- no data for coverage.exclude_lines + -- no data for coverage.summary.exclude_lines - if 0 == hits then - table.insert(coverage.missing_lines, number) + if count == 0 then + table.insert(coverage.missing_lines, linenr) coverage.summary.missing_lines = coverage.summary.missing_lines + 1 else - table.insert(coverage.executed_lines, number) + table.insert(coverage.executed_lines, linenr) coverage.summary.covered_lines = coverage.summary.covered_lines + 1 end - coverage.summary.num_statements = coverage.summary.num_statements + 1 -end -local function resolve_filename_from_sources(filename) - for _, source in pairs(sources) do - local filepath = Path:new({source, filename}) - if filepath:exists() then - return filepath.filename + if is_branch then + rc, cond_info = pcall(reader.get_attribute, reader, "condition-coverage") + if rc then + -- Example: "87% (7/8)" + local br_percent, br_hits, br_total = cond_info:match("([0-9.]+)%% [(](%d+)/(%d+)[)]") + if br_percent ~= nil then + br_percent = tonumber(br_percent, 10) + br_hits = tonumber(br_hits, 10) + br_total = tonumber(br_total, 10) + if br_hits < br_total then + table.insert(coverage.missing_branches, {linenr, linenr}) -- { from, to } + end + coverage.summary.num_branches = coverage.summary.num_branches + br_total + coverage.summary.num_partial_branches = coverage.summary.num_partial_branches + (br_total - br_hits) + end end end - return filename end -local function generate_coverages() - local coverages = {} - while next_element_in("package", "packages") do - local coverage = create_coverage_for_current_package() - local filename = resolve_filename_from_sources(reader:get_attribute("name")) - - enter_current_element() - if enter_next_element_in("classes", "package") then - while enter_next_element_in("class", "classes") do - while enter_next_element_in("lines", "class") do - while next_element_in("line", "lines") do - update_coverage_with_current_line(coverage) +local function process_coverage_packages_element(files) + while not is_end_element("packages") do + if is_element("package") then + -- If a package has files (with filename), then report at the file level + local package_has_files = false + -- If a package has pure classes (with no filename), then report at the package level + local package_has_classes = false + + local packages_coverage, packages_filename = nil, "" + local rc, package_name_attr = pcall(reader.get_attribute, reader, "name") + if rc then + package_filename = resolve_filename_from_sources(package_name_attr) + if package_filename ~= "" then + package_coverage = files[package_filename] + if package_coverage == nil then + package_coverage = util.new_file_meta() end end end - end + if package_coverage ~= nil then + package_coverage.summary.percent_covered = tonumber(reader:get_attribute("line-rate")) * 100 + end + + reader:read() + while not is_end_element("package") do + if is_element("classes") then + + reader:read() + while not is_end_element("classes") do + if is_element("class") then + + local class_coverage, class_filename = nil, "" + local rc, class_filename_attr = pcall(reader.get_attribute, reader, "filename") + if rc then + class_filename = resolve_filename_from_sources(class_filename_attr) + if class_filename == package_filename then + class_filename = "" + end + if class_filename ~= "" then + package_has_files = true + class_coverage = files[class_filename] + if class_coverage == nil then + class_coverage = util.new_file_meta() + end + end + end + if class_coverage ~= nil then + class_coverage.summary.percent_covered = tonumber(reader:get_attribute("line-rate")) * 100 + else + -- At least one class does not report its own coverage at the file level + package_has_classes = true + end + + reader:read() + while not is_end_element("class") do + if is_element("lines") then + + reader:read() + while not is_end_element("lines") do + if is_element("line") then + update_coverage_with_current_line(package_coverage) + update_coverage_with_current_line(class_coverage) + end + if not reader:next_node() then break end + end + + end + if not reader:next_node() then break end + end + + if class_coverage ~= nil and class_coverage.summary.num_statements > 0 then + files[class_filename] = class_coverage + end + + end + if not reader:next_node() then break end + end + + end + if not reader:next_node() then break end + end + + if + (package_coverage ~= nil and package_coverage.summary.num_statements > 0) and + (package_has_classes or not package_has_files) + then + files[package_filename] = package_coverage + end - local is_not_interface = 0 < coverage.summary.num_statements - if is_not_interface then - coverages[filename] = coverage end + if not reader:next_node() then break end end +end - return coverages +local function process_coverage_sources_element() + while not is_end_element("sources") do + if is_element("source") then + reader:read() + local source = apply_path_mappings(reader:value()) + table.insert(sources, source) + end + if not reader:next_node() then break end + end end --- Parses a cobertura report from path into files. @@ -146,22 +198,38 @@ return function (path, files, a_path_mappings) reader = read_path(path) path_mappings = a_path_mappings sources = {} + local found_coverage = false + local found_packages = false + + while true do + if is_element("coverage") then + found_coverage = true + + reader:read() + while not is_end_element("coverage") do + if is_element("sources") then + reader:read() + process_coverage_sources_element() + elseif is_element("packages") then + found_packages = true + reader:read() + process_coverage_packages_element(files) + end + if not reader:next_node() then break end + end + + end + if not reader:next_node() then break end + end - if false == enter_next_element("coverage") then + if not found_coverage then notify_element_missing("coverage") return end - - load_sources() - - if false == enter_next_element_in("packages", "coverage") then + if not found_packages then notify_element_missing("packages") return end - for filename, coverage in pairs(generate_coverages()) do - files[filename] = coverage - end - reader:close() end diff --git a/lua/coverage/util.lua b/lua/coverage/util.lua index bc80ca7..f552de4 100644 --- a/lua/coverage/util.lua +++ b/lua/coverage/util.lua @@ -32,6 +32,8 @@ M.new_file_meta = function() excluded_lines = 0, missing_lines = 0, num_statements = 0, + num_branches = 0, + num_partial_branches = 0, percent_covered = 0, }, missing_lines = {}, diff --git a/nvim-coverage-scm-1.rockspec b/nvim-coverage-scm-1.rockspec new file mode 100644 index 0000000..e79fe2f --- /dev/null +++ b/nvim-coverage-scm-1.rockspec @@ -0,0 +1,30 @@ +local MODREV, SPECREV = 'scm', '-1' +rockspec_format = '3.0' +package = 'nvim-coverage' +version = MODREV .. SPECREV + +description = { + summary = 'Displays coverage information in the sign column.', + detailed = [[ + Displays coverage information in the sign column. + ]], + labels = { 'neovim', 'plugin', }, + homepage = 'http://github.com/andythigpen/nvim-coverage', + license = 'MIT', +} + +dependencies = { + 'lua == 5.1', + 'lua-xmlreader', +} + +source = { + url = 'git://github.com/andythigpen/nvim-coverage', +} + +build = { + type = 'builtin', + copy_directories = { + 'doc', + }, +}