[ty] Request configuration from client (#18984)
Some checks are pending
CI / cargo test (linux, release) (push) Blocked by required conditions
CI / cargo test (windows) (push) Blocked by required conditions
CI / cargo test (wasm) (push) Blocked by required conditions
CI / cargo build (release) (push) Waiting to run
CI / cargo build (msrv) (push) Blocked by required conditions
CI / cargo fuzz build (push) Blocked by required conditions
CI / python package (push) Waiting to run
CI / Determine changes (push) Waiting to run
CI / cargo fmt (push) Waiting to run
CI / cargo clippy (push) Blocked by required conditions
CI / cargo test (linux) (push) Blocked by required conditions
CI / fuzz parser (push) Blocked by required conditions
CI / test scripts (push) Blocked by required conditions
CI / ecosystem (push) Blocked by required conditions
CI / Fuzz for new ty panics (push) Blocked by required conditions
CI / cargo shear (push) Blocked by required conditions
CI / pre-commit (push) Waiting to run
CI / mkdocs (push) Waiting to run
CI / formatter instabilities and black similarity (push) Blocked by required conditions
CI / test ruff-lsp (push) Blocked by required conditions
CI / check playground (push) Blocked by required conditions
CI / benchmarks-instrumented (push) Blocked by required conditions
CI / benchmarks-walltime (push) Blocked by required conditions
[ty Playground] Release / publish (push) Waiting to run

## Summary

This PR makes the necessary changes to the server that it can request
configurations from the client using the `configuration` request.
This PR doesn't make use of the request yet. It only sets up the
foundation (mainly the coordination between client and server)
so that future PRs could pull specific settings. 

I plan to use this for pulling the Python environment from the Python
extension.

Deno does something very similar to this.

## Test Plan

Tested that diagnostics are still shown.
This commit is contained in:
Micha Reiser 2025-07-02 11:01:41 +02:00 committed by GitHub
parent cdf91b8b74
commit f7fc8fb084
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 450 additions and 187 deletions

View file

@ -263,12 +263,23 @@ impl Files {
impl fmt::Debug for Files { impl fmt::Debug for Files {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if f.alternate() {
let mut map = f.debug_map(); let mut map = f.debug_map();
for entry in self.inner.system_by_path.iter() { for entry in self.inner.system_by_path.iter() {
map.entry(entry.key(), entry.value()); map.entry(entry.key(), entry.value());
} }
map.finish() map.finish()
} else {
f.debug_struct("Files")
.field("system_by_path", &self.inner.system_by_path.len())
.field(
"system_virtual_by_path",
&self.inner.system_virtual_by_path.len(),
)
.field("vendored_by_path", &self.inner.vendored_by_path.len())
.finish()
}
} }
} }

View file

@ -1,3 +1,4 @@
use std::fmt::Formatter;
use std::panic::{AssertUnwindSafe, RefUnwindSafe}; use std::panic::{AssertUnwindSafe, RefUnwindSafe};
use std::sync::Arc; use std::sync::Arc;
use std::{cmp, fmt}; use std::{cmp, fmt};
@ -146,6 +147,16 @@ impl ProjectDatabase {
} }
} }
impl std::fmt::Debug for ProjectDatabase {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
f.debug_struct("ProjectDatabase")
.field("project", &self.project)
.field("files", &self.files)
.field("system", &self.system)
.finish_non_exhaustive()
}
}
/// Stores memory usage information. /// Stores memory usage information.
pub struct SalsaMemoryDump { pub struct SalsaMemoryDump {
total_fields: usize, total_fields: usize,

View file

@ -55,6 +55,7 @@ pub fn default_lints_registry() -> LintRegistry {
/// it remains the same project. That's why program is a narrowed view of the project only /// it remains the same project. That's why program is a narrowed view of the project only
/// holding on to the most fundamental settings required for checking. /// holding on to the most fundamental settings required for checking.
#[salsa::input] #[salsa::input]
#[derive(Debug)]
pub struct Project { pub struct Project {
/// The files that are open in the project. /// The files that are open in the project.
/// ///

View file

@ -136,7 +136,7 @@ impl Server {
&client_capabilities, &client_capabilities,
position_encoding, position_encoding,
global_options, global_options,
&workspaces, workspaces,
)?, )?,
client_capabilities, client_capabilities,
}) })
@ -227,12 +227,10 @@ impl ServerPanicHookHandler {
writeln!(stderr, "{panic_info}\n{backtrace}").ok(); writeln!(stderr, "{panic_info}\n{backtrace}").ok();
if let Some(client) = hook_client.upgrade() { if let Some(client) = hook_client.upgrade() {
client client.show_message(
.show_message(
"The ty language server exited with a panic. See the logs for more details.", "The ty language server exited with a panic. See the logs for more details.",
MessageType::ERROR, MessageType::ERROR,
) );
.ok();
} }
})); }));

View file

@ -160,7 +160,7 @@ where
}; };
let db = match &path { let db = match &path {
AnySystemPath::System(path) => match session.project_db_for_path(path.as_std_path()) { AnySystemPath::System(path) => match session.project_db_for_path(path) {
Some(db) => db.clone(), Some(db) => db.clone(),
None => session.default_project_db().clone(), None => session.default_project_db().clone(),
}, },
@ -224,17 +224,14 @@ where
request.id, request.id,
request.method request.method
); );
if client.retry(request).is_ok() { client.retry(request);
return None; } else {
}
}
tracing::trace!( tracing::trace!(
"request id={} was cancelled by salsa, sending content modified", "request id={} was cancelled by salsa, sending content modified",
id id
); );
respond_silent_error(id.clone(), client, R::salsa_cancellation_error()); respond_silent_error(id.clone(), client, R::salsa_cancellation_error());
}
None None
} else { } else {
Some(Err(Error { Some(Err(Error {
@ -343,17 +340,13 @@ fn respond<Req>(
tracing::error!("An error occurred with request ID {id}: {err}"); tracing::error!("An error occurred with request ID {id}: {err}");
client.show_error_message("ty encountered a problem. Check the logs for more details."); client.show_error_message("ty encountered a problem. Check the logs for more details.");
} }
if let Err(err) = client.respond(id, result) { client.respond(id, result);
tracing::error!("Failed to send response: {err}");
}
} }
/// Sends back an error response to the server using a [`Client`] without showing a warning /// Sends back an error response to the server using a [`Client`] without showing a warning
/// to the user. /// to the user.
fn respond_silent_error(id: RequestId, client: &Client, error: lsp_server::ResponseError) { fn respond_silent_error(id: RequestId, client: &Client, error: lsp_server::ResponseError) {
if let Err(err) = client.respond_err(id, error) { client.respond_err(id, error);
tracing::error!("Failed to send response: {err}");
}
} }
/// Tries to cast a serialized request from the server into /// Tries to cast a serialized request from the server into

View file

@ -1,4 +1,3 @@
use lsp_server::ErrorCode;
use lsp_types::notification::PublishDiagnostics; use lsp_types::notification::PublishDiagnostics;
use lsp_types::{ use lsp_types::{
CodeDescription, Diagnostic, DiagnosticRelatedInformation, DiagnosticSeverity, DiagnosticTag, CodeDescription, Diagnostic, DiagnosticRelatedInformation, DiagnosticSeverity, DiagnosticTag,
@ -46,20 +45,17 @@ impl Diagnostics {
/// This is done by notifying the client with an empty list of diagnostics for the document. /// This is done by notifying the client with an empty list of diagnostics for the document.
/// For notebook cells, this clears diagnostics for the specific cell. /// For notebook cells, this clears diagnostics for the specific cell.
/// For other document types, this clears diagnostics for the main document. /// For other document types, this clears diagnostics for the main document.
pub(super) fn clear_diagnostics(key: &DocumentKey, client: &Client) -> Result<()> { pub(super) fn clear_diagnostics(key: &DocumentKey, client: &Client) {
let Some(uri) = key.to_url() else { let Some(uri) = key.to_url() else {
// If we can't convert to URL, we can't clear diagnostics // If we can't convert to URL, we can't clear diagnostics
return Ok(()); return;
}; };
client client.send_notification::<PublishDiagnostics>(PublishDiagnosticsParams {
.send_notification::<PublishDiagnostics>(PublishDiagnosticsParams {
uri, uri,
diagnostics: vec![], diagnostics: vec![],
version: None, version: None,
}) });
.with_failure_code(ErrorCode::InternalError)?;
Ok(())
} }
/// Publishes the diagnostics for the given document snapshot using the [publish diagnostics /// Publishes the diagnostics for the given document snapshot using the [publish diagnostics
@ -96,22 +92,20 @@ pub(super) fn publish_diagnostics(
// Sends a notification to the client with the diagnostics for the document. // Sends a notification to the client with the diagnostics for the document.
let publish_diagnostics_notification = |uri: Url, diagnostics: Vec<Diagnostic>| { let publish_diagnostics_notification = |uri: Url, diagnostics: Vec<Diagnostic>| {
client client.send_notification::<PublishDiagnostics>(PublishDiagnosticsParams {
.send_notification::<PublishDiagnostics>(PublishDiagnosticsParams {
uri, uri,
diagnostics, diagnostics,
version: Some(snapshot.query().version()), version: Some(snapshot.query().version()),
}) });
.with_failure_code(lsp_server::ErrorCode::InternalError)
}; };
match diagnostics { match diagnostics {
Diagnostics::TextDocument(diagnostics) => { Diagnostics::TextDocument(diagnostics) => {
publish_diagnostics_notification(url, diagnostics)?; publish_diagnostics_notification(url, diagnostics);
} }
Diagnostics::NotebookDocument(cell_diagnostics) => { Diagnostics::NotebookDocument(cell_diagnostics) => {
for (cell_url, diagnostics) in cell_diagnostics { for (cell_url, diagnostics) in cell_diagnostics {
publish_diagnostics_notification(cell_url, diagnostics)?; publish_diagnostics_notification(cell_url, diagnostics);
} }
} }
} }

View file

@ -20,7 +20,7 @@ impl SyncNotificationHandler for CancelNotificationHandler {
lsp_types::NumberOrString::String(id) => id.into(), lsp_types::NumberOrString::String(id) => id.into(),
}; };
let _ = client.cancel(session, id); client.cancel(session, id);
Ok(()) Ok(())
} }

View file

@ -39,7 +39,7 @@ impl SyncNotificationHandler for DidChangeTextDocumentHandler {
match key.path() { match key.path() {
AnySystemPath::System(path) => { AnySystemPath::System(path) => {
let db = match session.project_db_for_path_mut(path.as_std_path()) { let db = match session.project_db_for_path_mut(path) {
Some(db) => db, Some(db) => db,
None => session.default_project_db_mut(), None => session.default_project_db_mut(),
}; };

View file

@ -1,5 +1,4 @@
use crate::server::Result; use crate::server::Result;
use crate::server::api::LSPResult;
use crate::server::api::diagnostics::publish_diagnostics; use crate::server::api::diagnostics::publish_diagnostics;
use crate::server::api::traits::{NotificationHandler, SyncNotificationHandler}; use crate::server::api::traits::{NotificationHandler, SyncNotificationHandler};
use crate::session::Session; use crate::session::Session;
@ -45,7 +44,7 @@ impl SyncNotificationHandler for DidChangeWatchedFiles {
} }
}; };
let Some(db) = session.project_db_for_path(system_path.as_std_path()) else { let Some(db) = session.project_db_for_path(&system_path) else {
tracing::trace!( tracing::trace!(
"Ignoring change event for `{system_path}` because it's not in any workspace" "Ignoring change event for `{system_path}` because it's not in any workspace"
); );
@ -103,13 +102,11 @@ impl SyncNotificationHandler for DidChangeWatchedFiles {
if project_changed { if project_changed {
if client_capabilities.diagnostics_refresh { if client_capabilities.diagnostics_refresh {
client client.send_request::<types::request::WorkspaceDiagnosticRefresh>(
.send_request::<types::request::WorkspaceDiagnosticRefresh>(
session, session,
(), (),
|_, ()| {}, |_, ()| {},
) );
.with_failure_code(lsp_server::ErrorCode::InternalError)?;
} else { } else {
for key in session.text_document_keys() { for key in session.text_document_keys() {
publish_diagnostics(session, &key, client)?; publish_diagnostics(session, &key, client)?;
@ -120,9 +117,7 @@ impl SyncNotificationHandler for DidChangeWatchedFiles {
} }
if client_capabilities.inlay_refresh { if client_capabilities.inlay_refresh {
client client.send_request::<types::request::InlayHintRefreshRequest>(session, (), |_, ()| {});
.send_request::<types::request::InlayHintRefreshRequest>(session, (), |_, ()| {})
.with_failure_code(lsp_server::ErrorCode::InternalError)?;
} }
Ok(()) Ok(())

View file

@ -41,6 +41,8 @@ impl SyncNotificationHandler for DidCloseTextDocumentHandler {
); );
} }
clear_diagnostics(&key, client) clear_diagnostics(&key, client);
Ok(())
} }
} }

View file

@ -41,7 +41,7 @@ impl SyncNotificationHandler for DidOpenTextDocumentHandler {
match key.path() { match key.path() {
AnySystemPath::System(system_path) => { AnySystemPath::System(system_path) => {
let db = match session.project_db_for_path_mut(system_path.as_std_path()) { let db = match session.project_db_for_path_mut(system_path) {
Some(db) => db, Some(db) => db,
None => session.default_project_db_mut(), None => session.default_project_db_mut(),
}; };

View file

@ -40,7 +40,7 @@ impl SyncNotificationHandler for DidOpenNotebookHandler {
match &path { match &path {
AnySystemPath::System(system_path) => { AnySystemPath::System(system_path) => {
let db = match session.project_db_for_path_mut(system_path.as_std_path()) { let db = match session.project_db_for_path_mut(system_path) {
Some(db) => db, Some(db) => db,
None => session.default_project_db_mut(), None => session.default_project_db_mut(),
}; };

View file

@ -1,11 +1,15 @@
use crate::server::schedule::Scheduler; use crate::server::schedule::Scheduler;
use crate::server::{Server, api}; use crate::server::{Server, api};
use crate::session::ClientOptions;
use crate::session::client::Client; use crate::session::client::Client;
use anyhow::anyhow; use anyhow::anyhow;
use crossbeam::select; use crossbeam::select;
use lsp_server::Message; use lsp_server::Message;
use lsp_types::notification::Notification; use lsp_types::notification::Notification;
use lsp_types::{DidChangeWatchedFilesRegistrationOptions, FileSystemWatcher}; use lsp_types::{
ConfigurationParams, DidChangeWatchedFilesRegistrationOptions, FileSystemWatcher, Url,
};
use serde_json::Value;
pub(crate) type MainLoopSender = crossbeam::channel::Sender<Event>; pub(crate) type MainLoopSender = crossbeam::channel::Sender<Event>;
pub(crate) type MainLoopReceiver = crossbeam::channel::Receiver<Event>; pub(crate) type MainLoopReceiver = crossbeam::channel::Receiver<Event>;
@ -26,6 +30,10 @@ impl Server {
match next_event { match next_event {
Event::Message(msg) => { Event::Message(msg) => {
let Some(msg) = self.session.should_defer_message(msg) else {
continue;
};
let client = Client::new( let client = Client::new(
self.main_loop_sender.clone(), self.main_loop_sender.clone(),
self.connection.sender.clone(), self.connection.sender.clone(),
@ -49,7 +57,7 @@ impl Server {
message: "Shutdown already requested".to_owned(), message: "Shutdown already requested".to_owned(),
data: None, data: None,
}, },
)?; );
continue; continue;
} }
@ -130,6 +138,9 @@ impl Server {
); );
} }
} }
Action::InitializeWorkspaces(workspaces_with_options) => {
self.session.initialize_workspaces(workspaces_with_options);
}
}, },
} }
} }
@ -140,7 +151,25 @@ impl Server {
/// Waits for the next message from the client or action. /// Waits for the next message from the client or action.
/// ///
/// Returns `Ok(None)` if the client connection is closed. /// Returns `Ok(None)` if the client connection is closed.
fn next_event(&self) -> Result<Option<Event>, crossbeam::channel::RecvError> { fn next_event(&mut self) -> Result<Option<Event>, crossbeam::channel::RecvError> {
// We can't queue those into the main loop because that could result in reordering if
// the `select` below picks a client message first.
if let Some(deferred) = self.session.take_deferred_messages() {
match &deferred {
Message::Request(req) => {
tracing::debug!("Processing deferred request `{}`", req.method);
}
Message::Notification(notification) => {
tracing::debug!("Processing deferred notification `{}`", notification.method);
}
Message::Response(response) => {
tracing::debug!("Processing deferred response `{}`", response.id);
}
}
return Ok(Some(Event::Message(deferred)));
}
select!( select!(
recv(self.connection.receiver) -> msg => { recv(self.connection.receiver) -> msg => {
// Ignore disconnect errors, they're handled by the main loop (it will exit). // Ignore disconnect errors, they're handled by the main loop (it will exit).
@ -151,6 +180,47 @@ impl Server {
} }
fn initialize(&mut self, client: &Client) { fn initialize(&mut self, client: &Client) {
let urls = self
.session
.workspaces()
.urls()
.cloned()
.collect::<Vec<_>>();
let items = urls
.iter()
.map(|root| lsp_types::ConfigurationItem {
scope_uri: Some(root.clone()),
section: Some("ty".to_string()),
})
.collect();
tracing::debug!("Requesting workspace configuration for workspaces");
client
.send_request::<lsp_types::request::WorkspaceConfiguration>(
&self.session,
ConfigurationParams { items },
|client, result: Vec<Value>| {
tracing::debug!("Received workspace configurations, initializing workspaces");
assert_eq!(result.len(), urls.len());
let workspaces_with_options: Vec<_> = urls
.into_iter()
.zip(result)
.map(|(url, value)| {
let options: ClientOptions = serde_json::from_value(value).unwrap_or_else(|err| {
tracing::warn!("Failed to deserialize workspace options for {url}: {err}. Using default options.");
ClientOptions::default()
});
(url, options)
})
.collect();
client.queue_action(Action::InitializeWorkspaces(workspaces_with_options));
},
);
let fs_watcher = self let fs_watcher = self
.client_capabilities .client_capabilities
.workspace .workspace
@ -206,17 +276,13 @@ impl Server {
tracing::info!("File watcher successfully registered"); tracing::info!("File watcher successfully registered");
}; };
if let Err(err) = client.send_request::<lsp_types::request::RegisterCapability>( client.send_request::<lsp_types::request::RegisterCapability>(
&self.session, &self.session,
lsp_types::RegistrationParams { lsp_types::RegistrationParams {
registrations: vec![registration], registrations: vec![registration],
}, },
response_handler, response_handler,
) {
tracing::error!(
"An error occurred when trying to register the configuration file watcher: {err}"
); );
}
} else { } else {
tracing::warn!("The client does not support file system watching."); tracing::warn!("The client does not support file system watching.");
} }
@ -231,6 +297,10 @@ pub(crate) enum Action {
/// Retry a request that previously failed due to a salsa cancellation. /// Retry a request that previously failed due to a salsa cancellation.
RetryRequest(lsp_server::Request), RetryRequest(lsp_server::Request),
/// Initialize the workspace after the server received
/// the options from the client.
InitializeWorkspaces(Vec<(Url, ClientOptions)>),
} }
#[derive(Debug)] #[derive(Debug)]

View file

@ -83,9 +83,7 @@ impl Task {
R: Serialize + Send + 'static, R: Serialize + Send + 'static,
{ {
Self::sync(move |_, client| { Self::sync(move |_, client| {
if let Err(err) = client.respond(&id, result) { client.respond(&id, result);
tracing::error!("Unable to send immediate response: {err}");
}
}) })
} }

View file

@ -1,22 +1,24 @@
//! Data model, state management, and configuration resolution. //! Data model, state management, and configuration resolution.
use std::collections::BTreeMap; use std::collections::{BTreeMap, VecDeque};
use std::ops::{Deref, DerefMut}; use std::ops::{Deref, DerefMut};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
use anyhow::anyhow; use anyhow::{Context, anyhow};
use lsp_server::Message;
use lsp_types::{ClientCapabilities, TextDocumentContentChangeEvent, Url}; use lsp_types::{ClientCapabilities, TextDocumentContentChangeEvent, Url};
use options::GlobalOptions; use options::GlobalOptions;
use ruff_db::Db; use ruff_db::Db;
use ruff_db::files::{File, system_path_to_file}; use ruff_db::files::{File, system_path_to_file};
use ruff_db::system::SystemPath; use ruff_db::system::{System, SystemPath, SystemPathBuf};
use ty_project::metadata::Options;
use ty_project::{ProjectDatabase, ProjectMetadata}; use ty_project::{ProjectDatabase, ProjectMetadata};
pub(crate) use self::capabilities::ResolvedClientCapabilities; pub(crate) use self::capabilities::ResolvedClientCapabilities;
pub use self::index::DocumentQuery; pub use self::index::DocumentQuery;
pub(crate) use self::options::{AllOptions, ClientOptions}; pub(crate) use self::options::{AllOptions, ClientOptions};
use self::settings::ClientSettings; pub(crate) use self::settings::ClientSettings;
use crate::document::{DocumentKey, DocumentVersion, NotebookDocument}; use crate::document::{DocumentKey, DocumentVersion, NotebookDocument};
use crate::session::request_queue::RequestQueue; use crate::session::request_queue::RequestQueue;
use crate::system::{AnySystemPath, LSPSystem}; use crate::system::{AnySystemPath, LSPSystem};
@ -40,8 +42,13 @@ pub struct Session {
/// [`index_mut`]: Session::index_mut /// [`index_mut`]: Session::index_mut
index: Option<Arc<index::Index>>, index: Option<Arc<index::Index>>,
/// Maps workspace folders to their respective project databases. /// Maps workspace folders to their respective workspace.
projects_by_workspace_folder: BTreeMap<PathBuf, ProjectDatabase>, workspaces: Workspaces,
/// The projects across all workspaces.
projects: BTreeMap<SystemPathBuf, ProjectDatabase>,
default_project: ProjectDatabase,
/// The global position encoding, negotiated during LSP initialization. /// The global position encoding, negotiated during LSP initialization.
position_encoding: PositionEncoding, position_encoding: PositionEncoding,
@ -54,6 +61,8 @@ pub struct Session {
/// Has the client requested the server to shutdown. /// Has the client requested the server to shutdown.
shutdown_requested: bool, shutdown_requested: bool,
deferred_messages: VecDeque<Message>,
} }
impl Session { impl Session {
@ -61,32 +70,33 @@ impl Session {
client_capabilities: &ClientCapabilities, client_capabilities: &ClientCapabilities,
position_encoding: PositionEncoding, position_encoding: PositionEncoding,
global_options: GlobalOptions, global_options: GlobalOptions,
workspace_folders: &[(Url, ClientOptions)], workspace_folders: Vec<(Url, ClientOptions)>,
) -> crate::Result<Self> { ) -> crate::Result<Self> {
let mut workspaces = BTreeMap::new();
let index = Arc::new(index::Index::new(global_options.into_settings())); let index = Arc::new(index::Index::new(global_options.into_settings()));
// TODO: Consider workspace settings let mut workspaces = Workspaces::default();
for (url, _) in workspace_folders { for (url, options) in workspace_folders {
let path = url workspaces.register(url, options)?;
.to_file_path()
.map_err(|()| anyhow!("Workspace URL is not a file or directory: {:?}", url))?;
let system_path = SystemPath::from_std_path(&path)
.ok_or_else(|| anyhow!("Workspace path is not a valid UTF-8 path: {:?}", path))?;
let system = LSPSystem::new(index.clone());
// TODO(dhruvmanila): Get the values from the client settings
let mut metadata = ProjectMetadata::discover(system_path, &system)?;
metadata.apply_configuration_files(&system)?;
// TODO(micha): Handle the case where the program settings are incorrect more gracefully.
workspaces.insert(path, ProjectDatabase::new(metadata, system)?);
} }
let default_project = {
let system = LSPSystem::new(index.clone());
let metadata = ProjectMetadata::from_options(
Options::default(),
system.current_directory().to_path_buf(),
None,
)
.unwrap();
ProjectDatabase::new(metadata, system).unwrap()
};
Ok(Self { Ok(Self {
position_encoding, position_encoding,
projects_by_workspace_folder: workspaces, workspaces,
deferred_messages: VecDeque::new(),
index: Some(index), index: Some(index),
default_project,
projects: BTreeMap::new(),
resolved_client_capabilities: Arc::new(ResolvedClientCapabilities::new( resolved_client_capabilities: Arc::new(ResolvedClientCapabilities::new(
client_capabilities, client_capabilities,
)), )),
@ -111,6 +121,52 @@ impl Session {
self.shutdown_requested = requested; self.shutdown_requested = requested;
} }
/// The LSP specification doesn't allow configuration requests during initialization,
/// but we need access to the configuration to resolve the settings in turn to create the
/// project databases. This will become more important in the future when we support
/// persistent caching. It's then crucial that we have the correct settings to select the
/// right cache.
///
/// We work around this by queueing up all messages that arrive between the `initialized` notification
/// and the completion of workspace initialization (which waits for the client's configuration response).
///
/// This queuing is only necessary when registering *new* workspaces. Changes to configurations
/// don't need to go through the same process because we can update the existing
/// database in place.
///
/// See <https://github.com/Microsoft/language-server-protocol/issues/567#issuecomment-2085131917>
pub(crate) fn should_defer_message(&mut self, message: Message) -> Option<Message> {
if self.workspaces.all_initialized() {
Some(message)
} else {
match &message {
Message::Request(request) => {
tracing::debug!(
"Deferring `{}` request until all workspaces are initialized",
request.method
);
}
Message::Response(_) => {
// We still want to get client responses even during workspace initialization.
return Some(message);
}
Message::Notification(notification) => {
tracing::debug!(
"Deferring `{}` notification until all workspaces are initialized",
notification.method
);
}
}
self.deferred_messages.push_back(message);
None
}
}
pub(crate) fn workspaces(&self) -> &Workspaces {
&self.workspaces
}
// TODO(dhruvmanila): Ideally, we should have a single method for `workspace_db_for_path_mut` // TODO(dhruvmanila): Ideally, we should have a single method for `workspace_db_for_path_mut`
// and `default_workspace_db_mut` but the borrow checker doesn't allow that. // and `default_workspace_db_mut` but the borrow checker doesn't allow that.
// https://github.com/astral-sh/ruff/pull/13041#discussion_r1726725437 // https://github.com/astral-sh/ruff/pull/13041#discussion_r1726725437
@ -119,14 +175,17 @@ impl Session {
/// or the default project if no project is found for the path. /// or the default project if no project is found for the path.
pub(crate) fn project_db_or_default(&self, path: &AnySystemPath) -> &ProjectDatabase { pub(crate) fn project_db_or_default(&self, path: &AnySystemPath) -> &ProjectDatabase {
path.as_system() path.as_system()
.and_then(|path| self.project_db_for_path(path.as_std_path())) .and_then(|path| self.project_db_for_path(path))
.unwrap_or_else(|| self.default_project_db()) .unwrap_or_else(|| self.default_project_db())
} }
/// Returns a reference to the project's [`ProjectDatabase`] corresponding to the given path, if /// Returns a reference to the project's [`ProjectDatabase`] corresponding to the given path, if
/// any. /// any.
pub(crate) fn project_db_for_path(&self, path: impl AsRef<Path>) -> Option<&ProjectDatabase> { pub(crate) fn project_db_for_path(
self.projects_by_workspace_folder &self,
path: impl AsRef<SystemPath>,
) -> Option<&ProjectDatabase> {
self.projects
.range(..=path.as_ref().to_path_buf()) .range(..=path.as_ref().to_path_buf())
.next_back() .next_back()
.map(|(_, db)| db) .map(|(_, db)| db)
@ -136,9 +195,9 @@ impl Session {
/// path, if any. /// path, if any.
pub(crate) fn project_db_for_path_mut( pub(crate) fn project_db_for_path_mut(
&mut self, &mut self,
path: impl AsRef<Path>, path: impl AsRef<SystemPath>,
) -> Option<&mut ProjectDatabase> { ) -> Option<&mut ProjectDatabase> {
self.projects_by_workspace_folder self.projects
.range_mut(..=path.as_ref().to_path_buf()) .range_mut(..=path.as_ref().to_path_buf())
.next_back() .next_back()
.map(|(_, db)| db) .map(|(_, db)| db)
@ -147,23 +206,85 @@ impl Session {
/// Returns a reference to the default project [`ProjectDatabase`]. The default project is the /// Returns a reference to the default project [`ProjectDatabase`]. The default project is the
/// minimum root path in the project map. /// minimum root path in the project map.
pub(crate) fn default_project_db(&self) -> &ProjectDatabase { pub(crate) fn default_project_db(&self) -> &ProjectDatabase {
// SAFETY: Currently, ty only support a single project. &self.default_project
self.projects_by_workspace_folder.values().next().unwrap()
} }
/// Returns a mutable reference to the default project [`ProjectDatabase`]. /// Returns a mutable reference to the default project [`ProjectDatabase`].
pub(crate) fn default_project_db_mut(&mut self) -> &mut ProjectDatabase { pub(crate) fn default_project_db_mut(&mut self) -> &mut ProjectDatabase {
// SAFETY: Currently, ty only support a single project. &mut self.default_project
self.projects_by_workspace_folder }
fn projects_mut(&mut self) -> impl Iterator<Item = &'_ mut ProjectDatabase> + '_ {
self.projects
.values_mut() .values_mut()
.next() .chain(std::iter::once(&mut self.default_project))
.unwrap()
} }
pub(crate) fn key_from_url(&self, url: Url) -> crate::Result<DocumentKey> { pub(crate) fn key_from_url(&self, url: Url) -> crate::Result<DocumentKey> {
self.index().key_from_url(url) self.index().key_from_url(url)
} }
pub(crate) fn initialize_workspaces(&mut self, workspace_settings: Vec<(Url, ClientOptions)>) {
assert!(!self.workspaces.all_initialized());
for (url, options) in workspace_settings {
let Some(workspace) = self.workspaces.initialize(&url, options) else {
continue;
};
// For now, create one project database per workspace.
// In the future, index the workspace directories to find all projects
// and create a project database for each.
let system = LSPSystem::new(self.index.as_ref().unwrap().clone());
let Some(system_path) = SystemPath::from_std_path(workspace.root()) else {
tracing::warn!(
"Ignore workspace `{}` because it's root contains non UTF8 characters",
workspace.root().display()
);
continue;
};
let root = system_path.to_path_buf();
let project = ProjectMetadata::discover(&root, &system)
.context("Failed to find project configuration")
.and_then(|mut metadata| {
// TODO(dhruvmanila): Merge the client options with the project metadata options.
metadata
.apply_configuration_files(&system)
.context("Failed to apply configuration files")?;
ProjectDatabase::new(metadata, system)
.context("Failed to create project database")
});
// TODO(micha): Handle the case where the program settings are incorrect more gracefully.
// The easiest is to ignore those projects but to show a message to the user that we do so.
// Ignoring the projects has the effect that we'll use the default project for those files.
// The only challenge with this is that we need to register the project when the configuration
// becomes valid again. But that's a case we need to handle anyway for good mono repository support.
match project {
Ok(project) => {
self.projects.insert(root, project);
}
Err(err) => {
tracing::warn!("Failed to create project database for `{root}`: {err}",);
}
}
}
assert!(
self.workspaces.all_initialized(),
"All workspaces should be initialized after calling `initialize_workspaces`"
);
}
pub(crate) fn take_deferred_messages(&mut self) -> Option<Message> {
if self.workspaces.all_initialized() {
self.deferred_messages.pop_front()
} else {
None
}
}
/// Creates a document snapshot with the URL referencing the document to snapshot. /// Creates a document snapshot with the URL referencing the document to snapshot.
/// ///
/// Returns `None` if the url can't be converted to a document key or if the document isn't open. /// Returns `None` if the url can't be converted to a document key or if the document isn't open.
@ -240,7 +361,7 @@ impl Session {
fn index_mut(&mut self) -> MutIndexGuard { fn index_mut(&mut self) -> MutIndexGuard {
let index = self.index.take().unwrap(); let index = self.index.take().unwrap();
for db in self.projects_by_workspace_folder.values_mut() { for db in self.projects_mut() {
// Remove the `index` from each database. This drops the count of `Arc<Index>` down to 1 // Remove the `index` from each database. This drops the count of `Arc<Index>` down to 1
db.system_mut() db.system_mut()
.as_any_mut() .as_any_mut()
@ -250,11 +371,11 @@ impl Session {
} }
// There should now be exactly one reference to index which is self.index. // There should now be exactly one reference to index which is self.index.
let index = Arc::into_inner(index); let index = Arc::into_inner(index).unwrap();
MutIndexGuard { MutIndexGuard {
session: self, session: self,
index, index: Some(index),
} }
} }
@ -289,7 +410,7 @@ impl Drop for MutIndexGuard<'_> {
fn drop(&mut self) { fn drop(&mut self) {
if let Some(index) = self.index.take() { if let Some(index) = self.index.take() {
let index = Arc::new(index); let index = Arc::new(index);
for db in self.session.projects_by_workspace_folder.values_mut() { for db in self.session.projects_mut() {
db.system_mut() db.system_mut()
.as_any_mut() .as_any_mut()
.downcast_mut::<LSPSystem>() .downcast_mut::<LSPSystem>()
@ -339,3 +460,72 @@ impl DocumentSnapshot {
} }
} }
} }
#[derive(Debug, Default)]
pub(crate) struct Workspaces {
workspaces: BTreeMap<Url, Workspace>,
uninitialized: usize,
}
impl Workspaces {
pub(crate) fn register(&mut self, url: Url, options: ClientOptions) -> anyhow::Result<()> {
let path = url
.to_file_path()
.map_err(|()| anyhow!("Workspace URL is not a file or directory: {url:?}"))?;
self.workspaces.insert(
url,
Workspace {
options,
root: path,
},
);
self.uninitialized += 1;
Ok(())
}
pub(crate) fn initialize(
&mut self,
url: &Url,
options: ClientOptions,
) -> Option<&mut Workspace> {
if let Some(workspace) = self.workspaces.get_mut(url) {
workspace.options = options;
self.uninitialized -= 1;
Some(workspace)
} else {
None
}
}
pub(crate) fn urls(&self) -> impl Iterator<Item = &Url> + '_ {
self.workspaces.keys()
}
pub(crate) fn all_initialized(&self) -> bool {
self.uninitialized == 0
}
}
impl<'a> IntoIterator for &'a Workspaces {
type Item = (&'a Url, &'a Workspace);
type IntoIter = std::collections::btree_map::Iter<'a, Url, Workspace>;
fn into_iter(self) -> Self::IntoIter {
self.workspaces.iter()
}
}
#[derive(Debug)]
pub(crate) struct Workspace {
root: PathBuf,
options: ClientOptions,
}
impl Workspace {
pub(crate) fn root(&self) -> &Path {
&self.root
}
}

View file

@ -1,7 +1,6 @@
use crate::Session; use crate::Session;
use crate::server::{Action, ConnectionSender}; use crate::server::{Action, ConnectionSender};
use crate::server::{Event, MainLoopSender}; use crate::server::{Event, MainLoopSender};
use anyhow::{Context, anyhow};
use lsp_server::{ErrorCode, Message, Notification, RequestId, ResponseError}; use lsp_server::{ErrorCode, Message, Notification, RequestId, ResponseError};
use serde_json::Value; use serde_json::Value;
use std::any::TypeId; use std::any::TypeId;
@ -45,8 +44,7 @@ impl Client {
session: &Session, session: &Session,
params: R::Params, params: R::Params,
response_handler: impl FnOnce(&Client, R::Result) + Send + 'static, response_handler: impl FnOnce(&Client, R::Result) + Send + 'static,
) -> crate::Result<()> ) where
where
R: lsp_types::request::Request, R: lsp_types::request::Request,
{ {
let response_handler = Box::new(move |client: &Client, response: lsp_server::Response| { let response_handler = Box::new(move |client: &Client, response: lsp_server::Response| {
@ -95,60 +93,64 @@ impl Client {
.outgoing() .outgoing()
.register(response_handler); .register(response_handler);
self.client_sender if let Err(err) = self
.client_sender
.send(Message::Request(lsp_server::Request { .send(Message::Request(lsp_server::Request {
id, id,
method: R::METHOD.to_string(), method: R::METHOD.to_string(),
params: serde_json::to_value(params).context("Failed to serialize params")?, params: serde_json::to_value(params).expect("Params to be serializable"),
})) }))
.with_context(|| { {
format!("Failed to send request method={method}", method = R::METHOD) tracing::error!(
})?; "Failed to send request `{}` because the client sender is closed: {err}",
R::METHOD
Ok(()) );
}
} }
/// Sends a notification to the client. /// Sends a notification to the client.
pub(crate) fn send_notification<N>(&self, params: N::Params) -> crate::Result<()> pub(crate) fn send_notification<N>(&self, params: N::Params)
where where
N: lsp_types::notification::Notification, N: lsp_types::notification::Notification,
{ {
let method = N::METHOD.to_string(); let method = N::METHOD.to_string();
if let Err(err) =
self.client_sender self.client_sender
.send(lsp_server::Message::Notification(Notification::new( .send(lsp_server::Message::Notification(Notification::new(
method, params, method, params,
))) )))
.map_err(|error| { {
anyhow!( tracing::error!(
"Failed to send notification (method={method}): {error}", "Failed to send notification `{}` because the client sender is closed: {err}",
method = N::METHOD N::METHOD
) );
}) }
} }
/// Sends a notification without any parameters to the client. /// Sends a notification without any parameters to the client.
/// ///
/// This is useful for notifications that don't require any data. /// This is useful for notifications that don't require any data.
#[expect(dead_code)] #[expect(dead_code)]
pub(crate) fn send_notification_no_params(&self, method: &str) -> crate::Result<()> { pub(crate) fn send_notification_no_params(&self, method: &str) {
if let Err(err) =
self.client_sender self.client_sender
.send(lsp_server::Message::Notification(Notification::new( .send(lsp_server::Message::Notification(Notification::new(
method.to_string(), method.to_string(),
Value::Null, Value::Null,
))) )))
.map_err(|error| anyhow!("Failed to send notification (method={method}): {error}",)) {
tracing::error!(
"Failed to send notification `{method}` because the client sender is closed: {err}",
);
}
} }
/// Sends a response to the client for a given request ID. /// Sends a response to the client for a given request ID.
/// ///
/// The response isn't sent immediately. Instead, it's queued up in the main loop /// The response isn't sent immediately. Instead, it's queued up in the main loop
/// and checked for cancellation (each request must have exactly one response). /// and checked for cancellation (each request must have exactly one response).
pub(crate) fn respond<R>( pub(crate) fn respond<R>(&self, id: &RequestId, result: crate::server::Result<R>)
&self,
id: &RequestId,
result: crate::server::Result<R>,
) -> crate::Result<()>
where where
R: serde::Serialize, R: serde::Serialize,
{ {
@ -161,17 +163,13 @@ impl Client {
self.main_loop_sender self.main_loop_sender
.send(Event::Action(Action::SendResponse(response))) .send(Event::Action(Action::SendResponse(response)))
.map_err(|error| anyhow!("Failed to send response for request {id}: {error}")) .unwrap();
} }
/// Sends an error response to the client for a given request ID. /// Sends an error response to the client for a given request ID.
/// ///
/// The response isn't sent immediately. Instead, it's queued up in the main loop. /// The response isn't sent immediately. Instead, it's queued up in the main loop.
pub(crate) fn respond_err( pub(crate) fn respond_err(&self, id: RequestId, error: lsp_server::ResponseError) {
&self,
id: RequestId,
error: lsp_server::ResponseError,
) -> crate::Result<()> {
let response = lsp_server::Response { let response = lsp_server::Response {
id, id,
result: None, result: None,
@ -180,23 +178,19 @@ impl Client {
self.main_loop_sender self.main_loop_sender
.send(Event::Action(Action::SendResponse(response))) .send(Event::Action(Action::SendResponse(response)))
.map_err(|error| anyhow!("Failed to send response: {error}")) .unwrap();
} }
/// Shows a message to the user. /// Shows a message to the user.
/// ///
/// This opens a pop up in VS Code showing `message`. /// This opens a pop up in VS Code showing `message`.
pub(crate) fn show_message( pub(crate) fn show_message(&self, message: impl Display, message_type: lsp_types::MessageType) {
&self,
message: impl Display,
message_type: lsp_types::MessageType,
) -> crate::Result<()> {
self.send_notification::<lsp_types::notification::ShowMessage>( self.send_notification::<lsp_types::notification::ShowMessage>(
lsp_types::ShowMessageParams { lsp_types::ShowMessageParams {
typ: message_type, typ: message_type,
message: message.to_string(), message: message.to_string(),
}, },
) );
} }
/// Sends a request to display a warning to the client with a formatted message. The warning is /// Sends a request to display a warning to the client with a formatted message. The warning is
@ -204,11 +198,7 @@ impl Client {
/// ///
/// Logs an error if the message could not be sent. /// Logs an error if the message could not be sent.
pub(crate) fn show_warning_message(&self, message: impl Display) { pub(crate) fn show_warning_message(&self, message: impl Display) {
let result = self.show_message(message, lsp_types::MessageType::WARNING); self.show_message(message, lsp_types::MessageType::WARNING);
if let Err(err) = result {
tracing::error!("Failed to send warning message to the client: {err}");
}
} }
/// Sends a request to display an error to the client with a formatted message. The error is /// Sends a request to display an error to the client with a formatted message. The error is
@ -216,23 +206,23 @@ impl Client {
/// ///
/// Logs an error if the message could not be sent. /// Logs an error if the message could not be sent.
pub(crate) fn show_error_message(&self, message: impl Display) { pub(crate) fn show_error_message(&self, message: impl Display) {
let result = self.show_message(message, lsp_types::MessageType::ERROR); self.show_message(message, lsp_types::MessageType::ERROR);
if let Err(err) = result {
tracing::error!("Failed to send error message to the client: {err}");
}
} }
/// Re-queues this request after a salsa cancellation for a retry. /// Re-queues this request after a salsa cancellation for a retry.
/// ///
/// The main loop will skip the retry if the client cancelled the request in the meantime. /// The main loop will skip the retry if the client cancelled the request in the meantime.
pub(crate) fn retry(&self, request: lsp_server::Request) -> crate::Result<()> { pub(crate) fn retry(&self, request: lsp_server::Request) {
self.main_loop_sender self.main_loop_sender
.send(Event::Action(Action::RetryRequest(request))) .send(Event::Action(Action::RetryRequest(request)))
.map_err(|error| anyhow!("Failed to send retry request: {error}")) .unwrap();
} }
pub(crate) fn cancel(&self, session: &mut Session, id: RequestId) -> crate::Result<()> { pub(crate) fn queue_action(&self, action: Action) {
self.main_loop_sender.send(Event::Action(action)).unwrap();
}
pub(crate) fn cancel(&self, session: &mut Session, id: RequestId) {
let method_name = session.request_queue_mut().incoming_mut().cancel(&id); let method_name = session.request_queue_mut().incoming_mut().cancel(&id);
if let Some(method_name) = method_name { if let Some(method_name) = method_name {
@ -245,14 +235,18 @@ impl Client {
// Use `client_sender` here instead of `respond_err` because // Use `client_sender` here instead of `respond_err` because
// `respond_err` filters out responses for canceled requests (which we just did!). // `respond_err` filters out responses for canceled requests (which we just did!).
self.client_sender if let Err(err) = self
.client_sender
.send(Message::Response(lsp_server::Response { .send(Message::Response(lsp_server::Response {
id, id,
result: None, result: None,
error: Some(error), error: Some(error),
}))?; }))
{
tracing::error!(
"Failed to send cancellation response for request `{method_name}` because the client sender is closed: {err}",
);
}
} }
Ok(())
} }
} }

View file

@ -24,14 +24,7 @@ pub(crate) struct GlobalOptions {
impl GlobalOptions { impl GlobalOptions {
pub(crate) fn into_settings(self) -> ClientSettings { pub(crate) fn into_settings(self) -> ClientSettings {
ClientSettings { self.client.into_settings()
disable_language_services: self
.client
.python
.and_then(|python| python.ty)
.and_then(|ty| ty.disable_language_services)
.unwrap_or_default(),
}
} }
} }
@ -56,6 +49,19 @@ pub(crate) struct ClientOptions {
python: Option<Python>, python: Option<Python>,
} }
impl ClientOptions {
/// Returns the client settings that are relevant to the language server.
pub(crate) fn into_settings(self) -> ClientSettings {
ClientSettings {
disable_language_services: self
.python
.and_then(|python| python.ty)
.and_then(|ty| ty.disable_language_services)
.unwrap_or_default(),
}
}
}
// TODO(dhruvmanila): We need to mirror the "python.*" namespace on the server side but ideally it // TODO(dhruvmanila): We need to mirror the "python.*" namespace on the server side but ideally it
// would be useful to instead use `workspace/configuration` instead. This would be then used to get // would be useful to instead use `workspace/configuration` instead. This would be then used to get
// all settings and not just the ones in "python.*". // all settings and not just the ones in "python.*".