use std::collections::{BTreeMap, HashMap, HashSet};
use std::panic::AssertUnwindSafe;
use std::path::PathBuf;
use std::sync::Arc;
use std::thread::JoinHandle;
use crossbeam_channel::{Receiver, Sender, select};
use lsp_types::{Diagnostic, Uri};
use salsa::Database as _;
use crate::incremental::IncrementalDatabase;
use crate::index::{HarvestedLibrary, PackageIndex};
use crate::text::PositionEncoding;
use super::format::parse_diagnostics_to_lsp;
use super::graph_diagnostics::graph_diagnostics;
use super::lint::lint_diagnostics_via_db;
use super::read_jobs::{ReadJob, run_read};
use super::state::Outbound;
use super::task_pool::Spawner;
pub(crate) struct AnalysisRequest {
pub(crate) uri: Uri,
pub(crate) path: PathBuf,
pub(crate) text: String,
pub(crate) version: i32,
}
pub(crate) enum LibraryMessage {
Full(HarvestedLibrary),
Package {
name: String,
index: Arc<PackageIndex>,
},
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn spawn_analysis_thread(
analysis_rx: Receiver<AnalysisRequest>,
read_rx: Receiver<ReadJob>,
library_rx: Receiver<LibraryMessage>,
sync_rx: Receiver<PathBuf>,
out_tx: Sender<Outbound>,
read_spawner: Spawner,
encoding: PositionEncoding,
push_diagnostics: bool,
) -> JoinHandle<()> {
let (done_tx, done_rx) = crossbeam_channel::unbounded::<AnalyzeDone>();
std::thread::Builder::new()
.name("fatou-analysis".to_string())
.spawn(move || {
let mut worker = AnalysisWorker {
db: IncrementalDatabase::default(),
out_tx,
done_tx,
inflight: None,
pending: HashMap::new(),
active: None,
read_spawner,
encoding,
push_diagnostics,
published_graph_files: HashSet::new(),
};
worker.run(&analysis_rx, &read_rx, &library_rx, &sync_rx, &done_rx);
})
.expect("spawn analysis thread")
}
struct AnalyzeDone {
uri: Uri,
version: i32,
}
struct InflightAnalyze {
uri: Uri,
version: i32,
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum DispatchAction {
Wait,
Start(Uri),
SupersedeAndStart(Uri),
}
pub(crate) fn decide(
inflight: Option<(&Uri, i32)>,
pending: &HashMap<Uri, i32>,
active: Option<&Uri>,
) -> DispatchAction {
match inflight {
None => match active
.filter(|uri| pending.contains_key(*uri))
.or_else(|| pending.keys().next())
{
Some(uri) => DispatchAction::Start(uri.clone()),
None => DispatchAction::Wait,
},
Some((uri, version)) => {
if pending.get(uri).is_some_and(|&v| v > version) {
DispatchAction::SupersedeAndStart(uri.clone())
} else {
DispatchAction::Wait
}
}
}
}
struct AnalysisWorker {
db: IncrementalDatabase,
out_tx: Sender<Outbound>,
done_tx: Sender<AnalyzeDone>,
inflight: Option<InflightAnalyze>,
pending: HashMap<Uri, AnalysisRequest>,
active: Option<Uri>,
read_spawner: Spawner,
encoding: PositionEncoding,
push_diagnostics: bool,
published_graph_files: HashSet<Uri>,
}
impl AnalysisWorker {
fn run(
&mut self,
analysis_rx: &Receiver<AnalysisRequest>,
read_rx: &Receiver<ReadJob>,
library_rx: &Receiver<LibraryMessage>,
sync_rx: &Receiver<PathBuf>,
done_rx: &Receiver<AnalyzeDone>,
) {
loop {
select! {
recv(sync_rx) -> path => {
if let Ok(path) = path {
self.db.revert_file_to_disk(&path);
}
}
recv(library_rx) -> msg => {
match msg {
Ok(LibraryMessage::Full(lib)) => {
self.db.set_library(lib.packages, lib.roots, lib.workspaces);
self.db.seed_workspace_members();
self.refresh_graph_diagnostics();
}
Ok(LibraryMessage::Package { name, index }) => {
self.db.set_package_index(name, index);
self.db.seed_workspace_members();
self.refresh_graph_diagnostics();
}
Err(_) => {}
}
}
recv(analysis_rx) -> msg => {
let Ok(req) = msg else { break };
self.enqueue(req);
while let Ok(more) = analysis_rx.try_recv() {
self.enqueue(more);
}
self.try_dispatch();
}
recv(done_rx) -> done => {
let Ok(done) = done else { continue };
if matches!(&self.inflight, Some(f) if f.uri == done.uri && f.version == done.version) {
self.inflight = None;
}
self.try_dispatch();
}
recv(read_rx) -> job => {
let Ok(job) = job else { continue };
let snapshot = self.db.snapshot();
let encoding = self.encoding;
self.read_spawner.spawn(move || run_read(snapshot, job, encoding));
}
}
}
}
fn enqueue(&mut self, req: AnalysisRequest) {
self.active = Some(req.uri.clone());
match self.pending.get(&req.uri) {
Some(existing) if existing.version >= req.version => {}
_ => {
self.pending.insert(req.uri.clone(), req);
}
}
}
fn try_dispatch(&mut self) {
let versions: HashMap<Uri, i32> = self
.pending
.iter()
.map(|(uri, req)| (uri.clone(), req.version))
.collect();
let inflight = self.inflight.as_ref().map(|f| (&f.uri, f.version));
let uri = match decide(inflight, &versions, self.active.as_ref()) {
DispatchAction::Wait => return,
DispatchAction::Start(uri) => uri,
DispatchAction::SupersedeAndStart(uri) => {
self.db.trigger_cancellation();
self.inflight = None;
uri
}
};
if let Some(req) = self.pending.remove(&uri) {
self.start(req);
}
}
fn start(&mut self, req: AnalysisRequest) {
let file = self.db.upsert_file(&req.path, req.text.clone());
let snapshot = self.db.snapshot();
let out_tx = self.out_tx.clone();
let done_tx = self.done_tx.clone();
let encoding = self.encoding;
let AnalysisRequest {
uri,
path,
text,
version,
} = req;
self.inflight = Some(InflightAnalyze {
uri: uri.clone(),
version,
});
let push = self.push_diagnostics;
self.read_spawner.spawn(move || {
if push {
let result = salsa::Cancelled::catch(AssertUnwindSafe(|| {
let mut diags =
parse_diagnostics_to_lsp(snapshot.parse_diagnostics(file), &text, encoding);
if diags.is_empty() {
diags.extend(lint_diagnostics_via_db(&snapshot, &path, &text, encoding));
}
diags
}));
if let Ok(diags) = result {
let _ = out_tx.send(Outbound::Diagnostics {
uri: uri.clone(),
version,
diags,
});
}
}
drop(snapshot);
let _ = done_tx.send(AnalyzeDone { uri, version });
});
}
fn refresh_graph_diagnostics(&mut self) {
let updates: BTreeMap<PathBuf, Vec<Diagnostic>> = {
let snapshot = self.db.snapshot();
let graph = snapshot.project_graph();
graph_diagnostics(graph, self.encoding, |path| {
let file = snapshot.lookup_file(path)?;
Some((
snapshot.file_text_of(file).to_string(),
snapshot.parsed_tree(file),
))
})
};
let mut now = HashSet::new();
for (path, diags) in updates {
if let Some(uri) = super::uri::from_path(&path) {
now.insert(uri.clone());
let _ = self
.out_tx
.send(Outbound::ProjectDiagnostics { uri, diags });
}
}
for uri in self.published_graph_files.difference(&now) {
let _ = self.out_tx.send(Outbound::ProjectDiagnostics {
uri: uri.clone(),
diags: Vec::new(),
});
}
self.published_graph_files = now;
let _ = self.out_tx.send(Outbound::DiagnosticsRefresh);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::str::FromStr;
fn uri_named(name: &str) -> Uri {
Uri::from_str(&format!("file:///work/{name}")).unwrap()
}
#[test]
fn decide_idle_starts_a_pending_uri() {
let a = uri_named("a.jl");
let pending = HashMap::from([(a.clone(), 1)]);
assert_eq!(decide(None, &pending, None), DispatchAction::Start(a));
}
#[test]
fn decide_idle_empty_queue_waits() {
let pending: HashMap<Uri, i32> = HashMap::new();
assert_eq!(decide(None, &pending, None), DispatchAction::Wait);
}
#[test]
fn decide_idle_prefers_active_uri() {
let (a, b, c) = (uri_named("a.jl"), uri_named("b.jl"), uri_named("c.jl"));
let pending = HashMap::from([(a, 1), (b.clone(), 1), (c, 1)]);
assert_eq!(decide(None, &pending, Some(&b)), DispatchAction::Start(b));
}
#[test]
fn decide_idle_active_not_pending_falls_back() {
let a = uri_named("a.jl");
let pending = HashMap::from([(a.clone(), 1)]);
assert_eq!(
decide(None, &pending, Some(&uri_named("focused.jl"))),
DispatchAction::Start(a)
);
}
#[test]
fn decide_active_never_cancels_a_different_inflight_uri() {
let (a, b) = (uri_named("a.jl"), uri_named("b.jl"));
let pending = HashMap::from([(b.clone(), 1)]);
assert_eq!(
decide(Some((&a, 1)), &pending, Some(&b)),
DispatchAction::Wait
);
}
#[test]
fn decide_supersedes_same_uri_newer_version() {
let a = uri_named("a.jl");
let pending = HashMap::from([(a.clone(), 2)]);
assert_eq!(
decide(Some((&a, 1)), &pending, None),
DispatchAction::SupersedeAndStart(a)
);
}
#[test]
fn decide_waits_when_pending_same_uri_not_newer() {
let a = uri_named("a.jl");
let pending = HashMap::from([(a.clone(), 1)]);
assert_eq!(decide(Some((&a, 1)), &pending, None), DispatchAction::Wait);
}
#[test]
fn decide_never_cancels_a_different_uri() {
let a = uri_named("a.jl");
let pending = HashMap::from([(uri_named("b.jl"), 5), (uri_named("c.jl"), 9)]);
assert_eq!(decide(Some((&a, 1)), &pending, None), DispatchAction::Wait);
}
#[test]
fn decide_drains_multiple_uris_one_at_a_time() {
let (a, b, c) = (uri_named("a.jl"), uri_named("b.jl"), uri_named("c.jl"));
let mut pending = HashMap::from([(a.clone(), 1), (b.clone(), 1), (c.clone(), 1)]);
let DispatchAction::Start(first) = decide(None, &pending, None) else {
panic!("expected Start");
};
assert!(pending.contains_key(&first));
pending.remove(&first);
assert_eq!(
decide(Some((&first, 1)), &pending, None),
DispatchAction::Wait
);
let mut started = vec![first];
while !pending.is_empty() {
let DispatchAction::Start(next) = decide(None, &pending, None) else {
panic!("expected Start");
};
pending.remove(&next);
started.push(next);
}
started.sort_by_key(|u| u.as_str().to_string());
assert_eq!(started, {
let mut all = vec![a, b, c];
all.sort_by_key(|u| u.as_str().to_string());
all
});
}
}