blob: 6f6a7c598fdcb10e13a0ce1cb5dd46d69405207f [file]
//! Bootstrap an editor at the Bazel-provisioned rust-analyzer toolchain.
//!
//! The committed editor config — `.vscode/settings.json` or
//! `.code-workspace` — references the launcher dir via VS Code's
//! `${workspaceFolder}` variable, so the file is portable across
//! developers and platforms. The per-developer launcher dir
//! (`<workspace>/.rules_rust_analyzer/`, gitignored) holds:
//!
//! * **Source binaries** copied straight from runfiles
//! (`discover_bazel_rust_project.exe`, `flycheck.exe`) — toolchain
//! JSON they consume is baked at compile time via
//! `gen_rust_project_lib`'s `rustc_env_files` wiring.
//! * **Launcher shims** (`rust_analyzer.exe`,
//! `rust_analyzer_proc_macro_srv.exe`, `rustfmt.exe`) — three
//! byte-identical copies of `bin/launcher.rs` that look up the
//! real toolchain path in sibling `launcher_paths.json` and exec.
//! * **`launcher_paths.json`** — written from the runfiles-resolved
//! toolchain; the only thing that needs refreshing when the
//! toolchain moves (rustup update, MODULE.bazel change,
//! `bazel clean --expunge`).
//!
//! The `.exe` suffix on every platform is intentional: Node's
//! `child_process.spawn` (the rust-analyzer extension's spawner)
//! requires it on Windows, and POSIX `execve` ignores file extensions
//! — same filename works everywhere.
use std::{fs, path::Path};
use anyhow::{Context, Result};
use camino::{Utf8Path, Utf8PathBuf};
use clap::{Args, Parser, Subcommand};
use log::info;
use runfiles::{rlocation, Runfiles};
use serde_json::{json, Map, Value};
// ---------------------------------------------------------------------------
// Settings-file keys (VSCode JSON)
// ---------------------------------------------------------------------------
const DISCOVER_CONFIG_KEY: &str = "rust-analyzer.workspace.discoverConfig";
const SERVER_PATH_KEY: &str = "rust-analyzer.server.path";
const PROC_MACRO_SRV_KEY: &str = "rust-analyzer.procMacro.server";
const RUSTFMT_OVERRIDE_KEY: &str = "rust-analyzer.rustfmt.overrideCommand";
const FILES_WATCHER_EXCLUDE_KEY: &str = "files.watcherExclude";
const FILES_EXCLUDE_KEY: &str = "files.exclude";
const SEARCH_EXCLUDE_KEY: &str = "search.exclude";
/// `rust-analyzer.files.excludeDirs` — the list of workspace-relative dirs
/// rust-analyzer's filesystem scan should skip. Critical for Bazel-first
/// workspaces because rust-analyzer auto-discovers any `Cargo.toml` it
/// finds in immediate subdirectories of the workspace root and loads each
/// as a separate cargo workspace (in ADDITION to the discoverConfig
/// project). Those extra cargo workspaces:
/// 1. Exhaust the inotify watch limit on large repos.
/// 2. Slow down indexing of the actual Bazel-project files.
/// 3. Can cause cross-workspace file-id confusion that hides codelens
/// / runnables on files the user actually opens.
///
/// We populate this with every `<workspace>/<dir>/Cargo.toml` we find at
/// install time. See [`find_cargo_dirs_to_exclude`].
const FILES_EXCLUDE_DIRS_KEY: &str = "rust-analyzer.files.excludeDirs";
/// Glob that matches Bazel's four convenience symlinks at the workspace
/// root: `bazel-bin/`, `bazel-out/`, `bazel-testlogs/`, and
/// `bazel-<workspace-name>/`. Skipping them is the difference between a
/// happy IDE and one that thrashes the OS file-watch limit on every
/// `bazel build`.
const BAZEL_OUTPUTS_GLOB: &str = "**/bazel-*/**";
// ---------------------------------------------------------------------------
// Launcher dir + source-binary install paths
// ---------------------------------------------------------------------------
/// Subdirectory name used (under the per-IDE launcher root) to hold the
/// source binaries setup copies in. The leading dot keeps tidy file
/// explorers from surfacing it as workspace content; the rules_rust
/// prefix prevents collisions with anything else that might want to drop
/// files into the same parent dir.
const LAUNCHER_SUBDIR: &str = ".rules_rust_analyzer";
// On-disk filenames setup uses for the binaries it copies into the
// launcher dir. Re-exported from `gen_rust_project_lib` so the install
// side and the consumer side (rust_project.rs's flycheck-runnable
// path emitter) agree on extension handling — including the `.exe`
// suffix on Windows.
use gen_rust_project_lib::{DISCOVER_BINARY_FILENAME, FLYCHECK_BINARY_FILENAME};
// Runfiles paths setup looks up via `Runfiles::create()` at install
// time. The `_opt` suffix points at the `opt_executable` wrapper in
// `opt_transition.bzl` — these run on every save / discovery and pay
// off in opt mode.
const DISCOVER_BINARY_RLOCATION: &str =
"rules_rust/tools/rust_analyzer/discover_bazel_rust_project_opt";
const FLYCHECK_BINARY_RLOCATION: &str = "rules_rust/tools/rust_analyzer/flycheck_opt";
/// Source of truth for the launcher dispatch table: each entry is
/// both the install filename (`<name>.exe`) and the
/// `launcher_paths.json` key the launcher looks itself up under at
/// runtime. Keep in sync with [`toolchain_target_for`].
const LAUNCHER_LOGICAL_NAMES: &[&str] =
&["rust_analyzer", "rust_analyzer_proc_macro_srv", "rustfmt"];
const LAUNCHER_PATHS_JSON: &str = "launcher_paths.json";
fn launcher_filename(logical: &str) -> String {
format!("{logical}.exe")
}
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
#[derive(Parser)]
#[command(
name = "setup",
about = "Bootstrap an editor at the Bazel rust-analyzer toolchain."
)]
struct Cli {
/// Workspace root. Defaults to BUILD_WORKSPACE_DIRECTORY (set when
/// invoked via `bazel run`).
#[arg(long, env = "BUILD_WORKSPACE_DIRECTORY", global = true)]
workspace: Option<Utf8PathBuf>,
/// Skip the proc-macro server key. Useful when the editor's bundled
/// rust-analyzer already matches the Bazel rustc version.
#[arg(long, global = true)]
skip_proc_macro_server: bool,
/// Skip the rustfmt override key. Useful when the workspace
/// deliberately wants the host's rustfmt instead of the Bazel one.
#[arg(long, global = true)]
skip_rustfmt: bool,
/// Pass `{arg}` to the discover command so rust-analyzer switches
/// workspaces to the per-file package. Off by default — the whole
/// workspace gets indexed as one project, which is simpler and what
/// most users want. Turn this on for monorepos where indexing the
/// whole graph hurts LSP responsiveness; the trade-off is that
/// rust-analyzer reloads (and re-runs discover) every time you jump
/// to a file in a different package, AND that dependents of the
/// package you're working on aren't indexed.
#[arg(long, global = true)]
per_package_workspaces: bool,
#[command(subcommand)]
ide: IdeCmd,
}
#[derive(Subcommand)]
enum IdeCmd {
/// Write/merge `.vscode/settings.json` and install source binaries
/// under `.vscode/.rules_rust_analyzer/`.
Vscode(VscodeArgs),
/// Install source binaries under `.rules_rust_analyzer/` (no
/// `.vscode/` references) and print an `nvim-lspconfig` Lua snippet
/// to stdout.
Neovim,
/// Install source binaries under `.helix/.rules_rust_analyzer/` and
/// print a `languages.toml` snippet to stdout.
Helix,
/// Install source binaries under `.rules_rust_analyzer/` and print
/// the editor-agnostic JSON snippet (same `rust-analyzer.*` keys
/// VSCode uses; works with coc.nvim and similar JSON-config LSP
/// clients).
Print,
}
#[derive(Args)]
struct VscodeArgs {
/// Settings file to write. Relative paths are resolved under
/// `--workspace`. When unset, defaults to the unique
/// `*.code-workspace` at the workspace root if one exists,
/// otherwise `.vscode/settings.json`. Multiple `.code-workspace`
/// files at the root are ambiguous — pass `--output` explicitly.
#[arg(short, long)]
output: Option<Utf8PathBuf>,
/// Nest the managed `rust-analyzer.*` keys under this top-level
/// key. Defaults to `"settings"` when the resolved output ends
/// in `.code-workspace` (which requires settings inside a
/// `settings` object — folder-scoped writes don't satisfy
/// rust-analyzer's window-scoped config requests), otherwise
/// the managed keys go at the document root.
#[arg(long)]
settings_key: Option<String>,
/// Print the would-be-written JSON to stdout instead of writing it.
#[arg(long)]
dry_run: bool,
/// Overwrite the managed keys instead of merging them with whatever
/// the file already has. With `--settings-key` set, only that
/// nested object is replaced — sibling top-level keys (e.g.
/// `folders`, `tasks`, `extensions` on a `.code-workspace` file)
/// stay intact. Without `--settings-key`, the entire file is
/// overwritten with just the managed keys.
#[arg(long)]
replace: bool,
}
// ---------------------------------------------------------------------------
// Entry point + per-IDE dispatch
// ---------------------------------------------------------------------------
fn main() -> Result<()> {
env_logger::init();
let Cli {
workspace,
skip_proc_macro_server,
skip_rustfmt,
per_package_workspaces,
ide,
} = Cli::parse();
let workspace = workspace.unwrap_or_else(|| Utf8PathBuf::from("."));
// Resolve the Vscode output path eagerly so `launcher_dir_for`
// sees the autodetected `.code-workspace` (or default
// `.vscode/settings.json`) — its parent dir is the launcher_dir
// basis, and we don't want to compute it twice.
let vscode_targets = match &ide {
IdeCmd::Vscode(args) => Some(resolve_vscode_targets(&workspace, args)?),
IdeCmd::Neovim | IdeCmd::Helix | IdeCmd::Print => None,
};
let launcher_dir = launcher_dir_for(&workspace, &ide, vscode_targets.as_ref());
let runfiles = Runfiles::create().context("creating Runfiles for setup")?;
let toolchain = ToolchainBinaries {
rust_analyzer: lookup_canonical(&runfiles, env!("RUST_ANALYZER_RLOCATIONPATH"))?,
proc_macro_srv: lookup_canonical(
&runfiles,
env!("RUST_ANALYZER_PROC_MACRO_SRV_RLOCATIONPATH"),
)?,
rustfmt: lookup_canonical(&runfiles, env!("RUSTFMT_RLOCATIONPATH"))?,
};
install_source_binaries(&launcher_dir, &runfiles)?;
// Launcher shims are only referenced by the VSCode subcommand's
// managed keys; neovim / helix / print snippets bake absolute
// toolchain paths directly and don't go through the launchers.
if matches!(ide, IdeCmd::Vscode(_)) {
install_toolchain_launchers(&launcher_dir, &runfiles, &toolchain)?;
}
let ctx = SetupCtx {
workspace,
launcher_dir,
skip_proc_macro_server,
skip_rustfmt,
per_package_workspaces,
toolchain,
};
match ide {
IdeCmd::Vscode(args) => run_vscode(&ctx, args, vscode_targets.unwrap()),
IdeCmd::Neovim => run_neovim(&ctx),
IdeCmd::Helix => run_helix(&ctx),
IdeCmd::Print => run_print(&ctx),
}
}
/// Shared state computed once at startup and threaded through every
/// per-IDE runner.
struct SetupCtx {
workspace: Utf8PathBuf,
/// Editor-specific dir setup copies source binaries into. The
/// discover/flycheck binaries self-locate their cache + output dirs
/// as siblings of themselves (`<launcher_dir>/cache` and
/// `<launcher_dir>/output_user_root` respectively).
launcher_dir: Utf8PathBuf,
skip_proc_macro_server: bool,
skip_rustfmt: bool,
per_package_workspaces: bool,
/// Canonical absolute paths of the three toolchain binaries,
/// written into `launcher_paths.json` for the launcher shims to
/// read at LSP startup. See [`ToolchainBinaries`] for how they're
/// resolved.
toolchain: ToolchainBinaries,
}
/// Absolute, canonicalized paths to the three toolchain binaries the
/// launcher shims exec. Resolved once in `main` via setup's own
/// runfiles + the `*_RLOCATIONPATH` make-vars (baked at compile time
/// by the `rustc_env` block on setup's BUILD target) + [`fs::canonicalize`]
/// (escapes the runfiles symlink tree — which lives in `bazel-out` and
/// would be wiped by `bazel clean` — and lands at the canonical
/// `output_base/external/...` path that only goes away on
/// `bazel clean --expunge`).
struct ToolchainBinaries {
rust_analyzer: Utf8PathBuf,
proc_macro_srv: Utf8PathBuf,
rustfmt: Utf8PathBuf,
}
fn lookup_runfile(runfiles: &Runfiles, env_path: &str) -> Result<Utf8PathBuf> {
let pathbuf = rlocation!(runfiles, env_path)
.with_context(|| format!("rlocation not found: {env_path}"))?;
Utf8PathBuf::try_from(pathbuf)
.with_context(|| format!("rlocation {env_path} was not valid UTF-8"))
}
fn lookup_canonical(runfiles: &Runfiles, env_path: &str) -> Result<Utf8PathBuf> {
let path = lookup_runfile(runfiles, env_path)?;
let canonical = fs::canonicalize(&path)
.with_context(|| format!("canonicalizing rlocation {env_path} = {path}"))?;
Utf8PathBuf::try_from(canonical)
.with_context(|| format!("canonical path for {env_path} was not valid UTF-8"))
}
// ---------------------------------------------------------------------------
// VSCode subcommand
// ---------------------------------------------------------------------------
/// Output ending in this MUST nest the managed `rust-analyzer.*` keys
/// under `settings` — those keys are window-scoped and VS Code
/// answers `workspace/configuration` from the workspace file's
/// `settings` block, not from any folder's `.vscode/settings.json`.
const CODE_WORKSPACE_EXT: &str = ".code-workspace";
const DEFAULT_VSCODE_OUTPUT: &str = ".vscode/settings.json";
/// Resolved once before `launcher_dir_for` (which needs the output's
/// parent dir) so the same values flow into both that and `run_vscode`.
#[derive(Debug)]
struct ResolvedVscodeTargets {
output: Utf8PathBuf,
/// `None` writes managed keys at the document root
/// (`.vscode/settings.json`); `Some("settings")` nests them
/// (`.code-workspace`). See [`CODE_WORKSPACE_EXT`] for why.
settings_key: Option<String>,
}
fn resolve_vscode_targets(
workspace: &Utf8Path,
args: &VscodeArgs,
) -> Result<ResolvedVscodeTargets> {
let output = match args.output.as_ref() {
Some(p) if p.is_absolute() => p.clone(),
Some(p) => workspace.join(p),
None => autodetect_code_workspace(workspace)?
.unwrap_or_else(|| workspace.join(DEFAULT_VSCODE_OUTPUT)),
};
let settings_key = args
.settings_key
.clone()
.or_else(|| is_code_workspace(&output).then(|| "settings".to_owned()));
Ok(ResolvedVscodeTargets {
output,
settings_key,
})
}
fn is_code_workspace(path: &Utf8Path) -> bool {
path.file_name()
.is_some_and(|n| n.ends_with(CODE_WORKSPACE_EXT))
}
/// Zero matches → `Ok(None)` (caller falls back to the default
/// `.vscode/settings.json`); one → `Ok(Some)`; two or more → `Err`,
/// since auto-targeting is ambiguous (the user must pass `--output`).
fn autodetect_code_workspace(workspace: &Utf8Path) -> Result<Option<Utf8PathBuf>> {
let read = match fs::read_dir(workspace) {
Ok(r) => r,
// Workspace path missing or unreadable is fine for autodetect —
// the caller will fall back to the .vscode/ default.
Err(_) => return Ok(None),
};
let mut matches: Vec<Utf8PathBuf> = Vec::new();
for entry in read.flatten() {
let Ok(name) = entry.file_name().into_string() else {
continue;
};
if name.ends_with(CODE_WORKSPACE_EXT) {
matches.push(workspace.join(name));
}
}
matches.sort();
match matches.len() {
0 => Ok(None),
1 => Ok(matches.pop()),
_ => anyhow::bail!(
"multiple `*{CODE_WORKSPACE_EXT}` files at {workspace}: {}. Pass `--output` to disambiguate.",
matches
.iter()
.map(|p| p.file_name().unwrap_or(""))
.collect::<Vec<_>>()
.join(", "),
),
}
}
fn run_vscode(ctx: &SetupCtx, args: VscodeArgs, targets: ResolvedVscodeTargets) -> Result<()> {
let ResolvedVscodeTargets {
output: output_path,
settings_key,
} = targets;
let managed = vscode_managed_keys(ctx);
let key_count = managed.len();
let merged = if args.replace {
replace_managed(&output_path, managed, settings_key.as_deref())?
} else {
merge_into_existing(&output_path, managed, settings_key.as_deref())?
};
if args.dry_run {
println!("=== {output_path} ===");
println!("{}", serde_json::to_string_pretty(&merged)?);
return Ok(());
}
write_settings(output_path.as_std_path(), &merged)?;
info!(
"{} {} key(s) in {output_path}",
if args.replace { "Wrote" } else { "Merged" },
key_count,
);
Ok(())
}
// ---------------------------------------------------------------------------
// Neovim subcommand
// ---------------------------------------------------------------------------
fn run_neovim(ctx: &SetupCtx) -> Result<()> {
let snippet = generate_neovim_lua(ctx, &ctx.launcher_dir);
print_snippet_with_banner("Add this to your init.lua (nvim-lspconfig):", &snippet);
Ok(())
}
// ---------------------------------------------------------------------------
// Helix subcommand
// ---------------------------------------------------------------------------
fn run_helix(ctx: &SetupCtx) -> Result<()> {
let snippet = generate_helix_toml(ctx, &ctx.launcher_dir);
print_snippet_with_banner(
"Add this to .helix/languages.toml at the workspace root:",
&snippet,
);
Ok(())
}
// ---------------------------------------------------------------------------
// Print subcommand
// ---------------------------------------------------------------------------
fn run_print(ctx: &SetupCtx) -> Result<()> {
let snippet = generate_settings_json(ctx, &ctx.launcher_dir);
print_snippet_with_banner(
"Add this to your editor's rust-analyzer settings (coc-settings.json, etc.):",
&snippet,
);
Ok(())
}
// ---------------------------------------------------------------------------
// Source-binary install
// ---------------------------------------------------------------------------
/// Copy discover + flycheck into `dir`. They live in `bazel-out`
/// originally and would be wiped by `bazel clean`; the copy survives
/// until the next `bazel clean --expunge` (which also nukes the
/// toolchain binaries and requires re-running setup anyway).
fn install_source_binaries(dir: &Utf8Path, runfiles: &Runfiles) -> Result<()> {
fs::create_dir_all(dir).with_context(|| format!("creating directory {dir}"))?;
for (rlocation, filename) in [
(DISCOVER_BINARY_RLOCATION, DISCOVER_BINARY_FILENAME),
(FLYCHECK_BINARY_RLOCATION, FLYCHECK_BINARY_FILENAME),
] {
let src = lookup_runfile(runfiles, rlocation)?;
let dest = dir.join(filename);
fs::copy(&src, &dest).with_context(|| format!("copying {src} -> {dest}"))?;
set_executable(&dest)?;
}
Ok(())
}
/// Assumes `dir` already exists — `install_source_binaries`, called
/// earlier in `main`, creates it.
fn install_toolchain_launchers(
dir: &Utf8Path,
runfiles: &Runfiles,
toolchain: &ToolchainBinaries,
) -> Result<()> {
let launcher_src = lookup_runfile(runfiles, env!("LAUNCHER_RLOCATIONPATH"))?;
for logical in LAUNCHER_LOGICAL_NAMES {
let dest = dir.join(launcher_filename(logical));
fs::copy(&launcher_src, &dest)
.with_context(|| format!("copying {launcher_src} -> {dest}"))?;
set_executable(&dest)?;
}
write_launcher_paths_json(&dir.join(LAUNCHER_PATHS_JSON), toolchain)
}
fn write_launcher_paths_json(path: &Utf8Path, toolchain: &ToolchainBinaries) -> Result<()> {
let map = Value::Object(
LAUNCHER_LOGICAL_NAMES
.iter()
.map(|logical| {
let target = toolchain_target_for(logical, toolchain);
(
(*logical).to_owned(),
Value::String(target.as_str().to_owned()),
)
})
.collect(),
);
write_settings(path.as_std_path(), &map)
}
fn toolchain_target_for<'a>(logical: &str, toolchain: &'a ToolchainBinaries) -> &'a Utf8PathBuf {
match logical {
"rust_analyzer" => &toolchain.rust_analyzer,
"rust_analyzer_proc_macro_srv" => &toolchain.proc_macro_srv,
"rustfmt" => &toolchain.rustfmt,
other => panic!("unknown launcher logical name `{other}` — keep LAUNCHER_LOGICAL_NAMES and ToolchainBinaries in sync"),
}
}
#[cfg(unix)]
fn set_executable(path: &Utf8Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(path)
.with_context(|| format!("stat {path}"))?
.permissions();
// rwxr-xr-x: rust-analyzer (and the user from a shell) must be able
// to exec this; group/other read+exec is harmless.
perms.set_mode(0o755);
fs::set_permissions(path, perms).with_context(|| format!("chmod {path}"))?;
Ok(())
}
#[cfg(not(unix))]
fn set_executable(_path: &Utf8Path) -> Result<()> {
// Windows doesn't use POSIX exec bits; `.bat`/`.exe` extension
// is the cue for the OS loader.
Ok(())
}
/// Normalize backslashes to forward slashes. Applied to every path we
/// hand to an editor's config file (settings.json, languages.toml, init.lua,
/// coc-settings.json).
///
/// Why everywhere:
/// * In JSON / Lua / TOML, `\` is an escape character — Windows-native
/// paths (`C:\Users\me\...`) embed as invalid escape sequences and
/// break the parser.
/// * Modern Windows tooling — VSCode, rust-analyzer, bazel.exe — all
/// accept forward slashes universally.
fn to_forward_slashes(path: &str) -> String {
if cfg!(windows) {
path.replace('\\', "/")
} else {
path.to_owned()
}
}
// ---------------------------------------------------------------------------
// Editor-relative defaults
// ---------------------------------------------------------------------------
/// Resolve the launcher directory for a given IDE subcommand. Setup is
/// authoritative over the binary install dir / cache / output_user_root
/// placement; every downstream default is computed by appending to
/// whatever this returns. For Vscode the launcher_dir sits alongside
/// the resolved settings file (defaults to `.vscode/.rules_rust_analyzer/`
/// when writing to `.vscode/settings.json`, sits at workspace root when
/// targeting a `.code-workspace` there).
fn launcher_dir_for(
workspace: &Utf8Path,
ide: &IdeCmd,
vscode_targets: Option<&ResolvedVscodeTargets>,
) -> Utf8PathBuf {
match ide {
IdeCmd::Vscode(_) => vscode_targets
.expect("vscode_targets must be resolved for IdeCmd::Vscode")
.output
.parent()
.map(|p| p.to_owned())
.unwrap_or_else(|| workspace.join(".vscode"))
.join(LAUNCHER_SUBDIR),
IdeCmd::Helix => workspace.join(".helix").join(LAUNCHER_SUBDIR),
// Neovim has no canonical per-project dotdir; print covers
// editor-agnostic JSON-config LSP clients. Both land at the
// workspace root.
IdeCmd::Neovim | IdeCmd::Print => workspace.join(LAUNCHER_SUBDIR),
}
}
// ---------------------------------------------------------------------------
// Cargo.toml scan (for files.excludeDirs auto-population)
// ---------------------------------------------------------------------------
/// Scan the workspace root one level deep for `<dir>/Cargo.toml` and
/// return the subdirectory names. These match exactly the files that
/// rust-analyzer's `ProjectManifest::discover` finds via
/// `find_cargo_toml_in_child_dir` — which only goes one level down to
/// avoid runaway scans (see rust-analyzer's `crates/project-model/src/
/// lib.rs::find_cargo_toml_in_child_dir`). Feeding these to
/// `rust-analyzer.files.excludeDirs` is the load-bearing piece that
/// keeps the cargo workspaces from being auto-loaded alongside the
/// discoverConfig project.
///
/// Skip `bazel-*` symlinks — those point into the Bazel output tree
/// and any Cargo.toml found there is an artifact, not a real source
/// manifest the user cares about.
///
/// Returns dir names sorted for deterministic snippet / settings.json
/// output.
fn find_cargo_dirs_to_exclude(workspace_root: &Utf8Path) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let read = match fs::read_dir(workspace_root) {
Ok(r) => r,
Err(_) => return out,
};
for entry in read.flatten() {
let Ok(file_type) = entry.file_type() else {
continue;
};
if !file_type.is_dir() {
continue;
}
let Ok(name) = entry.file_name().into_string() else {
continue;
};
if name.starts_with("bazel-") || name.starts_with('.') {
continue;
}
let cargo_toml = entry.path().join("Cargo.toml");
if cargo_toml.is_file() {
out.push(name);
}
}
out.sort();
out
}
// ---------------------------------------------------------------------------
// VSCode settings.json merge
// ---------------------------------------------------------------------------
/// A managed key/value pair plus how to combine it with whatever the user
/// already has under that key.
enum ManagedValue {
/// Overwrite the whole key — appropriate for scalar / object-shaped
/// keys like `rust-analyzer.server.path` where the rules_rust value is
/// the canonical one and stale entries should be replaced.
Replace(Value),
/// Dict-merge: ensure the key is an object containing the listed
/// sub-entries. If the user already has a value for a given glob
/// pattern we leave it alone — including explicit `false` overrides —
/// so they can opt out of any individual exclude by setting it to
/// `false` rather than deleting the entry (which we'd just add back).
InsertEntries(Vec<(String, Value)>),
/// List-union: ensure the key is an array containing the listed
/// values. User entries are preserved; ours are appended only if not
/// already present (string equality). Removed user entries stay
/// removed across re-runs — we only ADD, never DELETE.
InsertListEntries(Vec<Value>),
}
/// VS Code expands `${workspaceFolder}` for rust-analyzer's path
/// settings — that's what keeps the committed settings file portable.
fn workspace_relative(filename: &str) -> String {
format!("${{workspaceFolder}}/{LAUNCHER_SUBDIR}/{filename}")
}
fn vscode_managed_keys(ctx: &SetupCtx) -> Vec<(String, ManagedValue)> {
let ra_path = workspace_relative("rust_analyzer.exe");
let pms_path = workspace_relative("rust_analyzer_proc_macro_srv.exe");
let rustfmt_path = workspace_relative("rustfmt.exe");
let discover_path = workspace_relative(DISCOVER_BINARY_FILENAME);
let bazel_outputs = || vec![(BAZEL_OUTPUTS_GLOB.to_string(), Value::Bool(true))];
// `{arg}` opts into per-package workspace switching. See `--per-package-workspaces`.
let discover_command = if ctx.per_package_workspaces {
json!([discover_path, "{arg}"])
} else {
json!([discover_path])
};
let mut out = vec![
(
DISCOVER_CONFIG_KEY.to_string(),
ManagedValue::Replace(json!({
"command": discover_command,
"progressLabel": "rules_rust",
"filesToWatch": [
"BUILD",
"BUILD.bazel",
"MODULE.bazel",
"WORKSPACE",
"WORKSPACE.bazel",
],
})),
),
(
SERVER_PATH_KEY.to_string(),
ManagedValue::Replace(Value::String(ra_path)),
),
];
if !ctx.skip_proc_macro_server {
out.push((
PROC_MACRO_SRV_KEY.to_string(),
ManagedValue::Replace(Value::String(pms_path)),
));
}
if !ctx.skip_rustfmt {
// overrideCommand is an argv array; the toolchain rustfmt takes
// file contents on stdin and writes formatted output to stdout,
// which is the contract rust-analyzer expects.
out.push((
RUSTFMT_OVERRIDE_KEY.to_string(),
ManagedValue::Replace(json!([rustfmt_path])),
));
}
// Three exclude maps share the same Bazel-outputs glob — dict-merged
// so any user entries (other patterns, explicit `false` overrides)
// survive untouched.
out.push((
FILES_WATCHER_EXCLUDE_KEY.to_string(),
ManagedValue::InsertEntries(bazel_outputs()),
));
out.push((
FILES_EXCLUDE_KEY.to_string(),
ManagedValue::InsertEntries(bazel_outputs()),
));
out.push((
SEARCH_EXCLUDE_KEY.to_string(),
ManagedValue::InsertEntries(bazel_outputs()),
));
// rust-analyzer.files.excludeDirs: list-union with the names of every
// immediate subdirectory that contains a Cargo.toml. See the key's
// doc comment for why this matters.
let cargo_dirs = find_cargo_dirs_to_exclude(&ctx.workspace);
if !cargo_dirs.is_empty() {
out.push((
FILES_EXCLUDE_DIRS_KEY.to_string(),
ManagedValue::InsertListEntries(cargo_dirs.into_iter().map(Value::String).collect()),
));
}
out
}
/// Strict JSON only — JSONC features (comments, trailing commas) that
/// VS Code tolerates will error here with a hint at `--replace`.
/// Missing / empty files yield an empty map.
fn read_root_object(path: &Utf8Path) -> Result<Map<String, Value>> {
match fs::read_to_string(path) {
Ok(content) if content.trim().is_empty() => Ok(Map::new()),
Ok(content) => match serde_json::from_str::<Value>(&content) {
Ok(Value::Object(map)) => Ok(map),
Ok(other) => anyhow::bail!(
"{} is not a JSON object (found {}); refusing to merge. Use --replace to overwrite.",
path,
describe_value(&other),
),
Err(e) => anyhow::bail!(
"{} is not valid strict JSON ({}). VSCode tolerates comments and trailing commas, but this tool does not. Either remove the JSONC features or pass --replace.",
path,
e,
),
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Map::new()),
Err(e) => Err(e).with_context(|| format!("reading {path}")),
}
}
fn describe_value(value: &Value) -> &'static str {
match value {
Value::Null => "null",
Value::Bool(_) => "bool",
Value::Number(_) => "number",
Value::String(_) => "string",
Value::Array(_) => "array",
Value::Object(_) => "object",
}
}
/// `Some(key)` creates `root[key] = {}` if missing; rejects a
/// non-object so we don't silently clobber user-typed values.
fn merge_target_mut<'a>(
path: &Utf8Path,
root: &'a mut Map<String, Value>,
settings_key: Option<&str>,
) -> Result<&'a mut Map<String, Value>> {
let Some(key) = settings_key else {
return Ok(root);
};
let entry = root
.entry(key.to_owned())
.or_insert_with(|| Value::Object(Map::new()));
match entry {
Value::Object(map) => Ok(map),
other => anyhow::bail!(
"{} has `{key}` set to a {}, expected an object — refusing to merge into a non-object container.",
path,
describe_value(other),
),
}
}
fn merge_into_existing(
path: &Utf8Path,
managed: Vec<(String, ManagedValue)>,
settings_key: Option<&str>,
) -> Result<Value> {
let mut root = read_root_object(path)?;
{
let target = merge_target_mut(path, &mut root, settings_key)?;
for (k, v) in managed {
apply_managed(target, k, v);
}
}
Ok(Value::Object(root))
}
/// `--replace` path. With `settings_key = Some` we only replace
/// `root[key]` — `folders` / `tasks` / `extensions` on a
/// `.code-workspace` must survive, since the user can't
/// usefully `--replace` the whole workspace file.
fn replace_managed(
path: &Utf8Path,
managed: Vec<(String, ManagedValue)>,
settings_key: Option<&str>,
) -> Result<Value> {
let mut fresh = Map::new();
for (k, v) in managed {
fresh.insert(k, realize_managed(v));
}
let Some(key) = settings_key else {
return Ok(Value::Object(fresh));
};
let mut root = read_root_object(path)?;
root.insert(key.to_owned(), Value::Object(fresh));
Ok(Value::Object(root))
}
fn apply_managed(object: &mut Map<String, Value>, key: String, value: ManagedValue) {
match value {
ManagedValue::Replace(v) => {
object.insert(key, v);
}
ManagedValue::InsertEntries(entries) => {
// Coerce the existing entry to an object. If a user managed to
// set, say, `files.exclude` to a non-object value, we replace
// it — VSCode wouldn't accept that anyway.
let existing = object
.entry(key)
.or_insert_with(|| Value::Object(Map::new()));
if !matches!(existing, Value::Object(_)) {
*existing = Value::Object(Map::new());
}
let Value::Object(map) = existing else {
unreachable!("just assigned to Value::Object above")
};
for (sub_k, sub_v) in entries {
// `or_insert` preserves any pre-existing entry under the
// same glob, including explicit `false` overrides — that's
// how users opt out of an individual exclude without us
// re-adding it on the next run.
map.entry(sub_k).or_insert(sub_v);
}
}
ManagedValue::InsertListEntries(entries) => {
// Same shape-coercion as InsertEntries but for arrays.
let existing = object
.entry(key)
.or_insert_with(|| Value::Array(Vec::new()));
if !matches!(existing, Value::Array(_)) {
*existing = Value::Array(Vec::new());
}
let Value::Array(arr) = existing else {
unreachable!("just assigned to Value::Array above")
};
for entry in entries {
if !arr.iter().any(|existing| existing == &entry) {
arr.push(entry);
}
}
}
}
}
/// Materialize a managed value into a fresh object — used by `--replace`,
/// which writes only our keys with no pre-existing context to merge into.
fn realize_managed(value: ManagedValue) -> Value {
match value {
ManagedValue::Replace(v) => v,
ManagedValue::InsertEntries(entries) => {
let mut m = Map::new();
for (k, v) in entries {
m.insert(k, v);
}
Value::Object(m)
}
ManagedValue::InsertListEntries(entries) => Value::Array(entries),
}
}
fn write_settings(path: &Path, value: &Value) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("creating directory {}", parent.display()))?;
}
let mut json = serde_json::to_string_pretty(value)?;
json.push('\n');
fs::write(path, json).with_context(|| format!("writing {}", path.display()))?;
Ok(())
}
// ---------------------------------------------------------------------------
// Snippet generators for non-VSCode IDEs
//
// Each format has one main template constant and three optional sub-block
// constants (proc-macro / rustfmt / files.excludeDirs). The generator
// resolves each sub-block to a string (substituted-or-empty) and then does
// a final pass on the main template. Reads top-to-bottom in the format
// it's emitting — `cargo expand` or `cat`-friendly during review.
//
// Placeholders are `__SHOUTING_SNAKE__` everywhere. The substitution is a
// literal string replace, so paths must not contain the placeholder
// strings themselves (in practice never true).
// ---------------------------------------------------------------------------
// Universal placeholders used across all snippet templates.
const TPL_RA_LAUNCHER: &str = "__RA_LAUNCHER__";
const TPL_DISCOVER_LAUNCHER: &str = "__DISCOVER_LAUNCHER__";
const TPL_PMS_LAUNCHER: &str = "__PMS_LAUNCHER__";
const TPL_RUSTFMT_LAUNCHER: &str = "__RUSTFMT_LAUNCHER__";
// `__EXCLUDE_ENTRIES__` is filled with the format-appropriate comma-
// separated string list (`"a", "b"` for Lua/TOML/JSON — same syntax in
// all three since they all quote with `"`).
const TPL_EXCLUDE_ENTRIES: &str = "__EXCLUDE_ENTRIES__";
// `__DISCOVER_PER_PACKAGE_ARG__` is filled with either `, "{arg}"` (when
// `--per-package-workspaces` is set) or the empty string. Same syntax
// across Lua/TOML/JSON since `"{arg}"` is a quoted string literal in all
// three formats.
const TPL_DISCOVER_PER_PACKAGE_ARG: &str = "__DISCOVER_PER_PACKAGE_ARG__";
const PER_PACKAGE_ARG_SUFFIX: &str = ", \"{arg}\"";
// Optional-block slots in the main templates.
const TPL_OPT_PROC_MACRO: &str = "__OPT_PROC_MACRO__";
const TPL_OPT_RUSTFMT: &str = "__OPT_RUSTFMT__";
const TPL_OPT_EXCLUDES: &str = "__OPT_EXCLUDES__";
// -- Neovim (nvim-lspconfig) Lua --
const NEOVIM_LUA_TEMPLATE: &str = r#"require("lspconfig").rust_analyzer.setup({
cmd = { "__RA_LAUNCHER__" },
settings = {
["rust-analyzer"] = {
workspace = {
discoverConfig = {
command = { "__DISCOVER_LAUNCHER__"__DISCOVER_PER_PACKAGE_ARG__ },
progressLabel = "rules_rust",
filesToWatch = { "BUILD", "BUILD.bazel", "MODULE.bazel", "WORKSPACE", "WORKSPACE.bazel" },
},
},
__OPT_PROC_MACRO____OPT_RUSTFMT____OPT_EXCLUDES__ lens = { enable = true },
},
},
})
"#;
const NEOVIM_LUA_PROC_MACRO: &str = r#" procMacro = {
server = "__PMS_LAUNCHER__",
},
"#;
const NEOVIM_LUA_RUSTFMT: &str = r#" rustfmt = {
overrideCommand = { "__RUSTFMT_LAUNCHER__" },
},
"#;
const NEOVIM_LUA_EXCLUDES: &str = r#" files = {
excludeDirs = { __EXCLUDE_ENTRIES__ },
},
"#;
// -- Helix languages.toml --
const HELIX_TOML_TEMPLATE: &str = r#"[language-server.rust-analyzer]
command = "__RA_LAUNCHER__"
[language-server.rust-analyzer.config.rust-analyzer.workspace.discoverConfig]
command = ["__DISCOVER_LAUNCHER__"__DISCOVER_PER_PACKAGE_ARG__]
progressLabel = "rules_rust"
filesToWatch = ["BUILD", "BUILD.bazel", "MODULE.bazel", "WORKSPACE", "WORKSPACE.bazel"]
__OPT_PROC_MACRO____OPT_RUSTFMT____OPT_EXCLUDES__
[language-server.rust-analyzer.config.rust-analyzer.lens]
enable = true
"#;
const HELIX_TOML_PROC_MACRO: &str = r#"
[language-server.rust-analyzer.config.rust-analyzer.procMacro]
server = "__PMS_LAUNCHER__"
"#;
const HELIX_TOML_RUSTFMT: &str = r#"
[language-server.rust-analyzer.config.rust-analyzer.rustfmt]
overrideCommand = ["__RUSTFMT_LAUNCHER__"]
"#;
const HELIX_TOML_EXCLUDES: &str = r#"
[language-server.rust-analyzer.config.rust-analyzer.files]
excludeDirs = [__EXCLUDE_ENTRIES__]
"#;
// -- Editor-agnostic JSON (coc.nvim, vim-lsp, ALE, ...) --
//
// JSON's trailing-comma intolerance is the awkward part: each optional
// block ends with `,\n ` so it slots between an existing comma-terminated
// line and the next un-comma'd key. The final unconditional key
// (`lens.enable`) has no trailing comma so the object closes cleanly
// whether or not any optionals fire.
const SETTINGS_JSON_TEMPLATE: &str = r#"{
"rust-analyzer.server.path": "__RA_LAUNCHER__",
"rust-analyzer.workspace.discoverConfig": {
"command": ["__DISCOVER_LAUNCHER__"__DISCOVER_PER_PACKAGE_ARG__],
"progressLabel": "rules_rust",
"filesToWatch": ["BUILD", "BUILD.bazel", "MODULE.bazel", "WORKSPACE", "WORKSPACE.bazel"]
},
__OPT_PROC_MACRO____OPT_RUSTFMT____OPT_EXCLUDES__"rust-analyzer.lens.enable": true
}
"#;
const SETTINGS_JSON_PROC_MACRO: &str = r#""rust-analyzer.procMacro.server": "__PMS_LAUNCHER__",
"#;
const SETTINGS_JSON_RUSTFMT: &str = r#""rust-analyzer.rustfmt.overrideCommand": ["__RUSTFMT_LAUNCHER__"],
"#;
const SETTINGS_JSON_EXCLUDES: &str = r#""rust-analyzer.files.excludeDirs": [__EXCLUDE_ENTRIES__],
"#;
// -- Helpers shared by all three generators --
/// Bracket a printed snippet with a banner on stderr so the user can
/// distinguish setup's own narration from the snippet itself.
fn print_snippet_with_banner(banner: &str, snippet: &str) {
eprintln!("\n========== {banner} ==========");
println!("{snippet}");
eprintln!("========== end ==========\n");
}
/// Format the cargo-excludes list for the `__EXCLUDE_ENTRIES__`
/// placeholder. All three target formats (Lua / TOML / JSON) quote
/// strings with `"` and separate with `, ` — same output works
/// everywhere.
fn cargo_excludes_as_quoted_list(ctx: &SetupCtx) -> Option<String> {
let excludes = find_cargo_dirs_to_exclude(&ctx.workspace);
if excludes.is_empty() {
None
} else {
Some(
excludes
.iter()
.map(|d| format!("{d:?}"))
.collect::<Vec<_>>()
.join(", "),
)
}
}
/// Resolve one of the three optional blocks. If `enabled`, run
/// `substitute` on the block template; otherwise return empty.
fn opt_block(enabled: bool, block: &str, substitute: impl FnOnce(&str) -> String) -> String {
if enabled {
substitute(block)
} else {
String::new()
}
}
/// Resolve the `__DISCOVER_PER_PACKAGE_ARG__` placeholder content. Returns
/// `, "{arg}"` (suffix to the discover-command array) when per-package
/// workspaces are on, empty string otherwise.
fn per_package_suffix(ctx: &SetupCtx) -> &'static str {
if ctx.per_package_workspaces {
PER_PACKAGE_ARG_SUFFIX
} else {
""
}
}
// -- Generators --
/// The four forward-slashed absolute paths every editor snippet
/// (Neovim Lua, Helix TOML, JSON) needs. Three come from the toolchain
/// directly; `discover` is at `<launcher_dir>/<DISCOVER_BINARY_FILENAME>`
/// (setup copied it there). Snippet generators use absolute paths
/// (unlike the VSCode subcommand's `${workspaceFolder}` references) —
/// snippets are user-pasted and gitignored, not committed. Precomputed
/// once per generator call so the optional-block closures can borrow
/// strings instead of re-running `to_forward_slashes` each time.
struct SnippetPaths {
ra: String,
pms: String,
rustfmt: String,
discover: String,
}
impl SnippetPaths {
fn for_ctx(ctx: &SetupCtx, launcher_dir: &Utf8Path) -> Self {
Self {
ra: to_forward_slashes(ctx.toolchain.rust_analyzer.as_str()),
pms: to_forward_slashes(ctx.toolchain.proc_macro_srv.as_str()),
rustfmt: to_forward_slashes(ctx.toolchain.rustfmt.as_str()),
discover: to_forward_slashes(launcher_dir.join(DISCOVER_BINARY_FILENAME).as_str()),
}
}
}
/// Render a snippet `main_template` plus three optional sub-templates
/// (proc-macro, rustfmt, excludes) under their `TPL_OPT_*`
/// placeholders. Shared by the three per-editor generators — they
/// differ only in their template constants.
fn render_snippet(
ctx: &SetupCtx,
paths: &SnippetPaths,
main_template: &str,
proc_macro_template: &str,
rustfmt_template: &str,
excludes_template: &str,
) -> String {
let proc_macro = opt_block(!ctx.skip_proc_macro_server, proc_macro_template, |t| {
t.replace(TPL_PMS_LAUNCHER, &paths.pms)
});
let rustfmt = opt_block(!ctx.skip_rustfmt, rustfmt_template, |t| {
t.replace(TPL_RUSTFMT_LAUNCHER, &paths.rustfmt)
});
let excludes = match cargo_excludes_as_quoted_list(ctx) {
Some(entries) => excludes_template.replace(TPL_EXCLUDE_ENTRIES, &entries),
None => String::new(),
};
main_template
.replace(TPL_RA_LAUNCHER, &paths.ra)
.replace(TPL_DISCOVER_LAUNCHER, &paths.discover)
.replace(TPL_DISCOVER_PER_PACKAGE_ARG, per_package_suffix(ctx))
.replace(TPL_OPT_PROC_MACRO, &proc_macro)
.replace(TPL_OPT_RUSTFMT, &rustfmt)
.replace(TPL_OPT_EXCLUDES, &excludes)
}
/// `nvim-lspconfig` Lua snippet. The user pastes this into their
/// `init.lua` (or similar). Absolute paths are baked in at install
/// time — re-run `setup neovim` if the workspace moves.
fn generate_neovim_lua(ctx: &SetupCtx, launcher_dir: &Utf8Path) -> String {
let paths = SnippetPaths::for_ctx(ctx, launcher_dir);
render_snippet(
ctx,
&paths,
NEOVIM_LUA_TEMPLATE,
NEOVIM_LUA_PROC_MACRO,
NEOVIM_LUA_RUSTFMT,
NEOVIM_LUA_EXCLUDES,
)
}
/// Helix `languages.toml` snippet. Pasted under
/// `.helix/languages.toml`. Absolute paths baked in (Helix's TOML
/// parser doesn't expand env or workspace vars).
fn generate_helix_toml(ctx: &SetupCtx, launcher_dir: &Utf8Path) -> String {
let paths = SnippetPaths::for_ctx(ctx, launcher_dir);
render_snippet(
ctx,
&paths,
HELIX_TOML_TEMPLATE,
HELIX_TOML_PROC_MACRO,
HELIX_TOML_RUSTFMT,
HELIX_TOML_EXCLUDES,
)
}
/// Editor-agnostic JSON snippet using the standard `rust-analyzer.*`
/// keys. Works with coc.nvim (`coc-settings.json`), helix-via-JSON,
/// vim-lsp, etc. — anything that lets you set `rust-analyzer` settings
/// as JSON.
fn generate_settings_json(ctx: &SetupCtx, launcher_dir: &Utf8Path) -> String {
let paths = SnippetPaths::for_ctx(ctx, launcher_dir);
render_snippet(
ctx,
&paths,
SETTINGS_JSON_TEMPLATE,
SETTINGS_JSON_PROC_MACRO,
SETTINGS_JSON_RUSTFMT,
SETTINGS_JSON_EXCLUDES,
)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
fn dummy_toolchain() -> ToolchainBinaries {
ToolchainBinaries {
rust_analyzer: Utf8PathBuf::from("/obase/external/ra/rust-analyzer"),
proc_macro_srv: Utf8PathBuf::from("/obase/external/ra/proc-macro-srv"),
rustfmt: Utf8PathBuf::from("/obase/external/rfmt/rustfmt"),
}
}
fn dummy_ctx() -> (SetupCtx, Utf8PathBuf) {
let workspace = Utf8PathBuf::from("/ws");
let launcher_dir = workspace.join(".vscode").join(LAUNCHER_SUBDIR);
let ctx = SetupCtx {
workspace,
launcher_dir: launcher_dir.clone(),
skip_proc_macro_server: false,
skip_rustfmt: false,
per_package_workspaces: false,
toolchain: dummy_toolchain(),
};
(ctx, launcher_dir)
}
/// Pull a `Replace`-flavored value out of a `(key, ManagedValue)` list
/// by key. Panics if missing or if the entry uses one of the merge
/// strategies.
fn replace_value<'a>(keys: &'a [(String, ManagedValue)], key: &str) -> &'a Value {
let entry = keys
.iter()
.find(|(k, _)| k == key)
.unwrap_or_else(|| panic!("missing managed key {key}"));
match &entry.1 {
ManagedValue::Replace(v) => v,
ManagedValue::InsertEntries(_) | ManagedValue::InsertListEntries(_) => {
panic!("expected Replace strategy for {key}")
}
}
}
#[test]
fn merge_preserves_user_keys() {
let (ctx, _launcher_dir) = dummy_ctx();
let tmp = std::env::temp_dir().join(format!("setup_test_{}", std::process::id()));
std::fs::create_dir_all(&tmp).unwrap();
let path = Utf8PathBuf::try_from(tmp.join("settings.json")).unwrap();
fs::write(
&path,
r#"{"editor.tabSize": 4, "rust-analyzer.server.path": "old"}"#,
)
.unwrap();
let merged = merge_into_existing(&path, vscode_managed_keys(&ctx), None).unwrap();
let obj = merged.as_object().unwrap();
// User key preserved
assert_eq!(obj.get("editor.tabSize"), Some(&json!(4)));
// Managed key overwritten and points at the launcher shim.
assert_eq!(
obj.get(SERVER_PATH_KEY).unwrap().as_str().unwrap(),
"${workspaceFolder}/.rules_rust_analyzer/rust_analyzer.exe"
);
// discoverConfig present
assert!(obj.get(DISCOVER_CONFIG_KEY).is_some());
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn merge_handles_missing_file() {
let (ctx, _launcher_dir) = dummy_ctx();
let tmp = std::env::temp_dir().join(format!("setup_test2_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
let path = Utf8PathBuf::try_from(tmp.join("settings.json")).unwrap();
let merged = merge_into_existing(&path, vscode_managed_keys(&ctx), None).unwrap();
let obj = merged.as_object().unwrap();
assert!(obj.contains_key(DISCOVER_CONFIG_KEY));
assert!(obj.contains_key(SERVER_PATH_KEY));
assert!(obj.contains_key(PROC_MACRO_SRV_KEY));
assert!(obj.contains_key(RUSTFMT_OVERRIDE_KEY));
assert!(obj.contains_key(FILES_WATCHER_EXCLUDE_KEY));
assert!(obj.contains_key(FILES_EXCLUDE_KEY));
assert!(obj.contains_key(SEARCH_EXCLUDE_KEY));
}
#[test]
fn skip_proc_macro_server_drops_one_key() {
let (mut ctx, _launcher_dir) = dummy_ctx();
let with_srv = vscode_managed_keys(&ctx);
ctx.skip_proc_macro_server = true;
let without_srv = vscode_managed_keys(&ctx);
// 4 rust-analyzer keys + 3 exclude maps = 7 total.
assert_eq!(with_srv.len(), 7);
assert_eq!(without_srv.len(), 6);
assert!(!without_srv.iter().any(|(k, _)| k == PROC_MACRO_SRV_KEY));
}
#[test]
fn skip_rustfmt_drops_only_the_rustfmt_key() {
let (mut ctx, _launcher_dir) = dummy_ctx();
let with_fmt = vscode_managed_keys(&ctx);
ctx.skip_rustfmt = true;
let without_fmt = vscode_managed_keys(&ctx);
assert_eq!(with_fmt.len(), 7);
assert_eq!(without_fmt.len(), 6);
assert!(!without_fmt.iter().any(|(k, _)| k == RUSTFMT_OVERRIDE_KEY));
// The proc-macro key still rides along.
assert!(without_fmt.iter().any(|(k, _)| k == PROC_MACRO_SRV_KEY));
}
#[test]
fn rustfmt_override_is_argv_array_pointing_at_launcher() {
let (ctx, _launcher_dir) = dummy_ctx();
let keys = vscode_managed_keys(&ctx);
let val = replace_value(&keys, RUSTFMT_OVERRIDE_KEY);
let arr = val.as_array().unwrap();
assert_eq!(arr.len(), 1);
assert_eq!(
arr[0].as_str().unwrap(),
"${workspaceFolder}/.rules_rust_analyzer/rustfmt.exe"
);
}
#[test]
fn vscode_managed_keys_emit_workspace_folder_refs_with_exe_suffix() {
let (ctx, _launcher_dir) = dummy_ctx();
let keys = vscode_managed_keys(&ctx);
// All four managed path keys reference the launcher dir via
// `${workspaceFolder}` so the committed settings file is
// portable across developers and platforms.
assert_eq!(
replace_value(&keys, SERVER_PATH_KEY).as_str().unwrap(),
"${workspaceFolder}/.rules_rust_analyzer/rust_analyzer.exe"
);
assert_eq!(
replace_value(&keys, PROC_MACRO_SRV_KEY).as_str().unwrap(),
"${workspaceFolder}/.rules_rust_analyzer/rust_analyzer_proc_macro_srv.exe"
);
let discover_cmd = replace_value(&keys, DISCOVER_CONFIG_KEY)
.get("command")
.and_then(|v| v.as_array())
.expect("command must be an array");
assert_eq!(
discover_cmd[0].as_str().unwrap(),
"${workspaceFolder}/.rules_rust_analyzer/discover_bazel_rust_project.exe"
);
}
#[test]
fn watcher_exclude_dict_merges_with_user_entries() {
let (ctx, _launcher_dir) = dummy_ctx();
let tmp = std::env::temp_dir().join(format!("setup_excludes_test_{}", std::process::id()));
std::fs::create_dir_all(&tmp).unwrap();
let path = Utf8PathBuf::try_from(tmp.join("settings.json")).unwrap();
// User already had one watcher exclude AND an explicit `false`
// override of our glob. Both must survive intact.
fs::write(
&path,
r#"{
"files.watcherExclude": {
"**/node_modules/**": true,
"**/bazel-*/**": false
}
}"#,
)
.unwrap();
let merged = merge_into_existing(&path, vscode_managed_keys(&ctx), None).unwrap();
let watchers = merged
.as_object()
.unwrap()
.get(FILES_WATCHER_EXCLUDE_KEY)
.unwrap()
.as_object()
.unwrap();
// User's other entry preserved.
assert_eq!(watchers.get("**/node_modules/**"), Some(&json!(true)));
// User's explicit override of our glob preserved — we did NOT
// overwrite their `false` with our `true`.
assert_eq!(watchers.get(BAZEL_OUTPUTS_GLOB), Some(&json!(false)));
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn find_cargo_dirs_finds_immediate_subdirs_with_cargo_toml() {
// Mock workspace: rules_rust-ish layout with two cargo dirs to
// exclude, one Bazel dir to ignore, one dot-dir to ignore, and
// one ordinary dir without Cargo.toml that should be left alone.
let tmp = std::env::temp_dir().join(format!("setup_cargo_dirs_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(&tmp).unwrap();
for d in ["cargo", "crate_universe", "bazel-bin", ".git", "util"] {
std::fs::create_dir_all(tmp.join(d)).unwrap();
}
for d in ["cargo", "crate_universe", "bazel-bin"] {
std::fs::write(tmp.join(d).join("Cargo.toml"), "[package]\nname=\"x\"\n").unwrap();
}
// `rust/runfiles/Cargo.toml` exists in rules_rust but `rust/`
// itself has no Cargo.toml at one level — verify we DON'T add
// `rust` (since rust-analyzer's one-level scan won't find it
// either, no point excluding it).
std::fs::create_dir_all(tmp.join("rust").join("runfiles")).unwrap();
std::fs::write(
tmp.join("rust").join("runfiles").join("Cargo.toml"),
"[package]\nname=\"runfiles\"\n",
)
.unwrap();
let dirs = find_cargo_dirs_to_exclude(&Utf8PathBuf::try_from(tmp.clone()).unwrap());
assert_eq!(
dirs,
vec!["cargo".to_string(), "crate_universe".to_string()]
);
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn insert_list_entries_preserves_user_values_and_dedupes() {
let mut obj = Map::new();
obj.insert(
"rust-analyzer.files.excludeDirs".to_string(),
json!(["user_dir", "cargo"]),
);
apply_managed(
&mut obj,
"rust-analyzer.files.excludeDirs".to_string(),
ManagedValue::InsertListEntries(vec![json!("cargo"), json!("crate_universe")]),
);
let arr = obj
.get("rust-analyzer.files.excludeDirs")
.unwrap()
.as_array()
.unwrap();
// user_dir preserved, cargo not duplicated, crate_universe added.
assert_eq!(
arr,
&vec![json!("user_dir"), json!("cargo"), json!("crate_universe")]
);
}
/// Build a `VscodeArgs` for tests that exercises a specific output
/// path / settings_key combo. Production code goes through
/// `resolve_vscode_targets`; tests of `launcher_dir_for` care only
/// about the post-resolution `ResolvedVscodeTargets`.
fn vscode_targets(output: &str, settings_key: Option<&str>) -> ResolvedVscodeTargets {
ResolvedVscodeTargets {
output: Utf8PathBuf::from(output),
settings_key: settings_key.map(str::to_owned),
}
}
fn empty_vscode_args() -> VscodeArgs {
VscodeArgs {
output: None,
settings_key: None,
dry_run: false,
replace: false,
}
}
#[test]
fn launcher_dir_for_picks_editor_specific_subdir() {
let ws = Utf8PathBuf::from("/workspace");
let vscode = IdeCmd::Vscode(empty_vscode_args());
let vscode_resolved = vscode_targets("/workspace/.vscode/settings.json", None);
assert_eq!(
launcher_dir_for(&ws, &vscode, Some(&vscode_resolved)),
Utf8PathBuf::from("/workspace/.vscode/.rules_rust_analyzer"),
);
assert_eq!(
launcher_dir_for(&ws, &IdeCmd::Helix, None),
Utf8PathBuf::from("/workspace/.helix/.rules_rust_analyzer"),
);
assert_eq!(
launcher_dir_for(&ws, &IdeCmd::Neovim, None),
Utf8PathBuf::from("/workspace/.rules_rust_analyzer"),
);
assert_eq!(
launcher_dir_for(&ws, &IdeCmd::Print, None),
Utf8PathBuf::from("/workspace/.rules_rust_analyzer"),
);
}
#[test]
fn launcher_dir_for_vscode_honors_custom_output() {
let ws = Utf8PathBuf::from("/workspace");
// Custom output path → launcher dir sits alongside it.
let vscode = IdeCmd::Vscode(empty_vscode_args());
let resolved = vscode_targets("/workspace/.custom/conf.json", None);
assert_eq!(
launcher_dir_for(&ws, &vscode, Some(&resolved)),
Utf8PathBuf::from("/workspace/.custom/.rules_rust_analyzer"),
);
}
#[test]
fn launcher_dir_for_vscode_with_code_workspace_targets_workspace_root() {
// A `.code-workspace` at the workspace root means the launcher
// dir sits at the workspace root too (same shape as Neovim /
// Print), not under `.vscode/`.
let ws = Utf8PathBuf::from("/workspace");
let vscode = IdeCmd::Vscode(empty_vscode_args());
let resolved = vscode_targets("/workspace/myproj.code-workspace", Some("settings"));
assert_eq!(
launcher_dir_for(&ws, &vscode, Some(&resolved)),
Utf8PathBuf::from("/workspace/.rules_rust_analyzer"),
);
}
#[test]
fn neovim_snippet_contains_toolchain_and_discover_paths_and_lens_enable() {
let (ctx, _) = dummy_ctx();
let launcher_dir = ctx.workspace.join(LAUNCHER_SUBDIR);
let snippet = generate_neovim_lua(&ctx, &launcher_dir);
assert!(snippet.contains("require(\"lspconfig\").rust_analyzer.setup"));
// rust-analyzer LSP from the toolchain.
assert!(snippet.contains("/obase/external/ra/rust-analyzer"));
// discover binary from the launcher dir.
assert!(snippet.contains("/ws/.rules_rust_analyzer/discover_bazel_rust_project"));
assert!(snippet.contains("lens = { enable = true }"));
}
#[test]
fn helix_snippet_uses_toml_section_headers() {
let (ctx, _) = dummy_ctx();
let launcher_dir = ctx.workspace.join(".helix").join(LAUNCHER_SUBDIR);
let snippet = generate_helix_toml(&ctx, &launcher_dir);
assert!(snippet.contains("[language-server.rust-analyzer]"));
assert!(snippet.contains(
"[language-server.rust-analyzer.config.rust-analyzer.workspace.discoverConfig]"
));
// rust-analyzer LSP path comes from the toolchain.
assert!(snippet.contains("/obase/external/ra/rust-analyzer"));
// discover sits next to the helix-specific launcher dir.
assert!(snippet.contains("/ws/.helix/.rules_rust_analyzer/discover_bazel_rust_project"));
}
#[test]
fn print_snippet_is_valid_json() {
let (ctx, _) = dummy_ctx();
let launcher_dir = ctx.workspace.join(LAUNCHER_SUBDIR);
let snippet = generate_settings_json(&ctx, &launcher_dir);
let parsed: Value = serde_json::from_str(&snippet).expect("snippet must be valid JSON");
let obj = parsed.as_object().unwrap();
assert!(obj.contains_key(SERVER_PATH_KEY));
assert!(obj.contains_key(DISCOVER_CONFIG_KEY));
assert!(obj.contains_key(PROC_MACRO_SRV_KEY));
assert!(obj.contains_key(RUSTFMT_OVERRIDE_KEY));
// Toolchain paths land in the right JSON keys.
assert_eq!(
obj.get(SERVER_PATH_KEY).unwrap().as_str().unwrap(),
"/obase/external/ra/rust-analyzer"
);
assert_eq!(
obj.get(PROC_MACRO_SRV_KEY).unwrap().as_str().unwrap(),
"/obase/external/ra/proc-macro-srv"
);
}
/// JSON's trailing-comma intolerance is the trickiest template-
/// substitution case; verify every combination of dropped optional
/// blocks still produces valid JSON.
#[test]
fn print_snippet_is_valid_json_under_every_skip_combination() {
for &(skip_pms, skip_fmt) in &[(false, false), (true, false), (false, true), (true, true)] {
let (mut ctx, _) = dummy_ctx();
ctx.skip_proc_macro_server = skip_pms;
ctx.skip_rustfmt = skip_fmt;
let launcher_dir = ctx.workspace.join(LAUNCHER_SUBDIR);
let snippet = generate_settings_json(&ctx, &launcher_dir);
serde_json::from_str::<Value>(&snippet).unwrap_or_else(|e| {
panic!(
"snippet not valid JSON for skip_pms={skip_pms} skip_fmt={skip_fmt}: {e}\n--- snippet ---\n{snippet}"
)
});
}
}
/// `skip_*` flags should drop the matching optional sub-block from
/// the Lua / TOML / JSON output entirely. Verifying via substring
/// search on the format-specific marker keeps the test simple — if
/// the marker drifts, both this and the format-specific generator
/// need to be updated, which is the right coupling.
#[test]
fn skip_flags_drop_optional_blocks_from_all_snippets() {
let (mut ctx, _) = dummy_ctx();
ctx.skip_proc_macro_server = true;
ctx.skip_rustfmt = true;
let launcher_dir = ctx.workspace.join(LAUNCHER_SUBDIR);
let lua = generate_neovim_lua(&ctx, &launcher_dir);
assert!(
!lua.contains("procMacro"),
"lua: procMacro block leaked\n{lua}"
);
assert!(
!lua.contains("rustfmt = {"),
"lua: rustfmt block leaked\n{lua}"
);
// Discover + RA paths still present.
assert!(lua.contains("discoverConfig"));
let toml = generate_helix_toml(&ctx, &launcher_dir);
assert!(
!toml.contains("procMacro"),
"helix: procMacro block leaked\n{toml}"
);
assert!(
!toml.contains("rustfmt]"),
"helix: rustfmt block leaked\n{toml}"
);
let json = generate_settings_json(&ctx, &launcher_dir);
let parsed: Value = serde_json::from_str(&json).unwrap();
let obj = parsed.as_object().unwrap();
assert!(!obj.contains_key(PROC_MACRO_SRV_KEY));
assert!(!obj.contains_key(RUSTFMT_OVERRIDE_KEY));
// Sanity: required keys still there.
assert!(obj.contains_key(SERVER_PATH_KEY));
assert!(obj.contains_key(DISCOVER_CONFIG_KEY));
}
/// Default (per-package off) → discover command has only the binary
/// path. Opt-in (per-package on) → discover command also has `"{arg}"`,
/// the rust-analyzer placeholder for the file the user opened.
#[test]
fn discover_command_includes_per_package_arg_only_when_opted_in() {
let (mut ctx, _launcher_dir) = dummy_ctx();
// Default: per_package_workspaces = false.
let keys = vscode_managed_keys(&ctx);
let cmd = replace_value(&keys, DISCOVER_CONFIG_KEY)
.get("command")
.and_then(|v| v.as_array())
.expect("command must be an array");
assert_eq!(
cmd.len(),
1,
"default: discover command should be [binary]; got {cmd:?}"
);
// Opt-in.
ctx.per_package_workspaces = true;
let keys = vscode_managed_keys(&ctx);
let cmd = replace_value(&keys, DISCOVER_CONFIG_KEY)
.get("command")
.and_then(|v| v.as_array())
.expect("command must be an array");
assert_eq!(
cmd.len(),
2,
"per-package on: discover command should be [binary, \"{{arg}}\"]; got {cmd:?}"
);
assert_eq!(cmd[1].as_str(), Some("{arg}"));
}
/// Same default-vs-opt-in coverage but for the Lua/TOML/JSON snippets,
/// since those go through a totally different substitution path.
#[test]
fn snippets_include_per_package_arg_only_when_opted_in() {
let (mut ctx, launcher_dir) = dummy_ctx();
// Default off.
let lua = generate_neovim_lua(&ctx, &launcher_dir);
let toml = generate_helix_toml(&ctx, &launcher_dir);
let json = generate_settings_json(&ctx, &launcher_dir);
assert!(
!lua.contains("\"{arg}\""),
"lua leaks {{arg}} when per-package off:\n{lua}"
);
assert!(
!toml.contains("\"{arg}\""),
"toml leaks {{arg}} when per-package off:\n{toml}"
);
assert!(
!json.contains("\"{arg}\""),
"json leaks {{arg}} when per-package off:\n{json}"
);
// No leftover placeholder either way.
for body in [&lua, &toml, &json] {
assert!(!body.contains(TPL_DISCOVER_PER_PACKAGE_ARG));
}
// Opt-in.
ctx.per_package_workspaces = true;
let lua = generate_neovim_lua(&ctx, &launcher_dir);
let toml = generate_helix_toml(&ctx, &launcher_dir);
let json = generate_settings_json(&ctx, &launcher_dir);
assert!(
lua.contains("\"{arg}\""),
"lua missing {{arg}} when per-package on:\n{lua}"
);
assert!(
toml.contains("\"{arg}\""),
"toml missing {{arg}} when per-package on:\n{toml}"
);
assert!(
json.contains("\"{arg}\""),
"json missing {{arg}} when per-package on:\n{json}"
);
// JSON must still parse.
serde_json::from_str::<Value>(&json).expect("json snippet stays valid with per-package on");
}
#[test]
fn snippet_templates_contain_every_placeholder_they_reference() {
for (name, body) in [
("neovim main", NEOVIM_LUA_TEMPLATE),
("helix main", HELIX_TOML_TEMPLATE),
("json main", SETTINGS_JSON_TEMPLATE),
] {
for ph in [
TPL_RA_LAUNCHER,
TPL_DISCOVER_LAUNCHER,
TPL_DISCOVER_PER_PACKAGE_ARG,
TPL_OPT_PROC_MACRO,
TPL_OPT_RUSTFMT,
TPL_OPT_EXCLUDES,
] {
assert!(body.contains(ph), "{name} missing {ph}");
}
}
for (name, body) in [
("neovim proc_macro", NEOVIM_LUA_PROC_MACRO),
("helix proc_macro", HELIX_TOML_PROC_MACRO),
("json proc_macro", SETTINGS_JSON_PROC_MACRO),
] {
assert!(
body.contains(TPL_PMS_LAUNCHER),
"{name} missing {TPL_PMS_LAUNCHER}"
);
}
for (name, body) in [
("neovim rustfmt", NEOVIM_LUA_RUSTFMT),
("helix rustfmt", HELIX_TOML_RUSTFMT),
("json rustfmt", SETTINGS_JSON_RUSTFMT),
] {
assert!(
body.contains(TPL_RUSTFMT_LAUNCHER),
"{name} missing {TPL_RUSTFMT_LAUNCHER}"
);
}
for (name, body) in [
("neovim excludes", NEOVIM_LUA_EXCLUDES),
("helix excludes", HELIX_TOML_EXCLUDES),
("json excludes", SETTINGS_JSON_EXCLUDES),
] {
assert!(
body.contains(TPL_EXCLUDE_ENTRIES),
"{name} missing {TPL_EXCLUDE_ENTRIES}"
);
}
}
// -----------------------------------------------------------------
// `.code-workspace` support
// -----------------------------------------------------------------
/// Build a workspace dir in $TMPDIR, populated with the listed
/// files. Returns the dir path; caller is responsible for cleanup
/// (use `remove_dir_all` in a `_guard`-style drop, or accept the
/// leak — TMPDIR gets cleaned eventually).
fn make_workspace(tag: &str, files: &[(&str, &str)]) -> Utf8PathBuf {
let tmp = std::env::temp_dir().join(format!("setup_{tag}_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(&tmp).unwrap();
for (name, content) in files {
std::fs::write(tmp.join(name), content).unwrap();
}
Utf8PathBuf::try_from(tmp).unwrap()
}
#[test]
fn merge_under_settings_key_preserves_top_level_keys() {
let (ctx, _launcher_dir) = dummy_ctx();
let ws = make_workspace(
"merge_nest_preserve",
&[(
"proj.code-workspace",
r#"{
"folders": [{"path": "."}],
"tasks": {"version": "2.0.0"},
"settings": {"editor.tabSize": 4}
}"#,
)],
);
let path = ws.join("proj.code-workspace");
let merged =
merge_into_existing(&path, vscode_managed_keys(&ctx), Some("settings")).unwrap();
let root = merged.as_object().unwrap();
// Top-level sibling keys survived intact.
assert_eq!(
root.get("folders").unwrap(),
&json!([{"path": "."}]),
"folders must survive a merge under settings_key",
);
assert_eq!(
root.get("tasks").unwrap(),
&json!({"version": "2.0.0"}),
"tasks must survive a merge under settings_key",
);
// Managed keys landed inside `settings`, NOT at the root.
assert!(!root.contains_key(SERVER_PATH_KEY));
let settings = root.get("settings").unwrap().as_object().unwrap();
assert_eq!(
settings.get(SERVER_PATH_KEY).unwrap().as_str().unwrap(),
"${workspaceFolder}/.rules_rust_analyzer/rust_analyzer.exe"
);
// Pre-existing user setting inside `settings` survived.
assert_eq!(settings.get("editor.tabSize"), Some(&json!(4)));
let _ = std::fs::remove_dir_all(&ws);
}
#[test]
fn merge_under_settings_key_creates_settings_when_missing() {
let (ctx, _launcher_dir) = dummy_ctx();
let ws = make_workspace(
"merge_nest_create",
&[("proj.code-workspace", r#"{"folders": [{"path": "."}]}"#)],
);
let path = ws.join("proj.code-workspace");
let merged =
merge_into_existing(&path, vscode_managed_keys(&ctx), Some("settings")).unwrap();
let settings = merged
.as_object()
.unwrap()
.get("settings")
.expect("settings object should have been created")
.as_object()
.unwrap();
assert!(settings.contains_key(DISCOVER_CONFIG_KEY));
let _ = std::fs::remove_dir_all(&ws);
}
#[test]
fn merge_under_settings_key_errors_on_non_object_settings() {
let (ctx, _launcher_dir) = dummy_ctx();
let ws = make_workspace(
"merge_nest_nonobj",
&[("proj.code-workspace", r#"{"settings": "not an object"}"#)],
);
let path = ws.join("proj.code-workspace");
let err = merge_into_existing(&path, vscode_managed_keys(&ctx), Some("settings"))
.unwrap_err()
.to_string();
assert!(
err.contains("`settings`") && err.contains("string"),
"error should name the bad key and its type, got: {err}"
);
let _ = std::fs::remove_dir_all(&ws);
}
#[test]
fn replace_under_settings_key_preserves_top_level() {
let (ctx, _launcher_dir) = dummy_ctx();
let ws = make_workspace(
"replace_nest",
&[(
"proj.code-workspace",
r#"{
"folders": [{"path": "."}],
"extensions": {"recommendations": ["rust-lang.rust-analyzer"]},
"settings": {"editor.tabSize": 4, "rust-analyzer.server.path": "stale"}
}"#,
)],
);
let path = ws.join("proj.code-workspace");
let replaced = replace_managed(&path, vscode_managed_keys(&ctx), Some("settings")).unwrap();
let root = replaced.as_object().unwrap();
// Siblings survived.
assert_eq!(root.get("folders").unwrap(), &json!([{"path": "."}]));
assert_eq!(
root.get("extensions").unwrap(),
&json!({"recommendations": ["rust-lang.rust-analyzer"]}),
);
// `settings` was fully replaced — user's editor.tabSize is gone
// (the documented `--replace` semantics).
let settings = root.get("settings").unwrap().as_object().unwrap();
assert!(!settings.contains_key("editor.tabSize"));
assert!(settings.contains_key(SERVER_PATH_KEY));
let _ = std::fs::remove_dir_all(&ws);
}
#[test]
fn replace_without_settings_key_overwrites_root_as_before() {
// Backwards-compat: --replace without --settings-key still
// produces just the managed keys at the root.
let (ctx, _launcher_dir) = dummy_ctx();
let ws = make_workspace("replace_root", &[]);
let path = ws.join("settings.json");
let replaced = replace_managed(&path, vscode_managed_keys(&ctx), None).unwrap();
let root = replaced.as_object().unwrap();
assert!(root.contains_key(SERVER_PATH_KEY));
assert!(root.contains_key(DISCOVER_CONFIG_KEY));
// No leftover sibling keys (nothing existed to start with).
let _ = std::fs::remove_dir_all(&ws);
}
#[test]
fn autodetect_picks_unique_code_workspace() {
let ws = make_workspace("autodetect_one", &[("myproj.code-workspace", "{}")]);
let args = empty_vscode_args();
let resolved = resolve_vscode_targets(&ws, &args).unwrap();
assert_eq!(resolved.output, ws.join("myproj.code-workspace"));
// Extension drives the default settings_key.
assert_eq!(resolved.settings_key.as_deref(), Some("settings"));
let _ = std::fs::remove_dir_all(&ws);
}
#[test]
fn autodetect_errors_on_multiple_code_workspaces() {
let ws = make_workspace(
"autodetect_multi",
&[("a.code-workspace", "{}"), ("b.code-workspace", "{}")],
);
let err = resolve_vscode_targets(&ws, &empty_vscode_args())
.unwrap_err()
.to_string();
assert!(
err.contains("a.code-workspace") && err.contains("b.code-workspace"),
"error should list both candidates, got: {err}"
);
let _ = std::fs::remove_dir_all(&ws);
}
#[test]
fn autodetect_falls_back_to_dot_vscode_settings() {
let ws = make_workspace("autodetect_none", &[]);
let resolved = resolve_vscode_targets(&ws, &empty_vscode_args()).unwrap();
assert_eq!(resolved.output, ws.join(".vscode/settings.json"));
assert!(resolved.settings_key.is_none());
let _ = std::fs::remove_dir_all(&ws);
}
#[test]
fn explicit_output_code_workspace_sets_settings_key_from_extension() {
// User passes `--output foo.code-workspace` but forgets
// `--settings-key`. The extension wins and nests under
// `settings` automatically — otherwise we'd corrupt their
// workspace file by writing managed keys at the root.
let ws = Utf8PathBuf::from("/ws");
let args = VscodeArgs {
output: Some(Utf8PathBuf::from("/ws/foo.code-workspace")),
settings_key: None,
dry_run: false,
replace: false,
};
let resolved = resolve_vscode_targets(&ws, &args).unwrap();
assert_eq!(resolved.output, Utf8PathBuf::from("/ws/foo.code-workspace"));
assert_eq!(resolved.settings_key.as_deref(), Some("settings"));
}
#[test]
fn explicit_settings_key_overrides_extension_default() {
let ws = Utf8PathBuf::from("/ws");
let args = VscodeArgs {
output: Some(Utf8PathBuf::from("/ws/foo.code-workspace")),
settings_key: Some("my_settings".to_owned()),
dry_run: false,
replace: false,
};
let resolved = resolve_vscode_targets(&ws, &args).unwrap();
assert_eq!(resolved.settings_key.as_deref(), Some("my_settings"));
}
#[test]
fn explicit_settings_key_nests_under_arbitrary_key() {
// For non-`.code-workspace` consumers who want nesting under
// a custom key (e.g., a homegrown wrapper). Confirms the flag
// works independent of the extension autodetect.
let (ctx, _launcher_dir) = dummy_ctx();
let ws = make_workspace("nest_custom", &[("settings.json", "{}")]);
let path = ws.join("settings.json");
let merged = merge_into_existing(&path, vscode_managed_keys(&ctx), Some("rust")).unwrap();
let nested = merged
.as_object()
.unwrap()
.get("rust")
.expect("nested key should be created")
.as_object()
.unwrap();
assert!(nested.contains_key(SERVER_PATH_KEY));
let _ = std::fs::remove_dir_all(&ws);
}
// -----------------------------------------------------------------
// Launcher install
// -----------------------------------------------------------------
#[test]
fn write_launcher_paths_json_emits_three_logical_names() {
let tmp = std::env::temp_dir().join(format!("setup_launcher_json_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(&tmp).unwrap();
let out = Utf8PathBuf::try_from(tmp.join("launcher_paths.json")).unwrap();
write_launcher_paths_json(&out, &dummy_toolchain()).unwrap();
let parsed: Value = serde_json::from_str(&fs::read_to_string(&out).unwrap()).unwrap();
let obj = parsed.as_object().unwrap();
assert_eq!(
obj.get("rust_analyzer").unwrap().as_str().unwrap(),
"/obase/external/ra/rust-analyzer"
);
assert_eq!(
obj.get("rust_analyzer_proc_macro_srv")
.unwrap()
.as_str()
.unwrap(),
"/obase/external/ra/proc-macro-srv"
);
assert_eq!(
obj.get("rustfmt").unwrap().as_str().unwrap(),
"/obase/external/rfmt/rustfmt"
);
let _ = std::fs::remove_dir_all(&tmp);
}
}