Initial implementation of cargo check watching

This commit is contained in:
Emil Lauridsen 2019-12-25 12:21:38 +01:00
parent 52b44ba7ed
commit 66e8ef53a0
8 changed files with 599 additions and 4 deletions

1
Cargo.lock generated
View file

@ -1051,6 +1051,7 @@ dependencies = [
name = "ra_lsp_server"
version = "0.1.0"
dependencies = [
"cargo_metadata 0.9.1 (registry+https://github.com/rust-lang/crates.io-index)",
"crossbeam-channel 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)",
"env_logger 0.7.1 (registry+https://github.com/rust-lang/crates.io-index)",
"jod-thread 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)",

View file

@ -27,6 +27,7 @@ ra_project_model = { path = "../ra_project_model" }
ra_prof = { path = "../ra_prof" }
ra_vfs_glob = { path = "../ra_vfs_glob" }
env_logger = { version = "0.7.1", default-features = false, features = ["humantime"] }
cargo_metadata = "0.9.1"
[dev-dependencies]
tempfile = "3"

View file

@ -6,7 +6,7 @@ use lsp_types::{
ImplementationProviderCapability, RenameOptions, RenameProviderCapability,
SelectionRangeProviderCapability, ServerCapabilities, SignatureHelpOptions,
TextDocumentSyncCapability, TextDocumentSyncKind, TextDocumentSyncOptions,
TypeDefinitionProviderCapability, WorkDoneProgressOptions,
TypeDefinitionProviderCapability, WorkDoneProgressOptions, SaveOptions
};
pub fn server_capabilities() -> ServerCapabilities {
@ -16,7 +16,7 @@ pub fn server_capabilities() -> ServerCapabilities {
change: Some(TextDocumentSyncKind::Full),
will_save: None,
will_save_wait_until: None,
save: None,
save: Some(SaveOptions::default()),
})),
hover_provider: Some(true),
completion_provider: Some(CompletionOptions {

View file

@ -0,0 +1,533 @@
use cargo_metadata::{
diagnostic::{
Applicability, Diagnostic as RustDiagnostic, DiagnosticLevel, DiagnosticSpan,
DiagnosticSpanMacroExpansion,
},
Message,
};
use crossbeam_channel::{select, unbounded, Receiver, RecvError, Sender, TryRecvError};
use lsp_types::{
Diagnostic, DiagnosticRelatedInformation, DiagnosticSeverity, DiagnosticTag, Location,
NumberOrString, Position, Range, Url,
};
use parking_lot::RwLock;
use std::{
collections::HashMap,
fmt::Write,
path::PathBuf,
process::{Command, Stdio},
sync::Arc,
thread::JoinHandle,
time::Instant,
};
#[derive(Debug)]
pub struct CheckWatcher {
pub task_recv: Receiver<CheckTask>,
pub cmd_send: Sender<CheckCommand>,
pub shared: Arc<RwLock<CheckWatcherSharedState>>,
handle: JoinHandle<()>,
}
impl CheckWatcher {
pub fn new(workspace_root: PathBuf) -> CheckWatcher {
let shared = Arc::new(RwLock::new(CheckWatcherSharedState::new()));
let (task_send, task_recv) = unbounded::<CheckTask>();
let (cmd_send, cmd_recv) = unbounded::<CheckCommand>();
let shared_ = shared.clone();
let handle = std::thread::spawn(move || {
let mut check = CheckWatcherState::new(shared_, workspace_root);
check.run(&task_send, &cmd_recv);
});
CheckWatcher { task_recv, cmd_send, handle, shared }
}
pub fn update(&self) {
self.cmd_send.send(CheckCommand::Update).unwrap();
}
}
pub struct CheckWatcherState {
workspace_root: PathBuf,
running: bool,
watcher: WatchThread,
last_update_req: Option<Instant>,
shared: Arc<RwLock<CheckWatcherSharedState>>,
}
#[derive(Debug)]
pub struct CheckWatcherSharedState {
diagnostic_collection: HashMap<Url, Vec<Diagnostic>>,
suggested_fix_collection: HashMap<Url, Vec<SuggestedFix>>,
}
impl CheckWatcherSharedState {
fn new() -> CheckWatcherSharedState {
CheckWatcherSharedState {
diagnostic_collection: HashMap::new(),
suggested_fix_collection: HashMap::new(),
}
}
pub fn clear(&mut self, task_send: &Sender<CheckTask>) {
let cleared_files: Vec<Url> = self.diagnostic_collection.keys().cloned().collect();
self.diagnostic_collection.clear();
self.suggested_fix_collection.clear();
for uri in cleared_files {
task_send.send(CheckTask::Update(uri.clone())).unwrap();
}
}
pub fn diagnostics_for(&self, uri: &Url) -> Option<&[Diagnostic]> {
self.diagnostic_collection.get(uri).map(|d| d.as_slice())
}
pub fn fixes_for(&self, uri: &Url) -> Option<&[SuggestedFix]> {
self.suggested_fix_collection.get(uri).map(|d| d.as_slice())
}
fn add_diagnostic(&mut self, file_uri: Url, diagnostic: Diagnostic) {
let diagnostics = self.diagnostic_collection.entry(file_uri).or_default();
// If we're building multiple targets it's possible we've already seen this diagnostic
let is_duplicate = diagnostics.iter().any(|d| are_diagnostics_equal(d, &diagnostic));
if is_duplicate {
return;
}
diagnostics.push(diagnostic);
}
fn add_suggested_fix_for_diagnostic(
&mut self,
mut suggested_fix: SuggestedFix,
diagnostic: &Diagnostic,
) {
let file_uri = suggested_fix.location.uri.clone();
let file_suggestions = self.suggested_fix_collection.entry(file_uri).or_default();
let existing_suggestion: Option<&mut SuggestedFix> =
file_suggestions.iter_mut().find(|s| s == &&suggested_fix);
if let Some(existing_suggestion) = existing_suggestion {
// The existing suggestion also applies to this new diagnostic
existing_suggestion.diagnostics.push(diagnostic.clone());
} else {
// We haven't seen this suggestion before
suggested_fix.diagnostics.push(diagnostic.clone());
file_suggestions.push(suggested_fix);
}
}
}
#[derive(Debug)]
pub enum CheckTask {
Update(Url),
}
pub enum CheckCommand {
Update,
}
impl CheckWatcherState {
pub fn new(
shared: Arc<RwLock<CheckWatcherSharedState>>,
workspace_root: PathBuf,
) -> CheckWatcherState {
let watcher = WatchThread::new(&workspace_root);
CheckWatcherState { workspace_root, running: false, watcher, last_update_req: None, shared }
}
pub fn run(&mut self, task_send: &Sender<CheckTask>, cmd_recv: &Receiver<CheckCommand>) {
self.running = true;
while self.running {
select! {
recv(&cmd_recv) -> cmd => match cmd {
Ok(cmd) => self.handle_command(cmd),
Err(RecvError) => {
// Command channel has closed, so shut down
self.running = false;
},
},
recv(self.watcher.message_recv) -> msg => match msg {
Ok(msg) => self.handle_message(msg, task_send),
Err(RecvError) => {},
}
};
if self.should_recheck() {
self.last_update_req.take();
self.shared.write().clear(task_send);
self.watcher.cancel();
self.watcher = WatchThread::new(&self.workspace_root);
}
}
}
fn should_recheck(&mut self) -> bool {
if let Some(_last_update_req) = &self.last_update_req {
// We currently only request an update on save, as we need up to
// date source on disk for cargo check to do it's magic, so we
// don't really need to debounce the requests at this point.
return true;
}
false
}
fn handle_command(&mut self, cmd: CheckCommand) {
match cmd {
CheckCommand::Update => self.last_update_req = Some(Instant::now()),
}
}
fn handle_message(&mut self, msg: cargo_metadata::Message, task_send: &Sender<CheckTask>) {
match msg {
Message::CompilerArtifact(_msg) => {
// TODO: Status display
}
Message::CompilerMessage(msg) => {
let map_result =
match map_rust_diagnostic_to_lsp(&msg.message, &self.workspace_root) {
Some(map_result) => map_result,
None => return,
};
let MappedRustDiagnostic { location, diagnostic, suggested_fixes } = map_result;
let file_uri = location.uri.clone();
if !suggested_fixes.is_empty() {
for suggested_fix in suggested_fixes {
self.shared
.write()
.add_suggested_fix_for_diagnostic(suggested_fix, &diagnostic);
}
}
self.shared.write().add_diagnostic(file_uri, diagnostic);
task_send.send(CheckTask::Update(location.uri)).unwrap();
}
Message::BuildScriptExecuted(_msg) => {}
Message::Unknown => {}
}
}
}
/// WatchThread exists to wrap around the communication needed to be able to
/// run `cargo check` without blocking. Currently the Rust standard library
/// doesn't provide a way to read sub-process output without blocking, so we
/// have to wrap sub-processes output handling in a thread and pass messages
/// back over a channel.
struct WatchThread {
message_recv: Receiver<cargo_metadata::Message>,
cancel_send: Sender<()>,
}
impl WatchThread {
fn new(workspace_root: &PathBuf) -> WatchThread {
let manifest_path = format!("{}/Cargo.toml", workspace_root.to_string_lossy());
let (message_send, message_recv) = unbounded();
let (cancel_send, cancel_recv) = unbounded();
std::thread::spawn(move || {
let mut command = Command::new("cargo")
.args(&["check", "--message-format=json", "--manifest-path", &manifest_path])
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("couldn't launch cargo");
for message in cargo_metadata::parse_messages(command.stdout.take().unwrap()) {
match cancel_recv.try_recv() {
Ok(()) | Err(TryRecvError::Disconnected) => {
command.kill().expect("couldn't kill command");
}
Err(TryRecvError::Empty) => (),
}
message_send.send(message.unwrap()).unwrap();
}
});
WatchThread { message_recv, cancel_send }
}
fn cancel(&self) {
let _ = self.cancel_send.send(());
}
}
/// Converts a Rust level string to a LSP severity
fn map_level_to_severity(val: DiagnosticLevel) -> Option<DiagnosticSeverity> {
match val {
DiagnosticLevel::Ice => Some(DiagnosticSeverity::Error),
DiagnosticLevel::Error => Some(DiagnosticSeverity::Error),
DiagnosticLevel::Warning => Some(DiagnosticSeverity::Warning),
DiagnosticLevel::Note => Some(DiagnosticSeverity::Information),
DiagnosticLevel::Help => Some(DiagnosticSeverity::Hint),
DiagnosticLevel::Unknown => None,
}
}
/// Check whether a file name is from macro invocation
fn is_from_macro(file_name: &str) -> bool {
file_name.starts_with('<') && file_name.ends_with('>')
}
/// Converts a Rust macro span to a LSP location recursively
fn map_macro_span_to_location(
span_macro: &DiagnosticSpanMacroExpansion,
workspace_root: &PathBuf,
) -> Option<Location> {
if !is_from_macro(&span_macro.span.file_name) {
return Some(map_span_to_location(&span_macro.span, workspace_root));
}
if let Some(expansion) = &span_macro.span.expansion {
return map_macro_span_to_location(&expansion, workspace_root);
}
None
}
/// Converts a Rust span to a LSP location
fn map_span_to_location(span: &DiagnosticSpan, workspace_root: &PathBuf) -> Location {
if is_from_macro(&span.file_name) && span.expansion.is_some() {
let expansion = span.expansion.as_ref().unwrap();
if let Some(macro_range) = map_macro_span_to_location(&expansion, workspace_root) {
return macro_range;
}
}
let mut file_name = workspace_root.clone();
file_name.push(&span.file_name);
let uri = Url::from_file_path(file_name).unwrap();
let range = Range::new(
Position::new(span.line_start as u64 - 1, span.column_start as u64 - 1),
Position::new(span.line_end as u64 - 1, span.column_end as u64 - 1),
);
Location { uri, range }
}
/// Converts a secondary Rust span to a LSP related information
///
/// If the span is unlabelled this will return `None`.
fn map_secondary_span_to_related(
span: &DiagnosticSpan,
workspace_root: &PathBuf,
) -> Option<DiagnosticRelatedInformation> {
if let Some(label) = &span.label {
let location = map_span_to_location(span, workspace_root);
Some(DiagnosticRelatedInformation { location, message: label.clone() })
} else {
// Nothing to label this with
None
}
}
/// Determines if diagnostic is related to unused code
fn is_unused_or_unnecessary(rd: &RustDiagnostic) -> bool {
if let Some(code) = &rd.code {
match code.code.as_str() {
"dead_code" | "unknown_lints" | "unreachable_code" | "unused_attributes"
| "unused_imports" | "unused_macros" | "unused_variables" => true,
_ => false,
}
} else {
false
}
}
/// Determines if diagnostic is related to deprecated code
fn is_deprecated(rd: &RustDiagnostic) -> bool {
if let Some(code) = &rd.code {
match code.code.as_str() {
"deprecated" => true,
_ => false,
}
} else {
false
}
}
#[derive(Debug)]
pub struct SuggestedFix {
pub title: String,
pub location: Location,
pub replacement: String,
pub applicability: Applicability,
pub diagnostics: Vec<Diagnostic>,
}
impl std::cmp::PartialEq<SuggestedFix> for SuggestedFix {
fn eq(&self, other: &SuggestedFix) -> bool {
if self.title == other.title
&& self.location == other.location
&& self.replacement == other.replacement
{
// Applicability doesn't impl PartialEq...
match (&self.applicability, &other.applicability) {
(Applicability::MachineApplicable, Applicability::MachineApplicable) => true,
(Applicability::HasPlaceholders, Applicability::HasPlaceholders) => true,
(Applicability::MaybeIncorrect, Applicability::MaybeIncorrect) => true,
(Applicability::Unspecified, Applicability::Unspecified) => true,
_ => false,
}
} else {
false
}
}
}
enum MappedRustChildDiagnostic {
Related(DiagnosticRelatedInformation),
SuggestedFix(SuggestedFix),
MessageLine(String),
}
fn map_rust_child_diagnostic(
rd: &RustDiagnostic,
workspace_root: &PathBuf,
) -> MappedRustChildDiagnostic {
let span: &DiagnosticSpan = match rd.spans.iter().find(|s| s.is_primary) {
Some(span) => span,
None => {
// `rustc` uses these spanless children as a way to print multi-line
// messages
return MappedRustChildDiagnostic::MessageLine(rd.message.clone());
}
};
// If we have a primary span use its location, otherwise use the parent
let location = map_span_to_location(&span, workspace_root);
if let Some(suggested_replacement) = &span.suggested_replacement {
// Include our replacement in the title unless it's empty
let title = if !suggested_replacement.is_empty() {
format!("{}: '{}'", rd.message, suggested_replacement)
} else {
rd.message.clone()
};
MappedRustChildDiagnostic::SuggestedFix(SuggestedFix {
title,
location,
replacement: suggested_replacement.clone(),
applicability: span.suggestion_applicability.clone().unwrap_or(Applicability::Unknown),
diagnostics: vec![],
})
} else {
MappedRustChildDiagnostic::Related(DiagnosticRelatedInformation {
location,
message: rd.message.clone(),
})
}
}
struct MappedRustDiagnostic {
location: Location,
diagnostic: Diagnostic,
suggested_fixes: Vec<SuggestedFix>,
}
/// Converts a Rust root diagnostic to LSP form
///
/// This flattens the Rust diagnostic by:
///
/// 1. Creating a LSP diagnostic with the root message and primary span.
/// 2. Adding any labelled secondary spans to `relatedInformation`
/// 3. Categorising child diagnostics as either `SuggestedFix`es,
/// `relatedInformation` or additional message lines.
///
/// If the diagnostic has no primary span this will return `None`
fn map_rust_diagnostic_to_lsp(
rd: &RustDiagnostic,
workspace_root: &PathBuf,
) -> Option<MappedRustDiagnostic> {
let primary_span = rd.spans.iter().find(|s| s.is_primary)?;
let location = map_span_to_location(&primary_span, workspace_root);
let severity = map_level_to_severity(rd.level);
let mut primary_span_label = primary_span.label.as_ref();
let mut source = String::from("rustc");
let mut code = rd.code.as_ref().map(|c| c.code.clone());
if let Some(code_val) = &code {
// See if this is an RFC #2103 scoped lint (e.g. from Clippy)
let scoped_code: Vec<&str> = code_val.split("::").collect();
if scoped_code.len() == 2 {
source = String::from(scoped_code[0]);
code = Some(String::from(scoped_code[1]));
}
}
let mut related_information = vec![];
let mut tags = vec![];
for secondary_span in rd.spans.iter().filter(|s| !s.is_primary) {
let related = map_secondary_span_to_related(secondary_span, workspace_root);
if let Some(related) = related {
related_information.push(related);
}
}
let mut suggested_fixes = vec![];
let mut message = rd.message.clone();
for child in &rd.children {
let child = map_rust_child_diagnostic(&child, workspace_root);
match child {
MappedRustChildDiagnostic::Related(related) => related_information.push(related),
MappedRustChildDiagnostic::SuggestedFix(suggested_fix) => {
suggested_fixes.push(suggested_fix)
}
MappedRustChildDiagnostic::MessageLine(message_line) => {
write!(&mut message, "\n{}", message_line).unwrap();
// These secondary messages usually duplicate the content of the
// primary span label.
primary_span_label = None;
}
}
}
if let Some(primary_span_label) = primary_span_label {
write!(&mut message, "\n{}", primary_span_label).unwrap();
}
if is_unused_or_unnecessary(rd) {
tags.push(DiagnosticTag::Unnecessary);
}
if is_deprecated(rd) {
tags.push(DiagnosticTag::Deprecated);
}
let diagnostic = Diagnostic {
range: location.range,
severity,
code: code.map(NumberOrString::String),
source: Some(source),
message: rd.message.clone(),
related_information: if !related_information.is_empty() {
Some(related_information)
} else {
None
},
tags: if !tags.is_empty() { Some(tags) } else { None },
};
Some(MappedRustDiagnostic { location, diagnostic, suggested_fixes })
}
fn are_diagnostics_equal(left: &Diagnostic, right: &Diagnostic) -> bool {
left.source == right.source
&& left.severity == right.severity
&& left.range == right.range
&& left.message == right.message
}

View file

@ -22,6 +22,7 @@ macro_rules! print {
}
mod caps;
mod cargo_check;
mod cargo_target_spec;
mod conv;
mod main_loop;

View file

@ -19,6 +19,7 @@ use serde::{de::DeserializeOwned, Serialize};
use threadpool::ThreadPool;
use crate::{
cargo_check::CheckTask,
main_loop::{
pending_requests::{PendingRequest, PendingRequests},
subscriptions::Subscriptions,
@ -176,7 +177,8 @@ pub fn main_loop(
Ok(task) => Event::Vfs(task),
Err(RecvError) => Err("vfs died")?,
},
recv(libdata_receiver) -> data => Event::Lib(data.unwrap())
recv(libdata_receiver) -> data => Event::Lib(data.unwrap()),
recv(world_state.check_watcher.task_recv) -> task => Event::CheckWatcher(task.unwrap())
};
if let Event::Msg(Message::Request(req)) = &event {
if connection.handle_shutdown(&req)? {
@ -222,6 +224,7 @@ enum Event {
Task(Task),
Vfs(VfsTask),
Lib(LibraryData),
CheckWatcher(CheckTask),
}
impl fmt::Debug for Event {
@ -259,6 +262,7 @@ impl fmt::Debug for Event {
Event::Task(it) => fmt::Debug::fmt(it, f),
Event::Vfs(it) => fmt::Debug::fmt(it, f),
Event::Lib(it) => fmt::Debug::fmt(it, f),
Event::CheckWatcher(it) => fmt::Debug::fmt(it, f),
}
}
}
@ -318,6 +322,20 @@ fn loop_turn(
world_state.maybe_collect_garbage();
loop_state.in_flight_libraries -= 1;
}
Event::CheckWatcher(task) => match task {
CheckTask::Update(uri) => {
// We manually send a diagnostic update when the watcher asks
// us to, to avoid the issue of having to change the file to
// receive updated diagnostics.
let path = uri.to_file_path().map_err(|()| format!("invalid uri: {}", uri))?;
if let Some(file_id) = world_state.vfs.read().path2file(&path) {
let params =
handlers::publish_diagnostics(&world_state.snapshot(), FileId(file_id.0))?;
let not = notification_new::<req::PublishDiagnostics>(params);
task_sender.send(Task::Notify(not)).unwrap();
}
}
},
Event::Msg(msg) => match msg {
Message::Request(req) => on_request(
world_state,
@ -517,6 +535,13 @@ fn on_notification(
}
Err(not) => not,
};
let not = match notification_cast::<req::DidSaveTextDocument>(not) {
Ok(_params) => {
state.check_watcher.update();
return Ok(());
}
Err(not) => not,
};
let not = match notification_cast::<req::DidCloseTextDocument>(not) {
Ok(params) => {
let uri = params.text_document.uri;

View file

@ -654,6 +654,29 @@ pub fn handle_code_action(
res.push(action.into());
}
for fix in world.check_watcher.read().fixes_for(&params.text_document.uri).into_iter().flatten()
{
let fix_range = fix.location.range.conv_with(&line_index);
if fix_range.intersection(&range).is_none() {
continue;
}
let edits = vec![TextEdit::new(fix.location.range, fix.replacement.clone())];
let mut edit_map = std::collections::HashMap::new();
edit_map.insert(fix.location.uri.clone(), edits);
let edit = WorkspaceEdit::new(edit_map);
let action = CodeAction {
title: fix.title.clone(),
kind: Some("quickfix".to_string()),
diagnostics: Some(fix.diagnostics.clone()),
edit: Some(edit),
command: None,
is_preferred: None,
};
res.push(action.into());
}
for assist in assists {
let title = assist.change.label.clone();
let edit = assist.change.try_conv_with(&world)?;
@ -820,7 +843,7 @@ pub fn publish_diagnostics(
let _p = profile("publish_diagnostics");
let uri = world.file_id_to_uri(file_id)?;
let line_index = world.analysis().file_line_index(file_id)?;
let diagnostics = world
let mut diagnostics: Vec<Diagnostic> = world
.analysis()
.diagnostics(file_id)?
.into_iter()
@ -834,6 +857,9 @@ pub fn publish_diagnostics(
tags: None,
})
.collect();
if let Some(check_diags) = world.check_watcher.read().diagnostics_for(&uri) {
diagnostics.extend(check_diags.iter().cloned());
}
Ok(req::PublishDiagnosticsParams { uri, diagnostics, version: None })
}

View file

@ -24,6 +24,7 @@ use std::path::{Component, Prefix};
use crate::{
main_loop::pending_requests::{CompletedRequest, LatestRequests},
cargo_check::{CheckWatcher, CheckWatcherSharedState},
LspError, Result,
};
use std::str::FromStr;
@ -52,6 +53,7 @@ pub struct WorldState {
pub vfs: Arc<RwLock<Vfs>>,
pub task_receiver: Receiver<VfsTask>,
pub latest_requests: Arc<RwLock<LatestRequests>>,
pub check_watcher: CheckWatcher,
}
/// An immutable snapshot of the world's state at a point in time.
@ -61,6 +63,7 @@ pub struct WorldSnapshot {
pub analysis: Analysis,
pub vfs: Arc<RwLock<Vfs>>,
pub latest_requests: Arc<RwLock<LatestRequests>>,
pub check_watcher: Arc<RwLock<CheckWatcherSharedState>>,
}
impl WorldState {
@ -127,6 +130,9 @@ impl WorldState {
}
change.set_crate_graph(crate_graph);
// FIXME: Figure out the multi-workspace situation
let check_watcher = CheckWatcher::new(folder_roots.first().cloned().unwrap());
let mut analysis_host = AnalysisHost::new(lru_capacity, feature_flags);
analysis_host.apply_change(change);
WorldState {
@ -138,6 +144,7 @@ impl WorldState {
vfs: Arc::new(RwLock::new(vfs)),
task_receiver,
latest_requests: Default::default(),
check_watcher,
}
}
@ -199,6 +206,7 @@ impl WorldState {
analysis: self.analysis_host.analysis(),
vfs: Arc::clone(&self.vfs),
latest_requests: Arc::clone(&self.latest_requests),
check_watcher: self.check_watcher.shared.clone(),
}
}