build: GN static_analysis polish

Some python fixes and documentation updates.
- Refine the implementation of the source filter
  to match relative paths only.

Bug: 45
Change-Id: Ia38f651599d32d46fe2a720a5c9f4a5f5ec233c3
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/59286
Commit-Queue: Henri Chataing <henrichataing@google.com>
Reviewed-by: Wyatt Hepler <hepler@google.com>
diff --git a/BUILD.gn b/BUILD.gn
index a86c9c8..3daab36 100644
--- a/BUILD.gn
+++ b/BUILD.gn
@@ -125,6 +125,8 @@
 }
 
 # Run clang-tidy on pigweed_default with host_clang_debug toolchain options.
+# Make sure to invoke gn clean out when any relevant .clang-tidy
+# file is updated.
 group("static_analysis") {
   _toolchain_prefix = "$dir_pigweed/targets/host:host_clang_"
   deps = [ ":pigweed_default(${_toolchain_prefix}debug.static_analysis)" ]
diff --git a/pw_toolchain/docs.rst b/pw_toolchain/docs.rst
index f13362c..39bd70c 100644
--- a/pw_toolchain/docs.rst
+++ b/pw_toolchain/docs.rst
@@ -87,8 +87,8 @@
 toolchain ``${target_name}.static_analysis`` using
 ``pw_generate_static_analysis_toolchain`` and the toolchain options.
 
-The build argument ``pw_toolchain_STATIC_ANALYSIS_SOURCE_FILTER`` may be
-used to select and/or exclude source files to output diagnostics from.
+The build argument ``pw_toolchain_STATIC_ANALYSIS_SOURCE_EXCLUDE`` may be
+used to exclude source files from the analysis.
 
 ``pw_toolchain`` provides static analysis GN toolchains that may be used to
 test host targets:
@@ -98,6 +98,9 @@
  - pw_toolchain_host_clang.size_optimized.static_analysis
  - pw_toolchain_host_clang.fuzz.static_analysis
    (if pw_toolchain_OSS_FUZZ_ENABLED is false)
+ - pw_toolchain_arm_clang.debug.static_analysis
+ - pw_toolchain_arm_clang.speed_optimized.static_analysis
+ - pw_toolchain_arm_clang.size_optimized.static_analysis
 
  For example, to run ``clang-tidy`` on all source dependencies of the
  ``default`` target:
@@ -112,3 +115,9 @@
   group("static_analysis") {
     deps = [ ":default(my_toolchain.static_analysis)" ]
   }
+
+.. warning::
+    The status of the static analysis checks might change when
+    any relevant .clang-tidy file is updated. You should
+    clean the output directory before invoking
+    ``clang-tidy``.
diff --git a/pw_toolchain/py/pw_toolchain/clang_tidy.py b/pw_toolchain/py/pw_toolchain/clang_tidy.py
index 3b7387e..7bfdc99 100644
--- a/pw_toolchain/py/pw_toolchain/clang_tidy.py
+++ b/pw_toolchain/py/pw_toolchain/clang_tidy.py
@@ -16,22 +16,18 @@
 
 Implements additional features compared to directly calling
 clang-tidy:
-  - add option `--source-filter` to select which sources to run the
-    clang-tidy on. Similar to `--header-filter` implemented by
-    clang-tidy.
+  - add option `--source-exclude` to exclude matching sources from the
+    clang-tidy analysis.
   - inputs the full compile command, with the cc binary name
   - TODO: infer platform options from the full compile command
 """
 
 import argparse
 import logging
-import pathlib
-import re
+from pathlib import Path
 import subprocess
 import sys
-
-from pathlib import Path
-from typing import List, Optional
+from typing import List, Optional, Union
 
 _LOG = logging.getLogger(__name__)
 
@@ -49,20 +45,33 @@
                         default='clang-tidy',
                         help='Path to clang-tidy executable.')
 
-    parser.add_argument('--source-file',
-                        required=True,
-                        help='Source file to analyze with clang-tidy.')
+    parser.add_argument(
+        '--source-file',
+        required=True,
+        type=Path,
+        help='Path to the source file to analyze with clang-tidy.')
+    parser.add_argument(
+        '--source-root',
+        required=True,
+        type=Path,
+        help=('Path to the root source directory.'
+              ' The relative path from the root directory is matched'
+              ' against source filter rather than the absolute path.'))
+    parser.add_argument(
+        '--export-fixes',
+        required=False,
+        type=Path,
+        help=('YAML file to store suggested fixes in. The '
+              'stored fixes can be applied to the input source '
+              'code with clang-apply-replacements.'))
 
-    parser.add_argument('--export-fixes',
-                        required=False,
-                        help='YAML file to store suggested fixes in. The ' +
-                        'stored fixes can be applied to the input source ' +
-                        'code with clang-apply-replacements.')
-
-    parser.add_argument('--source-filter',
-                        default='.*',
-                        help='Regular expression matching the names of' +
-                        ' the source files to output diagnostics from')
+    parser.add_argument('--source-exclude',
+                        default=[],
+                        action='append',
+                        type=str,
+                        help=('Glob-style patterns matching the paths of'
+                              ' source files to be excluded from the'
+                              ' analysis.'))
 
     # Add a silent placeholder arg for everything that was left over.
     parser.add_argument('extra_args',
@@ -71,7 +80,9 @@
 
     parsed_args = parser.parse_args()
 
-    assert parsed_args.extra_args[0] == '--', 'arguments not correctly split'
+    if parsed_args.extra_args[0] != '--':
+        parser.error('arguments not correctly split')
+
     parsed_args.extra_args = parsed_args.extra_args[1:]
     return parsed_args
 
@@ -79,28 +90,31 @@
 def run_clang_tidy(clang_tidy: str, verbose: bool, source_file: Path,
                    export_fixes: Optional[Path], extra_args: List[str]) -> int:
     """Executes clang_tidy via subprocess. Returns true if no failures."""
-    command = [clang_tidy, str(source_file)]
+    command: List[Union[str, Path]] = [clang_tidy, source_file, '--use-color']
 
     if not verbose:
         command.append('--quiet')
 
     if export_fixes is not None:
-        command.extend(['--export-fixes', str(export_fixes)])
+        command.extend(['--export-fixes', export_fixes])
 
     # Append extra compilation flags. extra_args[0] is skipped as it contains
     # the compiler binary name.
     command.append('--')
     command.extend(extra_args[1:])
 
-    process = subprocess.run(command,
-                             stdout=subprocess.PIPE,
-                             stderr=subprocess.PIPE)
+    process = subprocess.run(
+        command,
+        stdout=subprocess.PIPE,
+        # clang-tidy prints regular information on
+        # stderr, even with the option --quiet.
+        stderr=subprocess.PIPE)
 
     if process.stdout:
-        _LOG.warning(process.stdout.decode('utf-8').strip())
+        _LOG.warning(process.stdout.decode().strip())
 
     if process.stderr and process.returncode != 0:
-        _LOG.error(process.stderr.decode('utf-8').strip())
+        _LOG.error(process.stderr.decode().strip())
 
     return process.returncode
 
@@ -108,18 +122,27 @@
 def main(
     verbose: bool,
     clang_tidy: str,
-    source_file: str,
-    export_fixes: Optional[str],
-    source_filter: str,
+    source_file: Path,
+    source_root: Path,
+    export_fixes: Optional[Path],
+    source_exclude: List[str],
     extra_args: List[str],
 ) -> int:
+    # Rebase the source file path on source_root.
+    # If source_file is not relative to source_root (which may be the case for
+    # generated files) stick with the original source_file.
+    try:
+        relative_source_file = source_file.relative_to(source_root)
+    except ValueError:
+        relative_source_file = source_file
 
-    if not re.search(source_filter, source_file):
-        return 0
+    for pattern in source_exclude:
+        if relative_source_file.match(pattern):
+            return 0
 
-    source_file_path = pathlib.Path(source_file).resolve()
-    export_fixes_path = pathlib.Path(
-        export_fixes).resolve() if export_fixes is not None else None
+    source_file_path = source_file.resolve()
+    export_fixes_path = (export_fixes.resolve()
+                         if export_fixes is not None else None)
     return run_clang_tidy(clang_tidy, verbose, source_file_path,
                           export_fixes_path, extra_args)
 
diff --git a/pw_toolchain/static_analysis_toolchain.gni b/pw_toolchain/static_analysis_toolchain.gni
index f175642..6912486 100644
--- a/pw_toolchain/static_analysis_toolchain.gni
+++ b/pw_toolchain/static_analysis_toolchain.gni
@@ -17,15 +17,15 @@
 import("$dir_pw_toolchain/universal_tools.gni")
 
 declare_args() {
-  # Regular expression matching the names of the source files to output
-  # diagnostics from. The option is not provided by clang-tidy, even
-  # though --header-filter is. Example:
+  # Glob-style patterns matching the paths of the source files to be excluded
+  # from the analysis. clang-tidy provides no alternative option.
+  # Example:
   #
   #   gn gen out \
-  #     --args='pw_toolchain_STATIC_ANALYSIS_SOURCE_FILTER="^((?!third_party).)*$"'
+  #     --args='pw_toolchain_STATIC_ANALYSIS_SOURCE_EXCLUDE=["third_party/.*"]'
   #
   # disables clang-tidy on all files in the third_party directory.
-  pw_toolchain_STATIC_ANALYSIS_SOURCE_FILTER = ".*"
+  pw_toolchain_STATIC_ANALYSIS_SOURCE_EXCLUDE = []
 }
 
 # Creates a toolchain target for static analysis.
@@ -41,9 +41,14 @@
   # Clang tidy is invoked by a wrapper script which implements the missing
   # option --source-filter.
   _clang_tidy_py_path =
-      rebase_path("$dir_pw_toolchain/py/pw_toolchain/clang_tidy.py")
+      rebase_path("$dir_pw_toolchain/py/pw_toolchain/clang_tidy.py",
+                  root_build_dir)
   _clang_tidy_py = "${python_path} ${_clang_tidy_py_path}"
-  _source_filter = pw_toolchain_STATIC_ANALYSIS_SOURCE_FILTER
+  _source_root = rebase_path("//", root_build_dir)
+  _source_exclude = ""
+  foreach(pattern, pw_toolchain_STATIC_ANALYSIS_SOURCE_EXCLUDE) {
+    _source_exclude = _source_exclude + " --source-exclude '${pattern}'"
+  }
 
   toolchain(target_name) {
     # Uncomment this line to see which toolchains generate other toolchains.
@@ -68,8 +73,9 @@
       command = string_join(" ",
                             [
                               _clang_tidy_py,
-                              "--source-filter '${_source_filter}'",
+                              _source_exclude,
                               "--source-file {{source}}",
+                              "--source-root '${_source_root}'",
                               "--export-fixes {{output}}.yaml",
                               "--",
                               invoker.cc,
@@ -93,8 +99,9 @@
       command = string_join(" ",
                             [
                               _clang_tidy_py,
-                              "--source-filter '${_source_filter}'",
+                              _source_exclude,
                               "--source-file {{source}}",
+                              "--source-root '${_source_root}'",
                               "--export-fixes {{output}}.yaml",
                               "--",
                               invoker.cxx,