use std::error::Error;
use std::path::PathBuf;
use crossbeam_channel::select;
use lsp_server::{Connection, Message};
use lsp_types::{
CallHierarchyServerCapability, ClientCapabilities, CodeActionKind, CodeActionOptions,
CodeActionProviderCapability, CompletionOptions, DiagnosticOptions,
DiagnosticServerCapabilities, DocumentLinkOptions, FoldingRangeProviderCapability,
HoverProviderCapability, InitializeParams, OneOf, PositionEncodingKind, RenameOptions,
SelectionRangeProviderCapability, SemanticTokensFullOptions, SemanticTokensOptions,
ServerCapabilities, SignatureHelpOptions, TextDocumentSyncCapability, TextDocumentSyncKind,
TextDocumentSyncOptions, TextDocumentSyncSaveOptions, WorkspaceFoldersServerCapabilities,
WorkspaceServerCapabilities,
};
use std::sync::Arc;
use crate::environment::EnvContext;
use crate::incremental::normalize_path;
use crate::index::{PackageIndex, dev_packages, harvest_libraries, harvest_workspace};
use crate::text::PositionEncoding;
use super::analysis_thread::{AnalysisRequest, LibraryMessage, spawn_analysis_thread};
use super::read_jobs::ReadJob;
use super::semantic_tokens::legend;
use super::state::{GlobalState, Outbound};
use super::task_pool::{TaskPool, read_pool_size};
use super::uri::to_path;
pub(crate) type DynError = Box<dyn Error + Sync + Send>;
pub fn run() -> Result<(), DynError> {
let (connection, io_threads) = Connection::stdio();
serve(&connection)?;
io_threads.join()?;
Ok(())
}
pub fn serve(connection: &Connection) -> Result<(), DynError> {
let (id, params) = connection.initialize_start()?;
let params: InitializeParams = serde_json::from_value(params)?;
let encoding = negotiate_position_encoding(¶ms.capabilities);
let workspace_roots = workspace_roots(¶ms);
let register_watchers =
supports_watched_files_registration(¶ms.capabilities) && !workspace_roots.is_empty();
let pull_diagnostics = supports_pull_diagnostics(¶ms.capabilities);
let diagnostic_refresh = supports_diagnostic_refresh(¶ms.capabilities);
let result =
serde_json::json!({ "capabilities": capabilities_json(encoding, pull_diagnostics) });
connection.initialize_finish(id, result)?;
main_loop(
connection,
encoding,
workspace_roots,
register_watchers,
pull_diagnostics,
diagnostic_refresh,
)
}
fn supports_pull_diagnostics(capabilities: &ClientCapabilities) -> bool {
capabilities
.text_document
.as_ref()
.is_some_and(|text_document| text_document.diagnostic.is_some())
}
fn supports_diagnostic_refresh(capabilities: &ClientCapabilities) -> bool {
capabilities
.workspace
.as_ref()
.and_then(|workspace| workspace.diagnostic.as_ref())
.and_then(|diagnostic| diagnostic.refresh_support)
.unwrap_or(false)
}
fn supports_watched_files_registration(capabilities: &ClientCapabilities) -> bool {
capabilities
.workspace
.as_ref()
.and_then(|workspace| workspace.did_change_watched_files.as_ref())
.and_then(|caps| caps.dynamic_registration)
.unwrap_or(false)
}
fn workspace_roots(params: &InitializeParams) -> Vec<PathBuf> {
let folder_uris: Vec<&lsp_types::Uri> = match params.workspace_folders.as_deref() {
Some(folders) if !folders.is_empty() => folders.iter().map(|f| &f.uri).collect(),
#[allow(deprecated)]
_ => params.root_uri.iter().collect(),
};
let mut seen = std::collections::HashSet::new();
folder_uris
.into_iter()
.filter_map(to_path)
.filter(|path| seen.insert(normalize_path(path)))
.collect()
}
fn negotiate_position_encoding(capabilities: &ClientCapabilities) -> PositionEncoding {
let offered = capabilities
.general
.as_ref()
.and_then(|general| general.position_encodings.as_deref())
.unwrap_or_default();
if offered.contains(&PositionEncodingKind::UTF8) {
PositionEncoding::Utf8
} else {
PositionEncoding::Utf16
}
}
fn server_capabilities(encoding: PositionEncoding, pull_diagnostics: bool) -> ServerCapabilities {
ServerCapabilities {
diagnostic_provider: pull_diagnostics.then(|| {
DiagnosticServerCapabilities::Options(DiagnosticOptions {
identifier: Some("fatou".to_string()),
inter_file_dependencies: true,
workspace_diagnostics: false,
work_done_progress_options: Default::default(),
})
}),
position_encoding: Some(match encoding {
PositionEncoding::Utf8 => PositionEncodingKind::UTF8,
PositionEncoding::Utf16 => PositionEncodingKind::UTF16,
}),
text_document_sync: Some(TextDocumentSyncCapability::Options(
TextDocumentSyncOptions {
open_close: Some(true),
change: Some(TextDocumentSyncKind::INCREMENTAL),
save: Some(TextDocumentSyncSaveOptions::Supported(true)),
..Default::default()
},
)),
code_action_provider: Some(CodeActionProviderCapability::Options(CodeActionOptions {
code_action_kinds: Some(vec![CodeActionKind::QUICKFIX]),
work_done_progress_options: Default::default(),
resolve_provider: None,
})),
document_formatting_provider: Some(OneOf::Left(true)),
document_range_formatting_provider: Some(OneOf::Left(true)),
document_symbol_provider: Some(OneOf::Left(true)),
workspace_symbol_provider: Some(OneOf::Left(true)),
completion_provider: Some(CompletionOptions {
trigger_characters: Some(vec![".".to_string(), "@".to_string()]),
resolve_provider: Some(true),
..Default::default()
}),
hover_provider: Some(HoverProviderCapability::Simple(true)),
definition_provider: Some(OneOf::Left(true)),
references_provider: Some(OneOf::Left(true)),
document_highlight_provider: Some(OneOf::Left(true)),
rename_provider: Some(OneOf::Right(RenameOptions {
prepare_provider: Some(true),
work_done_progress_options: Default::default(),
})),
call_hierarchy_provider: Some(CallHierarchyServerCapability::Simple(true)),
signature_help_provider: Some(SignatureHelpOptions {
trigger_characters: Some(vec!["(".to_string(), ",".to_string()]),
retrigger_characters: Some(vec![",".to_string()]),
work_done_progress_options: Default::default(),
}),
folding_range_provider: Some(FoldingRangeProviderCapability::Simple(true)),
document_link_provider: Some(DocumentLinkOptions {
resolve_provider: Some(false),
work_done_progress_options: Default::default(),
}),
selection_range_provider: Some(SelectionRangeProviderCapability::Simple(true)),
semantic_tokens_provider: Some(
SemanticTokensOptions {
work_done_progress_options: Default::default(),
legend: legend(),
range: None,
full: Some(SemanticTokensFullOptions::Bool(true)),
}
.into(),
),
workspace: Some(WorkspaceServerCapabilities {
workspace_folders: Some(WorkspaceFoldersServerCapabilities {
supported: Some(true),
change_notifications: None,
}),
file_operations: None,
}),
..Default::default()
}
}
fn capabilities_json(encoding: PositionEncoding, pull_diagnostics: bool) -> serde_json::Value {
let mut capabilities = serde_json::to_value(server_capabilities(encoding, pull_diagnostics))
.expect("server capabilities serialize");
capabilities["typeHierarchyProvider"] = serde_json::Value::Bool(true);
capabilities
}
fn main_loop(
connection: &Connection,
encoding: PositionEncoding,
workspace_roots: Vec<PathBuf>,
register_watchers: bool,
pull_diagnostics: bool,
diagnostic_refresh: bool,
) -> Result<(), DynError> {
let (out_tx, out_rx) = crossbeam_channel::unbounded::<Outbound>();
let (analysis_tx, analysis_rx) = crossbeam_channel::unbounded::<AnalysisRequest>();
let (read_tx, read_rx) = crossbeam_channel::unbounded::<ReadJob>();
let (library_tx, library_rx) = crossbeam_channel::unbounded::<LibraryMessage>();
let (harvest_tx, harvest_rx) = crossbeam_channel::unbounded::<HarvestSignal>();
let (sync_tx, sync_rx) = crossbeam_channel::unbounded::<PathBuf>();
spawn_workspace_harvester(workspace_roots, library_tx, harvest_rx);
let read_pool = TaskPool::new("fatou-lsp-read", read_pool_size());
let analysis_handle = spawn_analysis_thread(
analysis_rx,
read_rx,
library_rx,
sync_rx,
out_tx,
read_pool.spawner(),
encoding,
!pull_diagnostics,
);
let mut state = GlobalState::new(
connection.sender.clone(),
analysis_tx,
read_tx,
harvest_tx,
sync_tx,
encoding,
pull_diagnostics,
diagnostic_refresh,
);
if register_watchers {
state.register_file_watchers();
}
loop {
select! {
recv(connection.receiver) -> msg => {
let Ok(msg) = msg else { break };
match msg {
Message::Request(req) => {
if connection.handle_shutdown(&req)? {
break;
}
state.on_request(req);
}
Message::Notification(note) => state.on_notification(note),
Message::Response(_) => {}
}
}
recv(out_rx) -> outbound => {
let Ok(outbound) = outbound else { break };
state.on_outbound(outbound);
}
}
}
drop(state);
let _ = analysis_handle.join();
Ok(())
}
pub(crate) enum HarvestSignal {
Source(PathBuf),
Environment,
}
fn spawn_workspace_harvester(
workspace_roots: Vec<PathBuf>,
library_tx: crossbeam_channel::Sender<LibraryMessage>,
signal_rx: crossbeam_channel::Receiver<HarvestSignal>,
) {
if workspace_roots.is_empty() {
return;
}
let spawned = std::thread::Builder::new()
.name("fatou-index-loader".to_string())
.spawn(move || {
'resolve: loop {
let mut envs = Vec::new();
let mut projects = std::collections::HashSet::new();
for root in &workspace_roots {
let ctx = EnvContext::from_process(root.clone());
let Ok(Some(env)) = crate::environment::resolve(&ctx) else {
continue;
};
if projects.insert(normalize_path(&env.project_file)) {
envs.push(env);
}
}
let devs = dev_packages(&envs);
if library_tx
.send(LibraryMessage::Full(harvest_libraries(&envs)))
.is_err()
{
return; }
let prefixes: Vec<(crate::environment::DevPackage, PathBuf)> = devs
.into_iter()
.map(|dev| {
let src = normalize_path(&dev.root.join("src"));
(dev, src)
})
.collect();
let mut last: std::collections::HashMap<String, Arc<PackageIndex>> =
std::collections::HashMap::new();
while let Ok(signal) = signal_rx.recv() {
let changed = match signal {
HarvestSignal::Environment => {
while signal_rx.try_recv().is_ok() {}
continue 'resolve;
}
HarvestSignal::Source(path) => normalize_path(&path),
};
let Some((dev, _)) = prefixes
.iter()
.filter(|(_, src)| changed.starts_with(src))
.max_by_key(|(_, src)| src.components().count())
else {
continue;
};
let index = Arc::new(harvest_workspace(dev));
if last.get(&dev.name) == Some(&index) {
continue;
}
last.insert(dev.name.clone(), Arc::clone(&index));
if library_tx
.send(LibraryMessage::Package {
name: dev.name.clone(),
index,
})
.is_err()
{
return; }
}
return; }
});
debug_assert!(spawned.is_ok(), "spawn index loader thread");
drop(spawned);
}
#[cfg(test)]
mod tests {
use lsp_types::GeneralClientCapabilities;
use super::*;
fn caps_offering(encodings: Option<Vec<PositionEncodingKind>>) -> ClientCapabilities {
ClientCapabilities {
general: Some(GeneralClientCapabilities {
position_encodings: encodings,
..Default::default()
}),
..Default::default()
}
}
#[test]
fn negotiation_defaults_to_utf16() {
let none = ClientCapabilities::default();
assert_eq!(negotiate_position_encoding(&none), PositionEncoding::Utf16);
assert_eq!(
negotiate_position_encoding(&caps_offering(None)),
PositionEncoding::Utf16
);
assert_eq!(
negotiate_position_encoding(&caps_offering(Some(vec![
PositionEncodingKind::UTF16,
PositionEncodingKind::UTF32,
]))),
PositionEncoding::Utf16
);
}
#[test]
fn negotiation_prefers_offered_utf8() {
assert_eq!(
negotiate_position_encoding(&caps_offering(Some(vec![
PositionEncodingKind::UTF16,
PositionEncodingKind::UTF8,
]))),
PositionEncoding::Utf8
);
}
fn folder(uri: &str) -> lsp_types::WorkspaceFolder {
lsp_types::WorkspaceFolder {
uri: uri.parse().unwrap(),
name: String::new(),
}
}
fn path_of(uri: &str) -> PathBuf {
to_path(&uri.parse().unwrap()).unwrap()
}
#[test]
fn workspace_roots_takes_every_folder_in_client_order() {
let params = InitializeParams {
workspace_folders: Some(vec![folder("file:///work/b"), folder("file:///work/a")]),
..Default::default()
};
assert_eq!(
workspace_roots(¶ms),
vec![path_of("file:///work/b"), path_of("file:///work/a")]
);
}
#[test]
fn workspace_roots_dedups_equivalent_folders() {
let params = InitializeParams {
workspace_folders: Some(vec![
folder("file:///work/a"),
folder("file:///work/./a"),
folder("file:///work/b"),
]),
..Default::default()
};
assert_eq!(
workspace_roots(¶ms),
vec![path_of("file:///work/a"), path_of("file:///work/b")]
);
}
#[test]
fn workspace_roots_falls_back_to_root_uri() {
#[allow(deprecated)]
let params = InitializeParams {
root_uri: Some("file:///work/a".parse().unwrap()),
..Default::default()
};
assert_eq!(workspace_roots(¶ms), vec![path_of("file:///work/a")]);
#[allow(deprecated)]
let both = InitializeParams {
workspace_folders: Some(vec![folder("file:///work/b")]),
root_uri: Some("file:///work/a".parse().unwrap()),
..Default::default()
};
assert_eq!(workspace_roots(&both), vec![path_of("file:///work/b")]);
#[allow(deprecated)]
let empty_folders = InitializeParams {
workspace_folders: Some(Vec::new()),
root_uri: Some("file:///work/a".parse().unwrap()),
..Default::default()
};
assert_eq!(
workspace_roots(&empty_folders),
vec![path_of("file:///work/a")]
);
}
#[test]
fn no_folders_yields_no_roots() {
assert!(workspace_roots(&InitializeParams::default()).is_empty());
}
#[test]
fn pull_diagnostics_require_the_client_capability() {
assert!(!supports_pull_diagnostics(&ClientCapabilities::default()));
let caps = ClientCapabilities {
text_document: Some(lsp_types::TextDocumentClientCapabilities {
diagnostic: Some(lsp_types::DiagnosticClientCapabilities::default()),
..Default::default()
}),
..Default::default()
};
assert!(supports_pull_diagnostics(&caps));
assert!(
server_capabilities(PositionEncoding::Utf16, true)
.diagnostic_provider
.is_some()
);
assert!(
server_capabilities(PositionEncoding::Utf16, false)
.diagnostic_provider
.is_none()
);
}
#[test]
fn type_hierarchy_capability_is_injected_into_the_json() {
let capabilities = capabilities_json(PositionEncoding::Utf16, false);
assert_eq!(
capabilities["typeHierarchyProvider"],
serde_json::json!(true)
);
assert_eq!(
capabilities["callHierarchyProvider"],
serde_json::json!(true)
);
}
#[test]
fn diagnostic_refresh_requires_the_client_capability() {
assert!(!supports_diagnostic_refresh(&ClientCapabilities::default()));
let caps = ClientCapabilities {
workspace: Some(lsp_types::WorkspaceClientCapabilities {
diagnostic: Some(lsp_types::DiagnosticWorkspaceClientCapabilities {
refresh_support: Some(true),
}),
..Default::default()
}),
..Default::default()
};
assert!(supports_diagnostic_refresh(&caps));
}
#[test]
fn watcher_registration_requires_the_client_capability() {
assert!(!supports_watched_files_registration(
&ClientCapabilities::default()
));
let caps = ClientCapabilities {
workspace: Some(lsp_types::WorkspaceClientCapabilities {
did_change_watched_files: Some(
lsp_types::DidChangeWatchedFilesClientCapabilities {
dynamic_registration: Some(true),
relative_pattern_support: None,
},
),
..Default::default()
}),
..Default::default()
};
assert!(supports_watched_files_registration(&caps));
}
}