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