I've been writing C and some C++ in terminal-based editors for the past five years.

By default, clangd searches the current file's parent directories for a compile_commands.json. This becomes annoying when a repository contains several independently built firmware projects that share code and have a common repository root.

Each project needs its own compilation database. Replacing a single repository-level compile_commands.json whenever I switch projects already sucks, but running separate editor instances for different projects breaks the whole thing, because both clangd instances would need the same file to contain different data.

I recently came up with a solution that I like enough (and found non-obvious enough) to justify a short post.

The script below takes a build target as its argument and creates a temporary, target-specific development environment.

It:

  1. Generates the target's compile_commands.json in a temporary directory.
  2. Creates a clangd wrapper in the same directory.
  3. Configures that wrapper to use the generated compilation database and also applies other settings we need.
  4. Starts an interactive Bash session with the temporary directory prepended to PATH.
  5. Adds the selected target to the shell prompt as a reminder.

Because the wrapper is named clangd and appears first in PATH, editors started from this shell launch the correctly configured clangd without needing any project-specific editor configuration.

The bashrc prepends the directory after sourcing my normal ~/.bashrc, which also prevents tools such as direnv from accidentally putting another clangd ahead of it during shell initialization.

#!/usr/bin/env bash
# Start a shell whose clangd uses the compilation database for one Bob target.

set -euo pipefail

if (($# != 1)); then
    echo "usage: $(basename "$0") <bob-target>" >&2
    exit 2
fi

target=$1
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)
repo_root=$(cd "$script_dir/.." && pwd -P)
firmware_dir=$repo_root/firmware
clangd=$repo_root/dev-tools/clangd

if [[ ! -d $firmware_dir || ! -x $clangd ]]; then
    echo "device-env must be located in the repository's dev-tools directory" >&2
    exit 1
fi

if ! command -v uv >/dev/null; then
    echo "uv is not on PATH; install it before running device-env" >&2
    exit 1
fi

tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/device-env.XXXXXX")
trap 'rm -rf "$tmpdir"' EXIT

(
    cd "$firmware_dir"
    uv run bob ninja-tool -f compdb "$target"
) >"$tmpdir/compile_commands.json"

cat >"$tmpdir/clangd" <<EOF
#!/usr/bin/env bash
exec "$clangd" \\
    "--compile-commands-dir=$tmpdir" \\
    "--query-driver=**" \\
    "\$@"
EOF
chmod +x "$tmpdir/clangd"

cat >"$tmpdir/bashrc" <<'EOF'
if [[ -f ~/.bashrc ]]; then
    source ~/.bashrc
fi

export PATH="$DEVICE_ENV_TMPDIR:$PATH"
PS1="[denv:${DEVICE_ENV_TARGET}] ${PS1}"
EOF

export DEVICE_ENV_TARGET=$target
export DEVICE_ENV_TMPDIR=$tmpdir
bash --noprofile --rcfile "$tmpdir/bashrc" -i

I can now start a project-specific shell with:

and launch Helix, or any other editor that invokes clangd through PATH from there.

Each shell gets its own compilation database and clangd process, so multiple projects can be open simultaneously without interfering with one another.

The mechanism is not specific to clangd btw. A temporary directory containing wrapper executables can be a useful way to provide project-specific behavior to tools that are normally configured globally or by the editor launching them.