Skip to main content

aether_lspd/
testing.rs

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
17/// An in-process daemon with explicit configuration and deterministic shutdown.
18pub 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
90/// Configure one language to use the shared fake Python server in the current test process.
91///
92/// # Safety
93///
94/// This mutates process-global environment variables. Call it only from an isolated test binary
95/// before starting any daemon or other thread that can read language-server configuration.
96pub 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/// Configure the Rust fake server to return an error for workspace symbols.
113///
114/// This is useful for exercising clients that must fall back to document-symbol
115/// queries when the workspace-symbol request is unavailable.
116pub fn use_fake_rust_server_failing_workspace_symbol() {
117    unsafe { configure_fake_server(LanguageId::Rust, &["--fail-on", "workspace/symbol"]) }
118}
119
120#[doc = include_str!("docs/testing.md")]
121pub trait TestProject {
122    fn root(&self) -> &Path;
123
124    fn add_file(&self, relative_path: &str, content: &str) -> Result<PathBuf, TestProjectError> {
125        let path = self.root().join(relative_path);
126        if let Some(parent) = path.parent() {
127            fs::create_dir_all(parent)?;
128        }
129        fs::write(&path, content)?;
130        Ok(path)
131    }
132
133    fn file_uri(&self, relative_path: &str) -> lsp_types::Uri {
134        path_to_uri(&self.root().join(relative_path)).expect("Invalid file path")
135    }
136
137    fn file_path_str(&self, relative_path: &str) -> String {
138        self.root().join(relative_path).to_str().expect("Non-UTF8 path").to_string()
139    }
140}
141
142/// Error type for test project operations.
143#[derive(Debug, thiserror::Error)]
144pub enum TestProjectError {
145    #[error("IO error: {0}")]
146    Io(#[from] std::io::Error),
147    #[error("Command '{command}' failed:\n{stderr}")]
148    CommandFailed { command: String, stderr: String },
149}
150
151/// A temporary Cargo project for testing.
152pub struct CargoProject {
153    temp_dir: TempDir,
154}
155
156impl TestProject for CargoProject {
157    fn root(&self) -> &Path {
158        self.temp_dir.path()
159    }
160}
161
162impl CargoProject {
163    /// Create a new minimal Cargo project.
164    pub fn new(name: &str) -> Result<Self, TestProjectError> {
165        let temp_dir = TempDir::new()?;
166        let project = Self { temp_dir };
167        project.init_cargo_toml(name)?;
168        project.init_src_dir()?;
169        Ok(project)
170    }
171
172    fn init_cargo_toml(&self, name: &str) -> Result<(), TestProjectError> {
173        let content = format!(
174            r#"[package]
175name = "{name}"
176version = "0.1.0"
177edition = "2021"
178"#
179        );
180        fs::write(self.root().join("Cargo.toml"), content)?;
181        Ok(())
182    }
183
184    fn init_src_dir(&self) -> Result<(), TestProjectError> {
185        let src_dir = self.root().join("src");
186        fs::create_dir_all(&src_dir)?;
187
188        let main_content = r#"fn main() {
189    println!("Hello, world!");
190}
191"#;
192        fs::write(src_dir.join("main.rs"), main_content)?;
193        Ok(())
194    }
195}
196
197const TYPESCRIPT_PACKAGE: &str = "typescript@6.0.3";
198const TYPESCRIPT_LANGUAGE_SERVER_PACKAGE: &str = "typescript-language-server@5.2.0";
199
200/// A temporary Node.js/TypeScript project for testing.
201pub struct NodeProject {
202    temp_dir: TempDir,
203}
204
205impl TestProject for NodeProject {
206    fn root(&self) -> &Path {
207        self.temp_dir.path()
208    }
209}
210
211impl NodeProject {
212    /// Create a new minimal Node/TypeScript project.
213    ///
214    /// Installs pinned TypeScript tooling locally so tests do not depend on global Node tools.
215    pub fn new(name: &str) -> Result<Self, TestProjectError> {
216        let temp_dir = TempDir::new()?;
217        let project = Self { temp_dir };
218        project.init_package_json(name)?;
219        project.init_tsconfig()?;
220        project.init_src_dir()?;
221        project.install_typescript()?;
222        Ok(project)
223    }
224
225    fn init_package_json(&self, name: &str) -> Result<(), TestProjectError> {
226        let content = format!(
227            r#"{{
228  "name": "{name}",
229  "version": "0.1.0"
230}}"#
231        );
232        fs::write(self.root().join("package.json"), content)?;
233        Ok(())
234    }
235
236    fn init_tsconfig(&self) -> Result<(), TestProjectError> {
237        let content = r#"{
238  "compilerOptions": {
239    "strict": true,
240    "noEmit": true
241  }
242}"#;
243        fs::write(self.root().join("tsconfig.json"), content)?;
244        Ok(())
245    }
246
247    fn init_src_dir(&self) -> Result<(), TestProjectError> {
248        let src_dir = self.root().join("src");
249        fs::create_dir_all(&src_dir)?;
250        fs::write(src_dir.join("index.ts"), "")?;
251        Ok(())
252    }
253
254    fn install_typescript(&self) -> Result<(), TestProjectError> {
255        let args = [
256            "install",
257            "--save-dev",
258            "--no-audit",
259            "--no-fund",
260            "--prefer-offline",
261            TYPESCRIPT_PACKAGE,
262            TYPESCRIPT_LANGUAGE_SERVER_PACKAGE,
263        ];
264        let output = Command::new("npm").args(args).current_dir(self.root()).output()?;
265
266        if !output.status.success() {
267            return Err(TestProjectError::CommandFailed {
268                command: format!("npm {}", args.join(" ")),
269
270                stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
271            });
272        }
273        Ok(())
274    }
275}