aboutsummaryrefslogtreecommitdiff
path: root/pw_ide/py/pw_ide/cli.py
blob: 999de3056a09b4a6238cd0d33f8dad2962a2a44d (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
# Copyright 2022 The Pigweed Authors
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may not
# use this file except in compliance with the License. You may obtain a copy of
# the License at
#
#     https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under
# the License.
"""CLI tools for pw_ide."""

import argparse
import enum
from inspect import cleandoc
import re
from typing import Any, Callable, Dict, List, Optional, Protocol

from pw_ide.commands import (
    cmd_cpp,
    cmd_python,
    cmd_setup,
    cmd_sync,
    cmd_vscode,
)

from pw_ide.vscode import VscSettingsType


def _get_docstring(obj: Any) -> Optional[str]:
    doc: Optional[str] = getattr(obj, '__doc__', None)
    return doc


class _ParsedDocstring:
    """Parses help content out of a standard docstring."""

    def __init__(self, obj: Any) -> None:
        self.description = ''
        self.epilog = ''

        if obj is not None and (doc := _get_docstring(obj)) is not None:
            lines = doc.split('\n')
            self.description = lines.pop(0)

            # Eliminate the blank line between the summary and the main content
            if len(lines) > 0:
                lines.pop(0)

            self.epilog = cleandoc('\n'.join(lines))


class SphinxStripperState(enum.Enum):
    SEARCHING = 0
    COLLECTING = 1
    HANDLING = 2


class SphinxStripper:
    """Strip Sphinx directives from text.

    The caller can provide an object with methods named _handle_directive_{}
    to handle specific directives. Otherwise, the default will apply.

    Feed text line by line to .process(line), then get the processed text back
    with .result().
    """

    def __init__(self, handler: Any) -> None:
        self.handler = handler
        self.directive: str = ''
        self.tag: str = ''
        self.lines_to_handle: List[str] = []
        self.handled_lines: List[str] = []
        self._prev_state: SphinxStripperState = SphinxStripperState.SEARCHING
        self._curr_state: SphinxStripperState = SphinxStripperState.SEARCHING

    @property
    def state(self) -> SphinxStripperState:
        return self._curr_state

    @state.setter
    def state(self, value: SphinxStripperState) -> None:
        self._prev_state = self._curr_state
        self._curr_state = value

    def search_for_directives(self, line: str) -> None:
        match = re.search(
            r'^\.\.\s*(?P<directive>[\-\w]+)::\s*(?P<tag>[\-\w]+)$', line
        )

        if match is not None:
            self.directive = match.group('directive')
            self.tag = match.group('tag')
            self.state = SphinxStripperState.COLLECTING
        else:
            self.handled_lines.append(line)

    def collect_lines(self, line) -> None:
        # Collect lines associated with a directive, including blank lines in
        # the middle of the directive text, but not the blank line between the
        # directive and the start of its text.
        if not (line.strip() == '' and len(self.lines_to_handle) == 0):
            self.lines_to_handle.append(line)

    def handle_lines(self, line: str = '') -> None:
        handler_fn = f'_handle_directive_{self.directive.replace("-", "_")}'

        self.handled_lines.extend(
            getattr(self.handler, handler_fn, lambda _, s: s)(
                self.tag, self.lines_to_handle
            )
        )

        self.handled_lines.append(line)
        self.lines_to_handle = []
        self.state = SphinxStripperState.SEARCHING

    def process_line(self, line: str) -> None:
        if self.state == SphinxStripperState.SEARCHING:
            self.search_for_directives(line)

        else:
            if self.state == SphinxStripperState.COLLECTING:
                # Assume that indented text below the directive is associated
                # with the directive.
                if line.strip() == '' or line[0] in (' ', '\t'):
                    self.collect_lines(line)
                # When we encounter non-indented text, we're done with this
                # directive.
                else:
                    self.state = SphinxStripperState.HANDLING

            if self.state == SphinxStripperState.HANDLING:
                self.handle_lines(line)

    def result(self) -> str:
        if self.state == SphinxStripperState.COLLECTING:
            self.state = SphinxStripperState.HANDLING
            self.handle_lines()

        return '\n'.join(self.handled_lines)


class RawDescriptionSphinxStrippedHelpFormatter(
    argparse.RawDescriptionHelpFormatter
):
    """An argparse formatter that strips Sphinx directives.

    CLI command docstrings can contain Sphinx directives for rendering in docs.
    But we don't want to include those directives when printing to the terminal.
    So we strip them and, if appropriate, replace them with something better
    suited to terminal output.
    """

    def _reformat(self, text: str) -> str:
        """Given a block of text, replace Sphinx directives.

        Directive handlers will be provided with the directive name, its tag,
        and all of the associated lines of text. "Association" is determined by
        those lines being indented to any degree under the directive.

        Unhandled directives will only have the directive line removed.
        """
        sphinx_stripper = SphinxStripper(self)

        for line in text.splitlines():
            sphinx_stripper.process_line(line)

        # The space at the end prevents the final blank line from being stripped
        # by argparse, which provides breathing room between the text and the
        # prompt.
        return sphinx_stripper.result() + ' '

    def _format_text(self, text: str) -> str:
        # This overrides an arparse method that is not technically a public API.
        return super()._format_text(self._reformat(text))

    def _handle_directive_code_block(  # pylint: disable=no-self-use
        self, tag: str, lines: List[str]
    ) -> List[str]:
        if tag == 'bash':
            processed_lines = []

            for line in lines:
                if line.strip() == '':
                    processed_lines.append(line)
                else:
                    stripped_line = line.lstrip()
                    indent = len(line) - len(stripped_line)
                    spaces = ' ' * indent
                    processed_line = f'{spaces}$ {stripped_line}'
                    processed_lines.append(processed_line)

            return processed_lines

        return lines


class _ParserAdder(Protocol):
    """Return type for _parser_adder.

    Essentially expresses the type of __call__, which cannot be expressed in
    type annotations.
    """

    def __call__(
        self, subcommand_handler: Callable[..., None], *args: Any, **kwargs: Any
    ) -> argparse.ArgumentParser:
        ...


def _parser_adder(subcommand_parser) -> _ParserAdder:
    """Create subcommand parsers with a consistent format.

    When given a subcommand handler, this will produce a parser that pulls the
    description, help, and epilog values from its docstring, and passes parsed
    args on to to the function.

    Create a subcommand parser, then feed it to this to get an `add_parser`
    function:

    .. code-block:: python

        subcommand_parser = parser_root.add_subparsers(help='Subcommands')
        add_parser = _parser_adder(subcommand_parser)

    Then use `add_parser` instead of `subcommand_parser.add_parser`.
    """

    def _add_parser(
        subcommand_handler: Callable[..., None], *args, **kwargs
    ) -> argparse.ArgumentParser:
        doc = _ParsedDocstring(subcommand_handler)
        default_kwargs = dict(
            # Displayed in list of subcommands
            description=doc.description,
            # Displayed as top-line summary for this subcommand's help
            help=doc.description,
            # Displayed as detailed help text for this subcommand's help
            epilog=doc.epilog,
            # Ensures that formatting is preserved and Sphinx directives are
            # stripped out when printing to the terminal
            formatter_class=RawDescriptionSphinxStrippedHelpFormatter,
        )

        new_kwargs = {**default_kwargs, **kwargs}
        parser = subcommand_parser.add_parser(*args, **new_kwargs)
        parser.set_defaults(func=subcommand_handler)
        return parser

    return _add_parser


def _build_argument_parser() -> argparse.ArgumentParser:
    parser_root = argparse.ArgumentParser(prog='pw ide', description=__doc__)

    parser_root.set_defaults(
        func=lambda *_args, **_kwargs: parser_root.print_help()
    )

    subcommand_parser = parser_root.add_subparsers(help='Subcommands')
    add_parser = _parser_adder(subcommand_parser)

    add_parser(cmd_sync, 'sync')
    add_parser(cmd_setup, 'setup')

    parser_cpp = add_parser(cmd_cpp, 'cpp')
    parser_cpp.add_argument(
        '-l',
        '--list',
        dest='should_list_targets',
        action='store_true',
        help='list the target toolchains available for C/C++ language analysis',
    )
    parser_cpp.add_argument(
        '-g',
        '--get',
        dest='should_get_target',
        action='store_true',
        help=(
            'print the current target toolchain '
            'used for C/C++ language analysis'
        ),
    )
    parser_cpp.add_argument(
        '-s',
        '--set',
        dest='target_to_set',
        metavar='TARGET',
        help=(
            'set the target toolchain to '
            'use for C/C++ language server analysis'
        ),
    )
    parser_cpp.add_argument(
        '--set-default',
        dest='use_default_target',
        action='store_true',
        help=(
            'set the C/C++ analysis target toolchain to the default '
            'defined in pw_ide settings'
        ),
    )
    parser_cpp.add_argument(
        '-p',
        '--process',
        action='store_true',
        help='process a file or several files matching '
        'the clang compilation database format',
    )
    parser_cpp.add_argument(
        '--clangd-command',
        action='store_true',
        help='print the command for your system that runs '
        'clangd in the activated Pigweed environment',
    )
    parser_cpp.add_argument(
        '--clangd-command-for',
        dest='clangd_command_system',
        metavar='SYSTEM',
        help='print the command for the specified system '
        'that runs clangd in the activated Pigweed '
        'environment',
    )

    parser_python = add_parser(cmd_python, 'python')
    parser_python.add_argument(
        '--venv',
        dest='should_print_venv',
        action='store_true',
        help='print the path to the Pigweed Python virtual environment',
    )
    parser_python.add_argument(
        '--install-editable',
        metavar='MODULE',
        help='install a Pigweed Python module in editable mode',
    )

    parser_vscode = add_parser(cmd_vscode, 'vscode')
    parser_vscode.add_argument(
        '--include',
        nargs='+',
        type=VscSettingsType,
        metavar='SETTINGS_TYPE',
        help='update only these settings types',
    )
    parser_vscode.add_argument(
        '--exclude',
        nargs='+',
        type=VscSettingsType,
        metavar='SETTINGS_TYPE',
        help='do not update these settings types',
    )
    parser_vscode.add_argument(
        '--install-extension',
        dest='should_install_extension',
        action='store_true',
        help='install the experimental extension',
    )

    return parser_root


def _parse_args() -> argparse.Namespace:
    args = _build_argument_parser().parse_args()
    return args


def _dispatch_command(func: Callable, **kwargs: Dict[str, Any]) -> int:
    """Dispatch arguments to a subcommand handler.

    Each CLI subcommand is handled by handler function, which is registered
    with the subcommand parser with `parser.set_defaults(func=handler)`.
    By calling this function with the parsed args, the appropriate subcommand
    handler is called, and the arguments are passed to it as kwargs.
    """
    return func(**kwargs)


def parse_args_and_dispatch_command() -> int:
    return _dispatch_command(**vars(_parse_args()))