sanitycheck: Add option to use gcovr for coverage

gcovr is already a dependency in scripts/requirements.txt. The
visualization is different, but the functionality should be the same.
Tested with gcovr 4.2.

Relates to #17626.

Signed-off-by: Christian Taedcke <hacking@taedcke.com>
diff --git a/scripts/sanitycheck b/scripts/sanitycheck
index 713c1b0..ad21f57 100755
--- a/scripts/sanitycheck
+++ b/scripts/sanitycheck
@@ -3420,6 +3420,9 @@
                         help="Path to the gcov tool to use for code coverage "
                         "reports")
 
+    parser.add_argument("--coverage-tool", choices=['lcov', 'gcovr'], default='lcov',
+                        help="Tool to use to generate coverage report.")
+
     return parser.parse_args()
 
 
@@ -3470,110 +3473,194 @@
          (sc.rom_size, sc.ram_size))
     info("")
 
-def retrieve_gcov_data(intput_file):
-    if VERBOSE:
-        print("Working on %s" %intput_file)
-    extracted_coverage_info = {}
-    capture_data = False
-    capture_complete = False
-    with open(intput_file, 'r') as fp:
-        for line in fp.readlines():
-            if re.search("GCOV_COVERAGE_DUMP_START", line):
-                capture_data = True
-                continue
-            if re.search("GCOV_COVERAGE_DUMP_END", line):
-                capture_complete = True
-                break
-            # Loop until the coverage data is found.
-            if not capture_data:
-                continue
-            if line.startswith("*"):
-                sp = line.split("<")
-                if len(sp) > 1:
-                    # Remove the leading delimiter "*"
-                    file_name = sp[0][1:]
-                    # Remove the trailing new line char
-                    hex_dump = sp[1][:-1]
+class CoverageTool:
+    """ Base class for every supported coverage tool
+    """
+
+    def __init__(self):
+        self.gcov_tool = options.gcov_tool
+
+    @staticmethod
+    def factory(tool):
+        if tool == 'lcov':
+            return Lcov()
+        if tool == 'gcovr':
+            return Gcovr()
+        error("Unsupported coverage tool specified: {}".format(tool))
+
+    @staticmethod
+    def retrieve_gcov_data(intput_file):
+        if VERBOSE:
+            print("Working on %s" %intput_file)
+        extracted_coverage_info = {}
+        capture_data = False
+        capture_complete = False
+        with open(intput_file, 'r') as fp:
+            for line in fp.readlines():
+                if re.search("GCOV_COVERAGE_DUMP_START", line):
+                    capture_data = True
+                    continue
+                if re.search("GCOV_COVERAGE_DUMP_END", line):
+                    capture_complete = True
+                    break
+                # Loop until the coverage data is found.
+                if not capture_data:
+                    continue
+                if line.startswith("*"):
+                    sp = line.split("<")
+                    if len(sp) > 1:
+                        # Remove the leading delimiter "*"
+                        file_name = sp[0][1:]
+                        # Remove the trailing new line char
+                        hex_dump = sp[1][:-1]
+                    else:
+                        continue
                 else:
                     continue
-            else:
+                extracted_coverage_info.update({file_name:hex_dump})
+        if not capture_data:
+            capture_complete = True
+        return {'complete': capture_complete, 'data': extracted_coverage_info}
+
+    @staticmethod
+    def create_gcda_files(extracted_coverage_info):
+        if VERBOSE:
+            print("Generating gcda files")
+        for filename, hexdump_val in extracted_coverage_info.items():
+            # if kobject_hash is given for coverage gcovr fails
+            # hence skipping it problem only in gcovr v4.1
+            if "kobject_hash" in filename:
+                filename = (filename[:-4]) +"gcno"
+                try:
+                    os.remove(filename)
+                except Exception:
+                    pass
                 continue
-            extracted_coverage_info.update({file_name:hex_dump})
-    if not capture_data:
-        capture_complete = True
-    return {'complete': capture_complete, 'data': extracted_coverage_info}
 
-def create_gcda_files(extracted_coverage_info):
-    if VERBOSE:
-        print("Generating gcda files")
-    for filename, hexdump_val in extracted_coverage_info.items():
-        # if kobject_hash is given for coverage gcovr fails
-        # hence skipping it problem only in gcovr v4.1
-        if "kobject_hash" in filename:
-            filename = (filename[:-4]) +"gcno"
-            try:
-                os.remove(filename)
-            except Exception:
-                pass
-            continue
+            with open(filename, 'wb') as fp:
+                fp.write(bytes.fromhex(hexdump_val))
 
-        with open(filename, 'wb') as fp:
-            fp.write(bytes.fromhex(hexdump_val))
 
-def generate_coverage(outdir, ignores):
+    def generate(self, outdir):
+        for filename in glob.glob("%s/**/handler.log" % outdir, recursive=True):
+            gcov_data = self.__class__.retrieve_gcov_data(filename)
+            capture_complete = gcov_data['complete']
+            extracted_coverage_info = gcov_data['data']
+            if capture_complete:
+                self.__class__.create_gcda_files(extracted_coverage_info)
+                verbose("Gcov data captured: {}".format(filename))
+            else:
+                error("Gcov data capture incomplete: {}".format(filename))
 
-    for filename in glob.glob("%s/**/handler.log" %outdir, recursive=True):
-        gcov_data = retrieve_gcov_data(filename)
-        capture_complete = gcov_data['complete']
-        extracted_coverage_info = gcov_data['data']
-        if capture_complete:
-            create_gcda_files(extracted_coverage_info)
-            verbose("Gcov data captured: {}".format(filename))
-        else:
-            error("Gcov data capture incomplete: {}".format(filename))
+        with open(os.path.join(outdir, "coverage.log"), "a") as coveragelog:
+            ret = self._generate(outdir, coveragelog)
+            if ret == 0:
+                info("HTML report generated: {}".format(
+                    os.path.join(outdir, "coverage", "index.html")))
 
-    gcov_tool = options.gcov_tool
 
-    with open(os.path.join(outdir, "coverage.log"), "a") as coveragelog:
+class Lcov(CoverageTool):
+
+    def __init__(self):
+        super().__init__()
+        self.ignores = []
+
+    def add_ignore_file(self, pattern):
+        self.ignores.append('*' + pattern + '*')
+
+    def add_ignore_directory(self, pattern):
+        self.ignores.append(pattern + '/*')
+
+    def _generate(self, outdir, coveragelog):
         coveragefile = os.path.join(outdir, "coverage.info")
         ztestfile = os.path.join(outdir, "ztest.info")
-        subprocess.call(["lcov", "--gcov-tool", gcov_tool,
-                            "--capture", "--directory", outdir,
-                            "--rc", "lcov_branch_coverage=1",
-                            "--output-file", coveragefile], stdout=coveragelog)
+        subprocess.call(["lcov", "--gcov-tool", self.gcov_tool,
+                         "--capture", "--directory", outdir,
+                         "--rc", "lcov_branch_coverage=1",
+                         "--output-file", coveragefile], stdout=coveragelog)
         # We want to remove tests/* and tests/ztest/test/* but save tests/ztest
-        subprocess.call(["lcov", "--gcov-tool", gcov_tool, "--extract", coveragefile,
+        subprocess.call(["lcov", "--gcov-tool", self.gcov_tool, "--extract",
+                         coveragefile,
                          os.path.join(ZEPHYR_BASE, "tests", "ztest", "*"),
                          "--output-file", ztestfile,
                          "--rc", "lcov_branch_coverage=1"], stdout=coveragelog)
 
         if os.path.exists(ztestfile) and os.path.getsize(ztestfile) > 0:
-            subprocess.call(["lcov", "--gcov-tool", gcov_tool, "--remove", ztestfile,
+            subprocess.call(["lcov", "--gcov-tool", self.gcov_tool, "--remove",
+                             ztestfile,
                              os.path.join(ZEPHYR_BASE, "tests/ztest/test/*"),
                              "--output-file", ztestfile,
                              "--rc", "lcov_branch_coverage=1"],
-                             stdout=coveragelog)
+                            stdout=coveragelog)
             files = [coveragefile, ztestfile]
         else:
             files = [coveragefile]
 
-        for i in ignores:
+        for i in self.ignores:
             subprocess.call(
-                ["lcov", "--gcov-tool", gcov_tool, "--remove",
-                    coveragefile, i, "--output-file",
-                    coveragefile, "--rc", "lcov_branch_coverage=1"],
+                ["lcov", "--gcov-tool", self.gcov_tool, "--remove",
+                 coveragefile, i, "--output-file",
+                 coveragefile, "--rc", "lcov_branch_coverage=1"],
                 stdout=coveragelog)
 
-        #The --ignore-errors source option is added to avoid it exiting due to
-        #samples/application_development/external_lib/
-        ret = subprocess.call(["genhtml", "--legend", "--branch-coverage",
-                               "--ignore-errors", "source",
-                               "-output-directory",
-                               os.path.join(outdir, "coverage")] + files,
+        # The --ignore-errors source option is added to avoid it exiting due to
+        # samples/application_development/external_lib/
+        return subprocess.call(["genhtml", "--legend", "--branch-coverage",
+                                "--ignore-errors", "source",
+                                "-output-directory",
+                                os.path.join(outdir, "coverage")] + files,
                                stdout=coveragelog)
-        if ret==0:
-            info("HTML report generated: %s"%
-                 os.path.join(outdir, "coverage","index.html"))
+
+
+class Gcovr(CoverageTool):
+
+    def __init__(self):
+        super().__init__()
+        self.ignores = []
+
+    def add_ignore_file(self, pattern):
+        self.ignores.append('.*' + pattern + '.*')
+
+    def add_ignore_directory(self, pattern):
+        self.ignores.append(pattern + '/.*')
+
+    @staticmethod
+    def _interleave_list(prefix, list):
+        tuple_list = [(prefix, item) for item in list]
+        return [item for sublist in tuple_list for item in sublist]
+
+    def _generate(self, outdir, coveragelog):
+        coveragefile = os.path.join(outdir, "coverage.json")
+        ztestfile = os.path.join(outdir, "ztest.json")
+
+        excludes = Gcovr._interleave_list("-e", self.ignores)
+
+        # We want to remove tests/* and tests/ztest/test/* but save tests/ztest
+        subprocess.call(["gcovr", "-r", ZEPHYR_BASE, "--gcov-executable",
+                         self.gcov_tool, "-e", "tests/*"] + excludes +
+                        ["--json", "-o", coveragefile, outdir],
+                        stdout=coveragelog)
+
+        subprocess.call(["gcovr", "-r", ZEPHYR_BASE, "--gcov-executable",
+                         self.gcov_tool, "-f", "tests/ztest", "-e",
+                         "tests/ztest/test/*", "--json", "-o", ztestfile,
+                         outdir], stdout=coveragelog)
+
+        if os.path.exists(ztestfile) and os.path.getsize(ztestfile) > 0:
+            files = [coveragefile, ztestfile]
+        else:
+            files = [coveragefile]
+
+        subdir = os.path.join(outdir, "coverage")
+        os.makedirs(subdir, exist_ok=True)
+
+        tracefiles = self._interleave_list("--add-tracefile", files)
+
+        return subprocess.call(["gcovr", "-r", ZEPHYR_BASE, "--html",
+                                "--html-details"] + tracefiles +
+                               ["-o", os.path.join(subdir, "index.html")],
+                               stdout=coveragelog)
+
 
 def get_generator():
     if options.ninja:
@@ -3990,7 +4077,11 @@
                     "i586-zephyr-elf/bin/i586-zephyr-elf-gcov")
 
         info("Generating coverage files...")
-        generate_coverage(options.outdir, ["*generated*", "tests/*", "samples/*"])
+        coverage_tool = CoverageTool.factory(options.coverage_tool)
+        coverage_tool.add_ignore_file('generated')
+        coverage_tool.add_ignore_directory('tests')
+        coverage_tool.add_ignore_directory('samples')
+        coverage_tool.generate(options.outdir)
 
     if options.device_testing:
         print("\nHardware distribution summary:\n")