Skip to content

Commit 90e2ae9

Browse files
dhruvmanilaAlexWaygood
authored andcommitted
[ty] Implement mock language server for testing (#19391)
## Summary Closes: astral-sh/ty#88 This PR implements an initial version of a mock language server that can be used to write e2e tests using the real server running in the background. The way it works is that you'd use the `TestServerBuilder` to help construct the `TestServer` with the setup data. This could be the workspace folders, populating the file and it's content in the memory file system, setting the right client capabilities to make the server respond correctly, etc. This can be expanded as we write more test cases. There are still a few things to follow-up on: - ~In the `Drop` implementation, we should assert that there are no pending notification, request and responses from the server that the test code hasn't handled yet~ Implemented in [`afd1f82` (#19391)](afd1f82) - Reduce the setup boilerplate in any way we can - Improve the final assertion, currently I'm just snapshotting the final output ## Test Plan Written a few test cases.
1 parent 018c0e8 commit 90e2ae9

12 files changed

Lines changed: 1430 additions & 51 deletions

Cargo.lock

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/ty_server/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ tracing = { workspace = true }
3838
tracing-subscriber = { workspace = true, features = ["chrono"] }
3939

4040
[dev-dependencies]
41+
dunce = { workspace = true }
42+
insta = { workspace = true, features = ["filters", "json"] }
43+
regex = { workspace = true }
44+
tempfile = { workspace = true }
4145

4246
[target.'cfg(target_vendor = "apple")'.dependencies]
4347
libc = { workspace = true }

crates/ty_server/src/lib.rs

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
use std::num::NonZeroUsize;
1+
use std::{num::NonZeroUsize, sync::Arc};
22

33
use anyhow::Context;
44
use lsp_server::Connection;
5+
use ruff_db::system::{OsSystem, SystemPathBuf};
56

67
use crate::server::Server;
78
pub use document::{NotebookDocument, PositionEncoding, TextDocument};
@@ -13,6 +14,9 @@ mod server;
1314
mod session;
1415
mod system;
1516

17+
#[cfg(test)]
18+
pub mod test;
19+
1620
pub(crate) const SERVER_NAME: &str = "ty";
1721
pub(crate) const DIAGNOSTIC_NAME: &str = "ty";
1822

@@ -30,7 +34,21 @@ pub fn run_server() -> anyhow::Result<()> {
3034

3135
let (connection, io_threads) = Connection::stdio();
3236

33-
let server_result = Server::new(worker_threads, connection)
37+
let cwd = {
38+
let cwd = std::env::current_dir().context("Failed to get the current working directory")?;
39+
SystemPathBuf::from_path_buf(cwd).map_err(|path| {
40+
anyhow::anyhow!(
41+
"The current working directory `{}` contains non-Unicode characters. \
42+
ty only supports Unicode paths.",
43+
path.display()
44+
)
45+
})?
46+
};
47+
48+
// This is to complement the `LSPSystem` if the document is not available in the index.
49+
let fallback_system = Arc::new(OsSystem::new(cwd));
50+
51+
let server_result = Server::new(worker_threads, connection, fallback_system, true)
3452
.context("Failed to start server")?
3553
.run();
3654

crates/ty_server/src/server.rs

Lines changed: 107 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@ use lsp_types::{
1111
ServerCapabilities, SignatureHelpOptions, TextDocumentSyncCapability, TextDocumentSyncKind,
1212
TextDocumentSyncOptions, TypeDefinitionProviderCapability, Url, WorkDoneProgressOptions,
1313
};
14+
use ruff_db::system::System;
1415
use std::num::NonZeroUsize;
15-
use std::panic::PanicHookInfo;
16+
use std::panic::{PanicHookInfo, RefUnwindSafe};
1617
use std::sync::Arc;
1718

1819
mod api;
@@ -35,7 +36,12 @@ pub(crate) struct Server {
3536
}
3637

3738
impl Server {
38-
pub(crate) fn new(worker_threads: NonZeroUsize, connection: Connection) -> crate::Result<Self> {
39+
pub(crate) fn new(
40+
worker_threads: NonZeroUsize,
41+
connection: Connection,
42+
native_system: Arc<dyn System + 'static + Send + Sync + RefUnwindSafe>,
43+
initialize_logging: bool,
44+
) -> crate::Result<Self> {
3945
let (id, init_value) = connection.initialize_start()?;
4046
let init_params: InitializeParams = serde_json::from_value(init_value)?;
4147

@@ -71,10 +77,12 @@ impl Server {
7177
let (main_loop_sender, main_loop_receiver) = crossbeam::channel::bounded(32);
7278
let client = Client::new(main_loop_sender.clone(), connection.sender.clone());
7379

74-
crate::logging::init_logging(
75-
global_options.tracing.log_level.unwrap_or_default(),
76-
global_options.tracing.log_file.as_deref(),
77-
);
80+
if initialize_logging {
81+
crate::logging::init_logging(
82+
global_options.tracing.log_level.unwrap_or_default(),
83+
global_options.tracing.log_file.as_deref(),
84+
);
85+
}
7886

7987
tracing::debug!("Version: {version}");
8088

@@ -102,10 +110,14 @@ impl Server {
102110
.collect()
103111
})
104112
.or_else(|| {
105-
let current_dir = std::env::current_dir().ok()?;
113+
let current_dir = native_system
114+
.current_directory()
115+
.as_std_path()
116+
.to_path_buf();
106117
tracing::warn!(
107118
"No workspace(s) were provided during initialization. \
108-
Using the current working directory as a default workspace: {}",
119+
Using the current working directory from the fallback system as a \
120+
default workspace: {}",
109121
current_dir.display()
110122
);
111123
let uri = Url::from_file_path(current_dir).ok()?;
@@ -143,6 +155,7 @@ impl Server {
143155
position_encoding,
144156
global_options,
145157
workspaces,
158+
native_system,
146159
)?,
147160
client_capabilities,
148161
})
@@ -288,3 +301,89 @@ impl Drop for ServerPanicHookHandler {
288301
}
289302
}
290303
}
304+
305+
#[cfg(test)]
306+
mod tests {
307+
use anyhow::Result;
308+
use lsp_types::notification::PublishDiagnostics;
309+
use ruff_db::system::SystemPath;
310+
311+
use crate::session::ClientOptions;
312+
use crate::test::TestServerBuilder;
313+
314+
#[test]
315+
fn initialization() -> Result<()> {
316+
let server = TestServerBuilder::new()?
317+
.build()?
318+
.wait_until_workspaces_are_initialized()?;
319+
320+
let initialization_result = server.initialization_result().unwrap();
321+
322+
insta::assert_json_snapshot!("initialization", initialization_result);
323+
324+
Ok(())
325+
}
326+
327+
#[test]
328+
fn initialization_with_workspace() -> Result<()> {
329+
let workspace_root = SystemPath::new("foo");
330+
let server = TestServerBuilder::new()?
331+
.with_workspace(workspace_root, ClientOptions::default())?
332+
.build()?
333+
.wait_until_workspaces_are_initialized()?;
334+
335+
let initialization_result = server.initialization_result().unwrap();
336+
337+
insta::assert_json_snapshot!("initialization_with_workspace", initialization_result);
338+
339+
Ok(())
340+
}
341+
342+
#[test]
343+
fn publish_diagnostics_on_did_open() -> Result<()> {
344+
let workspace_root = SystemPath::new("src");
345+
let foo = SystemPath::new("src/foo.py");
346+
let foo_content = "\
347+
def foo() -> str:
348+
return 42
349+
";
350+
351+
let mut server = TestServerBuilder::new()?
352+
.with_workspace(workspace_root, ClientOptions::default())?
353+
.with_file(foo, foo_content)?
354+
.enable_pull_diagnostics(false)
355+
.build()?
356+
.wait_until_workspaces_are_initialized()?;
357+
358+
server.open_text_document(foo, &foo_content, 1);
359+
let diagnostics = server.await_notification::<PublishDiagnostics>()?;
360+
361+
insta::assert_debug_snapshot!(diagnostics);
362+
363+
Ok(())
364+
}
365+
366+
#[test]
367+
fn pull_diagnostics_on_did_open() -> Result<()> {
368+
let workspace_root = SystemPath::new("src");
369+
let foo = SystemPath::new("src/foo.py");
370+
let foo_content = "\
371+
def foo() -> str:
372+
return 42
373+
";
374+
375+
let mut server = TestServerBuilder::new()?
376+
.with_workspace(workspace_root, ClientOptions::default())?
377+
.with_file(foo, foo_content)?
378+
.enable_pull_diagnostics(true)
379+
.build()?
380+
.wait_until_workspaces_are_initialized()?;
381+
382+
server.open_text_document(foo, &foo_content, 1);
383+
let diagnostics = server.document_diagnostic_request(foo)?;
384+
385+
insta::assert_debug_snapshot!(diagnostics);
386+
387+
Ok(())
388+
}
389+
}

crates/ty_server/src/session.rs

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,14 @@
22
33
use std::collections::{BTreeMap, VecDeque};
44
use std::ops::{Deref, DerefMut};
5+
use std::panic::RefUnwindSafe;
56
use std::sync::Arc;
67

78
use anyhow::{Context, anyhow};
89
use index::DocumentQueryError;
910
use lsp_server::Message;
11+
use lsp_types::notification::{Exit, Notification};
12+
use lsp_types::request::{Request, Shutdown};
1013
use lsp_types::{ClientCapabilities, TextDocumentContentChangeEvent, Url};
1114
use options::GlobalOptions;
1215
use ruff_db::Db;
@@ -37,6 +40,9 @@ mod settings;
3740

3841
/// The global state for the LSP
3942
pub(crate) struct Session {
43+
/// A native system to use with the [`LSPSystem`].
44+
native_system: Arc<dyn System + 'static + Send + Sync + RefUnwindSafe>,
45+
4046
/// Used to retrieve information about open documents and settings.
4147
///
4248
/// This will be [`None`] when a mutable reference is held to the index via [`index_mut`]
@@ -99,6 +105,7 @@ impl Session {
99105
position_encoding: PositionEncoding,
100106
global_options: GlobalOptions,
101107
workspace_folders: Vec<(Url, ClientOptions)>,
108+
native_system: Arc<dyn System + 'static + Send + Sync + RefUnwindSafe>,
102109
) -> crate::Result<Self> {
103110
let index = Arc::new(Index::new(global_options.into_settings()));
104111

@@ -108,6 +115,7 @@ impl Session {
108115
}
109116

110117
Ok(Self {
118+
native_system,
111119
position_encoding,
112120
workspaces,
113121
deferred_messages: VecDeque::new(),
@@ -155,6 +163,9 @@ impl Session {
155163
} else {
156164
match &message {
157165
Message::Request(request) => {
166+
if request.method == Shutdown::METHOD {
167+
return Some(message);
168+
}
158169
tracing::debug!(
159170
"Deferring `{}` request until all workspaces are initialized",
160171
request.method
@@ -165,6 +176,9 @@ impl Session {
165176
return Some(message);
166177
}
167178
Message::Notification(notification) => {
179+
if notification.method == Exit::METHOD {
180+
return Some(message);
181+
}
168182
tracing::debug!(
169183
"Deferring `{}` notification until all workspaces are initialized",
170184
notification.method
@@ -218,9 +232,12 @@ impl Session {
218232
/// If the path is a virtual path, it will return the first project database in the session.
219233
pub(crate) fn project_state(&self, path: &AnySystemPath) -> &ProjectState {
220234
match path {
221-
AnySystemPath::System(system_path) => self
222-
.project_state_for_path(system_path)
223-
.unwrap_or_else(|| self.default_project.get(self.index.as_ref())),
235+
AnySystemPath::System(system_path) => {
236+
self.project_state_for_path(system_path).unwrap_or_else(|| {
237+
self.default_project
238+
.get(self.index.as_ref(), &self.native_system)
239+
})
240+
}
224241
AnySystemPath::SystemVirtual(_virtual_path) => {
225242
// TODO: Currently, ty only supports single workspace but we need to figure out
226243
// which project should this virtual path belong to when there are multiple
@@ -247,7 +264,10 @@ impl Session {
247264
.range_mut(..=system_path.to_path_buf())
248265
.next_back()
249266
.map(|(_, project)| project)
250-
.unwrap_or_else(|| self.default_project.get_mut(self.index.as_ref())),
267+
.unwrap_or_else(|| {
268+
self.default_project
269+
.get_mut(self.index.as_ref(), &self.native_system)
270+
}),
251271
AnySystemPath::SystemVirtual(_virtual_path) => {
252272
// TODO: Currently, ty only supports single workspace but we need to figure out
253273
// which project should this virtual path belong to when there are multiple
@@ -330,7 +350,10 @@ impl Session {
330350
// For now, create one project database per workspace.
331351
// In the future, index the workspace directories to find all projects
332352
// and create a project database for each.
333-
let system = LSPSystem::new(self.index.as_ref().unwrap().clone());
353+
let system = LSPSystem::new(
354+
self.index.as_ref().unwrap().clone(),
355+
self.native_system.clone(),
356+
);
334357

335358
let project = ProjectMetadata::discover(&root, &system)
336359
.context("Failed to discover project configuration")
@@ -748,12 +771,16 @@ impl DefaultProject {
748771
DefaultProject(std::sync::OnceLock::new())
749772
}
750773

751-
pub(crate) fn get(&self, index: Option<&Arc<Index>>) -> &ProjectState {
774+
pub(crate) fn get(
775+
&self,
776+
index: Option<&Arc<Index>>,
777+
fallback_system: &Arc<dyn System + 'static + Send + Sync + RefUnwindSafe>,
778+
) -> &ProjectState {
752779
self.0.get_or_init(|| {
753780
tracing::info!("Initializing the default project");
754781

755782
let index = index.unwrap();
756-
let system = LSPSystem::new(index.clone());
783+
let system = LSPSystem::new(index.clone(), fallback_system.clone());
757784
let metadata = ProjectMetadata::from_options(
758785
Options::default(),
759786
system.current_directory().to_path_buf(),
@@ -771,8 +798,12 @@ impl DefaultProject {
771798
})
772799
}
773800

774-
pub(crate) fn get_mut(&mut self, index: Option<&Arc<Index>>) -> &mut ProjectState {
775-
let _ = self.get(index);
801+
pub(crate) fn get_mut(
802+
&mut self,
803+
index: Option<&Arc<Index>>,
804+
fallback_system: &Arc<dyn System + 'static + Send + Sync + RefUnwindSafe>,
805+
) -> &mut ProjectState {
806+
let _ = self.get(index, fallback_system);
776807

777808
// SAFETY: The `OnceLock` is guaranteed to be initialized at this point because
778809
// we called `get` above, which initializes it if it wasn't already.

0 commit comments

Comments
 (0)