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