Repository: P3HPC/code-base-investigator

Issue:
Extend coverage format to support computing coverage
### Feature/behavior summary

The current coverage format (from the P3 Analysis Library) was designed to be sufficient for computing code divergence, which does not need to know anything about the number of unused lines of code. Now that we have true coverage-related functionality, it is apparent that our "coverage" format lacks sufficient information to compute coverage.

### Request attributes

- [ ] Would this be a refactor of existing code?
- [ ] Does this proposal require new package dependencies?
- [ ] Would this change break backwards compatibility?

### Related issues

- #149
- #161 

### Solution description

The coverage format needs to include enough information to calculate the ratio of used lines to total lines. This could take several forms, for example:
- Separate lists of used lines and unused lines;
- A list of used lines and a number of unused lines; or
- A list of used lines and a number of total lines.

### Additional notes

Whatever we decide here will need to be mirrored in the P3 Analysis Library as well.

This is not a breaking change to either project: the coverage functionality here has not been included in any release, and the P3 Analysis Library is still in pre-release.

Hints from issue comments:
@Pennycook: @laserkelvin - I think my preference here is to replace the existing `lines` list with separate `used_lines` and `unused_lines` lists.

We could also consider using `compiled_lines` and `uncompiled_lines`, to leave the door open to future functionality (e.g., `executed_lines` and `unexecuted_lines`).

Any thoughts?

Solution patch:
diff --git a/codebasin/__main__.py b/codebasin/__main__.py
index 8f8e324..46f78d2 100755
--- a/codebasin/__main__.py
+++ b/codebasin/__main__.py
@@ -96,7 +96,7 @@ def _main():
         metavar="<report>",
         action="append",
         default=[],
-        choices=["all", "summary", "clustering", "duplicates", "files"],
+        choices=["all", "summary", "clustering", "duplicates"],
         help=_help_string(
             "Generate a report of the specified type:",
             "- summary: code divergence information",
@@ -246,10 +246,6 @@ def _main():
     if report_enabled("summary"):
         report.summary(setmap)
 
-    # Print files report
-    if report_enabled("files"):
-        report.files(codebase, state)
-
     # Print clustering report
     if report_enabled("clustering"):
         basename = os.path.basename(args.analysis_file)
diff --git a/codebasin/config.py b/codebasin/config.py
index d8bd2b8..f7aca83 100644
--- a/codebasin/config.py
+++ b/codebasin/config.py
@@ -6,7 +6,6 @@ defining a specific code base configuration.
 """
 
 import argparse
-import collections
 import logging
 import os
 import pkgutil
@@ -14,6 +13,7 @@ import re
 import string
 import tomllib
 from dataclasses import asdict, dataclass, field
+from itertools import chain
 from pathlib import Path
 from typing import Self
 
@@ -21,64 +21,9 @@ from codebasin import CompilationDatabase, util
 
 log = logging.getLogger(__name__)
 
-
-_importcfg = None
 _compilers = None
 
 
-def _parse_compiler_args(argv: list[str]):
-    """
-    Parameters
-    ----------
-    argv: list[str]
-        A list of arguments passed to a compiler.
-
-    Returns
-    -------
-    argparse.Namespace
-        The result of parsing `argv[1:]`.
-        - defines: -D arguments
-        - include_paths: -I/-isystem arguments
-        - include_files: -include arguments
-    """
-    parser = argparse.ArgumentParser()
-    parser.add_argument("-D", dest="defines", action="append", default=[])
-    parser.add_argument(
-        "-I",
-        "-isystem",
-        dest="include_paths",
-        action="append",
-        default=[],
-    )
-    parser.add_argument(
-        "-include",
-        dest="include_files",
-        action="append",
-        default=[],
-    )
-    args, _ = parser.parse_known_args(argv)
-    return args
-
-
-def load_importcfg():
-    """
-    Load the import configuration file, if it exists.
-    """
-    global _importcfg
-    _importcfg = collections.defaultdict(list)
-    path = ".cbi/config"
-    if os.path.exists(path):
-        log.info(f"Found configuration file at {path}")
-        with open(path, "rb") as f:
-            try:
-                _importcfg_toml = util._load_toml(f, "cbiconfig")
-            except ValueError as e:
-                log.error(str(e))
-                return
-        for name, compiler in _importcfg_toml["compiler"].items():
-            _importcfg[name] = compiler["options"]
-
-
 class _StoreSplitAction(argparse.Action):
     """
     A custom argparse.Action that splits the value based on a user-provided
@@ -222,6 +167,7 @@ def _load_compilers():
     """
     Load the configuration from the following files:
     - ${PACKAGE}/compilers/*.toml
+    - .cbi/config
     """
     global _compilers
     _compilers = {}
@@ -241,6 +187,67 @@ def _load_compilers():
         for name, definition in toml["compiler"].items():
             _compilers[name] = _Compiler.from_toml(definition)
 
+    # Check for any user-defined compiler behavior.
+    path = ".cbi/config"
+    if os.path.exists(path):
+        log.info(f"Found configuration file at {path}.")
+        with open(path, "rb") as f:
+            try:
+                toml = util._load_toml(f, "cbiconfig")
+            except ValueError as e:
+                log.error(str(e))
+                return
+
+        for name, definition in toml["compiler"].items():
+            # Check if the user is defining a new compiler.
+            if name not in _compilers:
+                _compilers[name] = _Compiler.from_toml(definition)
+                continue
+
+            compiler = _compilers[name]
+
+            # Check if the user is redefining a compiler as an alias.
+            # Warn because options may be lost.
+            if "alias_of" in definition:
+                log.warning(
+                    f'{name} redefined as alias of {definition["alias_of"]}.',
+                )
+                _compilers[name] = _Compiler.from_toml(definition)
+                continue
+
+            # Check if the user is redefining an alias as a compiler.
+            # Warn because options may be lost.
+            if compiler.alias_of:
+                log.warning(
+                    f"definition of {name} in .cbi/config overrides alias.",
+                )
+                compiler.alias_of = None
+
+            # Append options, parser options, modes and passes.
+            # Warn if modes/passes are redefined because options may be lost.
+            if "options" in definition:
+                compiler.options.extend(definition["options"])
+            if "parser" in definition:
+                for option in definition["parser"]:
+                    option = option.copy()
+                    if option["action"] == "store_split":
+                        option["action"] = _StoreSplitAction
+                    if option["action"] == "extend_match":
+                        option["action"] = _ExtendMatchAction
+                    compiler.parser.append(option)
+            if "modes" in definition:
+                for m in definition["modes"]:
+                    name = m["name"]
+                    if name in compiler.modes:
+                        log.warning(f"compiler mode '{name}' redefined")
+                    compiler.modes[name] = _CompilerMode.from_toml(m)
+            if "passes" in definition:
+                for p in definition["passes"]:
+                    name = p["name"]
+                    if name in compiler.passes:
+                        log.warning(f"compiler pass '{name}' redefined")
+                    compiler.passes[name] = _CompilerPass.from_toml(p)
+
 
 # Load the compiler configuration when this module is imported.
 if not _compilers:
@@ -262,6 +269,21 @@ class PreprocessorConfiguration:
     include_files: list[str]
     pass_name: str = "default"
 
+    def _update(self, pass_or_mode: _CompilerPass | _CompilerMode):
+        """
+        Update this PreprocessorConfiguration by extending the
+        defines, include paths and include files using the values
+        contained in the provided _CompilerPass or _CompilerMode.
+
+        Parameters
+        ----------
+        pass_or_mode: _CompilerPass | _CompilerMode
+            The pass or mode to enable.
+        """
+        self.defines.extend(pass_or_mode.defines)
+        self.include_paths.extend(pass_or_mode.include_paths)
+        self.include_files.extend(pass_or_mode.include_files)
+
 
 class ArgumentParser:
     """
@@ -271,10 +293,36 @@ class ArgumentParser:
     def __init__(self, path: str):
         self.name = os.path.basename(path)
 
-        # Check for any user-defined compiler behavior.
-        # Currently, users can only override default defines.
-        if _importcfg is None:
-            load_importcfg()
+        self.compiler = _Compiler()
+        if self.name not in _compilers:
+            log.warning(f"Compiler '{self.name}' not recognized.")
+            return
+
+        # If a compiler is not an alias, use its configuration directly.
+        if not _compilers[self.name].alias_of:
+            self.compiler = _compilers[self.name]
+            log.info(f"Compiler '{self.name}' recognized.")
+
+        # If a compiler is an alias of another, resolve the alias.
+        # An alias may itself be an alias, so we may need to iterate.
+        alias_chain = [self.name]
+        while _compilers[alias_chain[-1]].alias_of:
+            alias = _compilers[alias_chain[-1]].alias_of
+            if alias in alias_chain:
+                log.error(f"Compiler '{self.name}' alias results in a loop.")
+                return
+            if alias not in _compilers:
+                log.error(
+                    f"Compiler '{self.name}' aliases unrecognized '{alias}'.",
+                )
+                return
+            alias_chain.append(alias)
+
+        alias = alias_chain[-1]
+        self.compiler = _compilers[alias]
+        log.info(
+            f"Compiler '{self.name}' recognized; aliases '{alias}'.",
+        )
 
     def parse_args(self, argv: list[str]) -> list[PreprocessorConfiguration]:
         """
@@ -290,186 +338,105 @@ class ArgumentParser:
             a separate pass, that describe the compiler's behavior
             after parsing `argv`.
         """
-        args = _parse_compiler_args(argv + _importcfg[self.name])
-        configuration = PreprocessorConfiguration(
-            args.defines,
-            args.include_paths,
-            args.include_files,
+        # There may be no actions defined with these destinations.
+        namespace = argparse.Namespace()
+        namespace.defines = []
+        namespace.include_paths = []
+        namespace.include_files = []
+        namespace.modes = []
+
+        # "passes" requires special handling to emulate compilers accurately.
+        # - passes: Passes from built-in actions (e.g., store, append).
+        # - _passes: Flag-specific defaults from custom actions.
+        namespace.passes = []
+        namespace._passes = {}
+
+        # Configure the parser for arguments common to all compilers.
+        parser = argparse.ArgumentParser(
+            add_help=False,
+            exit_on_error=False,
+            allow_abbrev=False,
         )
-        return [configuration]
-
-
-class ClangArgumentParser(ArgumentParser):
-    """
-    Represents the behavior of Clang-based compilers.
-    """
-
-    device_passes = [
-        "spir64",
-        "x86_64",
-        "spir64_x86_64",
-        "spir64_gen",
-        "spir64_fpga",
-    ]
-
-    def parse_args(self, argv: list[str]) -> list[PreprocessorConfiguration]:
-        args = _parse_compiler_args(argv + _importcfg[self.name])
-
-        sycl = False
-        omp = False
-        sycl_targets = []
-        passes = {"default"}
-
-        for arg in argv:
-            if arg == "-fsycl":
-                sycl = True
-                continue
-
-            m = re.search("-fsycl-targets=", arg)
-            if m:
-                sycl_targets = arg.split("=")[1].split(",")
-                passes |= set(sycl_targets)
-                continue
-
-            if arg in ["-fopenmp", "-fiopenmp", "-qopenmp"]:
-                omp = True
-                continue
-
-            if arg in ["-fsycl-is-device"]:
-                args.defines.append("__SYCL_DEVICE_ONLY__")
-                continue
-
-        if sycl and sycl_targets == []:
-            passes |= {"spir64"}
-
-        configurations = []
-        for pass_ in passes:
-            defines = args.defines.copy()
-            include_files = args.include_files.copy()
-            include_paths = args.include_paths.copy()
-
-            if pass_ in ClangArgumentParser.device_passes:
-                defines.append("__SYCL_DEVICE_ONLY__")
-
-            if "spir64" in pass_ or pass_ == "x86_64":
-                defines.append("__SPIR__")
-                defines.append("__SPIRV__")
-
-            if pass_ == "default" and omp:
-                defines.append("_OPENMP")
-
-            configuration = PreprocessorConfiguration(
-                defines,
-                include_paths,
-                include_files,
-                pass_name=pass_,
-            )
-
-            configurations.append(configuration)
-
-        return configurations
-
-
-class GnuArgumentParser(ArgumentParser):
-    """
-    Represents the behavior of GNU-based compilers.
-    """
-
-    def parse_args(self, argv: list[str]) -> list[PreprocessorConfiguration]:
-        args = _parse_compiler_args(argv + _importcfg[self.name])
-        for arg in argv:
-            if arg == "-fopenmp":
-                args.defines.append("_OPENMP")
-        configuration = PreprocessorConfiguration(
-            args.defines,
-            args.include_paths,
-            args.include_files,
+        parser.add_argument("-D", dest="defines", action="append")
+        parser.add_argument(
+            "-I",
+            "-isystem",
+            dest="include_paths",
+            action="append",
+        )
+        parser.add_argument(
+            "-include",
+            dest="include_files",
+            action="append",
         )
-        return [configuration]
-
-
-class NvccArgumentParser(ArgumentParser):
-    """
-    Represents the behavior of the NVCC compiler.
-    """
-
-    def parse_args(self, argv: list[str]) -> list[PreprocessorConfiguration]:
-        args = _parse_compiler_args(argv + _importcfg[self.name])
 
-        omp = False
-        passes = {"default"}
+        # Suppress warnings for common arguments we don't care about.
+        parser.add_argument("-O", dest=None)
+        parser.add_argument("-o", dest=None)
+        parser.add_argument("-g", action="store_const", dest=None)
+        parser.add_argument("-c", action="store_const", dest=None)
+        parser.add_argument("file", nargs="*")
+
+        # Add additional options for this specific compiler.
+        for option in self.compiler.parser:
+            kwargs = {k: v for k, v in option.items() if k != "flags"}
+
+            # If a custom action, handle special-case for default passes.
+            if not isinstance(kwargs["action"], str):
+                if (
+                    "dest" in kwargs
+                    and kwargs["dest"] == "passes"
+                    and "default" in kwargs
+                ):
+                    default_value = kwargs.pop("default")
+                    flag_name = option["flags"][0]
+                    namespace._passes[flag_name] = default_value
+            parser.add_argument(*option["flags"], **kwargs)
+
+        # Make a best-effort attempt to parse arguments.
+        args, unrecognized = parser.parse_known_args(
+            argv + self.compiler.options,
+            namespace,
+        )
+        if unrecognized:
+            log.warning(f"Unrecognized arguments: '{' '.join(unrecognized)}'")
 
-        for arg in argv:
-            archs = re.findall("sm_(\\d+)", arg)
-            archs += re.findall("compute_(\\d+)", arg)
-            passes |= set(archs)
+        # Construct final list of active modes.
+        args.modes = set(args.modes)
 
-            if arg in ["-fopenmp", "-fiopenmp", "-qopenmp"]:
-                omp = True
-                continue
+        # Construct final list of active passes.
+        args.passes = set(args.passes)
+        args.passes |= set(chain(*args._passes.values()))
+        args.passes |= {"default"}
 
+        # Convert the arguments into a list of preprocessor configurations.
         configurations = []
-        for pass_ in passes:
-            defines = args.defines.copy()
-            include_files = args.include_files.copy()
-            include_paths = args.include_paths.copy()
-
-            defines.append("__NVCC__")
-            defines.append("__CUDACC__")
-
-            if pass_ != "default":
-                # The __CUDA_ARCH__ macro always has three digits.
-                # Multiplying the SM version by 10 gives the macro value.
-                # e.g., sm_71 corresponds to __CUDA_ARCH__=710.
-                arch = int(pass_) * 10
-                defines.append(f"__CUDA_ARCH__={arch}")
-
-            if pass_ == "default" and omp:
-                defines.append("_OPENMP")
-
-            configuration = PreprocessorConfiguration(
-                defines,
-                include_paths,
-                include_files,
-                pass_name=pass_,
+        for pass_name in args.passes:
+            config = PreprocessorConfiguration(
+                args.defines.copy(),
+                args.include_paths.copy(),
+                args.include_files.copy(),
+                pass_name,
             )
 
-            configurations.append(configuration)
-
-        return configurations
-
+            if pass_name == "default":
+                modes = args.modes
+            else:
+                if pass_name not in self.compiler.passes:
+                    log.error(f"Unrecognized compiler pass: {pass_name}")
+                    continue
+                config._update(self.compiler.passes[pass_name])
+                modes = self.compiler.passes[pass_name].modes
 
-_seen_compiler = collections.defaultdict(lambda: False)
+            for mode_name in modes:
+                if mode_name not in self.compiler.modes:
+                    log.error(f"Unrecognized compiler mode: {mode_name}")
+                    continue
+                config._update(self.compiler.modes[mode_name])
 
+            configurations.append(config)
 
-def recognize_compiler(path: str) -> ArgumentParser:
-    """
-    Attempt to recognize the compiler, given a path.
-    Return a ArgumentParser object.
-    """
-    parser = None
-    compiler_name = os.path.basename(path)
-    if compiler_name in ["clang", "clang++"]:
-        parser = ClangArgumentParser(path)
-    elif compiler_name in ["gcc", "g++"]:
-        parser = GnuArgumentParser(path)
-    elif compiler_name in ["icx", "icpx", "ifx"]:
-        parser = ClangArgumentParser(path)
-    elif compiler_name == "nvcc":
-        parser = NvccArgumentParser(path)
-    else:
-        parser = ArgumentParser(path)
-
-    if not _seen_compiler[compiler_name]:
-        if parser:
-            log.info(f"Recognized compiler: {compiler_name}.")
-        else:
-            log.warning(
-                f"Unrecognized compiler: {compiler_name}. "
-                + "Some implicit behavior may be missed.",
-            )
-        _seen_compiler[compiler_name] = True
-    return parser
+        return configurations
 
 
 def load_database(dbpath, rootdir):
@@ -513,7 +480,7 @@ def load_database(dbpath, rootdir):
 
         # Parse command-line arguments, emulating compiler-specific behavior.
         compiler_name = os.path.basename(command.arguments[0])
-        parser = recognize_compiler(compiler_name)
+        parser = ArgumentParser(compiler_name)
         preprocessor_configs = parser.parse_args(command.arguments[1:])
 
         # Create a configuration entry for each compiler pass.
diff --git a/codebasin/coverage/__main__.py b/codebasin/coverage/__main__.py
index fbfb20d..8c221c7 100755
--- a/codebasin/coverage/__main__.py
+++ b/codebasin/coverage/__main__.py
@@ -133,19 +133,22 @@ def _compute(args: argparse.Namespace):
         with open(filename, "rb") as f:
             digest = hashlib.file_digest(f, "sha512")
 
-        lines = []
+        used_lines = []
+        unused_lines = []
         tree = state.get_tree(filename)
         association = state.get_map(filename)
         for node in [n for n in tree.walk() if isinstance(n, CodeNode)]:
             if association[node] == frozenset([]):
-                continue
-            lines.extend(node.lines)
+                unused_lines.extend(node.lines)
+            else:
+                used_lines.extend(node.lines)
 
         covarray.append(
             {
                 "file": relative_path,
                 "id": digest.hexdigest(),
-                "lines": lines,
+                "used_lines": used_lines,
+                "unused_lines": unused_lines,
             },
         )
 
diff --git a/codebasin/report.py b/codebasin/report.py
index 0270a4f..fb7638f 100644
--- a/codebasin/report.py
+++ b/codebasin/report.py
@@ -649,9 +649,11 @@ class FileTree:
     def _print(
         self,
         node: Node,
+        depth: int = 0,
         prefix: str = "",
         connector: str = "",
         fancy: bool = True,
+        levels: int = None,
     ):
         """
         Recursive helper function to print all nodes in a FileTree.
@@ -669,7 +671,14 @@ class FileTree:
 
         fancy: bool, default: True
             Whether to use fancy formatting (including colors).
+
+        levels: int, optional
+            The maximum number of levels to print.
         """
+        # Skip this node and its children if we have hit the maximum depth.
+        if levels and depth > levels:
+            return []
+
         if fancy:
             dash = "\u2500"
             cont = "\u251C"
@@ -722,14 +731,16 @@ class FileTree:
                 next_connector = cont
             lines += self._print(
                 node.children[name],
+                depth + 1,
                 next_prefix,
                 next_connector,
                 fancy,
+                levels,
             )
 
         return lines
 
-    def write_to(self, stream: TextIO):
+    def write_to(self, stream: TextIO, levels: int = None):
         """
         Write the FileTree to the specified stream.
 
@@ -737,8 +748,12 @@ class FileTree:
         ----------
         stream: TextIO
             The text stream to write to.
+
+        levels: int, optional
+            The maximum number of levels to print.
+            If 0, print only the top-level summary.
         """
-        lines = self._print(self.root, fancy=stream.isatty())
+        lines = self._print(self.root, fancy=stream.isatty(), levels=levels)
         output = "\n".join(lines)
         if not stream.isatty():
             output = _strip_colors(output)
@@ -748,7 +763,10 @@ class FileTree:
 def files(
     codebase: CodeBase,
     state: ParserState | None = None,
+    *,
     stream: TextIO = sys.stdout,
+    prune: bool = False,
+    levels: int = None,
 ):
     """
     Produce a file tree representing the code base.
@@ -787,11 +805,13 @@ def files(
             ):
                 platform = frozenset(association[node])
                 setmap[platform] += node.num_lines
+        if prune:
+            # Prune unused files from the tree.
+            platforms = set().union(*setmap.keys())
+            if len(platforms) == 0:
+                continue
         tree.insert(f, setmap)
 
-    print("", file=stream)
-    print(_heading("Files", stream), file=stream)
-
     # Print a legend.
     legend = []
     legend += ["Legend:"]
@@ -814,4 +834,4 @@ def files(
     print(legend, file=stream)
 
     # Print the tree.
-    tree.write_to(stream)
+    tree.write_to(stream, levels=levels)
diff --git a/codebasin/tree.py b/codebasin/tree.py
new file mode 100755
index 0000000..c6a2408
--- /dev/null
+++ b/codebasin/tree.py
@@ -0,0 +1,187 @@
+#!/usr/bin/env python3
+# Copyright (C) 2019-2024 Intel Corporation
+# SPDX-License-Identifier: BSD-3-Clause
+
+import argparse
+import logging
+import os
+import sys
+
+from codebasin import CodeBase, config, finder, report, util
+
+# TODO: Refactor to avoid imports from __main__
+from codebasin.__main__ import Formatter, _help_string, version
+
+log = logging.getLogger("codebasin")
+
+
+def _build_parser() -> argparse.ArgumentParser:
+    """
+    Build argument parser.
+    """
+    parser = argparse.ArgumentParser(
+        description="CBI Tree Tool " + version,
+        formatter_class=argparse.RawTextHelpFormatter,
+        add_help=False,
+    )
+    parser.add_argument(
+        "-h",
+        "--help",
+        action="help",
+        help=_help_string("Display help message and exit."),
+    )
+    parser.add_argument(
+        "--version",
+        action="version",
+        version=f"CBI Coverage Tool {version}",
+        help=_help_string("Display version information and exit."),
+    )
+    parser.add_argument(
+        "-x",
+        "--exclude",
+        dest="excludes",
+        metavar="<pattern>",
+        action="append",
+        default=[],
+        help=_help_string(
+            "Exclude files matching this pattern from the code base.",
+            "May be specified multiple times.",
+            is_long=True,
+        ),
+    )
+    parser.add_argument(
+        "-p",
+        "--platform",
+        dest="platforms",
+        metavar="<platform>",
+        action="append",
+        default=[],
+        help=_help_string(
+            "Include the specified platform in the analysis.",
+            "May be specified multiple times.",
+            "If not specified, all platforms will be included.",
+            is_long=True,
+        ),
+    )
+    parser.add_argument(
+        "--prune",
+        dest="prune",
+        action="store_true",
+        help=_help_string(
+            "Prune unused files from the tree.",
+        ),
+    )
+    parser.add_argument(
+        "-L",
+        "--levels",
+        dest="levels",
+        metavar="<level>",
+        type=int,
+        help=_help_string(
+            "Print only the specified number of levels.",
+            is_long=True,
+            is_last=True,
+        ),
+    )
+
+    parser.add_argument(
+        "analysis_file",
+        metavar="<analysis-file>",
+        help=_help_string(
+            "TOML file describing the analysis to be performed, "
+            + "including the codebase and platform descriptions.",
+            is_last=True,
+        ),
+    )
+
+    return parser
+
+
+def _tree(args: argparse.Namespace):
+    # Refuse to print a tree with no levels, consistent with tree utility.
+    if args.levels is not None and args.levels <= 0:
+        raise ValueError("Number of levels must be greater than 0.")
+
+    # TODO: Refactor this to avoid duplication in __main__
+    # Determine the root directory based on where codebasin is run.
+    rootdir = os.path.abspath(os.getcwd())
+
+    # Set up a default configuration object.
+    configuration = {}
+
+    # Load the analysis file if it exists.
+    if args.analysis_file is not None:
+        path = os.path.abspath(args.analysis_file)
+        if os.path.exists(path):
+            if not os.path.splitext(path)[1] == ".toml":
+                raise RuntimeError(f"Analysis file {path} must end in .toml.")
+
+        with open(path, "rb") as f:
+            analysis_toml = util._load_toml(f, "analysis")
+
+        if "codebase" in analysis_toml:
+            if "exclude" in analysis_toml["codebase"]:
+                args.excludes += analysis_toml["codebase"]["exclude"]
+
+        for name in args.platforms:
+            if name not in analysis_toml["platform"].keys():
+                raise KeyError(
+                    f"Platform {name} requested on the command line "
+                    + "does not exist in the configuration file.",
+                )
+
+        cmd_platforms = args.platforms.copy()
+        for name in analysis_toml["platform"].keys():
+            if cmd_platforms and name not in cmd_platforms:
+                continue
+            if "commands" not in analysis_toml["platform"][name]:
+                raise ValueError(f"Missing 'commands' for platform {name}")
+            p = analysis_toml["platform"][name]["commands"]
+            db = config.load_database(p, rootdir)
+            args.platforms.append(name)
+            configuration.update({name: db})
+
+    # Construct a codebase object associated with the root directory.
+    codebase = CodeBase(rootdir, exclude_patterns=args.excludes)
+
+    # Parse the source tree, and determine source line associations.
+    # The trees and associations are housed in state.
+    state = finder.find(
+        rootdir,
+        codebase,
+        configuration,
+        show_progress=True,
+    )
+
+    # Print the file tree.
+    report.files(codebase, state, prune=args.prune, levels=args.levels)
+    sys.exit(0)
+
+
+def cli(argv: list[str]) -> int:
+    parser = _build_parser()
+    args = parser.parse_args(argv)
+
+    # Configure logging such that:
+    # - Only errors are written to the terminal
+    log.setLevel(logging.DEBUG)
+
+    stderr_handler = logging.StreamHandler(sys.stderr)
+    stderr_handler.setLevel(logging.ERROR)
+    stderr_handler.setFormatter(Formatter(colors=sys.stderr.isatty()))
+    log.addHandler(stderr_handler)
+
+    return _tree(args)
+
+
+def main():
+    try:
+        cli(sys.argv[1:])
+    except Exception as e:
+        log.error(str(e))
+        sys.exit(1)
+
+
+if __name__ == "__main__":
+    sys.argv[0] = "codebasin.tree"
+    main()
diff --git a/pyproject.toml b/pyproject.toml
index dc113f3..59b8bc7 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -38,6 +38,7 @@ dependencies = [
 [project.scripts]
 codebasin = "codebasin:__main__.main"
 cbi-cov = "codebasin.coverage:__main__.main"
+cbi-tree = "codebasin:tree.main"
 
 [project.urls]
 "Github" = "https://www.github.com/intel/code-base-investigator"


Test patch:
diff --git a/tests/cli/test_cbicov.py b/tests/cli/test_cbicov.py
index 3d49fe9..07b8a77 100644
--- a/tests/cli/test_cbicov.py
+++ b/tests/cli/test_cbicov.py
@@ -97,12 +97,14 @@ class TestCbiCov(unittest.TestCase):
             {
                 "file": "bar.h",
                 "id": "3ba8372282f8f1bafc59bb3d0472dcd7ecd5f13a54f17585c6012bfc40bfba7b9afb905f24ccea087546f4c90363bba97d988e4067ec880f619d0ab623c3a7a1",  # noqa: E501
-                "lines": [],
+                "used_lines": [],
+                "unused_lines": [1],
             },
             {
                 "file": "foo.cpp",
                 "id": "1359957a144db36091624c1091ac6a47c47945a3ff63a47ace3dc5c1b13159929adac14c21733ec1054f7f1f8809ac416e643483191aab6687d7849ee17edaa0",  # noqa: E501
-                "lines": [1, 3, 4],
+                "used_lines": [1, 3, 4],
+                "unused_lines": [2],
             },
         ]
         self.assertCountEqual(coverage, expected_coverage)
diff --git a/tests/compilers/test_compilers.py b/tests/compilers/test_compilers.py
index 357fe63..0d05cb9 100644
--- a/tests/compilers/test_compilers.py
+++ b/tests/compilers/test_compilers.py
@@ -8,6 +8,7 @@ import unittest
 from pathlib import Path
 
 from codebasin import config
+from codebasin.config import ArgumentParser
 
 
 class TestCompilers(unittest.TestCase):
@@ -42,23 +43,28 @@ class TestCompilers(unittest.TestCase):
             "MACRO_AFTER_SPACE",
             "test.cpp",
         ]
-        args = config._parse_compiler_args(argv)
-        self.assertEqual(
-            args.defines,
+        parser = ArgumentParser(argv[0])
+
+        passes = parser.parse_args(argv[1:])
+        self.assertEqual(len(passes), 1)
+
+        self.assertEqual(passes[0].pass_name, "default")
+
+        self.assertCountEqual(
+            passes[0].defines,
             ["MACRO", "FUNCTION_MACRO=1", "MACRO_AFTER_SPACE"],
         )
-        self.assertEqual(
-            args.include_paths,
+        self.assertCountEqual(
+            passes[0].include_paths,
             ["/path", "/path/after/space", "/system/path"],
         )
-        self.assertEqual(args.include_files, ["foo.inc", "bar.inc"])
+        self.assertCountEqual(passes[0].include_files, ["foo.inc", "bar.inc"])
 
     def test_gnu(self):
         """compilers/gnu"""
         argv = ["g++", "-fopenmp", "test.cpp"]
 
-        parser = config.recognize_compiler(argv[0])
-        self.assertTrue(type(parser) is config.GnuArgumentParser)
+        parser = ArgumentParser(argv[0])
 
         passes = parser.parse_args(argv[1:])
         self.assertEqual(len(passes), 1)
@@ -66,14 +72,13 @@ class TestCompilers(unittest.TestCase):
         self.assertEqual(passes[0].pass_name, "default")
 
         defines = passes[0].defines
-        self.assertEqual(defines, ["_OPENMP"])
+        self.assertCountEqual(defines, ["_OPENMP"])
 
     def test_clang(self):
         """compilers/clang"""
         argv = ["clang", "-fsycl-is-device", "test.cpp"]
 
-        parser = config.recognize_compiler(argv[0])
-        self.assertTrue(type(parser) is config.ClangArgumentParser)
+        parser = ArgumentParser(argv[0])
 
         passes = parser.parse_args(argv[1:])
         self.assertEqual(len(passes), 1)
@@ -81,81 +86,93 @@ class TestCompilers(unittest.TestCase):
         self.assertEqual(passes[0].pass_name, "default")
 
         defines = passes[0].defines
-        self.assertEqual(defines, ["__SYCL_DEVICE_ONLY__"])
+        self.assertCountEqual(defines, ["__SYCL_DEVICE_ONLY__"])
 
     def test_intel_sycl(self):
         """compilers/intel_sycl"""
         argv = ["icpx", "-fsycl", "test.cpp"]
 
-        parser = config.recognize_compiler(argv[0])
-        self.assertTrue(type(parser) is config.ClangArgumentParser)
+        parser = ArgumentParser(argv[0])
 
         passes = parser.parse_args(argv[1:])
         self.assertEqual(len(passes), 2)
 
         pass_names = {p.pass_name for p in passes}
-        self.assertEqual(pass_names, {"default", "spir64"})
+        self.assertCountEqual(pass_names, {"default", "sycl-spir64"})
 
         for p in passes:
             if p.pass_name == "default":
-                expected = []
+                expected = ["SYCL_LANGUAGE_VERSION"]
             else:
-                expected = ["__SYCL_DEVICE_ONLY__", "__SPIR__", "__SPIRV__"]
-            self.assertEqual(p.defines, expected)
+                expected = [
+                    "SYCL_LANGUAGE_VERSION",
+                    "__SYCL_DEVICE_ONLY__",
+                    "__SPIR__",
+                    "__SPIRV__",
+                ]
+            self.assertCountEqual(p.defines, expected)
 
     def test_intel_targets(self):
         """compilers/intel_targets"""
         argv = [
             "icpx",
             "-fsycl",
-            "-fsycl-targets=spir64,x86_64",
+            "-fsycl-targets=spir64,spir64_x86_64",
             "-fopenmp",
             "test.cpp",
         ]
 
-        parser = config.recognize_compiler(argv[0])
-        self.assertTrue(type(parser) is config.ClangArgumentParser)
+        parser = ArgumentParser(argv[0])
 
         passes = parser.parse_args(argv[1:])
 
         pass_names = {p.pass_name for p in passes}
-        self.assertEqual(pass_names, {"default", "spir64", "x86_64"})
+        self.assertCountEqual(
+            pass_names,
+            {"default", "sycl-spir64", "sycl-spir64_x86_64"},
+        )
 
         for p in passes:
             if p.pass_name == "default":
-                expected = ["_OPENMP"]
-                self.assertEqual(p.defines, ["_OPENMP"])
-            elif p.pass_name == "spir64" or p.pass_name == "x86_64":
-                expected = ["__SYCL_DEVICE_ONLY__", "__SPIR__", "__SPIRV__"]
-            self.assertEqual(p.defines, expected)
+                expected = ["SYCL_LANGUAGE_VERSION", "_OPENMP"]
+            elif (
+                p.pass_name == "sycl-spir64"
+                or p.pass_name == "sycl-spir64_x86_64"
+            ):
+                expected = [
+                    "SYCL_LANGUAGE_VERSION",
+                    "__SYCL_DEVICE_ONLY__",
+                    "__SPIR__",
+                    "__SPIRV__",
+                ]
+            self.assertCountEqual(p.defines, expected)
 
     def test_nvcc(self):
         """compilers/nvcc"""
         argv = [
             "nvcc",
             "-fopenmp",
-            "--gpu-architecture=compute_50",
-            "--gpu-code=compute_50,sm_50,sm_52",
+            "--gpu-architecture=compute_70",
+            "--gpu-code=compute_70,sm_70,sm_75",
             "test.cpp",
         ]
 
-        parser = config.recognize_compiler(argv[0])
-        self.assertTrue(type(parser) is config.NvccArgumentParser)
+        parser = ArgumentParser(argv[0])
 
         passes = parser.parse_args(argv[1:])
 
         pass_names = {p.pass_name for p in passes}
-        self.assertEqual(pass_names, {"default", "50", "52"})
+        self.assertCountEqual(pass_names, {"default", "sm_70", "sm_75"})
 
         defaults = ["__NVCC__", "__CUDACC__"]
         for p in passes:
             if p.pass_name == "default":
                 expected = defaults + ["_OPENMP"]
-            elif p.pass_name == "50":
-                expected = defaults + ["__CUDA_ARCH__=500"]
-            elif p.pass_name == "52":
-                expected = defaults + ["__CUDA_ARCH__=520"]
-            self.assertEqual(p.defines, expected)
+            elif p.pass_name == "sm_70":
+                expected = defaults + ["__CUDA_ARCH__=700"]
+            elif p.pass_name == "sm_75":
+                expected = defaults + ["__CUDA_ARCH__=750"]
+            self.assertCountEqual(p.defines, expected)
 
     def test_user_options(self):
         """Check that we import user-defined options"""
@@ -166,20 +183,245 @@ class TestCompilers(unittest.TestCase):
         with open(path / ".cbi" / "config", mode="w") as f:
             f.write('[compiler."c++"]\n')
             f.write('options = ["-D", "ASDF"]\n')
-        config.load_importcfg()
+        config._load_compilers()
 
         argv = [
             "c++",
             "test.cpp",
         ]
 
-        parser = config.recognize_compiler(argv[0])
+        parser = ArgumentParser(argv[0])
         passes = parser.parse_args(argv[1:])
         self.assertEqual(len(passes), 1)
         self.assertCountEqual(passes[0].defines, ["ASDF"])
 
         tmp.cleanup()
 
+    def test_user_alias(self):
+        """Check that we import user-defined aliases"""
+        tmp = tempfile.TemporaryDirectory()
+        path = Path(tmp.name)
+        os.chdir(tmp.name)
+        os.mkdir(".cbi")
+        with open(path / ".cbi" / "config", mode="w") as f:
+            f.write('[compiler."c++"]\n')
+            f.write('alias_of = "clang"\n')
+        config._load_compilers()
+
+        parser = ArgumentParser("c++")
+        passes = parser.parse_args(["-fopenmp", "test.cpp"])
+
+        self.assertEqual(len(passes), 1)
+        self.assertCountEqual(passes[0].defines, ["_OPENMP"])
+
+        tmp.cleanup()
+
+    def test_nested_aliases(self):
+        """Check that we handle user-defined aliases to aliases"""
+        tmp = tempfile.TemporaryDirectory()
+        path = Path(tmp.name)
+        os.chdir(tmp.name)
+        os.mkdir(".cbi")
+        with open(path / ".cbi" / "config", mode="w") as f:
+            f.write('[compiler."c++"]\n')
+            f.write('alias_of = "g++"\n')
+        config._load_compilers()
+
+        parser = ArgumentParser("c++")
+        passes = parser.parse_args(["-fopenmp", "test.cpp"])
+
+        self.assertEqual(len(passes), 1)
+        self.assertCountEqual(passes[0].defines, ["_OPENMP"])
+
+        tmp.cleanup()
+
+    def test_alias_loops(self):
+        """Check that we identify and complain about alias loops"""
+        tmp = tempfile.TemporaryDirectory()
+        path = Path(tmp.name)
+        os.chdir(tmp.name)
+        os.mkdir(".cbi")
+        with open(path / ".cbi" / "config", mode="w") as f:
+            f.write("[compiler.foo]\n")
+            f.write('alias_of = "bar"\n')
+            f.write("[compiler.bar]\n")
+            f.write('alias_of = "foo"\n')
+        config._load_compilers()
+
+        logging.disable(logging.NOTSET)
+        with self.assertLogs("codebasin", level=logging.ERROR):
+            _ = ArgumentParser("foo")
+        logging.disable()
+
+        tmp.cleanup()
+
+    def test_user_parser(self):
+        """Check that we import user-defined parser"""
+        tmp = tempfile.TemporaryDirectory()
+        path = Path(tmp.name)
+        os.chdir(tmp.name)
+        os.mkdir(".cbi")
+        with open(path / ".cbi" / "config", mode="w") as f:
+            f.write('[[compiler."c++".parser]]\n')
+            f.write('flags = ["-fasdf"]\n')
+            f.write('action = "append_const"\n')
+            f.write('dest = "passes"\n')
+            f.write('const = "asdf-pass"\n')
+            f.write('[[compiler."c++".modes]]\n')
+            f.write('name = "asdf-mode"\n')
+            f.write('defines = ["ASDF"]\n')
+            f.write('[[compiler."c++".passes]]\n')
+            f.write('name = "asdf-pass"\n')
+            f.write('modes = ["asdf-mode"]\n')
+
+        config._load_compilers()
+
+        parser = ArgumentParser("c++")
+        passes = parser.parse_args(["-fasdf", "test.cpp"])
+
+        self.assertEqual(len(passes), 2)
+        pass_names = {p.pass_name for p in passes}
+        self.assertCountEqual(pass_names, {"default", "asdf-pass"})
+
+        for p in passes:
+            if p.pass_name == "default":
+                expected = []
+            elif p.pass_name == "asdf-pass":
+                expected = ["ASDF"]
+            self.assertCountEqual(p.defines, expected)
+
+        tmp.cleanup()
+
+    def test_user_overrides(self):
+        """Check that we can merge user-defined overrides"""
+        tmp = tempfile.TemporaryDirectory()
+        path = Path(tmp.name)
+        os.chdir(tmp.name)
+        os.mkdir(".cbi")
+        with open(path / ".cbi" / "config", mode="w") as f:
+            f.write('[[compiler."g++".parser]]\n')
+            f.write('flags = ["-fopenmp"]\n')
+            f.write('action = "append_const"\n')
+            f.write('dest = "passes"\n')
+            f.write('const = "new-openmp-pass"\n')
+            f.write("\n")
+            f.write('[[compiler."g++".parser]]\n')
+            f.write('flags = ["-fextend"]\n')
+            f.write('action = "extend_match"\n')
+            f.write('pattern = "*"\n')
+            f.write('dest = "passes"\n')
+            f.write("\n")
+            f.write('[[compiler."g++".parser]]\n')
+            f.write('flags = ["-fstore"]\n')
+            f.write('action = "store_split"\n')
+            f.write('sep = ","\n')
+            f.write('dest = "passes"\n')
+            f.write("\n")
+            f.write('[[compiler."g++".passes]]\n')
+            f.write('name = "new-openmp-pass"\n')
+            f.write('modes = ["new-openmp-mode"]\n')
+            f.write("\n")
+            f.write('[[compiler."g++".modes]]\n')
+            f.write('name = "new-openmp-mode"\n')
+            f.write('defines = ["_NEW_OPENMP"]\n')
+            f.write("\n")
+            f.write('[compiler."g++"]\n')
+            f.write('options = ["-D", "EXTRA_DEFINE"]\n')
+            f.write("\n")
+            f.write("[[compiler.clang.modes]]\n")
+            f.write('name = "openmp"\n')
+            f.write("defines = []\n")
+            f.write("\n")
+            f.write("[[compiler.icx.passes]]\n")
+            f.write('name = "sycl-spir64"\n')
+            f.write("defines = []\n")
+            f.write("\n")
+            f.write("[compiler.nvcc]\n")
+            f.write('alias_of = "unknown"\n')
+
+        # Verify that the correct warnings are generated when loading the file.
+        logging.disable(logging.NOTSET)
+        with self.assertLogs("codebasin", level=logging.WARNING) as cm:
+            config._load_compilers()
+        logging.disable()
+
+        self.assertEqual(
+            cm.output,
+            [
+                "WARNING:codebasin.config:definition of g++ in .cbi/config overrides alias.",  # noqa: E501
+                "WARNING:codebasin.config:compiler mode 'openmp' redefined",  # noqa: E501
+                "WARNING:codebasin.config:compiler pass 'sycl-spir64' redefined",  # noqa: E501
+                "WARNING:codebasin.config:nvcc redefined as alias of unknown.",  # noqa: E501
+            ],
+        )
+
+        # Verify that user-defined overrides affect passes, etc.
+        parser = ArgumentParser("g++")
+        argv = ["-fopenmp"]
+        passes = parser.parse_args(argv)
+
+        pass_names = {p.pass_name for p in passes}
+        self.assertCountEqual(pass_names, {"default", "new-openmp-pass"})
+
+        for p in passes:
+            if p.pass_name == "default":
+                expected = ["EXTRA_DEFINE"]
+            else:
+                expected = ["EXTRA_DEFINE", "_NEW_OPENMP"]
+            self.assertCountEqual(p.defines, expected)
+
+        # Verify that invalid aliases are identified.
+        logging.disable(logging.NOTSET)
+        with self.assertLogs("codebasin", level=logging.ERROR) as cm:
+            _ = ArgumentParser("nvcc")
+        logging.disable()
+
+        self.assertEqual(
+            cm.output,
+            [
+                "ERROR:codebasin.config:Compiler 'nvcc' aliases unrecognized 'unknown'.",  # noqa: E501
+            ],
+        )
+
+        tmp.cleanup()
+
+    def test_unrecognized(self):
+        """Check that we report unrecognized compilers, modes and passes."""
+        tmp = tempfile.TemporaryDirectory()
+        path = Path(tmp.name)
+        os.chdir(tmp.name)
+        os.mkdir(".cbi")
+        with open(path / ".cbi" / "config", mode="w") as f:
+            f.write("[[compiler.foo.parser]]\n")
+            f.write('flags = ["-pass"]\n')
+            f.write('action = "append_const"\n')
+            f.write('dest = "passes"\n')
+            f.write('const = "unrecognized-pass"\n')
+            f.write("[[compiler.foo.parser]]\n")
+            f.write('flags = ["-mode"]\n')
+            f.write('action = "append_const"\n')
+            f.write('dest = "modes"\n')
+            f.write('const = "unrecognized-mode"\n')
+        config._load_compilers()
+
+        logging.disable(logging.NOTSET)
+        with self.assertLogs("codebasin", level=logging.WARNING) as cm:
+            _ = ArgumentParser("foo").parse_args(
+                ["-pass", "-mode", "-unrecognized"],
+            )
+        logging.disable()
+
+        self.assertCountEqual(
+            cm.output,
+            [
+                "WARNING:codebasin.config:Unrecognized arguments: '-unrecognized'",  # noqa: E501
+                "ERROR:codebasin.config:Unrecognized compiler pass: unrecognized-pass",  # noqa: E501
+                "ERROR:codebasin.config:Unrecognized compiler mode: unrecognized-mode",  # noqa: E501
+            ],
+        )
+
+        tmp.cleanup()
+
 
 if __name__ == "__main__":
     unittest.main()
diff --git a/tests/files/test_filetree.py b/tests/files/test_filetree.py
index 22ed3c4..2583a24 100644
--- a/tests/files/test_filetree.py
+++ b/tests/files/test_filetree.py
@@ -32,7 +32,6 @@ class TestFileTree(unittest.TestCase):
         self.path = Path(self.tmp.name)
         open(self.path / "file.cpp", mode="w").close()
         open(self.path / "other.cpp", mode="w").close()
-        open(self.path / "unused.cpp", mode="w").close()
         os.symlink(self.path / "file.cpp", self.path / "symlink.cpp")
 
     @classmethod
@@ -153,6 +152,115 @@ class TestFileTree(unittest.TestCase):
         self.assertTrue("[-B | 1 | 100.00 |  50.00]" in output)
         self.assertTrue("[A- | 1 | 100.00 |  50.00]" in output)
 
+    def test_levels(self):
+        """Check report --levels flag works correctly"""
+        # Set up subdirectories for this test
+        tmp = tempfile.TemporaryDirectory()
+        path = Path(tmp.name)
+        os.makedirs(path / "first" / "second" / "third")
+        open(path / "first" / "one.cpp", mode="w").close()
+        open(path / "first" / "second" / "two.cpp", mode="w").close()
+
+        codebase = CodeBase(path)
+        configuration = {
+            "X": [
+                {
+                    "file": str(path / "first" / "one.cpp"),
+                    "defines": [],
+                    "include_paths": [],
+                    "include_files": [],
+                },
+            ],
+            "Y": [
+                {
+                    "file": str(path / "first" / "second" / "two.cpp"),
+                    "defines": [],
+                    "include_paths": [],
+                    "include_files": [],
+                },
+            ],
+        }
+        state = finder.find(
+            path,
+            codebase,
+            configuration,
+            show_progress=False,
+        )
+
+        # By default, we should see all levels of the tree.
+        stream = io.StringIO()
+        report.files(codebase, state, stream=stream)
+        output = stream.getvalue()
+        self.assertTrue(str(path) in output)
+        self.assertTrue("first/" in output)
+        self.assertTrue("one.cpp" in output)
+        self.assertTrue("two.cpp" in output)
+
+        # With two levels, the "second" directory should be collapsed.
+        # This will hide "two.cpp" from the output.
+        stream = io.StringIO()
+        report.files(codebase, state, stream=stream, levels=2)
+        output = stream.getvalue()
+        self.assertTrue(str(path) in output)
+        self.assertTrue("first/" in output)
+        self.assertTrue("one.cpp" in output)
+        self.assertFalse("two.cpp" in output)
+
+        # With just one level, the "first" directory should be collapsed.
+        # This will hide "one.cpp" and "two.cpp" from the output.
+        stream = io.StringIO()
+        report.files(codebase, state, stream=stream, levels=1)
+        output = stream.getvalue()
+        self.assertTrue(str(path) in output)
+        self.assertTrue("first/" in output)
+        self.assertFalse("one.cpp" in output)
+        self.assertFalse("two.cpp" in output)
+
+        tmp.cleanup()
+
+    def test_prune(self):
+        """Check report --prune flag works correctly"""
+        # Set up subdirectories for this test
+        tmp = tempfile.TemporaryDirectory()
+        path = Path(tmp.name)
+        with open(path / "foo.cpp", mode="w") as f:
+            f.write("void foo();")
+        open(path / "unused.cpp", mode="w").close()
+
+        codebase = CodeBase(path)
+        configuration = {
+            "X": [
+                {
+                    "file": str(path / "foo.cpp"),
+                    "defines": [],
+                    "include_paths": [],
+                    "include_files": [],
+                },
+            ],
+        }
+        state = finder.find(
+            path,
+            codebase,
+            configuration,
+            show_progress=False,
+        )
+
+        # By default, we should see both used and unused files.
+        stream = io.StringIO()
+        report.files(codebase, state, stream=stream)
+        output = stream.getvalue()
+        self.assertTrue("foo.cpp" in output)
+        self.assertTrue("unused.cpp" in output)
+
+        # With prune, we should only see the used files.
+        stream = io.StringIO()
+        report.files(codebase, state, stream=stream, prune=True)
+        output = stream.getvalue()
+        self.assertTrue("foo.cpp" in output)
+        self.assertFalse("unused.cpp" in output)
+
+        tmp.cleanup()
+
 
 if __name__ == "__main__":
     unittest.main()