xref: /aosp_15_r20/external/pigweed/pw_ide/py/pw_ide/cli.py (revision 61c4878ac05f98d0ceed94b57d316916de578985)
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