(This is written for CLion and Rust development, but can be applied to pretty much everything else that has user-wide settings or settings that can’t easily be changed using environment variables that store a fixed path to some tools that should be determined by the dev shell.)
Caveat: this doesn’t work on macOS since it doesn’t have namespaces or any equivalent as far as I can tell. boooo
NB: The /Volumes directory I talk about here has to already exist on the real file system for this to work. Feel free to change it inside the nswrap script, maybe to some empty folder under /run created by tmpfilesd, since it most likely will not exist on your system.
I once again got frustrated at the bad experience with CLion when using it together with nix shells, since its Rust plugin expects you to set the path to both rustc/cargo and the Rust library sources, which isn’t really possible, or at the very least, fairly annoying, with dev shells. In the past I solved this by building a rust
symlink in the current project and pointing it there. However, that’s not really ideal because if you leave a bunch of these around they fill up your disk because they won’t get collected, and you have to configure it for every new project, and you also have to remember to update it when you update the nixpkgs you use for the project.
So, I came up with a new solution using user namespaces, specifically using the unshare program which creates new namespaces and runs something inside them. This consists of two scripts, nswrap which does most of the work and can be used for something other than clion, and the clion script which is intended to shadow the real CLion executable in environment.systemPackages.
nswrap takes a map of paths and names (argument --bind) and a list of commands (argument --prepare) which it executes during set-up of the user namespace. (The list of commands I added to make setting up more complex hierarchies possible because I was too lazy to make that work for --bind.) These two arguments set up paths under /Volumes. Finally, it also takes the command to execute inside that namespace, for example CLion.
For example, running nswrap --bind '/run/current-system/sw:sw' bash
runs a bash shell in which /Volumes/sw will be bind-mounted to /run/current-system/sw.
The clion script just calls nswrap to set up the namespace: if RUST_SRC_PATH environment variable is set (this one is also recognized by other tools such as racer), it will set up a directory structure under /Volumes/Rust containing links to rustc, cargo, and the Rust stdlib sources, which you can then point CLion to as the default path for Rust.
To make it all work, add the two scripts to your system configuration:
nswrap source
#!/usr/bin/env bash
set -e
: "${SETUP_STAGE:=0}"
VOL_PATH=/Volumes
usage() {
echo "Usage: $0 [OPTION...] [--] PROGRAM ARGS..."
echo "Bind mounts paths under $VOL_PATH using a user namespace"
echo
echo " --bind=SOURCE:NAME Bind SOURCE to $VOL_PATH/NAME"
echo " -h, --help Give this help list"
echo " --prepare=PROGRAM Run PROGRAM to prepare mounts"
}
if (( SETUP_STAGE == 0 )); then
SETUP_STAGE=1 TARGET_USER=$(id -u) TARGET_GROUP=$(id -g) exec unshare -Urm bash "$0" "${@}"
elif (( SETUP_STAGE == 1 )); then
if ! TEMP=$(getopt -o h -l bind:,help,prepare: -n "$0" -- "${@}"); then
exit 64
fi
eval set -- "$TEMP"
unset TEMP
BINDS=()
while true; do
case "$1" in
--bind) BINDS+=("$2"); shift;;
-h | --help) usage; exit;;
--prepare) COMMANDS+=("$2"); shift;;
--) shift; break;;
*) break;;
esac
shift
done
tmp=$(mktemp -d)
mount -t tmpfs -o mode=755 none "$tmp"
(
cd "$tmp"
for c in "${COMMANDS[@]}"; do
# shellcheck disable=SC2086
env ${c}
done
)
for v in "$VOL_PATH"/*; do
name="${v##*/}"
BINDS+=("$v:$name")
done
for v in "${BINDS[@]}"; do
src="${v%:*}"
name="${v##*:}"
if [[ -e "$tmp"/"$name" ]]; then
continue
fi
if [[ -f "$src" ]]; then
touch "$tmp"/"$name"
mount -o bind "$src" "$tmp"/"$name"
elif [[ -L "$src" ]]; then
cp -d "$src" "$tmp"/"$name"
else
mkdir "$tmp"/"$name"
mount --rbind "$src" "$tmp"/"$name"
fi
done
mount --move "$tmp" "$VOL_PATH" || true
rmdir "$tmp"
SETUP_STAGE=2 exec unshare -U --map-user="$TARGET_USER" --map-group="$TARGET_GROUP" bash "$0" "${@}"
elif (( SETUP_STAGE == 2 )); then
unset SETUP_STAGE TARGET_USER TARGET_GROUP
exec "${@}"
fi
clion source
#!/usr/bin/env bash
args=()
: "${RUST_PREPARE:=0}"
if (( RUST_PREPARE )); then
mkdir Rust
mount -t tmpfs -o mode=755 none Rust
mkdir -p Rust/bin Rust/lib/rustlib/src/rust
ln -s "$(command -v cargo)" "$(command -v rustc)" Rust/bin
ln -s "$RUST_SRC_PATH" Rust/lib/rustlib/src/rust/library
exit
fi
if [[ -n "${RUST_ENV_PATH-}" ]]; then
args+=(--bind "$RUST_ENV_PATH":Rust)
elif [[ -n "${RUST_SRC_PATH-}" ]]; then
args+=(--prepare "RUST_PREPARE=1 $0")
fi
exec nswrap "${args[@]}" -- clion "${@}"
{lib, pkgs, ...}: let
inherit (lib) mkBefore mkMerge readFile;
in {
environment.systemPackages = mkMerge [
(mkBefore [
(pkgs.writeShellApplication {
name = "clion";
text = readFile ./scripts/clion;
runtimeInputs = [pkgs.jetbrains.clion];
})
])
[
pkgs.jetbrains.clion
(pkgs.writeShellApplication {
name = "nswrap";
text = readFile ./scripts/nswrap;
runtimeInputs = [pkgs.getopt];
})
]
];
}
(You can see this set up in my configuration here.)
and then simply set RUST_SRC_PATH = "${pkgs.rustPlatform.rustLibSrc}";
in any Rust dev shells you want to use this with. Running CLion inside those dev shells should then have the /Volumes/Rust directory, with /Volumes/Rust/bin/{cargo,rustc} and /Volumes/Rust/lib/rustlib/src/rust/library, which you can set as the default for new projects in CLion.
Enjoy slightly more hassle-free Rust development with CLion!