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