xref: /aosp_15_r20/external/pigweed/pw_ide/py/pw_ide/settings.py (revision 61c4878ac05f98d0ceed94b57d316916de578985)
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"""pw_ide settings."""
15
16import enum
17from inspect import cleandoc
18import os
19from pathlib import Path
20from typing import Any, cast, Literal
21import yaml
22
23from pw_cli.env import pigweed_environment
24from pw_config_loader.yaml_config_loader_mixin import YamlConfigLoaderMixin
25
26env = pigweed_environment()
27env_vars = vars(env)
28
29PW_PROJECT_ROOT = Path(
30    env.PW_PROJECT_ROOT if env.PW_PROJECT_ROOT is not None else os.getcwd()
31)
32
33PW_IDE_DIR_NAME = '.pw_ide'
34
35_DEFAULT_BUILD_DIR_NAME = 'out'
36_DEFAULT_BUILD_DIR = PW_PROJECT_ROOT / _DEFAULT_BUILD_DIR_NAME
37
38_DEFAULT_WORKSPACE_ROOT = PW_PROJECT_ROOT
39
40_DEFAULT_TARGET_INFERENCE = '?'
41
42SupportedEditorName = Literal['vscode']
43
44
45class SupportedEditor(enum.Enum):
46    VSCODE = 'vscode'
47
48
49_DEFAULT_SUPPORTED_EDITORS: dict[SupportedEditorName, bool] = {
50    'vscode': True,
51}
52
53_DEFAULT_CONFIG: dict[str, Any] = {
54    'cascade_targets': False,
55    'clangd_alternate_path': None,
56    'clangd_additional_query_drivers': [],
57    'compdb_gen_cmd': None,
58    'compdb_search_paths': [_DEFAULT_BUILD_DIR_NAME],
59    'default_target': None,
60    'workspace_root': _DEFAULT_WORKSPACE_ROOT,
61    'editors': _DEFAULT_SUPPORTED_EDITORS,
62    'sync': ['pw --no-banner ide cpp --process'],
63    'targets_exclude': [],
64    'targets_include': [],
65    'target_inference': _DEFAULT_TARGET_INFERENCE,
66    'working_dir': _DEFAULT_WORKSPACE_ROOT / PW_IDE_DIR_NAME,
67}
68
69_DEFAULT_PROJECT_FILE = PW_PROJECT_ROOT / '.pw_ide.yaml'
70_DEFAULT_PROJECT_USER_FILE = PW_PROJECT_ROOT / '.pw_ide.user.yaml'
71_DEFAULT_USER_FILE = Path.home() / '.pw_ide.yaml'
72
73
74def _expand_any_vars(input_path: Path) -> Path:
75    """Expand any environment variables in a path.
76
77    Python's ``os.path.expandvars`` will only work on an isolated environment
78    variable name. In shell, you can expand variables within a larger command
79    or path. We replicate that functionality here.
80    """
81    outputs = []
82
83    for token in input_path.parts:
84        expanded_var = os.path.expandvars(token)
85
86        if expanded_var == token:
87            outputs.append(token)
88        else:
89            outputs.append(expanded_var)
90
91    # pylint: disable=no-value-for-parameter
92    return Path(os.path.join(*outputs))
93    # pylint: enable=no-value-for-parameter
94
95
96def _expand_any_vars_str(input_path: str) -> str:
97    """`_expand_any_vars`, except takes and returns a string instead of path."""
98    return str(_expand_any_vars(Path(input_path)))
99
100
101def _parse_dir_path(input_path_str: str, workspace_root: Path) -> Path:
102    if (path := Path(input_path_str)).is_absolute():
103        return path
104
105    return (workspace_root / path).resolve()
106
107
108def _parse_compdb_search_path(
109    input_data: str | tuple[str, str],
110    default_inference: str,
111    workspace_root: Path,
112) -> tuple[Path, str]:
113    if isinstance(input_data, (tuple, list)):
114        return _parse_dir_path(input_data[0], workspace_root), input_data[1]
115
116    return _parse_dir_path(input_data, workspace_root), default_inference
117
118
119class PigweedIdeSettings(YamlConfigLoaderMixin):
120    """Pigweed IDE features settings storage class."""
121
122    def __init__(
123        self,
124        project_file: Path | bool = _DEFAULT_PROJECT_FILE,
125        project_user_file: Path | bool = _DEFAULT_PROJECT_USER_FILE,
126        user_file: Path | bool = _DEFAULT_USER_FILE,
127        default_config: dict[str, Any] | None = None,
128    ) -> None:
129        self.config_init(
130            config_section_title='pw_ide',
131            project_file=project_file,
132            project_user_file=project_user_file,
133            user_file=user_file,
134            default_config=_DEFAULT_CONFIG
135            if default_config is None
136            else default_config,
137            environment_var='PW_IDE_CONFIG_FILE',
138        )
139
140        # YamlConfigLoaderMixin only conditionally assigns this, but we rely
141        # on it later, so we just extra assign it here.
142        self.project_file = project_file  # type: ignore
143
144    def __repr__(self) -> str:
145        return str(
146            {
147                key: getattr(self, key)
148                for key, value in self.__class__.__dict__.items()
149                if isinstance(value, property)
150            }
151        )
152
153    @property
154    def working_dir(self) -> Path:
155        """Path to the ``pw_ide`` working directory.
156
157        The working directory holds C++ compilation databases and caches, and
158        other supporting files. This should not be a directory that's regularly
159        deleted or manipulated by other processes (e.g. the GN ``out``
160        directory) nor should it be committed to source control.
161        """
162        return Path(
163            _expand_any_vars_str(
164                self._config.get(
165                    'working_dir', self.workspace_root / PW_IDE_DIR_NAME
166                )
167            )
168        )
169
170    @property
171    def compdb_gen_cmd(self) -> str | None:
172        """The command that should be run to generate a compilation database.
173
174        Defining this allows ``pw_ide`` to automatically generate a compilation
175        database if it suspects one has not been generated yet before a sync.
176        """
177        return self._config.get('compdb_gen_cmd')
178
179    @property
180    def compdb_search_paths(self) -> list[tuple[Path, str]]:
181        """Paths to directories to search for compilation databases.
182
183        If you're using a build system to generate compilation databases, this
184        may simply be your build output directory. However, you can add
185        additional directories to accommodate compilation databases from other
186        sources.
187
188        Entries can be just directories, in which case the default target
189        inference pattern will be used. Or entries can be tuples of a directory
190        and a target inference pattern. See the documentation for
191        ``target_inference`` for more information.
192
193        Finally, the directories can be concrete paths, or they can be globs
194        that expand to multiple paths.
195
196        Note that relative directory paths will be resolved relative to the
197        workspace root.
198        """
199        return [
200            _parse_compdb_search_path(
201                search_path, self.target_inference, self.workspace_root
202            )
203            for search_path in self._config.get(
204                'compdb_search_paths', [_DEFAULT_BUILD_DIR]
205            )
206        ]
207
208    @property
209    def targets_exclude(self) -> list[str]:
210        """The list of targets that should not be enabled for code analysis.
211
212        In this case, "target" is analogous to a GN target, i.e., a particular
213        build configuration. By default, all available targets are enabled. By
214        adding targets to this list, you can disable/hide targets that should
215        not be available for code analysis.
216
217        Target names need to match the name of the directory that holds the
218        build system artifacts for the target. For example, GN outputs build
219        artifacts for the ``pw_strict_host_clang_debug`` target in a directory
220        with that name in its output directory. So that becomes the canonical
221        name for the target.
222        """
223        return self._config.get('targets_exclude', list())
224
225    @property
226    def targets_include(self) -> list[str]:
227        """The list of targets that should be enabled for code analysis.
228
229        In this case, "target" is analogous to a GN target, i.e., a particular
230        build configuration. By default, all available targets are enabled. By
231        adding targets to this list, you can constrain the targets that are
232        enabled for code analysis to a subset of those that are available, which
233        may be useful if your project has many similar targets that are
234        redundant from a code analysis perspective.
235
236        Target names need to match the name of the directory that holds the
237        build system artifacts for the target. For example, GN outputs build
238        artifacts for the ``pw_strict_host_clang_debug`` target in a directory
239        with that name in its output directory. So that becomes the canonical
240        name for the target.
241        """
242        return self._config.get('targets_include', list())
243
244    @property
245    def target_inference(self) -> str:
246        """A glob-like string for extracting a target name from an output path.
247
248        Build systems and projects have varying ways of organizing their build
249        directory structure. For a given compilation unit, we need to know how
250        to extract the build's target name from the build artifact path. A
251        simple example:
252
253        .. code-block:: none
254
255           clang++ hello.cc -o host/obj/hello.cc.o
256
257        The top-level directory ``host`` is the target name we want. The same
258        compilation unit might be used with another build target:
259
260        .. code-block:: none
261
262           gcc-arm-none-eabi hello.cc -o arm_dev_board/obj/hello.cc.o
263
264        In this case, this compile command is associated with the
265        ``arm_dev_board`` target.
266
267        When importing and processing a compilation database, we assume by
268        default that for each compile command, the corresponding target name is
269        the name of the top level directory within the build directory root
270        that contains the build artifact. This is the default behavior for most
271        build systems. However, if your project is structured differently, you
272        can provide a glob-like string that indicates how to extract the target
273        name from build artifact path.
274
275        A ``*`` indicates any directory, and ``?`` indicates the directory that
276        has the name of the target. The path is resolved from the build
277        directory root, and anything deeper than the target directory is
278        ignored. For example, a glob indicating that the directory two levels
279        down from the build directory root has the target name would be
280        expressed with ``*/*/?``.
281
282        Note that the build artifact path is relative to the compilation
283        database search path that found the file. For example, for a compilation
284        database search path of ``{project dir}/out``, for the purposes of
285        target inference, the build artifact path is relative to the ``{project
286        dir}/out`` directory. Target inference patterns can be defined for each
287        compilation database search path. See the documentation for
288        ``compdb_search_paths`` for more information.
289        """
290        return self._config.get('target_inference', _DEFAULT_TARGET_INFERENCE)
291
292    @property
293    def default_target(self) -> str | None:
294        """The default target to use when calling ``--set-default``.
295
296        This target will be selected when ``pw ide cpp --set-default`` is
297        called. You can define an explicit default target here. If that command
298        is invoked without a default target definition, ``pw_ide`` will try to
299        infer the best choice of default target. Currently, it selects the
300        target with the broadest compilation unit coverage.
301        """
302        return self._config.get('default_target', None)
303
304    @property
305    def sync(self) -> list[str]:
306        """A sequence of commands to automate IDE features setup.
307
308        ``pw ide sync`` should do everything necessary to get the project from
309        a fresh checkout to a working default IDE experience. This defines the
310        list of commands that makes that happen, which will be executed
311        sequentially in subprocesses. These commands should be idempotent, so
312        that the user can run them at any time to update their IDE features
313        configuration without the risk of putting those features in a bad or
314        unexpected state.
315        """
316        return self._config.get('sync', list())
317
318    @property
319    def clangd_alternate_path(self) -> Path | None:
320        """An alternate path to ``clangd`` to use instead of Pigweed's.
321
322        Pigweed provides the ``clang`` toolchain, including ``clangd``, via
323        CIPD, and by default, ``pw_ide`` will look for that toolchain in the
324        CIPD directory at ``$PW_PIGWEED_CIPD_INSTALL_DIR`` *or* in an alternate
325        CIPD directory specified by ``$PW_{project name}_CIPD_INSTALL_DIR`` if
326        it exists.
327
328        If your project needs to use a ``clangd`` located somewhere else not
329        covered by the cases described above, you can define the path to that
330        ``clangd`` here.
331        """
332        return self._config.get('clangd_alternate_path', None)
333
334    @property
335    def clangd_additional_query_drivers(self) -> list[str] | None:
336        """Additional query driver paths that clangd should use.
337
338        By default, ``pw_ide`` supplies driver paths for the toolchains included
339        in Pigweed. If you are using toolchains that are not supplied by
340        Pigweed, you should include path globs to your toolchains here. These
341        paths will be given higher priority than the Pigweed toolchain paths.
342
343        If you want to omit the query drivers argument altogether, set this to
344        ``null``.
345        """
346        return self._config.get('clangd_additional_query_drivers', list())
347
348    def clangd_query_drivers(
349        self, host_clang_cc_path: Path
350    ) -> list[str] | None:
351        if self.clangd_additional_query_drivers is None:
352            return None
353
354        drivers = [
355            *[
356                _expand_any_vars_str(p)
357                for p in self.clangd_additional_query_drivers
358            ],
359        ]
360
361        drivers.append(str(host_clang_cc_path.parent / '*'))
362
363        if (env_var := env_vars.get('PW_ARM_CIPD_INSTALL_DIR')) is not None:
364            drivers.append(str(Path(env_var) / 'bin' / '*'))
365
366        return drivers
367
368    def clangd_query_driver_str(self, host_clang_cc_path: Path) -> str | None:
369        clangd_query_drivers = self.clangd_query_drivers(host_clang_cc_path)
370
371        if clangd_query_drivers is None:
372            return None
373
374        return ','.join(clangd_query_drivers)
375
376    @property
377    def workspace_root(self) -> Path:
378        """The root directory of the IDE workspace.
379
380        In most cases, the IDE workspace directory is also your Pigweed project
381        root; that's the default, and if that's the case, this you can omit this
382        configuration.
383
384        If your project has a structure where the directory you open in your
385        IDE is *not* the Pigweed project root directory, you can specify the
386        workspace root directory here to ensure that IDE support files are
387        put in the right place.
388
389        For example, given this directory structure:
390
391        .. code-block::
392
393           my_project
394           |
395           ├- third_party
396           ├- docs
397           ├- src
398           |  ├- pigweed.json
399           |  └- ... all other project source files
400           |
401           └- pigweed
402               └ ... upstream Pigweed source, e.g. a submodule
403
404        In this case ```my_project/src/``` is the Pigweed project root, but
405        ```my_project/``` is the workspace root directory you will open in your
406        IDE.
407
408        A relative path will be resolved relative to the directory in which the
409        ``.pw_ide.yaml`` config file is located.
410        """
411        workspace_root = Path(
412            self._config.get('workspace_root', _DEFAULT_WORKSPACE_ROOT)
413        )
414
415        project_file_exists = (
416            isinstance(self.project_file, Path) and self.project_file.exists()
417        )
418
419        config_file_root = (
420            cast(Path, self.project_file)
421            if project_file_exists
422            else _DEFAULT_PROJECT_FILE
423        ).parent
424
425        if workspace_root.is_absolute():
426            return workspace_root
427
428        return config_file_root.resolve() / workspace_root
429
430    @property
431    def editors(self) -> dict[str, bool]:
432        """Enable or disable automated support for editors.
433
434        Automatic support for some editors is provided by ``pw_ide``, which is
435        accomplished through generating configuration files in your project
436        directory. All supported editors are enabled by default, but you can
437        disable editors by adding an ``'<editor>': false`` entry.
438        """
439        return self._config.get('editors', _DEFAULT_SUPPORTED_EDITORS)
440
441    def editor_enabled(self, editor: SupportedEditorName) -> bool:
442        """True if the provided editor is enabled in settings.
443
444        This module will integrate the project with all supported editors by
445        default. If the project or user want to disable particular editors,
446        they can do so in the appropriate settings file.
447        """
448        return self._config.get('editors', {}).get(editor, False)
449
450    @property
451    def cascade_targets(self) -> bool:
452        """Mix compile commands for multiple targets to maximize code coverage.
453
454        By default (with this set to ``False``), the compilation database for
455        each target is consistent in the sense that it only contains compile
456        commands for one build target, so the code intelligence that database
457        provides is related to a single, known compilation artifact. However,
458        this means that code intelligence may not be provided for every source
459        file in a project, because some source files may be relevant to targets
460        other than the one you have currently set. Those source files won't
461        have compile commands for the current target, and no code intelligence
462        will appear in your editor.
463
464        If this is set to ``True``, compilation databases will still be
465        separated by target, but compile commands for *all other targets* will
466        be appended to the list of compile commands for *that* target. This
467        will maximize code coverage, ensuring that you have code intelligence
468        for every file that is built for any target, at the cost of
469        consistency—the code intelligence for some files may show information
470        that is incorrect or irrelevant to the currently selected build target.
471
472        The currently set target's compile commands will take priority at the
473        top of the combined file, then all other targets' commands will come
474        after in order of the number of commands they have (i.e. in the order of
475        their code coverage). This relies on the fact that ``clangd`` parses the
476        compilation database from the top down, using the first compile command
477        it encounters for each compilation unit.
478        """
479        return self._config.get('cascade_targets', False)
480
481
482def _docstring_set_default(
483    obj: Any, default: Any, literal: bool = False
484) -> None:
485    """Add a default value annotation to a docstring.
486
487    Formatting isn't allowed in docstrings, so by default we can't inject
488    variables that we would like to appear in the documentation, like the
489    default value of a property. But we can use this function to add it
490    separately.
491    """
492    if obj.__doc__ is not None:
493        default = str(default)
494
495        if literal:
496            lines = default.splitlines()
497
498            if len(lines) == 0:
499                return
500            if len(lines) == 1:
501                default = f'Default: ``{lines[0]}``'
502            else:
503                default = 'Default:\n\n.. code-block::\n\n  ' + '\n  '.join(
504                    lines
505                )
506
507        doc = cast(str, obj.__doc__)
508        obj.__doc__ = f'{cleandoc(doc)}\n\n{default}'
509
510
511_docstring_set_default(
512    PigweedIdeSettings.working_dir, PW_IDE_DIR_NAME, literal=True
513)
514_docstring_set_default(
515    PigweedIdeSettings.compdb_gen_cmd,
516    _DEFAULT_CONFIG['compdb_gen_cmd'],
517    literal=True,
518)
519_docstring_set_default(
520    PigweedIdeSettings.compdb_search_paths,
521    [_DEFAULT_BUILD_DIR_NAME],
522    literal=True,
523)
524_docstring_set_default(
525    PigweedIdeSettings.targets_exclude,
526    _DEFAULT_CONFIG['targets_exclude'],
527    literal=True,
528)
529_docstring_set_default(
530    PigweedIdeSettings.targets_include,
531    _DEFAULT_CONFIG['targets_include'],
532    literal=True,
533)
534_docstring_set_default(
535    PigweedIdeSettings.default_target,
536    _DEFAULT_CONFIG['default_target'],
537    literal=True,
538)
539_docstring_set_default(
540    PigweedIdeSettings.cascade_targets,
541    _DEFAULT_CONFIG['cascade_targets'],
542    literal=True,
543)
544_docstring_set_default(
545    PigweedIdeSettings.target_inference,
546    _DEFAULT_CONFIG['target_inference'],
547    literal=True,
548)
549_docstring_set_default(
550    PigweedIdeSettings.sync, _DEFAULT_CONFIG['sync'], literal=True
551)
552_docstring_set_default(
553    PigweedIdeSettings.clangd_alternate_path,
554    _DEFAULT_CONFIG['clangd_alternate_path'],
555    literal=True,
556)
557_docstring_set_default(
558    PigweedIdeSettings.clangd_additional_query_drivers,
559    _DEFAULT_CONFIG['clangd_additional_query_drivers'],
560    literal=True,
561)
562_docstring_set_default(
563    PigweedIdeSettings.editors,
564    yaml.dump(_DEFAULT_SUPPORTED_EDITORS),
565    literal=True,
566)
567