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