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:
- Generates the target's
compile_commands.jsonin a temporary directory. - Creates a
clangdwrapper in the same directory. - Configures that wrapper to use the generated compilation database and also applies other settings we need.
- Starts an interactive Bash session with the temporary directory prepended to
PATH. - 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.
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.