Repository: intel/code-base-investigator

Issue:
Document `uv` for "improved" OOB UX
### Feature/behavior summary

Currently, the recommendation for end-users to install `codebasin` depends on the user to first create their own virtual environment, install `codebasin` from the cloned repository, and whenever one needs to invoke the CLI, needs to have the virtual environment activated. The requirement for Python 3.12 can be a little finicky for users to install who aren't using more comprehensive environment managers like `mamba`, since `venv` requires you to have a base Python interpreter of the corresponding version.

### 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

_No response_

### Solution description

The proposition is to recommend the use of `uv` to offload the `venv` complexity, in addition to being able to add `codebasin` as a "tool" [that can be invoked without activating the `venv`](https://docs.astral.sh/uv/guides/tools/#installing-tools).

For the most part, the installation instructions only change by a little bit once users have installed `uv` with installation scripts:

```console
git clone codebasin
uv tool install .
```

`uv` then downloads the required Python interpreter, creates a virtual environment for `codebasin` as a **binary**, and adds the executable script to the user's local PATH (i.e. `/home/user/.local/bin`. The script will then be agnostic to activated Python environment, so users will be able to run it from anywhere on their system and not have to activate a virtual environment.

The suggested solution would be to:

1. Test the workflow ourselves
2. Update installation and run instructions that use `venv`
3. Optional, but could also update CI to use the same approach. Not critical since CI can trivially grab whatever version of Python as needed, but could potentially let us catch issues with the same workflow

### Additional notes

_No response_

Discussion:
@Pennycook: > Update installation and run instructions that use `venv`

I'm reluctant to update our documentation to say that people _should_ use `uv` instead.  I think that would make `uv` an external dependency of ours, and something that we would have to start paying close attention to.

But I'm not opposed to saying something like:

> "We strongly recommend installing CBI within a virtual environment, to simplify dependency management and improve security. Some alternative methods of creating a virtual environment are shown below.

Our Sphinx theme integrates nicely with a tab extension by the same author, so we could even display this as something like:

![Image](https://github.com/user-attachments/assets/152ea7d3-2726-40b4-8555-e053e3cc25bc)@laserkelvin: Sounds good to me!

Solution patch:
diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml
index 896d0fc..b957b3c 100644
--- a/.github/workflows/coverage.yml
+++ b/.github/workflows/coverage.yml
@@ -18,6 +18,8 @@ jobs:
 
     steps:
       - uses: actions/checkout@v4
+        with:
+          fetch-depth: 0
 
       - name: Set up Python ${{ matrix.python-version }}
         uses: actions/setup-python@v4
@@ -39,11 +41,11 @@ jobs:
 
       - name: Generate coverage.json
         run: |
-          python -m coverage json --include=$(git diff --name-status main codebasin/*.py | grep "^M" | awk '{ print $2 }' | paste -sd,)
+          python -m coverage json --include=$(git diff --name-status ${{ github.event.pull_request.base.sha }} codebasin/*.py | grep "^M" | awk '{ print $2 }' | paste -sd,)
 
       - name: Check coverage against latest commits
         run: |
           FROM=${{ github.event.pull_request.base.sha }}
-          TO=${{ github.event.pull_request.head.sha }}
+          TO=${{ github.sha }}
           COMMITS=$(git rev-list $FROM..$TO)
           python .github/workflows/check-coverage.py coverage.json --commits $COMMITS
diff --git a/codebasin/config.py b/codebasin/config.py
index 0923587..618af4e 100644
--- a/codebasin/config.py
+++ b/codebasin/config.py
@@ -5,72 +5,53 @@
 defining a specific code base configuration.
 """
 
+import argparse
 import collections
 import logging
 import os
 import re
+import string
 
 from codebasin import CompilationDatabase, util
 
 log = logging.getLogger(__name__)
 
 
-def extract_defines(argv):
-    """
-    Extract definitions from command-line arguments.
-    Recognizes two argument "-D MACRO" and one argument "-DMACRO".
-    """
-    defines = []
-    prefix = ""
-    for a in argv:
-        if a == "-D":
-            prefix = "-D"
-        elif prefix:
-            defines += [a]
-            prefix = ""
-        elif a[0:2] == "-D":
-            defines += [a[2:]]
-            prefix = ""
-    return defines
-
-
-def extract_include_paths(argv):
-    """
-    Extract include paths from command-line arguments.
-    Recognizes two argument "-I path" and one argument "-Ipath".
-    """
-    prefixes = ["-I", "-isystem"]
-
-    include_paths = []
-    prefix = ""
-    for a in argv:
-        if a in prefixes:
-            prefix = a
-        elif prefix in prefixes:
-            include_paths += [a]
-            prefix = ""
-        elif a[0:2] == "-I":
-            include_paths += [a[2:]]
-    return include_paths
-
-
-def extract_include_files(argv):
-    """
-    Extract include files from command-line arguments.
-    Recognizes two argument "-include file".
-    """
-    includes = []
-    prefix = ""
-    for a in argv:
-        if a == "-include":
-            prefix = "-include"
-        elif prefix:
-            includes += [a]
-            prefix = ""
-    return includes
+_importcfg = None
 
 
-_importcfg = 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():
@@ -92,6 +73,83 @@ def load_importcfg():
             _importcfg[name] = compiler["options"]
 
 
+class _StoreSplitAction(argparse.Action):
+    """
+    A custom argparse.Action that splits the value based on a user-provided
+    separator, then stores the resulting list.
+    """
+
+    def __init__(
+        self,
+        option_strings: list[str],
+        dest: str,
+        nargs=None,
+        **kwargs,
+    ):
+        self.sep = kwargs.pop("sep", None)
+        self.format = kwargs.pop("format", None)
+        super().__init__(option_strings, dest, nargs=nargs, **kwargs)
+
+    def __call__(
+        self,
+        parser: argparse.ArgumentParser,
+        namespace: argparse.Namespace,
+        values: str,
+        option_string: str,
+    ):
+        if not isinstance(values, str):
+            raise TypeError("store_split expects string values")
+        split_values = values.split(self.sep)
+        if self.format:
+            template = string.Template(self.format)
+            split_values = [template.substitute(value=v) for v in split_values]
+        if self.dest == "passes":
+            passes = getattr(namespace, self.dest)
+            passes[option_string] = split_values
+        else:
+            setattr(namespace, self.dest, split_values)
+
+
+class _ExtendMatchAction(argparse.Action):
+    """
+    A custom argparse.Action that matches the value against a user-provided
+    pattern, then extends the destination list using the result(s).
+    """
+
+    def __init__(
+        self,
+        option_strings: list[str],
+        dest: str,
+        nargs=None,
+        **kwargs,
+    ):
+        self.pattern = kwargs.pop("pattern", None)
+        self.format = kwargs.pop("format", None)
+        super().__init__(option_strings, dest, nargs=nargs, **kwargs)
+
+    def __call__(
+        self,
+        parser: argparse.ArgumentParser,
+        namespace: argparse.Namespace,
+        value: str,
+        option_string: str,
+    ):
+        if not isinstance(value, str):
+            raise TypeError("extend_match expects string value")
+        matches = re.findall(self.pattern, value)
+        if self.format:
+            template = string.Template(self.format)
+            matches = [template.substitute(value=v) for v in matches]
+        if self.dest == "passes":
+            passes = getattr(namespace, self.dest)
+            if option_string not in passes:
+                passes[option_string] = []
+            passes[option_string].extend(matches)
+        else:
+            dest = getattr(namespace, self.dest)
+            dest.extend(matches)
+
+
 class Compiler:
     """
     Represents the behavior of a specific compiler, including:
@@ -99,16 +157,17 @@ class Compiler:
     - Implicitly defined macros, which may be flag-dependent.
     """
 
-    def __init__(self, args):
-        self.name = os.path.basename(args[0])
-        self.args = args
+    def __init__(self, argv: list[str]):
+        self.name = os.path.basename(argv[0])
+        self.argv = argv
         self.passes = {"default"}
 
         # Check for any user-defined compiler behavior.
         # Currently, users can only override default defines.
         if _importcfg is None:
             load_importcfg()
-        self.defines = extract_defines(_importcfg[self.name])
+        args = _parse_compiler_args(_importcfg[self.name])
+        self.defines = args.defines
 
     def get_passes(self):
         return self.passes.copy()
@@ -153,14 +212,14 @@ class ClangCompiler(Compiler):
         "spir64_fpga",
     ]
 
-    def __init__(self, args):
-        super().__init__(args)
+    def __init__(self, argv: list[str]):
+        super().__init__(argv)
 
         self.sycl = False
         self.omp = False
         sycl_targets = []
 
-        for arg in args:
+        for arg in argv:
             if arg == "-fsycl":
                 self.sycl = True
                 continue
@@ -203,10 +262,10 @@ class GnuCompiler(Compiler):
     Represents the behavior of GNU-based compilers.
     """
 
-    def __init__(self, args):
-        super().__init__(args)
+    def __init__(self, argv: list[str]):
+        super().__init__(argv)
 
-        for arg in args:
+        for arg in argv:
             if arg in ["-fopenmp"]:
                 self.defines.append("_OPENMP")
                 break
@@ -217,8 +276,8 @@ class HipCompiler(Compiler):
     Represents the behavior of the HIP compiler.
     """
 
-    def __init__(self, args):
-        super().__init__(args)
+    def __init__(self, argv: list[str]):
+        super().__init__(argv)
 
 
 class IntelCompiler(ClangCompiler):
@@ -226,8 +285,8 @@ class IntelCompiler(ClangCompiler):
     Represents the behavior of Intel compilers.
     """
 
-    def __init__(self, args):
-        super().__init__(args)
+    def __init__(self, argv: list[str]):
+        super().__init__(argv)
 
 
 class NvccCompiler(Compiler):
@@ -235,11 +294,11 @@ class NvccCompiler(Compiler):
     Represents the behavior of the NVCC compiler.
     """
 
-    def __init__(self, args):
-        super().__init__(args)
+    def __init__(self, argv: list[str]):
+        super().__init__(argv)
         self.omp = False
 
-        for arg in args:
+        for arg in argv:
             archs = re.findall("sm_(\\d+)", arg)
             archs += re.findall("compute_(\\d+)", arg)
             self.passes |= set(archs)
@@ -267,7 +326,7 @@ def get_defines(self, pass_):
 _seen_compiler = collections.defaultdict(lambda: False)
 
 
-def recognize_compiler(argv):
+def recognize_compiler(argv: list[str]) -> Compiler:
     """
     Attempt to recognize the compiler, given an argument list.
     Return a Compiler object.
@@ -313,11 +372,11 @@ def load_database(dbpath, rootdir):
             continue
         argv = command.arguments
 
-        # Extract defines, include paths and include files
-        # from command-line arguments
-        defines = extract_defines(argv)
-        include_paths = extract_include_paths(argv)
-        include_files = extract_include_files(argv)
+        # Parse common command-line arguments.
+        args = _parse_compiler_args(argv[1:])
+        defines = args.defines
+        include_paths = args.include_paths
+        include_files = args.include_files
 
         # Certain tools may have additional, implicit, behaviors
         # (e.g., additional defines, multiple passes for multiple targets)
diff --git a/codebasin/file_parser.py b/codebasin/file_parser.py
index 4a0d917..763e6be 100644
--- a/codebasin/file_parser.py
+++ b/codebasin/file_parser.py
@@ -157,7 +157,7 @@ def insert_directive_node(tree, line_group, logical_line):
                 filename = tree.root.filename
                 line = tokens[0].line
                 column = tokens[0].col
-                spelling = preprocessor.toklist_print(tokens)
+                spelling = new_node.spelling()
                 message = f"unrecognized directive '{spelling}'"
                 log.warning(f"{filename}:{line}:{column}: {message}")
 
diff --git a/codebasin/preprocessor.py b/codebasin/preprocessor.py
index 371dad8..f63d14a 100644
--- a/codebasin/preprocessor.py
+++ b/codebasin/preprocessor.py
@@ -8,11 +8,11 @@
 """
 
 import collections
-import hashlib
 import logging
 import os
 from collections.abc import Callable, Iterable
 from copy import copy
+from dataclasses import dataclass, field
 from enum import Enum
 from typing import Self
 
@@ -23,20 +23,6 @@
 log = logging.getLogger(__name__)
 
 
-def toklist_print(toklist):
-    """
-    Helper function to print token lists with proper whitespace.
-    """
-    out = []
-    for tok in toklist:
-        if not tok:
-            continue
-        if tok.prev_white:
-            out.append(" ")
-        out.append(str(tok))
-    return "".join(out)
-
-
 def _representation_string(obj, *, name=None, attrs=None):
     """
     Helper function to build representation strings of the form:
@@ -68,19 +54,16 @@ class MacroExpandOverflow(ValueError):
     """
 
 
+@dataclass
 class Token:
     """
     Represents a token constructed by the parser.
     """
 
-    def __init__(self, line, col, prev_white, token):
-        self.line = line
-        self.col = col
-        self.prev_white = prev_white
-        self.token = token
-
-    def __repr__(self):
-        return _representation_string(self)
+    line: int
+    col: int
+    prev_white: bool
+    token: str
 
     def __str__(self):
         return "\n".join(self.spelling())
@@ -100,15 +83,14 @@ def sanitized_str(self):
         return str(self)
 
 
+@dataclass
 class CharacterConstant(Token):
     """
     Represents a character constant.
     """
 
-    def __repr__(self):
-        return _representation_string(self)
-
 
+@dataclass
 class NumericalConstant(Token):
     """
     Represents a 'preprocessing number'.
@@ -116,18 +98,13 @@ class NumericalConstant(Token):
     not be valid syntax).
     """
 
-    def __repr__(self):
-        return _representation_string(self)
-
 
+@dataclass
 class StringConstant(Token):
     """
     Represents a string constant.
     """
 
-    def __repr__(self):
-        return _representation_string(self)
-
     def spelling(self):
         """
         Return the string representation of this token in the input code.
@@ -155,45 +132,35 @@ def sanitized_str(self):
         return "".join(out)
 
 
+@dataclass
 class Identifier(Token):
     """
     Represents a C identifier.
     """
 
-    def __init__(self, line, col, prev_white, token):
-        super().__init__(line, col, prev_white, token)
-        self.expandable = True
-
-    def __repr__(self):
-        return _representation_string(self)
+    expandable: bool = field(default=True, init=True)
 
 
+@dataclass
 class Operator(Token):
     """
     Represents a C operator.
     """
 
-    def __repr__(self):
-        return _representation_string(self)
-
 
+@dataclass
 class Punctuator(Token):
     """
     Represents a punctuator (e.g. parentheses)
     """
 
-    def __repr__(self):
-        return _representation_string(self)
-
 
+@dataclass
 class Unknown(Token):
     """
     Represents an unknown token.
     """
 
-    def __repr__(self):
-        return _representation_string(self)
-
 
 class Lexer:
     """
@@ -544,15 +511,15 @@ class Visit(Enum):
     NEXT_SIBLING = 1
 
 
+@dataclass(eq=False)
 class Node:
     """
     Base class for all other Node types.
     Contains a single parent, and an ordered list of children.
     """
 
-    def __init__(self):
-        self.children = []
-        self.parent = None
+    children: list[Self] = field(default_factory=list, init=False)
+    parent: Self | None = field(default=None, init=False)
 
     def add_child(self, child):
         self.children.append(child)
@@ -619,35 +586,19 @@ def visit(self, visitor: Callable[[Self], Visit]):
                 child.visit(visitor)
 
 
+@dataclass(eq=False)
 class FileNode(Node):
     """
     Typically the root node of a tree. Simply contains a filename after
     inheriting from the Node class.
     """
 
-    def __init__(self, _filename):
-        super().__init__()
-        self.filename = _filename
-        # The length of the file, counting blank lines and comments
-        self.num_lines = 0
-        # The source lines of code, ignoring blank lines and comments
-        self.total_sloc = 0
-        self.file_hash = self.__compute_file_hash()
-
-    def __compute_file_hash(self):
-        chunk_size = 4096
-        hasher = hashlib.sha512()
-        with open(self.filename, "rb") as in_file:
-            for chunk in iter(lambda: in_file.read(chunk_size), b""):
-                hasher.update(chunk)
-
-        return hasher.hexdigest()
-
-    def __repr__(self):
-        return _representation_string(self, attrs=["filename"])
+    filename: str
+    num_lines: int = field(default=0, init=False)
+    total_sloc: int = field(default=0, init=False)
 
     def __str__(self):
-        return f"{str(self.filename)}; Hash: {str(self.file_hash)}"
+        return str(self.filename)
 
     def evaluate_for_platform(self, **kwargs):
         """
@@ -657,6 +608,7 @@ def evaluate_for_platform(self, **kwargs):
         return True
 
 
+@dataclass(eq=False, init=False)
 class CodeNode(Node):
     """
     Represents any line of code. Contains a start and end line, and the
@@ -664,13 +616,23 @@ class CodeNode(Node):
     the original source.
     """
 
+    start_line: int = field(default=-1, init=False)
+    end_line: int = field(default=-1, init=False)
+    num_lines: int = field(default=0, init=False)
+    source: str | None = field(default=None, init=False, repr=False)
+    lines: list[str] | None = field(
+        default_factory=list,
+        init=False,
+        repr=False,
+    )
+
     def __init__(
         self,
-        start_line=-1,
-        end_line=-1,
-        num_lines=0,
-        source=None,
-        lines=None,
+        start_line: int = -1,
+        end_line: int = -1,
+        num_lines: int = 0,
+        source: str | None = None,
+        lines: list[int] | None = None,
     ):
         super().__init__()
         self.start_line = start_line
@@ -682,12 +644,6 @@ def __init__(
             self.lines = lines
         self.source = source
 
-    def __repr__(self):
-        return _representation_string(
-            self,
-            attrs=["start_line", "end_line", "num_lines"],
-        )
-
     def __str__(self):
         start = self.start_line
         end = self.end_line
@@ -709,6 +665,7 @@ def spelling(self):
             return [f"/* {self.num_lines} SLOC omitted */"]
 
 
+@dataclass(eq=False)
 class DirectiveNode(CodeNode):
     """
     A CodeNode representing a C preprocessor directive.
@@ -716,86 +673,57 @@ class DirectiveNode(CodeNode):
     countable lines and extent.
     """
 
-    def __init__(self):
-        super().__init__()
+    tokens: list[Token]
+
+    def spelling(self) -> list[str]:
+        """
+        Recover the original spelling of this directive in the input code.
+        Useful primarily for debugging and generating error messages.
+
+        Returns
+        -------
+        list[str]
+            The string representation of this directive in the input code.
+        """
+        out = []
+        for token in self.tokens:
+            if not token:
+                continue
+            if token.prev_white:
+                out.append(" ")
+            out.append(str(token))
+        return ["".join(out)]
 
 
+@dataclass(eq=False)
 class UnrecognizedDirectiveNode(DirectiveNode):
     """
     A CodeNode representing an unrecognized preprocessor directive
     """
 
-    def __init__(self, tokens):
-        super().__init__()
-        self.kind = "unrecognized"
-        self.tokens = tokens
-
-    def __repr__(self):
-        return _representation_string(self, name="DirectiveNode")
-
-    def spelling(self):
-        """
-        Return the string representation of this directive in the input code.
-        Useful primarily for debugging and generating error messages.
-        """
-        return [" ".join([str(t) for t in self.tokens])]
-
 
+@dataclass(eq=False)
 class PragmaNode(DirectiveNode):
     """
     Represents a #pragma directive
     """
 
-    def __init__(self, tokens):
-        super().__init__()
-        self.kind = "pragma"
-        self.tokens = tokens
-
-    def __repr__(self):
-        return _representation_string(self, name="DirectiveNode")
-
-    def spelling(self):
-        """
-        Return the string representation of this pragma in the input code.
-        Useful primarily for debugging and generating error messages.
-        """
-        rest = " ".join([str(t) for t in self.tokens])
-        return [f"#pragma {rest}"]
+    expr: list[Token]
 
     def evaluate_for_platform(self, **kwargs):
-        if self.tokens and str(self.tokens[0]) == "once":
+        if self.expr and str(self.expr[0]) == "once":
             kwargs["platform"].add_include_to_skip(kwargs["filename"])
 
 
+@dataclass(eq=False)
 class DefineNode(DirectiveNode):
     """
     A DirectiveNode representing a #define directive.
     """
 
-    def __init__(self, identifier, args=None, value=None):
-        super().__init__()
-        self.kind = "define"
-        self.identifier = identifier
-        self.args = args
-        self.value = value
-
-    def __repr__(self):
-        return _representation_string(self, name="DirectiveNode")
-
-    def spelling(self):
-        """
-        Return the string representation of this define in the input code.
-        Useful primarily for debugging and generating error messages.
-        """
-        value_str = "".join([str(v) for v in self.value])
-
-        if self.args is None:
-            return [f"#define {self.identifier!s} {value_str!s}"]
-        elif self.args == []:
-            return [f"#define {self.identifier!s}() {value_str!s}"]
-        else:
-            arg_str = ", ".join([str(arg) for arg in self.args])
-            return [f"#define {self.identifier!s}({arg_str!s}) {value_str!s}"]
+    identifier: Identifier
+    args: list[Token] | None = None
+    value: list[Token] | None = None
 
     def evaluate_for_platform(self, **kwargs):
         """
@@ -806,25 +734,13 @@ def evaluate_for_platform(self, **kwargs):
         return False
 
 
+@dataclass(eq=False)
 class UndefNode(DirectiveNode):
     """
     A DirectiveNode representing an #undef directive.
     """
 
-    def __init__(self, identifier):
-        super().__init__()
-        self.kind = "undefine"
-        self.identifier = identifier
-
-    def __repr__(self):
-        return _representation_string(self, name="DirectiveNode")
-
-    def spelling(self):
-        """
-        Return the string representation of this undef in the input code.
-        Useful primarily for debugging and generating error messages.
-        """
-        return [f"#undef {self.identifier!s}"]
+    identifier: Identifier
 
     def evaluate_for_platform(self, **kwargs):
         """
@@ -862,31 +778,14 @@ def is_system_path(self):
         return self.system
 
 
+@dataclass(eq=False)
 class IncludeNode(DirectiveNode):
     """
     A DirectiveNode representing an #include directive.
     Its value is an IncludePath or a list of tokens.
     """
 
-    def __init__(self, value):
-        super().__init__()
-        self.kind = "include"
-        self.value = value
-
-    def __repr__(self):
-        return _representation_string(self, name="DirectiveNode")
-
-    def spelling(self):
-        """
-        Return the string representation of this include in the input code.
-        Useful primarily for debugging and generating error messages.
-        """
-        if isinstance(self.value, list):
-            str_rep = " ".join([str(t) for t in self.value])
-        else:
-            str_rep = " ".join(self.value.spelling())
-
-        return [f"#include {str_rep!s}"]
+    value: IncludePath | list[Token]
 
     def evaluate_for_platform(self, **kwargs):
         """
@@ -932,48 +831,32 @@ def evaluate_for_platform(self, **kwargs):
             )
 
 
+@dataclass(eq=False)
 class IfNode(DirectiveNode):
     """
     Represents an #if, #ifdef or #ifndef directive.
     """
 
-    def __init__(self, tokens):
-        super().__init__()
-        self.kind = "if"
-        self.tokens = tokens
+    expr: list[Token]
 
     @staticmethod
     def is_start_node():
         return True
 
-    def __repr__(self):
-        return _representation_string(self, name="DirectiveNode")
-
-    def spelling(self):
-        """
-        Return the string representation of this if in the input code.
-        Useful primarily for debugging and generating error messages.
-        """
-        rest = " ".join([str(t) for t in self.tokens])
-        return [f"#if {rest}"]
-
     def evaluate_for_platform(self, **kwargs):
         # Perform macro substitution with tokens
-        expanded_tokens = MacroExpander(kwargs["platform"]).expand(self.tokens)
+        expanded_tokens = MacroExpander(kwargs["platform"]).expand(self.expr)
 
         # Evaluate the expanded tokens
         return ExpressionEvaluator(expanded_tokens).evaluate()
 
 
+@dataclass(eq=False)
 class ElIfNode(IfNode):
     """
     Represents an #elif directive.
     """
 
-    def __init__(self, tokens):
-        super().__init__(tokens)
-        self.kind = "elif"
-
     @staticmethod
     def is_start_node():
         return False
@@ -983,56 +866,30 @@ def is_cont_node():
         return True
 
 
+@dataclass(eq=False)
 class ElseNode(DirectiveNode):
     """
     Represents an #else directive.
     """
 
-    def __init__(self):
-        super().__init__()
-        self.kind = "else"
-
     @staticmethod
     def is_cont_node():
         return True
 
-    def __repr__(self):
-        return _representation_string(self, name="DirectiveNode")
-
-    def spelling(self):
-        """
-        Return the string representation of this else in the input code.
-        Useful primarily for debugging and generating error messages.
-        """
-        return ["#else"]
-
     def evaluate_for_platform(self, **kwargs):
         return True
 
 
+@dataclass(eq=False)
 class EndIfNode(DirectiveNode):
     """
     Represents an #endif directive.
     """
 
-    def __init__(self):
-        super().__init__()
-        self.kind = "endif"
-
     @staticmethod
     def is_end_node():
         return True
 
-    def __repr__(self):
-        return _representation_string(self, name="DirectiveNode")
-
-    def spelling(self):
-        """
-        Return the string representation of this endif in the input code.
-        Useful primarily for debugging and generating error messages.
-        """
-        return ["#endif"]
-
 
 class Parser:
     """
@@ -1194,7 +1051,7 @@ def define(self):
             else:
                 expansion = []
 
-            return DefineNode(identifier, args, expansion)
+            return DefineNode(self.tokens, identifier, args, expansion)
         except ParseError:
             self.pos = initial_pos
             raise ParseError("Invalid define directive.")
@@ -1210,7 +1067,7 @@ def undef(self):
         try:
             self.match_value(Identifier, "undef")
             identifier = self.match_type(Identifier)
-            return UndefNode(identifier)
+            return UndefNode(self.tokens, identifier)
         except ParseError:
             self.pos = initial_pos
             raise ParseError("Invalid undef directive.")
@@ -1236,7 +1093,7 @@ def include(self):
                 include_payload = self.tokens[path_pos:]
                 self.pos = len(self.tokens)
 
-            return IncludeNode(include_payload)
+            return IncludeNode(self.tokens, include_payload)
         except ParseError:
             self.pos = initial_pos
             raise ParseError("Invalid include directive.")
@@ -1302,7 +1159,7 @@ def pragma(self):
             expr = self.tokens[self.pos :]
             self.pos = len(self.tokens)
 
-            return PragmaNode(expr)
+            return PragmaNode(self.tokens, expr)
         except ParseError:
             self.pos = initial_pos
             raise ParseError("Invalid pragma directive.")
@@ -1320,7 +1177,7 @@ def if_(self):
             expr = self.tokens[self.pos :]
             self.pos = len(self.tokens)
 
-            return IfNode(expr)
+            return IfNode(self.tokens, expr)
         except ParseError:
             self.pos = initial_pos
             raise ParseError("Invalid if directive.")
@@ -1345,7 +1202,7 @@ def ifdef(self):
             suffix = [Punctuator("Unknown", -1, False, ")")]
             expr = prefix + [identifier] + suffix
 
-            return IfNode(expr)
+            return IfNode(self.tokens, expr)
         except ParseError:
             self.pos = initial_pos
             raise ParseError("Invalid ifdef directive")
@@ -1371,7 +1228,7 @@ def ifndef(self):
             suffix = [Punctuator("Unknown", -1, False, ")")]
             expr = prefix + [identifier] + suffix
 
-            return IfNode(expr)
+            return IfNode(self.tokens, expr)
         except ParseError:
             self.pos = initial_pos
             raise ParseError("Invalid ifndef directive")
@@ -1389,7 +1246,7 @@ def elif_(self):
             expr = self.tokens[self.pos :]
             self.pos = len(self.tokens)
 
-            return ElIfNode(expr)
+            return ElIfNode(self.tokens, expr)
         except ParseError:
             self.pos = initial_pos
             raise ParseError("Invalid elif directive.")
@@ -1404,7 +1261,7 @@ def else_(self):
         initial_pos = self.pos
         try:
             self.match_value(Identifier, "else")
-            return ElseNode()
+            return ElseNode(self.tokens)
         except ParseError:
             self.pos = initial_pos
             raise ParseError("Invalid else directive.")
@@ -1419,7 +1276,7 @@ def endif(self):
         initial_pos = self.pos
         try:
             self.match_value(Identifier, "endif")
-            return EndIfNode()
+            return EndIfNode(self.tokens)
         except ParseError:
             self.pos = initial_pos
             raise ParseError("Invalid endif directive.")
diff --git a/docs/source/conf.py b/docs/source/conf.py
index 65dd978..869731b 100644
--- a/docs/source/conf.py
+++ b/docs/source/conf.py
@@ -19,7 +19,7 @@
 # -- General configuration ---------------------------------------------------
 # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
 
-extensions = ["sphinx.ext.mathjax"]
+extensions = ["sphinx.ext.mathjax", "sphinx_inline_tabs"]
 
 templates_path = ["_templates"]
 exclude_patterns = []
diff --git a/docs/source/index.rst b/docs/source/index.rst
index e791b18..9f5efb4 100644
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -66,10 +66,27 @@ release, run the following::
     $ cd code-base-investigator
     $ pip install .
 
-We strongly recommend installing CBI within a `virtual environment`_.
+We strongly recommend installing CBI within a virtual environment, to simplify
+dependency management and improve security. Some alternative methods of
+creating a virtual environment are shown below.
 
-.. _`virtual environment`: https://docs.python.org/3/library/venv.html
+.. tab:: venv
 
+    .. code-block:: text
+
+        $ git clone --branch 1.2.0 https://github.com/intel/code-base-investigator.git
+        $ python3 -m venv cbi
+        $ source cbi/bin/activate
+        $ cd code-base-investigator
+        $ pip install .
+
+.. tab:: uv
+
+    .. code-block:: text
+
+        $ git clone --branch 1.2.0 https://github.com/intel/code-base-investigator.git
+        $ cd code-base-investigator
+        $ uv tool install .
 
 Getting Started
 ###############


Test patch:
diff --git a/tests/cli/test_cbicov.py b/tests/cli/test_cbicov.py
index e810233..3d49fe9 100644
--- a/tests/cli/test_cbicov.py
+++ b/tests/cli/test_cbicov.py
@@ -7,7 +7,6 @@
 import sys
 import tempfile
 import unittest
-import warnings
 from contextlib import redirect_stdout
 from pathlib import Path
 
@@ -21,7 +20,6 @@ class TestCbiCov(unittest.TestCase):
 
     def setUp(self):
         logging.disable()
-        warnings.simplefilter("ignore", ResourceWarning)
 
     def test_help(self):
         """Check help string displays correctly."""
@@ -66,8 +64,8 @@ def test_compute(self):
         """Check that coverage is computed correctly."""
         sys.stdout = io.StringIO()
         # Create a temporary codebase to work on.
-        self.tmp = tempfile.TemporaryDirectory()
-        p = Path(self.tmp.name)
+        tmp = tempfile.TemporaryDirectory()
+        p = Path(tmp.name)
         with open(p / "foo.cpp", mode="w") as f:
             f.write(
                 r"""#ifdef MACRO
@@ -107,9 +105,11 @@ def test_compute(self):
                 "lines": [1, 3, 4],
             },
         ]
-        self.assertEqual(coverage, expected_coverage)
+        self.assertCountEqual(coverage, expected_coverage)
         sys.stdout = sys.__stdout__
 
+        tmp.cleanup()
+
 
 if __name__ == "__main__":
     unittest.main()
diff --git a/tests/code-base/test_code_base.py b/tests/code-base/test_code_base.py
index 0438b8a..46b3840 100644
--- a/tests/code-base/test_code_base.py
+++ b/tests/code-base/test_code_base.py
@@ -4,7 +4,6 @@
 import logging
 import tempfile
 import unittest
-import warnings
 from pathlib import Path
 
 from codebasin import CodeBase
@@ -15,9 +14,9 @@ class TestCodeBase(unittest.TestCase):
     Test CodeBase class.
     """
 
-    def setUp(self):
+    @classmethod
+    def setUpClass(self):
         logging.disable()
-        warnings.simplefilter("ignore", ResourceWarning)
 
         # Create a temporary codebase spread across two directories
         self.tmp1 = tempfile.TemporaryDirectory()
@@ -32,6 +31,11 @@ def setUp(self):
         open(p2 / "quux.h", mode="w").close()
         open(p2 / "README.md", mode="w").close()
 
+    @classmethod
+    def tearDownClass(self):
+        self.tmp1.cleanup()
+        self.tmp2.cleanup()
+
     def test_constructor(self):
         """Check directories and exclude_patterns are handled correctly"""
         path = Path(self.tmp1.name)
diff --git a/tests/compilers/test_actions.py b/tests/compilers/test_actions.py
new file mode 100644
index 0000000..532996b
--- /dev/null
+++ b/tests/compilers/test_actions.py
@@ -0,0 +1,128 @@
+# Copyright (C) 2019-2024 Intel Corporation
+# SPDX-License-Identifier: BSD-3-Clause
+
+import argparse
+import logging
+import unittest
+
+from codebasin.config import _ExtendMatchAction, _StoreSplitAction
+
+
+class TestActions(unittest.TestCase):
+    """
+    Test that custom argparse.Action classes work as expected.
+    These classes enable handling of complex user-defined compiler options.
+    """
+
+    def setUp(self):
+        logging.disable()
+
+    def test_store_split_init(self):
+        """Check that store_split recognizes custom arguments"""
+        action = _StoreSplitAction(["--foo"], "foo", sep=",", format="$value")
+        self.assertEqual(action.sep, ",")
+        self.assertEqual(action.format, "$value")
+
+        action = _StoreSplitAction(["--foo"], "foo")
+        self.assertEqual(action.sep, None)
+        self.assertEqual(action.format, None)
+
+    def test_store_split(self):
+        """Check that argparse calls store_split correctly"""
+        namespace = argparse.Namespace()
+        namespace.passes = {}
+
+        parser = argparse.ArgumentParser()
+        parser.add_argument("--foo", action=_StoreSplitAction, sep=",")
+        parser.add_argument(
+            "--bar",
+            action=_StoreSplitAction,
+            sep=",",
+            format="prefix-$value-suffix",
+        )
+        parser.add_argument("--baz", action=_StoreSplitAction, type=int)
+        parser.add_argument(
+            "--qux",
+            action=_StoreSplitAction,
+            sep=",",
+            dest="passes",
+        )
+
+        args, _ = parser.parse_known_args(["--foo=one,two"], namespace)
+        self.assertEqual(args.foo, ["one", "two"])
+
+        args, _ = parser.parse_known_args(["--bar=one,two"], namespace)
+        self.assertEqual(args.bar, ["prefix-one-suffix", "prefix-two-suffix"])
+
+        with self.assertRaises(TypeError):
+            args, _ = parser.parse_known_args(["--baz=1"], namespace)
+
+        args, _ = parser.parse_known_args(["--qux=one,two"], namespace)
+        self.assertEqual(args.passes, {"--qux": ["one", "two"]})
+
+    def test_extend_match_init(self):
+        """Check that extend_match recognizes custom arguments"""
+        action = _ExtendMatchAction(
+            ["--foo"],
+            "foo",
+            pattern="*",
+            format="$value",
+        )
+        self.assertEqual(action.pattern, "*")
+        self.assertEqual(action.format, "$value")
+
+        action = _ExtendMatchAction(["--foo"], "foo")
+        self.assertEqual(action.pattern, None)
+        self.assertEqual(action.format, None)
+
+    def test_extend_match(self):
+        """Check that argparse calls store_split correctly"""
+        namespace = argparse.Namespace()
+        namespace.passes = {}
+
+        parser = argparse.ArgumentParser()
+        parser.add_argument(
+            "--foo",
+            action=_ExtendMatchAction,
+            pattern=r"option_(\d+)",
+            default=[],
+        )
+        parser.add_argument(
+            "--bar",
+            action=_ExtendMatchAction,
+            pattern=r"option_(\d+)",
+            format="prefix-$value-suffix",
+            default=[],
+        )
+        parser.add_argument("--baz", action=_ExtendMatchAction, type=int)
+        parser.add_argument(
+            "--qux",
+            action=_ExtendMatchAction,
+            pattern=r"option_(\d+)",
+            dest="passes",
+        )
+
+        args, _ = parser.parse_known_args(
+            ["--foo=option_1,option_2"],
+            namespace,
+        )
+        self.assertEqual(args.foo, ["1", "2"])
+
+        args, _ = parser.parse_known_args(
+            ["--bar=option_1,option_2"],
+            namespace,
+        )
+        self.assertEqual(args.bar, ["prefix-1-suffix", "prefix-2-suffix"])
+
+        with self.assertRaises(TypeError):
+            args, _ = parser.parse_known_args(["--baz=1"], namespace)
+
+        args, _ = parser.parse_known_args(
+            ["--qux=option_1,option_2"],
+            namespace,
+        )
+        self.assertEqual(args.passes, {"--qux": ["1", "2"]})
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/tests/compilers/test_compilers.py b/tests/compilers/test_compilers.py
index 02a3bec..bfcf90f 100644
--- a/tests/compilers/test_compilers.py
+++ b/tests/compilers/test_compilers.py
@@ -2,7 +2,10 @@
 # SPDX-License-Identifier: BSD-3-Clause
 
 import logging
+import os
+import tempfile
 import unittest
+from pathlib import Path
 
 from codebasin import config
 
@@ -15,6 +18,40 @@ class TestCompilers(unittest.TestCase):
 
     def setUp(self):
         logging.disable()
+        self.cwd = os.getcwd()
+
+    def tearDown(self):
+        os.chdir(self.cwd)
+
+    def test_common(self):
+        """compilers/common"""
+        argv = [
+            "c++",
+            "-I/path",
+            "-I",
+            "/path/after/space",
+            "-isystem",
+            "/system/path",
+            "-include",
+            "foo.inc",
+            "-include",
+            "bar.inc",
+            "-DMACRO",
+            "-DFUNCTION_MACRO=1",
+            "-D",
+            "MACRO_AFTER_SPACE",
+            "test.cpp",
+        ]
+        args = config._parse_compiler_args(argv)
+        self.assertEqual(
+            args.defines,
+            ["MACRO", "FUNCTION_MACRO=1", "MACRO_AFTER_SPACE"],
+        )
+        self.assertEqual(
+            args.include_paths,
+            ["/path", "/path/after/space", "/system/path"],
+        )
+        self.assertEqual(args.include_files, ["foo.inc", "bar.inc"])
 
     def test_clang(self):
         """compilers/clang"""
@@ -101,6 +138,28 @@ def test_nvcc(self):
         defines = compiler.get_defines("52")
         self.assertEqual(defines, defaults + ["__CUDA_ARCH__=520"])
 
+    def test_user_options(self):
+        """Check that we import user-defined options"""
+        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('options = ["-D", "ASDF"]\n')
+        config.load_importcfg()
+
+        args = [
+            "c++",
+            "test.cpp",
+        ]
+
+        compiler = config.recognize_compiler(args)
+        defines = compiler.get_defines("default")
+        self.assertCountEqual(defines, ["ASDF"])
+
+        tmp.cleanup()
+
 
 if __name__ == "__main__":
     unittest.main()
diff --git a/tests/duplicates/test_duplicates.py b/tests/duplicates/test_duplicates.py
index fb157b3..62550aa 100644
--- a/tests/duplicates/test_duplicates.py
+++ b/tests/duplicates/test_duplicates.py
@@ -118,6 +118,8 @@ def test_symlink_files(self):
         setmap = state.get_setmap(codebase)
         self.assertDictEqual(setmap, expected_setmap, "Mismatch in setmap")
 
+        tmp.cleanup()
+
     def test_find_duplicates(self):
         """Check that we can correctly identify duplicate files."""
         tmp = tempfile.TemporaryDirectory()
diff --git a/tests/files/test_filetree.py b/tests/files/test_filetree.py
index 9b968af..b5dcb8d 100644
--- a/tests/files/test_filetree.py
+++ b/tests/files/test_filetree.py
@@ -5,7 +5,6 @@
 import os
 import tempfile
 import unittest
-import warnings
 from pathlib import Path
 
 from codebasin.report import FileTree
@@ -16,9 +15,9 @@ class TestFileTree(unittest.TestCase):
     Test FileTree functionality.
     """
 
-    def setUp(self):
+    @classmethod
+    def setUpClass(self):
         logging.disable()
-        warnings.simplefilter("ignore", ResourceWarning)
 
         self.setmap = {
             frozenset(["X"]): 1,
@@ -33,6 +32,10 @@ def setUp(self):
         open(self.path / "other.cpp", mode="w").close()
         os.symlink(self.path / "file.cpp", self.path / "symlink.cpp")
 
+    @classmethod
+    def tearDownClass(self):
+        self.tmp.cleanup()
+
     def test_constructor(self):
         """Check FileTree constructor."""
         tree = FileTree(self.path)
diff --git a/tests/files/test_filetree_node.py b/tests/files/test_filetree_node.py
index c1ed702..ef665f3 100644
--- a/tests/files/test_filetree_node.py
+++ b/tests/files/test_filetree_node.py
@@ -5,7 +5,6 @@
 import os
 import tempfile
 import unittest
-import warnings
 from collections import defaultdict
 from pathlib import Path
 
@@ -17,9 +16,9 @@ class TestFileTreeNode(unittest.TestCase):
     Test FileTree.Node functionality.
     """
 
-    def setUp(self):
+    @classmethod
+    def setUpClass(self):
         logging.disable()
-        warnings.simplefilter("ignore", ResourceWarning)
 
         self.setmap = {
             frozenset(["X"]): 1,
@@ -33,6 +32,10 @@ def setUp(self):
         open(self.path / "file.cpp", mode="w").close()
         os.symlink(self.path / "file.cpp", self.path / "symlink.cpp")
 
+    @classmethod
+    def tearDownClass(self):
+        self.tmp.cleanup()
+
     def test_constructor(self):
         """Check FileTree.Node constructor."""
         node = FileTree.Node(self.path)
diff --git a/tests/metrics/test_divergence.py b/tests/metrics/test_divergence.py
index 64eebb9..4646934 100644
--- a/tests/metrics/test_divergence.py
+++ b/tests/metrics/test_divergence.py
@@ -4,7 +4,6 @@
 import logging
 import math
 import unittest
-import warnings
 
 from codebasin.report import divergence
 
@@ -16,7 +15,6 @@ class TestDivergence(unittest.TestCase):
 
     def setUp(self):
         logging.disable()
-        warnings.simplefilter("ignore", ResourceWarning)
 
     def test_divergence(self):
         """Check divergence computation for simple setmap."""
diff --git a/tests/metrics/test_utilization.py b/tests/metrics/test_utilization.py
index 0013d62..29672cd 100644
--- a/tests/metrics/test_utilization.py
+++ b/tests/metrics/test_utilization.py
@@ -4,7 +4,6 @@
 import logging
 import math
 import unittest
-import warnings
 
 from codebasin.report import normalized_utilization, utilization
 
@@ -16,7 +15,6 @@ class TestUtilization(unittest.TestCase):
 
     def setUp(self):
         logging.disable()
-        warnings.simplefilter("ignore", ResourceWarning)
 
     def test_utilization(self):
         """Check utilization computation for simple setmap."""
diff --git a/tests/parsers/test_directive_parser.py b/tests/parsers/test_directive_parser.py
index f13c099..85168ae 100644
--- a/tests/parsers/test_directive_parser.py
+++ b/tests/parsers/test_directive_parser.py
@@ -96,34 +96,38 @@ def test_if(self):
         tokens = preprocessor.Lexer("#if FOO == BAR").tokenize()
         node = preprocessor.DirectiveParser(tokens).parse()
         self.assertTrue(isinstance(node, preprocessor.IfNode))
-        self.assertTrue(len(node.tokens) == 3)
+        self.assertTrue(len(node.tokens) == 5)
+        self.assertTrue(len(node.expr) == 3)
 
     def test_ifdef(self):
         """ifdef"""
         tokens = preprocessor.Lexer("#ifdef FOO").tokenize()
         node = preprocessor.DirectiveParser(tokens).parse()
         self.assertTrue(isinstance(node, preprocessor.IfNode))
-        self.assertTrue(len(node.tokens) == 4)
-        self.assertTrue(isinstance(node.tokens[0], preprocessor.Identifier))
-        self.assertTrue(node.tokens[0].token == "defined")
+        self.assertTrue(len(node.tokens) == 3)
+        self.assertTrue(len(node.expr) == 4)
+        self.assertTrue(isinstance(node.expr[0], preprocessor.Identifier))
+        self.assertTrue(node.expr[0].token == "defined")
 
     def test_ifndef(self):
         """ifndef"""
         tokens = preprocessor.Lexer("#ifndef FOO").tokenize()
         node = preprocessor.DirectiveParser(tokens).parse()
         self.assertTrue(isinstance(node, preprocessor.IfNode))
-        self.assertTrue(len(node.tokens) == 5)
-        self.assertTrue(isinstance(node.tokens[0], preprocessor.Operator))
-        self.assertTrue(node.tokens[0].token == "!")
-        self.assertTrue(isinstance(node.tokens[1], preprocessor.Identifier))
-        self.assertTrue(node.tokens[1].token == "defined")
+        self.assertTrue(len(node.tokens) == 3)
+        self.assertTrue(len(node.expr) == 5)
+        self.assertTrue(isinstance(node.expr[0], preprocessor.Operator))
+        self.assertTrue(node.expr[0].token == "!")
+        self.assertTrue(isinstance(node.expr[1], preprocessor.Identifier))
+        self.assertTrue(node.expr[1].token == "defined")
 
     def test_elif(self):
         """elif"""
         tokens = preprocessor.Lexer("#elif FOO == BAR").tokenize()
         node = preprocessor.DirectiveParser(tokens).parse()
         self.assertTrue(isinstance(node, preprocessor.ElIfNode))
-        self.assertTrue(len(node.tokens) == 3)
+        self.assertTrue(len(node.tokens) == 5)
+        self.assertTrue(len(node.expr) == 3)
 
     def test_else(self):
         """else"""
diff --git a/tests/source-tree/test_source_tree.py b/tests/source-tree/test_source_tree.py
index c47c0b9..fbd9256 100644
--- a/tests/source-tree/test_source_tree.py
+++ b/tests/source-tree/test_source_tree.py
@@ -4,7 +4,6 @@
 import logging
 import tempfile
 import unittest
-import warnings
 
 from codebasin.file_parser import FileParser
 from codebasin.preprocessor import CodeNode, DirectiveNode, FileNode, Visit
@@ -17,7 +16,6 @@ class TestSourceTree(unittest.TestCase):
 
     def setUp(self):
         logging.getLogger("codebasin").disabled = False
-        warnings.simplefilter("ignore", ResourceWarning)
 
         # TODO: Revisit this when SourceTree can be built without a file.
         with tempfile.NamedTemporaryFile(