diff --git a/.gitignore b/.gitignore index 857fbc52a..e51abe92f 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ hubris.testout.* !.vscode/launch.json !.vscode/tasks.json !.vscode/extensions.json +.nvim.lua diff --git a/README.mkdn b/README.mkdn index fb34a946e..a24368183 100644 --- a/README.mkdn +++ b/README.mkdn @@ -171,8 +171,52 @@ $ cargo xtask clippy app/gimletlet/app.toml ping pong ## Integrating with `rust-analyzer` The Hubris build system will not work with `rust-analyzer` out of the box. -However, `cargo xtask lsp` is here to help: it takes as its argument a Rust -file, and returns JSON-encoded configuration for how to set up `rust-analyzer`. +There are two strategies: + +- `cargo xtask rust-analyzer` is a shim that pretends to be `rust-analyzer` but + intercepts setup messages to apply configuration specific to a + `(manifest,task)` tuple. This is easy to use but somewhat limited. +- `cargo xtask lsp` accepts a file path and emits config information that can be + used when setting up `rust-analyzer`. It can be tightly integrated (if you + write a bunch of editor-specific code) to automatically find a manifest and + task when a file is edited, then launch an appropriate `rust-analyzer` + instance. + +Right now, you should probably use `cargo xtask rust-analyzer`; the instructions +below for `cargo xtask lsp` have rotted as Neovim's configuration has changed. + +### Using `cargo xtask rust-analyzer` +Configure your editor to use a custom `rust-analyzer`: +``` +cargo run -pxtask -- rust-analyzer PATH/TO/APP.TOML:TASK_NAME +``` + +#### Example Neovim integration +If you're using Neovim, you can enable project-specific `.nvim.lua` files in +your global configuration: +```lua +-- Enable project-specific `.nvim.lua` files +vim.o.exrc = true +``` + +Then add `.nvim.lua` to this repo which reads from a `HUBRIS_TASK` variable: +```lua +local project_root = vim.fn.fnamemodify(debug.getinfo(1, "S").source:sub(2), ":h") + +vim.lsp.config("rust_analyzer", { + cmd = { "cargo", "run", "-pxtask", "--", "rust-analyzer", vim.env.HUBRIS_TASK }, + cmd_cwd = project_root, +}) +``` + +Finally, launch your editor with an appropriate `HUBRIS_TASK`: +```console +$ HUBRIS_TASK=app/cosmo/rev-a.toml:control_plane_agent nvim +``` + +### Using `cargo xtask lsp` +`cargo xtask lsp` takes as its argument a Rust file, and returns JSON-encoded +configuration for how to set up `rust-analyzer`. To use this data, some editor configuration is required! @@ -297,7 +341,7 @@ require'rust-tools'.setup{ end ``` -### What's going on here? +#### What's going on here? When a new LSP configuration is created (`on_new_config`), we run `cargo xtask lsp` on the target file. The JSON configuration includes a hash of the configuration; we use that hash to modify the name of the client from diff --git a/build/xtask/src/main.rs b/build/xtask/src/main.rs index b75ebe0e7..374d2f451 100644 --- a/build/xtask/src/main.rs +++ b/build/xtask/src/main.rs @@ -21,6 +21,7 @@ mod humility; mod lsp; mod passthrough; mod print; +mod rust_analyzer; mod sizes; mod task_slot; @@ -247,6 +248,15 @@ enum Xtask { file: PathBuf, }, + /// Runs `rust-analyzer` for the target manifest + task + RustAnalyzer { + /// Path to which logs should be written + #[clap(short, long)] + log: Option, + /// Colon-delimited `path/to/app.toml:task_name` value + target: Option, + }, + /// Prepare artifacts for upload in CI. GhaPrepareArtifacts { /// Path to the image configuration file, in TOML. @@ -546,6 +556,26 @@ fn run(xtask: Xtask) -> Result<()> { Xtask::Lsp { clients, file } => { lsp::run(&file, &clients)?; } + Xtask::RustAnalyzer { log, target } => { + let t = if let Some(target) = target { + let mut iter = target.split(':'); + let manifest = iter + .next() + .ok_or_else(|| anyhow!("failed to get manifest"))?; + let task = + iter.next().ok_or_else(|| anyhow!("failed to get task"))?; + if iter.next().is_some() { + bail!("expected two colon-separated values in `{target}`"); + } + Some(rust_analyzer::HubrisTargetTask { + manifest: manifest.into(), + task_name: task.to_owned(), + }) + } else { + None + }; + rust_analyzer::run(log, t)?; + } Xtask::GhaPrepareArtifacts { cfg, attestation } => { gha_prepare_artifacts::run(&cfg, attestation.as_deref())?; } diff --git a/build/xtask/src/rust_analyzer.rs b/build/xtask/src/rust_analyzer.rs new file mode 100644 index 000000000..615997fa4 --- /dev/null +++ b/build/xtask/src/rust_analyzer.rs @@ -0,0 +1,340 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use crate::dist::PackageConfig; +use anyhow::{Context, Result, anyhow, bail}; +use std::collections::{BTreeMap, HashSet}; +use std::io::Write; + +pub struct HubrisTargetTask { + pub manifest: std::path::PathBuf, + pub task_name: String, +} + +struct LspConfig { + extra_env: BTreeMap, + target: String, + features: Vec, +} + +type JsonObject = serde_json::Map; + +impl LspConfig { + /// Applies patches from the config to a JSON object + /// + /// The JSON object should be the `rust-analyzer` section (described + /// [here](https://rust-analyzer.github.io/book/configuration.html)), which + /// is `initializationOptions` in the `initialize` message sent by the + /// editor. + fn patch_options(&self, options: &mut JsonObject) { + let cargo = get_or_insert(options, "cargo"); + cargo.insert("noDefaultFeatures".to_string(), true.into()); + if !cargo.contains_key("features") { + cargo.insert( + "features".to_string(), + serde_json::Value::Array(Default::default()), + ); + } + let features = cargo + .get_mut("features") + .and_then(|f| f.as_array_mut()) + .expect("`features` must be an array"); + features.extend(self.features.iter().map(|v| v.clone().into())); + let extra_env = get_or_insert(cargo, "extraEnv"); + extra_env.extend( + self.extra_env + .iter() + .map(|(k, v)| (k.clone(), v.clone().into())), + ); + cargo.insert("target".to_owned(), self.target.to_owned().into()); + + // Only check the package being edited, to avoid errors from all the + // other crates in the workspace (which may be incompatible with this + // specific task's build configuration) + let check = get_or_insert(options, "check"); + check.insert("workspace".to_owned(), false.into()); + } +} + +pub fn run( + log: Option, + target: Option, +) -> Result<()> { + let bonus_config = if let Some(HubrisTargetTask { + manifest, + task_name, + }) = &target + { + let app_cfg = PackageConfig::new(manifest, false, false).context( + format!("could not open manifest at {}", manifest.display()), + )?; + let task = app_cfg + .toml + .tasks + .get(task_name) + .ok_or_else(|| anyhow!("could not find task `{task_name}`"))?; + let build_cfg = app_cfg + .toml + .task_build_config(task_name, false, None) + .map_err(|_| anyhow!("could not get build config for {task_name}")) + .unwrap(); + + // Find the `--target` argument + let mut iter = build_cfg.args.iter(); + let mut target = None; + while let Some(t) = iter.next() { + if t == "--target" { + iter.next().clone_into(&mut target); + } + } + let Some(target) = target else { + bail!("missing --target argument in build config"); + }; + + // Build up features enabled on the task package (which will enable + // features on downstream packages as well). TODO do we need to resolve + // all downstream package features, or does feature unification work? + let features: Vec = task + .features + .iter() + .map(|f| format!("{}/{f}", task.name)) + .collect(); + + Some(LspConfig { + extra_env: build_cfg.env, + target: target.clone(), + features, + }) + } else { + None + }; + + let out = std::process::Command::new("rustup") + .args(["show", "active-toolchain"]) + .output() + .context("could not run `rustup`")?; + if !out.status.success() { + bail!( + "rustup failed with exit code {}{}{}", + out.status, + if out.stdout.is_empty() { + "".to_owned() + } else { + format!("\nstdout:\n{}", String::from_utf8_lossy(&out.stdout)) + }, + if out.stderr.is_empty() { + "".to_owned() + } else { + format!("\nstderr:\n{}", String::from_utf8_lossy(&out.stderr)) + }, + ); + } + let stdout = std::str::from_utf8(&out.stdout) + .context("could not parse stdout as utf-8")?; + let toolchain = stdout + .split_whitespace() + .next() + .ok_or_else(|| anyhow!("could not get toolchain"))?; + + let mut ra = std::process::Command::new("rustup") + .arg("run") + .arg(toolchain) + .arg("rust-analyzer") + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::inherit()) + .stdin(std::process::Stdio::piped()) + .spawn()?; + + let ra_stdout = ra.stdout.take().expect("stdout should be present"); + let ra_stdin = ra.stdin.take().expect("stdin should be present"); + + let (tx, rx) = std::sync::mpsc::channel(); + + // Thread to listen to the text editor's `stdout` (our `stdin`) + let tx_ = tx.clone(); + std::thread::spawn(move || { + let mut stdin = std::io::stdin().lock(); + while let Some(v) = read_json_object(&mut stdin) { + if tx_.send(Msg::EditorToLsp(v)).is_err() { + break; + } + } + }); + + // Thread to listen to `rust-analyzer`'s stdout + let tx_ = tx.clone(); + std::thread::spawn(move || { + let mut ra_stdout = std::io::BufReader::new(ra_stdout); + while let Some(v) = read_json_object(&mut ra_stdout) { + if tx_.send(Msg::LspToEditor(v)).is_err() { + break; + } + } + }); + + let log = if let Some(log_path) = &log { + Some(std::fs::File::create(log_path).with_context(|| { + format!("failed to create log file at `{}`", log_path.display()) + })?) + } else { + None + }; + let mut worker = Worker { + rx, + to_editor: std::io::stdout().lock(), + to_lsp: ra_stdin, + cfg: bonus_config, + pending_cfg: HashSet::new(), + log, + }; + worker.run(); + ra.kill().unwrap(); + + Ok(()) +} + +enum Msg { + EditorToLsp(JsonObject), + LspToEditor(JsonObject), +} + +struct Worker<'a> { + /// Channel for messages + rx: std::sync::mpsc::Receiver, + + /// Channel to write to the text editor (which is our `stdout`) + to_editor: std::io::StdoutLock<'a>, + + /// Channel to write to `rust-analyzer` (which is its `stdin`) + to_lsp: std::process::ChildStdin, + + /// Object containing bonus configuration + cfg: Option, + + /// Pending `workspace/configuration` messages which should be hotpatched + pending_cfg: HashSet, + + /// File for logging + log: Option, +} + +impl Worker<'_> { + fn run(&mut self) { + while let Ok(msg) = self.rx.recv() { + match msg { + Msg::LspToEditor(v) => self.lsp_to_editor(v), + Msg::EditorToLsp(v) => self.editor_to_lsp(v), + } + } + } + + fn write_log(&mut self, header: &'static str, v: &JsonObject) { + if let Some(log) = &mut self.log { + writeln!( + log, + "{header}\n{}\n", + serde_json::to_string_pretty(v).unwrap() + ) + .unwrap(); + } + } + + fn lsp_to_editor(&mut self, v: JsonObject) { + self.write_log("lsp -> editor", &v); + if self.cfg.is_some() + && let Some(method) = v.get("method") + && method.as_str() == Some("workspace/configuration") + && let Some(id) = v.get("id").and_then(|v| v.as_u64()) + { + self.pending_cfg.insert(id); + } + write_json(&mut self.to_editor, v); + } + + fn editor_to_lsp(&mut self, mut v: JsonObject) { + if let Some(cfg) = &self.cfg { + // We patch two different messages to inject our configuration: + // - `initialize` (editor -> lsp) + // - `workspace/configuration` (lsp -> editor, we patch the response + // below) + if let Some(method) = v.get("method") + && method.as_str() == Some("initialize") + && let Some(params) = v.get_mut("params") + && let Some(params) = params.as_object_mut() + { + let options = get_or_insert(params, "initializationOptions"); + cfg.patch_options(options); + } + + if let Some(id) = v.get("id").and_then(|v| v.as_u64()) + && let Some(result) = v.get_mut("result") + && let Some(result) = result.as_array_mut() + && result.len() == 1 + && let Some(result) = result[0].as_object_mut() + && self.pending_cfg.remove(&id) + { + cfg.patch_options(result); + } + } + self.write_log("editor -> lsp", &v); + write_json(&mut self.to_lsp, v); + } +} + +/// Reads an LSP-formatted message and returns the JSON object payload +fn read_json_object(buf: &mut B) -> Option { + let mut line = String::new(); + + // Read the Content-Length line + buf.read_line(&mut line).unwrap(); + if line.is_empty() { + return None; // EOF + } + let n = line + .strip_prefix("Content-Length: ") + .expect("missing `Content-Length: `") + .strip_suffix("\r\n") + .expect("missing trailing `\r\n`"); + let size: usize = n.parse().unwrap(); + + // Read the empty line + line.clear(); + buf.read_line(&mut line).unwrap(); + + let mut data = vec![0u8; size]; // for trailing "\r\n" + buf.read_exact(&mut data).unwrap(); + + let s = String::from_utf8(data).expect("could not parse body as utf-8"); + let v = serde_json::from_str(&s).expect("could not parse JSON"); + let serde_json::Value::Object(m) = v else { + panic!("expected JSON object, got {v}"); + }; + Some(m) +} + +/// Writes an LSP-formatted JSON object to a `Write` stream +fn write_json>( + buf: &mut B, + value: V, +) { + let out = value.into().to_string(); + buf.write_all(format!("Content-Length: {}\r\n", out.len()).as_bytes()) + .unwrap(); + buf.write_all(b"\r\n").unwrap(); + buf.write_all(out.as_bytes()).unwrap(); + buf.flush().unwrap(); +} + +/// Helper function to get or insert an Object into a JSON map +fn get_or_insert<'a>( + obj: &'a mut serde_json::Map, + v: &'static str, +) -> &'a mut serde_json::Map { + if !obj.contains_key(v) { + obj.insert(v.to_owned(), serde_json::Value::Object(Default::default())); + } + obj.get_mut(v) + .and_then(|o| o.as_object_mut()) + .unwrap_or_else(|| panic!("{v} should be an object")) +}