├── .gitignore ├── LICENSE ├── README.md ├── doc └── nvim-coverage.txt ├── lua └── coverage │ ├── config.lua │ ├── highlight.lua │ ├── init.lua │ ├── languages │ ├── c.lua │ ├── common.lua │ ├── cpp.lua │ ├── cs.lua │ ├── dart.lua │ ├── elixir.lua │ ├── go.lua │ ├── java.lua │ ├── javascript.lua │ ├── javascriptreact.lua │ ├── julia.lua │ ├── lua.lua │ ├── php.lua │ ├── python.lua │ ├── ruby.lua │ ├── rust.lua │ ├── swift.lua │ ├── typescript.lua │ ├── typescriptreact.lua │ └── vue.lua │ ├── lcov.lua │ ├── parsers │ └── corbertura.lua │ ├── report.lua │ ├── signs.lua │ ├── summary.lua │ ├── util.lua │ └── watch.lua └── tests └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Lua sources 2 | luac.out 3 | 4 | # luarocks build files 5 | *.src.rock 6 | *.zip 7 | *.tar.gz 8 | 9 | # Object files 10 | *.o 11 | *.os 12 | *.ko 13 | *.obj 14 | *.elf 15 | 16 | # Precompiled Headers 17 | *.gch 18 | *.pch 19 | 20 | # Libraries 21 | *.lib 22 | *.a 23 | *.la 24 | *.lo 25 | *.def 26 | *.exp 27 | 28 | # Shared objects (inc. Windows DLLs) 29 | *.dll 30 | *.so 31 | *.so.* 32 | *.dylib 33 | 34 | # Executables 35 | *.exe 36 | *.out 37 | *.app 38 | *.i*86 39 | *.x86_64 40 | *.hex 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Andrew Thigpen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nvim-coverage 2 | 3 | Displays coverage information in the sign column. 4 | 5 | ![markers](https://user-images.githubusercontent.com/542263/159128715-32e6eddf-5f9f-4853-9e2b-abd66bbf01d4.png) 6 | 7 | Displays a coverage summary report in a pop-up window. 8 | 9 | ![summary](https://user-images.githubusercontent.com/542263/159128732-8189b89d-4f71-4a34-8c6a-176e40fcd48d.png) 10 | 11 | Currently supports: 12 | 13 | - C/C++ (lcov) 14 | - C# (lcov - see wiki for details) 15 | - Dart (lcov) 16 | - Go (coverprofile) 17 | - Javascript/Typescript (lcov): [jest](https://jestjs.io/docs/getting-started) 18 | - Julia (lcov): [Pkg.jl](https://pkgdocs.julialang.org/v1/) 19 | - Python (json): [coverage.py](https://coverage.readthedocs.io/en/6.3.2/index.html) 20 | - Ruby (json): [SimpleCov](https://github.com/simplecov-ruby/simplecov) 21 | - Rust (json): [grcov](https://github.com/mozilla/grcov#usage) 22 | - Swift (json) 23 | - PHP (cobertura) 24 | - Lua (lcov) 25 | - Anything that generates lcov files 26 | 27 | Branch (partial) coverage support: 28 | 29 | | Language | Supported | 30 | | --------------------- | ---------------------- | 31 | | C/C++ | :x: | 32 | | C# | :x: | 33 | | Dart | :heavy_check_mark: (untested) | 34 | | Go | :x: | 35 | | Javascript/Typescript | :heavy_check_mark: | 36 | | Julia | :heavy_check_mark: (untested) | 37 | | Python | :heavy_check_mark: | 38 | | Ruby | :x: | 39 | | Rust | :x: | 40 | | Swift | :x: | 41 | | PHP | :x: | 42 | | Lua | :x: | 43 | 44 | *Note:* This plugin does not run tests. It justs loads/displays a coverage report generated by a test suite. 45 | To run tests from neovim with coverage enabled, try one of these plugins: 46 | 47 | * [neotest](https://github.com/nvim-neotest/neotest) 48 | * [vim-test](https://github.com/vim-test/vim-test) 49 | 50 | ## Installation 51 | 52 | This plugin depends on [plenary](https://github.com/nvim-lua/plenary.nvim) and optionally on the 53 | [lua-xmlreader](https://luarocks.org/modules/luarocks/lua-xmlreader) luarock to parse the cobertura format. 54 | 55 | Using vim-plug (not including the luarock dependency): 56 | ```vim 57 | Plug 'nvim-lua/plenary.nvim' 58 | Plug 'andythigpen/nvim-coverage' 59 | ``` 60 | 61 | The following lua is required to configure the plugin after installation. 62 | ```lua 63 | require("coverage").setup() 64 | ``` 65 | 66 | Using packer: 67 | ```lua 68 | use({ 69 | "andythigpen/nvim-coverage", 70 | requires = "nvim-lua/plenary.nvim", 71 | -- Optional: needed for PHP when using the cobertura parser 72 | rocks = { 'lua-xmlreader' }, 73 | config = function() 74 | require("coverage").setup() 75 | end, 76 | }) 77 | ``` 78 | 79 | Using lazyvim: 80 | ```lua 81 | { 82 | "andythigpen/nvim-coverage", 83 | version = "*", 84 | config = function() 85 | require("coverage").setup({ 86 | auto_reload = true, 87 | }) 88 | end, 89 | }, 90 | ``` 91 | 92 | ## Configuration 93 | 94 | See [docs](https://github.com/andythigpen/nvim-coverage/blob/main/doc/nvim-coverage.txt) for more info. 95 | 96 | Example: 97 | 98 | ```lua 99 | require("coverage").setup({ 100 | commands = true, -- create commands 101 | highlights = { 102 | -- customize highlight groups created by the plugin 103 | covered = { fg = "#C3E88D" }, -- supports style, fg, bg, sp (see :h highlight-gui) 104 | uncovered = { fg = "#F07178" }, 105 | }, 106 | signs = { 107 | -- use your own highlight groups or text markers 108 | covered = { hl = "CoverageCovered", text = "▎" }, 109 | uncovered = { hl = "CoverageUncovered", text = "▎" }, 110 | }, 111 | summary = { 112 | -- customize the summary pop-up 113 | min_coverage = 80.0, -- minimum coverage threshold (used for highlighting) 114 | }, 115 | lang = { 116 | -- customize language specific settings 117 | }, 118 | }) 119 | ``` 120 | 121 | ## Extending to other languages 122 | 123 | 1. Create a new lua module matching the pattern `coverage.languages.` where `` matches the vim filetype for the coverage language (ex. python). 124 | 2. Implement the required methods listed below. 125 | 126 | Required methods: 127 | ```lua 128 | local M = {} 129 | 130 | --- Loads a coverage report. 131 | -- This method should perform whatever steps are necessary to generate a coverage report. 132 | -- The coverage report results should passed to the callback, which will be cached by the plugin. 133 | -- @param callback called with results of the coverage report 134 | M.load = function(callback) 135 | -- TODO: callback(results) 136 | end 137 | 138 | --- Returns a list of signs that will be placed in buffers. 139 | -- This method should use the coverage data (previously generated via the load method) to 140 | -- return a list of signs. 141 | -- @return list of signs 142 | M.sign_list = function(data) 143 | -- TODO: generate a list of signs using: 144 | -- require("coverage.signs").new_covered(bufnr, linenr) 145 | -- require("coverage.signs").new_uncovered(bufnr, linenr) 146 | end 147 | 148 | --- Returns a summary report. 149 | -- @return summary report 150 | M.summary = function(data) 151 | -- TODO: generate a summary report in the format 152 | return { 153 | files = { 154 | { -- all fields, except filename, are optional - the report will be blank if the field is nil 155 | filename = fname, -- filename displayed in the report 156 | statements = statements, -- number of total statements in the file 157 | missing = missing, -- number of lines missing coverage (uncovered) in the file 158 | excluded = excluded, -- number of lines excluded from coverage reporting in the file 159 | branches = branches, -- number of total branches in the file 160 | partial = partial_branches, -- number of branches that are partially covered in the file 161 | coverage = coverage, -- coverage percentage (float) for this file 162 | } 163 | }, 164 | totals = { -- optional 165 | statements = total_statements, -- number of total statements in the report 166 | missing = total_missing, -- number of lines missing coverage (uncovered) in the report 167 | excluded = total_excluded, -- number of lines excluded from coverage reporting in the report 168 | branches = total_branches, -- number of total branches in the report 169 | partial = total_partial_branches, -- number of branches that are partially covered in the report 170 | coverage = total_coverage, -- coverage percentage to display in the report 171 | } 172 | } 173 | end 174 | 175 | return M 176 | ``` 177 | -------------------------------------------------------------------------------- /doc/nvim-coverage.txt: -------------------------------------------------------------------------------- 1 | ================================================================================ 2 | INTRODUCTION *nvim-coverage* 3 | 4 | nvim-coverage is a plugin for displaying test coverage data in neovim. 5 | 6 | 1. Setup...............................................|nvim-coverage-setup| 7 | 2. Language specific setup........................|nvim-coverage-lang-setup| 8 | 3. Commands.........................................|nvim-coverage-commands| 9 | 4. Lua API...........................................|nvim-coverage-lua-api| 10 | 11 | ================================================================================ 12 | SETUP *nvim-coverage-setup* 13 | 14 | *coverage.setup()* 15 | coverage.setup({opts}) 16 | Initial setup function. Must be called by the user to enable the plugin. 17 | 18 | Usage: > 19 | 20 | require('coverage').setup({ 21 | -- configuration options here 22 | highlights = { 23 | -- customize highlights 24 | }, 25 | signs = { 26 | -- customize signs 27 | }, 28 | summary = { 29 | -- customize summary pop-up 30 | }, 31 | lang = { 32 | -- customize language specific settings 33 | } 34 | }) 35 | < 36 | 37 | Valid keys for {opts}: 38 | 39 | auto_reload: ~ 40 | If true, the `coverage_file` for a language will be watched for 41 | changes after executing |:CoverageLoad| or |coverage.load()|. The file watcher will be 42 | stopped after executing |:CoverageClear| or |coverage.clear()|. 43 | auto_reload_timeout_ms: ~ 44 | The number of milliseconds to wait before auto-reloading coverage 45 | after detecting a change. 46 | commands: ~ 47 | If true, create commands. See |nvim-coverage-commands|. 48 | Defaults to: `true` 49 | load_coverage_cb: ~ 50 | A lua function that will be called when a coverage file is loaded. 51 | Example: > 52 | 53 | require("coverage").setup({ 54 | load_coverage_cb = function (ftype) 55 | vim.notify("Loaded " .. ftype .. " coverage") 56 | end, 57 | }) 58 | < 59 | sign_group: ~ 60 | Name of the sign group used when placing the signs. See |sign-group|. 61 | Defaults to: `coverage` 62 | lcov_file: ~ 63 | File that the plugin will try to read lcov coverage from. 64 | Defaults to: `nil` 65 | 66 | *coverage.highlights* 67 | Valid keys for {opts.highlights}: 68 | 69 | Each highlight group supports the following keys: 70 | 71 | 'fg': Customize the foreground color. See |highlight-guifg|. 72 | 'bg': Customize the background color. See |highlight-guibg|. 73 | 'sp': Customize the special color. See |highlight-guisp|. 74 | 'link': Link the highlight group. See |:highlight-link|. 75 | 76 | covered: ~ 77 | Customize the highlight group for covered signs. 78 | Defaults to: `{ fg = "#B7F071" }` 79 | uncovered: ~ 80 | Customize the highlight group for uncovered signs. 81 | Defaults to: `{ fg = "#F07178" }` 82 | partial: ~ 83 | Customize the highlight group for partial coverage signs. 84 | Defaults to: `{ fg = "#AA71F0" }` 85 | summary_border: ~ 86 | Customize the border highlight group of the summary pop-up. 87 | Defaults to: `{ link = "FloatBorder" }` 88 | summary_normal: ~ 89 | Customize the normal text highlight group of the summary pop-up. 90 | Defaults to: `{ link = "NormalFloat" }` 91 | summary_cursor_line: ~ 92 | Customize the cursor line highlight group of the summary pop-up. 93 | Defaults to: `{ link = "CursorLine" }` 94 | summary_header: ~ 95 | Customize the header text highlight group of the summary pop-up. 96 | Defaults to: `{ style = "bold,underline", sp = "bg" }` 97 | summary_pass: ~ 98 | Customize the pass text highlight group of the summary pop-up. 99 | Defaults to: `{ link = "CoverageCovered" }` 100 | summary_fail: ~ 101 | Customize the fail text highlight group of the summary pop-up. 102 | Defaults to: `{ link = "CoverageUncovered" }` 103 | 104 | *coverage.signs* 105 | Valid keys for {opts.signs}: 106 | 107 | covered: ~ 108 | Customize the highlight group and text used for covered signs. 109 | Defaults to: `{ hl = "CoverageCovered", text = "▎" }` 110 | uncovered: ~ 111 | Customize the highlight group and text used for uncovered signs. 112 | Defaults to: `{ hl = "CoverageUncovered", text = "▎" }` 113 | partial: ~ 114 | Customize the highlight group and text used for partial coverage signs. 115 | Defaults to: `{ hl = "CoveragePartial", text = "▎" }` 116 | 117 | *coverage.summary* 118 | Valid keys for {opts.summary}: 119 | 120 | width_percentage: ~ 121 | Width of the pop-up window (0 < value <= 1.0) 122 | Defaults to: `0.75` 123 | height_percentage: ~ 124 | Height of the pop-up window (0 < value <= 1.0) 125 | Defaults to: `0.50` 126 | borders: ~ 127 | Customize the borders (see plenary window API) 128 | window: ~ 129 | Customize the window options (see plenary window API) 130 | min_coverage: ~ 131 | Minimum coverage percentage (0 < value <= 100.0). 132 | Values below this are highlighted with the fail group, values above 133 | are highlighted with the pass group. 134 | Defaults to: `80.0` 135 | 136 | *coverage.lang* 137 | Valid keys for {opts.lang}: 138 | 139 | Each key corresponds with the |filetype| of the language and maps to a 140 | table of configuration values that differ. See below for language specific 141 | options. 142 | 143 | 144 | ================================================================================ 145 | LANGUAGE SPECIFIC SETUP *nvim-coverage-lang-setup* 146 | 147 | *nvim-coverage-c* 148 | *nvim-coverage-cpp* 149 | C/C++ supports the following configuration options: 150 | 151 | coverage_file: ~ 152 | File that the plugin will try to read coverage from. 153 | Defaults to: `"report.info"` 154 | 155 | *nvim-coverage-dart* 156 | Dart supports the following configuration options: 157 | 158 | coverage_file: ~ 159 | File that the plugin will try to read coverage from. 160 | Defaults to: `"coverage/lcov.info"` 161 | 162 | *nvim-coverage-go* 163 | Go supports the following configuration options: 164 | 165 | coverage_file: ~ 166 | File that the plugin will try to read coverage from. 167 | Defaults to: `"coverage.out"` 168 | 169 | *nvim-coverage-javascript* 170 | *nvim-coverage-typescript* 171 | Javascript and Typescript share the same configuration and support the 172 | following options: 173 | 174 | coverage_file: ~ 175 | File that the plugin will try to read coverage from. 176 | Defaults to: `"coverage/lcov.info"` 177 | 178 | *nvim-coverage-julia* 179 | Julias package manager outputs code coverage files when running package tests 180 | with `Pkg.test(; coverage=true)`. The plugin processes these files using the 181 | `CoverageTools.jl` package, which is expected to be installed in the 182 | `@nvim-coverage` environment. The package can be installed with the following 183 | shell command: 184 | 185 | `julia --project=@nvim-coverage -e 'using Pkg; Pkg.add("CoverageTools")'` 186 | 187 | No other configuration should be necessary, but the following options are 188 | available: 189 | 190 | coverage_file: ~ 191 | File that the plugin will try to read coverage from. 192 | Defaults to: `"lcov.info"` 193 | coverage_command: ~ 194 | Command for creating `"lcov.info"` from individual coverage output 195 | files. The configured `coverage_file` and `directories` are passed as 196 | the first and second argument to the command. 197 | Defaults to: > 198 | 199 | julia --project=@nvim-coverage - < 234 | { 235 | ["/var/www/html"] = "" 236 | } 237 | < 238 | Defaults to: `{}` 239 | *nvim-coverage-python* 240 | Python (coverage.py) supports the following configuration options: 241 | 242 | coverage_file: ~ 243 | File that the plugin will try to read coverage from. 244 | Defaults to: `".coverage"` 245 | coverage_command: ~ 246 | This command runs to load the coverage in coverage_file and output a 247 | JSON report to stdout. 248 | Defaults to: `"coverage json -q -o -"` 249 | 250 | *nvim-coverage-ruby* 251 | Ruby (simplecov) supports the following configuration options: 252 | 253 | coverage_file: ~ 254 | File that the plugin will try to read coverage from. 255 | Defaults to: `"coverage/coverage.json"` 256 | 257 | *nvim-coverage-rust* 258 | Rust (grcov) supports the following configuration options: 259 | 260 | coverage_command: ~ 261 | This command runs to load the coverage and output a JSON report to 262 | stdout. 263 | The values `${cwd}` are replaced by the plugin with the current 264 | working directory before the command is run. 265 | Defaults to: `"grcov ${cwd} -s ${cwd} --binary-path ./target/debug/ -t coveralls --branch --ignore-not-existing --token NO_TOKEN"` 266 | project_files_only: ~ 267 | By default, grcov outputs coverage for all crates. When true, this 268 | option will add additional filters to `coverage_command` to filter for 269 | only local files. 270 | Defaults to: `true` 271 | project_files: ~ 272 | When `project_files_only` is true, these filters are added to 273 | `coverage_command`. 274 | Defaults to: `{ "src/*", "tests/*" }` 275 | 276 | *nvim-coverage-swift* 277 | Swift supports the following configuration options: 278 | 279 | coverage_file: ~ 280 | File that the plugin will try to read coverage from. 281 | Defaults to: the output of `swift test --show-codecov-path`. 282 | 283 | *nvim-coverage-lua* 284 | Lua supports the following configuration options: 285 | 286 | coverage_file: ~ 287 | File that the plugin will try to read coverage from. 288 | Defaults to: `"luacov.report.out"` 289 | 290 | 291 | ================================================================================ 292 | COMMANDS *nvim-coverage-commands* 293 | 294 | The following commands are available (when configured): 295 | 296 | :Coverage *:Coverage* 297 | Loads a coverage report and immediately displays the coverage signs. 298 | 299 | :CoverageLoad *:CoverageLoad* 300 | Loads a coverage report but does not display the coverage signs. 301 | 302 | :CoverageLoadLcov {file} *:CoverageLoadLcov* 303 | Loads a coverage report from an lcov file but does not display the coverage 304 | signs. If `file` is not specified, the `lcov_file` configuration option is used 305 | instead. 306 | 307 | :CoverageShow *:CoverageShow* 308 | Shows the coverage signs. Must call |:Coverage| or |:CoverageLoad| first. 309 | 310 | :CoverageHide *:CoverageHide* 311 | Hides the coverage signs. Must call |:Coverage| or |:CoverageLoad| first. 312 | 313 | :CoverageToggle *:CoverageToggle* 314 | Toggles the coverage signs. Must call |:Coverage| or |:CoverageLoad| first. 315 | 316 | :CoverageClear *:CoverageClear* 317 | Unloads the cached coverage signs. |:Coverage| or |:CoverageLoad| must be 318 | called again to reload the data. 319 | 320 | :CoverageSummary *:CoverageSummary* 321 | Displays a coverage summary report in a floating window. 322 | 323 | 324 | ================================================================================ 325 | LUA API *nvim-coverage-lua-api* 326 | 327 | *coverage.load()* 328 | coverage.load({place}) 329 | Loads a coverage report. When {place} is true, also place the signs in the 330 | sign column. 331 | 332 | *coverage.load_lcov()* 333 | coverage.load_lcov({file}, {place}) 334 | Loads a coverage report from an lcov file. If `file` is not specified, the 335 | `lcov_file` configuration option is used instead. When {place} is true, also 336 | place the signs in the sign column. 337 | 338 | *coverage.clear()* 339 | coverage.clear() 340 | Clears a loaded coverage report. Removes any placed signs. 341 | 342 | *coverage.show()* 343 | coverage.show() 344 | Shows coverage signs in sign column, if coverage has already been loaded. 345 | 346 | *coverage.hide()* 347 | coverage.hide() 348 | Hides coverage signs in the sign column. 349 | 350 | *coverage.toggle()* 351 | coverage.toggle() 352 | Toggles coverage signs (must already be loaded). 353 | 354 | *coverage.summary()* 355 | coverage.summary() 356 | Displays a coverage summary report in a pop-up window. 357 | 358 | *coverage.jump_next()* 359 | coverage.jump_next({sign_type}) 360 | Jumps to the next line in the current buffer with the given sign_type 361 | ("covered"|"uncovered"|"partial") 362 | 363 | *coverage.jump_prev()* 364 | coverage.jump_prev({sign_type}) 365 | Jumps to the previous line in the current buffer with the given sign_type 366 | ("covered"|"uncovered"|"partial") 367 | 368 | vim:tw=78:ts=8:ft=help:norl: 369 | -------------------------------------------------------------------------------- /lua/coverage/config.lua: -------------------------------------------------------------------------------- 1 | local M = { 2 | --- @type Configuration 3 | opts = {}, 4 | } 5 | 6 | local cached_swift_coverage_file = nil 7 | 8 | --- @class Configuration 9 | --- @field auto_reload boolean 10 | --- @field auto_reload_timeout_ms integer 11 | --- @field commands boolean 12 | --- @field highlights HighlightConfig 13 | --- @field load_coverage_cb fun(ftype: string) 14 | --- @field signs SignsConfig 15 | --- @field sign_group string name of the sign group (:h sign_placelist) 16 | --- @field summary SummaryOpts 17 | --- @field lang table 18 | local defaults = { 19 | auto_reload = false, 20 | auto_reload_timeout_ms = 500, 21 | commands = true, 22 | 23 | --- @class HighlightConfig 24 | --- @field covered Highlight 25 | --- @field uncovered Highlight 26 | --- @field partial Highlight 27 | --- @field summary_border Highlight 28 | --- @field summary_normal Highlight 29 | --- @field summary_cursor_line Highlight 30 | --- @field summary_header Highlight 31 | --- @field summary_pass Highlight 32 | --- @field summary_fail Highlight 33 | highlights = { 34 | covered = { fg = "#B7F071" }, 35 | uncovered = { fg = "#F07178" }, 36 | partial = { fg = "#AA71F0" }, 37 | summary_border = { link = "FloatBorder" }, 38 | summary_normal = { link = "NormalFloat" }, 39 | summary_cursor_line = { link = "CursorLine" }, 40 | summary_header = { style = "bold,underline", sp = "fg" }, 41 | summary_pass = { link = "CoverageCovered" }, 42 | summary_fail = { link = "CoverageUncovered" }, 43 | }, 44 | load_coverage_cb = nil, 45 | 46 | --- @class SignsConfig 47 | --- @field covered Sign 48 | --- @field uncovered Sign 49 | --- @field partial Sign 50 | signs = { 51 | covered = { hl = "CoverageCovered", text = "▎" }, 52 | uncovered = { hl = "CoverageUncovered", text = "▎" }, 53 | partial = { hl = "CoveragePartial", text = "▎" }, 54 | }, 55 | sign_group = "coverage", 56 | 57 | --- @class SummaryOpts 58 | --- @field width_percentage number 59 | --- @field height_percentage number 60 | --- @field min_coverage number 61 | summary = { 62 | width_percentage = 0.70, 63 | height_percentage = 0.50, 64 | borders = { 65 | topleft = "╭", 66 | topright = "╮", 67 | top = "─", 68 | left = "│", 69 | right = "│", 70 | botleft = "╰", 71 | botright = "╯", 72 | bot = "─", 73 | highlight = "Normal:CoverageSummaryBorder", 74 | }, 75 | window = {}, 76 | min_coverage = 80.0, 77 | }, 78 | 79 | -- language specific configuration 80 | lang = { 81 | cpp = { 82 | coverage_file = "report.info", 83 | }, 84 | cs = { 85 | coverage_file = "TestResults/lcov.info", 86 | }, 87 | dart = { 88 | coverage_file = "coverage/lcov.info", 89 | }, 90 | elixir = { 91 | coverage_file = "cover/lcov.info", 92 | }, 93 | go = { 94 | coverage_file = "coverage.out", 95 | }, 96 | java = { 97 | coverage_file = "build/reports/jacoco/test/jacocoTestReport.xml", 98 | dir_prefix = "src/main/java", 99 | }, 100 | javascript = { 101 | coverage_file = "coverage/lcov.info", 102 | }, 103 | julia = { 104 | -- See https://github.com/julia-actions/julia-processcoverage 105 | coverage_command = "julia -e '" .. [[ 106 | coverage_file = ARGS[1] 107 | directories = ARGS[2] 108 | push!(empty!(LOAD_PATH), "@nvim-coverage", "@stdlib") 109 | using CoverageTools 110 | coverage_data = FileCoverage[] 111 | for dir in split(directories, ",") 112 | isdir(dir) || continue 113 | append!(coverage_data, process_folder(dir)) 114 | clean_folder(dir) 115 | end 116 | LCOV.writefile(coverage_file, coverage_data) 117 | ]] .. "'", 118 | coverage_file = "lcov.info", 119 | directories = "src,ext", 120 | -- julia is disabled because the coverage command itself produces the file to be 121 | -- watched which leads to an infinite loop (see 122 | -- https://github.com/andythigpen/nvim-coverage/issues/41) 123 | disable_auto_reload = true, 124 | }, 125 | lua = { 126 | coverage_file = "luacov.report.out", 127 | }, 128 | python = { 129 | coverage_file = ".coverage", 130 | coverage_command = "coverage json --fail-under=0 -q -o -", 131 | only_open_buffers = false, 132 | }, 133 | ruby = { 134 | coverage_file = "coverage/coverage.json", 135 | }, 136 | rust = { 137 | coverage_command = 138 | "grcov ${cwd} -s ${cwd} --binary-path ./target/debug/ -t coveralls --branch --ignore-not-existing --token NO_TOKEN", 139 | project_files_only = true, 140 | project_files = { "src/*", "tests/*" }, 141 | }, 142 | swift = { 143 | coverage_file = function() 144 | if cached_swift_coverage_file == nil then 145 | cached_swift_coverage_file = vim.trim(vim.system({ 'swift', 'test', '--show-codecov-path' }, 146 | { text = true }) 147 | :wait() 148 | .stdout) 149 | end 150 | return cached_swift_coverage_file 151 | end 152 | }, 153 | php = { 154 | coverage_file = "coverage/cobertura.xml", 155 | path_mappings = {}, 156 | } 157 | }, 158 | lcov_file = nil, 159 | } 160 | 161 | --- Setup configuration values. 162 | M.setup = function(config) 163 | M.opts = vim.tbl_deep_extend("force", M.opts, defaults) 164 | if config ~= nil then 165 | M.opts = vim.tbl_deep_extend("force", M.opts, config) 166 | end 167 | end 168 | 169 | return M 170 | -------------------------------------------------------------------------------- /lua/coverage/highlight.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local config = require("coverage.config") 4 | 5 | --- @class Highlight 6 | --- @field fg string 7 | --- @field bg string 8 | --- @field sp string 9 | --- @field style string 10 | --- @field link? string 11 | 12 | --- @param group string name of the highlight group 13 | --- @param color Highlight 14 | local highlight = function(group, color) 15 | local style = color.style and "gui=" .. color.style or "gui=NONE" 16 | local fg = color.fg and "guifg=" .. color.fg or "guifg=NONE" 17 | local bg = color.bg and "guibg=" .. color.bg or "guibg=NONE" 18 | local sp = color.sp and "guisp=" .. color.sp or "" 19 | local hl = "highlight default " .. group .. " " .. style .. " " .. fg .. " " .. bg .. " " .. sp 20 | vim.cmd(hl) 21 | if color.link then 22 | vim.cmd("highlight default link " .. group .. " " .. color.link) 23 | end 24 | end 25 | 26 | local create_highlight_groups = function() 27 | highlight("CoverageCovered", config.opts.highlights.covered) 28 | highlight("CoverageUncovered", config.opts.highlights.uncovered) 29 | highlight("CoveragePartial", config.opts.highlights.partial) 30 | highlight("CoverageSummaryBorder", config.opts.highlights.summary_border) 31 | highlight("CoverageSummaryNormal", config.opts.highlights.summary_normal) 32 | highlight("CoverageSummaryCursorLine", config.opts.highlights.summary_cursor_line) 33 | highlight("CoverageSummaryPass", config.opts.highlights.summary_pass) 34 | highlight("CoverageSummaryFail", config.opts.highlights.summary_fail) 35 | highlight("CoverageSummaryHeader", config.opts.highlights.summary_header) 36 | end 37 | 38 | -- Creates default highlight groups. 39 | local autocmd = nil 40 | M.setup = function() 41 | create_highlight_groups() 42 | if autocmd == nil then 43 | autocmd = vim.api.nvim_create_autocmd("ColorScheme", { 44 | desc = "Add nvim-coverage highlights on colorscheme change", 45 | callback = create_highlight_groups, 46 | }) 47 | end 48 | end 49 | 50 | return M 51 | -------------------------------------------------------------------------------- /lua/coverage/init.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local config = require("coverage.config") 4 | local signs = require("coverage.signs") 5 | local highlight = require("coverage.highlight") 6 | local summary = require("coverage.summary") 7 | local report = require("coverage.report") 8 | local watch = require("coverage.watch") 9 | local lcov = require("coverage.lcov") 10 | local util = require("coverage.util") 11 | 12 | --- Setup the coverage plugin. 13 | -- Also defines signs, creates highlight groups. 14 | -- @param config options 15 | M.setup = function(user_opts) 16 | config.setup(user_opts) 17 | signs.setup() 18 | highlight.setup() 19 | 20 | -- add commands 21 | if config.opts.commands then 22 | vim.cmd([[ 23 | command! Coverage lua require('coverage').load(true) 24 | command! CoverageLoad lua require('coverage').load() 25 | command! -nargs=? CoverageLoadLcov lua require('coverage').load_lcov() 26 | command! CoverageShow lua require('coverage').show() 27 | command! CoverageHide lua require('coverage').hide() 28 | command! CoverageToggle lua require('coverage').toggle() 29 | command! CoverageClear lua require('coverage').clear() 30 | command! CoverageSummary lua require('coverage').summary() 31 | ]]) 32 | end 33 | end 34 | 35 | --- Loads a coverage report but does not place signs. 36 | --- @param place boolean true to immediately place signs 37 | M.load = function(place) 38 | local ftype = vim.bo.filetype 39 | 40 | local ok, lang = pcall(require, "coverage.languages." .. ftype) 41 | if not ok then 42 | vim.notify("Coverage report not available for filetype " .. ftype) 43 | return 44 | end 45 | 46 | local load_lang = function() 47 | lang.load(function(result) 48 | if config.opts.load_coverage_cb ~= nil then 49 | vim.schedule(function() 50 | config.opts.load_coverage_cb(ftype) 51 | end) 52 | end 53 | report.cache(result, ftype) 54 | local sign_list = lang.sign_list(result) 55 | if place or signs.is_enabled() then 56 | signs.place(sign_list) 57 | else 58 | signs.cache(sign_list) 59 | end 60 | end) 61 | end 62 | 63 | local lang_config = config.opts.lang[ftype] 64 | if lang_config == nil then 65 | lang_config = config.opts.lang[lang.config_alias] 66 | end 67 | 68 | -- Automatically watch the coverage file for updates when auto_reload is enabled 69 | -- and when the language setup allows it 70 | if config.opts.auto_reload and 71 | lang_config ~= nil and 72 | lang_config.coverage_file ~= nil and 73 | not lang_config.disable_auto_reload then 74 | local coverage_file = util.get_coverage_file(lang_config.coverage_file) 75 | watch.start(coverage_file, load_lang) 76 | end 77 | 78 | signs.clear() 79 | load_lang() 80 | end 81 | 82 | -- Load an lcov file 83 | M.load_lcov = lcov.load_lcov 84 | 85 | -- Shows signs, if loaded. 86 | M.show = signs.show 87 | 88 | -- Hides signs. 89 | M.hide = signs.unplace 90 | 91 | --- Toggles signs. 92 | M.toggle = signs.toggle 93 | 94 | --- Hides and clears cached signs. 95 | M.clear = function() 96 | signs.clear() 97 | watch.stop() 98 | end 99 | 100 | --- Displays a pop-up with a coverage summary report. 101 | M.summary = summary.show 102 | 103 | --- Jumps to the next sign of the given type. 104 | --- @param sign_type? "covered"|"uncovered"|"partial" Defaults to "covered" 105 | M.jump_next = function(sign_type) 106 | signs.jump(sign_type, 1) 107 | end 108 | 109 | --- Jumps to the previous sign of the given type. 110 | --- @param sign_type? "covered"|"uncovered"|"partial" Defaults to "covered" 111 | M.jump_prev = function(sign_type) 112 | signs.jump(sign_type, -1) 113 | end 114 | 115 | return M 116 | -------------------------------------------------------------------------------- /lua/coverage/languages/c.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | -- CPP and C currently use the exact same configuration. 4 | local cpp = require("coverage.languages.cpp") 5 | 6 | --- Use the same configuration as CPP 7 | M.config_alias = "cpp" 8 | 9 | --- Returns a list of signs to be placed. 10 | M.sign_list = cpp.sign_list 11 | 12 | --- Returns a summary report. 13 | M.summary = cpp.summary 14 | 15 | --- Loads a coverage report. 16 | M.load = cpp.load 17 | 18 | return M 19 | -------------------------------------------------------------------------------- /lua/coverage/languages/common.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local signs = require("coverage.signs") 4 | 5 | --- @class FileCoverage 6 | --- @field excluded_lines integer[] line numbers excluded from the coverage report 7 | --- @field executed_lines integer[] line numbers executed under test 8 | --- @field missing_lines integer[] line numbers not executed under test 9 | --- @field missing_branches integer[][]|nil line numbers partially executed under test 10 | --- @field summary CoverageSummary 11 | 12 | --- @class CoverageSummary 13 | --- @field covered_lines integer total number of covered lines 14 | --- @field missing_lines integer total number of uncovered lines 15 | --- @field excluded_lines integer total number of excluded lines 16 | --- @field num_branches integer total number of branches 17 | --- @field num_partial_branches integer total number of partially covered branches 18 | --- @field num_statements integer total number of statements 19 | --- @field percent_covered number percentage of covered lines to total statements 20 | 21 | --- @class CoverageData 22 | --- @field files table 23 | --- @field totals CoverageSummary 24 | 25 | --- Returns a list of signs to be placed. 26 | --- @param json_data CoverageData data from the generated report 27 | --- @returns SignPlace[] 28 | M.sign_list = function(json_data) 29 | --- @type SignPlace[] 30 | local sign_list = {} 31 | for fname, cov in pairs(json_data.files) do 32 | local buffer = vim.fn.bufnr(fname, false) 33 | if buffer ~= -1 then 34 | -- group missing branches by `from` line number 35 | local missing_branches_from = {} 36 | if cov.missing_branches ~= nil then 37 | for _, branch in ipairs(cov.missing_branches) do 38 | -- branch is { from, to } 39 | table.insert(missing_branches_from, branch[1]) 40 | end 41 | end 42 | 43 | for _, lnum in ipairs(cov.executed_lines) do 44 | -- a line cannot be fully covered if there are executed missing branches from it 45 | if not vim.tbl_contains(missing_branches_from, lnum) then 46 | table.insert(sign_list, signs.new_covered(buffer, lnum)) 47 | end 48 | end 49 | 50 | for _, lnum in ipairs(cov.missing_lines) do 51 | table.insert(sign_list, signs.new_uncovered(buffer, lnum)) 52 | end 53 | 54 | for _, lnum in ipairs(missing_branches_from) do 55 | -- if from is a missing_line, all branches are missing coverage so we can ignore here 56 | -- otherwise, if the line is not missing but branches are, then the line is partially coverged 57 | if not vim.tbl_contains(cov.missing_lines, lnum) then 58 | table.insert(sign_list, signs.new_partial(buffer, lnum)) 59 | end 60 | end 61 | end 62 | end 63 | return sign_list 64 | end 65 | 66 | --- Returns a summary report. 67 | --- @param json_data CoverageData 68 | M.summary = function(json_data) 69 | local files = {} 70 | local totals = { 71 | statements = json_data.totals.num_statements, 72 | missing = json_data.totals.missing_lines, 73 | excluded = json_data.totals.excluded_lines, 74 | branches = json_data.totals.num_branches, 75 | partial = json_data.totals.num_partial_branches, 76 | coverage = json_data.totals.percent_covered, 77 | } 78 | for fname, cov in pairs(json_data.files) do 79 | table.insert(files, { 80 | filename = fname, 81 | statements = cov.summary.num_statements, 82 | missing = cov.summary.missing_lines, 83 | excluded = cov.summary.excluded_lines, 84 | branches = cov.summary.num_branches, 85 | partial = cov.summary.num_partial_branches, 86 | coverage = cov.summary.percent_covered, 87 | }) 88 | end 89 | return { 90 | files = files, 91 | totals = totals, 92 | } 93 | end 94 | 95 | return M 96 | -------------------------------------------------------------------------------- /lua/coverage/languages/cpp.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local Path = require("plenary.path") 4 | local common = require("coverage.languages.common") 5 | local config = require("coverage.config") 6 | local util = require("coverage.util") 7 | 8 | --- Returns a list of signs to be placed. 9 | M.sign_list = common.sign_list 10 | 11 | --- Returns a summary report. 12 | M.summary = common.summary 13 | 14 | --- Loads a coverage report. 15 | -- @param callback called with the results of the coverage report 16 | M.load = function(callback) 17 | local cpp_config = config.opts.lang.cpp 18 | local p = Path:new(util.get_coverage_file(cpp_config.coverage_file)) 19 | if not p:exists() then 20 | vim.notify("No coverage file exists.", vim.log.levels.INFO) 21 | return 22 | end 23 | 24 | callback(util.lcov_to_table(p)) 25 | end 26 | 27 | return M 28 | -------------------------------------------------------------------------------- /lua/coverage/languages/cs.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local Path = require("plenary.path") 4 | local common = require("coverage.languages.common") 5 | local config = require("coverage.config") 6 | local util = require("coverage.util") 7 | 8 | --- Returns a list of signs to be placed. 9 | M.sign_list = common.sign_list 10 | 11 | --- Returns a summary report. 12 | M.summary = common.summary 13 | 14 | --- Loads a coverage report. 15 | -- @param callback called with the results of the coverage report 16 | M.load = function(callback) 17 | local cs_config = config.opts.lang.cs 18 | local p = Path:new(util.get_coverage_file(cs_config.coverage_file)) 19 | if not p:exists() then 20 | vim.notify("No coverage file exists.", vim.log.levels.INFO) 21 | return 22 | end 23 | 24 | local result = util.lcov_to_table(p); 25 | callback(result) 26 | end 27 | 28 | return M 29 | -------------------------------------------------------------------------------- /lua/coverage/languages/dart.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local Path = require("plenary.path") 4 | local common = require("coverage.languages.common") 5 | local config = require("coverage.config") 6 | local util = require("coverage.util") 7 | 8 | --- Returns a list of signs to be placed. 9 | M.sign_list = common.sign_list 10 | 11 | --- Returns a summary report. 12 | M.summary = common.summary 13 | 14 | --- Loads a coverage report. 15 | -- @param callback called with the results of the coverage report 16 | M.load = function(callback) 17 | local dart_config = config.opts.lang.dart 18 | local p = Path:new(util.get_coverage_file(dart_config.coverage_file)) 19 | if not p:exists() then 20 | vim.notify("No coverage file exists.", vim.log.levels.INFO) 21 | return 22 | end 23 | 24 | callback(util.lcov_to_table(p)) 25 | end 26 | 27 | return M 28 | -------------------------------------------------------------------------------- /lua/coverage/languages/elixir.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local Path = require("plenary.path") 4 | local common = require("coverage.languages.common") 5 | local config = require("coverage.config") 6 | local util = require("coverage.util") 7 | 8 | --- Returns a list of signs to be placed. 9 | M.sign_list = common.sign_list 10 | 11 | --- Returns a summary report. 12 | M.summary = common.summary 13 | 14 | --- Loads a coverage report. 15 | -- @param callback called with the results of the coverage report 16 | M.load = function(callback) 17 | local elixir_config = config.opts.lang.elixir 18 | local p = Path:new(util.get_coverage_file(elixir_config.coverage_file)) 19 | if not p:exists() then 20 | vim.notify("No coverage file exists.", vim.log.levels.INFO) 21 | return 22 | end 23 | 24 | callback(util.lcov_to_table(p)) 25 | end 26 | 27 | return M 28 | -------------------------------------------------------------------------------- /lua/coverage/languages/go.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local Path = require("plenary.path") 4 | local common = require("coverage.languages.common") 5 | local config = require("coverage.config") 6 | local util = require("coverage.util") 7 | 8 | --- Returns a list of signs to be placed. 9 | M.sign_list = common.sign_list 10 | 11 | --- Returns a summary report. 12 | M.summary = common.summary 13 | 14 | -- the fields are: name.go:line.column,line.column numberOfStatements count 15 | -- see https://github.com/golang/go/blob/0104a31b8fbcbe52728a08867b26415d282c35d2/src/cmd/cover/profile.go#L115 16 | -- and https://github.com/golang/go/blob/master/src/testing/cover.go#L102 17 | local line_re = "^(.+):(%d+)%.%d+,(%d+)%.%d+ (%d+) (%d+)$" 18 | 19 | -- for parsing the module name from go.mod 20 | local mod_name_re = "^module (.*)$" 21 | 22 | local get_module_name = function() 23 | local p = Path:new(vim.fn.expand("%:p")):find_upwards("go.mod") 24 | if p == "" then 25 | return "" 26 | end 27 | local lines = p:readlines() 28 | for _, line in ipairs(lines) do 29 | if line:match(mod_name_re) then 30 | local name = line:match(mod_name_re) 31 | name = name:gsub("%-", "%%-") 32 | name = name:gsub("%.", "%%.") 33 | name = name:gsub("%+", "%%+") 34 | name = name:gsub("%?", "%%?") 35 | return name 36 | end 37 | end 38 | return "" 39 | end 40 | 41 | --- Parses a coverprofile formatted file 42 | --- @param path Path 43 | --- @param files table 44 | local parse_coverprofile = function(path, files) 45 | local lines_by_filename = {} 46 | local lines = path:readlines() 47 | local mod_name = get_module_name() 48 | for _, line in ipairs(lines) do 49 | if line:match("mode:.*") then 50 | -- do nothing 51 | elseif line:match(line_re) then 52 | -- example/main.go:3.14,5.2 0 0 53 | local fname, line_start, line_end, _, count = line:match(line_re) 54 | fname = fname:gsub(mod_name .. "/", "", 1) 55 | line_start = tonumber(line_start) 56 | line_end = tonumber(line_end) 57 | count = tonumber(count) 58 | if lines_by_filename[fname] == nil then 59 | lines_by_filename[fname] = {} 60 | end 61 | for linenr = line_start, line_end do 62 | lines_by_filename[fname][linenr] = (lines_by_filename[fname][linenr] or 0) + count 63 | end 64 | else 65 | -- do nothing 66 | end 67 | end 68 | 69 | for fname, linenrs in pairs(lines_by_filename) do 70 | local file = util.new_file_meta() 71 | for linenr, count in pairs(linenrs) do 72 | if count == 0 then 73 | table.insert(file.missing_lines, linenr) 74 | else 75 | table.insert(file.executed_lines, linenr) 76 | file.summary.covered_lines = file.summary.covered_lines + 1 77 | end 78 | file.summary.num_statements = file.summary.num_statements + 1 79 | end 80 | file.summary.percent_covered = file.summary.covered_lines / file.summary.num_statements * 100 81 | files[fname] = file 82 | end 83 | end 84 | 85 | --- Loads a coverage report. 86 | --- @param callback function called with the results of the coverage report 87 | M.load = function(callback) 88 | local go_config = config.opts.lang.go 89 | local p = Path:new(util.get_coverage_file(go_config.coverage_file)) 90 | if not p:exists() then 91 | vim.notify("No coverage file exists.", vim.log.levels.INFO) 92 | return 93 | end 94 | 95 | callback(util.report_to_table(p, parse_coverprofile)) 96 | end 97 | 98 | return M 99 | -------------------------------------------------------------------------------- /lua/coverage/languages/java.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local Path = require "plenary.path" 4 | local config = require "coverage.config" 5 | local util = require "coverage.util" 6 | local cs = require "coverage.signs" 7 | local lom = require "neotest.lib.xml" 8 | 9 | --- Loads a coverage report. 10 | -- @param callback called with results of the coverage report 11 | M.load = function(callback) 12 | -- Try and load file 13 | local opt = config.opts.lang.java.coverage_file 14 | local p = Path:new(util.get_coverage_file(opt)) 15 | if not p:exists() then 16 | vim.notify("No coverage file exists.", vim.log.levels.INFO) 17 | return 18 | end 19 | 20 | local dir_prefix = Path:new(config.opts.lang.java.dir_prefix .. "/").filename 21 | 22 | -- Parse into object 23 | local jacoco = lom.parse(table.concat(vim.fn.readfile(p.filename), "")) 24 | 25 | -- Failed to parse, ignore. 26 | if not jacoco then 27 | vim.notify "Error loading XML" 28 | return nil 29 | end 30 | 31 | -- Load xml 32 | local data = { 33 | files = {}, 34 | totals = {}, 35 | } 36 | 37 | local get_attr_by_type_name = function(tag, type_name) 38 | if not tag then 39 | return nil 40 | end 41 | for _, value in ipairs(tag) do 42 | if value._attr.type == type_name then 43 | return value._attr 44 | end 45 | end 46 | return nil 47 | end 48 | 49 | -- Global stats 50 | -- obtains the total counters 51 | local counter = assert(jacoco.report.counter, "not able to readjacoco.report.counter") 52 | 53 | local global_lines = get_attr_by_type_name(counter, "LINE") 54 | if global_lines then 55 | data.totals.line = { 56 | covered = tonumber(global_lines.covered), 57 | missed = tonumber(global_lines.missed), 58 | } 59 | end 60 | 61 | local branch = get_attr_by_type_name(counter, "BRANCH") 62 | if branch then 63 | data.totals.branch = { 64 | covered = tonumber(branch.covered), 65 | missed = tonumber(branch.missed), 66 | } 67 | end 68 | 69 | 70 | -- obtains fine grained data 71 | local packages = assert(jacoco.report.package, "not able to read jacoco.report.package") 72 | assert(type(packages) == "table") 73 | for _, pack in ipairs(packages) do 74 | local dir = dir_prefix .. pack._attr.name 75 | 76 | -- classes 77 | for _, class in ipairs(pack.class) do 78 | local filename = Path:new(dir .. "/" .. class._attr.sourcefilename).filename -- with .java 79 | 80 | -- set file total counters 81 | local file_total_lines = get_attr_by_type_name(class.counter, "LINE") 82 | local file_total_branches = get_attr_by_type_name(class.counter, "BRANCH") 83 | data.files[filename] = { 84 | lines = {}, 85 | totals = { 86 | line = { 87 | covered = file_total_lines and file_total_lines.covered or 0, 88 | missed = file_total_lines and file_total_lines.missed or 0 89 | }, 90 | branch = { 91 | covered = file_total_branches and file_total_branches.covered or 0, 92 | missed = file_total_branches and file_total_branches.missed or 0 93 | }, 94 | }, 95 | } 96 | 97 | 98 | end 99 | 100 | for _, src_file in ipairs(pack.sourcefile) do 101 | local lines = src_file.line 102 | -- So, jacoco reports in terms of instructions 103 | -- which is neat, but not uh that useful for this purpose. 104 | -- I'll mark any sort of missing instructions as missed lines, 105 | -- iff no instructions were missed, check if any were covered. 106 | -- Also,, it doesn't really specify if stuff is mutually exclusive or not. 107 | -- The priority will be 108 | -- 1. Missed branch 109 | -- 2. Missed instruction (as line) 110 | -- 3. Covered branch 111 | -- 4. Covered instruction (as line) 112 | if lines then 113 | for _, line in ipairs(lines) do 114 | local line_number = assert(tonumber(line._attr.nr)) 115 | local filename = Path:new(dir .. "/" .. src_file._attr.name).filename 116 | 117 | local mb = assert(line._attr.mb) ~= "0" 118 | local mi = assert(line._attr.mi) ~= "0" 119 | local cb = assert(line._attr.cb) ~= "0" 120 | local ci = assert(line._attr.ci) ~= "0" 121 | 122 | if mb and cb or mi and ci then 123 | data.files[filename].lines[line_number] = "partial" 124 | elseif mb or mi then 125 | data.files[filename].lines[line_number] = "missed" 126 | else 127 | data.files[filename].lines[line_number] = "covered" 128 | end 129 | end 130 | end 131 | end 132 | 133 | end 134 | 135 | callback(data) 136 | end 137 | 138 | --- Returns a list of signs that will be placed in buffers. 139 | -- This method should use the coverage data (previously generated via the load method) to 140 | -- return a list of signs. 141 | -- @return list of signs 142 | M.sign_list = function(data) 143 | local signs = {} 144 | local funcs = { 145 | covered = cs.new_covered, 146 | partial = cs.new_partial, 147 | missed = cs.new_uncovered, 148 | } 149 | for fn, fdata in pairs(data.files) do 150 | local bufnr = vim.fn.bufnr(fn, false) 151 | -- Only do loaded buffers 152 | if bufnr ~= -1 then 153 | for lnum, what in pairs(fdata.lines) do 154 | table.insert(signs, funcs[what](bufnr, lnum)) 155 | end 156 | end 157 | end 158 | 159 | return signs 160 | end 161 | 162 | --- Returns a summary report. 163 | -- @return summary report 164 | M.summary = function(data) 165 | local report = { files = {} } 166 | for fn, fdata in pairs(data.files) do 167 | local statements = fdata.totals.line.covered + fdata.totals.line.missed 168 | local rep = { 169 | filename = fn, 170 | statements = statements, 171 | missing = fdata.totals.line.missed, 172 | branches = fdata.totals.branch.covered + fdata.totals.branch.missed, 173 | partial = fdata.totals.branch.missed, 174 | coverage = (1 - fdata.totals.line.missed / statements) * 100, 175 | } 176 | -- Avoid nan 177 | if statements == 0 then 178 | rep.coverage = 100 179 | end 180 | table.insert(report.files, rep) 181 | end 182 | 183 | report.totals = { 184 | statements = data.totals.line.covered + data.totals.line.missed, 185 | missing = data.totals.line.missed, 186 | branches = data.totals.branch.covered + data.totals.branch.missed, 187 | partial = data.totals.branch.missed, 188 | } 189 | if report.totals.statements == 0 then 190 | report.totals.coverage = 100 191 | else 192 | report.totals.coverage = (1 - report.totals.missing / report.totals.statements) * 100 193 | end 194 | 195 | return report 196 | end 197 | 198 | return M 199 | -------------------------------------------------------------------------------- /lua/coverage/languages/javascript.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local Path = require("plenary.path") 4 | local common = require("coverage.languages.common") 5 | local config = require("coverage.config") 6 | local util = require("coverage.util") 7 | 8 | --- Returns a list of signs to be placed. 9 | M.sign_list = common.sign_list 10 | 11 | --- Returns a summary report. 12 | M.summary = common.summary 13 | 14 | --- Loads a coverage report. 15 | -- @param callback called with results of the coverage report 16 | M.load = function(callback) 17 | local javascript_config = config.opts.lang.javascript 18 | local p = Path:new(util.get_coverage_file(javascript_config.coverage_file)) 19 | if not p:exists() then 20 | vim.notify("No coverage file exists.", vim.log.levels.INFO) 21 | return 22 | end 23 | 24 | callback(util.lcov_to_table(p)) 25 | end 26 | 27 | return M 28 | -------------------------------------------------------------------------------- /lua/coverage/languages/javascriptreact.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | -- Javascript and Typescript currently use the exact same configuration. 4 | local javascript = require("coverage.languages.javascript") 5 | 6 | --- Use the same configuration as javascript 7 | M.config_alias = "javascript" 8 | 9 | --- Returns a list of signs to be placed. 10 | M.sign_list = javascript.sign_list 11 | 12 | --- Returns a summary report. 13 | M.summary = javascript.summary 14 | 15 | --- Loads a coverage report. 16 | M.load = javascript.load 17 | 18 | return M 19 | -------------------------------------------------------------------------------- /lua/coverage/languages/julia.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local Path = require("plenary.path") 4 | local common = require("coverage.languages.common") 5 | local config = require("coverage.config") 6 | local util = require("coverage.util") 7 | 8 | --- Returns a list of signs to be placed. 9 | M.sign_list = common.sign_list 10 | 11 | --- Returns a summary report. 12 | M.summary = common.summary 13 | 14 | --- Loads a coverage report. 15 | -- @param callback called with the results of the coverage report 16 | M.load = function(callback) 17 | local julia_config = config.opts.lang.julia 18 | 19 | -- Run the coverage command to construct the lcov.info file 20 | local cmd = julia_config.coverage_command 21 | cmd = cmd .. " -- " .. julia_config.coverage_file 22 | cmd = cmd .. " '" .. julia_config.directories .. "'" 23 | local stderr = "" 24 | local jobid = vim.fn.jobstart(cmd, { 25 | on_stderr = vim.schedule_wrap(function(_, data, _) 26 | for _, line in ipairs(data) do 27 | stderr = stderr .. line 28 | end 29 | end), 30 | on_exit = vim.schedule_wrap(function(_, rc, _) 31 | if rc ~= 0 then 32 | if stderr:match("Package CoverageTools not found in current path") then 33 | local msg = "Package CoverageTools not found in current path: " 34 | .. "Is it installed in the expected environment? You can install it " 35 | .. "with the following shell command: " 36 | .. "julia --project=@nvim-coverage -e 'using Pkg; Pkg.add(\"CoverageTools\")'" 37 | vim.notify(msg, vim.log.levels.ERROR) 38 | else 39 | vim.notify(stderr, vim.log.levels.ERROR) 40 | end 41 | return 42 | end 43 | end), 44 | }) 45 | for _, rc in ipairs(vim.fn.jobwait({ jobid })) do 46 | if rc ~= 0 then 47 | return 48 | end 49 | end 50 | 51 | -- Check if the process above resulted in the file as expected 52 | local p = Path:new(util.get_coverage_file(julia_config.coverage_file)) 53 | if not p:exists() then 54 | vim.notify("No coverage data file exists.", vim.log.levels.INFO) 55 | return 56 | end 57 | 58 | -- Parse the lcov file to table and pass to the callback 59 | callback(util.lcov_to_table(p)) 60 | end 61 | 62 | return M 63 | -------------------------------------------------------------------------------- /lua/coverage/languages/lua.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local Path = require("plenary.path") 4 | local common = require("coverage.languages.common") 5 | local config = require("coverage.config") 6 | local util = require("coverage.util") 7 | 8 | M.sign_list = common.sign_list 9 | 10 | M.summary = common.summary 11 | 12 | M.load = function(callback) 13 | local lua_config = config.opts.lang.lua 14 | local p = Path:new(util.get_coverage_file(lua_config.coverage_file)) 15 | if not p:exists() then 16 | vim.notify("No coverage file exists.", vim.log.levels.INFO) 17 | end 18 | callback(util.lcov_to_table(p)) 19 | end 20 | 21 | return M 22 | -------------------------------------------------------------------------------- /lua/coverage/languages/php.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local Path = require("plenary.path") 4 | local common = require("coverage.languages.common") 5 | local config = require("coverage.config") 6 | local util = require("coverage.util") 7 | 8 | --- Returns a list of signs to be placed. 9 | M.sign_list = common.sign_list 10 | 11 | --- Returns a summary report. 12 | M.summary = common.summary 13 | 14 | --- Loads a coverage report. 15 | -- @param callback called with the results of the coverage report 16 | M.load = function(callback) 17 | local php_config = config.opts.lang.php 18 | local p = Path:new(util.get_coverage_file(php_config.coverage_file)) 19 | if not p:exists() then 20 | vim.notify("No coverage file exists.", vim.log.levels.INFO) 21 | return 22 | end 23 | 24 | callback(util.cobertura_to_table(p, php_config.path_mappings or {})) 25 | end 26 | 27 | return M 28 | -------------------------------------------------------------------------------- /lua/coverage/languages/python.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local Path = require("plenary.path") 4 | local common = require("coverage.languages.common") 5 | local config = require("coverage.config") 6 | local util = require("coverage.util") 7 | 8 | local is_pipenv = function() 9 | return vim.fn.filereadable("Pipfile") ~= 0 10 | end 11 | 12 | --- Returns a list of signs to be placed. 13 | M.sign_list = common.sign_list 14 | 15 | --- Returns a summary report. 16 | M.summary = common.summary 17 | 18 | M.buffer_is_valid = function(buf_id, buf_name) 19 | return 1 == vim.fn.buflisted(buf_id) and buf_name ~= "" 20 | end 21 | 22 | --- Loads a coverage report. 23 | -- @param callback called with the results of the coverage report 24 | M.load = function(callback) 25 | local python_config = config.opts.lang.python 26 | local p = Path:new(util.get_coverage_file(python_config.coverage_file)) 27 | if not p:exists() then 28 | vim.notify("No coverage data file exists.", vim.log.levels.INFO) 29 | return 30 | end 31 | 32 | local cmd = python_config.coverage_command 33 | cmd = cmd .. " --data-file=" .. tostring(p) 34 | if python_config.only_open_buffers then 35 | local includes = {} 36 | local buffers = vim.api.nvim_list_bufs() 37 | for idx = 1, #buffers do 38 | local buf_id = buffers[idx] 39 | local buf_name = vim.api.nvim_buf_get_name(buf_id) 40 | -- if buffer is listed, then add to the includes list 41 | if M.buffer_is_valid(buf_id, buf_name) then 42 | table.insert(includes, buf_name) 43 | end 44 | end 45 | if next(includes) ~= nil then 46 | cmd = cmd .. " --include=" .. table.concat(includes, ",") 47 | end 48 | end 49 | if is_pipenv() then 50 | cmd = "pipenv run " .. cmd 51 | end 52 | 53 | local stdout = "" 54 | local stderr = "" 55 | vim.fn.jobstart(cmd, { 56 | on_stdout = vim.schedule_wrap(function(_, data, _) 57 | for _, line in ipairs(data) do 58 | stdout = stdout .. line 59 | end 60 | end), 61 | on_stderr = vim.schedule_wrap(function(_, data, _) 62 | for _, line in ipairs(data) do 63 | stderr = stderr .. line 64 | end 65 | end), 66 | on_exit = vim.schedule_wrap(function(_, exit_code) 67 | if exit_code ~= 0 then 68 | if #stderr == 0 then 69 | stderr = "Failed to generate coverage" 70 | end 71 | vim.notify(stderr, vim.log.levels.ERROR) 72 | return 73 | elseif #stderr > 0 then 74 | vim.notify(stderr, vim.log.levels.WARN) 75 | end 76 | if stdout == "No data to report." then 77 | vim.notify(stdout, vim.log.levels.INFO) 78 | return 79 | end 80 | util.safe_decode(stdout, callback) 81 | end), 82 | }) 83 | end 84 | 85 | return M 86 | -------------------------------------------------------------------------------- /lua/coverage/languages/ruby.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local Path = require("plenary.path") 4 | local config = require("coverage.config") 5 | local signs = require("coverage.signs") 6 | local util = require("coverage.util") 7 | 8 | --- Returns a list of signs to be placed. 9 | -- @param json_data from the generated report 10 | M.sign_list = function(json_data) 11 | local sign_list = {} 12 | for fname, cov in pairs(json_data.coverage) do 13 | local p = Path:new(fname) 14 | local buffer = vim.fn.bufnr(p:make_relative(), false) 15 | if buffer ~= -1 then 16 | for linenr, status in ipairs(cov.lines) do 17 | local s = nil 18 | if status ~= nil and status ~= vim.NIL and status >= 1 then 19 | s = signs.new_covered(buffer, linenr) 20 | elseif status == 0 then 21 | s = signs.new_uncovered(buffer, linenr) 22 | end 23 | if s ~= nil then 24 | table.insert(sign_list, s) 25 | end 26 | end 27 | end 28 | end 29 | return sign_list 30 | end 31 | 32 | --- Loads a coverage report. 33 | -- @param callback called with the list of signs from the coverage report 34 | M.load = function(callback) 35 | local ruby_config = config.opts.lang.ruby 36 | local p = Path:new(util.get_coverage_file(ruby_config.coverage_file)) 37 | if not p:exists() then 38 | vim.notify("No coverage file exists.", vim.log.levels.INFO) 39 | return 40 | end 41 | p:read(vim.schedule_wrap(function(data) 42 | util.safe_decode(data, callback) 43 | end)) 44 | end 45 | 46 | --- Returns a summary report. 47 | M.summary = function(json_data) 48 | local totals = { 49 | statements = 0, 50 | missing = 0, 51 | excluded = nil, -- simplecov JSON report doesn't have this information 52 | branches = nil, 53 | partial = nil, 54 | coverage = 0, 55 | } 56 | local files = {} 57 | for fname, cov in pairs(json_data.coverage) do 58 | local statements = 0 59 | local missing = 0 60 | for _, status in ipairs(cov.lines) do 61 | totals.statements = totals.statements + 1 62 | statements = statements + 1 63 | if status == 0 then 64 | totals.missing = totals.missing + 1 65 | missing = missing + 1 66 | end 67 | end 68 | table.insert(files, { 69 | filename = Path:new(fname):make_relative(), 70 | statements = statements, 71 | missing = missing, 72 | excluded = nil, -- simplecov JSON report doesn't have this information 73 | branches = nil, 74 | partial = nil, 75 | coverage = ((statements - missing) / statements) * 100.0, 76 | }) 77 | end 78 | totals.coverage = ((totals.statements - totals.missing) / totals.statements) * 100.0 79 | return { 80 | files = files, 81 | totals = totals, 82 | } 83 | end 84 | 85 | return M 86 | -------------------------------------------------------------------------------- /lua/coverage/languages/rust.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local Path = require("plenary.path") 4 | local config = require("coverage.config") 5 | local signs = require("coverage.signs") 6 | local util = require("coverage.util") 7 | 8 | --- Returns a list of signs to be placed. 9 | -- @param json_data from the generated report 10 | M.sign_list = function(json_data) 11 | local sign_list = {} 12 | for _, file in ipairs(json_data.source_files) do 13 | local fname = Path:new(file.name):make_relative() 14 | local buffer = vim.fn.bufnr(fname, false) 15 | if buffer ~= -1 then 16 | for linenr, hits in ipairs(file.coverage) do 17 | if hits ~= nil and hits ~= vim.NIL then 18 | if hits > 0 then 19 | table.insert(sign_list, signs.new_covered(buffer, linenr)) 20 | elseif hits == 0 then 21 | table.insert(sign_list, signs.new_uncovered(buffer, linenr)) 22 | end 23 | end 24 | end 25 | end 26 | end 27 | return sign_list 28 | end 29 | 30 | --- Returns a summary report. 31 | M.summary = function(json_data) 32 | local totals = { 33 | statements = 0, 34 | missing = 0, 35 | excluded = nil, 36 | branches = 0, 37 | partial = 0, 38 | coverage = 0, 39 | } 40 | local files = {} 41 | for _, file in ipairs(json_data.source_files) do 42 | local statements = 0 43 | local missing = 0 44 | local branches = 0 45 | local partial = 0 46 | local fname = Path:new(file.name):make_relative() 47 | for _, hits in ipairs(file.coverage) do 48 | totals.statements = totals.statements + 1 49 | statements = statements + 1 50 | if hits == 0 then 51 | totals.missing = totals.missing + 1 52 | missing = missing + 1 53 | end 54 | end 55 | for i = 1, #file.branches - 1, 4 do 56 | -- format: [line-number, block-number, branch-number, hits] 57 | local hits = file.branches[i + 3] 58 | totals.branches = totals.branches + 1 59 | branches = branches + 1 60 | if hits == 0 then 61 | totals.partial = totals.partial + 1 62 | partial = partial + 1 63 | end 64 | end 65 | table.insert(files, { 66 | filename = fname, 67 | statements = statements, 68 | missing = missing, 69 | excluded = nil, 70 | branches = branches, 71 | partial = partial, 72 | coverage = ((statements + branches - missing - partial) / (statements + branches)) * 100.0, 73 | }) 74 | end 75 | totals.coverage = ( 76 | (totals.statements + totals.branches - totals.missing - totals.partial) 77 | / (totals.statements + totals.branches) 78 | ) * 100.0 79 | return { 80 | files = files, 81 | totals = totals, 82 | } 83 | end 84 | 85 | -- From http://lua-users.org/wiki/StringInterpolation. 86 | local interp = function(s, tab) 87 | return (s:gsub("($%b{})", function(w) 88 | return tab[w:sub(3, -2)] or w 89 | end)) 90 | end 91 | 92 | --- Loads a coverage report. 93 | -- @param callback called with the results of the coverage report 94 | M.load = function(callback) 95 | local rust_config = config.opts.lang.rust 96 | local cwd = vim.fn.getcwd() 97 | local cmd = rust_config.coverage_command 98 | if rust_config.project_files_only then 99 | for _, pattern in ipairs(rust_config.project_files) do 100 | cmd = cmd .. " --keep-only '" .. pattern .. "'" 101 | end 102 | end 103 | cmd = interp(cmd, { cwd = cwd }) 104 | local stdout = "" 105 | local stderr = "" 106 | vim.fn.jobstart(cmd, { 107 | on_stdout = vim.schedule_wrap(function(_, data, _) 108 | for _, line in ipairs(data) do 109 | stdout = stdout .. line 110 | end 111 | end), 112 | on_stderr = vim.schedule_wrap(function(_, data, _) 113 | for _, line in ipairs(data) do 114 | stderr = stderr .. line 115 | end 116 | end), 117 | on_exit = vim.schedule_wrap(function() 118 | if #stderr > 0 then 119 | vim.notify(stderr, vim.log.levels.ERROR) 120 | return 121 | end 122 | util.safe_decode(stdout, callback) 123 | end), 124 | }) 125 | end 126 | 127 | return M 128 | -------------------------------------------------------------------------------- /lua/coverage/languages/swift.lua: -------------------------------------------------------------------------------- 1 | local config = require("coverage.config") 2 | local Path = require("plenary.path") 3 | local util = require("coverage.util") 4 | local signs = require("coverage.signs") 5 | 6 | local M = {} 7 | 8 | M.load = function(callback) 9 | local swift_config = config.opts.lang.swift 10 | local p = Path:new(util.get_coverage_file(swift_config.coverage_file)) 11 | if not p:exists() then 12 | vim.notify("No coverage file exists.", vim.log.levels.INFO) 13 | return 14 | end 15 | p:read(vim.schedule_wrap(function(data) 16 | util.safe_decode(data, callback) 17 | end)) 18 | end 19 | 20 | M.sign_list = function(data) 21 | local sign_list = {} 22 | for _, datum in ipairs(data.data) do 23 | for _, file in ipairs(datum.files) do 24 | local fname = Path:new(file.filename):make_relative() 25 | local buffer = vim.fn.bufnr(fname, false) 26 | if buffer ~= -1 then 27 | local last_sign_line = -1 28 | local last_sign_covered = nil 29 | local segment = nil 30 | for _, next_segment in ipairs(file.segments) do 31 | if segment ~= nil then 32 | local line, _, count, has_count = unpack(segment) 33 | if has_count then 34 | local next_line = next_segment[1] 35 | local covered = count > 0 36 | for i = line, next_line do 37 | if i == last_sign_line then 38 | if last_sign_covered ~= nil and last_sign_covered ~= covered then 39 | sign_list[#sign_list] = signs.new_partial(buffer, i) 40 | last_sign_covered = nil 41 | end 42 | else 43 | if covered then 44 | table.insert(sign_list, signs.new_covered(buffer, i)) 45 | else 46 | table.insert(sign_list, signs.new_uncovered(buffer, i)) 47 | end 48 | last_sign_covered = covered 49 | end 50 | end 51 | last_sign_line = next_line 52 | end 53 | end 54 | segment = next_segment 55 | end 56 | end 57 | end 58 | end 59 | return sign_list 60 | end 61 | 62 | M.summary = function(data) 63 | local totals = { 64 | statements = 0, 65 | missing = 0, 66 | } 67 | local files = {} 68 | for _, d in ipairs(data.data) do 69 | for _, f in ipairs(d.files) do 70 | local fname = Path:new(f.filename):make_relative() 71 | if fname:match("^%.build/") then 72 | goto next_file 73 | end 74 | table.insert(files, { 75 | filename = fname, 76 | statements = f.summary.lines.count, 77 | missing = f.summary.lines.count - f.summary.lines.covered, 78 | coverage = f.summary.lines.percent, 79 | }) 80 | ::next_file:: 81 | end 82 | totals.statements = totals.statements + d.totals.lines.count 83 | totals.missing = totals.missing + (d.totals.lines.count - d.totals.lines.covered) 84 | end 85 | totals.coverage = (totals.statements - totals.missing) / totals.statements * 100.0 86 | return { 87 | files = files, 88 | totals = totals, 89 | } 90 | end 91 | 92 | return M 93 | -------------------------------------------------------------------------------- /lua/coverage/languages/typescript.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | -- Javascript and Typescript currently use the exact same configuration. 4 | local javascript = require("coverage.languages.javascript") 5 | 6 | --- Use the same configuration as javascript 7 | M.config_alias = "javascript" 8 | 9 | --- Returns a list of signs to be placed. 10 | M.sign_list = javascript.sign_list 11 | 12 | --- Returns a summary report. 13 | M.summary = javascript.summary 14 | 15 | --- Loads a coverage report. 16 | M.load = javascript.load 17 | 18 | return M 19 | -------------------------------------------------------------------------------- /lua/coverage/languages/typescriptreact.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | -- Javascript and Typescript currently use the exact same configuration. 4 | local javascript = require("coverage.languages.javascript") 5 | 6 | --- Use the same configuration as javascript 7 | M.config_alias = "javascript" 8 | 9 | --- Returns a list of signs to be placed. 10 | M.sign_list = javascript.sign_list 11 | 12 | --- Returns a summary report. 13 | M.summary = javascript.summary 14 | 15 | --- Loads a coverage report. 16 | M.load = javascript.load 17 | 18 | return M 19 | -------------------------------------------------------------------------------- /lua/coverage/languages/vue.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | -- Javascript and Typescript currently use the exact same configuration. 4 | local javascript = require("coverage.languages.javascript") 5 | 6 | --- Use the same configuration as javascript 7 | M.config_alias = "javascript" 8 | 9 | --- Returns a list of signs to be placed. 10 | M.sign_list = javascript.sign_list 11 | 12 | --- Returns a summary report. 13 | M.summary = javascript.summary 14 | 15 | --- Loads a coverage report. 16 | M.load = javascript.load 17 | 18 | return M 19 | 20 | -------------------------------------------------------------------------------- /lua/coverage/lcov.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local Path = require("plenary.path") 4 | local common = require("coverage.languages.common") 5 | local config = require("coverage.config") 6 | local report = require("coverage.report") 7 | local signs = require("coverage.signs") 8 | local util = require("coverage.util") 9 | local watch = require("coverage.watch") 10 | 11 | --- Loads a coverage report from an lcov file but does not place signs. 12 | --- @param file string the path to the lcov file 13 | --- @param place boolean true to immediately place signs 14 | M.load_lcov = function(file, place) 15 | if file == nil then 16 | file = config.opts.lcov_file 17 | end 18 | if file == nil then 19 | vim.notify("A path to the lcov file was not supplied.", vim.log.levels.INFO) 20 | return 21 | end 22 | local p = Path:new(file) 23 | if not p:exists() then 24 | vim.notify("No coverage file exists.", vim.log.levels.INFO) 25 | return 26 | end 27 | 28 | local load_lcov = function() 29 | if config.opts.load_coverage_cb ~= nil then 30 | vim.schedule(function() 31 | config.opts.load_coverage_cb("lcov") 32 | end) 33 | end 34 | 35 | local result = util.lcov_to_table(p) 36 | 37 | -- Since we don't know the actual language, use the default common 38 | -- summary and sign_list. 39 | report.cache(result, "common") 40 | local sign_list = common.sign_list(result) 41 | if place or signs.is_enabled() then 42 | signs.place(sign_list) 43 | else 44 | signs.cache(sign_list) 45 | end 46 | end 47 | 48 | watch.start(file, load_lcov) 49 | 50 | -- When signs were enabled, calling load_lcov would disable them. 51 | -- That didn't seem like good UX to me, so I disabled this. 52 | -- signs.clear() 53 | load_lcov() 54 | end 55 | 56 | 57 | return M 58 | -------------------------------------------------------------------------------- /lua/coverage/parsers/corbertura.lua: -------------------------------------------------------------------------------- 1 | local Path = require("plenary.path") 2 | local util = require("coverage.util") 3 | local xmlreader = require("xmlreader") 4 | local reader, path_mappings, sources = nil, {}, {} 5 | 6 | --- @param name string 7 | local function notify_element_missing(name) 8 | vim.notify(string.format("Invalid cobertura format, no '%s' element found", name), vim.log.levels.ERROR) 9 | end 10 | 11 | --- @param path Path 12 | local function read_path(path) 13 | return assert(xmlreader.from_string(path:read())) 14 | end 15 | 16 | --- Position the reader on the next element `name` found before the closing element for `parent` 17 | --- 18 | --- @param name string 19 | --- @param parent string|nil 20 | local function next_element_in(name, parent) 21 | while nil ~= reader and reader:next_node() do 22 | if "element" == reader:node_type() and name == reader:name() then 23 | return true 24 | end 25 | if "end element" == reader:node_type() and parent == reader:name() then 26 | return false 27 | end 28 | end 29 | 30 | return false 31 | end 32 | 33 | local function enter_current_element() 34 | reader:read() 35 | end 36 | 37 | --- Enter in the next element `name` found before the closing element for `parent` 38 | --- 39 | --- @param name string 40 | --- @param parent string|nil 41 | local function enter_next_element_in(name, parent) 42 | if false == next_element_in(name, parent) then 43 | return false 44 | end 45 | 46 | if reader:is_empty_element() then 47 | return false 48 | end 49 | 50 | enter_current_element() 51 | 52 | return true 53 | end 54 | 55 | --- Enter in the next element `name`, will jump over any other element 56 | --- 57 | --- @param name string 58 | local function enter_next_element(name) 59 | return enter_next_element_in(name, nil) 60 | end 61 | 62 | local function apply_path_mappings(source) 63 | for needle, replace in pairs(path_mappings) do 64 | if 1 == source:find(needle) then 65 | return source:gsub(needle, replace) 66 | end 67 | end 68 | 69 | return source 70 | end 71 | 72 | local function load_sources() 73 | if enter_next_element_in("sources", "coverage") then 74 | while enter_next_element_in("source", "sources") do 75 | local source = apply_path_mappings(reader:value()) 76 | table.insert(sources, source) 77 | end 78 | end 79 | end 80 | 81 | local function create_coverage_for_current_package() 82 | local coverage = util.new_file_meta() 83 | coverage.summary.percent_covered = tonumber(reader:get_attribute("line-rate")) * 100 84 | 85 | return coverage 86 | end 87 | 88 | local function update_coverage_with_current_line(coverage) 89 | local number = tonumber(reader:get_attribute("number"), 10) 90 | local hits = tonumber(reader:get_attribute("hits"), 10) 91 | 92 | if 0 == hits then 93 | table.insert(coverage.missing_lines, number) 94 | coverage.summary.missing_lines = coverage.summary.missing_lines + 1 95 | else 96 | table.insert(coverage.executed_lines, number) 97 | coverage.summary.covered_lines = coverage.summary.covered_lines + 1 98 | end 99 | 100 | coverage.summary.num_statements = coverage.summary.num_statements + 1 101 | end 102 | 103 | local function resolve_filename_from_sources(filename) 104 | for _, source in pairs(sources) do 105 | local filepath = Path:new({source, filename}) 106 | if filepath:exists() then 107 | return filepath.filename 108 | end 109 | end 110 | 111 | return filename 112 | end 113 | 114 | local function generate_coverages() 115 | local coverages = {} 116 | while next_element_in("package", "packages") do 117 | local coverage = create_coverage_for_current_package() 118 | local filename = resolve_filename_from_sources(reader:get_attribute("name")) 119 | 120 | enter_current_element() 121 | if enter_next_element_in("classes", "package") then 122 | while enter_next_element_in("class", "classes") do 123 | while enter_next_element_in("lines", "class") do 124 | while next_element_in("line", "lines") do 125 | update_coverage_with_current_line(coverage) 126 | end 127 | end 128 | end 129 | end 130 | 131 | local is_not_interface = 0 < coverage.summary.num_statements 132 | if is_not_interface then 133 | coverages[filename] = coverage 134 | end 135 | end 136 | 137 | return coverages 138 | end 139 | 140 | --- Parses a cobertura report from path into files. 141 | --- 142 | --- @param path Path 143 | --- @param files table 144 | --- @param a_path_mappings table 145 | return function (path, files, a_path_mappings) 146 | reader = read_path(path) 147 | path_mappings = a_path_mappings 148 | sources = {} 149 | 150 | if false == enter_next_element("coverage") then 151 | notify_element_missing("coverage") 152 | return 153 | end 154 | 155 | load_sources() 156 | 157 | if false == enter_next_element_in("packages", "coverage") then 158 | notify_element_missing("packages") 159 | return 160 | end 161 | 162 | for filename, coverage in pairs(generate_coverages()) do 163 | files[filename] = coverage 164 | end 165 | 166 | reader:close() 167 | end 168 | -------------------------------------------------------------------------------- /lua/coverage/report.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local cached = nil 4 | local cached_lang = nil 5 | 6 | --- Returns true if there is currently a cached coverage report. 7 | M.is_cached = function() 8 | return cached ~= nil 9 | end 10 | 11 | --- Returns the cached coverage report or nil. 12 | -- The report format is dependent on the language that generated the report. 13 | M.get = function() 14 | return cached 15 | end 16 | 17 | --- Returns the language filetype that generated the report or nil. 18 | M.language = function() 19 | return cached_lang 20 | end 21 | 22 | --- Sets the cached report and language filetype that generated it. 23 | M.cache = function(report, language) 24 | cached = report 25 | cached_lang = language 26 | end 27 | 28 | --- Clears any cached report. 29 | M.clear = function() 30 | cached = nil 31 | cached_lang = nil 32 | end 33 | 34 | return M 35 | -------------------------------------------------------------------------------- /lua/coverage/signs.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | local config = require("coverage.config") 3 | 4 | local ns = "coverage_" 5 | local enabled = false 6 | local cached_signs = nil 7 | local default_priority = 10 8 | 9 | --- @class Sign 10 | --- @field hl string name of the highlight group 11 | --- @field text string text to place in sign column 12 | --- @field priority integer? optional priority (default 10; highest wins) 13 | 14 | --- @class SignPlace 15 | --- @field buffer string|integer 16 | --- @field group string 17 | --- @field id? integer 18 | --- @field lnum integer 19 | --- @field name string 20 | --- @field priority integer 21 | 22 | --- Defines signs. 23 | M.setup = function() 24 | vim.fn.sign_define(M.name("covered"), { 25 | text = config.opts.signs.covered.text, 26 | texthl = config.opts.signs.covered.hl, 27 | }) 28 | vim.fn.sign_define(M.name("uncovered"), { 29 | text = config.opts.signs.uncovered.text, 30 | texthl = config.opts.signs.uncovered.hl, 31 | }) 32 | vim.fn.sign_define(M.name("partial"), { 33 | text = config.opts.signs.partial.text, 34 | texthl = config.opts.signs.partial.hl, 35 | }) 36 | end 37 | 38 | --- Returns a namespaced sign name. 39 | --- @param name string 40 | M.name = function(name) 41 | return ns .. name 42 | end 43 | 44 | --- Caches signs but does not place them. 45 | --- @param signs SignPlace[] (:h sign_placelist) 46 | M.cache = function(signs) 47 | M.unplace() 48 | cached_signs = signs 49 | end 50 | 51 | --- Places a list of signs. 52 | --- Any previously placed signs are removed. 53 | --- @param signs SignPlace[] (:h sign_placelist) 54 | M.place = function(signs) 55 | if cached_signs ~= nil then 56 | M.unplace() 57 | end 58 | vim.fn.sign_placelist(signs) 59 | enabled = true 60 | cached_signs = signs 61 | end 62 | 63 | --- Unplaces all coverage signs. 64 | M.unplace = function() 65 | vim.fn.sign_unplace(config.opts.sign_group) 66 | enabled = false 67 | end 68 | 69 | --- Returns true if coverage signs are currently shown. 70 | M.is_enabled = function() 71 | return enabled 72 | end 73 | 74 | --- Displays cached signs. 75 | M.show = function() 76 | if enabled or cached_signs == nil then 77 | return 78 | end 79 | M.place(cached_signs) 80 | end 81 | 82 | --- Toggles the visibility of coverage signs. 83 | M.toggle = function() 84 | if enabled then 85 | M.unplace() 86 | elseif cached_signs ~= nil then 87 | M.place(cached_signs) 88 | end 89 | end 90 | 91 | --- Turns off coverage signs and removes cached results. 92 | M.clear = function() 93 | M.unplace() 94 | cached_signs = nil 95 | end 96 | 97 | --- Jumps to a sign of the given type in the given direction. 98 | --- @param sign_type? "covered"|"uncovered"|"partial" Defaults to "covered" 99 | --- @param direction? -1|1 Defaults to 1 (forward) 100 | M.jump = function(sign_type, direction) 101 | if not enabled or cached_signs == nil then 102 | return 103 | end 104 | local placed = vim.fn.sign_getplaced("", { group = config.opts.sign_group }) 105 | if #placed == 0 then 106 | return 107 | end 108 | local current_lnum = vim.fn.line(".") 109 | local sign_name = M.name("covered") 110 | if sign_type ~= nil then 111 | sign_name = M.name(sign_type) 112 | end 113 | direction = direction or 1 114 | 115 | local placed_signs = placed[1].signs 116 | if direction < 0 then 117 | table.sort(placed_signs, function(a, b) 118 | return a.lnum > b.lnum 119 | end) 120 | end 121 | 122 | for _, sign in ipairs(placed_signs) do 123 | if direction > 0 and sign.lnum > current_lnum and sign_name == sign.name then 124 | vim.fn.sign_jump(sign.id, config.opts.sign_group, "") 125 | return 126 | elseif direction < 0 and sign.lnum < current_lnum and sign_name == sign.name then 127 | vim.fn.sign_jump(sign.id, config.opts.sign_group, "") 128 | return 129 | end 130 | end 131 | end 132 | 133 | --- Returns a new covered sign in the format used by sign_placelist. 134 | --- @param buffer string|integer buffer name or id 135 | --- @param lnum integer line number 136 | --- @return SignPlace 137 | M.new_covered = function(buffer, lnum) 138 | return { 139 | buffer = buffer, 140 | group = config.opts.sign_group, 141 | lnum = lnum, 142 | name = M.name("covered"), 143 | priority = config.opts.signs.covered.priority or default_priority, 144 | } 145 | end 146 | 147 | --- Returns a new uncovered sign in the format used by sign_placelist. 148 | --- @param buffer string|integer buffer name or id 149 | --- @param lnum integer line number 150 | --- @return SignPlace 151 | M.new_uncovered = function(buffer, lnum) 152 | return { 153 | buffer = buffer, 154 | group = config.opts.sign_group, 155 | lnum = lnum, 156 | name = M.name("uncovered"), 157 | priority = config.opts.signs.uncovered.priority or default_priority, 158 | } 159 | end 160 | 161 | --- Returns a new partial coverage sign in the format used by sign_placelist. 162 | --- @param buffer string|integer buffer name or id 163 | --- @param lnum integer line number 164 | --- @return SignPlace 165 | M.new_partial = function(buffer, lnum) 166 | local priority = config.opts.signs.partial.priority 167 | if priority == nil then 168 | if config.opts.signs.uncovered.priority ~= nil then 169 | priority = config.opts.signs.uncovered.priority + 1 170 | else 171 | priority = default_priority + 1 172 | end 173 | end 174 | return { 175 | buffer = buffer, 176 | group = config.opts.sign_group, 177 | lnum = lnum, 178 | name = M.name("partial"), 179 | priority = priority, 180 | } 181 | end 182 | 183 | return M 184 | -------------------------------------------------------------------------------- /lua/coverage/summary.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local config = require("coverage.config") 4 | local Path = require("plenary.path") 5 | local report = require("coverage.report") 6 | local window = require("plenary.window.float") 7 | 8 | -- Plenary popup window 9 | -- Example format: 10 | -- { 11 | -- border_bufnr = 25, 12 | -- border_win_id = 1020, 13 | -- bufnr = 24, 14 | -- win_id = 1019, 15 | -- } 16 | local popup = nil 17 | -- cached summary report 18 | local summary = nil 19 | -- cached header 20 | local header = nil 21 | -- cached content (data for files in the report) 22 | local content = nil 23 | -- cached footer 24 | local footer = nil 25 | -- help screen toggle 26 | local help_displayed = false 27 | local cached_filename_width = nil 28 | local fixed_col_width = 64 -- this should match the width of header columns below 29 | local min_filename_width = 25 -- the filename column should be at least this wide 30 | local max_col_width = 99 -- string.format doesn't like values larger than this 31 | 32 | -- Sort file coverage ascending. 33 | local coverage_ascending = function(a, b) 34 | if a.coverage == b.coverage then 35 | return 0 36 | elseif a.coverage > b.coverage then 37 | return 1 38 | end 39 | return -1 40 | end 41 | 42 | -- Sort file coverage descending. 43 | local coverage_descending = function(a, b) 44 | if a.coverage == b.coverage then 45 | return 0 46 | elseif a.coverage > b.coverage then 47 | return -1 48 | end 49 | return 1 50 | end 51 | 52 | -- the current sort method 53 | local sort_method = coverage_ascending 54 | 55 | --- Returns the buffer number for a filename, if it exists; -1 otherwise. 56 | local get_bufnr = function(filename) 57 | local p = Path:new(filename) 58 | return vim.fn.bufnr(p:make_relative(), false) 59 | end 60 | 61 | --- Returns the coverage highlight group based on a configured minimum threshold. 62 | local get_cov_hl_group = function(threshold) 63 | local min_threshold = config.opts.summary.min_coverage 64 | if min_threshold == 0 then 65 | return nil 66 | end 67 | return threshold >= min_threshold and "CoverageSummaryPass" or "CoverageSummaryFail" 68 | end 69 | 70 | --- Returns the width of the filename column based on the popup window & filename widths. 71 | local get_filename_width = function() 72 | if cached_filename_width ~= nil then 73 | return cached_filename_width 74 | end 75 | 76 | local win_width = vim.api.nvim_win_get_width(popup.win_id) 77 | 78 | local filename_width = min_filename_width 79 | for _, file in ipairs(summary.files) do 80 | filename_width = vim.fn.max({ filename_width, string.len(file.filename) + 1 }) 81 | end 82 | -- cap it at the smallest possible to fit in the window (max 99) 83 | filename_width = vim.fn.min({ filename_width, win_width - fixed_col_width, max_col_width }) 84 | cached_filename_width = filename_width 85 | return filename_width 86 | end 87 | 88 | --- Loads the header lines and highlighting for rendering later. 89 | local load_header = function() 90 | header = { lines = {}, highlights = {} } 91 | table.insert(header.lines, "press ? for help") 92 | table.insert(header.lines, "") 93 | table.insert( 94 | header.highlights, 95 | { hl_group = "CoverageSummaryHeader", line = #header.lines, col_start = 0, col_end = -1 } 96 | ) 97 | table.insert( 98 | header.lines, 99 | string.format( 100 | "%" .. get_filename_width() .. "s %11s %9s %9s %9s %9s %11s", 101 | "Module", 102 | "Statements", 103 | "Missing", 104 | "Excluded", 105 | "Branches", 106 | "Partial", 107 | "Coverage" 108 | ) 109 | ) 110 | end 111 | 112 | --- Loads the content lines and highlighting for rendering later. 113 | local load_content = function() 114 | content = { lines = {}, highlights = {} } 115 | summary.files = vim.fn.sort(summary.files, sort_method) 116 | for _, file in ipairs(summary.files) do 117 | local filename = file.filename 118 | if string.len(filename) > get_filename_width() then 119 | -- this truncates paths other than first & last ({1, -1}) to 1 character 120 | filename = Path:new(filename):shorten(1, { 1, -1 }) 121 | end 122 | local line = string.format( 123 | "%" .. get_filename_width() .. "s %11s %9s %9s %9s %9s", 124 | filename, 125 | file.statements or "", 126 | file.missing or "", 127 | file.excluded or "", 128 | file.branches or "", 129 | file.partial or "", 130 | file.coverage or 0 131 | ) 132 | if file.coverage ~= nil then 133 | local hl_group = get_cov_hl_group(file.coverage) 134 | if hl_group ~= nil then 135 | table.insert( 136 | content.highlights, 137 | { hl_group = hl_group, line = #content.lines, col_start = #line, col_end = -1 } 138 | ) 139 | end 140 | line = string.format("%s %10.0f%%", line, file.coverage) 141 | else 142 | line = line .. "-" 143 | end 144 | table.insert(content.lines, line) 145 | end 146 | end 147 | 148 | --- Loads the footer lines and highlighting for rendering later. 149 | local load_footer = function() 150 | footer = { lines = {}, highlights = {} } 151 | 152 | if summary.totals == nil then 153 | return 154 | end 155 | 156 | local line = string.format( 157 | "%" .. get_filename_width() .. "s %11s %9s %9s %9s %9s", 158 | "Total", 159 | summary.totals.statements or "", 160 | summary.totals.missing or "", 161 | summary.totals.excluded or "", 162 | summary.totals.branches or "", 163 | summary.totals.partial or "" 164 | ) 165 | 166 | if summary.totals.coverage ~= nil then 167 | local hl_group = get_cov_hl_group(summary.totals.coverage) 168 | if hl_group ~= nil then 169 | table.insert( 170 | footer.highlights, 171 | { hl_group = hl_group, line = #footer.lines, col_start = #line, col_end = -1 } 172 | ) 173 | end 174 | line = string.format("%s %10.0f%%", line, summary.totals.coverage) 175 | else 176 | line = line .. "-" 177 | end 178 | table.insert(footer.lines, line) 179 | return footer 180 | end 181 | 182 | --- Sets the cursor row to the given filename if it matches or the first content line if no match is found. 183 | local focus_file = function(filename) 184 | local relative = Path:new(filename):make_relative() 185 | for index, file in ipairs(summary.files) do 186 | if file.filename == filename or file.filename == relative then 187 | vim.api.nvim_win_set_cursor(popup.win_id, { #header.lines + index, 0 }) 188 | return 189 | end 190 | end 191 | 192 | vim.api.nvim_win_set_cursor(popup.win_id, { #header.lines + 1, 0 }) 193 | end 194 | 195 | --- Adds a highlight to the popup buffer. 196 | -- @param highlight { hl_group = "", line = 0, col_start = 0, col_end = -1 } 197 | -- @param offset (optional) added to the highlight line 198 | local add_highlight = function(highlight, offset) 199 | offset = offset or 0 200 | vim.api.nvim_buf_add_highlight( 201 | popup.bufnr, 202 | -1, 203 | highlight.hl_group, 204 | highlight.line + offset, 205 | highlight.col_start, 206 | highlight.col_end 207 | ) 208 | end 209 | 210 | --- Sets the modifiable and readonly buffer options on the popup. 211 | -- @param modifiable (bool) 212 | local set_modifiable = function(modifiable) 213 | vim.api.nvim_buf_set_option(popup.bufnr, "modifiable", modifiable) 214 | vim.api.nvim_buf_set_option(popup.bufnr, "readonly", not modifiable) 215 | end 216 | 217 | --- Renders the summary report in the popup. 218 | local render_summary = function() 219 | local lines = {} 220 | vim.list_extend(lines, header.lines) 221 | vim.list_extend(lines, content.lines) 222 | vim.list_extend(lines, footer.lines) 223 | set_modifiable(true) 224 | vim.api.nvim_buf_set_lines(popup.bufnr, 0, -1, false, lines) 225 | vim.cmd("0center 0") -- centers the "press ? for help" text in the window 226 | set_modifiable(false) 227 | 228 | for _, highlight in ipairs(header.highlights) do 229 | add_highlight(highlight) 230 | end 231 | for _, highlight in ipairs(content.highlights) do 232 | add_highlight(highlight, #header.lines) 233 | end 234 | for _, highlight in ipairs(footer.highlights) do 235 | add_highlight(highlight, #header.lines + #content.lines) 236 | end 237 | help_displayed = false 238 | end 239 | 240 | --- Renders the help page in the popup. 241 | local render_help = function() 242 | local lines = { 243 | " Keyboard shortcuts", 244 | "", 245 | " Toggle help ?", 246 | " Jump to top entry H", 247 | " Sort coverage ascending s", 248 | " Sort coverage descending S", 249 | " Open selected file ", 250 | " Close window ", 251 | " Close window q", 252 | } 253 | set_modifiable(true) 254 | vim.api.nvim_buf_set_lines(popup.bufnr, 0, -1, false, lines) 255 | set_modifiable(false) 256 | add_highlight({ hl_group = "CoverageSummaryHeader", line = 0, col_start = 0, col_end = -1 }) 257 | help_displayed = true 258 | end 259 | 260 | --- Inserts keymaps into the popup buffer. 261 | local keymaps = function() 262 | vim.api.nvim_buf_set_keymap(popup.bufnr, "n", "q", ":" .. popup.bufnr .. "bwipeout!", { silent = true }) 263 | vim.api.nvim_buf_set_keymap(popup.bufnr, "n", "", ":" .. popup.bufnr .. "bwipeout!", { silent = true }) 264 | vim.api.nvim_buf_set_keymap(popup.bufnr, "n", "H", ":" .. #header.lines + 1 .. "", { silent = true }) 265 | vim.api.nvim_buf_set_keymap( 266 | popup.bufnr, 267 | "n", 268 | "s", 269 | ":lua require('coverage.summary').sort(false)", 270 | { silent = true } 271 | ) 272 | vim.api.nvim_buf_set_keymap( 273 | popup.bufnr, 274 | "n", 275 | "S", 276 | ":lua require('coverage.summary').sort(true)", 277 | { silent = true } 278 | ) 279 | vim.api.nvim_buf_set_keymap( 280 | popup.bufnr, 281 | "n", 282 | "", 283 | ":lua require('coverage.summary').select_item()", 284 | { silent = true } 285 | ) 286 | vim.api.nvim_buf_set_keymap( 287 | popup.bufnr, 288 | "n", 289 | "?", 290 | ":lua require('coverage.summary').toggle_help()", 291 | { silent = true } 292 | ) 293 | end 294 | 295 | --- Loads the summary report based on the language filetype. 296 | local load_summary = function() 297 | -- get summary results based on language filetype 298 | local json_data = report.get() 299 | local lang = require("coverage.languages." .. report.language()) 300 | summary = lang.summary(json_data) 301 | end 302 | 303 | --- Sets buffer/window options for the popup after creation. 304 | local set_options = function() 305 | local win_width = vim.api.nvim_win_get_width(popup.win_id) 306 | vim.api.nvim_buf_set_option(popup.bufnr, "textwidth", win_width) 307 | vim.api.nvim_buf_set_option(popup.bufnr, "filetype", "coverage") 308 | vim.api.nvim_win_set_option(popup.win_id, "cursorline", true) 309 | vim.api.nvim_win_set_option( 310 | popup.win_id, 311 | "winhl", 312 | "Normal:CoverageSummaryNormal,CursorLine:CoverageSummaryCursorLine" 313 | ) 314 | vim.cmd(string.format( 315 | [[ 316 | au BufLeave lua require('coverage.summary').close() 317 | ]] , 318 | popup.bufnr 319 | )) 320 | end 321 | 322 | --- Opens the file under the cursor and closes the popup. 323 | M.select_item = function() 324 | if popup == nil then 325 | return 326 | end 327 | local pos = vim.api.nvim_win_get_cursor(popup.win_id) 328 | local row = pos[1] 329 | if row <= #header.lines or row > #header.lines + #content.lines then 330 | return 331 | end 332 | 333 | local index = row - #header.lines 334 | local fname = summary.files[index].filename 335 | 336 | M.close() 337 | 338 | local bufnr = get_bufnr(fname) 339 | if bufnr == -1 then 340 | vim.cmd("edit " .. fname) 341 | require("coverage").load(true) 342 | else 343 | vim.api.nvim_win_set_buf(0, bufnr) 344 | end 345 | end 346 | 347 | --- Toggle the help screen in the popup. 348 | M.toggle_help = function() 349 | if popup == nil then 350 | return 351 | end 352 | if help_displayed then 353 | render_summary() 354 | else 355 | render_help() 356 | end 357 | end 358 | 359 | --- Try to adjust the width percentage to help on smaller screens. 360 | local adjust_width_percentage = function(width_percentage) 361 | local term_width = vim.o.columns 362 | local min_table_width = fixed_col_width + min_filename_width 363 | if term_width <= min_table_width + 20 then 364 | width_percentage = 1.0 365 | elseif term_width <= min_table_width + 40 then 366 | width_percentage = 0.9 367 | end 368 | return width_percentage 369 | end 370 | 371 | --- Display the coverage report summary popup. 372 | M.show = function() 373 | if not report.is_cached() then 374 | vim.notify("Coverage report not loaded.") 375 | return 376 | end 377 | 378 | load_summary() 379 | 380 | -- get the current filename before opening a new popup 381 | local current_filename = vim.api.nvim_buf_get_name(0) 382 | local border_opts = vim.tbl_deep_extend("force", {}, config.opts.summary.borders) 383 | border_opts.title = "Coverage Summary" 384 | if summary.totals ~= nil and summary.totals.coverage ~= nil then 385 | border_opts.title = string.format("%s: %.0f%%", border_opts.title, summary.totals.coverage) 386 | local hl_group = get_cov_hl_group(summary.totals.coverage) 387 | border_opts.titlehighlight = hl_group 388 | end 389 | 390 | -- get the window options 391 | local win_opts = vim.tbl_deep_extend("force", {}, config.opts.summary.window) 392 | 393 | popup = window.percentage_range_window( 394 | adjust_width_percentage(config.opts.summary.width_percentage), 395 | config.opts.summary.height_percentage, 396 | win_opts, 397 | border_opts 398 | ) 399 | 400 | load_header() 401 | load_content() 402 | load_footer() 403 | 404 | set_options() 405 | render_summary() 406 | keymaps() 407 | focus_file(current_filename) 408 | end 409 | 410 | --- Change the sort method for the coverage report and re-render the content. 411 | M.sort = function(descending) 412 | sort_method = descending and coverage_descending or coverage_ascending 413 | load_content() 414 | render_summary() 415 | end 416 | 417 | --- Close the coverage report summary popup. 418 | M.close = function() 419 | if popup == nil then 420 | return 421 | end 422 | vim.api.nvim_buf_delete(popup.bufnr, { force = true }) 423 | M.win_on_close() 424 | end 425 | 426 | --- Clear variables on window close. 427 | M.win_on_close = function() 428 | if popup == nil then 429 | return 430 | end 431 | cached_filename_width = nil 432 | summary = nil 433 | header = nil 434 | content = nil 435 | footer = nil 436 | help_displayed = false 437 | popup = nil 438 | end 439 | 440 | return M 441 | -------------------------------------------------------------------------------- /lua/coverage/util.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | local Path = require("plenary.path") 3 | 4 | --- Safely decode JSON and call the callback with decoded data. 5 | -- @param data to decode 6 | -- @param callback to call on decode success 7 | M.safe_decode = function(data, callback) 8 | local ok, json_data = pcall(vim.fn.json_decode, data) 9 | if ok then 10 | callback(json_data) 11 | else 12 | vim.notify("Failed to decode JSON coverage data: " .. json_data, vim.log.levels.ERROR) 13 | end 14 | end 15 | 16 | --- Chain two functions together. 17 | -- @param a first method to chain 18 | -- @param b second method to chain 19 | -- @return chained method 20 | M.chain = function(a, b) 21 | return function(...) 22 | a(b(...)) 23 | end 24 | end 25 | 26 | --- Returns a table containing file parameters. 27 | --- @return FileCoverage 28 | M.new_file_meta = function() 29 | return { 30 | summary = { 31 | covered_lines = 0, 32 | excluded_lines = 0, 33 | missing_lines = 0, 34 | num_statements = 0, 35 | percent_covered = 0, 36 | }, 37 | missing_lines = {}, 38 | missing_branches = {}, 39 | executed_lines = {}, 40 | excluded_lines = {}, 41 | } 42 | end 43 | 44 | --- Parses an lcov report from path into files. 45 | --- @param path Path 46 | --- @param files table 47 | local lcov_parser = function(path, files) 48 | --- Current file 49 | --- @type string|nil 50 | local cfile = nil 51 | --- Current metadata 52 | --- @type FileCoverage|nil 53 | local cmeta = nil 54 | 55 | for _, line in ipairs(path:readlines()) do 56 | if line:match("end_of_record") and cmeta ~= nil and cfile ~= nil then 57 | -- Commit the current file 58 | cmeta.summary["excluded_lines"] = 0 59 | cmeta.summary["percent_covered"] = cmeta.summary.covered_lines / cmeta.summary.num_statements * 100 60 | files[cfile] = cmeta 61 | -- Reset variables 62 | cfile = nil 63 | cmeta = nil 64 | elseif line:match("SF:.+") then 65 | -- SF: 66 | cfile = line:gsub("SF:", "") 67 | cmeta = M.new_file_meta() 68 | elseif line:match("^DA:%d+,%d+,?.*") and cmeta ~= nil then 69 | -- DA:,[,] 70 | local ls, ns = line:match("DA:(%d+),(%d+),?.*") 71 | local l, n = tonumber(ls, 10), tonumber(ns, 10) 72 | if n > 0 then 73 | table.insert(cmeta.executed_lines, l) 74 | else 75 | table.insert(cmeta.missing_lines, l) 76 | cmeta.summary.missing_lines = cmeta.summary.missing_lines + 1 77 | end 78 | elseif line:match("^BRDA:%d+,%d+,%d+,(%d+|-)") and cmeta ~= nil then 79 | -- BRDA:,,, 80 | -- Block number and branch number are gcc internal IDs for the branch. 81 | -- Taken is either '-' if the basic block containing the branch was never 82 | -- executed or a number indicating how often that branch was taken. 83 | local ls, ns = line:match("^BRDA:(%d+),%d+,%d+,(%d+|-)") 84 | local l = tonumber(ls, 10) 85 | --- @type integer? 86 | local n = 0 87 | if ns ~= '-' then 88 | n = tonumber(ns, 10) 89 | end 90 | if n == 0 then 91 | -- lcov uses internal ids for branch blocks and branch numbers... 92 | -- it may be possible to map these to line numbers but only the `from` branch number is used currently anyway 93 | table.insert(cmeta.missing_branches, { l, -1 }) 94 | end 95 | elseif line:match("^BRF:%d+") and cmeta ~= nil then 96 | -- BRF: 97 | local brf = tonumber(line:gsub("BRF:", ""), 10) 98 | cmeta.summary.num_branches = brf 99 | elseif line:match("^BRH:%d+") and cmeta ~= nil then 100 | -- BRH: 101 | if cmeta.summary.num_branches ~= nil then 102 | local brh = tonumber(line:gsub("BRH:", ""), 10) 103 | cmeta.summary.num_partial_branches = cmeta.summary.num_branches - brh 104 | end 105 | elseif line:match("LH:%d+") and cmeta ~= nil then 106 | -- LH: 107 | local lh = tonumber(line:gsub("LH:", ""), 10) 108 | cmeta.summary["covered_lines"] = lh 109 | elseif line:match("LF:%d+") and cmeta ~= nil then 110 | -- LF: 111 | local lf = tonumber(line:gsub("LF:", ""), 10) 112 | cmeta.summary["num_statements"] = lf 113 | else 114 | -- Everything else is uninteresting, just move on... 115 | end 116 | end 117 | end 118 | 119 | --- Parses a generic report into a files table. 120 | --- @param path Path 121 | --- @param parser fun(path:Path, files:table) 122 | --- @return CoverageData 123 | M.report_to_table = function(path, parser) 124 | --- @type table 125 | local files = {} 126 | 127 | parser(path, files) 128 | 129 | --- @type CoverageSummary 130 | local totals = { 131 | num_statements = 0, 132 | covered_lines = 0, 133 | missing_lines = 0, 134 | excluded_lines = 0, 135 | } 136 | for _, meta in pairs(files) do 137 | totals.num_statements = totals.num_statements + meta.summary.num_statements 138 | totals.covered_lines = totals.covered_lines + meta.summary.covered_lines 139 | totals.missing_lines = totals.missing_lines + meta.summary.missing_lines 140 | totals.excluded_lines = totals.excluded_lines + meta.summary.excluded_lines 141 | end 142 | totals.percent_covered = totals.covered_lines / totals.num_statements * 100 143 | 144 | return { meta = {}, totals = totals, files = files } 145 | end 146 | 147 | --- Parses a lcov files into a table, 148 | --- see http://ltp.sourceforge.net/coverage/lcov/geninfo.1.php for spec 149 | --- @param path Path 150 | M.lcov_to_table = function(path) 151 | return M.report_to_table(path, lcov_parser) 152 | end 153 | 154 | --- Parses a cobertura file into a table, 155 | --- @param path Path 156 | M.cobertura_to_table = function(path, path_mappings) 157 | local cobertura_parser = require("coverage.parsers.corbertura") 158 | 159 | return M.report_to_table(path, function(p, f) 160 | return cobertura_parser(p, f, path_mappings or {}) 161 | end) 162 | end 163 | 164 | --- Get the coverage file 165 | --- In case the config offers a function, this is called, 166 | --- if it is a list, it tries all files, till one is found 167 | --- in case of a single file, just return it. 168 | M.get_coverage_file = function(file_configuration) 169 | if type(file_configuration) == 'function' then 170 | return file_configuration() 171 | elseif type(file_configuration) == 'table' then 172 | for _,v in ipairs(file_configuration) do 173 | if Path:new(v):exists() then 174 | return v 175 | end 176 | end 177 | else 178 | return file_configuration 179 | end 180 | end 181 | 182 | return M 183 | -------------------------------------------------------------------------------- /lua/coverage/watch.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local config = require("coverage.config") 4 | 5 | local fs_event = nil 6 | local debounce_timer = nil 7 | 8 | --- @class Event 9 | --- @field change? boolean 10 | --- @field rename? boolean 11 | 12 | --- @param fname string filename to watch 13 | --- @param change_cb fun() callback when a file changes 14 | --- @param events? Event previous triggered events 15 | local function watch(fname, change_cb, events) 16 | if fs_event ~= nil then 17 | M.stop() 18 | end 19 | 20 | if vim.fn.filereadable(fname) == 0 then 21 | vim.defer_fn(function() 22 | -- if events is nil, default to rename = true to trigger change_cb when the file is readable 23 | -- this can happen if the file does not initially exist when coverage.load() is called but is created later 24 | local ev = events or { rename = true } 25 | watch(fname, change_cb, ev) 26 | end, config.opts.auto_reload_timeout_ms) 27 | return 28 | end 29 | 30 | if events ~= nil and events.rename then 31 | -- the file was deleted and recreated 32 | change_cb() 33 | end 34 | 35 | fs_event = vim.loop.new_fs_event() 36 | local flags = { 37 | watch_entry = false, 38 | stat = false, 39 | recursive = false, 40 | } 41 | ---@diagnostic disable-next-line: unused-local 42 | local cb = function(err, filename, ev) 43 | if err then 44 | vim.notify("Coverage watch error: " .. err, vim.log.levels.ERROR) 45 | M.stop() 46 | elseif ev.rename then 47 | if debounce_timer ~= nil then 48 | vim.loop.timer_stop(debounce_timer) 49 | end 50 | -- reschedule immediately to watch for the file to be recreated 51 | debounce_timer = vim.defer_fn(function() 52 | watch(fname, change_cb, ev) 53 | end, 0) 54 | else 55 | if debounce_timer ~= nil then 56 | vim.loop.timer_stop(debounce_timer) 57 | end 58 | debounce_timer = vim.defer_fn(function() 59 | debounce_timer = nil 60 | change_cb() 61 | end, config.opts.auto_reload_timeout_ms) 62 | end 63 | end 64 | vim.loop.fs_event_start(fs_event, fname, flags, cb) 65 | end 66 | 67 | --- Starts the file watcher that executes a callback when a file changes. 68 | --- @param fname string filename to watch 69 | --- @param change_cb fun() callback when a file changes 70 | M.start = function(fname, change_cb) 71 | watch(fname, change_cb) 72 | end 73 | 74 | --- Stops the file watcher. 75 | M.stop = function() 76 | if fs_event ~= nil then 77 | vim.loop.fs_event_stop(fs_event) 78 | end 79 | fs_event = nil 80 | end 81 | 82 | return M 83 | -------------------------------------------------------------------------------- /tests/README.md: -------------------------------------------------------------------------------- 1 | # Running tests for nvim-coverage 2 | 3 | Unit and integration tests have moved to a separate [repo](https://github.com/andythigpen/nvim-coverage-tests) 4 | in order to keep the amount of extra files in this plugin to a minimum. 5 | --------------------------------------------------------------------------------