aboutsummaryrefslogtreecommitdiff
path: root/pw_ide/py/pw_ide/cpp.py
diff options
context:
space:
mode:
Diffstat (limited to 'pw_ide/py/pw_ide/cpp.py')
-rw-r--r--pw_ide/py/pw_ide/cpp.py827
1 files changed, 466 insertions, 361 deletions
diff --git a/pw_ide/py/pw_ide/cpp.py b/pw_ide/py/pw_ide/cpp.py
index e58d66d74..b8cea99de 100644
--- a/pw_ide/py/pw_ide/cpp.py
+++ b/pw_ide/py/pw_ide/cpp.py
@@ -43,20 +43,24 @@ point at the symlink and is set up with the right paths, you'll get code
intelligence.
"""
-from collections import defaultdict
-from dataclasses import dataclass
+from contextlib import contextmanager
+from dataclasses import asdict, dataclass, field
import glob
+from hashlib import sha1
from io import TextIOBase
import json
-import os
+import logging
from pathlib import Path
import platform
+import random
+import re
+import sys
from typing import (
Any,
cast,
- Callable,
Dict,
Generator,
+ Iterator,
List,
Optional,
Tuple,
@@ -64,6 +68,8 @@ from typing import (
Union,
)
+from pw_cli.env import pigweed_environment
+
from pw_ide.exceptions import (
BadCompDbException,
InvalidTargetException,
@@ -71,105 +77,234 @@ from pw_ide.exceptions import (
UnresolvablePathException,
)
-from pw_ide.settings import PigweedIdeSettings, PW_PIGWEED_CIPD_INSTALL_DIR
+from pw_ide.settings import PigweedIdeSettings
from pw_ide.symlinks import set_symlink
-_COMPDB_FILE_PREFIX = 'compile_commands'
-_COMPDB_FILE_SEPARATOR = '_'
-_COMPDB_FILE_EXTENSION = '.json'
-
-_COMPDB_CACHE_DIR_PREFIX = '.cache'
-_COMPDB_CACHE_DIR_SEPARATOR = '_'
+_LOG = logging.getLogger(__package__)
+env = pigweed_environment()
-COMPDB_FILE_GLOB = f'{_COMPDB_FILE_PREFIX}*{_COMPDB_FILE_EXTENSION}'
-COMPDB_CACHE_DIR_GLOB = f'{_COMPDB_CACHE_DIR_PREFIX}*'
+COMPDB_FILE_NAME = 'compile_commands.json'
+STABLE_CLANGD_DIR_NAME = '.stable'
+_CPP_IDE_FEATURES_DATA_FILE = 'pw_ide_state.json'
+_UNSUPPORTED_TOOLCHAIN_EXECUTABLES = ('_pw_invalid', 'python')
+_SUPPORTED_WRAPPER_EXECUTABLES = ('ccache',)
-MAX_COMMANDS_TARGET_FILENAME = 'max_commands_target'
-_SUPPORTED_TOOLCHAIN_EXECUTABLES = ('clang', 'gcc', 'g++')
+@dataclass(frozen=True)
+class CppIdeFeaturesTarget:
+ """Data pertaining to a C++ code analysis target."""
+ name: str
+ compdb_file_path: Path
+ num_commands: int
+ is_enabled: bool = True
+
+ def __str__(self) -> str:
+ return self.name
+
+ def serialized(self) -> Dict[str, Any]:
+ return {
+ **asdict(self),
+ **{
+ 'compdb_file_path': str(self.compdb_file_path),
+ },
+ }
-def compdb_generate_file_path(target: str = '') -> Path:
- """Generate a compilation database file path."""
+ @classmethod
+ def deserialize(cls, **data) -> 'CppIdeFeaturesTarget':
+ return cls(
+ **{
+ **data,
+ **{
+ 'compdb_file_path': Path(data['compdb_file_path']),
+ },
+ }
+ )
- path = Path(f'{_COMPDB_FILE_PREFIX}{_COMPDB_FILE_EXTENSION}')
- if target:
- path = path.with_name(
- f'{_COMPDB_FILE_PREFIX}'
- f'{_COMPDB_FILE_SEPARATOR}{target}'
- f'{_COMPDB_FILE_EXTENSION}'
- )
+CppCompilationDatabaseFileHashes = Dict[Path, str]
+CppCompilationDatabaseFileTargets = Dict[Path, List[CppIdeFeaturesTarget]]
- return path
+@dataclass
+class CppIdeFeaturesData:
+ """State data about C++ code analysis features."""
-def compdb_generate_cache_path(target: str = '') -> Path:
- """Generate a compilation database cache directory path."""
+ targets: Dict[str, CppIdeFeaturesTarget] = field(default_factory=dict)
+ current_target: Optional[CppIdeFeaturesTarget] = None
+ compdb_hashes: CppCompilationDatabaseFileHashes = field(
+ default_factory=dict
+ )
+ compdb_targets: CppCompilationDatabaseFileTargets = field(
+ default_factory=dict
+ )
- path = Path(f'{_COMPDB_CACHE_DIR_PREFIX}')
+ def serialized(self) -> Dict[str, Any]:
+ return {
+ 'current_target': self.current_target.serialized()
+ if self.current_target is not None
+ else None,
+ 'targets': {
+ name: target_data.serialized()
+ for name, target_data in self.targets.items()
+ },
+ 'compdb_hashes': {
+ str(path): hash_str
+ for path, hash_str in self.compdb_hashes.items()
+ },
+ 'compdb_targets': {
+ str(path): [
+ target_data.serialized() for target_data in target_data_list
+ ]
+ for path, target_data_list in self.compdb_targets.items()
+ },
+ }
- if target:
- path = path.with_name(
- f'{_COMPDB_CACHE_DIR_PREFIX}'
- f'{_COMPDB_CACHE_DIR_SEPARATOR}{target}'
+ @classmethod
+ def deserialize(cls, **data) -> 'CppIdeFeaturesData':
+ return cls(
+ current_target=CppIdeFeaturesTarget.deserialize(
+ **data['current_target']
+ )
+ if data['current_target'] is not None
+ else None,
+ targets={
+ name: CppIdeFeaturesTarget.deserialize(**target_data)
+ for name, target_data in data['targets'].items()
+ },
+ compdb_hashes={
+ Path(path_str): hash_str
+ for path_str, hash_str in data['compdb_hashes'].items()
+ },
+ compdb_targets={
+ Path(path_str): [
+ CppIdeFeaturesTarget.deserialize(**target_data)
+ for target_data in target_data_list
+ ]
+ for path_str, target_data_list in data['compdb_targets'].items()
+ },
)
- return path
+class CppIdeFeaturesState:
+ """Container for IDE features state data."""
-def compdb_target_from_path(filename: Path) -> Optional[str]:
- """Get a target name from a compilation database path."""
+ def __init__(self, pw_ide_settings: PigweedIdeSettings) -> None:
+ self.settings = pw_ide_settings
- # The length of the common compilation database file name prefix
- prefix_length = len(_COMPDB_FILE_PREFIX) + len(_COMPDB_FILE_SEPARATOR)
+ def __len__(self) -> int:
+ return len(self.targets)
- if len(filename.stem) <= prefix_length:
- # This will return None for the symlink filename, and any filename that
- # is too short to be a compilation database.
- return None
+ def __getitem__(self, index: str) -> CppIdeFeaturesTarget:
+ return self.targets[index]
- if filename.stem[:prefix_length] != (
- _COMPDB_FILE_PREFIX + _COMPDB_FILE_SEPARATOR
- ):
- # This will return None for any files that don't have the common prefix.
- return None
+ def __iter__(self) -> Generator[CppIdeFeaturesTarget, None, None]:
+ return (target for target in self.targets.values())
- return filename.stem[prefix_length:]
+ @property
+ def stable_target_link(self) -> Path:
+ return self.settings.working_dir / STABLE_CLANGD_DIR_NAME
+ @contextmanager
+ def _file(self) -> Generator[CppIdeFeaturesData, None, None]:
+ """A simple key-value store for state data."""
+ file_path = self.settings.working_dir / _CPP_IDE_FEATURES_DATA_FILE
-def _none_to_empty_str(value: Optional[str]) -> str:
- return value if value is not None else ''
+ try:
+ with open(file_path) as file:
+ data = CppIdeFeaturesData.deserialize(**json.load(file))
+ except (FileNotFoundError, json.decoder.JSONDecodeError):
+ data = CppIdeFeaturesData()
+ yield data
-def _none_if_not_exists(path: Path) -> Optional[Path]:
- return path if path.exists() else None
+ with open(file_path, 'w') as file:
+ json.dump(data.serialized(), file, indent=2)
+ @property
+ def targets(self) -> Dict[str, CppIdeFeaturesTarget]:
+ with self._file() as state:
+ return state.targets
-def compdb_cache_path_if_exists(
- working_dir: Path, target: Optional[str]
-) -> Optional[Path]:
- return _none_if_not_exists(
- working_dir / compdb_generate_cache_path(_none_to_empty_str(target))
- )
+ @targets.setter
+ def targets(self, new_targets: Dict[str, CppIdeFeaturesTarget]) -> None:
+ with self._file() as state:
+ state.targets = new_targets
+ @property
+ def current_target(self) -> Optional[CppIdeFeaturesTarget]:
+ with self._file() as state:
+ return state.current_target
-def target_is_enabled(
- target: Optional[str], settings: PigweedIdeSettings
-) -> bool:
- """Determine if a target is enabled.
+ @current_target.setter
+ def current_target(
+ self, new_current_target: Optional[Union[str, CppIdeFeaturesTarget]]
+ ) -> None:
+ with self._file() as state:
+ if new_current_target is None:
+ state.current_target = None
+ else:
+ if isinstance(new_current_target, CppIdeFeaturesTarget):
+ name = new_current_target.name
+ new_current_target_inst = new_current_target
+ else:
+ name = new_current_target
+
+ try:
+ new_current_target_inst = state.targets[name]
+ except KeyError:
+ raise InvalidTargetException
+
+ if not new_current_target_inst.compdb_file_path.exists():
+ raise MissingCompDbException
+
+ set_symlink(
+ new_current_target_inst.compdb_file_path.parent,
+ self.stable_target_link,
+ )
- By default, all targets are enabled. If specific targets are defined in a
- settings file, only those targets will be enabled.
- """
+ state.current_target = state.targets[name]
+
+ @property
+ def max_commands_target(self) -> Optional[CppIdeFeaturesTarget]:
+ with self._file() as state:
+ if len(state.targets) == 0:
+ return None
+
+ max_commands_target_name = sorted(
+ [
+ (name, target.num_commands)
+ for name, target in state.targets.items()
+ ],
+ key=lambda x: x[1],
+ reverse=True,
+ )[0][0]
+
+ return state.targets[max_commands_target_name]
+
+ @property
+ def compdb_hashes(self) -> CppCompilationDatabaseFileHashes:
+ with self._file() as state:
+ return state.compdb_hashes
- if target is None:
- return False
+ @compdb_hashes.setter
+ def compdb_hashes(
+ self, new_compdb_hashes: CppCompilationDatabaseFileHashes
+ ) -> None:
+ with self._file() as state:
+ state.compdb_hashes = new_compdb_hashes
- if len(settings.targets) == 0:
- return True
+ @property
+ def compdb_targets(self) -> CppCompilationDatabaseFileTargets:
+ with self._file() as state:
+ return state.compdb_targets
- return target in settings.targets
+ @compdb_targets.setter
+ def compdb_targets(
+ self, new_compdb_targets: CppCompilationDatabaseFileTargets
+ ) -> None:
+ with self._file() as state:
+ state.compdb_targets = new_compdb_targets
def path_to_executable(
@@ -236,16 +371,20 @@ def path_to_executable(
# We were give an empty string, not a path. Not a valid command.
if len(maybe_path.parts) == 0:
+ _LOG.debug("Invalid executable path. The path was an empty string.")
return None
- # Determine if the executable name matches supported drivers.
- is_supported_driver = False
+ # Determine if the executable name matches unsupported drivers.
+ is_supported_driver = True
- for supported_executable in _SUPPORTED_TOOLCHAIN_EXECUTABLES:
- if supported_executable in maybe_path.name:
- is_supported_driver = True
+ for unsupported_executable in _UNSUPPORTED_TOOLCHAIN_EXECUTABLES:
+ if unsupported_executable in maybe_path.name:
+ is_supported_driver = False
if not is_supported_driver:
+ _LOG.debug(
+ "Invalid executable path. This is not a supported driver: %s", exe
+ )
return None
# Now, ensure the executable has a path.
@@ -282,16 +421,40 @@ def path_to_executable(
return maybe_path
-def command_parts(command: str) -> Tuple[str, List[str]]:
- """Return the executable string and the rest of the command tokens."""
+def command_parts(command: str) -> Tuple[Optional[str], str, List[str]]:
+ """Return the executable string and the rest of the command tokens.
+
+ If the command contains a prefixed wrapper like `ccache`, it will be
+ extracted separately. So the return value contains:
+ (wrapper, compiler executable, all other tokens)
+ """
parts = command.split()
- head = parts[0] if len(parts) > 0 else ''
- tail = parts[1:] if len(parts) > 1 else []
- return head, tail
+ curr = ''
+ wrapper = None
+
+ try:
+ curr = parts.pop(0)
+ except IndexError:
+ return (None, curr, [])
+
+ if curr in _SUPPORTED_WRAPPER_EXECUTABLES:
+ wrapper = curr
+
+ while curr := parts.pop(0):
+ # This is very `ccache`-centric. It will work for other wrappers
+ # that use KEY=VALUE-style options or no options at all, but will
+ # not work for other cases.
+ if re.fullmatch(r'(.*)=(.*)', curr):
+ wrapper = f'{wrapper} {curr}'
+ else:
+ break
+
+ return (wrapper, curr, parts)
# This is a clumsy way to express optional keys, which is not directly
# supported in TypedDicts right now.
+# TODO(chadnorvell): Use `NotRequired` when we support Python 3.11.
class BaseCppCompileCommandDict(TypedDict):
file: str
directory: str
@@ -343,7 +506,7 @@ class CppCompileCommand:
self._file = file
self._directory = directory
- executable, tokens = command_parts(command)
+ _, executable, tokens = command_parts(command)
self._executable_path = Path(executable)
self._inferred_output: Optional[str] = None
@@ -454,7 +617,7 @@ class CppCompileCommand:
'Compile commands without \'command\' ' 'are not supported yet.'
)
- executable_str, tokens = command_parts(self.command)
+ wrapper, executable_str, tokens = command_parts(self.command)
executable_path = path_to_executable(
executable_str,
default_path=default_path,
@@ -462,13 +625,27 @@ class CppCompileCommand:
strict=strict,
)
- if executable_path is None or self.output is None:
+ if executable_path is None:
+ _LOG.debug(
+ "Compile command rejected due to bad executable path: %s",
+ self.command,
+ )
+ return None
+
+ if self.output is None:
+ _LOG.debug(
+ "Compile command rejected due to no output property: %s",
+ self.command,
+ )
return None
# TODO(chadnorvell): Some commands include the executable multiple
# times. It's not clear if that affects clangd.
new_command = f'{str(executable_path)} {" ".join(tokens)}'
+ if wrapper is not None:
+ new_command = f'{wrapper} {new_command}'
+
return self.__class__(
file=self.file,
directory=self.directory,
@@ -504,6 +681,27 @@ class CppCompileCommand:
return compile_command_dict
+def _path_nearest_parent(path1: Path, path2: Path) -> Path:
+ """Get the closest common parent of two paths."""
+ # This is the Python < 3.9 version of: if path2.is_relative_to(path1)
+ try:
+ path2.relative_to(path1)
+ return path1
+ except ValueError:
+ pass
+
+ if path1 == path2:
+ return path1
+
+ if len(path1.parts) > len(path2.parts):
+ return _path_nearest_parent(path1.parent, path2)
+
+ if len(path1.parts) < len(path2.parts):
+ return _path_nearest_parent(path1, path2.parent)
+
+ return _path_nearest_parent(path1.parent, path2.parent)
+
+
def _infer_target_pos(target_glob: str) -> List[int]:
"""Infer the position of the target in a compilation unit artifact path."""
tokens = Path(target_glob).parts
@@ -535,7 +733,17 @@ def infer_target(
# may be in the "directory" or the "output" of the compile command. So we
# need to construct the full path that combines both and use that to search
# for the target.
- subpath = output_path.relative_to(root)
+ try:
+ # The path used for target inference is the path relative to the root
+ # dir. If this artifact is a direct child of the root, this just
+ # truncates the root off of its path.
+ subpath = output_path.relative_to(root)
+ except ValueError:
+ # If the output path isn't a child path of the root dir, find the
+ # closest shared parent dir and use that as the root for truncation.
+ common_parent = _path_nearest_parent(root, output_path)
+ subpath = output_path.relative_to(common_parent)
+
return '_'.join([subpath.parts[pos] for pos in target_pos])
@@ -550,14 +758,28 @@ class CppCompilationDatabase:
See: https://clang.llvm.org/docs/JSONCompilationDatabase.html
"""
- def __init__(self, build_dir: Optional[Path] = None) -> None:
+ def __init__(
+ self,
+ root_dir: Optional[Path] = None,
+ file_path: Optional[Path] = None,
+ source_file_path: Optional[Path] = None,
+ target_inference: Optional[str] = None,
+ ) -> None:
self._db: List[CppCompileCommand] = []
+ self.file_path: Optional[Path] = file_path
+ self.source_file_path: Optional[Path] = source_file_path
+ self.source_file_hash: Optional[str] = None
+
+ if target_inference is None:
+ self.target_inference = PigweedIdeSettings().target_inference
+ else:
+ self.target_inference = target_inference
# Only compilation databases that are loaded will have this, and it
# contains the root directory of the build that the compilation
# database is based on. Processed compilation databases will not have
# a value here.
- self._build_dir = build_dir
+ self._root_dir = root_dir
def __len__(self) -> int:
return len(self._db)
@@ -568,6 +790,17 @@ class CppCompilationDatabase:
def __iter__(self) -> Generator[CppCompileCommand, None, None]:
return (compile_command for compile_command in self._db)
+ @property
+ def file_hash(self) -> str:
+ # If this compilation database did not originate from a file, return a
+ # hash that is almost certainly not going to match any other hash; these
+ # sources are not persistent, so they cannot be compared.
+ if self.file_path is None:
+ return '%032x' % random.getrandbits(160)
+
+ data = self.file_path.read_text().encode('utf-8')
+ return sha1(data).hexdigest()
+
def add(self, *commands: CppCompileCommand):
"""Add compile commands to the compilation database."""
self._db.extend(commands)
@@ -596,13 +829,17 @@ class CppCompilationDatabase:
def to_file(self, path: Path):
"""Write the compilation database to a JSON file."""
+ path.parent.mkdir(parents=True, exist_ok=True)
with open(path, 'w') as file:
json.dump(self.as_dicts(), file, indent=2, sort_keys=True)
@classmethod
def load(
- cls, compdb_to_load: LoadableToCppCompilationDatabase, build_dir: Path
+ cls,
+ compdb_to_load: LoadableToCppCompilationDatabase,
+ root_dir: Path,
+ target_inference: Optional[str] = None,
) -> 'CppCompilationDatabase':
"""Load a compilation database.
@@ -610,6 +847,7 @@ class CppCompilationDatabase:
Python data structure that matches the format (list of dicts).
"""
db_as_dicts: List[Dict[str, Any]]
+ file_path = None
if isinstance(compdb_to_load, list):
# The provided data is already in the format we want it to be in,
@@ -620,11 +858,13 @@ class CppCompilationDatabase:
if isinstance(compdb_to_load, Path):
# The provided data is a path to a file, presumably JSON.
try:
+ file_path = compdb_to_load
compdb_data = compdb_to_load.read_text()
except FileNotFoundError:
raise MissingCompDbException()
elif isinstance(compdb_to_load, TextIOBase):
# The provided data is a file handle, presumably JSON.
+ file_path = Path(compdb_to_load.name) # type: ignore
compdb_data = compdb_to_load.read()
elif isinstance(compdb_to_load, str):
# The provided data is a a string, presumably JSON.
@@ -632,7 +872,11 @@ class CppCompilationDatabase:
db_as_dicts = json.loads(compdb_data)
- compdb = cls(build_dir=build_dir)
+ compdb = cls(
+ root_dir=root_dir,
+ file_path=file_path,
+ target_inference=target_inference,
+ )
try:
compdb.add(
@@ -660,14 +904,24 @@ class CppCompilationDatabase:
default_path: Optional[Path] = None,
path_globs: Optional[List[str]] = None,
strict: bool = False,
- ) -> 'CppCompilationDatabasesMap':
+ always_output_new: bool = False,
+ ) -> Optional['CppCompilationDatabasesMap']:
"""Process a ``clangd`` compilation database file.
Given a clang compilation database that may have commands for multiple
valid or invalid targets/toolchains, keep only the valid compile
commands and store them in target-specific compilation databases.
+
+ If this finds that the processed file is functionally identical to the
+ input file (meaning that the input file did not require processing to
+ be used successfully with ``clangd``), then it will return ``None``,
+ indicating that the original file should be used. This behavior can be
+ overridden by setting ``always_output_new``, which will ensure that a
+ new compilation database is always written to the working directory and
+ original compilation databases outside the working directory are never
+ made available for code intelligence.
"""
- if self._build_dir is None:
+ if self._root_dir is None:
raise ValueError(
'Can only process a compilation database that '
'contains a root build directory, usually '
@@ -678,6 +932,8 @@ class CppCompilationDatabase:
clean_compdbs = CppCompilationDatabasesMap(settings)
+ # Do processing, segregate processed commands into separate databases
+ # for each target.
for compile_command in self:
processed_command = compile_command.process(
default_path=default_path, path_globs=path_globs, strict=strict
@@ -688,16 +944,39 @@ class CppCompilationDatabase:
and processed_command.output_path is not None
):
target = infer_target(
- settings.target_inference,
- self._build_dir,
+ self.target_inference,
+ self._root_dir,
processed_command.output_path,
)
- if target_is_enabled(target, settings):
- # This invariant is satisfied by target_is_enabled
- target = cast(str, target)
- processed_command.target = target
- clean_compdbs[target].add(processed_command)
+ target = cast(str, target)
+ processed_command.target = target
+ clean_compdbs[target].add(processed_command)
+
+ if clean_compdbs[target].source_file_path is None:
+ clean_compdbs[target].source_file_path = self.file_path
+ clean_compdbs[target].source_file_hash = self.file_hash
+
+ # TODO(chadnorvell): Handle len(clean_compdbs) == 0
+
+ # Determine if the processed database is functionally identical to the
+ # original, unless configured to always output the new databases.
+ # The criteria for "functionally identical" are:
+ #
+ # - The original file only contained commands for a single target
+ # - The number of compile commands in the processed database is equal to
+ # that of the original database.
+ #
+ # This is a little bit crude. For example, it doesn't account for the
+ # (rare) edge case of multiple databases having commands for the same
+ # target. However, if you know that you have that kind of situation, you
+ # should use `always_output_new` and not rely on this.
+ if (
+ not always_output_new
+ and len(clean_compdbs) == 1
+ and len(clean_compdbs[0]) == len(self)
+ ):
+ return None
return clean_compdbs
@@ -707,19 +986,36 @@ class CppCompilationDatabasesMap:
def __init__(self, settings: PigweedIdeSettings):
self.settings = settings
- self._dbs: Dict[str, CppCompilationDatabase] = defaultdict(
- CppCompilationDatabase
- )
+ self._dbs: Dict[str, CppCompilationDatabase] = dict()
def __len__(self) -> int:
return len(self._dbs)
- def __getitem__(self, key: str) -> CppCompilationDatabase:
+ def _default(self, key: Union[str, int]):
+ # This is like `defaultdict` except that we can use the provided key
+ # (i.e. the target name) in the constructor.
+ if isinstance(key, str) and key not in self._dbs:
+ file_path = self.settings.working_dir / key / COMPDB_FILE_NAME
+ self._dbs[key] = CppCompilationDatabase(file_path=file_path)
+
+ def __getitem__(self, key: Union[str, int]) -> CppCompilationDatabase:
+ self._default(key)
+
+ # Support list-based indexing...
+ if isinstance(key, int):
+ return list(self._dbs.values())[key]
+
+ # ... and key-based indexing.
return self._dbs[key]
def __setitem__(self, key: str, item: CppCompilationDatabase) -> None:
+ self._default(key)
self._dbs[key] = item
+ def __iter__(self) -> Iterator[str]:
+ for target, _ in self.items():
+ yield target
+
@property
def targets(self) -> List[str]:
return list(self._dbs.keys())
@@ -729,36 +1025,65 @@ class CppCompilationDatabasesMap:
) -> Generator[Tuple[str, CppCompilationDatabase], None, None]:
return ((key, value) for (key, value) in self._dbs.items())
- def write(self) -> None:
- """Write compilation databases to target-specific JSON files."""
- # This also writes out a file with the name of the target that has the
- # largest number of commands, i.e., the target with the broadest
- # compilation unit coverage. We can use this as a default target of
- # last resort.
- max_commands = 0
- max_commands_target = None
-
- for target, compdb in self.items():
- if max_commands_target is None or len(compdb) > max_commands:
- max_commands_target = target
- max_commands = len(compdb)
-
- compdb.to_file(
- self.settings.working_dir / compdb_generate_file_path(target)
- )
-
- max_commands_target_path = (
- self.settings.working_dir / MAX_COMMANDS_TARGET_FILENAME
+ def _sort_by_commands(self) -> List[str]:
+ """Sort targets by the number of compile commands they have."""
+ enumerated_targets = sorted(
+ [(len(db), target) for target, db in self._dbs.items()],
+ key=lambda x: x[0],
+ reverse=True,
)
- if max_commands_target is not None:
- if max_commands_target_path.exists():
- max_commands_target_path.unlink()
+ return [target for (_, target) in enumerated_targets]
+
+ def _sort_with_target_priority(self, target: str) -> List[str]:
+ """Sorted targets, but with the provided target first."""
+ sorted_targets = self._sort_by_commands()
+ # This will raise a ValueError if the target is not in the list, but
+ # we have ensured that that will never happen by the time we get here.
+ sorted_targets.remove(target)
+ return [target, *sorted_targets]
- with open(
- max_commands_target_path, 'x'
- ) as max_commands_target_file:
- max_commands_target_file.write(max_commands_target)
+ def _targets_to_write(self, target: str) -> List[str]:
+ """Return the list of targets whose comp. commands should be written.
+
+ Under most conditions, this will return a list with just the provided
+ target; essentially it's a no-op. But if ``cascade_targets`` is
+ enabled, this returns a list of all targets with the provided target
+ at the head of the list.
+ """
+ if not self.settings.cascade_targets:
+ return [target]
+
+ return self._sort_with_target_priority(target)
+
+ def _compdb_to_write(self, target: str) -> CppCompilationDatabase:
+ """The compilation database to write to file for this target.
+
+ Under most conditions, this will return the compilation database
+ associated with the provided target. But if ``cascade_targets`` is
+ enabled, this returns a compilation database with commands from all
+ targets, ordered per ``_sort_with_target_priority``.
+ """
+ targets = self._targets_to_write(target)
+ compdb = CppCompilationDatabase()
+
+ for iter_target in targets:
+ compdb.add(*self[iter_target])
+
+ return compdb
+
+ def test_write(self) -> None:
+ """Test writing to file.
+
+ This will raise an exception if the file is not JSON-serializable."""
+ for _, compdb in self.items():
+ compdb.to_json()
+
+ def write(self) -> None:
+ """Write compilation databases to target-specific JSON files."""
+ for target in self:
+ path = self.settings.working_dir / target / COMPDB_FILE_NAME
+ self._compdb_to_write(target).to_file(path)
@classmethod
def merge(
@@ -789,7 +1114,7 @@ class CppCompilationDatabasesMap:
"""
if len(db_sets) == 0:
raise ValueError(
- 'At least one set of compilation databases is ' 'required.'
+ 'At least one set of compilation databases is required.'
)
# Shortcut for the most common case.
@@ -805,248 +1130,28 @@ class CppCompilationDatabasesMap:
return merged
-@dataclass(frozen=True)
-class CppIdeFeaturesTarget:
- """Data pertaining to a C++ code analysis target."""
-
- name: str
- compdb_file_path: Path
- compdb_cache_path: Optional[Path]
- is_enabled: bool
-
-
-class CppIdeFeaturesState:
- """The state of the C++ analysis targets in the working directory.
-
- Targets can be:
-
- - **Available**: A compilation database is present for this target.
- - **Enabled**: Any targets are enabled by default, but a subset can be
- enabled instead in the pw_ide settings. Enabled targets need
- not be available if they haven't had a compilation database
- created through processing yet.
- - **Valid**: Is both available and enabled.
- - **Current**: The one currently activated target that is exposed to clangd.
- """
-
- def __init__(self, settings: PigweedIdeSettings) -> None:
- self.settings = settings
-
- # We filter out Nones below, so we can assume its a str
- target: Callable[[Path], str] = lambda path: cast(
- str, compdb_target_from_path(path)
- )
-
- # Contains every compilation database that's present in the working dir.
- # This dict comprehension looks monstrous, but it just finds targets and
- # associates the target names with their CppIdeFeaturesTarget objects.
- self.targets: Dict[str, CppIdeFeaturesTarget] = {
- target(file_path): CppIdeFeaturesTarget(
- name=target(file_path),
- compdb_file_path=file_path,
- compdb_cache_path=compdb_cache_path_if_exists(
- settings.working_dir, compdb_target_from_path(file_path)
- ),
- is_enabled=target_is_enabled(target(file_path), settings),
- )
- for file_path in settings.working_dir.iterdir()
- if file_path.match(
- f'{_COMPDB_FILE_PREFIX}*{_COMPDB_FILE_EXTENSION}'
- )
- # This filters out the symlink
- and compdb_target_from_path(file_path) is not None
- }
-
- # Contains the currently selected target.
- self._current_target: Optional[CppIdeFeaturesTarget] = None
-
- # This is diagnostic data; it tells us what the current target should
- # be, even if the state of the working directory is corrupted and the
- # compilation database for the target isn't actually present. Anything
- # that requires a compilation database to be definitely present should
- # use `current_target` instead of these values.
- self.current_target_name: Optional[str] = None
- self.current_target_file_path: Optional[Path] = None
- self.current_target_exists: Optional[bool] = None
-
- # Contains the name of the target that has the most compile commands,
- # i.e., the target with the most file coverage in the project.
- self._max_commands_target: Optional[str] = None
-
- try:
- src_file = Path(
- os.readlink(
- (settings.working_dir / compdb_generate_file_path())
- )
- )
-
- self.current_target_file_path = src_file
- self.current_target_name = compdb_target_from_path(src_file)
-
- if not self.current_target_file_path.exists():
- self.current_target_exists = False
-
- else:
- self.current_target_exists = True
- self._current_target = CppIdeFeaturesTarget(
- name=target(src_file),
- compdb_file_path=src_file,
- compdb_cache_path=compdb_cache_path_if_exists(
- settings.working_dir, target(src_file)
- ),
- is_enabled=target_is_enabled(target(src_file), settings),
- )
- except (FileNotFoundError, OSError):
- # If the symlink doesn't exist, there is no current target.
- pass
-
- try:
- with open(
- settings.working_dir / MAX_COMMANDS_TARGET_FILENAME
- ) as max_commands_target_file:
- self._max_commands_target = max_commands_target_file.readline()
- except FileNotFoundError:
- # If the file doesn't exist, a compilation database probably
- # hasn't been processed yet.
- pass
-
- def __len__(self) -> int:
- return len(self.targets)
-
- def __getitem__(self, index: str) -> CppIdeFeaturesTarget:
- return self.targets[index]
-
- def __iter__(self) -> Generator[CppIdeFeaturesTarget, None, None]:
- return (target for target in self.targets.values())
-
- @property
- def current_target(self) -> Optional[str]:
- """The name of current target used for code analysis.
-
- The presence of a symlink with the expected filename pointing to a
- compilation database matching the expected filename format is the source
- of truth on what the current target is.
- """
- return (
- self._current_target.name
- if self._current_target is not None
- else None
- )
-
- @current_target.setter
- def current_target(self, target: Optional[str]) -> None:
- settings = self.settings
-
- if not self.is_valid_target(target):
- raise InvalidTargetException()
-
- # The check above rules out None.
- target = cast(str, target)
-
- compdb_symlink_path = settings.working_dir / compdb_generate_file_path()
-
- compdb_target_path = settings.working_dir / compdb_generate_file_path(
- target
- )
-
- if not compdb_target_path.exists():
- raise MissingCompDbException()
-
- set_symlink(compdb_target_path, compdb_symlink_path)
-
- cache_symlink_path = settings.working_dir / compdb_generate_cache_path()
-
- cache_target_path = settings.working_dir / compdb_generate_cache_path(
- target
- )
-
- if not cache_target_path.exists():
- os.mkdir(cache_target_path)
-
- set_symlink(cache_target_path, cache_symlink_path)
+class ClangdSettings:
+ """Makes system-specific settings for running ``clangd`` with Pigweed."""
- @property
- def max_commands_target(self) -> Optional[str]:
- """The target with the most compile commands.
+ def __init__(self, settings: PigweedIdeSettings):
+ state = CppIdeFeaturesState(settings)
- The return value is the name of the target with the largest number of
- compile commands (i.e., the largest coverage across the files in the
- project). This can be a useful "default target of last resort".
- """
- return self._max_commands_target
+ clangd_bin = "clangd"
- @property
- def available_targets(self) -> List[str]:
- return list(self.targets.keys())
+ if sys.platform.lower() == "windows":
+ clangd_bin += ".exe"
- @property
- def enabled_available_targets(self) -> Generator[str, None, None]:
- return (
- name for name, target in self.targets.items() if target.is_enabled
+ self.clangd_path: Path = (
+ Path(env.PW_PIGWEED_CIPD_INSTALL_DIR) / 'bin' / clangd_bin
)
- def is_valid_target(self, target: Optional[str]) -> bool:
- if target is None or (data := self.targets.get(target, None)) is None:
- return False
-
- return data.is_enabled
-
+ compile_commands_dir = env.PW_PROJECT_ROOT
-def aggregate_compilation_database_targets(
- compdb_file: LoadableToCppCompilationDatabase,
- settings: PigweedIdeSettings,
- build_dir: Path,
- *,
- default_path: Optional[Path] = None,
- path_globs: Optional[List[str]] = None,
-) -> List[str]:
- """Return all valid unique targets from a ``clang`` compilation database."""
- compdbs_map = CppCompilationDatabase.load(compdb_file, build_dir).process(
- settings, default_path=default_path, path_globs=path_globs
- )
-
- return compdbs_map.targets
-
-
-def delete_compilation_databases(settings: PigweedIdeSettings) -> None:
- """Delete all compilation databases in the working directory.
-
- This leaves cache directories in place.
- """
- if settings.working_dir.exists():
- for path in settings.working_dir.iterdir():
- if path.name.startswith(_COMPDB_FILE_PREFIX):
- try:
- path.unlink()
- except FileNotFoundError:
- pass
-
-
-def delete_compilation_database_caches(settings: PigweedIdeSettings) -> None:
- """Delete all compilation database caches in the working directory.
-
- This leaves all compilation databases in place.
- """
- if settings.working_dir.exists():
- for path in settings.working_dir.iterdir():
- if path.name.startswith(_COMPDB_CACHE_DIR_PREFIX):
- try:
- path.unlink()
- except FileNotFoundError:
- pass
-
-
-class ClangdSettings:
- """Makes system-specific settings for running ``clangd`` with Pigweed."""
-
- def __init__(self, settings: PigweedIdeSettings):
- self.compile_commands_dir: Path = PigweedIdeSettings().working_dir
- self.clangd_path: Path = (
- Path(PW_PIGWEED_CIPD_INSTALL_DIR) / 'bin' / 'clangd'
- )
+ if state.current_target is not None:
+ compile_commands_dir = str(state.stable_target_link)
self.arguments: List[str] = [
- f'--compile-commands-dir={self.compile_commands_dir}',
+ f'--compile-commands-dir={compile_commands_dir}',
f'--query-driver={settings.clangd_query_driver_str()}',
'--background-index',
'--clang-tidy',