| //! 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, io, path::Path}; |
| |
| use anyhow::{Context, Result}; |
| use camino::{Utf8Path, Utf8PathBuf}; |
| use clap::{Args, Parser, Subcommand}; |
| use jsonc_parser::{ |
| cst::{CstInputValue, CstObject, CstRootNode}, |
| ParseOptions, |
| }; |
| use log::info; |
| use runfiles::{rlocation, Runfiles}; |
| use serde_json::{json, 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"; |
| /// Load-bearing: without this override, RA falls back to `cargo |
| /// check` for any auto-detected `Cargo.toml`, creating `target/` and |
| /// emitting cargo-anchored diagnostic paths. |
| const CHECK_OVERRIDE_KEY: &str = "rust-analyzer.check.overrideCommand"; |
| |
| const FILES_WATCHER_EXCLUDE_KEY: &str = "files.watcherExclude"; |
| const SEARCH_EXCLUDE_KEY: &str = "search.exclude"; |
| |
| /// Bazel's convenience symlinks (`bazel-bin/`, `bazel-out/`, etc). |
| /// Skipping them keeps the OS file-watch limit from thrashing on |
| /// every `bazel build`. |
| const BAZEL_OUTPUTS_GLOB: &str = "**/bazel-*/**"; |
| |
| // --------------------------------------------------------------------------- |
| // Launcher dir + source-binary install paths |
| // --------------------------------------------------------------------------- |
| |
| /// Dotted, rules_rust-prefixed dir under the per-IDE launcher root |
| /// for the source binaries setup copies in. |
| const LAUNCHER_SUBDIR: &str = ".rules_rust_analyzer"; |
| |
| // Re-exported so install (setup) and consumer (rust_project.rs) |
| // agree on the `.exe` filenames. |
| use gen_rust_project_lib::{ |
| bazel_command, bazel_info, flycheck_output_base, user_config, BazelInfo, ToolchainInfoSidecar, |
| CACHE_SUBDIR, DISCOVER_BINARY_FILENAME, FLYCHECK_BINARY_FILENAME, TOOLCHAIN_INFO_SIDECAR, |
| }; |
| |
| // `_opt` targets the `opt_executable` wrapper — 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"; |
| |
| /// Install filename (`<name>.exe`) + `launcher_paths.json` key for |
| /// each launcher. 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 { |
| /// 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, |
| |
| /// Pin per-package workspace switching ON (the default). Only |
| /// matters if `--no-per-package-workspaces` previously flipped it |
| /// off. Persists to `<launcher_dir>/user_config.json`. |
| #[arg(long, conflicts_with = "no_per_package_workspaces", global = true)] |
| per_package_workspaces: bool, |
| |
| /// Opt out of per-package workspace switching — RA loads the |
| /// whole aspect graph. Tens of GB RSS on real monorepos; only |
| /// pick this if you need cross-package "find usages". |
| #[arg(long, conflicts_with = "per_package_workspaces", global = true)] |
| no_per_package_workspaces: bool, |
| |
| /// Opt in to running clippy on save. Writes `{"clippy": true}` |
| /// into `<launcher_dir>/user_config.json`; the shared committed |
| /// settings file is unaffected. `--no-clippy` flips it back off; |
| /// omitting both leaves the file alone. |
| #[arg(long, conflicts_with = "no_clippy", global = true)] |
| clippy: bool, |
| |
| /// Opt out of running clippy on save. See `--clippy`. |
| #[arg(long, conflicts_with = "clippy", global = true)] |
| no_clippy: bool, |
| |
| /// Delete the discover cache (`<launcher-dir>/cache/`) AND wipe |
| /// flycheck's dedicated `output_base` (via `bazel clean --expunge |
| /// --output_base=<...>`). Use after a toolchain change. Reads the |
| /// flycheck base from the sidecar; a no-op for it if no sidecar |
| /// exists yet. Does not touch `user_config.json`. |
| #[arg(long, global = true)] |
| clean: bool, |
| |
| /// Persist a per-user override for flycheck's inner |
| /// `--output_base`, into `<launcher_dir>/user_config.json`. The |
| /// flycheck CLI flag still wins for one-off overrides. Clear by |
| /// hand-deleting the key from `user_config.json`. |
| #[arg(long, global = true, value_name = "PATH")] |
| output_base: Option<Utf8PathBuf>, |
| |
| #[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 { |
| /// `.vscode/settings.json` file to write. Relative paths are |
| /// resolved under the workspace. Defaults to |
| /// `<workspace>/.vscode/settings.json` — always written. |
| #[arg(long)] |
| settings_json: Option<Utf8PathBuf>, |
| |
| /// `.code-workspace` file to also update (in addition to |
| /// `settings.json`). When unset, autodetects a unique |
| /// `*.code-workspace` at the workspace root; multiple candidates |
| /// require this flag to disambiguate. Pass a path to force-target |
| /// a specific file even if it doesn't currently exist. |
| #[arg(long)] |
| code_workspace: Option<Utf8PathBuf>, |
| |
| /// Skip the `.code-workspace` write entirely (bypasses autodetect |
| /// and any `--code-workspace` value). |
| #[arg(long)] |
| no_code_workspace: bool, |
| |
| /// Key to nest managed `rust-analyzer.*` keys under when writing |
| /// to a `.code-workspace`. Defaults to `settings` — the block |
| /// VS Code reads window-scoped rust-analyzer configuration from |
| /// when the project is opened via the workspace file. |
| #[arg(long)] |
| settings_key: Option<String>, |
| |
| /// Print each would-be-written file to stdout instead of writing. |
| #[arg(long)] |
| dry_run: bool, |
| |
| /// Replace the managed portion of each target file instead of |
| /// merging. In `.vscode/settings.json` this overwrites the root; |
| /// in `.code-workspace` this overwrites the nested `settings` |
| /// object only (sibling `folders` / `tasks` / `extensions` stay |
| /// intact — the workspace file wouldn't be usable otherwise). |
| #[arg(long)] |
| replace: bool, |
| } |
| |
| // --------------------------------------------------------------------------- |
| // Entry point + per-IDE dispatch |
| // --------------------------------------------------------------------------- |
| |
| /// Resolve a `--foo` / `--no-foo` pair: `Some(true)` for `--foo`, |
| /// `Some(false)` for `--no-foo`, `None` when neither is given. |
| fn pick_toggle(on: bool, off: bool) -> Option<bool> { |
| if on { |
| Some(true) |
| } else if off { |
| Some(false) |
| } else { |
| None |
| } |
| } |
| |
| /// Merge the CLI-provided toggles into `<launcher_dir>/user_config.json`. |
| /// Only touches named fields — unrelated keys are preserved. |
| fn apply_user_config_edits( |
| launcher_dir: &Utf8Path, |
| clippy: Option<bool>, |
| per_package_workspaces: Option<bool>, |
| output_base: Option<Utf8PathBuf>, |
| ) -> Result<()> { |
| if clippy.is_none() && per_package_workspaces.is_none() && output_base.is_none() { |
| return Ok(()); |
| } |
| let mut config = user_config::load(launcher_dir); |
| if let Some(v) = clippy { |
| config.clippy = v; |
| } |
| if let Some(v) = per_package_workspaces { |
| config.per_package_workspaces = v; |
| } |
| if let Some(v) = output_base { |
| config.output_base = Some(v); |
| } |
| user_config::save(launcher_dir, &config)?; |
| info!( |
| "user_config: clippy={} per_package_workspaces={} output_base={:?}", |
| config.clippy, config.per_package_workspaces, config.output_base |
| ); |
| Ok(()) |
| } |
| |
| fn main() -> Result<()> { |
| env_logger::init(); |
| let Cli { |
| skip_proc_macro_server, |
| skip_rustfmt, |
| per_package_workspaces, |
| no_per_package_workspaces, |
| clippy, |
| no_clippy, |
| clean, |
| output_base, |
| ide, |
| } = Cli::parse(); |
| |
| // Setup must run under `bazel run` so the discover / flycheck |
| // binaries embed toolchain paths resolved by the TARGET |
| // workspace's Bazel — cross-workspace deploys would bake in the |
| // wrong sysroot. |
| let workspace = std::env::var("BUILD_WORKSPACE_DIRECTORY") |
| .map(Utf8PathBuf::from) |
| .context( |
| "BUILD_WORKSPACE_DIRECTORY unset — run via `bazel run @rules_rust//tools/rust_analyzer:setup`", |
| )?; |
| |
| // Tri-state resolution of the per-user opt-ins: explicit flag wins, |
| // otherwise the file is left as-is. `clap`'s `conflicts_with` above |
| // guarantees the (true, true) case can't happen. |
| let pending_clippy = pick_toggle(clippy, no_clippy); |
| let pending_ppw = pick_toggle(per_package_workspaces, no_per_package_workspaces); |
| |
| 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); |
| |
| if clean { |
| clean_cache(&launcher_dir, &workspace)?; |
| } |
| |
| 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)?; |
| // Only VSCode goes through launcher shims; other IDEs bake |
| // absolute toolchain paths into their snippets. |
| if matches!(ide, IdeCmd::Vscode(_)) { |
| install_toolchain_launchers(&launcher_dir, &runfiles, &toolchain)?; |
| } |
| |
| // Apply user_config edits before per-IDE dispatch so the runners |
| // and any --dry-run flow see the same on-disk state. |
| apply_user_config_edits(&launcher_dir, pending_clippy, pending_ppw, output_base)?; |
| |
| // Pre-populate the flycheck server's bazel-info cache so the |
| // steady-state clippy path never invokes `bazel info` on save. |
| // Best-effort — a failure just means flycheck refreshes on first |
| // save (its normal fallback path). See [`prepopulate_bazel_info`]. |
| prepopulate_bazel_info(&launcher_dir, &workspace); |
| |
| let ctx = SetupCtx { |
| launcher_dir, |
| skip_proc_macro_server, |
| skip_rustfmt, |
| 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 { |
| /// Editor-specific dir setup copies source binaries into. Discover |
| /// self-locates its cache at `<launcher_dir>/cache/`; flycheck |
| /// derives its `--output_base` from the sidecar written here. |
| launcher_dir: Utf8PathBuf, |
| skip_proc_macro_server: bool, |
| skip_rustfmt: 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 toolchain paths the launcher shims exec. |
| /// Canonicalization escapes the `bazel-out` runfiles symlink tree |
| /// (wiped by `bazel clean`) and lands at |
| /// `output_base/external/...` (only wiped by `--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 |
| // --------------------------------------------------------------------------- |
| |
| /// VS Code multi-root workspace file extension; used by |
| /// [`autodetect_code_workspace`] to find a workspace file at the |
| /// project root. |
| const CODE_WORKSPACE_EXT: &str = ".code-workspace"; |
| |
| const DEFAULT_VSCODE_OUTPUT: &str = ".vscode/settings.json"; |
| |
| /// `settings_json` is always written. `code_workspace` is optional |
| /// (autodetected, forced by `--code-workspace`, or skipped by |
| /// `--no-code-workspace`). Resolved before install so ambiguity |
| /// errors fail fast. |
| #[derive(Debug)] |
| struct ResolvedVscodeTargets { |
| settings_json: Utf8PathBuf, |
| /// When set, managed keys are also merged into this file under |
| /// [`CodeWorkspaceTarget::settings_key`] (default `"settings"`). |
| code_workspace: Option<CodeWorkspaceTarget>, |
| } |
| |
| #[derive(Debug)] |
| struct CodeWorkspaceTarget { |
| path: Utf8PathBuf, |
| settings_key: String, |
| } |
| |
| fn resolve_vscode_targets( |
| workspace: &Utf8Path, |
| args: &VscodeArgs, |
| ) -> Result<ResolvedVscodeTargets> { |
| let settings_json = args |
| .settings_json |
| .as_deref() |
| .map(|p| abs_or_under(workspace, p)) |
| .unwrap_or_else(|| workspace.join(DEFAULT_VSCODE_OUTPUT)); |
| let code_workspace = resolve_code_workspace_target(workspace, args)?; |
| Ok(ResolvedVscodeTargets { |
| settings_json, |
| code_workspace, |
| }) |
| } |
| |
| fn resolve_code_workspace_target( |
| workspace: &Utf8Path, |
| args: &VscodeArgs, |
| ) -> Result<Option<CodeWorkspaceTarget>> { |
| if args.no_code_workspace { |
| return Ok(None); |
| } |
| let path = match args.code_workspace.as_deref() { |
| Some(p) => Some(abs_or_under(workspace, p)), |
| None => autodetect_code_workspace(workspace)?, |
| }; |
| Ok(path.map(|path| CodeWorkspaceTarget { |
| path, |
| settings_key: args |
| .settings_key |
| .clone() |
| .unwrap_or_else(|| "settings".to_owned()), |
| })) |
| } |
| |
| /// Resolve a CLI-supplied path against `workspace`: absolute paths |
| /// pass through, relative ones join under the workspace root. |
| fn abs_or_under(workspace: &Utf8Path, path: &Utf8Path) -> Utf8PathBuf { |
| if path.is_absolute() { |
| path.to_owned() |
| } else { |
| workspace.join(path) |
| } |
| } |
| |
| /// Zero matches → `Ok(None)` (`settings.json` gets written alone); |
| /// one → `Ok(Some)`; two or more → `Err`, since auto-targeting is |
| /// ambiguous (user must pass `--code-workspace` to pick, or |
| /// `--no-code-workspace` to skip). |
| 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 — |
| // no code-workspace to write. |
| 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 `--code-workspace <path>` to pick one, or `--no-code-workspace` to skip.", |
| matches |
| .iter() |
| .map(|p| p.file_name().unwrap_or("")) |
| .collect::<Vec<_>>() |
| .join(", "), |
| ), |
| } |
| } |
| |
| fn run_vscode(ctx: &SetupCtx, args: VscodeArgs, targets: ResolvedVscodeTargets) -> Result<()> { |
| let ResolvedVscodeTargets { |
| settings_json, |
| code_workspace, |
| } = targets; |
| let managed = vscode_managed_keys(ctx); |
| |
| // Always settings.json (managed keys at root); then optionally |
| // the .code-workspace (managed keys nested under `settings_key` |
| // so sibling folders / tasks / extensions / user comments |
| // survive). |
| let writes = std::iter::once((settings_json, None)) |
| .chain(code_workspace.map(|cw| (cw.path, Some(cw.settings_key)))); |
| |
| for (path, settings_key) in writes { |
| let merged = if args.replace { |
| replace_managed_file(&path, &managed, settings_key.as_deref())? |
| } else { |
| merge_file(&path, &managed, settings_key.as_deref())? |
| }; |
| if args.dry_run { |
| println!("=== {path} ==="); |
| println!("{merged}"); |
| } else { |
| write_text(path.as_std_path(), &merged)?; |
| info!( |
| "{} {} in {path}", |
| if args.replace { "Wrote" } else { "Merged" }, |
| managed.len(), |
| ); |
| } |
| } |
| 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 |
| // --------------------------------------------------------------------------- |
| |
| /// Called on `--clean`. Deletes the discover-output cache under |
| /// `launcher_dir`, the flycheck `bazel_info.json` cache (invalid |
| /// once the flycheck server is expunged), AND expunges flycheck's |
| /// dedicated `output_base` (`<sidecar.output_base>_rra`) via `bazel |
| /// clean --expunge`. Leaves `user_config.json` alone (per-user prefs). |
| /// All steps are idempotent: missing paths are fine, missing sidecar / |
| /// missing bazel just skips the expunge with a warning. |
| fn clean_cache(launcher_dir: &Utf8Path, workspace: &Utf8Path) -> Result<()> { |
| let cache = launcher_dir.join(CACHE_SUBDIR); |
| match fs::remove_dir_all(&cache) { |
| Ok(()) => eprintln!("cleared discover cache at {cache}"), |
| Err(e) if e.kind() == io::ErrorKind::NotFound => {} |
| Err(e) => return Err(e).with_context(|| format!("removing discover cache at {cache}")), |
| } |
| // The stored `execution_root` becomes meaningless once its server |
| // is expunged below, so drop the cache first. |
| let _ = fs::remove_file(launcher_dir.join(gen_rust_project_lib::BAZEL_INFO_FILENAME)); |
| expunge_flycheck_output_base(launcher_dir, workspace); |
| Ok(()) |
| } |
| |
| /// Best-effort `bazel --output_base=<...> clean --expunge` for |
| /// flycheck's dedicated server. `--expunge` handles the server |
| /// shutdown before removing the directory — no separate `bazel |
| /// shutdown` step needed. Warns and continues on any failure: `--clean` |
| /// is a nice-to-have wipe, not a load-bearing operation, and there's |
| /// nothing to clean before discover has ever run. |
| fn expunge_flycheck_output_base(launcher_dir: &Utf8Path, workspace: &Utf8Path) { |
| let sidecar_path = launcher_dir.join(TOOLCHAIN_INFO_SIDECAR); |
| let Some(outer) = fs::read(&sidecar_path) |
| .ok() |
| .and_then(|b| serde_json::from_slice::<ToolchainInfoSidecar>(&b).ok()) |
| .and_then(|s| s.output_base) |
| else { |
| eprintln!( |
| "no sidecar at {sidecar_path}; skipping flycheck output_base expunge (nothing to \ |
| clean before rust-analyzer discovery has run)" |
| ); |
| return; |
| }; |
| let flycheck_base = flycheck_output_base(&outer); |
| if !flycheck_base.exists() { |
| return; |
| } |
| let status = bazel_command( |
| Utf8Path::new("bazel"), |
| Some(workspace), |
| Some(&flycheck_base), |
| ) |
| .arg("clean") |
| .arg("--expunge") |
| .status(); |
| match status { |
| Ok(s) if s.success() => eprintln!("expunged flycheck output_base at {flycheck_base}"), |
| Ok(s) => eprintln!( |
| "bazel clean --expunge on flycheck output_base {flycheck_base} exited with {s}; \ |
| leaving the directory as-is" |
| ), |
| Err(e) => eprintln!( |
| "could not invoke `bazel` to expunge flycheck output_base at {flycheck_base}: {e}; \ |
| delete it by hand if needed" |
| ), |
| } |
| } |
| |
| /// Populate `<launcher_dir>/bazel_info.json` by invoking `bazel info` |
| /// against flycheck's dedicated server. Setup runs before discover on |
| /// a fresh checkout, so the sidecar may not exist yet — falls back to |
| /// `bazel info output_base` against the outer server to derive the |
| /// `_rra` sibling. Best-effort throughout: any failure logs and |
| /// returns, and flycheck's first save will populate the cache instead. |
| fn prepopulate_bazel_info(launcher_dir: &Utf8Path, workspace: &Utf8Path) { |
| let bazel = Utf8Path::new("bazel"); |
| let user = user_config::load(launcher_dir); |
| let flycheck_base = match resolve_flycheck_output_base(bazel, workspace, launcher_dir, &user) { |
| Ok(p) => p, |
| Err(e) => { |
| eprintln!( |
| "flycheck bazel_info prepopulate: could not resolve output_base ({e:#}); \ |
| flycheck will populate on first save" |
| ); |
| return; |
| } |
| }; |
| match BazelInfo::try_new(bazel, workspace, &flycheck_base) { |
| Ok(info) => { |
| info.save(launcher_dir); |
| eprintln!("populated flycheck bazel_info cache at {launcher_dir}"); |
| } |
| Err(e) => eprintln!( |
| "flycheck bazel_info prepopulate: {e:#}; flycheck will populate on first save" |
| ), |
| } |
| } |
| |
| /// Same precedence flycheck uses on save (user_config, then sidecar's |
| /// `_rra`, then a `bazel info output_base` against the outer server), |
| /// minus the CLI override (setup doesn't take one). |
| fn resolve_flycheck_output_base( |
| bazel: &Utf8Path, |
| workspace: &Utf8Path, |
| launcher_dir: &Utf8Path, |
| user: &user_config::UserConfig, |
| ) -> Result<Utf8PathBuf> { |
| if let Some(p) = user.output_base.clone() { |
| return Ok(p); |
| } |
| let sidecar_path = launcher_dir.join(TOOLCHAIN_INFO_SIDECAR); |
| if let Some(outer) = fs::read(&sidecar_path) |
| .ok() |
| .and_then(|b| serde_json::from_slice::<ToolchainInfoSidecar>(&b).ok()) |
| .and_then(|s| s.output_base) |
| { |
| return Ok(flycheck_output_base(&outer)); |
| } |
| let outer = bazel_info(bazel, Some(workspace), None, &[], &[])? |
| .remove("output_base") |
| .context("outer `bazel info` returned no `output_base` line")?; |
| Ok(flycheck_output_base(Utf8Path::new(&outer))) |
| } |
| |
| /// Copy discover + flycheck into `dir`. The runfiles originals live |
| /// in `bazel-out` and would be wiped by `bazel clean`; copies survive |
| /// until `bazel clean --expunge`. |
| 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<()> { |
| // Fully generated — plain serde_json (unlike the CST-merged |
| // settings.json / .code-workspace paths). |
| 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(), |
| ); |
| let mut json = serde_json::to_string_pretty(&map)?; |
| json.push('\n'); |
| write_text(path.as_std_path(), &json) |
| } |
| |
| 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(); |
| perms.set_mode(0o755); |
| fs::set_permissions(path, perms).with_context(|| format!("chmod {path}"))?; |
| Ok(()) |
| } |
| |
| #[cfg(not(unix))] |
| fn set_executable(_path: &Utf8Path) -> Result<()> { |
| Ok(()) |
| } |
| |
| /// Normalize backslashes to forward slashes for embedding in editor |
| /// config files (JSON/Lua/TOML all treat `\` as an escape). |
| fn to_forward_slashes(path: &str) -> String { |
| if cfg!(windows) { |
| path.replace('\\', "/") |
| } else { |
| path.to_owned() |
| } |
| } |
| |
| // --------------------------------------------------------------------------- |
| // Editor-relative defaults |
| // --------------------------------------------------------------------------- |
| |
| /// Workspace-relative launcher dir for the VSCode subcommand. |
| /// Pinned to `.vscode/` regardless of the settings file location so |
| /// the committed `${workspaceFolder}/.vscode/.rules_rust_analyzer/...` |
| /// reference stays stable. |
| const VSCODE_LAUNCHER_REL: &str = ".vscode"; |
| |
| fn launcher_dir_for(workspace: &Utf8Path, ide: &IdeCmd) -> Utf8PathBuf { |
| match ide { |
| IdeCmd::Vscode(_) => workspace.join(VSCODE_LAUNCHER_REL).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), |
| } |
| } |
| |
| // --------------------------------------------------------------------------- |
| // VSCode settings.json merge |
| // --------------------------------------------------------------------------- |
| |
| /// A managed key plus how to combine it with the user's existing value. |
| enum ManagedValue { |
| /// Overwrite the whole key. |
| Replace(Value), |
| /// Dict-merge: add the listed sub-entries. Existing keys under |
| /// the same glob are preserved (including explicit `false`) so |
| /// users can opt out of individual excludes. |
| InsertEntries(Vec<(String, Value)>), |
| } |
| |
| /// `${workspaceFolder}` keeps the committed settings file portable. |
| /// Kept in sync with [`launcher_dir_for`]'s VSCode arm via |
| /// [`VSCODE_LAUNCHER_REL`]. |
| fn workspace_relative(filename: &str) -> String { |
| format!("${{workspaceFolder}}/{VSCODE_LAUNCHER_REL}/{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 flycheck_path = workspace_relative(FLYCHECK_BINARY_FILENAME); |
| let bazel_outputs = || vec![(BAZEL_OUTPUTS_GLOB.to_string(), Value::Bool(true))]; |
| // Command is identical for every developer. Per-user knobs (clippy, |
| // per-package-workspaces) live in the launcher-dir user_config, not |
| // here. `{arg}` is always present; discover ignores it in |
| // whole-workspace mode. |
| let discover_command = json!([discover_path, "{arg}"]); |
| 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 { |
| out.push(( |
| RUSTFMT_OVERRIDE_KEY.to_string(), |
| ManagedValue::Replace(json!([rustfmt_path])), |
| )); |
| } |
| // Prevents RA from running `cargo check` for any auto-detected |
| // `Cargo.toml`. `$saved_file` is RA's substitution — flycheck |
| // resolves the label from it. |
| out.push(( |
| CHECK_OVERRIDE_KEY.to_string(), |
| ManagedValue::Replace(json!([flycheck_path, "--saved-file", "$saved_file"])), |
| )); |
| // All three exclude maps get the same glob, dict-merged so any |
| // user entries survive. |
| out.push(( |
| FILES_WATCHER_EXCLUDE_KEY.to_string(), |
| ManagedValue::InsertEntries(bazel_outputs()), |
| )); |
| out.push(( |
| SEARCH_EXCLUDE_KEY.to_string(), |
| ManagedValue::InsertEntries(bazel_outputs()), |
| )); |
| out |
| } |
| |
| /// Parse `path` as JSONC via `jsonc-parser`'s CST — which preserves |
| /// comments, whitespace, and trailing commas so mutations round-trip |
| /// without stripping trivia. Missing / empty files return a fresh empty |
| /// root object. |
| /// |
| /// The returned `CstRootNode` MUST NOT be dropped while any of its |
| /// descendants are in use — the CST uses weak parent pointers and |
| /// dropping the root panics its children. |
| fn parse_root(path: &Utf8Path) -> Result<CstRootNode> { |
| let text = match fs::read_to_string(path) { |
| Ok(t) if t.trim().is_empty() => "{}\n".to_owned(), |
| Ok(t) => t, |
| Err(e) if e.kind() == std::io::ErrorKind::NotFound => "{}\n".to_owned(), |
| Err(e) => return Err(e).with_context(|| format!("reading {path}")), |
| }; |
| CstRootNode::parse(&text, &ParseOptions::default()) |
| .map_err(|e| anyhow::anyhow!("{path} is not valid JSON ({e})")) |
| } |
| |
| fn root_object(path: &Utf8Path, root: &CstRootNode) -> Result<CstObject> { |
| root.object_value().ok_or_else(|| { |
| anyhow::anyhow!( |
| "{path}'s root is not a JSON object; refusing to merge. Use --replace to overwrite." |
| ) |
| }) |
| } |
| |
| /// Merge `managed` into the JSONC file at `path`, preserving all |
| /// existing comments and formatting. Returns the merged file content |
| /// as a string ready to write. See [`apply_managed_cst`] for the |
| /// per-strategy semantics. |
| fn merge_file( |
| path: &Utf8Path, |
| managed: &[(String, ManagedValue)], |
| settings_key: Option<&str>, |
| ) -> Result<String> { |
| let root = parse_root(path)?; |
| let root_obj = root_object(path, &root)?; |
| let target = match settings_key { |
| None => root_obj, |
| Some(k) => root_obj.object_value_or_create(k).ok_or_else(|| { |
| anyhow::anyhow!( |
| "{path}'s `{k}` is not an object; refusing to merge. Use --replace to overwrite." |
| ) |
| })?, |
| }; |
| for (key, value) in managed { |
| apply_managed_cst(&target, key, value); |
| } |
| Ok(root.to_string()) |
| } |
| |
| /// `--replace` path. With `settings_key = Some` we only replace |
| /// `root[key]` — sibling keys (`folders` / `tasks` / `extensions` on |
| /// a `.code-workspace`) survive, since the user can't usefully |
| /// `--replace` the whole workspace file. Without a `settings_key` |
| /// the entire root document is replaced with just the managed keys. |
| fn replace_managed_file( |
| path: &Utf8Path, |
| managed: &[(String, ManagedValue)], |
| settings_key: Option<&str>, |
| ) -> Result<String> { |
| let props: Vec<(String, CstInputValue)> = managed |
| .iter() |
| .map(|(k, v)| (k.clone(), realize_managed_cst(v))) |
| .collect(); |
| // Without a settings_key we're wholesale-replacing the document; |
| // no need to parse (and no need to inherit corruption from the |
| // existing file). Only the settings_key branch has to preserve |
| // siblings and therefore has to read what's there. |
| let root = if settings_key.is_some() { |
| parse_root(path)? |
| } else { |
| CstRootNode::parse("{}\n", &ParseOptions::default()).expect("empty object is valid JSON") |
| }; |
| match settings_key { |
| None => { |
| root.set_value(CstInputValue::Object(props)); |
| } |
| Some(k) => { |
| let root_obj = root_object(path, &root)?; |
| if let Some(existing) = root_obj.get(k) { |
| existing.remove(); |
| } |
| root_obj.append(k, CstInputValue::Object(props)); |
| } |
| } |
| Ok(root.to_string()) |
| } |
| |
| /// Mutate `target` per the [`ManagedValue`] strategy: |
| /// |
| /// * `Replace` — overwrite (creates the property if missing). |
| /// * `InsertEntries` — dict-merge: add sub-entries that aren't |
| /// already present. If the existing value isn't an object, it's |
| /// overwritten (VSCode wouldn't accept a non-object here anyway). |
| fn apply_managed_cst(target: &CstObject, key: &str, value: &ManagedValue) { |
| match value { |
| ManagedValue::Replace(v) => { |
| let cst_v = to_cst_input(v); |
| match target.get(key) { |
| Some(prop) => prop.set_value(cst_v), |
| None => { |
| target.append(key, cst_v); |
| } |
| } |
| } |
| ManagedValue::InsertEntries(entries) => { |
| let obj = target.object_value_or_set(key); |
| for (sub_k, sub_v) in entries { |
| if obj.get(sub_k).is_none() { |
| obj.append(sub_k, to_cst_input(sub_v)); |
| } |
| } |
| } |
| } |
| } |
| |
| /// Materialize a managed value as a fresh CST input — used by the |
| /// `--replace` path where there's no pre-existing structure to |
| /// preserve. |
| fn realize_managed_cst(value: &ManagedValue) -> CstInputValue { |
| match value { |
| ManagedValue::Replace(v) => to_cst_input(v), |
| ManagedValue::InsertEntries(entries) => CstInputValue::Object( |
| entries |
| .iter() |
| .map(|(k, v)| (k.clone(), to_cst_input(v))) |
| .collect(), |
| ), |
| } |
| } |
| |
| fn to_cst_input(v: &Value) -> CstInputValue { |
| match v { |
| Value::Null => CstInputValue::Null, |
| Value::Bool(b) => CstInputValue::Bool(*b), |
| // `jsonc-parser` takes numbers as their source text — reusing |
| // serde_json's canonical formatting round-trips cleanly since |
| // both agree on the numeric grammar. |
| Value::Number(n) => CstInputValue::Number(n.to_string()), |
| Value::String(s) => CstInputValue::String(s.clone()), |
| Value::Array(arr) => CstInputValue::Array(arr.iter().map(to_cst_input).collect()), |
| Value::Object(obj) => CstInputValue::Object( |
| obj.iter() |
| .map(|(k, v)| (k.clone(), to_cst_input(v))) |
| .collect(), |
| ), |
| } |
| } |
| |
| fn write_text(path: &Path, text: &str) -> Result<()> { |
| if let Some(parent) = path.parent() { |
| fs::create_dir_all(parent) |
| .with_context(|| format!("creating directory {}", parent.display()))?; |
| } |
| fs::write(path, text).with_context(|| format!("writing {}", path.display()))?; |
| Ok(()) |
| } |
| |
| // --------------------------------------------------------------------------- |
| // Snippet generators for non-VSCode IDEs |
| // |
| // Each format has one main template constant and two optional sub-block |
| // constants (proc-macro / rustfmt). 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__"; |
| // Optional-block slots in the main templates. |
| const TPL_OPT_PROC_MACRO: &str = "__OPT_PROC_MACRO__"; |
| const TPL_OPT_RUSTFMT: &str = "__OPT_RUSTFMT__"; |
| // The `, "{arg}"` after `__DISCOVER_LAUNCHER__` in each template is |
| // literal, not a placeholder. Per-package-workspaces is per-user |
| // (`<launcher_dir>/user_config.json`), so the shared snippet always |
| // includes the arg slot — discover honors the user's preference at |
| // runtime. Same syntax works in Lua/TOML/JSON since all three quote |
| // strings with `"`. |
| |
| // -- 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__", "{arg}" }, |
| progressLabel = "rules_rust", |
| filesToWatch = { "BUILD", "BUILD.bazel", "MODULE.bazel", "WORKSPACE", "WORKSPACE.bazel" }, |
| }, |
| }, |
| __OPT_PROC_MACRO____OPT_RUSTFMT__ lens = { enable = true }, |
| }, |
| }, |
| }) |
| "#; |
| |
| const NEOVIM_LUA_PROC_MACRO: &str = r#" procMacro = { |
| server = "__PMS_LAUNCHER__", |
| }, |
| "#; |
| |
| const NEOVIM_LUA_RUSTFMT: &str = r#" rustfmt = { |
| overrideCommand = { "__RUSTFMT_LAUNCHER__" }, |
| }, |
| "#; |
| |
| // -- 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__", "{arg}"] |
| progressLabel = "rules_rust" |
| filesToWatch = ["BUILD", "BUILD.bazel", "MODULE.bazel", "WORKSPACE", "WORKSPACE.bazel"] |
| __OPT_PROC_MACRO____OPT_RUSTFMT__ |
| [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__"] |
| "#; |
| |
| // -- 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__", "{arg}"], |
| "progressLabel": "rules_rust", |
| "filesToWatch": ["BUILD", "BUILD.bazel", "MODULE.bazel", "WORKSPACE", "WORKSPACE.bazel"] |
| }, |
| __OPT_PROC_MACRO____OPT_RUSTFMT__"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__"], |
| "#; |
| |
| // -- 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"); |
| } |
| |
| /// 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() |
| } |
| } |
| |
| // -- 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 two optional sub-templates |
| /// (proc-macro, rustfmt) 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, |
| ) -> 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) |
| }); |
| main_template |
| .replace(TPL_RA_LAUNCHER, &paths.ra) |
| .replace(TPL_DISCOVER_LAUNCHER, &paths.discover) |
| .replace(TPL_OPT_PROC_MACRO, &proc_macro) |
| .replace(TPL_OPT_RUSTFMT, &rustfmt) |
| } |
| |
| /// `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, |
| ) |
| } |
| |
| /// 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, |
| ) |
| } |
| |
| /// 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, |
| ) |
| } |
| |
| // --------------------------------------------------------------------------- |
| // 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"), |
| } |
| } |
| |
| /// Fake workspace root used to derive per-editor launcher dirs in |
| /// tests. Not stored on `SetupCtx`; test callers `.join(...)` this |
| /// directly for the launcher_dir they want to test against. |
| const DUMMY_WORKSPACE: &str = "/ws"; |
| |
| fn dummy_ctx() -> (SetupCtx, Utf8PathBuf) { |
| let launcher_dir = Utf8PathBuf::from(DUMMY_WORKSPACE) |
| .join(VSCODE_LAUNCHER_REL) |
| .join(LAUNCHER_SUBDIR); |
| let ctx = SetupCtx { |
| launcher_dir: launcher_dir.clone(), |
| skip_proc_macro_server: false, |
| skip_rustfmt: 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(_) => { |
| panic!("expected Replace strategy for {key}") |
| } |
| } |
| } |
| |
| /// Parse the merged-text output of `merge_file` / `replace_managed_file` |
| /// back into a `serde_json::Value` for assertion. Uses `jsonc_parser`'s |
| /// serde_json bridge so JSONC output (comments / trailing commas) round-trips. |
| fn parse_merged(merged: &str) -> Value { |
| jsonc_parser::parse_to_serde_value(merged, &Default::default()) |
| .expect("output must be valid JSON") |
| .expect("non-empty JSON") |
| } |
| |
| #[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_file(&path, &vscode_managed_keys(&ctx), None).unwrap(); |
| let parsed = parse_merged(&merged); |
| let obj = parsed.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}/.vscode/.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_file(&path, &vscode_managed_keys(&ctx), None).unwrap(); |
| let parsed = parse_merged(&merged); |
| let obj = parsed.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(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); |
| // 5 rust-analyzer keys (discover, server, proc-macro, rustfmt, |
| // check.overrideCommand) + 2 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 and check-override keys still ride along. |
| assert!(without_fmt.iter().any(|(k, _)| k == PROC_MACRO_SRV_KEY)); |
| assert!(without_fmt.iter().any(|(k, _)| k == CHECK_OVERRIDE_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}/.vscode/.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}/.vscode/.rules_rust_analyzer/rust_analyzer.exe" |
| ); |
| assert_eq!( |
| replace_value(&keys, PROC_MACRO_SRV_KEY).as_str().unwrap(), |
| "${workspaceFolder}/.vscode/.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}/.vscode/.rules_rust_analyzer/discover_bazel_rust_project.exe" |
| ); |
| } |
| |
| #[test] |
| fn discover_command_is_identical_regardless_of_user_preferences() { |
| // The whole point of user_config.json is that the shared, |
| // committed settings file must be byte-identical for every |
| // developer. So the rendered discover command must never |
| // contain per-user opt-ins like `--clippy`, and `{arg}` must |
| // always be present so discover can serve either scope on |
| // demand — see the comment in `vscode_managed_keys`. |
| let (ctx, _launcher_dir) = dummy_ctx(); |
| let keys = vscode_managed_keys(&ctx); |
| let discover_cmd = replace_value(&keys, DISCOVER_CONFIG_KEY) |
| .get("command") |
| .and_then(|v| v.as_array()) |
| .expect("command must be an array"); |
| assert!( |
| !discover_cmd.iter().any(|v| v == "--clippy"), |
| "shared discover command must not encode --clippy: {discover_cmd:?}", |
| ); |
| assert!( |
| discover_cmd.iter().any(|v| v == "{arg}"), |
| "shared discover command must always template {{arg}}: {discover_cmd:?}", |
| ); |
| } |
| |
| #[test] |
| fn apply_user_config_edits_writes_only_named_fields() { |
| // `setup --clippy` (no other toggle) must NOT reset |
| // per_package_workspaces — the file is a merge target, not a |
| // reset target. |
| let dir = std::env::temp_dir().join(format!("setup_uc_partial_{}", std::process::id())); |
| let _ = std::fs::remove_dir_all(&dir); |
| std::fs::create_dir_all(&dir).unwrap(); |
| let launcher_dir = Utf8PathBuf::try_from(dir.clone()).unwrap(); |
| user_config::save( |
| &launcher_dir, |
| &user_config::UserConfig { |
| clippy: false, |
| per_package_workspaces: true, |
| output_base: Some(Utf8PathBuf::from("/existing/base")), |
| }, |
| ) |
| .unwrap(); |
| |
| apply_user_config_edits(&launcher_dir, Some(true), None, None).unwrap(); |
| |
| let loaded = user_config::load(&launcher_dir); |
| assert!(loaded.clippy, "--clippy must land in the file"); |
| assert!( |
| loaded.per_package_workspaces, |
| "unnamed fields must be preserved: got {loaded:?}", |
| ); |
| assert_eq!( |
| loaded.output_base, |
| Some(Utf8PathBuf::from("/existing/base")), |
| "unnamed fields must be preserved: got {loaded:?}", |
| ); |
| let _ = std::fs::remove_dir_all(&dir); |
| } |
| |
| #[test] |
| fn apply_user_config_edits_persists_output_base() { |
| let dir = std::env::temp_dir().join(format!("setup_uc_ob_{}", std::process::id())); |
| let _ = std::fs::remove_dir_all(&dir); |
| std::fs::create_dir_all(&dir).unwrap(); |
| let launcher_dir = Utf8PathBuf::try_from(dir.clone()).unwrap(); |
| let path = Utf8PathBuf::from("/custom/flycheck/base"); |
| |
| apply_user_config_edits(&launcher_dir, None, None, Some(path.clone())).unwrap(); |
| |
| let loaded = user_config::load(&launcher_dir); |
| assert_eq!(loaded.output_base, Some(path)); |
| assert!(!loaded.clippy, "unrelated fields must stay default"); |
| let _ = std::fs::remove_dir_all(&dir); |
| } |
| |
| #[test] |
| fn apply_user_config_edits_is_a_noop_when_nothing_pending() { |
| // Bare `setup` (no toggles) must not create the file — no |
| // point committing an all-defaults marker to the launcher |
| // dir just because someone re-ran discovery. |
| let dir = std::env::temp_dir().join(format!("setup_uc_noop_{}", std::process::id())); |
| let _ = std::fs::remove_dir_all(&dir); |
| std::fs::create_dir_all(&dir).unwrap(); |
| let launcher_dir = Utf8PathBuf::try_from(dir.clone()).unwrap(); |
| |
| apply_user_config_edits(&launcher_dir, None, None, None).unwrap(); |
| |
| assert!(!launcher_dir |
| .join(user_config::USER_CONFIG_FILENAME) |
| .exists()); |
| let _ = std::fs::remove_dir_all(&dir); |
| } |
| |
| #[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_file(&path, &vscode_managed_keys(&ctx), None).unwrap(); |
| let parsed = parse_merged(&merged); |
| let watchers = parsed |
| .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 clean_cache_removes_cache_dir_and_leaves_siblings_alone() { |
| let ws = make_workspace( |
| "clean_cache", |
| &[ |
| ("user_config.json", r#"{"clippy":true}"#), |
| ("rust_analyzer.exe", ""), |
| ], |
| ); |
| std::fs::create_dir_all(ws.join("cache")).unwrap(); |
| std::fs::write(ws.join("cache").join("entry.json"), "{}").unwrap(); |
| |
| // Second arg is workspace cwd for the bazel expunge step; with |
| // no sidecar in `ws`, that step is a no-op — the test only |
| // exercises the discover-cache half. |
| clean_cache(&ws, &ws).unwrap(); |
| assert!(!ws.join("cache").exists(), "cache dir should be gone"); |
| assert!( |
| ws.join("user_config.json").exists(), |
| "user_config preserved" |
| ); |
| assert!(ws.join("rust_analyzer.exe").exists(), "launcher preserved"); |
| |
| // Idempotent: second call on now-missing dir is fine. |
| clean_cache(&ws, &ws).unwrap(); |
| let _ = std::fs::remove_dir_all(&ws); |
| } |
| |
| fn empty_vscode_args() -> VscodeArgs { |
| VscodeArgs { |
| settings_json: None, |
| code_workspace: None, |
| no_code_workspace: false, |
| 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()); |
| assert_eq!( |
| launcher_dir_for(&ws, &vscode), |
| Utf8PathBuf::from("/workspace/.vscode/.rules_rust_analyzer"), |
| ); |
| assert_eq!( |
| launcher_dir_for(&ws, &IdeCmd::Helix), |
| Utf8PathBuf::from("/workspace/.helix/.rules_rust_analyzer"), |
| ); |
| assert_eq!( |
| launcher_dir_for(&ws, &IdeCmd::Neovim), |
| Utf8PathBuf::from("/workspace/.rules_rust_analyzer"), |
| ); |
| assert_eq!( |
| launcher_dir_for(&ws, &IdeCmd::Print), |
| Utf8PathBuf::from("/workspace/.rules_rust_analyzer"), |
| ); |
| } |
| |
| #[test] |
| fn launcher_dir_for_vscode_is_pinned_regardless_of_output() { |
| // Whether the user targets `.vscode/settings.json`, a custom |
| // path, or a `.code-workspace` at the workspace root, the |
| // Vscode launcher dir is always `.vscode/.rules_rust_analyzer/` |
| // — the committed settings file references it via |
| // `${workspaceFolder}` so all three outputs use the same path. |
| let ws = Utf8PathBuf::from("/workspace"); |
| let expected = Utf8PathBuf::from("/workspace/.vscode/.rules_rust_analyzer"); |
| assert_eq!( |
| launcher_dir_for(&ws, &IdeCmd::Vscode(empty_vscode_args())), |
| expected, |
| ); |
| } |
| |
| #[test] |
| fn neovim_snippet_contains_toolchain_and_discover_paths_and_lens_enable() { |
| let (ctx, _) = dummy_ctx(); |
| let launcher_dir = Utf8PathBuf::from(DUMMY_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 = Utf8PathBuf::from(DUMMY_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 = Utf8PathBuf::from(DUMMY_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 = Utf8PathBuf::from(DUMMY_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 = Utf8PathBuf::from(DUMMY_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 rendered discover command is per-user-preference agnostic |
| /// now: `{arg}` is always present so rust-analyzer can serve the |
| /// per-file arg on demand, and discover decides whether to honor it |
| /// by consulting `user_config.json`. |
| #[test] |
| fn discover_command_always_includes_per_package_arg_template() { |
| let (ctx, _launcher_dir) = dummy_ctx(); |
| 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, |
| "discover command should always be [binary, \"{{arg}}\"]; got {cmd:?}" |
| ); |
| assert_eq!(cmd[1].as_str(), Some("{arg}")); |
| } |
| |
| /// Same coverage but for the Lua/TOML/JSON snippets, since those |
| /// go through a totally different substitution path. |
| #[test] |
| fn snippets_always_include_per_package_arg_template() { |
| let (ctx, launcher_dir) = dummy_ctx(); |
| 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}}:\n{lua}"); |
| assert!(toml.contains("\"{arg}\""), "toml missing {{arg}}:\n{toml}"); |
| assert!(json.contains("\"{arg}\""), "json missing {{arg}}:\n{json}"); |
| // JSON must still parse. |
| serde_json::from_str::<Value>(&json).expect("json snippet stays valid"); |
| } |
| |
| #[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_OPT_PROC_MACRO, |
| TPL_OPT_RUSTFMT, |
| ] { |
| 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}" |
| ); |
| } |
| } |
| |
| // ----------------------------------------------------------------- |
| // `.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_file(&path, &vscode_managed_keys(&ctx), Some("settings")).unwrap(); |
| let parsed = parse_merged(&merged); |
| let root = parsed.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}/.vscode/.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_file(&path, &vscode_managed_keys(&ctx), Some("settings")).unwrap(); |
| let parsed = parse_merged(&merged); |
| let settings = parsed |
| .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_file(&path, &vscode_managed_keys(&ctx), Some("settings")) |
| .unwrap_err() |
| .to_string(); |
| assert!( |
| err.contains("`settings`") && err.contains("not an object"), |
| "error should name the bad key and its shape, 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_file(&path, &vscode_managed_keys(&ctx), Some("settings")).unwrap(); |
| let parsed = parse_merged(&replaced); |
| let root = parsed.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_file(&path, &vscode_managed_keys(&ctx), None).unwrap(); |
| let parsed = parse_merged(&replaced); |
| let root = parsed.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 settings_json_always_resolved_to_default_when_unset() { |
| let ws = make_workspace("resolve_default", &[]); |
| let resolved = resolve_vscode_targets(&ws, &empty_vscode_args()).unwrap(); |
| assert_eq!(resolved.settings_json, ws.join(".vscode/settings.json")); |
| assert!(resolved.code_workspace.is_none()); |
| let _ = std::fs::remove_dir_all(&ws); |
| } |
| |
| #[test] |
| fn autodetect_picks_unique_code_workspace_alongside_settings_json() { |
| let ws = make_workspace("autodetect_one", &[("myproj.code-workspace", "{}")]); |
| let resolved = resolve_vscode_targets(&ws, &empty_vscode_args()).unwrap(); |
| assert_eq!(resolved.settings_json, ws.join(".vscode/settings.json")); |
| let cw = resolved |
| .code_workspace |
| .expect("autodetect should pick up the .code-workspace"); |
| assert_eq!(cw.path, ws.join("myproj.code-workspace")); |
| // Default nesting key for .code-workspace. |
| assert_eq!(cw.settings_key, "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}" |
| ); |
| // The message must point at BOTH escape hatches. |
| assert!(err.contains("--code-workspace")); |
| assert!(err.contains("--no-code-workspace")); |
| let _ = std::fs::remove_dir_all(&ws); |
| } |
| |
| #[test] |
| fn no_code_workspace_skips_even_if_one_is_present() { |
| let ws = make_workspace("skip_flag", &[("myproj.code-workspace", "{}")]); |
| let mut args = empty_vscode_args(); |
| args.no_code_workspace = true; |
| let resolved = resolve_vscode_targets(&ws, &args).unwrap(); |
| assert!(resolved.code_workspace.is_none()); |
| let _ = std::fs::remove_dir_all(&ws); |
| } |
| |
| #[test] |
| fn explicit_code_workspace_flag_forces_target() { |
| // `--code-workspace` accepts a path that doesn't exist yet |
| // (setup will create the file on write). |
| let ws = Utf8PathBuf::from("/ws"); |
| let mut args = empty_vscode_args(); |
| args.code_workspace = Some(Utf8PathBuf::from("/ws/new.code-workspace")); |
| let resolved = resolve_vscode_targets(&ws, &args).unwrap(); |
| let cw = resolved.code_workspace.unwrap(); |
| assert_eq!(cw.path, Utf8PathBuf::from("/ws/new.code-workspace")); |
| assert_eq!(cw.settings_key, "settings"); |
| } |
| |
| #[test] |
| fn explicit_settings_key_overrides_default() { |
| let ws = Utf8PathBuf::from("/ws"); |
| let mut args = empty_vscode_args(); |
| args.code_workspace = Some(Utf8PathBuf::from("/ws/foo.code-workspace")); |
| args.settings_key = Some("my_settings".to_owned()); |
| let resolved = resolve_vscode_targets(&ws, &args).unwrap(); |
| let cw = resolved.code_workspace.unwrap(); |
| assert_eq!(cw.settings_key, "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_file(&path, &vscode_managed_keys(&ctx), Some("rust")).unwrap(); |
| let parsed = parse_merged(&merged); |
| let nested = parsed |
| .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); |
| } |
| |
| // ----------------------------------------------------------------- |
| // JSONC support (via `serde_jsonrc`) |
| // ----------------------------------------------------------------- |
| |
| #[test] |
| fn read_root_object_accepts_jsonc_file() { |
| // End-to-end: a real `.vscode/settings.json`-flavored file |
| // with comments + trailing commas parses cleanly through |
| // read_root_object, no `--replace` fallback needed. |
| let tmp = std::env::temp_dir().join(format!("setup_jsonc_root_{}", std::process::id())); |
| let _ = std::fs::remove_dir_all(&tmp); |
| std::fs::create_dir_all(&tmp).unwrap(); |
| let path = Utf8PathBuf::try_from(tmp.join("settings.json")).unwrap(); |
| fs::write( |
| &path, |
| r#"{ |
| // team-wide indent |
| "editor.tabSize": 4, |
| "rust-analyzer.checkOnSave": true, |
| } |
| "#, |
| ) |
| .unwrap(); |
| // Trivia-preserving round-trip: merging into a file with |
| // comments and trailing commas keeps them intact. |
| let managed: Vec<(String, ManagedValue)> = vec![]; |
| let merged = merge_file(&path, &managed, None).unwrap(); |
| assert!( |
| merged.contains("// team-wide indent"), |
| "line comment must survive: {merged}" |
| ); |
| // Existing keys still there. |
| let parsed = parse_merged(&merged); |
| let obj = parsed.as_object().unwrap(); |
| assert_eq!(obj.get("editor.tabSize"), Some(&json!(4))); |
| assert_eq!(obj.get("rust-analyzer.checkOnSave"), Some(&json!(true))); |
| let _ = std::fs::remove_dir_all(&tmp); |
| } |
| } |