Skip to content

Commit 81e01d7

Browse files
feat: add native code coverage support (#348)
This PR introduces native code coverage support for Ruby targets using Bazel's coverage command. It integrates SimpleCov and simplecov-lcov to generate LCOV-formatted reports. Key implementation details: - Coverage Helper: A specialized coverage.rb script is automatically required via RUBYOPT when coverage is enabled. - Workspace-Relative Paths: To ensure Bazel can aggregate coverage from multiple targets, we ensure all SF: paths in the LCOV report are relative to the workspace root. - MRI: MRI preserves loaded paths as symlinks within the Bazel sandbox. By setting the SimpleCov root to Dir.pwd (the sandbox root), we naturally get workspace-relative paths. - JRuby: JRuby resolves all files to their absolute realpaths on the physical disk, bypassing the sandbox symlinks. To generate relative paths, we dynamically calculate the realpath of the workspace root relative to the coverage.rb helper and set it as the SimpleCov root. - Bazel Integration: - COVERAGE_OUTPUT_FILE: SimpleCov is configured to write directly to the path provided by Bazel. - BAZEL_TARGET: Used as the command_name to provide unique identification for merged results and avoid warnings. - JRuby Support: Automatically enables --debug via JRUBY_OPTS when coverage is requested, as required for JRuby coverage collection. - Documentation: Updated the main README and rb_test rule documentation. - Examples: Added a test_coverage.sh script to the gem example to verify coverage across different engines. - CI Verification: Added a coverage verification step to the CI matrix in examples/gem to ensure it works across all supported MRI and JRuby versions and platforms. Fixes #347
1 parent d0a5bca commit 81e01d7

15 files changed

Lines changed: 249 additions & 14 deletions

File tree

.github/workflows/ci.yml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,23 @@ jobs:
8181
- run: bazel run "@bundle//bin:rake" "--" --version
8282
- run: bazel run "@bundle//bin:rspec" "--" --help
8383
- run: bazel test ...
84+
- name: Run coverage
85+
if: matrix.os != 'windows-latest'
86+
shell: bash
87+
run: |
88+
bazel coverage //spec:add
89+
COVERAGE_FILE=bazel-testlogs/spec/add/coverage.dat
90+
if ! grep -q "SF:.*lib/gem/add.rb" "$COVERAGE_FILE"; then
91+
echo "Error: Coverage report does not contain expected file coverage"
92+
cat "$COVERAGE_FILE"
93+
exit 1
94+
fi
95+
- name: Upload coverage report
96+
if: matrix.os != 'windows-latest'
97+
uses: actions/upload-artifact@v4
98+
with:
99+
name: coverage-${{ matrix.os }}-${{ matrix.ruby }}-${{ matrix.mode }}
100+
path: examples/gem/bazel-testlogs/spec/add/coverage.dat
84101
- if: failure() && runner.debug == '1'
85102
uses: mxschmitt/action-tmate@v3
86103

README.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,37 @@ However, some are known not to work or work only partially (e.g. mRuby has no bu
214214
To work it around, use [`--experimental_inprocess_symlink_creation`][16] Bazel flag.
215215
See [`bazelbuild/bazel#4327`][17] for more details.
216216

217+
## Code Coverage
218+
219+
Ruby rules support Bazel's native coverage collection using [SimpleCov][21] and [simplecov-lcov][22].
220+
221+
> [!NOTE]
222+
> Code coverage is currently not supported on Windows.
223+
224+
To enable coverage:
225+
226+
1. Add `simplecov` and `simplecov-lcov` gems to your `Gemfile`.
227+
2. Run your tests with the `coverage` command:
228+
229+
```bash
230+
bazel coverage //...
231+
```
232+
233+
The rules automatically configure SimpleCov to use the LCOV formatter and output reports to the location expected by Bazel. Coverage reports will have workspace-relative paths, allowing Bazel to aggregate results from multiple targets.
234+
235+
For JRuby, coverage requires `--debug` mode, which is automatically enabled by the rules when coverage is requested.
236+
237+
You can add additional coverage filters using `coverage_filters` attribute in `rb_test` or `rb_binary`:
238+
239+
```python
240+
rb_test(
241+
name = "my_test",
242+
...
243+
coverage_filters = ["/vendor/", "/custom/"],
244+
)
245+
```
246+
247+
217248
[1]: https://www.ruby-lang.org
218249
[2]: https://bazel.build
219250
[3]: docs/repository_rules.md
@@ -234,3 +265,5 @@ However, some are known not to work or work only partially (e.g. mRuby has no bu
234265
[18]: docs/rails.md
235266
[19]: https://github.com/jdx/ruby
236267
[20]: ruby/private/portable_ruby_checksums.bzl
268+
[21]: https://github.com/simplecov-ruby/simplecov
269+
[22]: https://github.com/fortissimo1997/simplecov-lcov

docs/rules.md

Lines changed: 17 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

examples/gem/Gemfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,6 @@ gem 'debug', '>= 1.0.0', platforms: %i[mri mswin64]
77
gem 'psych', '~> 5.3' # native extensions
88
gem 'rspec', '~> 3.0'
99
gem 'rubocop', '~> 1.64'
10+
gem 'simplecov', '~> 0.22'
11+
gem 'simplecov-lcov', '~> 0.8'
1012
gem 'testcontainers', '~> 0.2' # empty gem

examples/gem/Gemfile.lock

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ GEM
1616
irb (~> 1.10)
1717
reline (>= 0.3.8)
1818
diff-lcs (1.5.1)
19+
docile (1.4.1)
1920
docker-api (2.4.0)
2021
excon (>= 0.64.0)
2122
multi_json
@@ -83,6 +84,13 @@ GEM
8384
rubocop-ast (1.37.0)
8485
parser (>= 3.3.1.0)
8586
ruby-progressbar (1.13.0)
87+
simplecov (0.22.0)
88+
docile (~> 1.1)
89+
simplecov-html (~> 0.11)
90+
simplecov_json_formatter (~> 0.1)
91+
simplecov-html (0.13.2)
92+
simplecov-lcov (0.9.0)
93+
simplecov_json_formatter (0.1.4)
8694
stringio (3.2.0)
8795
testcontainers (0.2.0)
8896
testcontainers-core (= 0.2.0)
@@ -106,6 +114,8 @@ DEPENDENCIES
106114
psych (~> 5.3)
107115
rspec (~> 3.0)
108116
rubocop (~> 1.64)
117+
simplecov (~> 0.22)
118+
simplecov-lcov (~> 0.8)
109119
testcontainers (~> 0.2)
110120

111121
BUNDLED WITH

examples/gem/MODULE.bazel

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ ruby.bundle_fetch(
3434
"date-3.5.1-java": "12e09477dc932afe45bf768cd362bf73026804e0db1e6c314186d6cd0bee3344",
3535
"debug-1.10.0": "11e28ca74875979e612444104f3972bd5ffb9e79179907d7ad46dba44bd2e7a4",
3636
"diff-lcs-1.5.1": "273223dfb40685548436d32b4733aa67351769c7dea621da7d9dd4813e63ddfe",
37+
"docile-1.4.1": "96159be799bfa73cdb721b840e9802126e4e03dfc26863db73647204c727f21e",
3738
"docker-api-2.4.0": "824be734f4cc8718189be9c8e795b6414acbbf7e8b082a06f959a27dd8dd63e6",
3839
"excon-1.3.2": "a089babe98638e58042a7d542b2bbd183304527e33d612b6dde22fa491a544a5",
3940
"i18n-1.14.7": "ceba573f8138ff2c0915427f1fc5bdf4aa3ab8ae88c8ce255eb3ecf0a11a5d0f",
@@ -67,6 +68,10 @@ ruby.bundle_fetch(
6768
"rubocop-1.71.0": "e19679efd447346ac476122313d3788ae23c38214790bcf660e984c747608bf0",
6869
"rubocop-ast-1.37.0": "9513ac88aaf113d04b52912533ffe46475de1362d4aa41141b51b2455827c080",
6970
"ruby-progressbar-1.13.0": "80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33",
71+
"simplecov-0.22.0": "fe2622c7834ff23b98066bb0a854284b2729a569ac659f82621fc22ef36213a5",
72+
"simplecov-html-0.13.2": "bd0b8e54e7c2d7685927e8d6286466359b6f16b18cb0df47b508e8d73c777246",
73+
"simplecov-lcov-0.9.0": "7a77a31e200a595ed4b0249493056efd0c920601f53d2ef135ca34ee796346cd",
74+
"simplecov_json_formatter-0.1.4": "529418fbe8de1713ac2b2d612aa3daa56d316975d307244399fa4838c601b428",
7075
"stringio-3.2.0": "c37cb2e58b4ffbd33fe5cd948c05934af997b36e0b6ca6fdf43afa234cf222e1",
7176
"testcontainers-0.2.0": "b8fb466cfbba02eef9120fa0f4e1ebce6c7220b65c5b025ce003d8c6a6b68c67",
7277
"testcontainers-core-0.2.0": "94f316f08f388a8afb75a4fec6ff9ed4289cf60ca7a77522d4ca26688f4c4da4",

ruby/private/BUILD

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
load("@bazel_skylib//:bzl_library.bzl", "bzl_library")
22

3+
exports_files(["coverage.rb"])
4+
35
bzl_library(
46
name = "binary",
57
srcs = ["binary.bzl"],

ruby/private/binary.bzl

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -61,10 +61,17 @@ Supports `$(location)` expansion for targets from `srcs`, `data` and `deps`.
6161
"_windows_constraint": attr.label(
6262
default = "@platforms//os:windows",
6363
),
64+
"_coverage_helper": attr.label(
65+
allow_single_file = True,
66+
default = "@rules_ruby//ruby/private:coverage.rb",
67+
),
68+
"coverage_filters": attr.string_list(
69+
doc = "Additional coverage filters to add to SimpleCov. Only applied during 'bazel coverage'.",
70+
),
6471
}
6572

6673
# buildifier: disable=function-docstring
67-
def generate_rb_binary_script(ctx, binary, bundler = False, args = [], env = {}, java_bin = "", jars_home_strip_suffix = ""):
74+
def generate_rb_binary_script(ctx, binary, bundler = False, args = [], env = {}, java_bin = "", jars_home_strip_suffix = "", coverage_helper = "", is_jruby = False):
6875
toolchain = ctx.toolchains["@rules_ruby//ruby:toolchain_type"]
6976
if ctx.attr.ruby != None:
7077
toolchain = ctx.attr.ruby[platform_common.ToolchainInfo]
@@ -79,7 +86,7 @@ def generate_rb_binary_script(ctx, binary, bundler = False, args = [], env = {},
7986
# running binary scripts directly and should not affect normal `bazel run`.
8087
# See BATCH_RLOCATION_FUNCTION comments for more details.
8188
if binary_path.startswith("../") and not _is_windows(ctx):
82-
binary_path = _to_rlocation_path(binary)
89+
binary_path = _to_rlocation_path(ctx, binary)
8390
locate_binary_in_runfiles = "true"
8491
else:
8592
binary_path = _normalize_path(ctx, binary_path)
@@ -116,11 +123,13 @@ def generate_rb_binary_script(ctx, binary, bundler = False, args = [], env = {},
116123
"{env}": _convert_env_to_script(ctx, environment),
117124
"{bundler_command}": bundler_command,
118125
"{jars_home_strip_suffix}": jars_home_strip_suffix,
119-
"{ruby}": _to_rlocation_path(toolchain.ruby),
126+
"{ruby}": _to_rlocation_path(ctx, toolchain.ruby),
120127
"{ruby_binary_name}": toolchain.ruby.basename,
121128
"{java_bin}": java_bin,
122129
"{rlocation_function}": rlocation_function,
123130
"{locate_binary_in_runfiles}": locate_binary_in_runfiles,
131+
"{coverage_helper}": coverage_helper,
132+
"{is_jruby}": "true" if is_jruby else "",
124133
},
125134
)
126135

@@ -143,6 +152,7 @@ def rb_binary_impl(ctx):
143152
if ctx.attr.ruby != None:
144153
ruby_toolchain = ctx.attr.ruby[platform_common.ToolchainInfo]
145154
tools = list(ruby_toolchain.files)
155+
tools.append(ctx.file._coverage_helper)
146156

147157
if ruby_toolchain.version.startswith("jruby"):
148158
java_toolchain = ctx.toolchains["@bazel_tools//tools/jdk:runtime_toolchain_type"]
@@ -163,8 +173,8 @@ def rb_binary_impl(ctx):
163173

164174
# See https://bundler.io/v2.5/man/bundle-config.1.html for confiugration keys.
165175
env.update({
166-
"BUNDLE_GEMFILE": _to_rlocation_path(info.gemfile),
167-
"BUNDLE_PATH": _to_rlocation_path(info.path),
176+
"BUNDLE_GEMFILE": _to_rlocation_path(ctx, info.gemfile),
177+
"BUNDLE_PATH": _to_rlocation_path(ctx, info.path),
168178
})
169179
if len(bundler_srcs) > 0:
170180
transitive_srcs = depset(bundler_srcs, transitive = [transitive_srcs])
@@ -174,6 +184,9 @@ def rb_binary_impl(ctx):
174184
env.update(ruby_toolchain.env)
175185
env.update(ctx.attr.env)
176186

187+
if ctx.configuration.coverage_enabled and ctx.attr.coverage_filters:
188+
env["COVERAGE_FILTERS"] = ",".join(ctx.attr.coverage_filters)
189+
177190
runfiles = ctx.runfiles(tools, transitive_files = depset(transitive = [transitive_srcs, transitive_data]))
178191
runfiles = get_transitive_runfiles(runfiles, ctx.attr.srcs, ctx.attr.deps, ctx.attr.data)
179192
runfiles = runfiles.merge(ctx.attr._runfiles_library[DefaultInfo].default_runfiles)
@@ -190,6 +203,8 @@ def rb_binary_impl(ctx):
190203
env = env,
191204
java_bin = java_bin,
192205
jars_home_strip_suffix = jars_home_strip_suffix,
206+
coverage_helper = _to_rlocation_path(ctx, ctx.file._coverage_helper),
207+
is_jruby = ruby_toolchain.version.startswith("jruby"),
193208
)
194209

195210
return [
@@ -214,6 +229,7 @@ def rb_binary_impl(ctx):
214229
rb_binary = rule(
215230
implementation = rb_binary_impl,
216231
executable = True,
232+
fragments = ["coverage"],
217233
attrs = dict(
218234
ATTRS,
219235
srcs = LIBRARY_ATTRS["srcs"],

ruby/private/binary/binary.cmd.tpl

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ if "{bundler_command}" neq "" (
4040
)
4141
)
4242

43+
if "%COVERAGE%" == "1" (
44+
echo>&2 ERROR: Code coverage is currently not supported on Windows.
45+
exit 1
46+
)
47+
4348
{bundler_command} {ruby_binary_name} {binary} {args} %*
4449

4550
:: vim: ft=dosbatch

ruby/private/binary/binary.sh.tpl

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,13 @@ else
8181
binary="{binary}"
8282
fi
8383

84+
if [[ "${COVERAGE:-}" == "1" ]]; then
85+
export RUBYOPT="-r$(rlocation {coverage_helper}) ${RUBYOPT:-}"
86+
if [ -n "{is_jruby}" ]; then
87+
export JRUBY_OPTS="--debug ${JRUBY_OPTS:-}"
88+
fi
89+
fi
90+
8491
exec {bundler_command} {ruby_binary_name} $binary {args} "$@"
8592

8693
# vim: ft=bash

0 commit comments

Comments
 (0)