blob: fcca5aac49c61da26fc81071d4b5fb5ac9a5075c [file] [log] [blame]
# 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.
"""pw_ide CLI command handlers."""
import logging
from pathlib import Path
import shlex
import shutil
import subprocess
import sys
from typing import cast, Callable, List, Optional, Set, Tuple, Union
from pw_cli.color import colors
from pw_ide.cpp import (
ClangdSettings,
compdb_generate_file_path,
CppCompilationDatabase,
CppCompilationDatabasesMap,
CppIdeFeaturesState,
delete_compilation_databases,
delete_compilation_database_caches,
MAX_COMMANDS_TARGET_FILENAME,
)
from pw_ide.exceptions import (
BadCompDbException,
InvalidTargetException,
MissingCompDbException,
)
from pw_ide.python import PythonPaths
from pw_ide.settings import (
PigweedIdeSettings,
SupportedEditor,
SupportedEditorName,
)
from pw_ide import vscode
from pw_ide.vscode import VscSettingsManager, VscSettingsType
def _no_color(msg: str) -> str:
return msg
def _split_lines(msg: Union[str, List[str]]) -> Tuple[str, List[str]]:
"""Turn a list of strings into a tuple of the first and list of rest."""
if isinstance(msg, str):
return (msg, [])
return (msg[0], msg[1:])
class StatusReporter:
"""Print user-friendly status reports to the terminal for CLI tools.
The output of ``demo()`` looks something like this, but more colorful:
.. code-block:: none
• FYI, here's some information:
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
Donec condimentum metus molestie metus maximus ultricies ac id dolor.
✓ This is okay, no changes needed.
✓ We changed some things successfully!
⚠ Uh oh, you might want to be aware of this.
❌ This is bad! Things might be broken!
You can instead redirect these lines to logs without formatting by
substituting ``LoggingStatusReporter``. Consumers of this should be
designed to take any subclass and not make assumptions about where the
output will go. But the reason you would choose this over plain logging is
because you want to support pretty-printing to the terminal.
This is also "themable" in the sense that you can subclass this, override
the methods with whatever formatting you want, and supply the subclass to
anything that expects an instance of this.
Key:
- info: Plain ol' informational status.
- ok: Something was checked and it was okay.
- new: Something needed to be changed/updated and it was successfully.
- wrn: Warning, non-critical.
- err: Error, critical.
This doesn't expose the %-style string formatting that is used in idiomatic
Python logging, but this shouldn't be used for performance-critical logging
situations anyway.
"""
def _report( # pylint: disable=no-self-use
self,
msg: Union[str, List[str]],
color: Callable[[str], str],
char: str,
func: Callable,
silent: bool,
) -> None:
"""Actually print/log/whatever the status lines."""
first_line, rest_lines = _split_lines(msg)
first_line = color(f'{char} {first_line}')
spaces = ' ' * len(char)
rest_lines = [color(f'{spaces} {line}') for line in rest_lines]
if not silent:
for line in [first_line, *rest_lines]:
func(line)
def demo(self):
"""Run this to see what your status reporter output looks like."""
self.info(
[
'FYI, here\'s some information:',
'Lorem ipsum dolor sit amet, consectetur adipiscing elit.',
'Donec condimentum metus molestie metus maximus ultricies '
'ac id dolor.',
]
)
self.ok('This is okay, no changes needed.')
self.new('We changed some things successfully!')
self.wrn('Uh oh, you might want to be aware of this.')
self.err('This is bad! Things might be broken!')
def info(self, msg: Union[str, List[str]], silent: bool = False) -> None:
self._report(msg, _no_color, '\u2022', print, silent)
def ok(self, msg: Union[str, List[str]], silent: bool = False) -> None:
self._report(msg, colors().blue, '\u2713', print, silent)
def new(self, msg: Union[str, List[str]], silent: bool = False) -> None:
self._report(msg, colors().green, '\u2713', print, silent)
def wrn(self, msg: Union[str, List[str]], silent: bool = False) -> None:
self._report(msg, colors().yellow, '\u26A0', print, silent)
def err(self, msg: Union[str, List[str]], silent: bool = False) -> None:
self._report(msg, colors().red, '\u274C', print, silent)
class LoggingStatusReporter(StatusReporter):
"""Print status lines to logs instead of to the terminal."""
def __init__(self, logger: logging.Logger) -> None:
self.logger = logger
super().__init__()
def _report(
self,
msg: Union[str, List[str]],
color: Callable[[str], str],
char: str,
func: Callable,
silent: bool,
) -> None:
first_line, rest_lines = _split_lines(msg)
if not silent:
for line in [first_line, *rest_lines]:
func(line)
def info(self, msg: Union[str, List[str]], silent: bool = False) -> None:
self._report(msg, _no_color, '', self.logger.info, silent)
def ok(self, msg: Union[str, List[str]], silent: bool = False) -> None:
self._report(msg, _no_color, '', self.logger.info, silent)
def new(self, msg: Union[str, List[str]], silent: bool = False) -> None:
self._report(msg, _no_color, '', self.logger.info, silent)
def wrn(self, msg: Union[str, List[str]], silent: bool = False) -> None:
self._report(msg, _no_color, '', self.logger.warning, silent)
def err(self, msg: Union[str, List[str]], silent: bool = False) -> None:
self._report(msg, _no_color, '', self.logger.error, silent)
def _make_working_dir(
reporter: StatusReporter, settings: PigweedIdeSettings, quiet: bool = False
) -> None:
if not settings.working_dir.exists():
settings.working_dir.mkdir()
reporter.new(
'Initialized the Pigweed IDE working directory at '
f'{settings.working_dir}'
)
else:
if not quiet:
reporter.ok(
'Pigweed IDE working directory already present at '
f'{settings.working_dir}'
)
def _report_unrecognized_editor(reporter: StatusReporter, editor: str) -> None:
supported_editors = ', '.join(sorted([ed.value for ed in SupportedEditor]))
reporter.wrn(f'Unrecognized editor: {editor}')
reporter.wrn('This may not be an automatically-supported editor.')
reporter.wrn(f'Automatically-supported editors: {supported_editors}')
def cmd_clear(
compdb: bool,
cache: bool,
editor: Optional[SupportedEditorName],
editor_backups: Optional[SupportedEditorName],
silent: bool = False,
reporter: StatusReporter = StatusReporter(),
pw_ide_settings: PigweedIdeSettings = PigweedIdeSettings(),
) -> None:
"""Clear components of the IDE features.
In contrast to the ``reset`` subcommand, ``clear`` allows you to specify
components to delete. You will not need this command under normal
circumstances.
"""
if compdb:
delete_compilation_databases(pw_ide_settings)
reporter.wrn('Cleared compilation databases', silent)
if cache:
delete_compilation_database_caches(pw_ide_settings)
reporter.wrn('Cleared compilation database caches', silent)
if editor is not None:
try:
validated_editor = SupportedEditor(editor)
except ValueError:
_report_unrecognized_editor(reporter, cast(str, editor))
sys.exit(1)
if validated_editor == SupportedEditor.VSCODE:
vsc_settings_manager = VscSettingsManager(pw_ide_settings)
vsc_settings_manager.delete_all_active_settings()
reporter.wrn(
f'Cleared active settings for {validated_editor.value}', silent
)
if editor_backups is not None:
try:
validated_editor = SupportedEditor(editor_backups)
except ValueError:
_report_unrecognized_editor(reporter, cast(str, editor))
sys.exit(1)
if validated_editor == SupportedEditor.VSCODE:
vsc_settings_manager = VscSettingsManager(pw_ide_settings)
vsc_settings_manager.delete_all_backups()
reporter.wrn(
f'Cleared backup settings for {validated_editor.value}',
silent=silent,
)
def cmd_reset(
hard: bool = False,
reporter: StatusReporter = StatusReporter(),
pw_ide_settings: PigweedIdeSettings = PigweedIdeSettings(),
) -> None:
"""Reset IDE settings.
This will clear your .pw_ide working directory and active settings for
supported editors, restoring your repository to a pre-"pw ide setup" state.
Any clangd caches in the working directory will not be removed, so that they
don't need to be generated again later. All backed up supported editor
settings will also be left in place.
Adding the --hard flag will completely delete the .pw_ide directory and all
supported editor backup settings, restoring your repository to a
pre-`pw ide setup` state.
This command does not affect this project's pw_ide and editor settings or
your own pw_ide and editor override settings.
"""
delete_compilation_databases(pw_ide_settings)
vsc_settings_manager = VscSettingsManager(pw_ide_settings)
vsc_settings_manager.delete_all_active_settings()
if hard:
try:
shutil.rmtree(pw_ide_settings.working_dir)
except FileNotFoundError:
pass
vsc_settings_manager.delete_all_backups()
reporter.wrn('Pigweed IDE settings were reset!')
def cmd_setup(
reporter: StatusReporter = StatusReporter(),
pw_ide_settings: PigweedIdeSettings = PigweedIdeSettings(),
) -> None:
"""Set up or update your Pigweed project IDE features.
This will automatically set up your development environment with all the
features that Pigweed IDE supports, with sensible defaults.
At minimum, this command will create the .pw_ide working directory and
create settings files for all supported editors. Projects can define
additional setup steps in .pw_ide.yaml.
When new IDE features are introduced in the future (either by Pigweed or
your downstream project), you can re-run this command to set up the new
features. It will not overwrite or break any of your existing configuration.
"""
_make_working_dir(reporter, pw_ide_settings)
if pw_ide_settings.editor_enabled('vscode'):
cmd_vscode(no_override=True)
for command in pw_ide_settings.setup:
subprocess.run(shlex.split(command))
def cmd_vscode(
include: Optional[List[VscSettingsType]] = None,
exclude: Optional[List[VscSettingsType]] = None,
no_override: bool = False,
reporter: StatusReporter = StatusReporter(),
pw_ide_settings: PigweedIdeSettings = PigweedIdeSettings(),
) -> None:
"""Configure support for Visual Studio Code.
This will replace your current Visual Studio Code (VSC) settings for this
project (in ``.vscode/settings.json``, etc.) with the following sets of
settings, in order:
- The Pigweed default settings
- Your project's settings, if any (in ``.vscode/pw_project_settings.json``)
- Your personal settings, if any (in ``.vscode/pw_user_settings.json``)
In other words, settings files lower on the list can override settings
defined by those higher on the list. Settings defined in the sources above
are not active in VSC until they are merged and output to the current
settings file by running:
.. code-block:: bash
pw ide vscode
Refer to the Visual Studio Code documentation for more information about
these settings: https://code.visualstudio.com/docs/getstarted/settings
This command also manages VSC tasks (``.vscode/tasks.json``) and extensions
(``.vscode/extensions.json``). You can explicitly control which of these
settings types ("settings", "tasks", and "extensions") is modified by
this command by using the ``--include`` or ``--exclude`` options.
Your current VSC settings will never change unless you run ``pw ide``
commands. Since the current VSC settings are an artifact built from the
three settings files described above, you should avoid manually editing
that file; it will be replaced the next time you run ``pw ide vscode``. A
backup of your previous settings file will be made, and you can diff it
against the new file to see what changed.
These commands will never modify your VSC user settings, which are
stored outside of the project repository and apply globally to all VSC
instances.
The settings files are system-specific and shouldn't be checked into the
repository, except for the project settings (those with ``pw_project_``),
which can be used to define consistent settings for everyone working on the
project.
Note that support for VSC can be disabled at the project level or the user
level by adding the following to .pw_ide.yaml or .pw_ide.user.yaml
respectively:
.. code-block:: yaml
editors:
vscode: false
Likewise, it can be enabled by setting that value to true. It is enabled by
default.
"""
if not pw_ide_settings.editor_enabled('vscode'):
reporter.wrn('Visual Studio Code support is disabled in settings!')
sys.exit(1)
if not vscode.DEFAULT_SETTINGS_PATH.exists():
vscode.DEFAULT_SETTINGS_PATH.mkdir()
vsc_manager = VscSettingsManager(pw_ide_settings)
if include is None:
include_set = set(VscSettingsType.all())
else:
include_set = set(include)
if exclude is None:
exclude_set: Set[VscSettingsType] = set()
else:
exclude_set = set(exclude)
types_to_update = cast(
List[VscSettingsType], tuple(include_set - exclude_set)
)
for settings_type in types_to_update:
active_settings_existed = vsc_manager.active(settings_type).is_present()
if no_override and active_settings_existed:
reporter.ok(
f'Visual Studio Code active {settings_type.value} '
'already present; will not overwrite'
)
else:
with vsc_manager.active(settings_type).modify(
reinit=True
) as active_settings:
vsc_manager.default(settings_type).sync_to(active_settings)
vsc_manager.project(settings_type).sync_to(active_settings)
vsc_manager.user(settings_type).sync_to(active_settings)
verb = 'Updated' if active_settings_existed else 'Created'
reporter.new(
f'{verb} Visual Studio Code active ' f'{settings_type.value}'
)
# TODO(chadnorvell): Break up this function.
# The linting errors are a nuisance but they're beginning to have a point.
def cmd_cpp( # pylint: disable=too-many-arguments, too-many-locals, too-many-branches, too-many-statements
should_list_targets: bool,
should_get_target: bool,
target_to_set: Optional[str],
compdb_file_paths: Optional[List[Path]],
build_dir: Optional[Path],
use_default_target: bool = False,
should_run_ninja: bool = False,
should_run_gn: bool = False,
override_current_target: bool = True,
clangd_command: bool = False,
clangd_command_system: Optional[str] = None,
reporter: StatusReporter = StatusReporter(),
pw_ide_settings: PigweedIdeSettings = PigweedIdeSettings(),
) -> None:
"""Configure C/C++ code intelligence support.
Code intelligence can be provided by clangd or other language servers that
use the clangd compilation database format, defined at:
https://clang.llvm.org/docs/JSONCompilationDatabase.html
This command helps you use clangd with Pigweed projects, which use multiple
toolchains within a distinct environment, and often define multiple targets.
This means compilation units are likely have multiple compile commands, and
clangd is not equipped to deal with this out of the box. We handle this by:
- Processing the compilation database produced the build system into
multiple internally-consistent compilation databases, one for each target
(where a "target" is a particular build for a particular system using a
particular toolchain).
- Providing commands to select which target you want to use for code
analysis.
Refer to the Pigweed documentation or your build system's documentation to
learn how to produce a clangd compilation database. Once you have one, run
this command to process it (or provide a glob to process multiple):
.. code-block:: bash
pw ide cpp --process {path to compile_commands.json}
If you're using GN to generate the compilation database, you can do that and
process it in a single command:
.. code-block:: bash
pw ide cpp --gn
You can do the same for a Ninja build (whether it was generated by GN or
another way):
.. code-block:: bash
pw ide cpp --ninja
You can now examine the targets that are available to you:
.. code-block:: bash
pw ide cpp --list
... and select the target you want to use:
.. code-block:: bash
pw ide cpp --set host_clang
As long as your editor or language server plugin is properly configured, you
will now get code intelligence features relevant to that particular target.
You can see what target is selected by running:
.. code-block:: bash
pw ide cpp
Whenever you switch to a target you haven't used before, clangd will need to
index the build, which may take several minutes. These indexes are cached,
so you can switch between targets without re-indexing each time.
If your build configuration changes significantly (e.g. you add a new file
to the project), you will need to re-process the compilation database for
that change to be recognized. Your target selection will not change, and
your index will only need to be incrementally updated.
You can generate the clangd command your editor needs to run with:
.. code-block:: bash
pw ide cpp --clangd-command
If your editor uses JSON for configuration, you can export the same command
in that format:
.. code-block:: bash
pw ide cpp --clangd-command-for json
"""
_make_working_dir(reporter, pw_ide_settings, quiet=True)
# If true, no arguments were provided so we do the default behavior.
default = True
build_dir = (
build_dir if build_dir is not None else pw_ide_settings.build_dir
)
if compdb_file_paths is not None:
should_process = True
if len(compdb_file_paths) == 0:
compdb_file_paths = pw_ide_settings.compdb_paths_expanded
else:
should_process = False
# This simplifies typing in the rest of this method. We rely on
# `should_process` instead of the status of this variable.
compdb_file_paths = []
# Order of operations matters from here on. It should be possible to run
# a build system command to generate a compilation database, then process
# the compilation database, then successfully set the target in a single
# command.
# Use Ninja to generate the initial compile_commands.json
if should_run_ninja:
default = False
ninja_commands = ['ninja', '-t', 'compdb']
reporter.info(f'Running Ninja: {" ".join(ninja_commands)}')
output_compdb_file_path = build_dir / compdb_generate_file_path()
try:
# Ninja writes to STDOUT, so we capture to a file.
with open(output_compdb_file_path, 'w') as compdb_file:
result = subprocess.run(
ninja_commands,
cwd=build_dir,
stdout=compdb_file,
stderr=subprocess.PIPE,
)
except FileNotFoundError:
reporter.err(f'Could not open path! {str(output_compdb_file_path)}')
if result.returncode == 0:
reporter.info('Ran Ninja successfully!')
should_process = True
compdb_file_paths.append(output_compdb_file_path)
else:
reporter.err('Something went wrong!')
# Convert from bytes and remove trailing newline
err = result.stderr.decode().split('\n')[:-1]
for line in err:
reporter.err(line)
sys.exit(1)
# Use GN to generate the initial compile_commands.json
if should_run_gn:
default = False
gn_commands = ['gn', 'gen', str(build_dir), '--export-compile-commands']
try:
with open(build_dir / 'args.gn') as args_file:
gn_args = [
line
for line in args_file.readlines()
if not line.startswith('#')
]
except FileNotFoundError:
gn_args = []
gn_args_string = 'none' if len(gn_args) == 0 else ', '.join(gn_args)
reporter.info(
[f'Running GN: {" ".join(gn_commands)} (args: {gn_args_string})']
)
result = subprocess.run(gn_commands, capture_output=True)
gn_status_lines = ['Ran GN successfully!']
if result.returncode == 0:
# Convert from bytes and remove trailing newline
out = result.stdout.decode().split('\n')[:-1]
for line in out:
gn_status_lines.append(line)
reporter.info(gn_status_lines)
should_process = True
output_compdb_file_path = build_dir / compdb_generate_file_path()
compdb_file_paths.append(output_compdb_file_path)
else:
reporter.err('Something went wrong!')
# Convert from bytes and remove trailing newline
err = result.stderr.decode().split('\n')[:-1]
for line in err:
reporter.err(line)
sys.exit(1)
if should_process:
default = False
prev_targets = len(CppIdeFeaturesState(pw_ide_settings))
compdb_databases: List[CppCompilationDatabasesMap] = []
last_processed_path = Path()
for compdb_file_path in compdb_file_paths:
# If the path is a dir, append the default compile commands
# file name.
if compdb_file_path.is_dir():
compdb_file_path /= compdb_generate_file_path()
try:
compdb_databases.append(
CppCompilationDatabase.load(
Path(compdb_file_path), build_dir
).process(
settings=pw_ide_settings,
path_globs=pw_ide_settings.clangd_query_drivers(),
)
)
except MissingCompDbException:
reporter.err(f'File not found: {str(compdb_file_path)}')
if '*' in str(compdb_file_path):
reporter.wrn(
'It looks like you provided a glob that '
'did not match any files.'
)
sys.exit(1)
# TODO(chadnorvell): Recover more gracefully from errors.
except BadCompDbException:
reporter.err(
'File does not match compilation database format: '
f'{str(compdb_file_path)}'
)
sys.exit(1)
last_processed_path = compdb_file_path
if len(compdb_databases) == 0:
reporter.err(
'No compilation databases found in: '
f'{str(compdb_file_paths)}'
)
sys.exit(1)
try:
CppCompilationDatabasesMap.merge(*compdb_databases).write()
except TypeError:
reporter.err('Could not serialize file to JSON!')
total_targets = len(CppIdeFeaturesState(pw_ide_settings))
new_targets = total_targets - prev_targets
if len(compdb_file_paths) == 1:
processed_text = str(last_processed_path)
else:
processed_text = f'{len(compdb_file_paths)} compilation databases'
reporter.new(
[
f'Processed {processed_text} '
f'to {pw_ide_settings.working_dir}',
f'{total_targets} targets are now available '
f'({new_targets} are new)',
]
)
if use_default_target:
defined_default = pw_ide_settings.default_target
max_commands_target: Optional[str] = None
try:
with open(
pw_ide_settings.working_dir / MAX_COMMANDS_TARGET_FILENAME
) as max_commands_target_file:
max_commands_target = max_commands_target_file.readline()
except FileNotFoundError:
pass
if defined_default is None and max_commands_target is None:
reporter.err('Can\'t use default target because none is defined!')
reporter.wrn('Have you processed a compilation database yet?')
sys.exit(1)
target_to_set = (
defined_default
if defined_default is not None
else max_commands_target
)
if target_to_set is not None:
default = False
# Always set the target if it's not already set, but if it is,
# respect the --no-override flag.
should_set_target = (
CppIdeFeaturesState(pw_ide_settings).current_target is None
or override_current_target
)
if should_set_target:
try:
CppIdeFeaturesState(
pw_ide_settings
).current_target = target_to_set
except InvalidTargetException:
reporter.err(
[
f'Invalid target! {target_to_set} not among the '
'defined targets.',
'Check .pw_ide.yaml or .pw_ide.user.yaml for defined '
'targets.',
]
)
sys.exit(1)
except MissingCompDbException:
reporter.err(
[
f'File not found for target! {target_to_set}',
'Did you run pw ide cpp --process '
'{path to compile_commands.json}?',
]
)
sys.exit(1)
reporter.new(
'Set C/C++ language server analysis target to: '
f'{target_to_set}'
)
else:
reporter.ok(
'Target already is set and will not be overridden: '
f'{CppIdeFeaturesState(pw_ide_settings).current_target}'
)
if clangd_command:
default = False
reporter.info(
[
'Command to run clangd with Pigweed paths:',
ClangdSettings(pw_ide_settings).command(),
]
)
if clangd_command_system is not None:
default = False
reporter.info(
[
'Command to run clangd with Pigweed paths for '
f'{clangd_command_system}:',
ClangdSettings(pw_ide_settings).command(clangd_command_system),
]
)
if should_list_targets:
default = False
targets_list_status = [
'C/C++ targets available for language server analysis:'
]
for target in sorted(
CppIdeFeaturesState(pw_ide_settings).enabled_available_targets
):
targets_list_status.append(f'\t{target}')
reporter.info(targets_list_status)
if should_get_target or default:
reporter.info(
'Current C/C++ language server analysis target: '
f'{CppIdeFeaturesState(pw_ide_settings).current_target}'
)
def cmd_python(
should_print_venv: bool, reporter: StatusReporter = StatusReporter()
) -> None:
"""Configure Python code intelligence support.
You can generate the path to the Python virtual environment interpreter that
your editor/language server should use with:
.. code-block:: bash
pw ide python --venv
"""
# If true, no arguments were provided and we should do the default
# behavior.
default = True
if should_print_venv or default:
reporter.info(
[
'Location of the Pigweed Python virtual environment:',
PythonPaths().interpreter,
]
)