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