3 # SPDX-License-Identifier: MIT
6 # $ scanbuild-plist-to-junit.py /path/to/meson-logs/scanbuild/ > junit-report.xml
8 # Converts the plist output from scan-build into a JUnit-compatible XML.
10 # For use with meson, use a wrapper script with this content:
11 # scan-build -v --status-bugs -plist-html "$@"
13 # SCANBUILD="/abs/path/to/wrapper.sh" ninja -C builddir scan-build
15 # For file context, $PWD has to be the root source directory.
17 # Note that the XML format is tailored towards being useful in the gitlab
18 # CI, the JUnit format supports more features.
20 # This file is formatted with Python Black
26 from pathlib import Path
35 parser = argparse.ArgumentParser(
36 description="This tool convers scan-build's plist format to JUnit XML"
39 "directory", help="Path to a scan-build output directory", type=Path
41 args = parser.parse_args()
43 if not args.directory.exists():
44 print(f"Invalid directory: {args.directory}", file=sys.stderr)
47 # Meson places scan-build runs into a timestamped directory. To make it
48 # easier to invoke this script, we just glob everything on the assumption
49 # that there's only one scanbuild/$timestamp/ directory anyway.
50 for file in Path(args.directory).glob("**/*.plist"):
51 with open(file, "rb") as fd:
52 plist = plistlib.load(fd, fmt=plistlib.FMT_XML)
54 sources = plist["files"]
55 for elem in plist["diagnostics"]:
57 e.type = elem["type"] # Human-readable error type
58 e.description = elem["description"] # Longer description
59 e.func = elem["issue_context"] # function name
60 e.lineno = elem["location"]["line"]
61 filename = sources[elem["location"]["file"]]
62 # Remove the ../../../ prefix from the file
63 e.file = re.sub(r"^(\.\./)*", "", filename)
67 "Failed to access plist content, incompatible format?", file=sys.stderr
72 # Add a few lines of context for each error that we can print in the xml
73 # output. Note that e.lineno is 1-indexed.
75 # If one of the files fail, we stop doing this, we're probably in the wrong
80 for e in sorted(errors, key=lambda x: x.file):
81 if current_file != e.file:
83 lines = open(current_file).readlines()
85 # e.lineno is 1-indexed, lineno is our 0-indexed line number
87 start = max(0, lineno - 4)
88 end = min(len(lines), lineno + 5) # end is exclusive
90 f"{'>' if line == e.lineno else ' '} {line}: {content}"
91 for line, content in zip(range(start + 1, end), lines[start:end])
93 except FileNotFoundError:
96 print('<?xml version="1.0" encoding="utf-8"?>')
99 suites = sorted(set([s.type for s in errors]))
100 # Use a counter to ensure test names are unique, otherwise the CI
101 # display ignores duplicates.
104 errs = [e for e in errors if e.type == suite]
105 # Note: the grouping by suites doesn't actually do anything in gitlab. Oh well
106 print(f'<testsuite name="{suite}" failures="{len(errs)}" tests="{len(errs)}">')
110 <testcase name="{counter}. {error.type} - {error.file}:{error.lineno}" classname="{error.file}">
111 <failure message="{error.description}">
113 In function {error.func}(),
116 {error.file}:{error.lineno}
118 {"".join(error.context)}
124 print("</testsuite>")
126 # In case of success, add one test case so that registers in the UI
128 print('<testsuite name="scanbuild" failures="0" tests="1">')
129 print('<testcase name="scanbuild" classname="scanbuild"/>')
130 print("</testsuite>")
131 print("</testsuites>")