From 2618da6bee3ad39520032ee977a60301f8901ef3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gast=C3=B3n=20Haro?= Date: Fri, 14 Aug 2026 17:28:19 -0300 Subject: [PATCH 1/5] feat: add kotlin jacoco coverage support --- lua/coverage/config.lua | 4 + lua/coverage/languages/kotlin.lua | 194 ++++++++++++++++++++++++++++++ 2 files changed, 198 insertions(+) create mode 100644 lua/coverage/languages/kotlin.lua 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/languages/kotlin.lua b/lua/coverage/languages/kotlin.lua new file mode 100644 index 0000000..8d52b1e --- /dev/null +++ b/lua/coverage/languages/kotlin.lua @@ -0,0 +1,194 @@ +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") + +--- Loads a coverage report. +-- @param callback called with results of the coverage report +M.load = function(callback) + -- Try and load file + local opt = config.opts.lang.kotlin.coverage_file + local p = Path:new(util.get_coverage_file(opt)) + if not p:exists() then + vim.notify("No coverage file exists.", vim.log.levels.INFO) + return + end + + local dir_prefix = Path:new(config.opts.lang.kotlin.dir_prefix .. "/").filename + + -- 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(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 = assert(jacoco.report.package, "not able to read jacoco.report.package") + assert(type(packages) == "table") + for _, pack in ipairs(packages) do + local dir = dir_prefix .. pack._attr.name + + -- classes + for _, class in ipairs(pack.class) do + local filename = Path:new(dir .. "/" .. class._attr.sourcefilename).filename -- with .java + + -- 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(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(lines) do + local line_number = assert(tonumber(line._attr.nr)) + local filename = Path:new(dir .. "/" .. src_file._attr.name).filename + + 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 From cbbd2d51a75b4ee8fceb2d86f06b76b4e52d8908 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gast=C3=B3n=20Haro?= Date: Fri, 14 Aug 2026 17:32:32 -0300 Subject: [PATCH 2/5] updated README.md --- README.md | 2 ++ 1 file changed, 2 insertions(+) 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: | From c123aa39d4a62c0e54829fb9ac2bb763a89b767a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gast=C3=B3n=20Haro?= <62256733+gaston-haro@users.noreply.github.com> Date: Fri, 14 Aug 2026 17:50:39 -0300 Subject: [PATCH 3/5] Update kotlin.lua --- lua/coverage/languages/kotlin.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/coverage/languages/kotlin.lua b/lua/coverage/languages/kotlin.lua index 8d52b1e..bbfa5e7 100644 --- a/lua/coverage/languages/kotlin.lua +++ b/lua/coverage/languages/kotlin.lua @@ -74,7 +74,7 @@ M.load = function(callback) -- classes for _, class in ipairs(pack.class) do - local filename = Path:new(dir .. "/" .. class._attr.sourcefilename).filename -- with .java + local filename = Path:new(dir .. "/" .. class._attr.sourcefilename).filename -- with .kt -- set file total counters local file_total_lines = get_attr_by_type_name(class.counter, "LINE") From 5b1e2b7c8a0cc11bddb070a53611a54ef932401c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gast=C3=B3n=20Haro?= Date: Fri, 14 Aug 2026 19:14:43 -0300 Subject: [PATCH 4/5] support multi project setups --- lua/coverage/config.lua | 47 +++++++++++++++++++++++++- lua/coverage/languages/kotlin.lua | 56 +++++++++++++++++++++++++------ 2 files changed, 91 insertions(+), 12 deletions(-) diff --git a/lua/coverage/config.lua b/lua/coverage/config.lua index 42576f9..3407775 100644 --- a/lua/coverage/config.lua +++ b/lua/coverage/config.lua @@ -123,7 +123,52 @@ local defaults = { disable_auto_reload = true, }, kotlin = { - coverage_file = "build/reports/jacoco/test/jacocoTestReport.xml", + coverage_file = function() + local Path = require("plenary.path") + local current_file = vim.fn.expand("%:p") + local dir_prefix = "src/main/kotlin" + if M.opts.lang and M.opts.lang.kotlin and M.opts.lang.kotlin.dir_prefix then + dir_prefix = M.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, dir_prefix = "src/main/kotlin", }, lua = { diff --git a/lua/coverage/languages/kotlin.lua b/lua/coverage/languages/kotlin.lua index bbfa5e7..520dfec 100644 --- a/lua/coverage/languages/kotlin.lua +++ b/lua/coverage/languages/kotlin.lua @@ -6,6 +6,17 @@ 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 + --- Loads a coverage report. -- @param callback called with results of the coverage report M.load = function(callback) @@ -17,7 +28,32 @@ M.load = function(callback) return end - local dir_prefix = Path:new(config.opts.lang.kotlin.dir_prefix .. "/").filename + 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), "")) @@ -38,7 +74,7 @@ M.load = function(callback) if not tag then return nil end - for _, value in ipairs(tag) do + for _, value in ipairs(ensure_array(tag)) do if value._attr.type == type_name then return value._attr end @@ -67,14 +103,11 @@ M.load = function(callback) end -- obtains fine grained data - local packages = assert(jacoco.report.package, "not able to read jacoco.report.package") - assert(type(packages) == "table") + local packages = ensure_array(assert(jacoco.report.package, "not able to read jacoco.report.package")) for _, pack in ipairs(packages) do - local dir = dir_prefix .. pack._attr.name - -- classes - for _, class in ipairs(pack.class) do - local filename = Path:new(dir .. "/" .. class._attr.sourcefilename).filename -- with .kt + 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") @@ -94,7 +127,7 @@ M.load = function(callback) } end - for _, src_file in ipairs(pack.sourcefile) do + 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. @@ -107,9 +140,9 @@ M.load = function(callback) -- 3. Covered branch -- 4. Covered instruction (as line) if lines then - for _, line in ipairs(lines) do + for _, line in ipairs(ensure_array(lines)) do local line_number = assert(tonumber(line._attr.nr)) - local filename = Path:new(dir .. "/" .. src_file._attr.name).filename + 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" @@ -192,3 +225,4 @@ M.summary = function(data) end return M + From b028908c59000a2f5cb9e712d20a19e55f9dceb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gast=C3=B3n=20Haro?= Date: Fri, 14 Aug 2026 19:33:41 -0300 Subject: [PATCH 5/5] move get_coverage_file logic to language implementation --- lua/coverage/config.lua | 47 +------------------------- lua/coverage/init.lua | 12 +++++-- lua/coverage/languages/kotlin.lua | 55 +++++++++++++++++++++++++++++-- 3 files changed, 63 insertions(+), 51 deletions(-) diff --git a/lua/coverage/config.lua b/lua/coverage/config.lua index 3407775..42576f9 100644 --- a/lua/coverage/config.lua +++ b/lua/coverage/config.lua @@ -123,52 +123,7 @@ local defaults = { disable_auto_reload = true, }, kotlin = { - coverage_file = function() - local Path = require("plenary.path") - local current_file = vim.fn.expand("%:p") - local dir_prefix = "src/main/kotlin" - if M.opts.lang and M.opts.lang.kotlin and M.opts.lang.kotlin.dir_prefix then - dir_prefix = M.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, + coverage_file = "build/reports/jacoco/test/jacocoTestReport.xml", dir_prefix = "src/main/kotlin", }, lua = { 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 index 520dfec..872f5fd 100644 --- a/lua/coverage/languages/kotlin.lua +++ b/lua/coverage/languages/kotlin.lua @@ -17,12 +17,63 @@ local function ensure_array(tbl) 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 opt = config.opts.lang.kotlin.coverage_file - local p = Path:new(util.get_coverage_file(opt)) + local p = Path:new(M.get_coverage_file()) if not p:exists() then vim.notify("No coverage file exists.", vim.log.levels.INFO) return