pw_ide: add init & info subcommands
Change-Id: I062915c5c6a028e8543396aadd4b2925e78eccf2
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/110257
Reviewed-by: Anthony DiGirolamo <tonymd@google.com>
Commit-Queue: Chad Norvell <chadnorvell@google.com>
diff --git a/pw_ide/docs.rst b/pw_ide/docs.rst
index ebc11a8..d6ed778 100644
--- a/pw_ide/docs.rst
+++ b/pw_ide/docs.rst
@@ -23,6 +23,12 @@
``pw_strict_host_clang_debug`` target in a directory with that name in the
``out`` directory. So that becomes the canonical name for that target.
+Setup
+=====
+The working directory can be created by running ``pw ide init``, although it is
+rarely necessary to run this command manually; other subcommands will initialize
+if needed. You can also clear out the working directory with ``pw ide clean``.
+
C++ Code Intelligence via ``clangd``
====================================
`clangd <https://clangd.llvm.org/>`_ is a language server that provides C/C++
diff --git a/pw_ide/py/BUILD.gn b/pw_ide/py/BUILD.gn
index 2f7d093..0165529 100644
--- a/pw_ide/py/BUILD.gn
+++ b/pw_ide/py/BUILD.gn
@@ -33,6 +33,7 @@
"pw_ide/symlinks.py",
]
tests = [
+ "commands_test.py",
"cpp_test.py",
"test_cases.py",
]
diff --git a/pw_ide/py/commands_test.py b/pw_ide/py/commands_test.py
new file mode 100644
index 0000000..9fbc413f
--- /dev/null
+++ b/pw_ide/py/commands_test.py
@@ -0,0 +1,141 @@
+# 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.
+"""Tests for pw_ide.commands"""
+
+import os
+import unittest
+
+from pw_ide.commands import cmd_init
+from pw_ide.cpp import CLANGD_WRAPPER_FILE_NAME
+from pw_ide.python import PYTHON_BIN_DIR_SYMLINK_NAME, PYTHON_SYMLINK_NAME
+from pw_ide.settings import PW_IDE_DIR_NAME
+
+from test_cases import PwIdeTestCase
+
+
+class TestCmdInit(PwIdeTestCase):
+ """Tests cmd_init"""
+ def test_make_dir_does_not_exist_creates_dir(self):
+ settings = self.make_ide_settings(working_dir=PW_IDE_DIR_NAME)
+ self.assertFalse(settings.working_dir.exists())
+ cmd_init(make_dir=True,
+ make_clangd_wrapper=False,
+ make_python_symlink=False,
+ silent=True,
+ settings=settings)
+ self.assertTrue(settings.working_dir.exists())
+
+ def test_make_dir_does_exist_is_idempotent(self):
+ settings = self.make_ide_settings(working_dir=PW_IDE_DIR_NAME)
+ cmd_init(make_dir=True,
+ make_clangd_wrapper=False,
+ make_python_symlink=False,
+ silent=True,
+ settings=settings)
+ modified_when_1 = os.path.getmtime(settings.working_dir)
+ cmd_init(make_dir=True,
+ make_clangd_wrapper=False,
+ make_python_symlink=False,
+ silent=True,
+ settings=settings)
+ modified_when_2 = os.path.getmtime(settings.working_dir)
+ self.assertEqual(modified_when_1, modified_when_2)
+
+ def test_make_clangd_wrapper_does_not_exist_creates_wrapper(self):
+ settings = self.make_ide_settings()
+ clangd_wrapper_path = settings.working_dir / CLANGD_WRAPPER_FILE_NAME
+ self.assertFalse(clangd_wrapper_path.exists())
+ cmd_init(make_dir=True,
+ make_clangd_wrapper=True,
+ make_python_symlink=False,
+ silent=True,
+ settings=settings)
+ self.assertTrue(clangd_wrapper_path.exists())
+
+ def test_make_clangd_wrapper_does_exist_is_idempotent(self):
+ settings = self.make_ide_settings()
+ clangd_wrapper_path = settings.working_dir / CLANGD_WRAPPER_FILE_NAME
+ cmd_init(make_dir=True,
+ make_clangd_wrapper=True,
+ make_python_symlink=False,
+ silent=True,
+ settings=settings)
+ modified_when_1 = os.path.getmtime(clangd_wrapper_path)
+ cmd_init(make_dir=True,
+ make_clangd_wrapper=True,
+ make_python_symlink=False,
+ silent=True,
+ settings=settings)
+ modified_when_2 = os.path.getmtime(clangd_wrapper_path)
+ self.assertEqual(modified_when_1, modified_when_2)
+
+ def test_make_python_symlink_does_not_exist_creates_symlink(self):
+ settings = self.make_ide_settings()
+ python_symlink_path = settings.working_dir / PYTHON_SYMLINK_NAME
+ python_bin_dir_symlink_path = (settings.working_dir /
+ PYTHON_BIN_DIR_SYMLINK_NAME)
+ self.assertFalse(python_symlink_path.exists())
+ self.assertFalse(python_bin_dir_symlink_path.exists())
+ cmd_init(make_dir=True,
+ make_clangd_wrapper=False,
+ make_python_symlink=True,
+ silent=True,
+ settings=settings)
+ self.assertTrue(python_symlink_path.exists())
+ self.assertTrue(python_bin_dir_symlink_path.exists())
+
+ def test_make_python_symlink_does_exist_is_idempotent(self):
+ settings = self.make_ide_settings()
+ python_symlink_path = settings.working_dir / PYTHON_SYMLINK_NAME
+ python_bin_dir_symlink_path = (settings.working_dir /
+ PYTHON_BIN_DIR_SYMLINK_NAME)
+ cmd_init(make_dir=True,
+ make_clangd_wrapper=False,
+ make_python_symlink=True,
+ silent=True,
+ settings=settings)
+ python_modified_when_1 = os.path.getmtime(python_symlink_path)
+ python_bin_modified_when_1 = os.path.getmtime(
+ python_bin_dir_symlink_path)
+ cmd_init(make_dir=True,
+ make_clangd_wrapper=False,
+ make_python_symlink=True,
+ silent=True,
+ settings=settings)
+ python_modified_when_2 = os.path.getmtime(python_symlink_path)
+ python_bin_modified_when_2 = os.path.getmtime(
+ python_bin_dir_symlink_path)
+ self.assertEqual(python_modified_when_1, python_modified_when_2)
+ self.assertEqual(python_bin_modified_when_1,
+ python_bin_modified_when_2)
+
+ def test_do_everything(self):
+ settings = self.make_ide_settings()
+ cmd_init(make_dir=True,
+ make_clangd_wrapper=True,
+ make_python_symlink=True,
+ silent=True,
+ settings=settings)
+ self.assertTrue(settings.working_dir.exists())
+ self.assertTrue(
+ (settings.working_dir / CLANGD_WRAPPER_FILE_NAME).exists())
+ self.assertTrue(
+ (settings.working_dir / CLANGD_WRAPPER_FILE_NAME).exists())
+ self.assertTrue((settings.working_dir / PYTHON_SYMLINK_NAME).exists())
+ self.assertTrue(
+ (settings.working_dir / PYTHON_BIN_DIR_SYMLINK_NAME).exists())
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/pw_ide/py/pw_ide/__main__.py b/pw_ide/py/pw_ide/__main__.py
index f15efbf..fdc11dd 100644
--- a/pw_ide/py/pw_ide/__main__.py
+++ b/pw_ide/py/pw_ide/__main__.py
@@ -18,7 +18,7 @@
import sys
from typing import (Any, Callable, cast, Dict, Generic, NoReturn, Optional,
TypeVar, Union)
-from pw_ide.commands import cmd_cpp, cmd_python
+from pw_ide.commands import (cmd_info, cmd_init, cmd_cpp, cmd_python)
# TODO(chadnorvell): Move this docstring-as-argparse-docs functionality
# to pw_cli.
@@ -128,6 +128,59 @@
subcommand_parser = parser_root.add_subparsers(help='Subcommands')
+ parser_info = subcommand_parser.add_parser(
+ 'info',
+ description=_docstring_summary(cmd_info.__doc__),
+ help=_reflow_docstring(cmd_info.__doc__))
+ parser_info.add_argument('--working-dir',
+ dest='working_dir',
+ action='store_true',
+ help='Report Pigweed IDE working directory.')
+ parser_info.add_argument('--available-compdbs',
+ dest='available_compdbs',
+ action='store_true',
+ help='Report the compilation databases currently '
+ 'in the working directory.')
+ parser_info.add_argument('--available-targets',
+ dest='available_targets',
+ action='store_true',
+ help='Report all available targets, which are '
+ 'targets that are defined in .pw_ide.yaml '
+ 'and have compilation databases in the '
+ 'working directory. This is equivalent to: '
+ 'pw ide cpp --list')
+ parser_info.add_argument('--defined-targets',
+ dest='defined_targets',
+ action='store_true',
+ help='Report all defined targets, which are '
+ 'targets that are defined in .pw_ide.yaml.')
+ parser_info.add_argument('--compdb-targets',
+ dest='compdb_file_for_targets',
+ type=Path,
+ metavar='COMPILATION_DATABASE',
+ help='Report all of the targets found in the '
+ 'provided compilation database.')
+ parser_info.set_defaults(func=cmd_info)
+
+ parser_init = subcommand_parser.add_parser(
+ 'init',
+ description=_docstring_summary(cmd_init.__doc__),
+ help=_reflow_docstring(cmd_init.__doc__))
+ parser_init.add_argument('--dir',
+ dest='make_dir',
+ action='store_true',
+ help='Create the Pigweed IDE working directory.')
+ parser_init.add_argument('--clangd-wrapper',
+ dest='make_clangd_wrapper',
+ action='store_true',
+ help='Create a wrapper script for clangd.')
+ parser_init.add_argument('--python-symlink',
+ dest='make_python_symlink',
+ action='store_true',
+ help='Create a symlink to the Python virtual '
+ 'environment.')
+ parser_init.set_defaults(func=cmd_init)
+
parser_cpp = subcommand_parser.add_parser(
'cpp',
description=_docstring_summary(cmd_cpp.__doc__),
diff --git a/pw_ide/py/pw_ide/commands.py b/pw_ide/py/pw_ide/commands.py
index 1b15086..e37eb7c 100644
--- a/pw_ide/py/pw_ide/commands.py
+++ b/pw_ide/py/pw_ide/commands.py
@@ -16,26 +16,54 @@
from pathlib import Path
import platform
import sys
-from typing import Optional
+from typing import Callable, Optional
-from pw_ide.cpp import (get_defined_available_targets, get_target,
- process_compilation_database, set_target,
- write_compilation_databases)
+from pw_ide.cpp import (CLANGD_WRAPPER_FILE_NAME,
+ aggregate_compilation_database_targets,
+ get_available_compdbs, get_available_targets,
+ get_defined_available_targets, get_target,
+ make_clangd_script, process_compilation_database,
+ set_target, write_compilation_databases,
+ write_clangd_wrapper_script)
from pw_ide.exceptions import (BadCompDbException, InvalidTargetException,
MissingCompDbException,
UnsupportedPlatformException)
-from pw_ide.python import get_python_venv_path
+from pw_ide.python import (PYTHON_SYMLINK_NAME, create_python_symlink,
+ get_python_venv_path)
from pw_ide.settings import IdeSettings
+# TODO(b/246850113): Replace prints with pw_cli.logs
+def _print_working_dir(settings: IdeSettings) -> None:
+ print(f'Pigweed IDE working directory: {str(settings.working_dir)}\n')
+
+
def _print_current_target(settings: IdeSettings) -> None:
print('Current C/C++ language server analysis target: '
f'{get_target(settings)}\n')
+def _print_defined_targets(settings: IdeSettings) -> None:
+ print('C/C++ targets defined in .pw_ide.yaml:')
+
+ for target in settings.targets:
+ print(f'\t{target}')
+
+ print('')
+
+
+def _print_available_targets(settings: IdeSettings) -> None:
+ print('C/C++ targets available for language server analysis:')
+
+ for toolchain in sorted(get_available_targets(settings)):
+ print(f'\t{toolchain}')
+
+ print('')
+
+
def _print_defined_available_targets(settings: IdeSettings) -> None:
print('C/C++ targets available for language server analysis:')
@@ -45,6 +73,29 @@
print('')
+def _print_available_compdbs(settings: IdeSettings) -> None:
+ print('C/C++ compilation databases in the working directory:')
+
+ for compdb, cache in get_available_compdbs(settings):
+ output = compdb.name
+
+ if cache is not None:
+ output += f'\n\t\tcache: {str(cache.name)}'
+
+ print(f'\t{output}')
+
+ print('')
+
+
+def _print_compdb_targets(compdb_file: Path) -> None:
+ print(f'Unique targets in {str(compdb_file)}:')
+
+ for target in sorted(aggregate_compilation_database_targets(compdb_file)):
+ print(f'\t{target}')
+
+ print('')
+
+
def _print_python_venv_path() -> None:
print('Python virtual environment path: ' f'{get_python_venv_path()}\n')
@@ -55,6 +106,94 @@
print(f'Failed to {msg} on this unsupported platform: {system}\n')
+def cmd_info(available_compdbs: bool,
+ available_targets: bool,
+ defined_targets: bool,
+ working_dir: bool,
+ compdb_file_for_targets: Path = None,
+ settings: IdeSettings = IdeSettings()):
+ """Report diagnostic info about Pigweed IDE features."""
+ if working_dir:
+ _print_working_dir(settings)
+
+ if defined_targets:
+ _print_defined_targets(settings)
+
+ if available_compdbs:
+ _print_available_compdbs(settings)
+
+ if available_targets:
+ _print_available_targets(settings)
+
+ if compdb_file_for_targets is not None:
+ _print_compdb_targets(compdb_file_for_targets)
+
+
+def cmd_init(
+ make_dir: bool,
+ make_clangd_wrapper: bool,
+ make_python_symlink: bool,
+ silent: bool = False,
+ settings: IdeSettings = IdeSettings()) -> None:
+ """Create IDE features working directory and supporting files.
+
+ When called without arguments, this creates the Pigweed IDE features working
+ directory defined in the settings file and ensures that further `pw_ide`
+ commands work as expected by creating all the other IDE infrastructure.
+
+ This command is idempotent, so it's safe to run it prophylactically or as a
+ precursor to other commands to ensure that the Pigweed IDE features are in a
+ working state.
+ """
+
+ maybe_print: Callable[[str], None] = print
+
+ if silent:
+ maybe_print = lambda _: None
+
+ # If no flags were provided, do everything.
+ if not make_dir and not make_clangd_wrapper and not make_python_symlink:
+ make_dir = True
+ make_clangd_wrapper = True
+ make_python_symlink = True
+
+ if make_dir:
+ if not settings.working_dir.exists():
+ settings.working_dir.mkdir()
+ maybe_print('Initialized the Pigweed IDE working directory.')
+ else:
+ maybe_print('Pigweed IDE working directory already present.')
+
+ if make_clangd_wrapper:
+ clangd_wrapper_path = (settings.working_dir / CLANGD_WRAPPER_FILE_NAME)
+
+ if not clangd_wrapper_path.exists():
+ try:
+ write_clangd_wrapper_script(make_clangd_script(),
+ settings.working_dir)
+ except UnsupportedPlatformException:
+ _print_unsupported_platform_error('create clangd wrapper')
+ sys.exit(1)
+
+ maybe_print('Created a clangd wrapper script.')
+ else:
+ maybe_print('clangd wrapper script already present.')
+
+ if make_python_symlink:
+ python_symlink_path = settings.working_dir / PYTHON_SYMLINK_NAME
+
+ if not python_symlink_path.exists():
+ try:
+ create_python_symlink(settings.working_dir)
+ except UnsupportedPlatformException:
+ _print_unsupported_platform_error('create Python symlink')
+ sys.exit(1)
+
+ maybe_print('Created Python symlink.')
+ else:
+ maybe_print('Python symlink already present.')
+
+
def cmd_cpp(
should_list_targets: bool,
target_to_set: Optional[str],
@@ -66,6 +205,11 @@
Provides tools for processing C/C++ compilation databases and setting the
particular target/toochain to use for code analysis."""
+ cmd_init(make_dir=True,
+ make_clangd_wrapper=True,
+ make_python_symlink=True,
+ silent=True,
+ settings=settings)
default = True
if should_list_targets:
@@ -126,8 +270,14 @@
_print_current_target(settings)
-def cmd_python(should_get_venv_path: bool) -> None:
+def cmd_python(
+ should_get_venv_path: bool, settings: IdeSettings = IdeSettings()) -> None:
"""Configure Python IDE support for Pigweed projects."""
+ cmd_init(make_dir=True,
+ make_clangd_wrapper=True,
+ make_python_symlink=True,
+ silent=True,
+ settings=settings)
if should_get_venv_path:
try:
diff --git a/pw_ide/py/test_cases.py b/pw_ide/py/test_cases.py
index 7da04c6..a95768b 100644
--- a/pw_ide/py/test_cases.py
+++ b/pw_ide/py/test_cases.py
@@ -53,6 +53,20 @@
yield (file, path)
@contextmanager
+ def open_temp_file(
+ self,
+ filename: Union[Path, str],
+ ) -> Generator[Tuple[TextIOWrapper, Path], None, None]:
+ """Open an existing temp file in the test case's temp dir.
+
+ Returns a tuple containing the file reference and the file's path.
+ """
+ path = self.temp_dir_path / filename
+
+ with open(path, 'r', encoding='utf-8') as file:
+ yield (file, path)
+
+ @contextmanager
def make_temp_files(
self, files_data: List[Tuple[Union[Path, str], str]]
) -> Generator[List[TextIOWrapper], None, None]:
@@ -79,6 +93,27 @@
for file in files:
file.close()
+ @contextmanager
+ def open_temp_files(
+ self, files_data: List[Union[Path, str]]
+ ) -> Generator[List[TextIOWrapper], None, None]:
+ """Open several existing temp files in the test case's temp dir.
+
+ Provide a list of file names. Saves you the trouble of excessive
+ `with self.open_temp_file, self.open_temp_file...` nesting, and allows
+ programmatic definition of multiple temp file contexts.
+ """
+ files: List[TextIOWrapper] = []
+
+ for filename in files_data:
+ file = open(self.path_in_temp_dir(filename), 'r', encoding='utf-8')
+ files.append(file)
+
+ yield files
+
+ for file in files:
+ file.close()
+
def path_in_temp_dir(self, path: Union[Path, str]) -> Path:
"""Place a path into the test case's temp dir.