pw_presubmit: Check for files in the CMake build

- Add functions for checking if source files are present in
  compile_commands.json.
- Check for sources in CMake files in the source_is_in_build_files
  presubmit check. Report missing files, but don't fail, since there are
  many files missing.

Change-Id: I3def8a0c19760256f70c5c263023a1c2ff04d1bb
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/28740
Reviewed-by: Rob Mohr <mohrr@google.com>
Commit-Queue: Wyatt Hepler <hepler@google.com>
diff --git a/pw_presubmit/py/pw_presubmit/build.py b/pw_presubmit/py/pw_presubmit/build.py
index 563f8ef..835a95e 100644
--- a/pw_presubmit/py/pw_presubmit/build.py
+++ b/pw_presubmit/py/pw_presubmit/build.py
@@ -14,11 +14,14 @@
 """Functions for building code during presubmit checks."""
 
 import collections
+import itertools
+import json
 import logging
 import os
 from pathlib import Path
 import re
-from typing import Container, Dict, Iterable, List, Mapping, Set, Tuple
+from typing import (Collection, Container, Dict, Iterable, List, Mapping, Set,
+                    Tuple, Union)
 
 from pw_package import package_manager
 from pw_presubmit import call, log_run, plural, PresubmitFailure, tools
@@ -152,6 +155,38 @@
                 yield path
 
 
+def _read_compile_commands(compile_commands: Path) -> dict:
+    with compile_commands.open('rb') as fd:
+        return json.load(fd)
+
+
+def compiled_files(compile_commands: Path) -> Iterable[Path]:
+    for command in _read_compile_commands(compile_commands):
+        file = Path(command['file'])
+        if file.is_absolute():
+            yield file
+        else:
+            yield file.joinpath(command['directory']).resolve()
+
+
+def check_compile_commands_for_files(
+        compile_commands: Union[Path, Iterable[Path]],
+        files: Iterable[Path],
+        extensions: Collection[str] = ('.c', '.cc', '.cpp'),
+) -> List[Path]:
+    """Checks for paths in one or more compile_commands.json files.
+
+    Only checks C and C++ source files by default.
+    """
+    if isinstance(compile_commands, Path):
+        compile_commands = [compile_commands]
+
+    compiled = frozenset(
+        itertools.chain.from_iterable(
+            compiled_files(cmds) for cmds in compile_commands))
+    return [f for f in files if f not in compiled and f.suffix in extensions]
+
+
 def check_builds_for_files(
         bazel_extensions_to_check: Container[str],
         gn_extensions_to_check: Container[str],
diff --git a/pw_presubmit/py/pw_presubmit/pigweed_presubmit.py b/pw_presubmit/py/pw_presubmit/pigweed_presubmit.py
index d75f498..f069720 100755
--- a/pw_presubmit/py/pw_presubmit/pigweed_presubmit.py
+++ b/pw_presubmit/py/pw_presubmit/pigweed_presubmit.py
@@ -158,17 +158,22 @@
     )
 
 
-@filter_paths(endswith=(*format_code.C_FORMAT.extensions, '.cmake',
-                        'CMakeLists.txt'))
-def cmake_tests(ctx: PresubmitContext):
+def _run_cmake(ctx: PresubmitContext) -> None:
     build.install_package(ctx.package_root, 'nanopb')
 
     toolchain = ctx.root / 'pw_toolchain' / 'host_clang' / 'toolchain.cmake'
     build.cmake(ctx.root,
                 ctx.output_dir,
                 f'-DCMAKE_TOOLCHAIN_FILE={toolchain}',
+                '-DCMAKE_EXPORT_COMPILE_COMMANDS=1',
                 f'-Ddir_pw_third_party_nanopb={ctx.package_root / "nanopb"}',
                 env=build.env_with_clang_vars())
+
+
+@filter_paths(endswith=(*format_code.C_FORMAT.extensions, '.cmake',
+                        'CMakeLists.txt'))
+def cmake_tests(ctx: PresubmitContext):
+    _run_cmake(ctx)
     build.ninja(ctx.output_dir, 'pw_apps', 'pw_run_tests.modules')
 
 
@@ -396,6 +401,18 @@
             'All source files must appear in BUILD and BUILD.gn files')
         raise PresubmitFailure
 
+    _run_cmake(ctx)
+    cmake_missing = build.check_compile_commands_for_files(
+        ctx.output_dir / 'compile_commands.json',
+        (f for f in ctx.paths if f.suffix in ('.c', '.cc')))
+    if cmake_missing:
+        _LOG.warning('The CMake build is missing %d files', len(cmake_missing))
+        _LOG.warning('Files missing from CMake:\n%s',
+                     '\n'.join(str(f) for f in cmake_missing))
+        # TODO(hepler): Many files are missing from the CMake build. Make this
+        #     check an error when the missing files are fixed.
+        # raise PresubmitFailure
+
 
 def build_env_setup(ctx: PresubmitContext):
     if 'PW_CARGO_SETUP' not in os.environ: