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"""CLI tools for pw_ide.""" 15 16import argparse 17import enum 18from inspect import cleandoc 19from pathlib import Path 20import re 21from typing import Any, Callable, Dict, List, Optional, Protocol 22 23from pw_ide.commands import ( 24 cmd_clear, 25 cmd_cpp, 26 cmd_python, 27 cmd_reset, 28 cmd_setup, 29 cmd_vscode, 30) 31 32from pw_ide.vscode import VscSettingsType 33 34 35def _get_docstring(obj: Any) -> Optional[str]: 36 doc: Optional[str] = getattr(obj, '__doc__', None) 37 return doc 38 39 40class _ParsedDocstring: 41 """Parses help content out of a standard docstring.""" 42 43 def __init__(self, obj: Any) -> None: 44 self.description = '' 45 self.epilog = '' 46 47 if obj is not None and (doc := _get_docstring(obj)) is not None: 48 lines = doc.split('\n') 49 self.description = lines.pop(0) 50 51 # Eliminate the blank line between the summary and the main content 52 if len(lines) > 0: 53 lines.pop(0) 54 55 self.epilog = cleandoc('\n'.join(lines)) 56 57 58class SphinxStripperState(enum.Enum): 59 SEARCHING = 0 60 COLLECTING = 1 61 HANDLING = 2 62 63 64class SphinxStripper: 65 """Strip Sphinx directives from text. 66 67 The caller can provide an object with methods named _handle_directive_{} 68 to handle specific directives. Otherwise, the default will apply. 69 70 Feed text line by line to .process(line), then get the processed text back 71 with .result(). 72 """ 73 74 def __init__(self, handler: Any) -> None: 75 self.handler = handler 76 self.directive: str = '' 77 self.tag: str = '' 78 self.lines_to_handle: List[str] = [] 79 self.handled_lines: List[str] = [] 80 self._prev_state: SphinxStripperState = SphinxStripperState.SEARCHING 81 self._curr_state: SphinxStripperState = SphinxStripperState.SEARCHING 82 83 @property 84 def state(self) -> SphinxStripperState: 85 return self._curr_state 86 87 @state.setter 88 def state(self, value: SphinxStripperState) -> None: 89 self._prev_state = self._curr_state 90 self._curr_state = value 91 92 def search_for_directives(self, line: str) -> None: 93 match = re.search( 94 r'^\.\.\s*(?P<directive>[\-\w]+)::\s*(?P<tag>[\-\w]+)$', line 95 ) 96 97 if match is not None: 98 self.directive = match.group('directive') 99 self.tag = match.group('tag') 100 self.state = SphinxStripperState.COLLECTING 101 else: 102 self.handled_lines.append(line) 103 104 def collect_lines(self, line) -> None: 105 # Collect lines associated with a directive, including blank lines in 106 # the middle of the directive text, but not the blank line between the 107 # directive and the start of its text. 108 if not (line.strip() == '' and len(self.lines_to_handle) == 0): 109 self.lines_to_handle.append(line) 110 111 def handle_lines(self, line: str = '') -> None: 112 handler_fn = f'_handle_directive_{self.directive.replace("-", "_")}' 113 114 self.handled_lines.extend( 115 getattr(self.handler, handler_fn, lambda _, s: s)( 116 self.tag, self.lines_to_handle 117 ) 118 ) 119 120 self.handled_lines.append(line) 121 self.lines_to_handle = [] 122 self.state = SphinxStripperState.SEARCHING 123 124 def process_line(self, line: str) -> None: 125 if self.state == SphinxStripperState.SEARCHING: 126 self.search_for_directives(line) 127 128 else: 129 if self.state == SphinxStripperState.COLLECTING: 130 # Assume that indented text below the directive is associated 131 # with the directive. 132 if line.strip() == '' or line[0] in (' ', '\t'): 133 self.collect_lines(line) 134 # When we encounter non-indented text, we're done with this 135 # directive. 136 else: 137 self.state = SphinxStripperState.HANDLING 138 139 if self.state == SphinxStripperState.HANDLING: 140 self.handle_lines(line) 141 142 def result(self) -> str: 143 if self.state == SphinxStripperState.COLLECTING: 144 self.state = SphinxStripperState.HANDLING 145 self.handle_lines() 146 147 return '\n'.join(self.handled_lines) 148 149 150class RawDescriptionSphinxStrippedHelpFormatter( 151 argparse.RawDescriptionHelpFormatter 152): 153 """An argparse formatter that strips Sphinx directives. 154 155 CLI command docstrings can contain Sphinx directives for rendering in docs. 156 But we don't want to include those directives when printing to the terminal. 157 So we strip them and, if appropriate, replace them with something better 158 suited to terminal output. 159 """ 160 161 def _reformat(self, text: str) -> str: 162 """Given a block of text, replace Sphinx directives. 163 164 Directive handlers will be provided with the directive name, its tag, 165 and all of the associated lines of text. "Association" is determined by 166 those lines being indented to any degree under the directive. 167 168 Unhandled directives will only have the directive line removed. 169 """ 170 sphinx_stripper = SphinxStripper(self) 171 172 for line in text.splitlines(): 173 sphinx_stripper.process_line(line) 174 175 # The space at the end prevents the final blank line from being stripped 176 # by argparse, which provides breathing room between the text and the 177 # prompt. 178 return sphinx_stripper.result() + ' ' 179 180 def _format_text(self, text: str) -> str: 181 # This overrides an arparse method that is not technically a public API. 182 return super()._format_text(self._reformat(text)) 183 184 def _handle_directive_code_block( # pylint: disable=no-self-use 185 self, tag: str, lines: List[str] 186 ) -> List[str]: 187 if tag == 'bash': 188 processed_lines = [] 189 190 for line in lines: 191 if line.strip() == '': 192 processed_lines.append(line) 193 else: 194 stripped_line = line.lstrip() 195 indent = len(line) - len(stripped_line) 196 spaces = ' ' * indent 197 processed_line = f'{spaces}$ {stripped_line}' 198 processed_lines.append(processed_line) 199 200 return processed_lines 201 202 return lines 203 204 205class _ParserAdder(Protocol): 206 """Return type for _parser_adder. 207 208 Essentially expresses the type of __call__, which cannot be expressed in 209 type annotations. 210 """ 211 212 def __call__( 213 self, subcommand_handler: Callable[..., None], *args: Any, **kwargs: Any 214 ) -> argparse.ArgumentParser: 215 ... 216 217 218def _parser_adder(subcommand_parser) -> _ParserAdder: 219 """Create subcommand parsers with a consistent format. 220 221 When given a subcommand handler, this will produce a parser that pulls the 222 description, help, and epilog values from its docstring, and passes parsed 223 args on to to the function. 224 225 Create a subcommand parser, then feed it to this to get an `add_parser` 226 function: 227 228 .. code-block:: python 229 230 subcommand_parser = parser_root.add_subparsers(help='Subcommands') 231 add_parser = _parser_adder(subcommand_parser) 232 233 Then use `add_parser` instead of `subcommand_parser.add_parser`. 234 """ 235 236 def _add_parser( 237 subcommand_handler: Callable[..., None], *args, **kwargs 238 ) -> argparse.ArgumentParser: 239 doc = _ParsedDocstring(subcommand_handler) 240 default_kwargs = dict( 241 # Displayed in list of subcommands 242 description=doc.description, 243 # Displayed as top-line summary for this subcommand's help 244 help=doc.description, 245 # Displayed as detailed help text for this subcommand's help 246 epilog=doc.epilog, 247 # Ensures that formatting is preserved and Sphinx directives are 248 # stripped out when printing to the terminal 249 formatter_class=RawDescriptionSphinxStrippedHelpFormatter, 250 ) 251 252 new_kwargs = {**default_kwargs, **kwargs} 253 parser = subcommand_parser.add_parser(*args, **new_kwargs) 254 parser.set_defaults(func=subcommand_handler) 255 return parser 256 257 return _add_parser 258 259 260def _build_argument_parser() -> argparse.ArgumentParser: 261 parser_root = argparse.ArgumentParser(prog='pw ide', description=__doc__) 262 263 parser_root.set_defaults( 264 func=lambda *_args, **_kwargs: parser_root.print_help() 265 ) 266 267 subcommand_parser = parser_root.add_subparsers(help='Subcommands') 268 add_parser = _parser_adder(subcommand_parser) 269 270 add_parser(cmd_setup, 'setup') 271 272 parser_reset = add_parser(cmd_reset, 'reset') 273 parser_reset.add_argument( 274 '--hard', 275 action='store_true', 276 help='completely remove the .pw_ide working ' 277 'dir and supported editor files', 278 ) 279 280 parser_cpp = add_parser(cmd_cpp, 'cpp') 281 parser_cpp.add_argument( 282 '-l', 283 '--list', 284 dest='should_list_targets', 285 action='store_true', 286 help='list the targets available for C/C++ ' 'language analysis', 287 ) 288 parser_cpp.add_argument( 289 '-g', 290 '--get', 291 dest='should_get_target', 292 action='store_true', 293 help='print the current target used for C/C++ ' 'language analysis', 294 ) 295 parser_cpp.add_argument( 296 '-s', 297 '--set', 298 dest='target_to_set', 299 metavar='TARGET', 300 help='set the target to use for C/C++ language ' 'server analysis', 301 ) 302 parser_cpp.add_argument( 303 '--set-default', 304 dest='use_default_target', 305 action='store_true', 306 help='set the C/C++ analysis target to the default ' 307 'defined in pw_ide settings', 308 ) 309 parser_cpp.add_argument( 310 '--no-override', 311 dest='override_current_target', 312 action='store_const', 313 const=False, 314 default=True, 315 help='if called with --set, don\'t override the ' 316 'current target if one is already set', 317 ) 318 parser_cpp.add_argument( 319 '--ninja', 320 dest='should_run_ninja', 321 action='store_true', 322 help='use Ninja to generate a compilation database', 323 ) 324 parser_cpp.add_argument( 325 '--gn', 326 dest='should_run_gn', 327 action='store_true', 328 help='run gn gen {out} --export-compile-commands, ' 329 'along with any other arguments defined in args.gn', 330 ) 331 parser_cpp.add_argument( 332 '-p', 333 '--process', 334 dest='compdb_file_paths', 335 metavar='COMPILATION_DATABASE_FILES', 336 type=Path, 337 nargs='*', 338 help='process a file or several files matching ' 339 'the clang compilation database format', 340 ) 341 parser_cpp.add_argument( 342 '--build-dir', 343 type=Path, 344 help='override the build directory defined in ' 'pw_ide settings', 345 ) 346 parser_cpp.add_argument( 347 '--clangd-command', 348 action='store_true', 349 help='print the command for your system that runs ' 350 'clangd in the activated Pigweed environment', 351 ) 352 parser_cpp.add_argument( 353 '--clangd-command-for', 354 dest='clangd_command_system', 355 metavar='SYSTEM', 356 help='print the command for the specified system ' 357 'that runs clangd in the activated Pigweed ' 358 'environment', 359 ) 360 361 parser_python = add_parser(cmd_python, 'python') 362 parser_python.add_argument( 363 '--venv', 364 dest='should_print_venv', 365 action='store_true', 366 help='print the path to the Pigweed Python ' 'virtual environment', 367 ) 368 369 parser_vscode = add_parser(cmd_vscode, 'vscode') 370 parser_vscode.add_argument( 371 '--include', 372 nargs='+', 373 type=VscSettingsType, 374 metavar='SETTINGS_TYPE', 375 help='update only these settings types', 376 ) 377 parser_vscode.add_argument( 378 '--exclude', 379 nargs='+', 380 type=VscSettingsType, 381 metavar='SETTINGS_TYPE', 382 help='do not update these settings types', 383 ) 384 parser_vscode.add_argument( 385 '--no-override', 386 action='store_true', 387 help='don\'t overwrite existing active ' 'settings files', 388 ) 389 390 parser_clear = add_parser(cmd_clear, 'clear') 391 parser_clear.add_argument( 392 '--compdb', 393 action='store_true', 394 help='delete all compilation database from ' 'the working directory', 395 ) 396 parser_clear.add_argument( 397 '--cache', 398 action='store_true', 399 help='delete all compilation database caches ' 400 'from the working directory', 401 ) 402 parser_clear.add_argument( 403 '--editor', 404 metavar='EDITOR', 405 help='delete the active settings file for ' 406 'the provided supported editor', 407 ) 408 parser_clear.add_argument( 409 '--editor-backups', 410 metavar='EDITOR', 411 help='delete backup settings files for ' 412 'the provided supported editor', 413 ) 414 415 return parser_root 416 417 418def _parse_args() -> argparse.Namespace: 419 args = _build_argument_parser().parse_args() 420 return args 421 422 423def _dispatch_command(func: Callable, **kwargs: Dict[str, Any]) -> int: 424 """Dispatch arguments to a subcommand handler. 425 426 Each CLI subcommand is handled by handler function, which is registered 427 with the subcommand parser with `parser.set_defaults(func=handler)`. 428 By calling this function with the parsed args, the appropriate subcommand 429 handler is called, and the arguments are passed to it as kwargs. 430 """ 431 return func(**kwargs) 432 433 434def parse_args_and_dispatch_command() -> int: 435 return _dispatch_command(**vars(_parse_args())) 436