use std::{ path::{Path, PathBuf}, sync::Once, thread::JoinHandle, }; use anyhow::Result; use lsp_server::Connection; use lsp_types::{ notification::{DidOpenTextDocument, Exit, Initialized}, request::{Initialize, Shutdown}, ClientCapabilities, DidOpenTextDocumentParams, InitializeParams, InitializedParams, Location, Position, Range, TextDocumentIdentifier, TextDocumentItem, TextDocumentPositionParams, Url, }; use tempfile::{tempdir, TempDir}; use texlab::{db::Language, LspClient, Server}; #[derive(Debug)] pub struct Fixture { pub documents: Vec, } impl Fixture { pub fn parse(input: &str) -> Fixture { let mut documents = Vec::new(); let mut start = 0; for end in input .match_indices("%!") .skip(1) .map(|(i, _)| i) .chain(std::iter::once(input.len())) { documents.push(Document::parse(&input[start..end])); start = end; } Self { documents } } pub fn setup(&self, client: &LspClient, dir: &Path) -> Result<()> { for document in &self.documents { let text = String::from(&document.text); let path = dir.join(&document.path); std::fs::create_dir_all(path.parent().unwrap())?; std::fs::write(&path, &text)?; let uri = Url::from_file_path(&path).unwrap(); let language = Language::from_path(&path).unwrap_or(Language::Tex); let language_id = String::from(match language { Language::Tex => "latex", Language::Bib => "bibtex", Language::Log | Language::TexlabRoot | Language::Tectonic => continue, }); client.send_notification::(DidOpenTextDocumentParams { text_document: TextDocumentItem::new(uri, language_id, 0, text), })?; } Ok(()) } } #[derive(Debug)] pub struct Document { pub path: PathBuf, pub text: String, pub cursor: Option, pub ranges: Vec, } impl Document { pub fn parse(input: &str) -> Self { let mut lines = Vec::new(); let (path, input) = input .trim() .strip_prefix("%! ") .map(|input| input.split_once('\n').unwrap_or((input, ""))) .unwrap(); let mut ranges = Vec::new(); let mut cursor = None; for line in input.lines().map(|line| line.trim_end()) { if line.chars().all(|c| matches!(c, ' ' | '^' | '|' | '!')) && !line.is_empty() { let index = (lines.len() - 1) as u32; cursor = cursor.or_else(|| { let character = line.find('|')?; Some(Position::new(index, character as u32)) }); if let Some(start) = line.find('!') { let position = Position::new(index, start as u32); ranges.push(Range::new(position, position)); } if let Some(start) = line.find('^') { let end = line.rfind('^').unwrap() + 1; ranges.push(Range::new( Position::new(index, start as u32), Position::new(index, end as u32), )); } } else { lines.push(line); } } Self { path: PathBuf::from(path), text: lines.join("\n"), cursor, ranges, } } } static LOGGER: Once = Once::new(); #[derive(Debug)] pub struct TestBed { fixture: Fixture, locations: Vec, directory: TempDir, client: LspClient, client_thread: Option>, server_thread: Option>, } impl Drop for TestBed { fn drop(&mut self) { let _ = self.client.send_request::(()); let _ = self.client.send_notification::(()); self.client_thread.take().unwrap().join().unwrap(); self.server_thread.take().unwrap().join().unwrap(); } } impl TestBed { pub fn new(fixture: &str) -> Result { LOGGER.call_once(|| { if option_env!("TEST_LOG") == Some("1") { fern::Dispatch::new() .filter(|metadata| { metadata.target().contains("texlab") || metadata.target().contains("lsp_server") }) .level(log::LevelFilter::Trace) .chain(std::io::stderr()) .apply() .unwrap() } }); let fixture = Fixture::parse(fixture); let (server_conn, client_conn) = Connection::memory(); let client = LspClient::new(client_conn.sender); let server_thread = std::thread::spawn(move || Server::new(server_conn).run().unwrap()); let client_thread = { let client = client.clone(); std::thread::spawn(move || { for message in &client_conn.receiver { match message { lsp_server::Message::Request(request) => { client .send_error( request.id, lsp_server::ErrorCode::MethodNotFound.into(), "Method not found".into(), ) .unwrap(); } lsp_server::Message::Response(response) => { client.recv_response(response).unwrap(); } lsp_server::Message::Notification(_) => {} } } }) }; let directory = tempdir()?; let locations = fixture .documents .iter() .flat_map(|document| { let uri = Url::from_file_path(directory.path().join(&document.path)).unwrap(); document .ranges .iter() .map(move |range| Location::new(uri.clone(), *range)) }) .collect(); Ok(TestBed { fixture, locations, directory, client, client_thread: Some(client_thread), server_thread: Some(server_thread), }) } pub fn initialize(&self, capabilities: ClientCapabilities) -> Result<()> { self.client.send_request::(InitializeParams { capabilities, initialization_options: Some(serde_json::json!({ "skipDistro": true })), ..Default::default() })?; self.client .send_notification::(InitializedParams {})?; self.fixture.setup(&self.client, &self.directory.path())?; Ok(()) } pub fn client(&self) -> &LspClient { &self.client } pub fn cursor(&self) -> Option { let (document, cursor) = self .fixture .documents .iter() .find_map(|document| document.cursor.map(|cursor| (document, cursor)))?; let uri = Url::from_file_path(self.directory.path().join(&document.path)).unwrap(); let id = TextDocumentIdentifier::new(uri); Some(TextDocumentPositionParams::new(id, cursor)) } pub fn locations(&self) -> &[Location] { &self.locations } pub fn directory(&self) -> &Path { self.directory.path() } pub fn documents(&self) -> &[Document] { &self.fixture.documents } pub fn redact(&self, uri: &Url) -> Url { let root = if cfg!(windows) { PathBuf::from("C:/") } else { PathBuf::from("/") }; let path = uri.to_file_path().unwrap(); let path = path.strip_prefix(self.directory()).unwrap_or(&path); let path = root.join(path); let uri = Url::from_file_path(path).unwrap(); Url::parse(&uri.as_str().replace("file:///C:/", "file:///")).unwrap() } }