diff --git a/README.md b/README.md index aa90d3a..3dbb8f6 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Currently supports: - C# (lcov - see wiki for details) - Dart (lcov) - Go (coverprofile) +- Java/Kotlin (jacoco) - Javascript/Typescript (lcov): [jest](https://jestjs.io/docs/getting-started) - Julia (lcov): [Pkg.jl](https://pkgdocs.julialang.org/v1/) - Python (json): [coverage.py](https://coverage.readthedocs.io/en/6.3.2/index.html) @@ -32,6 +33,7 @@ Branch (partial) coverage support: | C# | :x: | | Dart | :heavy_check_mark: (untested) | | Go | :x: | +| Java/Kotlin | :heavy_check_mark: (untested) | | Javascript/Typescript | :heavy_check_mark: | | Julia | :heavy_check_mark: (untested) | | Python | :heavy_check_mark: | diff --git a/lua/coverage/config.lua b/lua/coverage/config.lua index 7bc7504..42576f9 100644 --- a/lua/coverage/config.lua +++ b/lua/coverage/config.lua @@ -122,6 +122,10 @@ local defaults = { -- https://github.com/andythigpen/nvim-coverage/issues/41) disable_auto_reload = true, }, + kotlin = { + coverage_file = "build/reports/jacoco/test/jacocoTestReport.xml", + dir_prefix = "src/main/kotlin", + }, lua = { coverage_file = "luacov.report.out", }, diff --git a/lua/coverage/init.lua b/lua/coverage/init.lua index f42fee6..8a9f043 100644 --- a/lua/coverage/init.lua +++ b/lua/coverage/init.lua @@ -69,10 +69,16 @@ M.load = function(place) -- and when the language setup allows it if config.opts.auto_reload and lang_config ~= nil and - lang_config.coverage_file ~= nil and not lang_config.disable_auto_reload then - local coverage_file = util.get_coverage_file(lang_config.coverage_file) - watch.start(coverage_file, load_lang) + local coverage_file + if lang.get_coverage_file then + coverage_file = lang.get_coverage_file() + elseif lang_config.coverage_file ~= nil then + coverage_file = util.get_coverage_file(lang_config.coverage_file) + end + if coverage_file then + watch.start(coverage_file, load_lang) + end end signs.clear() diff --git a/lua/coverage/languages/kotlin.lua b/lua/coverage/languages/kotlin.lua new file mode 100644 index 0000000..872f5fd --- /dev/null +++ b/lua/coverage/languages/kotlin.lua @@ -0,0 +1,279 @@ +local M = {} + +local Path = require("plenary.path") +local config = require("coverage.config") +local util = require("coverage.util") +local cs = require("coverage.signs") +local lom = require("neotest.lib.xml") + +-- Helper to ensure single elements parsed by neotest.lib.xml are treated as arrays +local function ensure_array(tbl) + if not tbl then + return {} + end + if not tbl[1] then + return { tbl } + end + return tbl +end + +--- Returns the path to the coverage report. +M.get_coverage_file = function() + local opt = config.opts.lang.kotlin.coverage_file + if opt ~= "build/reports/jacoco/test/jacocoTestReport.xml" then + return util.get_coverage_file(opt) + end + + local current_file = vim.fn.expand("%:p") + local dir_prefix = "src/main/kotlin" + if config.opts.lang and config.opts.lang.kotlin and config.opts.lang.kotlin.dir_prefix then + dir_prefix = config.opts.lang.kotlin.dir_prefix + end + + -- Find where dir_prefix starts in the path to identify subproject root + local start_idx, _ = current_file:find(dir_prefix) + if start_idx then + local subproject_root = current_file:sub(1, start_idx - 1) + local path = Path:new({ subproject_root, "build/reports/jacoco/test/jacocoTestReport.xml" }) + if path:exists() then + return path.filename + end + end + + -- Fallback 1: check root project coverage file + local root_path = Path:new("build/reports/jacoco/test/jacocoTestReport.xml") + if root_path:exists() then + return root_path.filename + end + + -- Fallback 2: check root project aggregated coverage file + local aggregated_root_path = Path:new("build/reports/jacoco/jacocoAggregatedReport/jacocoAggregatedReport.xml") + if aggregated_root_path:exists() then + return aggregated_root_path.filename + end + + -- Fallback 3: check if any subproject has a coverage file + local cwd = vim.fn.getcwd() + local entries = vim.fn.readdir(cwd) + for _, entry in ipairs(entries) do + local entry_path = Path:new({ cwd, entry }) + if entry_path:is_dir() then + local path = Path:new({ entry, "build/reports/jacoco/test/jacocoTestReport.xml" }) + if path:exists() then + return path.filename + end + end + end + + -- Ultimate fallback: default location + return "build/reports/jacoco/test/jacocoTestReport.xml" +end + +--- Loads a coverage report. +-- @param callback called with results of the coverage report +M.load = function(callback) + -- Try and load file + local p = Path:new(M.get_coverage_file()) + if not p:exists() then + vim.notify("No coverage file exists.", vim.log.levels.INFO) + return + end + + local dir_prefix = config.opts.lang.kotlin.dir_prefix or "src/main/kotlin" + + -- Helper to resolve the path of a source file across root and any subprojects + local resolve_file = function(pack_name, filename) + -- Candidate 1: Root project + local root_path = Path:new({ dir_prefix, pack_name, filename }) + if root_path:exists() then + return root_path.filename + end + + -- Candidate 2: Check all directories in the current working directory (subprojects) + local cwd = vim.fn.getcwd() + local entries = vim.fn.readdir(cwd) + for _, entry in ipairs(entries) do + local entry_path = Path:new({ cwd, entry }) + if entry_path:is_dir() then + local candidate = Path:new({ entry, dir_prefix, pack_name, filename }) + if candidate:exists() then + return candidate.filename + end + end + end + + -- Fallback to the root path filename if not found on disk + return root_path.filename + end + + -- Parse into object + local jacoco = lom.parse(table.concat(vim.fn.readfile(p.filename), "")) + + -- Failed to parse, ignore. + if not jacoco then + vim.notify("Error loading XML") + return nil + end + + -- Load xml + local data = { + files = {}, + totals = {}, + } + + local get_attr_by_type_name = function(tag, type_name) + if not tag then + return nil + end + for _, value in ipairs(ensure_array(tag)) do + if value._attr.type == type_name then + return value._attr + end + end + return nil + end + + -- Global stats + -- obtains the total counters + local counter = assert(jacoco.report.counter, "not able to readjacoco.report.counter") + + local global_lines = get_attr_by_type_name(counter, "LINE") + if global_lines then + data.totals.line = { + covered = tonumber(global_lines.covered), + missed = tonumber(global_lines.missed), + } + end + + local branch = get_attr_by_type_name(counter, "BRANCH") + if branch then + data.totals.branch = { + covered = tonumber(branch.covered), + missed = tonumber(branch.missed), + } + end + + -- obtains fine grained data + local packages = ensure_array(assert(jacoco.report.package, "not able to read jacoco.report.package")) + for _, pack in ipairs(packages) do + -- classes + for _, class in ipairs(ensure_array(pack.class)) do + local filename = resolve_file(pack._attr.name, class._attr.sourcefilename) + + -- set file total counters + local file_total_lines = get_attr_by_type_name(class.counter, "LINE") + local file_total_branches = get_attr_by_type_name(class.counter, "BRANCH") + data.files[filename] = { + lines = {}, + totals = { + line = { + covered = file_total_lines and file_total_lines.covered or 0, + missed = file_total_lines and file_total_lines.missed or 0, + }, + branch = { + covered = file_total_branches and file_total_branches.covered or 0, + missed = file_total_branches and file_total_branches.missed or 0, + }, + }, + } + end + + for _, src_file in ipairs(ensure_array(pack.sourcefile)) do + local lines = src_file.line + -- So, jacoco reports in terms of instructions + -- which is neat, but not uh that useful for this purpose. + -- I'll mark any sort of missing instructions as missed lines, + -- iff no instructions were missed, check if any were covered. + -- Also,, it doesn't really specify if stuff is mutually exclusive or not. + -- The priority will be + -- 1. Missed branch + -- 2. Missed instruction (as line) + -- 3. Covered branch + -- 4. Covered instruction (as line) + if lines then + for _, line in ipairs(ensure_array(lines)) do + local line_number = assert(tonumber(line._attr.nr)) + local filename = resolve_file(pack._attr.name, src_file._attr.name) + + local mb = assert(line._attr.mb) ~= "0" + local mi = assert(line._attr.mi) ~= "0" + local cb = assert(line._attr.cb) ~= "0" + local ci = assert(line._attr.ci) ~= "0" + + if mb and cb or mi and ci then + data.files[filename].lines[line_number] = "partial" + elseif mb or mi then + data.files[filename].lines[line_number] = "missed" + else + data.files[filename].lines[line_number] = "covered" + end + end + end + end + end + + callback(data) +end + +--- Returns a list of signs that will be placed in buffers. +-- This method should use the coverage data (previously generated via the load method) to +-- return a list of signs. +-- @return list of signs +M.sign_list = function(data) + local signs = {} + local funcs = { + covered = cs.new_covered, + partial = cs.new_partial, + missed = cs.new_uncovered, + } + for fn, fdata in pairs(data.files) do + local bufnr = vim.fn.bufnr(fn, false) + -- Only do loaded buffers + if bufnr ~= -1 then + for lnum, what in pairs(fdata.lines) do + table.insert(signs, funcs[what](bufnr, lnum)) + end + end + end + + return signs +end + +--- Returns a summary report. +-- @return summary report +M.summary = function(data) + local report = { files = {} } + for fn, fdata in pairs(data.files) do + local statements = fdata.totals.line.covered + fdata.totals.line.missed + local rep = { + filename = fn, + statements = statements, + missing = fdata.totals.line.missed, + branches = fdata.totals.branch.covered + fdata.totals.branch.missed, + partial = fdata.totals.branch.missed, + coverage = (1 - fdata.totals.line.missed / statements) * 100, + } + -- Avoid nan + if statements == 0 then + rep.coverage = 100 + end + table.insert(report.files, rep) + end + + report.totals = { + statements = data.totals.line.covered + data.totals.line.missed, + missing = data.totals.line.missed, + branches = data.totals.branch.covered + data.totals.branch.missed, + partial = data.totals.branch.missed, + } + if report.totals.statements == 0 then + report.totals.coverage = 100 + else + report.totals.coverage = (1 - report.totals.missing / report.totals.statements) * 100 + end + + return report +end + +return M +