| //! 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.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.lens] | |
| enable = true | |
| "#; | |
| const HELIX_TOML_PROC_MACRO: &str = r#" | |
| [language-server.rust-analyzer.config.procMacro] | |
| server = "__PMS_LAUNCHER__" | |
| "#; | |
| const HELIX_TOML_RUSTFMT: &str = r#" | |
| [language-server.rust-analyzer.config.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::*; | |
| use gen_rust_project_lib::make_workspace; | |
| 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.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 | |
| // ----------------------------------------------------------------- | |
| #[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); | |
| } | |
| } |