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.