pw_ide: clangd wrapper generators

clangd must be run within the activated Pigweed environment to pick up
the right paths to the Pigweed toolchains. We point clangd language
servers to these wrapper scripts instead of the bare executable.

We can't just store platform-specific scripts as files in this repo,
because most editors don't let us specify a different clangd location
for different OS's (e.g. `clangd.bat` on Windows and `clangd.sh`
elsewhere). Also, the location of the Pigweed environment can vary. So
we generate wrapper scripts for the user that are appropriate to their
OS and project configuration.

Change-Id: I70a8d3536678329a802507ca950ca79a274aa36f
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/110254
Reviewed-by: Anthony DiGirolamo <tonymd@google.com>
Commit-Queue: Chad Norvell <chadnorvell@google.com>
diff --git a/pw_ide/py/pw_ide/cpp.py b/pw_ide/py/pw_ide/cpp.py
index d9f8a2d..979196a 100644
--- a/pw_ide/py/pw_ide/cpp.py
+++ b/pw_ide/py/pw_ide/cpp.py
@@ -16,15 +16,19 @@
 from collections import defaultdict
 from dataclasses import dataclass, field
 from io import TextIOBase
+from inspect import cleandoc
 import json
 import os
 from pathlib import Path
+import platform
 import re
+import stat
 from typing import (cast, Dict, Generator, List, Optional, Tuple, TypedDict,
                     Union)
 
 from pw_ide.exceptions import (BadCompDbException, InvalidTargetException,
-                               MissingCompDbException)
+                               MissingCompDbException,
+                               UnsupportedPlatformException)
 
 from pw_ide.settings import IdeSettings
 from pw_ide.symlinks import set_symlink
@@ -437,3 +441,86 @@
     for target, compdb in compdbs.items():
         compdb.to_file(settings.working_dir /
                        compdb_generate_file_path(target))
+
+
+def make_clangd_script(system: str = platform.system()) -> str:
+    """Create a clangd wrapper script appropriate to the platform this is
+    running on."""
+
+    boilerplate = """
+# A wrapper around clangd that ensures it is run in the activated Pigweed
+# environment, which in turn ensures that the correct toolchain paths are used.
+# THIS FILE IS AUTOMATICALLY GENERATED AND SHOULDN'T BE MODIFIED!"""
+
+    posix_script = cleandoc(f"""
+        #!/bin/bash
+        {boilerplate}
+
+        CWD="$(pwd)"
+
+        if [ ! -f "$CWD/activate.sh" ]; then
+            echo "clangd: must be run from workspace root (currently in $CWD)" 1>&2
+            exit 1
+        fi
+
+        if [ ! -d "$CWD/environment" ]; then
+            echo "clangd: Pigweed must be bootstrapped" 1>&2
+            exit 1
+        fi
+
+        [[ -z "$PW_ROOT" ]] && source "$CWD/activate.sh" >/dev/null 2>&1
+
+        exec "$PW_PIGWEED_CIPD_INSTALL_DIR/bin/clangd" --query-driver="$PW_PIGWEED_CIPD_INSTALL_DIR/bin/*,$PW_ARM_CIPD_INSTALL_DIR/bin/*" "$@"
+""")
+
+    windows_script = cleandoc(f"""
+        :<<"::WINDOWS_ONLY"
+        @echo off
+        :<<"::WINDOWS_ONLY"
+        {boilerplate.replace('#', '::')}
+
+        if not exist "%cd%\\activate.sh" {{
+            echo clangd: must be run from workspace root (currently in %cd%) 1>&2
+            exit /b 2
+        }}
+
+        if not exist "%cd%\\environment" {{
+            echo clangd: Pigweed must be bootstrapped 1>&2
+            exit /b 2
+        }}
+
+        if "%PW_ROOT%" == "" {{
+            %cd%\\activate.bat >nul 2>&1
+        }}
+
+        start "%PW_PIGWEED_CIPD_INSTALL_DIR%\\bin\\clangd" --query-driver="%PW_PIGWEED_CIPD_INSTALL_DIR%\\bin\\*,%PW_ARM_CIPD_INSTALL_DIR%\\bin\\*" "%*"
+        ::WINDOWS_ONLY
+""")
+
+    if system == '':
+        raise UnsupportedPlatformException()
+
+    if system == 'Windows':
+        # On Windows, use a batch script.
+        return windows_script
+
+    # If it's not Windows, assume a POSIX shell script will work.
+    return posix_script
+
+
+def write_clangd_wrapper_script(script: str, working_dir: Path) -> None:
+    """Write a clangd wrapper script to file and make it executable.
+
+    clangd needs to run in the activated environment to detect toolchains
+    correctly, so we wrap it in this script instead of calling it directly.
+    This also allows us to abstract over the actual environment directory.
+    """
+
+    wrapper_path = working_dir / CLANGD_WRAPPER_FILE_NAME
+
+    with wrapper_path.open('w') as wrapper_file:
+        wrapper_file.write(script)
+
+    current_stat = wrapper_path.stat()
+    # This is `chmod +x`.
+    wrapper_path.chmod(current_stat.st_mode | stat.S_IEXEC)