• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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