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