mirror of
https://github.com/astral-sh/ruff.git
synced 2026-10-28 10:45:37 +08:00
[ty] Watch script dependencies in the language server (#28522)
## Summary Same as https://github.com/astral-sh/ruff/pull/28125 but for the server. The main difference to the CLI is that, up to now, the server did not support changing its watched paths after its initial initialization. This PR adds the infrastructure so that the server now supports updating its watched paths as search paths change. ## Test Plan **Before** https://github.com/user-attachments/assets/a6ace5a1-84c9-4acb-a15c-991389543109 Changes to `dependency.py` are not picked up **Now** https://github.com/user-attachments/assets/b3d6bf4b-496f-4bdc-a610-82eb9690ffb3
This commit is contained in:
1 parent
8defe19c5d
commit
fd5d636bdd
15 files changed
+1085
-222
No files matched your search
Generated
+1
@@ -4952,6 +4952,7 @@ dependencies = [
|
||||
"crossbeam",
|
||||
"dunce",
|
||||
"gen-lsp-types",
|
||||
"indexmap",
|
||||
"insta",
|
||||
"jod-thread",
|
||||
"libc",
|
||||
|
||||
@@ -3040,6 +3040,14 @@ mod uv_metadata {
|
||||
)?;
|
||||
assert!(synchronized);
|
||||
|
||||
// Apply the package creation reported by the watcher after uv finishes writing it.
|
||||
let changes = case.stop_watch(|event: &ChangeEvent| {
|
||||
matches!(event, ChangeEvent::Created { path, .. }
|
||||
if path.file_name() == Some("attrs")
|
||||
&& path.parent().is_some_and(|parent| parent.file_name() == Some("site-packages")))
|
||||
});
|
||||
case.apply_changes(&changes);
|
||||
|
||||
assert!(case.db().check().is_empty());
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -75,7 +75,7 @@ use parking_lot::Mutex;
|
||||
use ruff_cache::{CacheKey, CacheKeyHasher};
|
||||
use ruff_db::FxDashMap;
|
||||
use ruff_db::cancellation::CancellationTokenSource;
|
||||
use ruff_db::files::{File, Files};
|
||||
use ruff_db::files::File;
|
||||
use ruff_db::system::{SystemPath, SystemPathBuf};
|
||||
use salsa::Setter;
|
||||
|
||||
@@ -712,33 +712,10 @@ fn apply_sync_result(
|
||||
request: &ScriptSyncRequest,
|
||||
output: std::io::Result<std::process::Output>,
|
||||
) {
|
||||
let previous_root = environment
|
||||
.uv_metadata(db)
|
||||
.and_then(UvMetadata::environment)
|
||||
.map(ToOwned::to_owned);
|
||||
let recovering_from_error = environment.initialization_error(db).is_some();
|
||||
let (uv_metadata, initialization_error) = match Uv::parse_metadata_output(db.system(), output) {
|
||||
Ok(metadata) => (Some(metadata), None),
|
||||
Err(error) => (None, Some(error.to_string().into_boxed_str())),
|
||||
};
|
||||
let current_root = uv_metadata.as_ref().and_then(UvMetadata::environment);
|
||||
|
||||
if let Some(root) = previous_root
|
||||
.as_deref()
|
||||
.or_else(|| current_root.filter(|_| recovering_from_error))
|
||||
{
|
||||
// uv can install, update, or remove packages without changing the virtual-environment path.
|
||||
// Refresh files under that path so semantic queries see the updated package contents.
|
||||
// After a failed synchronization, recover the path from the new metadata because the
|
||||
// previous metadata was cleared along with its virtual-environment path.
|
||||
//
|
||||
// FIXME: This is overbroad. A file watcher can tell us precisely what changed.
|
||||
// Remove this fallback once the language server also watches script environments.
|
||||
// Changes inside virtual environments should instead be watched and processed through `ProjectDatabase::apply_changes`.
|
||||
// Using a file watcher also ensures that virtual environment changes in
|
||||
// scripts without using uv are detected.
|
||||
Files::sync_all_recursive(db, [root]);
|
||||
}
|
||||
|
||||
if environment.uv_metadata(db) != uv_metadata.as_ref() {
|
||||
environment.set_uv_metadata(db).to(uv_metadata);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::fs;
|
||||
|
||||
pub use project_watcher::ProjectWatcher;
|
||||
pub use project_watcher::{ProjectWatcher, WatchPaths, watch_paths};
|
||||
use ruff_db::system::{System, SystemPath, SystemPathBuf, SystemVirtualPathBuf};
|
||||
pub use watcher::{EventHandler, Watcher, directory_watcher};
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ impl ProjectWatcher {
|
||||
// script's `site-packages` was unwatched when a `.pth` file changed from `/old`
|
||||
// to `/new`, this pass registered `/old` using stale contents. The refresh above
|
||||
// updates the `.pth` file; recompute the plan and register `/new` before returning.
|
||||
if self.cache_key == Some(watch_paths(db, db.project()).cache_key) {
|
||||
if self.cache_key == Some(watch_paths(db, db.project()).cache_key()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -69,11 +69,11 @@ impl ProjectWatcher {
|
||||
fn update_once(&mut self, db: &mut ProjectDatabase) -> bool {
|
||||
let watch_plan = watch_paths(db, db.project());
|
||||
|
||||
if self.cache_key == Some(watch_plan.cache_key) {
|
||||
if self.cache_key == Some(watch_plan.cache_key()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let paths = &watch_plan.paths;
|
||||
let paths = watch_plan.paths();
|
||||
let previously_watched = self.watched_paths.clone();
|
||||
let mut watcher_paths = self.watcher.paths_mut();
|
||||
let mut newly_covered_paths = Vec::new();
|
||||
@@ -132,7 +132,7 @@ impl ProjectWatcher {
|
||||
}
|
||||
);
|
||||
|
||||
self.cache_key = Some(watch_plan.cache_key);
|
||||
self.cache_key = Some(watch_plan.cache_key());
|
||||
|
||||
if newly_covered_paths.is_empty() {
|
||||
return false;
|
||||
@@ -159,12 +159,25 @@ impl ProjectWatcher {
|
||||
}
|
||||
}
|
||||
|
||||
/// The paths watched for a project and its scripts, with a key for detecting changes.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
struct WatchPaths {
|
||||
pub struct WatchPaths {
|
||||
cache_key: u64,
|
||||
paths: Box<[SystemPathBuf]>,
|
||||
}
|
||||
|
||||
impl WatchPaths {
|
||||
/// A key for changes to the watched paths.
|
||||
pub fn cache_key(&self) -> u64 {
|
||||
self.cache_key
|
||||
}
|
||||
|
||||
/// Paths whose changes need watching.
|
||||
pub fn paths(&self) -> &[SystemPathBuf] {
|
||||
&self.paths
|
||||
}
|
||||
}
|
||||
|
||||
/// Watches are registered in project, module, then configuration order. On Linux, the last
|
||||
/// registered watch determines the path reported for overlapping symlinks.
|
||||
///
|
||||
@@ -173,7 +186,7 @@ struct WatchPaths {
|
||||
/// search roots, rather than through symlinks inside the project. Configuration paths come last
|
||||
/// so their events use the explicit paths checked for configuration changes.
|
||||
#[salsa::tracked(returns(ref))]
|
||||
fn watch_paths(db: &dyn Db, project: Project) -> WatchPaths {
|
||||
pub fn watch_paths(db: &dyn Db, project: Project) -> WatchPaths {
|
||||
let project_path = project.root(db);
|
||||
|
||||
// Watch both the project root and any paths provided by the user on the CLI (removing any redundant nested paths).
|
||||
|
||||
@@ -33,6 +33,7 @@ ty_python_semantic = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
bitflags = { workspace = true }
|
||||
crossbeam = { workspace = true }
|
||||
indexmap = { workspace = true }
|
||||
jod-thread = { workspace = true }
|
||||
lsp-server = { workspace = true }
|
||||
lsp-types = { workspace = true }
|
||||
|
||||
@@ -85,14 +85,13 @@ impl Server {
|
||||
.outgoing_mut()
|
||||
.complete(&response.id)
|
||||
{
|
||||
handler.handle_response(&client, response);
|
||||
handler.handle_response(&client, &mut self.session, response);
|
||||
} else {
|
||||
tracing::error!(
|
||||
"Received a response with ID {}, which was not expected",
|
||||
response.id
|
||||
);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -112,59 +112,40 @@ impl State {
|
||||
mod tests {
|
||||
use anyhow::{Context, Result, bail};
|
||||
use crossbeam::channel::unbounded;
|
||||
use lsp_server::{Message, Response};
|
||||
use lsp_types::ProgressParams;
|
||||
use lsp_server::Message;
|
||||
use lsp_types::{ProgressParams, ProgressToken};
|
||||
|
||||
use crate::capabilities::ResolvedClientCapabilities;
|
||||
use crate::server::{Action, Event};
|
||||
use crate::server::LazyWorkDoneProgress;
|
||||
use crate::session::client::Client;
|
||||
|
||||
use super::ScriptProgress;
|
||||
use super::State;
|
||||
|
||||
#[test]
|
||||
fn script_progress_counts_pending_scripts_and_shows_last_started() -> Result<()> {
|
||||
fn script_progress_reports_completion_counts_and_last_started() -> Result<()> {
|
||||
let (main_loop, actions) = unbounded();
|
||||
let (sender, messages) = unbounded();
|
||||
let client = Client::new(main_loop, sender);
|
||||
let progress = ScriptProgress::default();
|
||||
let capabilities = ResolvedClientCapabilities::WORK_DONE_PROGRESS;
|
||||
let script = |name: &str| {
|
||||
progress
|
||||
.for_script(&client, capabilities, name.to_string())
|
||||
.context("progress is supported")
|
||||
let work_done = LazyWorkDoneProgress::new(
|
||||
&client,
|
||||
Some(ProgressToken::String("scripts".into())),
|
||||
"Synchronizing scripts",
|
||||
ResolvedClientCapabilities::WORK_DONE_PROGRESS,
|
||||
);
|
||||
let mut state = State {
|
||||
total: 2,
|
||||
..State::default()
|
||||
};
|
||||
let acknowledge_progress = || -> Result<()> {
|
||||
let Event::Action(Action::SendRequest(request)) = actions.try_recv()? else {
|
||||
bail!("expected progress creation request");
|
||||
};
|
||||
request
|
||||
.response_handler
|
||||
.handle_response(&client, Response::new_ok(0.into(), ()));
|
||||
Ok(())
|
||||
};
|
||||
|
||||
// Queued requests show their count before any uv command starts.
|
||||
let mut first = script("first.py")?;
|
||||
acknowledge_progress()?;
|
||||
let mut second = script("second.py")?;
|
||||
first.started();
|
||||
second.started();
|
||||
|
||||
// A replacement run keeps the same count. Finishing it keeps the last started name.
|
||||
second.finished();
|
||||
second.started();
|
||||
second.finished();
|
||||
second.completed();
|
||||
|
||||
// Failure to start uv still completes the request when its error is handled.
|
||||
script("failed.py")?.completed();
|
||||
first.finished();
|
||||
first.completed();
|
||||
|
||||
// Abandoning a later request closes its indicator without reporting completion.
|
||||
let abandoned = script("abandoned.py")?;
|
||||
acknowledge_progress()?;
|
||||
drop(abandoned);
|
||||
state.report_progress(&work_done);
|
||||
state.last_started = "first.py".into();
|
||||
state.report_progress(&work_done);
|
||||
state.last_started = "second.py".into();
|
||||
state.report_progress(&work_done);
|
||||
state.completed = 1;
|
||||
state.report_progress(&work_done);
|
||||
state.completed = 2;
|
||||
state.report_progress(&work_done);
|
||||
drop(work_done);
|
||||
|
||||
assert_eq!(
|
||||
messages
|
||||
@@ -172,18 +153,13 @@ mod tests {
|
||||
.map(progress_notification)
|
||||
.collect::<Result<Vec<_>>>()?,
|
||||
[
|
||||
"begin: 0/1",
|
||||
"begin: ",
|
||||
"report: 0/2",
|
||||
"report: 0/2: first.py",
|
||||
"report: 0/2: second.py",
|
||||
"report: 0/2: second.py",
|
||||
"report: 1/2: second.py",
|
||||
"report: 1/3: second.py",
|
||||
"report: 2/3: second.py",
|
||||
"report: 3/3: second.py",
|
||||
"report: 2/2: second.py",
|
||||
"end: Finished synchronizing scripts",
|
||||
"begin: 0/1",
|
||||
"end: ",
|
||||
]
|
||||
);
|
||||
assert!(actions.is_empty());
|
||||
|
||||
+24
-108
@@ -8,16 +8,15 @@ use std::sync::Arc;
|
||||
use anyhow::{Context, anyhow};
|
||||
use lsp_server::{Message, RequestId};
|
||||
use lsp_types::{
|
||||
ClientInfo, DiagnosticProvider, DiagnosticRegistrationOptions,
|
||||
DidChangeWatchedFilesRegistrationOptions, FileSystemWatcher, Registration, RegistrationParams,
|
||||
TextDocumentContentChangeEvent, Unregistration, UnregistrationParams, Uri,
|
||||
ClientInfo, DiagnosticProvider, DiagnosticRegistrationOptions, Registration,
|
||||
RegistrationParams, TextDocumentContentChangeEvent, Unregistration, UnregistrationParams, Uri,
|
||||
WorkDoneProgressBegin,
|
||||
};
|
||||
use lsp_types::{DidChangeWatchedFilesNotification, ExitNotification, Notification};
|
||||
use lsp_types::{
|
||||
DocumentDiagnosticRequest, RegistrationRequest, Request, ShutdownRequest,
|
||||
UnregistrationRequest, WorkspaceDiagnosticRequest,
|
||||
};
|
||||
use lsp_types::{ExitNotification, Notification};
|
||||
use ruff_db::Db;
|
||||
use ruff_db::files::{File, system_path_to_file};
|
||||
use ruff_db::system::{System, SystemPath, SystemPathBuf};
|
||||
@@ -44,6 +43,7 @@ use crate::server::{
|
||||
publish_diagnostics_if_needed, publish_settings_diagnostics,
|
||||
};
|
||||
use crate::session::client::Client;
|
||||
use crate::session::file_watcher::LspFileWatcher;
|
||||
use crate::session::index::Document;
|
||||
use crate::session::request_queue::RequestQueue;
|
||||
use crate::system::{AnySystemPath, LSPSystem};
|
||||
@@ -51,6 +51,7 @@ use crate::{PositionEncoding, TextDocument};
|
||||
use index::Index;
|
||||
|
||||
pub(crate) mod client;
|
||||
mod file_watcher;
|
||||
pub(crate) mod index;
|
||||
mod options;
|
||||
mod request_queue;
|
||||
@@ -120,6 +121,8 @@ pub(crate) struct Session {
|
||||
/// client.
|
||||
registrations: HashSet<String>,
|
||||
|
||||
file_watcher: Option<LspFileWatcher>,
|
||||
|
||||
/// The name of the client (editor) that connected to this server.
|
||||
client_name: ClientName,
|
||||
}
|
||||
@@ -189,6 +192,7 @@ impl Session {
|
||||
suspended_workspace_diagnostics_request: None,
|
||||
revision: 0,
|
||||
registrations: HashSet::new(),
|
||||
file_watcher: LspFileWatcher::new(resolved_client_capabilities),
|
||||
client_name,
|
||||
})
|
||||
}
|
||||
@@ -336,6 +340,8 @@ impl Session {
|
||||
if capabilities.supports_inlay_hint_refresh() {
|
||||
client.send_request::<lsp_types::InlayHintRefreshRequest>(self, (), |_, ()| {});
|
||||
}
|
||||
|
||||
self.update_file_watcher(client);
|
||||
}
|
||||
|
||||
/// Requests synchronization using the scripts' saved metadata.
|
||||
@@ -582,6 +588,7 @@ impl Session {
|
||||
}
|
||||
let scripts = result.scripts_to_synchronize(db);
|
||||
Self::synchronize_closed_scripts(db, &scripts, client, capabilities, &script_progress);
|
||||
self.update_file_watcher(client);
|
||||
result
|
||||
}
|
||||
|
||||
@@ -1035,6 +1042,8 @@ impl Session {
|
||||
|
||||
self.bump_revision();
|
||||
|
||||
self.update_file_watcher(client);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1085,7 +1094,6 @@ impl Session {
|
||||
/// `ty.experimental.rename` global setting.
|
||||
fn register_capabilities(&mut self, client: &Client) {
|
||||
static DIAGNOSTIC_REGISTRATION_ID: &str = "ty/textDocument/diagnostic";
|
||||
static FILE_WATCHER_REGISTRATION_ID: &str = "ty/workspace/didChangeWatchedFiles";
|
||||
|
||||
let mut registrations = vec![];
|
||||
let mut unregistrations = vec![];
|
||||
@@ -1139,26 +1147,19 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(register_options) = self.file_watcher_registration_options() {
|
||||
if self
|
||||
.registrations
|
||||
.contains(DidChangeWatchedFilesNotification::METHOD.as_str())
|
||||
{
|
||||
unregistrations.push(Unregistration {
|
||||
id: FILE_WATCHER_REGISTRATION_ID.into(),
|
||||
method: DidChangeWatchedFilesNotification::METHOD.into(),
|
||||
});
|
||||
}
|
||||
registrations.push(Registration {
|
||||
id: FILE_WATCHER_REGISTRATION_ID.into(),
|
||||
method: DidChangeWatchedFilesNotification::METHOD.into(),
|
||||
register_options: Some(serde_json::to_value(register_options).unwrap()),
|
||||
});
|
||||
}
|
||||
|
||||
// First, unregister any existing capabilities and then register or re-register them.
|
||||
self.unregister_dynamic_capability(client, unregistrations);
|
||||
self.register_dynamic_capability(client, registrations);
|
||||
self.update_file_watcher(client);
|
||||
}
|
||||
|
||||
fn update_file_watcher(&mut self, client: &Client) {
|
||||
let Some(file_watcher) = &mut self.file_watcher else {
|
||||
return;
|
||||
};
|
||||
if let Some(update) = file_watcher.update(self.projects.values().map(|state| &state.db)) {
|
||||
update.apply(self, client);
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers a list of dynamic capabilities with the client.
|
||||
@@ -1210,92 +1211,6 @@ impl Session {
|
||||
);
|
||||
}
|
||||
|
||||
/// Try to register the file watcher provided by the client if the client supports it.
|
||||
///
|
||||
/// Note that this should be called *after* workspaces/projects have been initialized.
|
||||
/// This is required because the globs we use for registering file watching take
|
||||
/// project search paths into account.
|
||||
fn file_watcher_registration_options(
|
||||
&self,
|
||||
) -> Option<DidChangeWatchedFilesRegistrationOptions> {
|
||||
fn make_watcher(glob: &str) -> FileSystemWatcher {
|
||||
FileSystemWatcher {
|
||||
glob_pattern: lsp_types::GlobPattern::Pattern(glob.into()),
|
||||
// When `kind` is omitted, it defaults to `WatchKind.Create | WatchKind.Change | WatchKind.Delete`.
|
||||
// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.18/specification/#fileSystemWatcher
|
||||
kind: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_relative_watcher(relative_to: &SystemPath, glob: &str) -> FileSystemWatcher {
|
||||
let base_uri = Uri::from_file_path(relative_to.as_std_path())
|
||||
.expect("system path must be a valid URI");
|
||||
let glob_pattern =
|
||||
lsp_types::GlobPattern::RelativePattern(lsp_types::RelativePattern {
|
||||
base_uri: base_uri.into(),
|
||||
pattern: glob.to_string(),
|
||||
});
|
||||
FileSystemWatcher {
|
||||
glob_pattern,
|
||||
kind: Some(
|
||||
lsp_types::WatchKind::Change
|
||||
| lsp_types::WatchKind::Delete
|
||||
| lsp_types::WatchKind::Create,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
if !self.client_capabilities().supports_file_watcher() {
|
||||
tracing::warn!(
|
||||
"Your LSP client doesn't support file watching: \
|
||||
You may see stale results when files change outside the editor"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
|
||||
// We also want to watch everything in the search paths as
|
||||
// well. But this seems to require "relative" watcher support.
|
||||
// I had trouble getting this working without using a base uri.
|
||||
//
|
||||
// Specifically, I tried this for each search path:
|
||||
//
|
||||
// make_watcher(&format!("{path}/**"))
|
||||
//
|
||||
// But while this seemed to work for the project root, it
|
||||
// simply wouldn't result in any file notifications for changes
|
||||
// to files outside of the project root.
|
||||
let watchers = if !self.client_capabilities().supports_relative_file_watcher() {
|
||||
tracing::warn!(
|
||||
"Your LSP client doesn't support file watching outside of project: \
|
||||
You may see stale results when dependencies change"
|
||||
);
|
||||
// Initialize our list of watchers with the standard globs relative
|
||||
// to the project root if we can't use relative globs.
|
||||
vec![make_watcher("**")]
|
||||
} else {
|
||||
// Gather up all of our project roots and all of the corresponding
|
||||
// project root system paths, then deduplicate them relative to
|
||||
// one another. Then listen to everything.
|
||||
let roots = self.project_dbs().map(|db| db.project().root(db));
|
||||
let paths = self
|
||||
.project_dbs()
|
||||
.flat_map(|db| {
|
||||
ty_module_resolver::system_module_search_paths(
|
||||
db,
|
||||
db.project().program(db).resolver_environment(db),
|
||||
)
|
||||
.map(move |path| (db, path))
|
||||
})
|
||||
.filter(|(db, path)| !path.starts_with(db.project().root(*db)))
|
||||
.map(|(_, path)| path)
|
||||
.chain(roots);
|
||||
ruff_db::system::deduplicate_nested_paths(paths)
|
||||
.map(|path| make_relative_watcher(path, "**"))
|
||||
.collect()
|
||||
};
|
||||
Some(DidChangeWatchedFilesRegistrationOptions { watchers })
|
||||
}
|
||||
|
||||
/// Creates a document snapshot with the URI referencing the document to snapshot.
|
||||
pub(crate) fn snapshot_document(&self, uri: &Uri) -> Result<DocumentSnapshot, DocumentError> {
|
||||
let index = self.index();
|
||||
@@ -1483,6 +1398,7 @@ impl Session {
|
||||
}
|
||||
|
||||
self.bump_revision();
|
||||
self.update_file_watcher(client);
|
||||
}
|
||||
|
||||
/// Returns a reference to the index.
|
||||
|
||||
@@ -287,15 +287,23 @@ impl Client {
|
||||
|
||||
/// Type erased handler for client responses.
|
||||
#[expect(clippy::type_complexity)]
|
||||
pub(crate) struct ClientResponseHandler(Box<dyn FnOnce(&Client, lsp_server::Response) + Send>);
|
||||
pub(crate) struct ClientResponseHandler(
|
||||
Box<dyn FnOnce(&Client, &mut Session, lsp_server::Response) + Send>,
|
||||
);
|
||||
|
||||
impl ClientResponseHandler {
|
||||
pub(crate) fn new(
|
||||
handler: impl FnOnce(&Client, &mut Session, lsp_server::Response) + Send + 'static,
|
||||
) -> Self {
|
||||
Self(Box::new(handler))
|
||||
}
|
||||
|
||||
fn for_request<R>(response_handler: impl FnOnce(&Client, R::Result) + Send + 'static) -> Self
|
||||
where
|
||||
R: lsp_types::Request,
|
||||
{
|
||||
Self(Box::new(
|
||||
move |client: &Client, response: lsp_server::Response| {
|
||||
Self::new(
|
||||
move |client: &Client, _session: &mut Session, response: lsp_server::Response| {
|
||||
let _span =
|
||||
tracing::debug_span!("client_response", id=%response.id, method = %R::METHOD)
|
||||
.entered();
|
||||
@@ -320,11 +328,16 @@ impl ClientResponseHandler {
|
||||
},
|
||||
}
|
||||
},
|
||||
))
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn handle_response(self, client: &Client, response: lsp_server::Response) {
|
||||
pub(crate) fn handle_response(
|
||||
self,
|
||||
client: &Client,
|
||||
session: &mut Session,
|
||||
response: lsp_server::Response,
|
||||
) {
|
||||
let handler = self.0;
|
||||
handler(client, response);
|
||||
handler(client, session, response);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
//! Dynamic LSP file-watcher registrations for projects and their scripts.
|
||||
|
||||
use std::hash::{DefaultHasher, Hash, Hasher};
|
||||
|
||||
use indexmap::IndexMap;
|
||||
use lsp_types::{
|
||||
DidChangeWatchedFilesNotification, DidChangeWatchedFilesRegistrationOptions, FileSystemWatcher,
|
||||
GlobPattern, Notification, Registration, RegistrationParams, RegistrationRequest,
|
||||
RelativePattern, Request, Unregistration, UnregistrationParams, UnregistrationRequest, Uri,
|
||||
};
|
||||
use ruff_db::Db as _;
|
||||
use ruff_db::files::Files;
|
||||
use ruff_db::system::{SystemPath, SystemPathBuf};
|
||||
use ty_project::Db as _;
|
||||
use ty_project::ProjectDatabase;
|
||||
use ty_project::watch::watch_paths;
|
||||
|
||||
use crate::capabilities::ResolvedClientCapabilities;
|
||||
use crate::server::{SendRequest, publish_all_document_diagnostics};
|
||||
|
||||
use super::{
|
||||
Session,
|
||||
client::{Client, ClientResponseHandler},
|
||||
};
|
||||
|
||||
const MAX_RECONCILIATION_PASSES: u8 = 10;
|
||||
|
||||
/// Manages the client's watched-file registration for all projects.
|
||||
///
|
||||
/// The watched paths are all registered with a single register request. A replacement is registered
|
||||
/// before the previous registration is removed, so existing paths remain watched.
|
||||
#[derive(Default)]
|
||||
pub(super) struct LspFileWatcher {
|
||||
/// ID of the last registration accepted by the client, retained during replacement.
|
||||
registered_id: Option<String>,
|
||||
|
||||
/// Absolute paths currently watched.
|
||||
/// Empty when the client doesn't support relative file watchers.
|
||||
registered_paths: Vec<SystemPathBuf>,
|
||||
|
||||
/// Id of the next registration request.
|
||||
next_registration_id: u64,
|
||||
|
||||
/// Registration ID and path state awaiting the client's response.
|
||||
pending_registration: Option<PendingRegistration>,
|
||||
|
||||
/// Cache key to short-circuit `reconcile` if the watched paths are unchanged.
|
||||
cache_key: Option<u64>,
|
||||
|
||||
/// Registrations in the current reconciliation cycle, capped if refreshes keep changing paths.
|
||||
reconciliation_passes: u8,
|
||||
|
||||
/// Whether the client supports relative file watches. For the watcher,
|
||||
/// this mainly defines whether the client supports watching paths outside the project's root.
|
||||
supports_relative_file_watcher: bool,
|
||||
}
|
||||
|
||||
impl LspFileWatcher {
|
||||
pub(super) fn new(capabilities: ResolvedClientCapabilities) -> Option<Self> {
|
||||
if !capabilities.supports_file_watcher() {
|
||||
tracing::warn!(
|
||||
"Your LSP client doesn't support file watching: \
|
||||
You may see stale results when files change outside the editor"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(Self {
|
||||
supports_relative_file_watcher: capabilities.supports_relative_file_watcher(),
|
||||
..Self::default()
|
||||
})
|
||||
}
|
||||
|
||||
/// Updates project watch paths, starting a new reconciliation cycle when idle.
|
||||
pub(super) fn update<'db>(
|
||||
&mut self,
|
||||
projects: impl ExactSizeIterator<Item = &'db ProjectDatabase> + Clone,
|
||||
) -> Option<FileWatcherUpdate> {
|
||||
// The pending response will reconcile current paths, so this update
|
||||
// stays within the same reconciliation limit.
|
||||
if self.pending_registration.is_none() {
|
||||
self.reconciliation_passes = 0;
|
||||
}
|
||||
|
||||
self.reconcile(projects)
|
||||
}
|
||||
|
||||
/// Computes which paths need to be watched and returns an updated registration
|
||||
/// if there are any changes to the watched paths.
|
||||
fn reconcile<'db>(
|
||||
&mut self,
|
||||
projects: impl ExactSizeIterator<Item = &'db ProjectDatabase> + Clone,
|
||||
) -> Option<FileWatcherUpdate> {
|
||||
// Wait for the client's response before preparing another registration;
|
||||
// its handler will reconcile the latest project paths.
|
||||
if self.pending_registration.is_some() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Compute a single cache key across all projects
|
||||
// We can remove this once ty supports multiple project and
|
||||
// workspace-folders is migrated to that feature.
|
||||
let mut hasher = DefaultHasher::new();
|
||||
projects.len().hash(&mut hasher);
|
||||
for db in projects.clone() {
|
||||
watch_paths(db, db.project()).cache_key().hash(&mut hasher);
|
||||
}
|
||||
let cache_key = hasher.finish();
|
||||
|
||||
if self.cache_key == Some(cache_key) {
|
||||
return None;
|
||||
}
|
||||
|
||||
if self.reconciliation_passes >= MAX_RECONCILIATION_PASSES {
|
||||
tracing::warn!(
|
||||
"LSP file watcher paths did not stabilize after {MAX_RECONCILIATION_PASSES} registrations; changes outside the registered paths may be missed until the next update"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
|
||||
let paths: Vec<_> = projects
|
||||
.map(|db| (db, db.project().root(db), watch_paths(db, db.project())))
|
||||
.collect();
|
||||
|
||||
let (watchers, paths): (Vec<_>, Vec<_>) = if self.supports_relative_file_watcher {
|
||||
let watchers: IndexMap<_, _> = paths
|
||||
.iter()
|
||||
.flat_map(|(db, _, paths)| {
|
||||
paths.paths().iter().map(move |path| {
|
||||
// Watch a file by name from its parent so the glob matches the file itself.
|
||||
let watcher = if db.system().is_file(path)
|
||||
&& let (Some(parent), Some(name)) = (path.parent(), path.file_name())
|
||||
{
|
||||
relative_watcher(parent, &escape_glob_file_name(name))
|
||||
} else {
|
||||
relative_watcher(path, "**")
|
||||
};
|
||||
(path.to_path_buf(), watcher)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let (paths, watchers) = watchers.into_iter().unzip();
|
||||
(watchers, paths)
|
||||
} else {
|
||||
if let Some(unwatched_path) = paths.iter().find_map(|(_, root, paths)| {
|
||||
paths.paths().iter().find(|path| !path.starts_with(root))
|
||||
}) {
|
||||
tracing::warn!(
|
||||
"Your LSP client doesn't support file watching outside the project: \
|
||||
changes at or under `{unwatched_path}` may not be reported"
|
||||
);
|
||||
}
|
||||
if self.registered_id.is_some() {
|
||||
// Without relative patterns, changing an external search path cannot
|
||||
// change the workspace-wide watch we registered with the client.
|
||||
self.cache_key = Some(cache_key);
|
||||
return None;
|
||||
}
|
||||
(
|
||||
vec![FileSystemWatcher {
|
||||
glob_pattern: GlobPattern::Pattern("**".into()),
|
||||
kind: None,
|
||||
}],
|
||||
Vec::new(),
|
||||
)
|
||||
};
|
||||
|
||||
// Refresh files in paths that weren't watched before. This is to handle
|
||||
// the case where a path was watched, then unwatched, and now gets watched again.
|
||||
// Files in that path might have changed while it was unwatched.
|
||||
// We don't need to do this if this is the initial registration.
|
||||
let newly_covered_paths = if self.registered_id.is_some() {
|
||||
paths
|
||||
.iter()
|
||||
.filter(|path| {
|
||||
!self
|
||||
.registered_paths
|
||||
.iter()
|
||||
.any(|registered| path.starts_with(registered))
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let registration_id = format!(
|
||||
"ty/workspace/didChangeWatchedFiles/{}",
|
||||
self.next_registration_id
|
||||
);
|
||||
self.next_registration_id += 1;
|
||||
self.reconciliation_passes += 1;
|
||||
|
||||
Some(FileWatcherUpdate {
|
||||
registration: Registration {
|
||||
id: registration_id,
|
||||
method: DidChangeWatchedFilesNotification::METHOD.into(),
|
||||
register_options: Some(
|
||||
serde_json::to_value(DidChangeWatchedFilesRegistrationOptions { watchers })
|
||||
.unwrap(),
|
||||
),
|
||||
},
|
||||
cache_key,
|
||||
paths,
|
||||
newly_covered_paths,
|
||||
})
|
||||
}
|
||||
|
||||
fn complete_registration(
|
||||
&mut self,
|
||||
response: &lsp_server::Response,
|
||||
) -> Option<FileWatcherCompletion> {
|
||||
let update = self.pending_registration.take()?;
|
||||
match &response.response_result {
|
||||
Ok(value) if value.is_null() => {}
|
||||
Err(error) => {
|
||||
tracing::error!(
|
||||
"File watcher registration failed (code {}): {}",
|
||||
error.code,
|
||||
error.message
|
||||
);
|
||||
// Leave the old registration active. A later update can retry.
|
||||
return None;
|
||||
}
|
||||
Ok(_) => {
|
||||
tracing::error!("File watcher registration returned an invalid response");
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
let old_id = self.registered_id.replace(update.registration_id);
|
||||
self.registered_paths = update.paths;
|
||||
self.cache_key = Some(update.cache_key);
|
||||
Some(FileWatcherCompletion {
|
||||
old_id,
|
||||
newly_covered_paths: update.newly_covered_paths,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct FileWatcherUpdate {
|
||||
registration: Registration,
|
||||
cache_key: u64,
|
||||
paths: Vec<SystemPathBuf>,
|
||||
newly_covered_paths: Vec<SystemPathBuf>,
|
||||
}
|
||||
|
||||
impl FileWatcherUpdate {
|
||||
pub(super) fn apply(self, session: &mut Session, client: &Client) {
|
||||
let FileWatcherUpdate {
|
||||
registration,
|
||||
cache_key,
|
||||
paths,
|
||||
newly_covered_paths,
|
||||
} = self;
|
||||
|
||||
let pending_registration = PendingRegistration {
|
||||
registration_id: registration.id.clone(),
|
||||
cache_key,
|
||||
paths,
|
||||
newly_covered_paths,
|
||||
};
|
||||
|
||||
client.send_request_raw(
|
||||
session,
|
||||
SendRequest {
|
||||
method: RegistrationRequest::METHOD.to_string(),
|
||||
params: serde_json::to_value(RegistrationParams {
|
||||
registrations: vec![registration],
|
||||
})
|
||||
.expect("registration options are serializable"),
|
||||
response_handler: ClientResponseHandler::new(|client, session, response| {
|
||||
let Some(completion) = session
|
||||
.file_watcher
|
||||
.as_mut()
|
||||
.and_then(|watcher| watcher.complete_registration(&response))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
completion.apply(session, client);
|
||||
// Refreshing a newly covered `site-packages` can reveal an edited
|
||||
// `.pth` file with another search path that needs a watch.
|
||||
if let Some(update) = session.file_watcher.as_mut().and_then(|watcher| {
|
||||
watcher.reconcile(session.projects.values().map(|state| &state.db))
|
||||
}) {
|
||||
update.apply(session, client);
|
||||
}
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if let Some(watcher) = session.file_watcher.as_mut() {
|
||||
watcher.pending_registration = Some(pending_registration);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct FileWatcherCompletion {
|
||||
old_id: Option<String>,
|
||||
newly_covered_paths: Vec<SystemPathBuf>,
|
||||
}
|
||||
|
||||
impl FileWatcherCompletion {
|
||||
fn apply(self, session: &mut Session, client: &Client) {
|
||||
// Existing paths were watched by the old registration throughout this
|
||||
// request. Refresh newly covered paths after the client's response so
|
||||
// edits missed before registration invalidate cached files, including `.pth` files.
|
||||
// A client could respond before its watches are active, leaving a small
|
||||
// gap that the protocol does not let us close completely.
|
||||
if !self.newly_covered_paths.is_empty() {
|
||||
for state in session.projects.values_mut() {
|
||||
Files::sync_all_recursive(&mut state.db, &self.newly_covered_paths);
|
||||
}
|
||||
|
||||
// A previous refresh may have run before these unwatched paths were scanned.
|
||||
// Notify the client about files that changed while the watch was absent.
|
||||
session.bump_revision();
|
||||
session.resume_suspended_workspace_diagnostic_request(client);
|
||||
let capabilities = session.client_capabilities();
|
||||
if capabilities.supports_workspace_diagnostic_refresh() {
|
||||
client.send_request::<lsp_types::DiagnosticRefreshRequest>(session, (), |_, ()| {});
|
||||
} else {
|
||||
publish_all_document_diagnostics(session, client);
|
||||
}
|
||||
|
||||
if capabilities.supports_inlay_hint_refresh() {
|
||||
client.send_request::<lsp_types::InlayHintRefreshRequest>(session, (), |_, ()| {});
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(id) = self.old_id {
|
||||
client.send_request::<UnregistrationRequest>(
|
||||
session,
|
||||
UnregistrationParams {
|
||||
unregisterations: vec![Unregistration {
|
||||
id,
|
||||
method: DidChangeWatchedFilesNotification::METHOD.into(),
|
||||
}],
|
||||
},
|
||||
|_, ()| {},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn relative_watcher(path: &SystemPath, pattern: &str) -> FileSystemWatcher {
|
||||
let base_uri =
|
||||
Uri::from_file_path(path.as_std_path()).expect("system path must be a valid URI");
|
||||
FileSystemWatcher {
|
||||
glob_pattern: GlobPattern::RelativePattern(RelativePattern {
|
||||
base_uri: base_uri.into(),
|
||||
pattern: pattern.to_string(),
|
||||
}),
|
||||
kind: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Escapes glob operators in a file name using single-character classes.
|
||||
/// LSP defines the operators but not an escape syntax, so we assume clients
|
||||
/// interpret these classes like VS Code does.
|
||||
fn escape_glob_file_name(name: &str) -> String {
|
||||
let mut escaped = String::with_capacity(name.len());
|
||||
for character in name.chars() {
|
||||
if matches!(character, '*' | '?' | '{' | '}' | '[' | ']') {
|
||||
escaped.push('[');
|
||||
escaped.push(character);
|
||||
escaped.push(']');
|
||||
} else {
|
||||
escaped.push(character);
|
||||
}
|
||||
}
|
||||
escaped
|
||||
}
|
||||
|
||||
struct PendingRegistration {
|
||||
registration_id: String,
|
||||
cache_key: u64,
|
||||
paths: Vec<SystemPathBuf>,
|
||||
newly_covered_paths: Vec<SystemPathBuf>,
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
use anyhow::Result;
|
||||
use anyhow::{Context, Result};
|
||||
use insta::assert_json_snapshot;
|
||||
use lsp_types::RegistrationRequest;
|
||||
use ruff_db::system::SystemPath;
|
||||
use ruff_python_trivia::textwrap::dedent;
|
||||
use serde_json::{Map, json};
|
||||
@@ -22,7 +23,7 @@ def foo() -> str:
|
||||
|
||||
let builder = TestServerBuilder::new()?;
|
||||
|
||||
let settings_path = builder.file_path("ty2.toml");
|
||||
let settings_path = builder.file_path("ty[dev].toml");
|
||||
|
||||
let mut server = builder
|
||||
.with_workspace(
|
||||
@@ -43,13 +44,37 @@ def foo() -> str:
|
||||
unresolved-reference="warn"
|
||||
"#,
|
||||
)?
|
||||
.with_watched_file_support(true)
|
||||
.build()
|
||||
.wait_until_workspaces_are_initialized();
|
||||
|
||||
let (_, registrations) = server.await_request::<RegistrationRequest>();
|
||||
let options = registrations
|
||||
.registrations
|
||||
.first()
|
||||
.and_then(|registration| registration.register_options.as_ref())
|
||||
.context("expected file watcher registration")?;
|
||||
assert_json_snapshot!(options["watchers"], @r#"
|
||||
[
|
||||
{
|
||||
"globPattern": {
|
||||
"baseUri": "file://<temp_dir>/src",
|
||||
"pattern": "**"
|
||||
}
|
||||
},
|
||||
{
|
||||
"globPattern": {
|
||||
"baseUri": "file://<temp_dir>/",
|
||||
"pattern": "ty[[]dev[]].toml"
|
||||
}
|
||||
}
|
||||
]
|
||||
"#);
|
||||
|
||||
server.open_text_document(foo, foo_content, 1);
|
||||
let diagnostics = server.document_diagnostic_request(foo, None);
|
||||
|
||||
assert_json_snapshot!(diagnostics);
|
||||
assert_json_snapshot!("configuration_file", diagnostics);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
use anyhow::Result;
|
||||
use insta::{assert_json_snapshot, assert_snapshot};
|
||||
use lsp_types::{
|
||||
BaseUri, FileSystemWatcher, GlobPattern, InlayHintRefreshRequest,
|
||||
TextDocumentContentChangeEvent, TextDocumentContentChangeWholeDocument,
|
||||
};
|
||||
use ruff_db::system::SystemPath;
|
||||
use ruff_python_trivia::textwrap::dedent;
|
||||
use ty_server::{ClientOptions, DiagnosticMode};
|
||||
|
||||
use crate::workspace_folders::condensed_document_diagnostic_snapshot;
|
||||
use crate::{TestServer, TestServerBuilder};
|
||||
|
||||
#[test]
|
||||
fn refreshes_script_dependency_after_rewatch() -> Result<()> {
|
||||
let script = SystemPath::new("src/script.py");
|
||||
let dependency = SystemPath::new("dependencies/dependency.py");
|
||||
let source = dedent(
|
||||
r#"
|
||||
# /// script
|
||||
# [tool.ty.environment]
|
||||
# extra-paths = ["../dependencies"]
|
||||
# ///
|
||||
from dependency import value
|
||||
result: int = value
|
||||
"#,
|
||||
);
|
||||
|
||||
let mut server = TestServerBuilder::new()?
|
||||
.with_workspace(
|
||||
SystemPath::new("src"),
|
||||
Some(ClientOptions::default().with_diagnostic_mode(DiagnosticMode::Workspace)),
|
||||
)?
|
||||
.with_file(script, &source)?
|
||||
.with_file(dependency, "value = 1")?
|
||||
.with_watched_file_support(true)
|
||||
.enable_workspace_diagnostic_refresh(true)
|
||||
.enable_inlay_hints(true)
|
||||
.enable_inlay_hint_refresh(true)
|
||||
.build()
|
||||
.wait_until_workspaces_are_initialized();
|
||||
|
||||
// The initial registration covers both the project and the script's external search path.
|
||||
let (initial_id, watches) = watcher_registrations(&mut server)?;
|
||||
assert_json_snapshot!(watcher_snapshot(&watches), @r#"
|
||||
[
|
||||
"file://<temp_dir>/src :: **",
|
||||
"file://<temp_dir>/dependencies :: **"
|
||||
]
|
||||
"#);
|
||||
|
||||
server.open_text_document(script, &source, 1);
|
||||
assert_snapshot!(
|
||||
condensed_document_diagnostic_snapshot(server.document_diagnostic_request(script, None)),
|
||||
@""
|
||||
);
|
||||
|
||||
// Removing the extra path replaces the registration and leaves the dependency unwatched.
|
||||
let without_dependency =
|
||||
source.replace(r#"extra-paths = ["../dependencies"]"#, "extra-paths = []");
|
||||
server.change_text_document(
|
||||
script,
|
||||
vec![
|
||||
TextDocumentContentChangeEvent::TextDocumentContentChangeWholeDocument(
|
||||
TextDocumentContentChangeWholeDocument {
|
||||
text: without_dependency,
|
||||
},
|
||||
),
|
||||
],
|
||||
2,
|
||||
);
|
||||
let (project_id, watches) = watcher_registrations(&mut server)?;
|
||||
assert_json_snapshot!(watcher_snapshot(&watches), @r#"
|
||||
[
|
||||
"file://<temp_dir>/src :: **"
|
||||
]
|
||||
"#);
|
||||
assert_eq!(server.acknowledge_unregistration()?, initial_id);
|
||||
|
||||
// No file event reaches the server for this edit while the dependency is unwatched.
|
||||
server.write_file(dependency, "value = 'wrong'")?;
|
||||
server.change_text_document(
|
||||
script,
|
||||
vec![
|
||||
TextDocumentContentChangeEvent::TextDocumentContentChangeWholeDocument(
|
||||
TextDocumentContentChangeWholeDocument {
|
||||
text: source.into(),
|
||||
},
|
||||
),
|
||||
],
|
||||
3,
|
||||
);
|
||||
let (request_id, _, watches) = server.watcher_registration_request()?;
|
||||
assert_json_snapshot!(watcher_snapshot(&watches), @r#"
|
||||
[
|
||||
"file://<temp_dir>/src :: **",
|
||||
"file://<temp_dir>/dependencies :: **"
|
||||
]
|
||||
"#);
|
||||
assert_snapshot!(
|
||||
condensed_document_diagnostic_snapshot(server.document_diagnostic_request(script, None)),
|
||||
@""
|
||||
);
|
||||
|
||||
// Completing registration refreshes the known dependency and finds its missed edit.
|
||||
server.acknowledge_request(request_id);
|
||||
assert_eq!(server.acknowledge_unregistration()?, project_id);
|
||||
server.await_diagnostic_refresh();
|
||||
let (refresh_id, ()) = server.await_request::<InlayHintRefreshRequest>();
|
||||
server.acknowledge_request(refresh_id);
|
||||
assert_snapshot!(
|
||||
condensed_document_diagnostic_snapshot(server.document_diagnostic_request(script, None)),
|
||||
@r#"6:14..6:19[ERROR]: Object of type `Literal["wrong"]` is not assignable to `int`"#
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picks_up_edits_to_unwatched_pth_files() -> Result<()> {
|
||||
let main = SystemPath::new("src/main.py");
|
||||
let config = SystemPath::new("src/pyproject.toml");
|
||||
let source = dedent(
|
||||
r"
|
||||
from dependency import value
|
||||
result: int = value
|
||||
",
|
||||
)
|
||||
.trim_start()
|
||||
.to_owned();
|
||||
let project_config = dedent(
|
||||
r#"
|
||||
[tool.ty.environment]
|
||||
python = "../venv"
|
||||
"#,
|
||||
)
|
||||
.trim_start()
|
||||
.to_owned();
|
||||
|
||||
let (base_python, pth) = if cfg!(windows) {
|
||||
(
|
||||
"base/bin/python.exe",
|
||||
"venv/Lib/site-packages/dependency.pth",
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"base/bin/python",
|
||||
"venv/lib/python3.12/site-packages/dependency.pth",
|
||||
)
|
||||
};
|
||||
|
||||
let builder = TestServerBuilder::new()?.with_workspace(
|
||||
SystemPath::new("src"),
|
||||
Some(ClientOptions::default().with_diagnostic_mode(DiagnosticMode::Workspace)),
|
||||
)?;
|
||||
let python_home = builder.file_path("base/bin");
|
||||
let old_root = builder.file_path("old");
|
||||
|
||||
let mut server = builder
|
||||
.with_file(base_python, "")?
|
||||
.with_file(
|
||||
"venv/pyvenv.cfg",
|
||||
dedent(&format!(
|
||||
r"
|
||||
home = {python_home}
|
||||
version = 3.12
|
||||
"
|
||||
))
|
||||
.trim_start(),
|
||||
)?
|
||||
.with_file(pth, old_root.as_str())?
|
||||
.with_file("old/dependency.py", "value = 1")?
|
||||
.with_file("new/dependency.py", "value = 'wrong'")?
|
||||
.with_file(main, &source)?
|
||||
.with_file(config, &project_config)?
|
||||
.with_watched_file_support(true)
|
||||
.build()
|
||||
.wait_until_workspaces_are_initialized();
|
||||
|
||||
let (_, watches) = watcher_registrations(&mut server)?;
|
||||
assert_json_snapshot!(watcher_snapshot(&watches), @r#"
|
||||
[
|
||||
"file://<temp_dir>/src :: **",
|
||||
"file://<temp_dir>/old :: **",
|
||||
"file://<temp_dir>/venv/<site-packages> :: **"
|
||||
]
|
||||
"#);
|
||||
server.open_text_document(main, &source, 1);
|
||||
assert_snapshot!(
|
||||
condensed_document_diagnostic_snapshot(server.document_diagnostic_request(main, None)),
|
||||
@""
|
||||
);
|
||||
|
||||
// Switch to the default environment so the client stops watching site-packages.
|
||||
server.write_file(config, "[tool.ty]")?;
|
||||
server.did_change_watched_files(vec![lsp_types::FileEvent {
|
||||
uri: server.file_uri(config),
|
||||
kind: lsp_types::FileChangeType::Changed,
|
||||
}]);
|
||||
let (project_only_id, watches) = watcher_registrations(&mut server)?;
|
||||
assert_json_snapshot!(watcher_snapshot(&watches), @r#"
|
||||
[
|
||||
"file://<temp_dir>/src :: **"
|
||||
]
|
||||
"#);
|
||||
server.acknowledge_unregistration()?;
|
||||
|
||||
// The client doesn't report the `.pth` edit while site-packages is unwatched.
|
||||
let new = server.file_path("new");
|
||||
server.write_file(pth, new.as_str())?;
|
||||
|
||||
// Restoring the environment initially registers paths from the cached `.pth` contents.
|
||||
server.write_file(config, project_config)?;
|
||||
server.did_change_watched_files(vec![lsp_types::FileEvent {
|
||||
uri: server.file_uri(config),
|
||||
kind: lsp_types::FileChangeType::Changed,
|
||||
}]);
|
||||
let (request_id, stale_pth_id, watches) = server.watcher_registration_request()?;
|
||||
assert_json_snapshot!(watcher_snapshot(&watches), @r#"
|
||||
[
|
||||
"file://<temp_dir>/src :: **",
|
||||
"file://<temp_dir>/old :: **",
|
||||
"file://<temp_dir>/venv/<site-packages> :: **"
|
||||
]
|
||||
"#);
|
||||
server.acknowledge_request(request_id);
|
||||
assert_eq!(server.acknowledge_unregistration()?, project_only_id);
|
||||
|
||||
// Completing registration refreshes `.pth` and registers its new search path.
|
||||
let (_, watches) = watcher_registrations(&mut server)?;
|
||||
assert_json_snapshot!(watcher_snapshot(&watches), @r#"
|
||||
[
|
||||
"file://<temp_dir>/src :: **",
|
||||
"file://<temp_dir>/new :: **",
|
||||
"file://<temp_dir>/venv/<site-packages> :: **"
|
||||
]
|
||||
"#);
|
||||
assert_eq!(server.acknowledge_unregistration()?, stale_pth_id);
|
||||
|
||||
assert_snapshot!(
|
||||
condensed_document_diagnostic_snapshot(server.document_diagnostic_request(main, None)),
|
||||
@r#"1:14..1:19[ERROR]: Object of type `Literal["wrong"]` is not assignable to `int`"#
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_search_path_picks_up_missed_dependency_edit() -> Result<()> {
|
||||
let main = SystemPath::new("src/main.py");
|
||||
let config = SystemPath::new("src/pyproject.toml");
|
||||
let dependency = SystemPath::new("dependencies/pkg/dependency.py");
|
||||
let source = dedent(
|
||||
r"
|
||||
from pkg.dependency import value
|
||||
result: int = value
|
||||
",
|
||||
)
|
||||
.trim_start()
|
||||
.to_owned();
|
||||
|
||||
let mut server = TestServerBuilder::new()?
|
||||
.with_workspace(
|
||||
SystemPath::new("src"),
|
||||
Some(ClientOptions::default().with_diagnostic_mode(DiagnosticMode::Workspace)),
|
||||
)?
|
||||
.with_file(main, &source)?
|
||||
.with_file(
|
||||
config,
|
||||
dedent(
|
||||
r#"
|
||||
[tool.ty.environment]
|
||||
extra-paths = ["../dependencies"]
|
||||
"#,
|
||||
)
|
||||
.trim_start(),
|
||||
)?
|
||||
.with_file(dependency, "value = 1")?
|
||||
.with_watched_file_support(true)
|
||||
.build()
|
||||
.wait_until_workspaces_are_initialized();
|
||||
|
||||
let (_, watches) = watcher_registrations(&mut server)?;
|
||||
assert_json_snapshot!(watcher_snapshot(&watches), @r#"
|
||||
[
|
||||
"file://<temp_dir>/src :: **",
|
||||
"file://<temp_dir>/dependencies :: **"
|
||||
]
|
||||
"#);
|
||||
server.open_text_document(main, &source, 1);
|
||||
assert_snapshot!(
|
||||
condensed_document_diagnostic_snapshot(server.document_diagnostic_request(main, None)),
|
||||
@""
|
||||
);
|
||||
|
||||
// Remove the parent search path before changing the dependency on disk.
|
||||
server.write_file(config, "[tool.ty]")?;
|
||||
server.did_change_watched_files(vec![lsp_types::FileEvent {
|
||||
uri: server.file_uri(config),
|
||||
kind: lsp_types::FileChangeType::Changed,
|
||||
}]);
|
||||
let (_, watches) = watcher_registrations(&mut server)?;
|
||||
assert_json_snapshot!(watcher_snapshot(&watches), @r#"
|
||||
[
|
||||
"file://<temp_dir>/src :: **"
|
||||
]
|
||||
"#);
|
||||
server.acknowledge_unregistration()?;
|
||||
|
||||
// No file event reports the edit while the parent search path is unwatched.
|
||||
server.write_file(dependency, "value = 'wrong'")?;
|
||||
// Register the nested search path, which contains the previously read dependency.
|
||||
server.write_file(
|
||||
config,
|
||||
dedent(
|
||||
r#"
|
||||
[tool.ty.environment]
|
||||
extra-paths = ["../dependencies/pkg"]
|
||||
"#,
|
||||
)
|
||||
.trim_start(),
|
||||
)?;
|
||||
server.did_change_watched_files(vec![lsp_types::FileEvent {
|
||||
uri: server.file_uri(config),
|
||||
kind: lsp_types::FileChangeType::Changed,
|
||||
}]);
|
||||
let (_, watches) = watcher_registrations(&mut server)?;
|
||||
assert_json_snapshot!(watcher_snapshot(&watches), @r#"
|
||||
[
|
||||
"file://<temp_dir>/src :: **",
|
||||
"file://<temp_dir>/dependencies/pkg :: **"
|
||||
]
|
||||
"#);
|
||||
server.acknowledge_unregistration()?;
|
||||
|
||||
// The new search root exposes the same file as `dependency` instead of `pkg.dependency`.
|
||||
// Reimport it to check that the edit missed while it was unwatched has been refreshed.
|
||||
server.change_text_document(
|
||||
main,
|
||||
vec![
|
||||
TextDocumentContentChangeEvent::TextDocumentContentChangeWholeDocument(
|
||||
TextDocumentContentChangeWholeDocument {
|
||||
text: dedent(
|
||||
r"
|
||||
from dependency import value
|
||||
result: int = value
|
||||
",
|
||||
)
|
||||
.trim_start()
|
||||
.to_owned(),
|
||||
},
|
||||
),
|
||||
],
|
||||
2,
|
||||
);
|
||||
assert_snapshot!(
|
||||
condensed_document_diagnostic_snapshot(server.document_diagnostic_request(main, None)),
|
||||
@r#"1:14..1:19[ERROR]: Object of type `Literal["wrong"]` is not assignable to `int`"#
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watches_project_without_relative_patterns() -> Result<()> {
|
||||
let main = SystemPath::new("src/main.py");
|
||||
let dependency = SystemPath::new("src/dependency.py");
|
||||
let source = dedent(
|
||||
r"
|
||||
from dependency import value
|
||||
result: int = value
|
||||
",
|
||||
)
|
||||
.trim_start()
|
||||
.to_owned();
|
||||
|
||||
let mut server = TestServerBuilder::new()?
|
||||
.with_workspace(
|
||||
SystemPath::new("src"),
|
||||
Some(ClientOptions::default().with_diagnostic_mode(DiagnosticMode::Workspace)),
|
||||
)?
|
||||
.with_file(main, &source)?
|
||||
.with_file(dependency, "value = 1")?
|
||||
.with_watched_file_support(false)
|
||||
.build()
|
||||
.wait_until_workspaces_are_initialized();
|
||||
|
||||
// A client without relative patterns receives one workspace-wide glob.
|
||||
let (_, watches) = watcher_registrations(&mut server)?;
|
||||
assert_json_snapshot!(watcher_snapshot(&watches), @r#"
|
||||
[
|
||||
"**"
|
||||
]
|
||||
"#);
|
||||
|
||||
server.open_text_document(main, &source, 1);
|
||||
server.write_file(dependency, "value = 'wrong'")?;
|
||||
server.did_change_watched_files(vec![lsp_types::FileEvent {
|
||||
uri: server.file_uri(dependency),
|
||||
kind: lsp_types::FileChangeType::Changed,
|
||||
}]);
|
||||
assert_snapshot!(
|
||||
condensed_document_diagnostic_snapshot(server.document_diagnostic_request(main, None)),
|
||||
@r#"1:14..1:19[ERROR]: Object of type `Literal["wrong"]` is not assignable to `int`"#
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn watcher_registrations(server: &mut TestServer) -> Result<(String, Vec<FileSystemWatcher>)> {
|
||||
let (request_id, registration_id, watchers) = server.watcher_registration_request()?;
|
||||
server.acknowledge_request(request_id);
|
||||
Ok((registration_id, watchers))
|
||||
}
|
||||
|
||||
fn watcher_snapshot(watchers: &[FileSystemWatcher]) -> Vec<String> {
|
||||
watchers
|
||||
.iter()
|
||||
.map(|watcher| {
|
||||
let pattern = match &watcher.glob_pattern {
|
||||
GlobPattern::Pattern(pattern) => pattern.clone(),
|
||||
GlobPattern::RelativePattern(relative) => match &relative.base_uri {
|
||||
BaseUri::Uri(uri) => format!("{uri} :: {}", relative.pattern),
|
||||
BaseUri::WorkspaceFolder(folder) => {
|
||||
format!("workspace {} :: {}", folder.uri, relative.pattern)
|
||||
}
|
||||
},
|
||||
};
|
||||
// The fixture's virtual environment has a different site-packages layout on Windows.
|
||||
let pattern = pattern
|
||||
.replace("/venv/Lib/site-packages", "/venv/<site-packages>")
|
||||
.replace(
|
||||
"/venv/lib/python3.12/site-packages",
|
||||
"/venv/<site-packages>",
|
||||
);
|
||||
if let Some(kind) = watcher.kind {
|
||||
format!("{pattern} [{kind:?}]")
|
||||
} else {
|
||||
pattern
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -59,17 +59,17 @@ from dependency import script_only
|
||||
|
||||
#[cfg(feature = "test-uv")]
|
||||
mod uv_metadata {
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use anyhow::{Context, Result, anyhow, ensure};
|
||||
use lsp_types::{
|
||||
Code, Definition, DefinitionResponse, FileChangeType, FileEvent, Position,
|
||||
TextDocumentContentChangeEvent, TextDocumentContentChangeWholeDocument,
|
||||
BaseUri, Code, Definition, DefinitionResponse, FileChangeType, FileEvent, GlobPattern,
|
||||
Position, TextDocumentContentChangeEvent, TextDocumentContentChangeWholeDocument, Uri,
|
||||
WorkspaceDocumentDiagnosticReport,
|
||||
};
|
||||
use ruff_db::system::{SystemPath, SystemPathBuf};
|
||||
use ty_project::UseUv;
|
||||
use ty_server::{ClientOptions, DiagnosticMode};
|
||||
|
||||
use crate::TestServerBuilder;
|
||||
use crate::{TestServer, TestServerBuilder};
|
||||
|
||||
#[test]
|
||||
fn synchronization_reports_progress_before_resolving_dependency_definitions() -> Result<()> {
|
||||
@@ -222,12 +222,14 @@ from idna import encode
|
||||
.with_workspace(workspace_root, None)?
|
||||
.with_file(script, initial)?
|
||||
.with_real_uv(UseUv::Scripts)?
|
||||
.with_watched_file_support(true)
|
||||
.enable_workspace_diagnostic_refresh(true)
|
||||
.build()
|
||||
.wait_until_workspaces_are_initialized();
|
||||
|
||||
server.open_text_document(script, initial, 1);
|
||||
server.await_diagnostic_refresh();
|
||||
let (_, site_packages) = watched_site_packages(&mut server)?;
|
||||
|
||||
server.change_text_document(
|
||||
script,
|
||||
@@ -268,6 +270,7 @@ from idna import encode
|
||||
server.save_text_document(script);
|
||||
|
||||
server.await_diagnostic_refresh();
|
||||
installed_package_event(&mut server, &site_packages, "idna")?;
|
||||
|
||||
assert!(
|
||||
server
|
||||
@@ -298,6 +301,7 @@ from attrs import define
|
||||
.with_workspace(SystemPath::new("src"), None)?
|
||||
.with_file(script, initial)?
|
||||
.with_real_uv(UseUv::Scripts)?
|
||||
.with_watched_file_support(true)
|
||||
.enable_workspace_diagnostic_refresh(true)
|
||||
.build()
|
||||
.wait_until_workspaces_are_initialized();
|
||||
@@ -305,6 +309,7 @@ from attrs import define
|
||||
// Opening synchronizes the backing file, not the unsaved metadata.
|
||||
server.open_text_document(script, updated, 1);
|
||||
server.await_diagnostic_refresh();
|
||||
let (_, site_packages) = watched_site_packages(&mut server)?;
|
||||
assert!(
|
||||
server
|
||||
.goto_definition_request(script, Position::new(4, 18))
|
||||
@@ -315,6 +320,7 @@ from attrs import define
|
||||
server.write_file(script, updated)?;
|
||||
server.save_text_document(script);
|
||||
server.await_diagnostic_refresh();
|
||||
installed_package_event(&mut server, &site_packages, "attrs")?;
|
||||
assert!(
|
||||
server
|
||||
.goto_definition_request(script, Position::new(4, 18))
|
||||
@@ -395,12 +401,14 @@ from attrs import define
|
||||
.with_workspace(workspace_root, None)?
|
||||
.with_file(script, initial)?
|
||||
.with_real_uv(UseUv::Scripts)?
|
||||
.with_watched_file_support(true)
|
||||
.enable_workspace_diagnostic_refresh(true)
|
||||
.build()
|
||||
.wait_until_workspaces_are_initialized();
|
||||
|
||||
server.open_text_document(script, initial, 1);
|
||||
server.await_diagnostic_refresh();
|
||||
let (mut registration_id, site_packages) = watched_site_packages(&mut server)?;
|
||||
|
||||
assert!(
|
||||
server
|
||||
@@ -445,8 +453,19 @@ from attrs import define
|
||||
server.write_file(script, updated)?;
|
||||
server.save_text_document(script);
|
||||
server.await_diagnostic_refresh();
|
||||
|
||||
// Invalid metadata removes the environment watch; correcting it restores the watch.
|
||||
let (request_id, next_id, _) = server.watcher_registration_request()?;
|
||||
server.acknowledge_request(request_id);
|
||||
ensure!(server.acknowledge_unregistration()? == registration_id);
|
||||
registration_id = next_id;
|
||||
}
|
||||
|
||||
// Restoring the environment rescans its previously unwatched dependencies.
|
||||
server.await_diagnostic_refresh();
|
||||
|
||||
installed_package_event(&mut server, &site_packages, "attrs")?;
|
||||
|
||||
assert!(
|
||||
server
|
||||
.goto_definition_request(script, Position::new(4, 18))
|
||||
@@ -507,4 +526,53 @@ from idna import encode
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn watched_site_packages(server: &mut TestServer) -> Result<(String, Uri)> {
|
||||
let mut previous_id = None;
|
||||
// uv may finish after the initial project-only watch was requested.
|
||||
for _ in 0..2 {
|
||||
let (request_id, id, watchers) = server.watcher_registration_request()?;
|
||||
let site_packages = watchers.iter().find_map(|watcher| {
|
||||
if let GlobPattern::RelativePattern(pattern) = &watcher.glob_pattern
|
||||
&& let BaseUri::Uri(uri) = &pattern.base_uri
|
||||
&& uri.to_string().ends_with("/site-packages")
|
||||
{
|
||||
Some(uri.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
server.acknowledge_request(request_id);
|
||||
if let Some(previous_id) = previous_id {
|
||||
ensure!(server.acknowledge_unregistration()? == previous_id);
|
||||
server.await_diagnostic_refresh();
|
||||
}
|
||||
if let Some(site_packages) = site_packages {
|
||||
return Ok((id, site_packages));
|
||||
}
|
||||
previous_id = Some(id);
|
||||
}
|
||||
Err(anyhow!("expected a watcher for the script's site-packages"))
|
||||
}
|
||||
|
||||
fn installed_package_event(
|
||||
server: &mut TestServer,
|
||||
site_packages: &Uri,
|
||||
package: &str,
|
||||
) -> Result<()> {
|
||||
let directory = Uri::parse(&format!("{site_packages}/{package}"))?;
|
||||
let uri = Uri::parse(&format!("{site_packages}/{package}/__init__.py"))?;
|
||||
server.did_change_watched_files(vec![
|
||||
FileEvent {
|
||||
uri: directory,
|
||||
kind: FileChangeType::Created,
|
||||
},
|
||||
FileEvent {
|
||||
uri,
|
||||
kind: FileChangeType::Created,
|
||||
},
|
||||
]);
|
||||
server.await_diagnostic_refresh();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ mod code_actions;
|
||||
mod commands;
|
||||
mod completions;
|
||||
mod configuration;
|
||||
mod file_watching;
|
||||
mod folding_range;
|
||||
mod goto_definition;
|
||||
mod hover;
|
||||
@@ -65,18 +66,20 @@ use lsp_types::{
|
||||
DefinitionRequest, DefinitionResponse, DiagnosticClientCapabilities,
|
||||
DidChangeTextDocumentNotification, DidChangeTextDocumentParams,
|
||||
DidChangeWatchedFilesClientCapabilities, DidChangeWatchedFilesNotification,
|
||||
DidChangeWatchedFilesParams, DidChangeWorkspaceFoldersNotification,
|
||||
DidChangeWorkspaceFoldersParams, DidCloseTextDocumentNotification, DidCloseTextDocumentParams,
|
||||
DidOpenTextDocumentNotification, DidOpenTextDocumentParams, DidSaveTextDocumentNotification,
|
||||
DidSaveTextDocumentParams, DocumentDiagnosticParams, DocumentDiagnosticReport,
|
||||
DocumentDiagnosticRequest, ExitNotification, FileEvent, FoldingRange, FoldingRangeParams,
|
||||
Hover, HoverParams, HoverRequest, InitializeParams, InitializeRequest, InitializeResult,
|
||||
DidChangeWatchedFilesParams, DidChangeWatchedFilesRegistrationOptions,
|
||||
DidChangeWorkspaceFoldersNotification, DidChangeWorkspaceFoldersParams,
|
||||
DidCloseTextDocumentNotification, DidCloseTextDocumentParams, DidOpenTextDocumentNotification,
|
||||
DidOpenTextDocumentParams, DidSaveTextDocumentNotification, DidSaveTextDocumentParams,
|
||||
DocumentDiagnosticParams, DocumentDiagnosticReport, DocumentDiagnosticRequest,
|
||||
ExitNotification, FileEvent, FileSystemWatcher, FoldingRange, FoldingRangeParams, Hover,
|
||||
HoverParams, HoverRequest, InitializeParams, InitializeRequest, InitializeResult,
|
||||
InitializedNotification, InitializedParams, InlayHint, InlayHintClientCapabilities,
|
||||
InlayHintParams, InlayHintRequest, LanguageKind, Notification, PartialResultParams, Position,
|
||||
PrepareRenameRequest, PreviousResultId, PublishDiagnosticsClientCapabilities, Range, Request,
|
||||
SemanticTokens, ShutdownRequest, SignatureHelp, SignatureHelpParams, SignatureHelpRequest,
|
||||
SignatureHelpTriggerKind, TextDocumentClientCapabilities, TextDocumentContentChangeEvent,
|
||||
TextDocumentIdentifier, TextDocumentItem, TextDocumentPositionParams, Uri,
|
||||
PrepareRenameRequest, PreviousResultId, PublishDiagnosticsClientCapabilities, Range,
|
||||
RegistrationRequest, Request, SemanticTokens, ShutdownRequest, SignatureHelp,
|
||||
SignatureHelpParams, SignatureHelpRequest, SignatureHelpTriggerKind,
|
||||
TextDocumentClientCapabilities, TextDocumentContentChangeEvent, TextDocumentIdentifier,
|
||||
TextDocumentItem, TextDocumentPositionParams, UnregistrationRequest, Uri,
|
||||
VersionedTextDocumentIdentifier, WorkDoneProgressParams, WorkspaceClientCapabilities,
|
||||
WorkspaceDiagnosticParams, WorkspaceDiagnosticReport, WorkspaceDiagnosticRequest,
|
||||
WorkspaceEdit, WorkspaceFolder, WorkspaceFoldersChangeEvent, WorkspaceFoldersInitializeParams,
|
||||
@@ -607,6 +610,42 @@ impl TestServer {
|
||||
self.send(Message::Response(Response::new_ok(id, ())));
|
||||
}
|
||||
|
||||
/// Acknowledge a successful server-to-client request.
|
||||
pub(crate) fn acknowledge_request(&mut self, id: RequestId) {
|
||||
self.send(Message::Response(Response::new_ok(id, ())));
|
||||
}
|
||||
|
||||
pub(crate) fn watcher_registration_request(
|
||||
&mut self,
|
||||
) -> Result<(RequestId, String, Vec<FileSystemWatcher>)> {
|
||||
let (request_id, params) = self.await_request::<RegistrationRequest>();
|
||||
let [registration] = params.registrations.as_slice() else {
|
||||
anyhow::bail!("expected exactly one file watcher registration");
|
||||
};
|
||||
anyhow::ensure!(
|
||||
registration.method == "workspace/didChangeWatchedFiles",
|
||||
"unexpected registration method: {}",
|
||||
registration.method
|
||||
);
|
||||
let options: DidChangeWatchedFilesRegistrationOptions = serde_json::from_value(
|
||||
registration
|
||||
.register_options
|
||||
.clone()
|
||||
.context("expected file watcher options")?,
|
||||
)?;
|
||||
Ok((request_id, registration.id.clone(), options.watchers))
|
||||
}
|
||||
|
||||
pub(crate) fn acknowledge_unregistration(&mut self) -> Result<String> {
|
||||
let (request_id, params) = self.await_request::<UnregistrationRequest>();
|
||||
let [unregistration] = params.unregisterations.as_slice() else {
|
||||
anyhow::bail!("expected exactly one unregistration");
|
||||
};
|
||||
let registration_id = unregistration.id.clone();
|
||||
self.acknowledge_request(request_id);
|
||||
Ok(registration_id)
|
||||
}
|
||||
|
||||
/// Checks server-created progress with matching begin, report, and end notifications.
|
||||
#[cfg(feature = "test-uv")]
|
||||
#[track_caller]
|
||||
@@ -1421,6 +1460,17 @@ impl TestServerBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
/// Enable server-requested refreshes for inlay hints.
|
||||
pub(crate) fn enable_inlay_hint_refresh(mut self, enabled: bool) -> Self {
|
||||
self.client_capabilities
|
||||
.workspace
|
||||
.get_or_insert_default()
|
||||
.inlay_hint
|
||||
.get_or_insert_default()
|
||||
.refresh_support = Some(enabled);
|
||||
self
|
||||
}
|
||||
|
||||
/// Enable or disable the completion snippet capability.
|
||||
pub(crate) fn enable_completion_snippets(mut self, enabled: bool) -> Self {
|
||||
self.client_capabilities
|
||||
@@ -1434,17 +1484,15 @@ impl TestServerBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
/// Enable or disable file watching capability
|
||||
#[expect(dead_code)]
|
||||
pub(crate) fn enable_did_change_watched_files(mut self, enabled: bool) -> Self {
|
||||
/// Enable dynamic file watching, optionally with relative patterns.
|
||||
pub(crate) fn with_watched_file_support(mut self, relative_pattern_support: bool) -> Self {
|
||||
self.client_capabilities
|
||||
.workspace
|
||||
.get_or_insert_default()
|
||||
.did_change_watched_files = if enabled {
|
||||
Some(DidChangeWatchedFilesClientCapabilities::default())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
.did_change_watched_files = Some(DidChangeWatchedFilesClientCapabilities {
|
||||
dynamic_registration: Some(true),
|
||||
relative_pattern_support: Some(relative_pattern_support),
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user