1# Copyright 2022 The Pigweed Authors 2# 3# Licensed under the Apache License, Version 2.0 (the "License"); you may not 4# use this file except in compliance with the License. You may obtain a copy of 5# the License at 6# 7# https://www.apache.org/licenses/LICENSE-2.0 8# 9# Unless required by applicable law or agreed to in writing, software 10# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12# License for the specific language governing permissions and limitations under 13# the License. 14"""Configure C/C++ IDE support for Pigweed projects. 15 16We support C/C++ code analysis via ``clangd``, or other language servers that 17are compatible with the ``clangd`` compilation database format. 18 19While clangd can work well out of the box for typical C++ codebases, some work 20is required to coax it to work for embedded projects. In particular, Pigweed 21projects use multiple toolchains within a distinct environment, and almost 22always define multiple targets. This means compilation units are likely have 23multiple compile commands and the toolchain executables are unlikely to be in 24your path. ``clangd`` is not equipped to deal with this out of the box. We 25handle this by: 26 27- Processing the compilation database produced by the build system into 28 multiple internally-consistent compilation databases, one for each target 29 (where a "target" is a particular build for a particular system using a 30 particular toolchain). 31 32- Creating unambiguous paths to toolchain drivers to ensure the right toolchain 33 is used and that clangd knows where to find that toolchain's system headers. 34 35- Providing tools for working with several compilation databases that are 36 spiritually similar to tools like ``pyenv``, ``rbenv``, etc. 37 38In short, we take the probably-broken compilation database that the build system 39generates, process it into several not-broken compilation databases in the 40``pw_ide`` working directory, and provide a stable symlink that points to the 41selected active target's compliation database. If ``clangd`` is configured to 42point at the symlink and is set up with the right paths, you'll get code 43intelligence. 44""" 45 46from collections import defaultdict 47from dataclasses import dataclass 48import glob 49from io import TextIOBase 50import json 51import os 52from pathlib import Path 53import platform 54from typing import ( 55 Any, 56 cast, 57 Callable, 58 Dict, 59 Generator, 60 List, 61 Optional, 62 Tuple, 63 TypedDict, 64 Union, 65) 66 67from pw_ide.exceptions import ( 68 BadCompDbException, 69 InvalidTargetException, 70 MissingCompDbException, 71 UnresolvablePathException, 72) 73 74from pw_ide.settings import PigweedIdeSettings, PW_PIGWEED_CIPD_INSTALL_DIR 75from pw_ide.symlinks import set_symlink 76 77_COMPDB_FILE_PREFIX = 'compile_commands' 78_COMPDB_FILE_SEPARATOR = '_' 79_COMPDB_FILE_EXTENSION = '.json' 80 81_COMPDB_CACHE_DIR_PREFIX = '.cache' 82_COMPDB_CACHE_DIR_SEPARATOR = '_' 83 84COMPDB_FILE_GLOB = f'{_COMPDB_FILE_PREFIX}*{_COMPDB_FILE_EXTENSION}' 85COMPDB_CACHE_DIR_GLOB = f'{_COMPDB_CACHE_DIR_PREFIX}*' 86 87MAX_COMMANDS_TARGET_FILENAME = 'max_commands_target' 88 89_SUPPORTED_TOOLCHAIN_EXECUTABLES = ('clang', 'gcc', 'g++') 90 91 92def compdb_generate_file_path(target: str = '') -> Path: 93 """Generate a compilation database file path.""" 94 95 path = Path(f'{_COMPDB_FILE_PREFIX}{_COMPDB_FILE_EXTENSION}') 96 97 if target: 98 path = path.with_name( 99 f'{_COMPDB_FILE_PREFIX}' 100 f'{_COMPDB_FILE_SEPARATOR}{target}' 101 f'{_COMPDB_FILE_EXTENSION}' 102 ) 103 104 return path 105 106 107def compdb_generate_cache_path(target: str = '') -> Path: 108 """Generate a compilation database cache directory path.""" 109 110 path = Path(f'{_COMPDB_CACHE_DIR_PREFIX}') 111 112 if target: 113 path = path.with_name( 114 f'{_COMPDB_CACHE_DIR_PREFIX}' 115 f'{_COMPDB_CACHE_DIR_SEPARATOR}{target}' 116 ) 117 118 return path 119 120 121def compdb_target_from_path(filename: Path) -> Optional[str]: 122 """Get a target name from a compilation database path.""" 123 124 # The length of the common compilation database file name prefix 125 prefix_length = len(_COMPDB_FILE_PREFIX) + len(_COMPDB_FILE_SEPARATOR) 126 127 if len(filename.stem) <= prefix_length: 128 # This will return None for the symlink filename, and any filename that 129 # is too short to be a compilation database. 130 return None 131 132 if filename.stem[:prefix_length] != ( 133 _COMPDB_FILE_PREFIX + _COMPDB_FILE_SEPARATOR 134 ): 135 # This will return None for any files that don't have the common prefix. 136 return None 137 138 return filename.stem[prefix_length:] 139 140 141def _none_to_empty_str(value: Optional[str]) -> str: 142 return value if value is not None else '' 143 144 145def _none_if_not_exists(path: Path) -> Optional[Path]: 146 return path if path.exists() else None 147 148 149def compdb_cache_path_if_exists( 150 working_dir: Path, target: Optional[str] 151) -> Optional[Path]: 152 return _none_if_not_exists( 153 working_dir / compdb_generate_cache_path(_none_to_empty_str(target)) 154 ) 155 156 157def target_is_enabled( 158 target: Optional[str], settings: PigweedIdeSettings 159) -> bool: 160 """Determine if a target is enabled. 161 162 By default, all targets are enabled. If specific targets are defined in a 163 settings file, only those targets will be enabled. 164 """ 165 166 if target is None: 167 return False 168 169 if len(settings.targets) == 0: 170 return True 171 172 return target in settings.targets 173 174 175def path_to_executable( 176 exe: str, 177 *, 178 default_path: Optional[Path] = None, 179 path_globs: Optional[List[str]] = None, 180 strict: bool = False, 181) -> Optional[Path]: 182 """Return the path to a compiler executable. 183 184 In a ``clang`` compile command, the executable may or may not include a 185 path. For example: 186 187 .. code-block:: none 188 189 /usr/bin/clang <- includes a path 190 ../path/to/my/clang <- includes a path 191 clang <- doesn't include a path 192 193 If it includes a path, then ``clangd`` will have no problem finding the 194 driver, so we can simply return the path. If the executable *doesn't* 195 include a path, then ``clangd`` will search ``$PATH``, and may not find the 196 intended driver unless you actually want the default system toolchain or 197 Pigweed paths have been added to ``$PATH``. So this function provides two 198 options for resolving those ambiguous paths: 199 200 - Provide a default path, and all executables without a path will be 201 re-written with a path within the default path. 202 - Provide the a set of globs that will be used to search for the executable, 203 which will normally be the query driver globs used with clangd. 204 205 By default, if neither of these options is chosen, or if the executable 206 cannot be found within the provided globs, the pathless executable that was 207 provided will be returned, and clangd will resort to searching $PATH. If you 208 instead pass ``strict=True``, this will raise an exception if an unambiguous 209 path cannot be constructed. 210 211 This function only tries to ensure that all executables have a path to 212 eliminate ambiguity. A couple of important things to keep in mind: 213 214 - This doesn't guarantee that the path exists or an executable actually 215 exists at the path. It only ensures that some path is provided to an 216 executable. 217 - An executable being present at the indicated path doesn't guarantee that 218 it will work flawlessly for clangd code analysis. The clangd 219 ``--query-driver`` argument needs to include a path to this executable in 220 order for its bundled headers to be resolved correctly. 221 222 This function also filters out invalid or unsupported drivers. For example, 223 build systems will sometimes naively include build steps for Python or other 224 languages in the compilation database, which are not usable with clangd. 225 As a result, this function has four possible end states: 226 227 - It returns a path with an executable that can be used as a ``clangd`` 228 driver. 229 - It returns ``None``, meaning the compile command was invalid. 230 - It returns the same string that was provided (as a ``Path``), if a path 231 couldn't be resolved and ``strict=False``. 232 - It raises an ``UnresolvablePathException`` if the executable cannot be 233 placed in an unambiguous path and ``strict=True``. 234 """ 235 maybe_path = Path(exe) 236 237 # We were give an empty string, not a path. Not a valid command. 238 if len(maybe_path.parts) == 0: 239 return None 240 241 # Determine if the executable name matches supported drivers. 242 is_supported_driver = False 243 244 for supported_executable in _SUPPORTED_TOOLCHAIN_EXECUTABLES: 245 if supported_executable in maybe_path.name: 246 is_supported_driver = True 247 248 if not is_supported_driver: 249 return None 250 251 # Now, ensure the executable has a path. 252 253 # This is either a relative or absolute path -- return it. 254 if len(maybe_path.parts) > 1: 255 return maybe_path 256 257 # If we got here, there's only one "part", so we assume it's an executable 258 # without a path. This logic doesn't work with a path like `./exe` since 259 # that also yields only one part. So currently this breaks if you actually 260 # have your compiler executable in your root build directory, which is 261 # (hopefully) very rare. 262 263 # If we got a default path, use it. 264 if default_path is not None: 265 return default_path / maybe_path 266 267 # Otherwise, try to find the executable within the query driver globs. 268 # Note that unlike the previous paths, this path will only succeed if an 269 # executable actually exists somewhere in the query driver globs. 270 if path_globs is not None: 271 for path_glob in path_globs: 272 for path_str in glob.iglob(path_glob): 273 path = Path(path_str) 274 if path.name == maybe_path.name: 275 return path.absolute() 276 277 if strict: 278 raise UnresolvablePathException( 279 f'Cannot place {exe} in an unambiguous path!' 280 ) 281 282 return maybe_path 283 284 285def command_parts(command: str) -> Tuple[str, List[str]]: 286 """Return the executable string and the rest of the command tokens.""" 287 parts = command.split() 288 head = parts[0] if len(parts) > 0 else '' 289 tail = parts[1:] if len(parts) > 1 else [] 290 return head, tail 291 292 293# This is a clumsy way to express optional keys, which is not directly 294# supported in TypedDicts right now. 295class BaseCppCompileCommandDict(TypedDict): 296 file: str 297 directory: str 298 output: Optional[str] 299 300 301class CppCompileCommandDictWithCommand(BaseCppCompileCommandDict): 302 command: str 303 304 305class CppCompileCommandDictWithArguments(BaseCppCompileCommandDict): 306 arguments: List[str] 307 308 309CppCompileCommandDict = Union[ 310 CppCompileCommandDictWithCommand, CppCompileCommandDictWithArguments 311] 312 313 314class CppCompileCommand: 315 """A representation of a clang compilation database compile command. 316 317 See: https://clang.llvm.org/docs/JSONCompilationDatabase.html 318 """ 319 320 def __init__( 321 self, 322 file: str, 323 directory: str, 324 command: Optional[str] = None, 325 arguments: Optional[List[str]] = None, 326 output: Optional[str] = None, 327 ) -> None: 328 # Per the spec, either one of these two must be present. clangd seems 329 # to prefer "arguments" when both are present. 330 if command is None and arguments is None: 331 raise TypeError( 332 'A compile command requires either \'command\' ' 333 'or \'arguments\'.' 334 ) 335 336 if command is None: 337 raise TypeError( 338 'Compile commands without \'command\' ' 'are not supported yet.' 339 ) 340 341 self._command = command 342 self._arguments = arguments 343 self._file = file 344 self._directory = directory 345 346 executable, tokens = command_parts(command) 347 self._executable_path = Path(executable) 348 self._inferred_output: Optional[str] = None 349 350 try: 351 # Find the output argument and grab its value. 352 output_flag_idx = tokens.index('-o') 353 self._inferred_output = tokens[output_flag_idx + 1] 354 except ValueError: 355 # No -o found, probably not a C/C++ compile command. 356 self._inferred_output = None 357 except IndexError: 358 # It has an -o but no argument after it. 359 raise TypeError( 360 'Failed to load compile command with no output argument!' 361 ) 362 363 self._provided_output = output 364 self.target: Optional[str] = None 365 366 @property 367 def file(self) -> str: 368 return self._file 369 370 @property 371 def directory(self) -> str: 372 return self._directory 373 374 @property 375 def command(self) -> Optional[str]: 376 return self._command 377 378 @property 379 def arguments(self) -> Optional[List[str]]: 380 return self._arguments 381 382 @property 383 def output(self) -> Optional[str]: 384 # We're ignoring provided output values for now. 385 return self._inferred_output 386 387 @property 388 def output_path(self) -> Optional[Path]: 389 if self.output is None: 390 return None 391 392 return Path(self.directory) / Path(self.output) 393 394 @property 395 def executable_path(self) -> Path: 396 return self._executable_path 397 398 @property 399 def executable_name(self) -> str: 400 return self.executable_path.name 401 402 @classmethod 403 def from_dict( 404 cls, compile_command_dict: Dict[str, Any] 405 ) -> 'CppCompileCommand': 406 return cls( 407 # We want to let possible Nones through to raise at runtime. 408 file=cast(str, compile_command_dict.get('file')), 409 directory=cast(str, compile_command_dict.get('directory')), 410 command=compile_command_dict.get('command'), 411 arguments=compile_command_dict.get('arguments'), 412 output=compile_command_dict.get('output'), 413 ) 414 415 @classmethod 416 def try_from_dict( 417 cls, compile_command_dict: Dict[str, Any] 418 ) -> Optional['CppCompileCommand']: 419 try: 420 return cls.from_dict(compile_command_dict) 421 except TypeError: 422 return None 423 424 def process( 425 self, 426 *, 427 default_path: Optional[Path] = None, 428 path_globs: Optional[List[str]] = None, 429 strict: bool = False, 430 ) -> Optional['CppCompileCommand']: 431 """Process a compile command. 432 433 At minimum, a compile command from a clang compilation database needs to 434 be correlated with its target, and this method returns the target name 435 with the compile command. But it also cleans up other things we need for 436 reliable code intelligence: 437 438 - Some targets may not be valid C/C++ compile commands. For example, 439 some build systems will naively include build steps for Python or for 440 linting commands. We want to filter those out. 441 442 - Some compile commands don't provide a path to the compiler executable 443 (referred to by clang as the "driver"). In that case, clangd is very 444 unlikely to find the executable unless it happens to be in ``$PATH``. 445 The ``--query-driver`` argument to ``clangd`` allowlists 446 executables/drivers for use its use, but clangd doesn't use it to 447 resolve ambiguous paths. We bridge that gap here. Any executable 448 without a path will be either placed in the provided default path or 449 searched for in the query driver globs and be replaced with a path to 450 the executable. 451 """ 452 if self.command is None: 453 raise NotImplementedError( 454 'Compile commands without \'command\' ' 'are not supported yet.' 455 ) 456 457 executable_str, tokens = command_parts(self.command) 458 executable_path = path_to_executable( 459 executable_str, 460 default_path=default_path, 461 path_globs=path_globs, 462 strict=strict, 463 ) 464 465 if executable_path is None or self.output is None: 466 return None 467 468 # TODO(chadnorvell): Some commands include the executable multiple 469 # times. It's not clear if that affects clangd. 470 new_command = f'{str(executable_path)} {" ".join(tokens)}' 471 472 return self.__class__( 473 file=self.file, 474 directory=self.directory, 475 command=new_command, 476 arguments=None, 477 output=self.output, 478 ) 479 480 def as_dict(self) -> CppCompileCommandDict: 481 base_compile_command_dict: BaseCppCompileCommandDict = { 482 'file': self.file, 483 'directory': self.directory, 484 'output': self.output, 485 } 486 487 # TODO(chadnorvell): Support "arguments". The spec requires that a 488 # We don't support "arguments" at all right now. When we do, we should 489 # preferentially include "arguments" only, and only include "command" 490 # when "arguments" is not present. 491 if self.command is not None: 492 compile_command_dict: CppCompileCommandDictWithCommand = { 493 'command': self.command, 494 # Unfortunately dict spreading doesn't work with mypy. 495 'file': base_compile_command_dict['file'], 496 'directory': base_compile_command_dict['directory'], 497 'output': base_compile_command_dict['output'], 498 } 499 else: 500 raise NotImplementedError( 501 'Compile commands without \'command\' ' 'are not supported yet.' 502 ) 503 504 return compile_command_dict 505 506 507def _infer_target_pos(target_glob: str) -> List[int]: 508 """Infer the position of the target in a compilation unit artifact path.""" 509 tokens = Path(target_glob).parts 510 positions = [] 511 512 for pos, token in enumerate(tokens): 513 if token == '?': 514 positions.append(pos) 515 elif token == '*': 516 pass 517 else: 518 raise ValueError(f'Invalid target inference token: {token}') 519 520 return positions 521 522 523def infer_target( 524 target_glob: str, root: Path, output_path: Path 525) -> Optional[str]: 526 """Infer a target from a compilation unit artifact path. 527 528 See the documentation for ``PigweedIdeSettings.target_inference``.""" 529 target_pos = _infer_target_pos(target_glob) 530 531 if len(target_pos) == 0: 532 return None 533 534 # Depending on the build system and project configuration, the target name 535 # may be in the "directory" or the "output" of the compile command. So we 536 # need to construct the full path that combines both and use that to search 537 # for the target. 538 subpath = output_path.relative_to(root) 539 return '_'.join([subpath.parts[pos] for pos in target_pos]) 540 541 542LoadableToCppCompilationDatabase = Union[ 543 List[Dict[str, Any]], str, TextIOBase, Path 544] 545 546 547class CppCompilationDatabase: 548 """A representation of a clang compilation database. 549 550 See: https://clang.llvm.org/docs/JSONCompilationDatabase.html 551 """ 552 553 def __init__(self, build_dir: Optional[Path] = None) -> None: 554 self._db: List[CppCompileCommand] = [] 555 556 # Only compilation databases that are loaded will have this, and it 557 # contains the root directory of the build that the compilation 558 # database is based on. Processed compilation databases will not have 559 # a value here. 560 self._build_dir = build_dir 561 562 def __len__(self) -> int: 563 return len(self._db) 564 565 def __getitem__(self, index: int) -> CppCompileCommand: 566 return self._db[index] 567 568 def __iter__(self) -> Generator[CppCompileCommand, None, None]: 569 return (compile_command for compile_command in self._db) 570 571 def add(self, *commands: CppCompileCommand): 572 """Add compile commands to the compilation database.""" 573 self._db.extend(commands) 574 575 def merge(self, other: 'CppCompilationDatabase') -> None: 576 """Merge values from another database into this one. 577 578 This will not overwrite a compile command that already exists for a 579 particular file. 580 """ 581 self_dict = {c.file: c for c in self._db} 582 583 for compile_command in other: 584 if compile_command.file not in self_dict: 585 self_dict[compile_command.file] = compile_command 586 587 self._db = list(self_dict.values()) 588 589 def as_dicts(self) -> List[CppCompileCommandDict]: 590 return [compile_command.as_dict() for compile_command in self._db] 591 592 def to_json(self) -> str: 593 """Output the compilation database to a JSON string.""" 594 595 return json.dumps(self.as_dicts(), indent=2, sort_keys=True) 596 597 def to_file(self, path: Path): 598 """Write the compilation database to a JSON file.""" 599 600 with open(path, 'w') as file: 601 json.dump(self.as_dicts(), file, indent=2, sort_keys=True) 602 603 @classmethod 604 def load( 605 cls, compdb_to_load: LoadableToCppCompilationDatabase, build_dir: Path 606 ) -> 'CppCompilationDatabase': 607 """Load a compilation database. 608 609 You can provide a JSON file handle or path, a JSON string, or a native 610 Python data structure that matches the format (list of dicts). 611 """ 612 db_as_dicts: List[Dict[str, Any]] 613 614 if isinstance(compdb_to_load, list): 615 # The provided data is already in the format we want it to be in, 616 # probably, and if it isn't we'll find out when we try to 617 # instantiate the database. 618 db_as_dicts = compdb_to_load 619 else: 620 if isinstance(compdb_to_load, Path): 621 # The provided data is a path to a file, presumably JSON. 622 try: 623 compdb_data = compdb_to_load.read_text() 624 except FileNotFoundError: 625 raise MissingCompDbException() 626 elif isinstance(compdb_to_load, TextIOBase): 627 # The provided data is a file handle, presumably JSON. 628 compdb_data = compdb_to_load.read() 629 elif isinstance(compdb_to_load, str): 630 # The provided data is a a string, presumably JSON. 631 compdb_data = compdb_to_load 632 633 db_as_dicts = json.loads(compdb_data) 634 635 compdb = cls(build_dir=build_dir) 636 637 try: 638 compdb.add( 639 *[ 640 compile_command 641 for compile_command_dict in db_as_dicts 642 if ( 643 compile_command := CppCompileCommand.try_from_dict( 644 compile_command_dict 645 ) 646 ) 647 is not None 648 ] 649 ) 650 except TypeError: 651 # This will arise if db_as_dicts is not actually a list of dicts 652 raise BadCompDbException() 653 654 return compdb 655 656 def process( 657 self, 658 settings: PigweedIdeSettings, 659 *, 660 default_path: Optional[Path] = None, 661 path_globs: Optional[List[str]] = None, 662 strict: bool = False, 663 ) -> 'CppCompilationDatabasesMap': 664 """Process a ``clangd`` compilation database file. 665 666 Given a clang compilation database that may have commands for multiple 667 valid or invalid targets/toolchains, keep only the valid compile 668 commands and store them in target-specific compilation databases. 669 """ 670 if self._build_dir is None: 671 raise ValueError( 672 'Can only process a compilation database that ' 673 'contains a root build directory, usually ' 674 'specified when loading the file. Are you ' 675 'trying to process an already-processed ' 676 'compilation database?' 677 ) 678 679 clean_compdbs = CppCompilationDatabasesMap(settings) 680 681 for compile_command in self: 682 processed_command = compile_command.process( 683 default_path=default_path, path_globs=path_globs, strict=strict 684 ) 685 686 if ( 687 processed_command is not None 688 and processed_command.output_path is not None 689 ): 690 target = infer_target( 691 settings.target_inference, 692 self._build_dir, 693 processed_command.output_path, 694 ) 695 696 if target_is_enabled(target, settings): 697 # This invariant is satisfied by target_is_enabled 698 target = cast(str, target) 699 processed_command.target = target 700 clean_compdbs[target].add(processed_command) 701 702 return clean_compdbs 703 704 705class CppCompilationDatabasesMap: 706 """Container for a map of target name to compilation database.""" 707 708 def __init__(self, settings: PigweedIdeSettings): 709 self.settings = settings 710 self._dbs: Dict[str, CppCompilationDatabase] = defaultdict( 711 CppCompilationDatabase 712 ) 713 714 def __len__(self) -> int: 715 return len(self._dbs) 716 717 def __getitem__(self, key: str) -> CppCompilationDatabase: 718 return self._dbs[key] 719 720 def __setitem__(self, key: str, item: CppCompilationDatabase) -> None: 721 self._dbs[key] = item 722 723 @property 724 def targets(self) -> List[str]: 725 return list(self._dbs.keys()) 726 727 def items( 728 self, 729 ) -> Generator[Tuple[str, CppCompilationDatabase], None, None]: 730 return ((key, value) for (key, value) in self._dbs.items()) 731 732 def write(self) -> None: 733 """Write compilation databases to target-specific JSON files.""" 734 # This also writes out a file with the name of the target that has the 735 # largest number of commands, i.e., the target with the broadest 736 # compilation unit coverage. We can use this as a default target of 737 # last resort. 738 max_commands = 0 739 max_commands_target = None 740 741 for target, compdb in self.items(): 742 if max_commands_target is None or len(compdb) > max_commands: 743 max_commands_target = target 744 max_commands = len(compdb) 745 746 compdb.to_file( 747 self.settings.working_dir / compdb_generate_file_path(target) 748 ) 749 750 max_commands_target_path = ( 751 self.settings.working_dir / MAX_COMMANDS_TARGET_FILENAME 752 ) 753 754 if max_commands_target is not None: 755 if max_commands_target_path.exists(): 756 max_commands_target_path.unlink() 757 758 with open( 759 max_commands_target_path, 'x' 760 ) as max_commands_target_file: 761 max_commands_target_file.write(max_commands_target) 762 763 @classmethod 764 def merge( 765 cls, *db_sets: 'CppCompilationDatabasesMap' 766 ) -> 'CppCompilationDatabasesMap': 767 """Merge several sets of processed compilation databases. 768 769 If you process N compilation databases produced by a build system, 770 you'll end up with N sets of processed compilation databases, 771 containing databases for one or more targets each. This method 772 merges them into one set of databases with one database per target. 773 774 The expectation is that the vast majority of the time, each of the 775 raw compilation databases that are processed will contain distinct 776 targets, meaning that the keys of each ``CppCompilationDatabases`` 777 object that's merged will be unique to each object, and this operation 778 is nothing more than a shallow merge. 779 780 However, this also supports the case where targets may overlap between 781 ``CppCompilationDatabases`` objects. In that case, we prioritize 782 correctness, ensuring that the resulting compilation databases will 783 work correctly with clangd. This means not including duplicate compile 784 commands for the same file in the same target's database. The choice 785 of which duplicate compile command ends up in the final database is 786 unspecified and subject to change. Note also that this method expects 787 the ``settings`` value to be the same between all of the provided 788 ``CppCompilationDatabases`` objects. 789 """ 790 if len(db_sets) == 0: 791 raise ValueError( 792 'At least one set of compilation databases is ' 'required.' 793 ) 794 795 # Shortcut for the most common case. 796 if len(db_sets) == 1: 797 return db_sets[0] 798 799 merged = cls(db_sets[0].settings) 800 801 for dbs in db_sets: 802 for target, db in dbs.items(): 803 merged[target].merge(db) 804 805 return merged 806 807 808@dataclass(frozen=True) 809class CppIdeFeaturesTarget: 810 """Data pertaining to a C++ code analysis target.""" 811 812 name: str 813 compdb_file_path: Path 814 compdb_cache_path: Optional[Path] 815 is_enabled: bool 816 817 818class CppIdeFeaturesState: 819 """The state of the C++ analysis targets in the working directory. 820 821 Targets can be: 822 823 - **Available**: A compilation database is present for this target. 824 - **Enabled**: Any targets are enabled by default, but a subset can be 825 enabled instead in the pw_ide settings. Enabled targets need 826 not be available if they haven't had a compilation database 827 created through processing yet. 828 - **Valid**: Is both available and enabled. 829 - **Current**: The one currently activated target that is exposed to clangd. 830 """ 831 832 def __init__(self, settings: PigweedIdeSettings) -> None: 833 self.settings = settings 834 835 # We filter out Nones below, so we can assume its a str 836 target: Callable[[Path], str] = lambda path: cast( 837 str, compdb_target_from_path(path) 838 ) 839 840 # Contains every compilation database that's present in the working dir. 841 # This dict comprehension looks monstrous, but it just finds targets and 842 # associates the target names with their CppIdeFeaturesTarget objects. 843 self.targets: Dict[str, CppIdeFeaturesTarget] = { 844 target(file_path): CppIdeFeaturesTarget( 845 name=target(file_path), 846 compdb_file_path=file_path, 847 compdb_cache_path=compdb_cache_path_if_exists( 848 settings.working_dir, compdb_target_from_path(file_path) 849 ), 850 is_enabled=target_is_enabled(target(file_path), settings), 851 ) 852 for file_path in settings.working_dir.iterdir() 853 if file_path.match( 854 f'{_COMPDB_FILE_PREFIX}*{_COMPDB_FILE_EXTENSION}' 855 ) 856 # This filters out the symlink 857 and compdb_target_from_path(file_path) is not None 858 } 859 860 # Contains the currently selected target. 861 self._current_target: Optional[CppIdeFeaturesTarget] = None 862 863 # This is diagnostic data; it tells us what the current target should 864 # be, even if the state of the working directory is corrupted and the 865 # compilation database for the target isn't actually present. Anything 866 # that requires a compilation database to be definitely present should 867 # use `current_target` instead of these values. 868 self.current_target_name: Optional[str] = None 869 self.current_target_file_path: Optional[Path] = None 870 self.current_target_exists: Optional[bool] = None 871 872 # Contains the name of the target that has the most compile commands, 873 # i.e., the target with the most file coverage in the project. 874 self._max_commands_target: Optional[str] = None 875 876 try: 877 src_file = Path( 878 os.readlink( 879 (settings.working_dir / compdb_generate_file_path()) 880 ) 881 ) 882 883 self.current_target_file_path = src_file 884 self.current_target_name = compdb_target_from_path(src_file) 885 886 if not self.current_target_file_path.exists(): 887 self.current_target_exists = False 888 889 else: 890 self.current_target_exists = True 891 self._current_target = CppIdeFeaturesTarget( 892 name=target(src_file), 893 compdb_file_path=src_file, 894 compdb_cache_path=compdb_cache_path_if_exists( 895 settings.working_dir, target(src_file) 896 ), 897 is_enabled=target_is_enabled(target(src_file), settings), 898 ) 899 except (FileNotFoundError, OSError): 900 # If the symlink doesn't exist, there is no current target. 901 pass 902 903 try: 904 with open( 905 settings.working_dir / MAX_COMMANDS_TARGET_FILENAME 906 ) as max_commands_target_file: 907 self._max_commands_target = max_commands_target_file.readline() 908 except FileNotFoundError: 909 # If the file doesn't exist, a compilation database probably 910 # hasn't been processed yet. 911 pass 912 913 def __len__(self) -> int: 914 return len(self.targets) 915 916 def __getitem__(self, index: str) -> CppIdeFeaturesTarget: 917 return self.targets[index] 918 919 def __iter__(self) -> Generator[CppIdeFeaturesTarget, None, None]: 920 return (target for target in self.targets.values()) 921 922 @property 923 def current_target(self) -> Optional[str]: 924 """The name of current target used for code analysis. 925 926 The presence of a symlink with the expected filename pointing to a 927 compilation database matching the expected filename format is the source 928 of truth on what the current target is. 929 """ 930 return ( 931 self._current_target.name 932 if self._current_target is not None 933 else None 934 ) 935 936 @current_target.setter 937 def current_target(self, target: Optional[str]) -> None: 938 settings = self.settings 939 940 if not self.is_valid_target(target): 941 raise InvalidTargetException() 942 943 # The check above rules out None. 944 target = cast(str, target) 945 946 compdb_symlink_path = settings.working_dir / compdb_generate_file_path() 947 948 compdb_target_path = settings.working_dir / compdb_generate_file_path( 949 target 950 ) 951 952 if not compdb_target_path.exists(): 953 raise MissingCompDbException() 954 955 set_symlink(compdb_target_path, compdb_symlink_path) 956 957 cache_symlink_path = settings.working_dir / compdb_generate_cache_path() 958 959 cache_target_path = settings.working_dir / compdb_generate_cache_path( 960 target 961 ) 962 963 if not cache_target_path.exists(): 964 os.mkdir(cache_target_path) 965 966 set_symlink(cache_target_path, cache_symlink_path) 967 968 @property 969 def max_commands_target(self) -> Optional[str]: 970 """The target with the most compile commands. 971 972 The return value is the name of the target with the largest number of 973 compile commands (i.e., the largest coverage across the files in the 974 project). This can be a useful "default target of last resort". 975 """ 976 return self._max_commands_target 977 978 @property 979 def available_targets(self) -> List[str]: 980 return list(self.targets.keys()) 981 982 @property 983 def enabled_available_targets(self) -> Generator[str, None, None]: 984 return ( 985 name for name, target in self.targets.items() if target.is_enabled 986 ) 987 988 def is_valid_target(self, target: Optional[str]) -> bool: 989 if target is None or (data := self.targets.get(target, None)) is None: 990 return False 991 992 return data.is_enabled 993 994 995def aggregate_compilation_database_targets( 996 compdb_file: LoadableToCppCompilationDatabase, 997 settings: PigweedIdeSettings, 998 build_dir: Path, 999 *, 1000 default_path: Optional[Path] = None, 1001 path_globs: Optional[List[str]] = None, 1002) -> List[str]: 1003 """Return all valid unique targets from a ``clang`` compilation database.""" 1004 compdbs_map = CppCompilationDatabase.load(compdb_file, build_dir).process( 1005 settings, default_path=default_path, path_globs=path_globs 1006 ) 1007 1008 return compdbs_map.targets 1009 1010 1011def delete_compilation_databases(settings: PigweedIdeSettings) -> None: 1012 """Delete all compilation databases in the working directory. 1013 1014 This leaves cache directories in place. 1015 """ 1016 if settings.working_dir.exists(): 1017 for path in settings.working_dir.iterdir(): 1018 if path.name.startswith(_COMPDB_FILE_PREFIX): 1019 try: 1020 path.unlink() 1021 except FileNotFoundError: 1022 pass 1023 1024 1025def delete_compilation_database_caches(settings: PigweedIdeSettings) -> None: 1026 """Delete all compilation database caches in the working directory. 1027 1028 This leaves all compilation databases in place. 1029 """ 1030 if settings.working_dir.exists(): 1031 for path in settings.working_dir.iterdir(): 1032 if path.name.startswith(_COMPDB_CACHE_DIR_PREFIX): 1033 try: 1034 path.unlink() 1035 except FileNotFoundError: 1036 pass 1037 1038 1039class ClangdSettings: 1040 """Makes system-specific settings for running ``clangd`` with Pigweed.""" 1041 1042 def __init__(self, settings: PigweedIdeSettings): 1043 self.compile_commands_dir: Path = PigweedIdeSettings().working_dir 1044 self.clangd_path: Path = ( 1045 Path(PW_PIGWEED_CIPD_INSTALL_DIR) / 'bin' / 'clangd' 1046 ) 1047 1048 self.arguments: List[str] = [ 1049 f'--compile-commands-dir={self.compile_commands_dir}', 1050 f'--query-driver={settings.clangd_query_driver_str()}', 1051 '--background-index', 1052 '--clang-tidy', 1053 ] 1054 1055 def command(self, system: str = platform.system()) -> str: 1056 """Return the command that runs clangd with Pigweed paths.""" 1057 1058 def make_command(line_continuation: str): 1059 arguments = f' {line_continuation}\n'.join( 1060 f' {arg}' for arg in self.arguments 1061 ) 1062 return f'\n{self.clangd_path} {line_continuation}\n{arguments}' 1063 1064 if system.lower() == 'json': 1065 return '\n' + json.dumps( 1066 [str(self.clangd_path), *self.arguments], indent=2 1067 ) 1068 1069 if system.lower() in ['cmd', 'batch']: 1070 return make_command('`') 1071 1072 if system.lower() in ['powershell', 'pwsh']: 1073 return make_command('^') 1074 1075 if system.lower() == 'windows': 1076 return ( 1077 f'\nIn PowerShell:\n{make_command("`")}' 1078 f'\n\nIn Command Prompt:\n{make_command("^")}' 1079 ) 1080 1081 # Default case for *sh-like shells. 1082 return make_command('\\') 1083