summaryrefslogtreecommitdiff
path: root/.gitlab-ci/scanbuild-plist-to-junit.py
blob: 987148a2e074098121e4eeac42655a9126d683f1 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
#!/usr/bin/env python3
#
# SPDX-License-Identifier: MIT
#
# Usage:
#   $ scanbuild-plist-to-junit.py /path/to/meson-logs/scanbuild/ > junit-report.xml
#
# Converts the plist output from scan-build into a JUnit-compatible XML.
#
# For use with meson, use a wrapper script with this content:
#   scan-build -v --status-bugs -plist-html "$@"
# then build with
#  SCANBUILD="/abs/path/to/wrapper.sh" ninja -C builddir scan-build
#
# For file context, $PWD has to be the root source directory.
#
# Note that the XML format is tailored towards being useful in the gitlab
# CI, the JUnit format supports more features.
#
# This file is formatted with Python Black

import argparse
import plistlib
import re
import sys
from pathlib import Path

errors = []


class Error(object):
    pass


parser = argparse.ArgumentParser(
    description="This tool convers scan-build's plist format to JUnit XML"
)
parser.add_argument(
    "directory", help="Path to a scan-build output directory", type=Path
)
args = parser.parse_args()

if not args.directory.exists():
    print(f"Invalid directory: {args.directory}", file=sys.stderr)
    sys.exit(1)

# Meson places scan-build runs into a timestamped directory. To make it
# easier to invoke this script, we just glob everything on the assumption
# that there's only one scanbuild/$timestamp/ directory anyway.
for file in Path(args.directory).glob("**/*.plist"):
    with open(file, "rb") as fd:
        plist = plistlib.load(fd, fmt=plistlib.FMT_XML)
        try:
            sources = plist["files"]
            for elem in plist["diagnostics"]:
                e = Error()
                e.type = elem["type"]  # Human-readable error type
                e.description = elem["description"]  # Longer description
                e.func = elem["issue_context"]  # function name
                e.lineno = elem["location"]["line"]
                filename = sources[elem["location"]["file"]]
                # Remove the ../../../ prefix from the file
                e.file = re.sub(r"^(\.\./)*", "", filename)
                errors.append(e)
        except KeyError:
            print(
                "Failed to access plist content, incompatible format?", file=sys.stderr
            )
            sys.exit(1)


# Add a few lines of context for each error that we can print in the xml
# output. Note that e.lineno is 1-indexed.
#
# If one of the files fail, we stop doing this, we're probably in the wrong
# directory.
try:
    current_file = None
    lines = []
    for e in sorted(errors, key=lambda x: x.file):
        if current_file != e.file:
            current_file = e.file
            lines = open(current_file).readlines()

        # e.lineno is 1-indexed, lineno is our 0-indexed line number
        lineno = e.lineno - 1
        start = max(0, lineno - 4)
        end = min(len(lines), lineno + 5)  # end is exclusive
        e.context = [
            f"{'>' if line == e.lineno else ' '} {line}: {content}"
            for line, content in zip(range(start + 1, end), lines[start:end])
        ]
except FileNotFoundError:
    pass

print('<?xml version="1.0" encoding="utf-8"?>')
print("<testsuites>")
if errors:
    suites = sorted(set([s.type for s in errors]))
    # Use a counter to ensure test names are unique, otherwise the CI
    # display ignores duplicates.
    counter = 0
    for suite in suites:
        errs = [e for e in errors if e.type == suite]
        # Note: the grouping by suites doesn't actually do anything in gitlab. Oh well
        print(f'<testsuite name="{suite}" failures="{len(errs)}" tests="{len(errs)}">')
        for error in errs:
            print(
                f"""\
<testcase name="{counter}. {error.type} - {error.file}:{error.lineno}" classname="{error.file}">
<failure message="{error.description}">
<![CDATA[
In function {error.func}(),
{error.description}

{error.file}:{error.lineno}
---
{"".join(error.context)}
]]>
</failure>
</testcase>"""
            )
            counter += 1
        print("</testsuite>")
else:
    # In case of success, add one test case so that registers in the UI
    # properly
    print('<testsuite name="scanbuild" failures="0" tests="1">')
    print('<testcase name="scanbuild" classname="scanbuild"/>')
    print("</testsuite>")
print("</testsuites>")