• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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