mirror of https://github.com/astral-sh/ruff
562 lines
19 KiB
Rust
562 lines
19 KiB
Rust
//! Data model, state management, and configuration resolution.
|
|
|
|
use std::collections::{BTreeMap, VecDeque};
|
|
use std::ops::{Deref, DerefMut};
|
|
use std::sync::Arc;
|
|
|
|
use anyhow::{Context, anyhow};
|
|
use lsp_server::Message;
|
|
use lsp_types::{ClientCapabilities, TextDocumentContentChangeEvent, Url};
|
|
use options::GlobalOptions;
|
|
use ruff_db::Db;
|
|
use ruff_db::files::{File, system_path_to_file};
|
|
use ruff_db::system::{System, SystemPath, SystemPathBuf};
|
|
use ty_project::metadata::Options;
|
|
use ty_project::{ProjectDatabase, ProjectMetadata};
|
|
|
|
pub(crate) use self::capabilities::ResolvedClientCapabilities;
|
|
pub use self::index::DocumentQuery;
|
|
pub(crate) use self::options::{AllOptions, ClientOptions};
|
|
pub(crate) use self::settings::ClientSettings;
|
|
use crate::document::{DocumentKey, DocumentVersion, NotebookDocument};
|
|
use crate::session::request_queue::RequestQueue;
|
|
use crate::system::{AnySystemPath, LSPSystem};
|
|
use crate::{PositionEncoding, TextDocument};
|
|
|
|
mod capabilities;
|
|
pub(crate) mod client;
|
|
pub(crate) mod index;
|
|
mod options;
|
|
mod request_queue;
|
|
mod settings;
|
|
|
|
/// The global state for the LSP
|
|
pub struct Session {
|
|
/// Used to retrieve information about open documents and settings.
|
|
///
|
|
/// This will be [`None`] when a mutable reference is held to the index via [`index_mut`]
|
|
/// to prevent the index from being accessed while it is being modified. It will be restored
|
|
/// when the mutable reference ([`MutIndexGuard`]) is dropped.
|
|
///
|
|
/// [`index_mut`]: Session::index_mut
|
|
index: Option<Arc<index::Index>>,
|
|
|
|
/// Maps workspace folders to their respective workspace.
|
|
workspaces: Workspaces,
|
|
|
|
/// The projects across all workspaces.
|
|
projects: BTreeMap<SystemPathBuf, ProjectDatabase>,
|
|
|
|
default_project: ProjectDatabase,
|
|
|
|
/// The global position encoding, negotiated during LSP initialization.
|
|
position_encoding: PositionEncoding,
|
|
|
|
/// Tracks what LSP features the client supports and doesn't support.
|
|
resolved_client_capabilities: Arc<ResolvedClientCapabilities>,
|
|
|
|
/// Tracks the pending requests between client and server.
|
|
request_queue: RequestQueue,
|
|
|
|
/// Has the client requested the server to shutdown.
|
|
shutdown_requested: bool,
|
|
|
|
deferred_messages: VecDeque<Message>,
|
|
}
|
|
|
|
impl Session {
|
|
pub(crate) fn new(
|
|
client_capabilities: &ClientCapabilities,
|
|
position_encoding: PositionEncoding,
|
|
global_options: GlobalOptions,
|
|
workspace_folders: Vec<(Url, ClientOptions)>,
|
|
) -> crate::Result<Self> {
|
|
let index = Arc::new(index::Index::new(global_options.into_settings()));
|
|
|
|
let mut workspaces = Workspaces::default();
|
|
for (url, options) in workspace_folders {
|
|
workspaces.register(url, options)?;
|
|
}
|
|
|
|
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 {
|
|
position_encoding,
|
|
workspaces,
|
|
deferred_messages: VecDeque::new(),
|
|
index: Some(index),
|
|
default_project,
|
|
projects: BTreeMap::new(),
|
|
resolved_client_capabilities: Arc::new(ResolvedClientCapabilities::new(
|
|
client_capabilities,
|
|
)),
|
|
request_queue: RequestQueue::new(),
|
|
shutdown_requested: false,
|
|
})
|
|
}
|
|
|
|
pub(crate) fn request_queue(&self) -> &RequestQueue {
|
|
&self.request_queue
|
|
}
|
|
|
|
pub(crate) fn request_queue_mut(&mut self) -> &mut RequestQueue {
|
|
&mut self.request_queue
|
|
}
|
|
|
|
pub(crate) fn is_shutdown_requested(&self) -> bool {
|
|
self.shutdown_requested
|
|
}
|
|
|
|
pub(crate) fn set_shutdown_requested(&mut self, requested: bool) {
|
|
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`
|
|
// and `default_workspace_db_mut` but the borrow checker doesn't allow that.
|
|
// https://github.com/astral-sh/ruff/pull/13041#discussion_r1726725437
|
|
|
|
/// Returns a reference to the project's [`ProjectDatabase`] corresponding to the given path,
|
|
/// or the default project if no project is found for the path.
|
|
pub(crate) fn project_db_or_default(&self, path: &AnySystemPath) -> &ProjectDatabase {
|
|
path.as_system()
|
|
.and_then(|path| self.project_db_for_path(path))
|
|
.unwrap_or_else(|| self.default_project_db())
|
|
}
|
|
|
|
/// Returns a reference to the project's [`ProjectDatabase`] corresponding to the given path, if
|
|
/// any.
|
|
pub(crate) fn project_db_for_path(
|
|
&self,
|
|
path: impl AsRef<SystemPath>,
|
|
) -> Option<&ProjectDatabase> {
|
|
self.projects
|
|
.range(..=path.as_ref().to_path_buf())
|
|
.next_back()
|
|
.map(|(_, db)| db)
|
|
}
|
|
|
|
/// Returns a mutable reference to the project [`ProjectDatabase`] corresponding to the given
|
|
/// path, if any.
|
|
pub(crate) fn project_db_for_path_mut(
|
|
&mut self,
|
|
path: impl AsRef<SystemPath>,
|
|
) -> Option<&mut ProjectDatabase> {
|
|
self.projects
|
|
.range_mut(..=path.as_ref().to_path_buf())
|
|
.next_back()
|
|
.map(|(_, db)| db)
|
|
}
|
|
|
|
/// Returns a reference to the default project [`ProjectDatabase`]. The default project is the
|
|
/// minimum root path in the project map.
|
|
pub(crate) fn default_project_db(&self) -> &ProjectDatabase {
|
|
&self.default_project
|
|
}
|
|
|
|
/// Returns a mutable reference to the default project [`ProjectDatabase`].
|
|
pub(crate) fn default_project_db_mut(&mut self) -> &mut ProjectDatabase {
|
|
&mut self.default_project
|
|
}
|
|
|
|
fn projects_mut(&mut self) -> impl Iterator<Item = &'_ mut ProjectDatabase> + '_ {
|
|
self.projects
|
|
.values_mut()
|
|
.chain(std::iter::once(&mut self.default_project))
|
|
}
|
|
|
|
pub(crate) fn key_from_url(&self, url: Url) -> crate::Result<DocumentKey> {
|
|
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 system_path = workspace.root();
|
|
|
|
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.
|
|
///
|
|
/// Returns `None` if the url can't be converted to a document key or if the document isn't open.
|
|
pub(crate) fn take_document_snapshot(&self, url: Url) -> Option<DocumentSnapshot> {
|
|
let key = self.key_from_url(url).ok()?;
|
|
Some(DocumentSnapshot {
|
|
resolved_client_capabilities: self.resolved_client_capabilities.clone(),
|
|
client_settings: self.index().global_settings(),
|
|
document_ref: self.index().make_document_ref(&key)?,
|
|
position_encoding: self.position_encoding,
|
|
})
|
|
}
|
|
|
|
/// Creates a snapshot of the current state of the [`Session`].
|
|
pub(crate) fn take_session_snapshot(&self) -> SessionSnapshot {
|
|
SessionSnapshot {
|
|
projects: self.projects.values().cloned().collect(),
|
|
index: self.index.clone().unwrap(),
|
|
position_encoding: self.position_encoding,
|
|
}
|
|
}
|
|
|
|
/// Iterates over the document keys for all open text documents.
|
|
pub(super) fn text_document_keys(&self) -> impl Iterator<Item = DocumentKey> + '_ {
|
|
self.index()
|
|
.text_document_paths()
|
|
.map(|path| DocumentKey::Text(path.clone()))
|
|
}
|
|
|
|
/// Registers a notebook document at the provided `path`.
|
|
/// If a document is already open here, it will be overwritten.
|
|
pub(crate) fn open_notebook_document(
|
|
&mut self,
|
|
path: &AnySystemPath,
|
|
document: NotebookDocument,
|
|
) {
|
|
self.index_mut().open_notebook_document(path, document);
|
|
}
|
|
|
|
/// Registers a text document at the provided `path`.
|
|
/// If a document is already open here, it will be overwritten.
|
|
pub(crate) fn open_text_document(&mut self, path: &AnySystemPath, document: TextDocument) {
|
|
self.index_mut().open_text_document(path, document);
|
|
}
|
|
|
|
/// Updates a text document at the associated `key`.
|
|
///
|
|
/// The document key must point to a text document, or this will throw an error.
|
|
pub(crate) fn update_text_document(
|
|
&mut self,
|
|
key: &DocumentKey,
|
|
content_changes: Vec<TextDocumentContentChangeEvent>,
|
|
new_version: DocumentVersion,
|
|
) -> crate::Result<()> {
|
|
let position_encoding = self.position_encoding;
|
|
self.index_mut()
|
|
.update_text_document(key, content_changes, new_version, position_encoding)
|
|
}
|
|
|
|
/// De-registers a document, specified by its key.
|
|
/// Calling this multiple times for the same document is a logic error.
|
|
pub(crate) fn close_document(&mut self, key: &DocumentKey) -> crate::Result<()> {
|
|
self.index_mut().close_document(key)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Returns a reference to the index.
|
|
///
|
|
/// # Panics
|
|
///
|
|
/// Panics if there's a mutable reference to the index via [`index_mut`].
|
|
///
|
|
/// [`index_mut`]: Session::index_mut
|
|
fn index(&self) -> &index::Index {
|
|
self.index.as_ref().unwrap()
|
|
}
|
|
|
|
/// Returns a mutable reference to the index.
|
|
///
|
|
/// This method drops all references to the index and returns a guard that will restore the
|
|
/// references when dropped. This guard holds the only reference to the index and allows
|
|
/// modifying it.
|
|
fn index_mut(&mut self) -> MutIndexGuard {
|
|
let index = self.index.take().unwrap();
|
|
|
|
for db in self.projects_mut() {
|
|
// Remove the `index` from each database. This drops the count of `Arc<Index>` down to 1
|
|
db.system_mut()
|
|
.as_any_mut()
|
|
.downcast_mut::<LSPSystem>()
|
|
.unwrap()
|
|
.take_index();
|
|
}
|
|
|
|
// There should now be exactly one reference to index which is self.index.
|
|
let index = Arc::into_inner(index).unwrap();
|
|
|
|
MutIndexGuard {
|
|
session: self,
|
|
index: Some(index),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn client_capabilities(&self) -> &ResolvedClientCapabilities {
|
|
&self.resolved_client_capabilities
|
|
}
|
|
|
|
pub(crate) fn global_settings(&self) -> Arc<ClientSettings> {
|
|
self.index().global_settings()
|
|
}
|
|
}
|
|
|
|
/// A guard that holds the only reference to the index and allows modifying it.
|
|
///
|
|
/// When dropped, this guard restores all references to the index.
|
|
struct MutIndexGuard<'a> {
|
|
session: &'a mut Session,
|
|
index: Option<index::Index>,
|
|
}
|
|
|
|
impl Deref for MutIndexGuard<'_> {
|
|
type Target = index::Index;
|
|
|
|
fn deref(&self) -> &Self::Target {
|
|
self.index.as_ref().unwrap()
|
|
}
|
|
}
|
|
|
|
impl DerefMut for MutIndexGuard<'_> {
|
|
fn deref_mut(&mut self) -> &mut Self::Target {
|
|
self.index.as_mut().unwrap()
|
|
}
|
|
}
|
|
|
|
impl Drop for MutIndexGuard<'_> {
|
|
fn drop(&mut self) {
|
|
if let Some(index) = self.index.take() {
|
|
let index = Arc::new(index);
|
|
for db in self.session.projects_mut() {
|
|
db.system_mut()
|
|
.as_any_mut()
|
|
.downcast_mut::<LSPSystem>()
|
|
.unwrap()
|
|
.set_index(index.clone());
|
|
}
|
|
|
|
self.session.index = Some(index);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// An immutable snapshot of `Session` that references
|
|
/// a specific document.
|
|
#[derive(Debug)]
|
|
pub struct DocumentSnapshot {
|
|
resolved_client_capabilities: Arc<ResolvedClientCapabilities>,
|
|
client_settings: Arc<ClientSettings>,
|
|
document_ref: index::DocumentQuery,
|
|
position_encoding: PositionEncoding,
|
|
}
|
|
|
|
impl DocumentSnapshot {
|
|
pub(crate) fn resolved_client_capabilities(&self) -> &ResolvedClientCapabilities {
|
|
&self.resolved_client_capabilities
|
|
}
|
|
|
|
pub(crate) fn query(&self) -> &index::DocumentQuery {
|
|
&self.document_ref
|
|
}
|
|
|
|
pub(crate) fn encoding(&self) -> PositionEncoding {
|
|
self.position_encoding
|
|
}
|
|
|
|
pub(crate) fn client_settings(&self) -> &ClientSettings {
|
|
&self.client_settings
|
|
}
|
|
|
|
pub(crate) fn file(&self, db: &dyn Db) -> Option<File> {
|
|
match AnySystemPath::try_from_url(self.document_ref.file_url()).ok()? {
|
|
AnySystemPath::System(path) => system_path_to_file(db, path).ok(),
|
|
AnySystemPath::SystemVirtual(virtual_path) => db
|
|
.files()
|
|
.try_virtual_file(&virtual_path)
|
|
.map(|virtual_file| virtual_file.file()),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// An immutable snapshot of the current state of [`Session`].
|
|
pub(crate) struct SessionSnapshot {
|
|
projects: Vec<ProjectDatabase>,
|
|
index: Arc<index::Index>,
|
|
position_encoding: PositionEncoding,
|
|
}
|
|
|
|
impl SessionSnapshot {
|
|
pub(crate) fn projects(&self) -> &[ProjectDatabase] {
|
|
&self.projects
|
|
}
|
|
|
|
pub(crate) fn index(&self) -> &index::Index {
|
|
&self.index
|
|
}
|
|
|
|
pub(crate) fn position_encoding(&self) -> PositionEncoding {
|
|
self.position_encoding
|
|
}
|
|
}
|
|
|
|
#[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:?}"))?;
|
|
|
|
// Realistically I don't think this can fail because we got the path from a Url
|
|
let system_path = SystemPathBuf::from_path_buf(path)
|
|
.map_err(|_| anyhow!("Workspace URL is not valid UTF8"))?;
|
|
|
|
self.workspaces.insert(
|
|
url,
|
|
Workspace {
|
|
options,
|
|
root: system_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: SystemPathBuf,
|
|
options: ClientOptions,
|
|
}
|
|
|
|
impl Workspace {
|
|
pub(crate) fn root(&self) -> &SystemPath {
|
|
&self.root
|
|
}
|
|
}
|