diff --git a/README.md b/README.md index 083751424..895d393eb 100644 --- a/README.md +++ b/README.md @@ -293,3 +293,31 @@ The version tag must match the regex defined on the Makefile (`tag_regex := ^v([ >**Note**: \ >Releases with `test` word in them are created as `draft`. \ >Releases with `beta` word in them are created as `pre-release`. + +### D coverage exclusions + +When file fixes are enabled, the CLI recognizes these markers in D `//` +comments, including indented comments and comments after executable code: + +```d +return double.nan; // LCOV_EXCL_LINE + +// LCOV_EXCL_START +fallback(); +// LCOV_EXCL_STOP +``` + +`LCOV_EXCL_LINE` excludes only the marked line. `LCOV_EXCL_START` and +`LCOV_EXCL_STOP` delimit an excluded block; `LCOV_EXCL_END` is also accepted +as a closing marker. Pair block markers in source order and do not nest them. +Branch-only exclusion markers are not supported for D's line-based reports. + +Markers must appear immediately after `//` and whitespace. Text in ordinary, +raw, or backtick strings, character literals, and block comments is ignored. +As a conservative limitation, files containing D token or delimited strings +(`q{...}` or `q"..."`) receive no exclusions. These forms require a fuller D +lexer to distinguish their contents safely. + +The source files must be available to the CLI. Exclusions are uploaded as +file-fix metadata; the original `.lst` reports are not modified. Setting +`--disable-file-fixes` disables this processing. diff --git a/codecov_cli/services/upload/d_file_fixes.py b/codecov_cli/services/upload/d_file_fixes.py new file mode 100644 index 000000000..341a6bd6a --- /dev/null +++ b/codecov_cli/services/upload/d_file_fixes.py @@ -0,0 +1,77 @@ +"""Collect explicit coverage markers from D line comments. + +This is deliberately not a D parser. Token/delimited strings are skipped +conservatively by returning no fixes for the file. +""" + +import re + +_MARKER = re.compile(r"//\s+LCOV_EXCL_(LINE|START|STOP|END)\b") + + +def d_exclusion_markers(source: str) -> set[tuple[int, str]]: + markers = set() + index, line = 0, 1 + quote = None + raw = False + block = False + nested = 0 + while index < len(source): + char = source[index] + pair = source[index : index + 2] + if char == "\n": + line += 1 + if block: + if pair == "*/": + block = False + index += 2 + continue + elif nested: + if pair == "/+": + nested += 1 + index += 2 + continue + if pair == "+/": + nested -= 1 + index += 2 + continue + elif quote: + if char == "\\" and not raw: + # Escapes cannot start comments or close a quoted literal. + if index + 1 < len(source) and source[index + 1] == "\n": + line += 1 + index += 2 + continue + if char == quote: + quote = None + elif pair == "//": + end = source.find("\n", index) + if end < 0: + end = len(source) + match = _MARKER.match(source[index:end]) + if match: + # Only send the marker, not explanatory text that might name + # another marker and confuse legacy block expansion. + markers.add((line, "// LCOV_EXCL_" + match[1] + "\n")) + index = end + continue + elif pair == "/*": + block = True + index += 2 + continue + elif pair == "/+": + nested = 1 + index += 2 + continue + elif pair in ('q"', "q{"): + # D token/delimited strings require a fuller lexer. Never risk + # treating their contents as instructions to exclude real code. + return set() + elif pair == 'r"': + quote, raw = '"', True + index += 2 + continue + elif char in ('"', "'", "`"): + quote, raw = char, char == "`" + index += 1 + return markers diff --git a/codecov_cli/services/upload/upload_collector.py b/codecov_cli/services/upload/upload_collector.py index 22310e2f4..358fcd6af 100644 --- a/codecov_cli/services/upload/upload_collector.py +++ b/codecov_cli/services/upload/upload_collector.py @@ -83,6 +83,9 @@ def _produce_file_fixes( ) file_regex_patterns = { + # D reports already identify executable lines. Only collect explicit + # exclusion markers, including those indented inside functions. + "*.d": fix_patterns_to_apply([], [], False), "*.kt": kt_patterns_to_apply, "*.go": go_patterns_to_apply, "*.dart": dart_patterns_to_apply, @@ -116,6 +119,14 @@ def _get_file_fixes( try: with open(filename, "r", encoding="utf-8") as f: + if path.suffix == ".d": + from codecov_cli.services.upload.d_file_fixes import ( + d_exclusion_markers, + ) + + return UploadCollectionResultFileFixer( + path, set(), d_exclusion_markers(f.read()), None + ) # If lineno is unset that means that the # file is empty thus the eof should be 0 # so lineno will be set to -1 here diff --git a/codecov_cli/services/upload/upload_sender.py b/codecov_cli/services/upload/upload_sender.py index db8658d3c..347fde67e 100644 --- a/codecov_cli/services/upload/upload_sender.py +++ b/codecov_cli/services/upload/upload_sender.py @@ -178,12 +178,29 @@ def _get_file_fixers( fixed_lines_with_reason = set( [fixer[0] for fixer in file_fixer.fixed_lines_with_reason] ) - total_fixed_lines = list( + total_fixed_lines = set( file_fixer.fixed_lines_without_reason.union(fixed_lines_with_reason) ) + # The JSON payload has no source text for the worker to expand. + # Match the legacy worker's pairing of sorted block boundaries + # before discarding their reasons. Unpaired markers exclude only + # their own lines, rather than the rest of the file. + starts, stops = [], [] + for line, source in file_fixer.fixed_lines_with_reason: + source = source.strip() + if source.startswith("/*") or "LCOV_EXCL_START" in source: + starts.append(line) + elif ( + source.endswith("*/") + or "LCOV_EXCL_STOP" in source + or "LCOV_EXCL_END" in source + ): + stops.append(line) + for start, stop in zip(sorted(starts), sorted(stops)): + total_fixed_lines.update(range(start + 1, stop)) file_fixers[file_fixer.path.as_posix()] = { "eof": file_fixer.eof, - "lines": total_fixed_lines, + "lines": sorted(total_fixed_lines), } return file_fixers diff --git a/tests/helpers/test_upload_sender.py b/tests/helpers/test_upload_sender.py index 2eb50eec3..78fff1104 100644 --- a/tests/helpers/test_upload_sender.py +++ b/tests/helpers/test_upload_sender.py @@ -69,9 +69,7 @@ def _upload_ingest_post_json_base(*, file_not_found: bool) -> dict: _report_payload_bytes_coverage = len( - UploadSender()._generate_payload( - upload_collection, {}, ReportType.COVERAGE - ) + UploadSender()._generate_payload(upload_collection, {}, ReportType.COVERAGE) ) request_data = { @@ -90,9 +88,7 @@ def _test_results_ingest_post_json( "commit": random_sha, "service": "github", "report_payload_bytes": len( - UploadSender()._generate_payload( - upload_data, {}, ReportType.TEST_RESULTS - ) + UploadSender()._generate_payload(upload_data, {}, ReportType.TEST_RESULTS) ), } @@ -515,7 +511,7 @@ def test_generate_payload_overall(self, mocked_coverage_file): }, "SwiftExample/Hello.swift": { "eof": 30, - "lines": [1, 17, 3, 22, 7, 9, 12, 14], + "lines": [1, 3, 7, 9, 12, 14, 17, 18, 19, 20, 21, 22], }, "SwiftExample/ViewController.swift": { "eof": None, @@ -606,3 +602,43 @@ def test_coverage_file_format(self, mocker, mocked_coverage_file): "data": "encoded_file_data", "labels": "", } + + +@pytest.mark.parametrize( + "start,stop", + [ + ("// LCOV_EXCL_START", "// LCOV_EXCL_STOP"), + ("// LCOV_EXCL_START", "// LCOV_EXCL_END"), + (" /*", " */"), + ], +) +def test_payload_expands_exclusion_blocks(start, stop): + fixes = UploadCollectionResultFileFixer( + Path("sample.cpp"), {1}, {(3, start), (6, stop)}, None + ) + data = UploadCollectionResult([], [], [fixes]) + payload = json.loads(UploadSender()._generate_payload(data, {})) + assert payload["report_fixes"]["value"]["sample.cpp"]["lines"] == [1, 3, 4, 5, 6] + + +@pytest.mark.parametrize( + "markers,expected", + [ + ({(3, "// LCOV_EXCL_START")}, [3]), + ({(6, "// LCOV_EXCL_STOP")}, [6]), + ({(3, "// LCOV_EXCL_LINE")}, [3]), + ( + { + (3, "// LCOV_EXCL_START"), + (5, "// LCOV_EXCL_STOP"), + (8, "// LCOV_EXCL_START"), + (10, "// LCOV_EXCL_END"), + }, + [3, 4, 5, 8, 9, 10], + ), + ], +) +def test_payload_exclusion_boundaries(markers, expected): + fixes = UploadCollectionResultFileFixer(Path("sample.cpp"), set(), markers, None) + data = UploadCollectionResult([], [], [fixes]) + assert UploadSender()._get_file_fixers(data)["sample.cpp"]["lines"] == expected diff --git a/tests/services/upload/test_d_file_fixes.py b/tests/services/upload/test_d_file_fixes.py new file mode 100644 index 000000000..d50f8dbab --- /dev/null +++ b/tests/services/upload/test_d_file_fixes.py @@ -0,0 +1,64 @@ +import pytest + +from codecov_cli.services.upload.d_file_fixes import d_exclusion_markers + + +@pytest.mark.parametrize( + "literal", + [ + '"// LCOV_EXCL_LINE"', + '"escaped \\" // LCOV_EXCL_LINE"', + "`// LCOV_EXCL_LINE`", + 'r"// LCOV_EXCL_LINE"', + '"first\n// LCOV_EXCL_START\nlast"', + "`first\n// LCOV_EXCL_START\nlast`", + ], +) +def test_markers_in_strings_are_not_exclusions(literal): + assert d_exclusion_markers(f"auto text = {literal};\n") == set() + + +@pytest.mark.parametrize( + "comment", + [ + "/*\n// LCOV_EXCL_START\n*/", + "/+ outer /+ inner +/\n// LCOV_EXCL_START\n+/", + ], +) +def test_markers_in_block_comments_are_not_exclusions(comment): + assert d_exclusion_markers(comment) == set() + + +@pytest.mark.parametrize("ending", ["STOP", "END"]) +def test_line_and_block_markers(ending): + source = ( + 'auto text = "// LCOV_EXCL_START"; // LCOV_EXCL_LINE reason\r\n' + "keep();\r\n" + " // LCOV_EXCL_START\r\n" + "excluded();\r\n" + f" // LCOV_EXCL_{ending}" + ) + assert d_exclusion_markers(source) == { + (1, "// LCOV_EXCL_LINE\n"), + (3, "// LCOV_EXCL_START\n"), + (5, f"// LCOV_EXCL_{ending}\n"), + } + + +def test_explanation_cannot_open_a_block(): + assert d_exclusion_markers("return; // LCOV_EXCL_LINE unlike LCOV_EXCL_START") == { + (1, "// LCOV_EXCL_LINE\n") + } + + +@pytest.mark.parametrize( + "source", ['auto text = q"[text]";', "auto text = q{ int n; };"] +) +def test_token_strings_disable_exclusions_conservatively(source): + assert d_exclusion_markers("// LCOV_EXCL_START\n" + source) == set() + + +def test_character_literal_and_invalid_marker(): + assert d_exclusion_markers( + "auto c = '\\''; // LCOV_EXCL_LINE\n// LCOV_EXCL_LINES\n" + ) == {(1, "// LCOV_EXCL_LINE\n")} diff --git a/tests/services/upload/test_upload_collector.py b/tests/services/upload/test_upload_collector.py index 38d2f3e45..fc1bd97df 100644 --- a/tests/services/upload/test_upload_collector.py +++ b/tests/services/upload/test_upload_collector.py @@ -222,3 +222,82 @@ def test_generate_network_with_no_versioning_system(tmp_path): (tmp_path / "coverage.xml").touch() found_files = versioning_system.list_relevant_files(tmp_path) assert len(found_files) == 1 + + +def test_fix_d_exclusion_blocks(tmp_path): + source = tmp_path / "sample.d" + source.write_text( + "void main() {\n" + " before();\n" + " // LCOV_EXCL_START\n" + " excluded();\n" + " // LCOV_EXCL_STOP\n" + " after();\n" + "}\n", + encoding="utf-8", + ) + collector = UploadCollector(None, None, None, None) + fixes = collector._produce_file_fixes([source]) + assert len(fixes) == 1 + assert fixes[0].eof is None + assert fixes[0].fixed_lines_without_reason == set() + assert fixes[0].fixed_lines_with_reason == { + (3, "// LCOV_EXCL_START\n"), + (5, "// LCOV_EXCL_STOP\n"), + } + + +def test_fix_d_exclusions_disabled(tmp_path): + source = tmp_path / "sample.d" + source.write_text("// LCOV_EXCL_START\nreturn;\n// LCOV_EXCL_END\n") + collector = UploadCollector(None, None, None, None, True) + assert collector._produce_file_fixes([source]) == [] + + +def test_fix_d_preserves_unmarked_code(tmp_path): + source = tmp_path / "sample.d" + source.write_text('void main() {\n auto text = "// LCOV_EXCL_START";\n}\n') + collector = UploadCollector(None, None, None, None) + fixes = collector._produce_file_fixes([source]) + assert fixes[0].fixed_lines_without_reason == set() + assert fixes[0].fixed_lines_with_reason == set() + + +def test_d_exclusion_block_in_upload_payload(tmp_path): + import json + from codecov_cli.services.upload.upload_sender import UploadSender + from codecov_cli.types import UploadCollectionResult + + source = tmp_path / "sample.d" + source.write_text( + "void main() {\n" + " before();\n" + " // LCOV_EXCL_START\n" + " excluded();\n" + " // LCOV_EXCL_STOP\n" + " after();\n" + "}\n" + ) + fixes = UploadCollector(None, None, None, None)._produce_file_fixes([source]) + payload = json.loads( + UploadSender()._generate_payload(UploadCollectionResult([], [], fixes), {}) + ) + assert payload["report_fixes"]["value"][source.as_posix()]["lines"] == [3, 4, 5] + + +def test_d_single_line_exclusion_in_upload_payload(tmp_path): + import json + from codecov_cli.services.upload.upload_sender import UploadSender + from codecov_cli.types import UploadCollectionResult + + source = tmp_path / "sample.d" + source.write_text( + 'auto text = "// LCOV_EXCL_LINE";\n' + "return double.nan; // LCOV_EXCL_LINE\n" + "keep();\n" + ) + fixes = UploadCollector(None, None, None, None)._produce_file_fixes([source]) + payload = json.loads( + UploadSender()._generate_payload(UploadCollectionResult([], [], fixes), {}) + ) + assert payload["report_fixes"]["value"][source.as_posix()]["lines"] == [2]