Skip to content

Commit a28f5c2

Browse files
committed
[ty] Implement mock language server for testing
1 parent f3a2740 commit a28f5c2

File tree

11 files changed

+958
-90
lines changed

11 files changed

+958
-90
lines changed

Cargo.lock

Lines changed: 1 addition & 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: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ tracing = { workspace = true }
3737
tracing-subscriber = { workspace = true, features = ["chrono"] }
3838

3939
[dev-dependencies]
40+
insta = { workspace = true, features = ["json"] }
4041

4142
[target.'cfg(target_vendor = "apple")'.dependencies]
4243
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)
3452
.context("Failed to start server")?
3553
.run();
3654

crates/ty_server/src/logging.rs

Lines changed: 47 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
//! are written to `stderr` by default, which should appear in the logs for most LSP clients. A
55
//! `logFile` path can also be specified in the settings, and output will be directed there
66
//! instead.
7-
use std::sync::Arc;
7+
use std::sync::{Arc, Once};
88

99
use ruff_db::system::{SystemPath, SystemPathBuf};
1010
use serde::Deserialize;
@@ -14,51 +14,55 @@ use tracing_subscriber::fmt::time::ChronoLocal;
1414
use tracing_subscriber::fmt::writer::BoxMakeWriter;
1515
use tracing_subscriber::layer::SubscriberExt;
1616

17+
static INIT_LOGGING: Once = Once::new();
18+
1719
pub(crate) fn init_logging(log_level: LogLevel, log_file: Option<&SystemPath>) {
18-
let log_file = log_file
19-
.map(|path| {
20-
// this expands `logFile` so that tildes and environment variables
21-
// are replaced with their values, if possible.
22-
if let Some(expanded) = shellexpand::full(&path.to_string())
23-
.ok()
24-
.map(|path| SystemPathBuf::from(&*path))
25-
{
26-
expanded
27-
} else {
28-
path.to_path_buf()
29-
}
30-
})
31-
.and_then(|path| {
32-
std::fs::OpenOptions::new()
33-
.create(true)
34-
.append(true)
35-
.open(path.as_std_path())
36-
.map_err(|err| {
37-
#[expect(clippy::print_stderr)]
38-
{
39-
eprintln!("Failed to open file at {path} for logging: {err}");
40-
}
41-
})
42-
.ok()
43-
});
20+
INIT_LOGGING.call_once(|| {
21+
let log_file = log_file
22+
.map(|path| {
23+
// this expands `logFile` so that tildes and environment variables
24+
// are replaced with their values, if possible.
25+
if let Some(expanded) = shellexpand::full(&path.to_string())
26+
.ok()
27+
.map(|path| SystemPathBuf::from(&*path))
28+
{
29+
expanded
30+
} else {
31+
path.to_path_buf()
32+
}
33+
})
34+
.and_then(|path| {
35+
std::fs::OpenOptions::new()
36+
.create(true)
37+
.append(true)
38+
.open(path.as_std_path())
39+
.map_err(|err| {
40+
#[expect(clippy::print_stderr)]
41+
{
42+
eprintln!("Failed to open file at {path} for logging: {err}");
43+
}
44+
})
45+
.ok()
46+
});
4447

45-
let logger = match log_file {
46-
Some(file) => BoxMakeWriter::new(Arc::new(file)),
47-
None => BoxMakeWriter::new(std::io::stderr),
48-
};
49-
let is_trace_level = log_level == LogLevel::Trace;
50-
let subscriber = tracing_subscriber::Registry::default().with(
51-
tracing_subscriber::fmt::layer()
52-
.with_timer(ChronoLocal::new("%Y-%m-%d %H:%M:%S.%f".to_string()))
53-
.with_thread_names(is_trace_level)
54-
.with_target(is_trace_level)
55-
.with_ansi(false)
56-
.with_writer(logger)
57-
.with_filter(LogLevelFilter { filter: log_level }),
58-
);
48+
let logger = match log_file {
49+
Some(file) => BoxMakeWriter::new(Arc::new(file)),
50+
None => BoxMakeWriter::new(std::io::stderr),
51+
};
52+
let is_trace_level = log_level == LogLevel::Trace;
53+
let subscriber = tracing_subscriber::Registry::default().with(
54+
tracing_subscriber::fmt::layer()
55+
.with_timer(ChronoLocal::new("%Y-%m-%d %H:%M:%S.%f".to_string()))
56+
.with_thread_names(is_trace_level)
57+
.with_target(is_trace_level)
58+
.with_ansi(false)
59+
.with_writer(logger)
60+
.with_filter(LogLevelFilter { filter: log_level }),
61+
);
5962

60-
tracing::subscriber::set_global_default(subscriber)
61-
.expect("should be able to set global default subscriber");
63+
tracing::subscriber::set_global_default(subscriber)
64+
.expect("should be able to set global default subscriber");
65+
});
6266
}
6367

6468
/// The log level for the server as provided by the client during initialization.

crates/ty_server/src/server.rs

Lines changed: 54 additions & 4 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,11 @@ 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+
fallback_system: Arc<dyn System + 'static + Send + Sync + RefUnwindSafe>,
43+
) -> crate::Result<Self> {
3944
let (id, init_value) = connection.initialize_start()?;
4045
let init_params: InitializeParams = serde_json::from_value(init_value)?;
4146

@@ -102,10 +107,14 @@ impl Server {
102107
.collect()
103108
})
104109
.or_else(|| {
105-
let current_dir = std::env::current_dir().ok()?;
110+
let current_dir = fallback_system
111+
.current_directory()
112+
.as_std_path()
113+
.to_path_buf();
106114
tracing::warn!(
107115
"No workspace(s) were provided during initialization. \
108-
Using the current working directory as a default workspace: {}",
116+
Using the current working directory from the fallback system as a \
117+
default workspace: {}",
109118
current_dir.display()
110119
);
111120
let uri = Url::from_file_path(current_dir).ok()?;
@@ -143,6 +152,7 @@ impl Server {
143152
position_encoding,
144153
global_options,
145154
workspaces,
155+
fallback_system,
146156
)?,
147157
client_capabilities,
148158
})
@@ -286,3 +296,43 @@ impl Drop for ServerPanicHookHandler {
286296
}
287297
}
288298
}
299+
300+
#[cfg(test)]
301+
mod tests {
302+
use ruff_db::system::{InMemorySystem, SystemPathBuf};
303+
304+
use crate::session::ClientOptions;
305+
use crate::test::TestServerBuilder;
306+
307+
#[test]
308+
fn initialization_sequence() {
309+
let system = InMemorySystem::default();
310+
let test_server = TestServerBuilder::new()
311+
.with_memory_system(system)
312+
.build()
313+
.unwrap()
314+
.wait_until_workspaces_are_initialized()
315+
.unwrap();
316+
317+
let initialization_result = test_server.initialization_result().unwrap();
318+
319+
insta::assert_json_snapshot!("initialization_capabilities", initialization_result);
320+
}
321+
322+
#[test]
323+
fn initialization_with_workspace() {
324+
let workspace_root = SystemPathBuf::from("/foo");
325+
let system = InMemorySystem::new(workspace_root.clone());
326+
let test_server = TestServerBuilder::new()
327+
.with_memory_system(system)
328+
.with_workspace(&workspace_root, ClientOptions::default())
329+
.build()
330+
.unwrap()
331+
.wait_until_workspaces_are_initialized()
332+
.unwrap();
333+
334+
let initialization_result = test_server.initialization_result().unwrap();
335+
336+
insta::assert_json_snapshot!("initialization_with_workspace", initialization_result);
337+
}
338+
}

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;
@@ -36,6 +39,9 @@ mod settings;
3639

3740
/// The global state for the LSP
3841
pub(crate) struct Session {
42+
/// A fallback system to use with the [`LSPSystem`].
43+
fallback_system: Arc<dyn System + 'static + Send + Sync + RefUnwindSafe>,
44+
3945
/// Used to retrieve information about open documents and settings.
4046
///
4147
/// This will be [`None`] when a mutable reference is held to the index via [`index_mut`]
@@ -79,6 +85,7 @@ impl Session {
7985
position_encoding: PositionEncoding,
8086
global_options: GlobalOptions,
8187
workspace_folders: Vec<(Url, ClientOptions)>,
88+
fallback_system: Arc<dyn System + 'static + Send + Sync + RefUnwindSafe>,
8289
) -> crate::Result<Self> {
8390
let index = Arc::new(Index::new(global_options.into_settings()));
8491

@@ -88,6 +95,7 @@ impl Session {
8895
}
8996

9097
Ok(Self {
98+
fallback_system,
9199
position_encoding,
92100
workspaces,
93101
deferred_messages: VecDeque::new(),
@@ -137,6 +145,9 @@ impl Session {
137145
} else {
138146
match &message {
139147
Message::Request(request) => {
148+
if request.method == Shutdown::METHOD {
149+
return Some(message);
150+
}
140151
tracing::debug!(
141152
"Deferring `{}` request until all workspaces are initialized",
142153
request.method
@@ -147,6 +158,9 @@ impl Session {
147158
return Some(message);
148159
}
149160
Message::Notification(notification) => {
161+
if notification.method == Exit::METHOD {
162+
return Some(message);
163+
}
150164
tracing::debug!(
151165
"Deferring `{}` notification until all workspaces are initialized",
152166
notification.method
@@ -171,9 +185,12 @@ impl Session {
171185
/// If the path is a virtual path, it will return the first project database in the session.
172186
pub(crate) fn project_db(&self, path: &AnySystemPath) -> &ProjectDatabase {
173187
match path {
174-
AnySystemPath::System(system_path) => self
175-
.project_db_for_path(system_path)
176-
.unwrap_or_else(|| self.default_project.get(self.index.as_ref())),
188+
AnySystemPath::System(system_path) => {
189+
self.project_db_for_path(system_path).unwrap_or_else(|| {
190+
self.default_project
191+
.get(self.index.as_ref(), &self.fallback_system)
192+
})
193+
}
177194
AnySystemPath::SystemVirtual(_virtual_path) => {
178195
// TODO: Currently, ty only supports single workspace but we need to figure out
179196
// which project should this virtual path belong to when there are multiple
@@ -196,7 +213,10 @@ impl Session {
196213
.range_mut(..=system_path.to_path_buf())
197214
.next_back()
198215
.map(|(_, db)| db)
199-
.unwrap_or_else(|| self.default_project.get_mut(self.index.as_ref())),
216+
.unwrap_or_else(|| {
217+
self.default_project
218+
.get_mut(self.index.as_ref(), &self.fallback_system)
219+
}),
200220
AnySystemPath::SystemVirtual(_virtual_path) => {
201221
// TODO: Currently, ty only supports single workspace but we need to figure out
202222
// which project should this virtual path belong to when there are multiple
@@ -268,7 +288,10 @@ impl Session {
268288
// For now, create one project database per workspace.
269289
// In the future, index the workspace directories to find all projects
270290
// and create a project database for each.
271-
let system = LSPSystem::new(self.index.as_ref().unwrap().clone());
291+
let system = LSPSystem::new(
292+
self.index.as_ref().unwrap().clone(),
293+
self.fallback_system.clone(),
294+
);
272295

273296
let project = ProjectMetadata::discover(&root, &system)
274297
.context("Failed to discover project configuration")
@@ -663,11 +686,15 @@ impl DefaultProject {
663686
DefaultProject(std::sync::OnceLock::new())
664687
}
665688

666-
pub(crate) fn get(&self, index: Option<&Arc<Index>>) -> &ProjectDatabase {
689+
pub(crate) fn get(
690+
&self,
691+
index: Option<&Arc<Index>>,
692+
fallback_system: &Arc<dyn System + 'static + Send + Sync + RefUnwindSafe>,
693+
) -> &ProjectDatabase {
667694
self.0.get_or_init(|| {
668695
tracing::info!("Initialize default project");
669696

670-
let system = LSPSystem::new(index.unwrap().clone());
697+
let system = LSPSystem::new(index.unwrap().clone(), fallback_system.clone());
671698
let metadata = ProjectMetadata::from_options(
672699
Options::default(),
673700
system.current_directory().to_path_buf(),
@@ -678,8 +705,12 @@ impl DefaultProject {
678705
})
679706
}
680707

681-
pub(crate) fn get_mut(&mut self, index: Option<&Arc<Index>>) -> &mut ProjectDatabase {
682-
let _ = self.get(index);
708+
pub(crate) fn get_mut(
709+
&mut self,
710+
index: Option<&Arc<Index>>,
711+
fallback_system: &Arc<dyn System + 'static + Send + Sync + RefUnwindSafe>,
712+
) -> &mut ProjectDatabase {
713+
let _ = self.get(index, fallback_system);
683714

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

0 commit comments

Comments
 (0)