diff options
Diffstat (limited to 'rust-analyzer-chromiumos-wrapper')
-rw-r--r-- | rust-analyzer-chromiumos-wrapper/Cargo.lock | 147 | ||||
-rw-r--r-- | rust-analyzer-chromiumos-wrapper/Cargo.toml | 17 | ||||
-rw-r--r-- | rust-analyzer-chromiumos-wrapper/README.md | 61 | ||||
-rw-r--r-- | rust-analyzer-chromiumos-wrapper/src/main.rs | 364 |
4 files changed, 589 insertions, 0 deletions
diff --git a/rust-analyzer-chromiumos-wrapper/Cargo.lock b/rust-analyzer-chromiumos-wrapper/Cargo.lock new file mode 100644 index 00000000..aedf8bfc --- /dev/null +++ b/rust-analyzer-chromiumos-wrapper/Cargo.lock @@ -0,0 +1,147 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "anyhow" +version = "1.0.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08f9b8508dccb7687a1d6c4ce66b2b0ecef467c94667de27d8d7fe1f8d2a9cdc" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "itoa" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112c678d4050afce233f4f2852bb2eb519230b3cf12f33585275537d7e41578d" + +[[package]] +name = "libc" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349d5a591cd28b49e1d1037471617a32ddcda5731b99419008085f72d5a53836" + +[[package]] +name = "log" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abb12e687cfb44aa40f41fc3978ef76448f9b6038cad6aef4259d3c095a2382e" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "num_threads" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2819ce041d2ee131036f4fc9d6ae7ae125a3a40e97ba64d04fe799ad9dabbb44" +dependencies = [ + "libc", +] + +[[package]] +name = "rust-analyzer-chromiumos-wrapper" +version = "0.1.0" +dependencies = [ + "anyhow", + "log", + "serde_json", + "simplelog", +] + +[[package]] +name = "ryu" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3f6f92acf49d1b98f7a81226834412ada05458b7364277387724a237f062695" + +[[package]] +name = "serde" +version = "1.0.137" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ea8d54c77f8315140a05f4c7237403bf38b72704d031543aa1d16abbf517d1" + +[[package]] +name = "serde_json" +version = "1.0.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b7ce2b32a1aed03c558dc61a5cd328f15aff2dbc17daad8fb8af04d2100e15c" +dependencies = [ + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "simplelog" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48dfff04aade74dd495b007c831cd6f4e0cee19c344dd9dc0884c0289b70a786" +dependencies = [ + "log", + "termcolor", + "time", +] + +[[package]] +name = "termcolor" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bab24d30b911b2376f3a13cc2cd443142f0c81dda04c118693e35b3835757755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "time" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72c91f41dcb2f096c05f0873d667dceec1087ce5bcf984ec8ffb19acddbb3217" +dependencies = [ + "itoa", + "libc", + "num_threads", + "time-macros", +] + +[[package]] +name = "time-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42657b1a6f4d817cda8e7a0ace261fe0cc946cf3a80314390b22cc61ae080792" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" +dependencies = [ + "winapi", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" diff --git a/rust-analyzer-chromiumos-wrapper/Cargo.toml b/rust-analyzer-chromiumos-wrapper/Cargo.toml new file mode 100644 index 00000000..91d0f9a9 --- /dev/null +++ b/rust-analyzer-chromiumos-wrapper/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "rust-analyzer-chromiumos-wrapper" +version = "0.1.0" +edition = "2021" + +[profile.release] +panic = "abort" + +[dependencies] +anyhow = "1.0" +log = { version = "0.4.17" } +serde_json = "1.0" +simplelog = { version = "0.12.0" } + +[features] +default = ["no_debug_log"] +no_debug_log = ["log/max_level_off", "log/release_max_level_off"] diff --git a/rust-analyzer-chromiumos-wrapper/README.md b/rust-analyzer-chromiumos-wrapper/README.md new file mode 100644 index 00000000..e834ff34 --- /dev/null +++ b/rust-analyzer-chromiumos-wrapper/README.md @@ -0,0 +1,61 @@ +# rust-analyzer-chromiumos-wrapper + +## Intro + +rust-analyzer is an LSP server for the Rust language. It allows editors like +vim, emacs, or VS Code to provide IDE-like features for Rust. + +This program, `rust-analyzer-chromiumos-wrapper`, is a wrapper around +`rust-analyzer`. It exists to translate paths between an instance of +rust-analyzer running inside the chromiumos chroot and a client running outside +the chroot. + +It is of course possible to simply run `rust-analyzer` outside the chroot, but +version mismatch issues may lead to a suboptimal experience. + +It should run outside the chroot. If invoked in a `chromiumos` repo in a +subdirectory of either `chromiumos/src` or `chromiumos/chroot`, it will attempt +to invoke `rust-analyzer` inside the chroot and translate paths. Otherwise, it +will attempt to invoke a `rust-analyzer` outside the chroot and will not +translate paths. + +It supports none of rust-analyzer's command line options, which aren't +necessary for acting as a LSP server anyway. + +## Quickstart + +*Outside* the chroot, install the `rust-analyzer-chromiumos-wrapper` binary: + +``` +cargo install --path /path-to-a-chromiumos-checkout/src/third_party/toolchain-utils/rust-analyzer-chromiumos-wrapper +``` + +Make sure `~/.cargo/bin' is in your PATH, or move/symlink `~/.cargo/bin/rust-analyzer-chromiumos-wrapper` to a location in your PATH. + +Configure your editor to use the binary `rust-analyzer-chromiumos-wrapper` as +`rust-analyzer`. In Neovim, if you're using +[nvim-lspconfig](https://github.com/neovim/nvim-lspconfig), this can be done by +putting the following in your `init.lua`: + +``` +require('lspconfig')['rust_analyzer'].setup { + cmd = {'rust-analyzer-chromiumos-wrapper'}, +} +``` + +This configuration is specific to your editor, but see the +[Rust analyzer manual](https://rust-analyzer.github.io/manual.html) for +more about several different editors. + +Once the above general configuration is set up, you'll need to install +`rust-analyzer` inside each chroot where you want to edit code: +``` +sudo emerge rust-analyzer +``` + +## Misc + +A wrapper isn't necessary for clangd, because clangd supports the option +`--path-mappings` to translate paths. In principle a similar option could be +added to `rust-analyzer`, obviating the need for this wrapper. See this +[issue on github](https://github.com/rust-lang/rust-analyzer/issues/12485). diff --git a/rust-analyzer-chromiumos-wrapper/src/main.rs b/rust-analyzer-chromiumos-wrapper/src/main.rs new file mode 100644 index 00000000..f59af454 --- /dev/null +++ b/rust-analyzer-chromiumos-wrapper/src/main.rs @@ -0,0 +1,364 @@ +// Copyright 2022 The ChromiumOS Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +use std::env; +use std::fs::File; +use std::io::{self, BufRead, BufReader, BufWriter, Write}; +use std::os::unix::process::CommandExt; +use std::path::{Path, PathBuf}; +use std::process::{self, Child}; +use std::str::from_utf8; +use std::thread; + +use anyhow::{anyhow, bail, Context, Result}; + +use log::trace; + +use simplelog::{Config, LevelFilter, WriteLogger}; + +use serde_json::{from_slice, to_writer, Value}; + +fn main() -> Result<()> { + let args = env::args().skip(1); + + let d = env::current_dir()?; + let chromiumos_root = match find_chromiumos_root(&d) { + Some(x) => x, + None => { + // It doesn't appear that we're in a chroot. Run the + // regular rust-analyzer. + return Err(process::Command::new("rust-analyzer").args(args).exec())?; + } + }; + + let args: Vec<String> = args.collect(); + if !args.is_empty() { + // We've received command line arguments, and there are 3 possibilities: + // * We just forward the arguments to rust-analyzer and exit. + // * We don't support the arguments, so we bail. + // * We still need to do our path translation in the LSP protocol. + fn run(args: &[String]) -> Result<()> { + return Err(process::Command::new("cros_sdk") + .args(["--", "rust-analyzer"]) + .args(args) + .exec())?; + } + + if args.iter().any(|x| match x.as_str() { + "--version" | "--help" | "-h" | "--print-config-schema" => true, + _ => false, + }) { + // With any of these options rust-analyzer will just print something and exit. + return run(&args); + } + + if !args[0].starts_with("-") { + // It's a subcommand, and seemingly none of these need the path translation + // rust-analyzer-chromiumos-wrapper provides. + return run(&args); + } + + if args.iter().any(|x| x == "--log-file") { + bail!("rust-analyzer-chromiums_wrapper doesn't support --log-file"); + } + + // Otherwise it seems we're probably OK to proceed. + } + + init_log()?; + + let outside_prefix: &'static str = { + let path = chromiumos_root + .to_str() + .ok_or_else(|| anyhow!("Path is not valid UTF-8"))?; + + let mut tmp = format!("file://{}", path); + if Some(&b'/') != tmp.as_bytes().last() { + tmp.push('/'); + } + + // No need to ever free this memory, so let's get a static reference. + Box::leak(tmp.into_boxed_str()) + }; + + trace!("Found chromiumos root {}", outside_prefix); + + let inside_prefix: &'static str = "file:///mnt/host/source/"; + + let cmd = "cros_sdk"; + let all_args = ["--", "rust-analyzer"] + .into_iter() + .chain(args.iter().map(|x| x.as_str())); + let mut child = KillOnDrop(run_command(cmd, all_args)?); + + let mut child_stdin = BufWriter::new(child.0.stdin.take().unwrap()); + let mut child_stdout = BufReader::new(child.0.stdout.take().unwrap()); + + let join_handle = { + thread::spawn(move || { + let mut stdin = io::stdin().lock(); + stream_with_replacement(&mut stdin, &mut child_stdin, outside_prefix, inside_prefix) + .context("Streaming from stdin into rust-analyzer") + }) + }; + + let mut stdout = BufWriter::new(io::stdout().lock()); + stream_with_replacement( + &mut child_stdout, + &mut stdout, + inside_prefix, + outside_prefix, + ) + .context("Streaming from rust-analyzer into stdout")?; + + join_handle.join().unwrap()?; + + let code = child.0.wait().context("Running rust-analyzer")?.code(); + std::process::exit(code.unwrap_or(127)); +} + +fn init_log() -> Result<()> { + if !cfg!(feature = "no_debug_log") { + let filename = env::var("RUST_ANALYZER_CHROMIUMOS_WRAPPER_LOG") + .context("Obtaining RUST_ANALYZER_CHROMIUMOS_WRAPPER_LOG environment variable")?; + let file = File::create(&filename).with_context(|| { + format!( + "Opening log file `{}` (value of RUST_ANALYZER_WRAPPER_LOG)", + filename + ) + })?; + WriteLogger::init(LevelFilter::Trace, Config::default(), file) + .with_context(|| format!("Creating WriteLogger with log file `{}`", filename))?; + } + Ok(()) +} + +#[derive(Debug, Default)] +struct Header { + length: Option<usize>, + other_fields: Vec<u8>, +} + +/// Read the `Content-Length` (if present) into `header.length`, and the text of every other header +/// field into `header.other_fields`. +fn read_header<R: BufRead>(r: &mut R, header: &mut Header) -> Result<()> { + header.length = None; + header.other_fields.clear(); + const CONTENT_LENGTH: &[u8] = b"Content-Length:"; + let slen = CONTENT_LENGTH.len(); + loop { + let index = header.other_fields.len(); + + // HTTP header spec says line endings are supposed to be '\r\n' but recommends + // implementations accept just '\n', so let's not worry whether a '\r' is present. + r.read_until(b'\n', &mut header.other_fields) + .context("Reading a header")?; + + let new_len = header.other_fields.len(); + + if new_len <= index + 2 { + // Either we've just received EOF, or just a newline, indicating end of the header. + return Ok(()); + } + if header + .other_fields + .get(index..index + slen) + .map_or(false, |v| v == CONTENT_LENGTH) + { + let s = from_utf8(&header.other_fields[index + slen..]) + .context("Parsing Content-Length")?; + header.length = Some(s.trim().parse().context("Parsing Content-Length")?); + header.other_fields.truncate(index); + } + } +} + +/// Extend `dest` with `contents`, replacing any occurrence of `pattern` in a json string in +/// `contents` with `replacement`. +fn replace(contents: &[u8], pattern: &str, replacement: &str, dest: &mut Vec<u8>) -> Result<()> { + fn map_value(val: Value, pattern: &str, replacement: &str) -> Value { + match val { + Value::String(s) => + // `s.replace` is very likely doing more work than necessary. Probably we only need + // to look for the pattern at the beginning of the string. + { + Value::String(s.replace(pattern, replacement)) + } + Value::Array(mut v) => { + for val_ref in v.iter_mut() { + let value = std::mem::replace(val_ref, Value::Null); + *val_ref = map_value(value, pattern, replacement); + } + Value::Array(v) + } + Value::Object(mut map) => { + // Surely keys can't be paths. + for val_ref in map.values_mut() { + let value = std::mem::replace(val_ref, Value::Null); + *val_ref = map_value(value, pattern, replacement); + } + Value::Object(map) + } + x => x, + } + } + + let init_val: Value = from_slice(contents).with_context(|| match from_utf8(contents) { + Err(_) => format!( + "JSON parsing content of length {} that's not valid UTF-8", + contents.len() + ), + Ok(s) => format!("JSON parsing content of length {}:\n{}", contents.len(), s), + })?; + let mapped_val = map_value(init_val, pattern, replacement); + to_writer(dest, &mapped_val)?; + Ok(()) +} + +/// Read LSP messages from `r`, replacing each occurrence of `pattern` in a json string in the +/// payload with `replacement`, adjusting the `Content-Length` in the header to match, and writing +/// the result to `w`. +fn stream_with_replacement<R: BufRead, W: Write>( + r: &mut R, + w: &mut W, + pattern: &str, + replacement: &str, +) -> Result<()> { + let mut head = Header::default(); + let mut buf = Vec::with_capacity(1024); + let mut buf2 = Vec::with_capacity(1024); + loop { + read_header(r, &mut head)?; + if head.length.is_none() && head.other_fields.len() == 0 { + // No content in the header means we're apparently done. + return Ok(()); + } + let len = head + .length + .ok_or_else(|| anyhow!("No Content-Length in header"))?; + + trace!("Received header with length {}", head.length.unwrap()); + trace!( + "Received header with contents\n{}", + from_utf8(&head.other_fields)? + ); + + buf.resize(len, 0); + r.read_exact(&mut buf) + .with_context(|| format!("Reading payload expecting size {}", len))?; + + trace!("Received payload\n{}", from_utf8(&buf)?); + + buf2.clear(); + replace(&buf, pattern, replacement, &mut buf2)?; + + trace!("After replacements payload\n{}", from_utf8(&buf2)?); + + write!(w, "Content-Length: {}\r\n", buf2.len())?; + w.write_all(&head.other_fields)?; + w.write_all(&buf2)?; + w.flush()?; + } +} + +fn run_command<'a, I>(cmd: &'a str, args: I) -> Result<process::Child> +where + I: IntoIterator<Item = &'a str>, +{ + Ok(process::Command::new(cmd) + .args(args) + .stdin(process::Stdio::piped()) + .stdout(process::Stdio::piped()) + .spawn()?) +} + +fn find_chromiumos_root(start: &Path) -> Option<PathBuf> { + let mut buf = start.to_path_buf(); + loop { + buf.push(".chroot_lock"); + if buf.exists() { + buf.pop(); + return Some(buf); + } + buf.pop(); + if !buf.pop() { + return None; + } + } +} + +struct KillOnDrop(Child); + +impl Drop for KillOnDrop { + fn drop(&mut self) { + let _ = self.0.kill(); + } +} + +#[cfg(test)] +mod test { + use super::*; + + fn test_stream_with_replacement( + read: &str, + pattern: &str, + replacement: &str, + json_expected: &str, + ) -> Result<()> { + let mut w = Vec::<u8>::with_capacity(read.len()); + stream_with_replacement(&mut read.as_bytes(), &mut w, pattern, replacement)?; + + // serde_json may not format the json output the same as we do, so we can't just compare + // as strings or slices. + + let (w1, w2) = { + let mut split = w.rsplitn(2, |&c| c == b'\n'); + let w2 = split.next().unwrap(); + (split.next().unwrap(), w2) + }; + + assert_eq!( + from_utf8(w1)?, + format!("Content-Length: {}\r\n\r", w2.len()) + ); + + let v1: Value = from_slice(w2)?; + let v2: Value = serde_json::from_str(json_expected)?; + assert_eq!(v1, v2); + + Ok(()) + } + + #[test] + fn test_stream_with_replacement_1() -> Result<()> { + test_stream_with_replacement( + // read + "Content-Length: 93\r\n\r\n{\"somekey\": {\"somepath\": \"XYZXYZabc\",\ + \"anotherpath\": \"somestring\"}, \"anotherkey\": \"XYZXYZdef\"}", + // pattern + "XYZXYZ", + // replacement + "REPLACE", + // json_expected + "{\"somekey\": {\"somepath\": \"REPLACEabc\", \"anotherpath\": \"somestring\"},\ + \"anotherkey\": \"REPLACEdef\"}", + ) + } + + #[test] + fn test_stream_with_replacement_2() -> Result<()> { + test_stream_with_replacement( + // read + "Content-Length: 83\r\n\r\n{\"key0\": \"sometextABCDEF\",\ + \"key1\": {\"key2\": 5, \"key3\": \"moreABCDEFtext\"}, \"key4\": 1}", + // pattern + "ABCDEF", + // replacement + "replacement", + // json_expected + "{\"key0\": \"sometextreplacement\", \"key1\": {\"key2\": 5,\ + \"key3\": \"morereplacementtext\"}, \"key4\": 1}", + ) + } +} |