1use std::fs;
2use std::path::{Path, PathBuf};
3use std::process::Command;
4use std::thread::JoinHandle;
5use std::time::Duration;
6use tempfile::TempDir;
7use tokio::net::UnixStream;
8use tokio::sync::oneshot;
9
10use crate::LanguageId;
11use crate::daemon::LspDaemon;
12use crate::error::DaemonError;
13use crate::language_catalog::server_kind_for_language;
14use crate::socket_path::socket_path;
15use crate::uri::path_to_uri;
16
17pub struct TestDaemon {
19 shutdown_tx: Option<oneshot::Sender<()>>,
20 task: Option<JoinHandle<Result<(), TestDaemonError>>>,
21}
22
23impl TestDaemon {
24 pub async fn spawn(
25 workspace_root: &Path,
26 language: LanguageId,
27 request_timeout: Duration,
28 ) -> Result<Self, TestDaemonError> {
29 let socket_path = socket_path(workspace_root, language);
30 let _ = fs::remove_file(&socket_path);
31 let _ = fs::remove_file(socket_path.with_extension("lock"));
32 let (shutdown_tx, shutdown_rx) = oneshot::channel();
33 let daemon_socket_path = socket_path.clone();
34 let task = std::thread::spawn(move || {
35 tokio::runtime::Runtime::new()
36 .map_err(TestDaemonError::Runtime)?
37 .block_on(LspDaemon::new(daemon_socket_path, None, request_timeout).run_until_shutdown(shutdown_rx))
38 .map_err(TestDaemonError::Daemon)
39 });
40 let mut daemon = Self { shutdown_tx: Some(shutdown_tx), task: Some(task) };
41
42 while UnixStream::connect(&socket_path).await.is_err() {
43 if daemon.task.as_ref().is_some_and(JoinHandle::is_finished) {
44 daemon.finish()?;
45 return Err(TestDaemonError::ExitedBeforeReady);
46 }
47 tokio::task::yield_now().await;
48 }
49
50 Ok(daemon)
51 }
52
53 pub fn shutdown(mut self) -> Result<(), TestDaemonError> {
54 if let Some(shutdown_tx) = self.shutdown_tx.take() {
55 let _ = shutdown_tx.send(());
56 }
57 self.finish()
58 }
59
60 fn finish(&mut self) -> Result<(), TestDaemonError> {
61 let task = self.task.take().expect("test daemon task should be present");
62 task.join().map_err(|_| TestDaemonError::ThreadPanicked)??;
63 Ok(())
64 }
65}
66
67impl Drop for TestDaemon {
68 fn drop(&mut self) {
69 if let Some(shutdown_tx) = self.shutdown_tx.take() {
70 let _ = shutdown_tx.send(());
71 }
72 if let Some(task) = self.task.take() {
73 let _ = task.join();
74 }
75 }
76}
77
78#[derive(Debug, thiserror::Error)]
79pub enum TestDaemonError {
80 #[error("Daemon failed: {0}")]
81 Daemon(#[from] DaemonError),
82 #[error("Daemon exited before its socket was ready")]
83 ExitedBeforeReady,
84 #[error("Failed to create daemon runtime: {0}")]
85 Runtime(std::io::Error),
86 #[error("Daemon thread panicked")]
87 ThreadPanicked,
88}
89
90pub unsafe fn configure_fake_server(language: LanguageId, extra_args: &[&str]) {
97 let server_kind = server_kind_for_language(language).expect("language should have a configured server");
98 let script = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/common/fake_lsp_server.py");
99 let mut args = vec![script.to_string_lossy().into_owned()];
100 args.extend(extra_args.iter().map(ToString::to_string));
101 let env_key = server_kind.env_key();
102
103 unsafe {
104 std::env::set_var(format!("AETHER_LSPD_SERVER_COMMAND_{env_key}"), "python3");
105 std::env::set_var(
106 format!("AETHER_LSPD_SERVER_ARGS_{env_key}"),
107 serde_json::to_string(&args).expect("fake server arguments should serialize"),
108 );
109 }
110}
111
112#[doc = include_str!("docs/testing.md")]
113pub trait TestProject {
114 fn root(&self) -> &Path;
115
116 fn add_file(&self, relative_path: &str, content: &str) -> Result<PathBuf, TestProjectError> {
117 let path = self.root().join(relative_path);
118 if let Some(parent) = path.parent() {
119 fs::create_dir_all(parent)?;
120 }
121 fs::write(&path, content)?;
122 Ok(path)
123 }
124
125 fn file_uri(&self, relative_path: &str) -> lsp_types::Uri {
126 path_to_uri(&self.root().join(relative_path)).expect("Invalid file path")
127 }
128
129 fn file_path_str(&self, relative_path: &str) -> String {
130 self.root().join(relative_path).to_str().expect("Non-UTF8 path").to_string()
131 }
132}
133
134#[derive(Debug, thiserror::Error)]
136pub enum TestProjectError {
137 #[error("IO error: {0}")]
138 Io(#[from] std::io::Error),
139 #[error("Command '{command}' failed:\n{stderr}")]
140 CommandFailed { command: String, stderr: String },
141}
142
143pub struct CargoProject {
145 temp_dir: TempDir,
146}
147
148impl TestProject for CargoProject {
149 fn root(&self) -> &Path {
150 self.temp_dir.path()
151 }
152}
153
154impl CargoProject {
155 pub fn new(name: &str) -> Result<Self, TestProjectError> {
157 let temp_dir = TempDir::new()?;
158 let project = Self { temp_dir };
159 project.init_cargo_toml(name)?;
160 project.init_src_dir()?;
161 Ok(project)
162 }
163
164 fn init_cargo_toml(&self, name: &str) -> Result<(), TestProjectError> {
165 let content = format!(
166 r#"[package]
167name = "{name}"
168version = "0.1.0"
169edition = "2021"
170"#
171 );
172 fs::write(self.root().join("Cargo.toml"), content)?;
173 Ok(())
174 }
175
176 fn init_src_dir(&self) -> Result<(), TestProjectError> {
177 let src_dir = self.root().join("src");
178 fs::create_dir_all(&src_dir)?;
179
180 let main_content = r#"fn main() {
181 println!("Hello, world!");
182}
183"#;
184 fs::write(src_dir.join("main.rs"), main_content)?;
185 Ok(())
186 }
187}
188
189const TYPESCRIPT_PACKAGE: &str = "typescript@7.0.2";
190
191pub struct NodeProject {
193 temp_dir: TempDir,
194}
195
196impl TestProject for NodeProject {
197 fn root(&self) -> &Path {
198 self.temp_dir.path()
199 }
200}
201
202impl NodeProject {
203 pub fn new(name: &str) -> Result<Self, TestProjectError> {
205 let temp_dir = TempDir::new()?;
206 let project = Self { temp_dir };
207 project.init_package_json(name)?;
208 project.init_tsconfig()?;
209 project.init_src_dir()?;
210 project.install_typescript()?;
211 Ok(project)
212 }
213
214 fn init_package_json(&self, name: &str) -> Result<(), TestProjectError> {
215 let content = format!(
216 r#"{{
217 "name": "{name}",
218 "version": "0.1.0"
219}}"#
220 );
221 fs::write(self.root().join("package.json"), content)?;
222 Ok(())
223 }
224
225 fn init_tsconfig(&self) -> Result<(), TestProjectError> {
226 let content = r#"{
227 "compilerOptions": {
228 "strict": true,
229 "noEmit": true
230 }
231}"#;
232 fs::write(self.root().join("tsconfig.json"), content)?;
233 Ok(())
234 }
235
236 fn init_src_dir(&self) -> Result<(), TestProjectError> {
237 let src_dir = self.root().join("src");
238 fs::create_dir_all(&src_dir)?;
239 fs::write(src_dir.join("index.ts"), "")?;
240 Ok(())
241 }
242
243 fn install_typescript(&self) -> Result<(), TestProjectError> {
244 let args = ["install", "--save-dev", "--no-audit", "--no-fund", "--prefer-offline", TYPESCRIPT_PACKAGE];
245 let output = Command::new("npm").args(args).current_dir(self.root()).output()?;
246
247 if !output.status.success() {
248 return Err(TestProjectError::CommandFailed {
249 command: format!("npm {}", args.join(" ")),
250
251 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
252 });
253 }
254 Ok(())
255 }
256}