pw_ide: CLI tools for C++ comp. DBs

This surfaces a set of CLI commands for accessing the C++/clangd
compilation database management features.

This also scaffolds the git-subcommand-like structure of the `pw ide`
CLI.

Change-Id: Ia6cf86aa91ff51d10f5fc1cfaef742ce46eebdcf
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/110255
Commit-Queue: Chad Norvell <chadnorvell@google.com>
Reviewed-by: Anthony DiGirolamo <tonymd@google.com>
diff --git a/PW_PLUGINS b/PW_PLUGINS
index 2c71655..e368610 100644
--- a/PW_PLUGINS
+++ b/PW_PLUGINS
@@ -13,6 +13,7 @@
 # Pigweed's presubmit check script
 format pw_presubmit.format_code _pigweed_upstream_main
 heap-viewer pw_allocator.heap_viewer main
+ide pw_ide.__main__ main
 package pw_package.pigweed_packages main
 presubmit pw_presubmit.pigweed_presubmit main
 requires pw_cli.requires main
diff --git a/pw_ide/docs.rst b/pw_ide/docs.rst
index 2663067..ebc11a8 100644
--- a/pw_ide/docs.rst
+++ b/pw_ide/docs.rst
@@ -22,3 +22,49 @@
   target. For example, GN outputs build artifacts for the
   ``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.
+
+C++ Code Intelligence via ``clangd``
+====================================
+`clangd <https://clangd.llvm.org/>`_ is a language server that provides C/C++
+code intelligence features to any editor that supports the language server
+protocol (LSP). It uses a
+`compilation database <https://clang.llvm.org/docs/JSONCompilationDatabase.html>`_,
+a JSON file containing the compile commands for the project. Projects that have
+multiple targets and/or use multiple toolchains need separate compilation
+databases for each target/toolchain. ``pw_ide`` provides tools for managing
+those databases.
+
+Assuming you have a compilation database output from a build system, start with:
+
+.. code-block:: bash
+
+  pw ide cpp --process <path to your compile_commands.json>
+
+The ``pw_ide`` working directory will now contain one or more compilation
+database files, each for a separate target among the targets defined in
+``.pw_ide.yaml``. List the available targets with:
+
+.. code-block:: bash
+
+  pw ide cpp --list
+
+Then set the target that ``clangd`` should use with:
+
+.. code-block:: bash
+
+  pw ide cpp --set <selected target name>
+
+``clangd`` can now be configured to point to the ``compile_commands.json`` file
+in the ``pw_ide`` working directory and provide code intelligence for the
+selected target. If you select a new target, ``clangd`` *does not* need to be
+reconfigured to look at a new file (in other words, ``clangd`` can always be
+pointed at the same, stable ``compile_commands.json`` file). However,
+``clangd`` may need to be restarted when the target changes.
+
+``clangd`` must be run within the activated Pigweed environment in order for
+correct toolchain paths and sysroots to be detected. If you launch your editor
+from the terminal in an activated environment, then nothing special needs to be
+done (e.g. running ``vim`` from the terminal). But if you launch your editor
+outside of the activated environment (e.g. launching Visual Studio Code from
+your GUI shell's launcher), you will need to use the included wrapper
+``clangd.sh`` instead of directly using the ``clangd`` binary.
diff --git a/pw_ide/py/BUILD.gn b/pw_ide/py/BUILD.gn
index 7570575..b9ab35c 100644
--- a/pw_ide/py/BUILD.gn
+++ b/pw_ide/py/BUILD.gn
@@ -25,6 +25,7 @@
   sources = [
     "pw_ide/__init__.py",
     "pw_ide/__main__.py",
+    "pw_ide/commands.py",
     "pw_ide/cpp.py",
     "pw_ide/exceptions.py",
     "pw_ide/settings.py",
diff --git a/pw_ide/py/pw_ide/__main__.py b/pw_ide/py/pw_ide/__main__.py
index 5f6ba72..a7a8b82 100644
--- a/pw_ide/py/pw_ide/__main__.py
+++ b/pw_ide/py/pw_ide/__main__.py
@@ -13,12 +13,170 @@
 # the License.
 """Configure IDE support for Pigweed projects."""
 
+import argparse
+from pathlib import Path
 import sys
-from typing import NoReturn
+from typing import (Any, Callable, cast, Dict, Generic, NoReturn, Optional,
+                    TypeVar, Union)
+from pw_ide.commands import cmd_cpp
+
+# TODO(chadnorvell): Move this docstring-as-argparse-docs functionality
+# to pw_cli.
+T = TypeVar('T')
+
+
+class Maybe(Generic[T]):
+    """A very rudimentary monadic option type.
+
+    This makes it easier to multiple chain T -> T and T -> T? functions when
+    dealing with Optional[T] values.
+
+    For example, rather than doing:
+
+    .. code-block:: python
+
+        def f(x: T) -> Optional[T]: ...
+        def g(x: T) -> T: ...
+
+        def foo(i: Optional[T]) -> Optional[T]:
+            if i is None:
+                return None
+
+            i = f(i)
+
+            if j is None:
+                return None
+
+            return g(j)
+
+    ... which becomes tedious as the number of functions increases, instead do:
+
+    .. code-block:: python
+
+        def f(x: T) -> Optional[T]: ...
+        def g(x: T) -> T: ...
+
+        def foo(i: Optional[T]) -> Optional[T]:
+            return Maybe(i)\
+                .and_then(f)
+                .and_then(g)
+                .to_optional()
+    """
+    def __init__(self, value: Optional[T]) -> None:
+        self.value: Optional[T] = value
+
+    def and_then(
+        self, func: Union[Callable[[T], T],
+                          Callable[[T], 'Maybe[T]']]) -> 'Maybe[T]':
+        if self.value is None:
+            return self
+
+        result = func(self.value)
+
+        if isinstance(result, self.__class__):
+            return cast('Maybe[T]', result)
+
+        return self.__class__(cast(T, result))
+
+    def to_optional(self) -> Optional[T]:
+        return self.value
+
+
+def _reflow_multiline_string(doc: str) -> str:
+    """Reflow a multi-line string by removing formatting newlines.
+
+    This works on any string, but is intended for docstrings, where newlines
+    are added within sentences and paragraphs to satisfy the whims of line
+    length limits.
+
+    Paragraphs are assumed to be surrounded by blank lines. They will be joined
+    together without newlines, then each paragraph will be rejoined with blank
+    line separators.
+    """
+    return '\n\n'.join([para.replace('\n', ' ') for para in doc.split('\n\n')])
+
+
+def _multiline_string_summary(doc: str) -> str:
+    """Return the one-line summary from a multi-line string.
+
+    This works on any string, but is intended for docstrings, where you
+    typically have a single line summary.
+    """
+    return doc.split('\n\n', maxsplit=1)[0]
+
+
+def _reflow_docstring(doc: Optional[str]) -> Optional[str]:
+    """Reflow an docstring."""
+    return Maybe(doc)\
+        .and_then(_reflow_multiline_string)\
+        .to_optional()
+
+
+def _docstring_summary(doc: Optional[str]) -> Optional[str]:
+    """Return the one-line summary of a docstring."""
+    return Maybe(doc)\
+        .and_then(_reflow_multiline_string)\
+        .and_then(_multiline_string_summary)\
+        .to_optional()
+
+
+def _parse_args() -> argparse.Namespace:
+    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')
+
+    parser_cpp = subcommand_parser.add_parser(
+        'cpp',
+        description=_docstring_summary(cmd_cpp.__doc__),
+        help=_reflow_docstring(cmd_cpp.__doc__))
+    parser_cpp.add_argument('-l',
+                            '--list',
+                            dest='should_list_targets',
+                            action='store_true',
+                            help='List the targets available for C/C++ '
+                            'language analysis.')
+    parser_cpp.add_argument('-s',
+                            '--set',
+                            dest='target_to_set',
+                            metavar='TARGET',
+                            help='Set the target to use for C/C++ language '
+                            'server analysis.')
+    parser_cpp.add_argument('--no-override',
+                            dest='override_current_target',
+                            action='store_const',
+                            const=False,
+                            default=True,
+                            help='If called with --set, don\'t override the '
+                            'current target if one is already set.')
+    parser_cpp.add_argument('-p',
+                            '--process',
+                            dest='compdb_file_path',
+                            type=Path,
+                            metavar='COMPILATION_DATABASE_FILE',
+                            help='Process a file matching the clang '
+                            'compilation database format.')
+    parser_cpp.set_defaults(func=cmd_cpp)
+
+    args = parser_root.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 main() -> NoReturn:
-    sys.exit(0)
+    sys.exit(_dispatch_command(**vars(_parse_args())))
 
 
 if __name__ == '__main__':
diff --git a/pw_ide/py/pw_ide/commands.py b/pw_ide/py/pw_ide/commands.py
new file mode 100644
index 0000000..17b55c6
--- /dev/null
+++ b/pw_ide/py/pw_ide/commands.py
@@ -0,0 +1,112 @@
+# 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 implementations."""
+
+from pathlib import Path
+import sys
+from typing import Optional
+
+from pw_ide.cpp import (get_defined_available_targets, get_target,
+                        process_compilation_database, set_target,
+                        write_compilation_databases)
+
+from pw_ide.exceptions import (BadCompDbException, InvalidTargetException,
+                               MissingCompDbException)
+
+from pw_ide.settings import IdeSettings
+
+
+def _print_current_target(settings: IdeSettings) -> None:
+    print('Current C/C++ language server analysis target: '
+          f'{get_target(settings)}\n')
+
+
+def _print_defined_available_targets(settings: IdeSettings) -> None:
+    print('C/C++ targets available for language server analysis:')
+
+    for toolchain in sorted(get_defined_available_targets(settings)):
+        print(f'\t{toolchain}')
+
+    print('')
+
+
+def cmd_cpp(
+    should_list_targets: bool,
+    target_to_set: Optional[str],
+    compdb_file_path: Optional[Path],
+    override_current_target: bool = True,
+    settings: IdeSettings = IdeSettings()
+) -> None:
+    """Configure C/C++ IDE support for Pigweed projects.
+
+    Provides tools for processing C/C++ compilation databases and setting the
+    particular target/toochain to use for code analysis."""
+    default = True
+
+    if should_list_targets:
+        default = False
+        _print_defined_available_targets(settings)
+
+    # Order of operations matters here. It should be possible to process a
+    # compilation database then set successfully set the target in a single
+    # command.
+    if compdb_file_path is not None:
+        default = False
+        try:
+            write_compilation_databases(
+                process_compilation_database(
+                    compdb_file_path,
+                    settings,
+                ),
+                settings,
+            )
+
+            print(
+                f'Processed {str(compdb_file_path)} to {settings.working_dir}')
+        except MissingCompDbException:
+            print(f'File not found: {str(compdb_file_path)}')
+            sys.exit(1)
+        except BadCompDbException:
+            print('File does not match compilation database format: '
+                  f'{str(compdb_file_path)}')
+            sys.exit(1)
+
+    if target_to_set is not None:
+        default = False
+        should_set_target = get_target(settings) is None \
+            or override_current_target
+
+        if should_set_target:
+            try:
+                set_target(target_to_set, settings)
+            except InvalidTargetException:
+                print(f'Invalid target! {target_to_set} not among the '
+                      'available defined targets.\n\n'
+                      'Check .pw_ide.yaml or .pw_ide.user.yaml for defined '
+                      'targets.')
+                sys.exit(1)
+            except MissingCompDbException:
+                print(f'File not found for target! {target_to_set}\n'
+                      'Did you run pw ide cpp --process '
+                      '{path to compile_commands.json}?')
+                sys.exit(1)
+
+            print('Set C/C++ language server analysis target to: '
+                  f'{target_to_set}')
+        else:
+            print('Target already is set and will not be overridden.')
+            _print_current_target(settings)
+
+    if default:
+        _print_current_target(settings)