Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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: |
Expand Down
4 changes: 4 additions & 0 deletions lua/coverage/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
Expand Down
12 changes: 9 additions & 3 deletions lua/coverage/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
279 changes: 279 additions & 0 deletions lua/coverage/languages/kotlin.lua
Original file line number Diff line number Diff line change
@@ -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