diff --git a/Cargo.lock b/Cargo.lock index 5fcd3fc9f..8ac58267e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -55,6 +55,12 @@ version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +[[package]] +name = "bitflags" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4682ae6287fcf752ecaabbfcc7b6f9b72aa33933dc23a554d853aea8eea8635" + [[package]] name = "cc" version = "1.0.83" @@ -76,7 +82,7 @@ version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e64e6c0fbe2c17357405f7c758c1ef960fce08bdfb2c03d88d2a18d7e09c4b67" dependencies = [ - "bitflags", + "bitflags 1.3.2", "crossterm_winapi", "libc", "mio", @@ -101,6 +107,7 @@ version = "0.1.31" dependencies = [ "erg_common", "erg_compiler", + "gag", "lsp-types", "serde", "serde_json", @@ -142,6 +149,44 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "errno" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136526188508e25c6fef639d7927dfb3e0e3084488bf202267829cf7fc23dbdd" +dependencies = [ + "errno-dragonfly", + "libc", + "windows-sys", +] + +[[package]] +name = "errno-dragonfly" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa68f1b12764fab894d2755d2518754e71b4fd80ecfb822714a1206c2aab39bf" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "fastrand" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6999dc1837253364c2ebb0704ba97994bd874e8f195d665c50b7548f6ea92764" + +[[package]] +name = "filedescriptor" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7199d965852c3bac31f779ef99cbb4537f80e952e2d6aa0ffeb30cce00f4f46e" +dependencies = [ + "libc", + "thiserror", + "winapi", +] + [[package]] name = "form_urlencoded" version = "1.2.0" @@ -151,6 +196,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "gag" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a713bee13966e9fbffdf7193af71d54a6b35a0bb34997cd6c9519ebeb5005972" +dependencies = [ + "filedescriptor", + "tempfile", +] + [[package]] name = "gimli" version = "0.28.0" @@ -179,6 +234,12 @@ version = "0.2.147" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4668fb0ea861c1df094127ac5f1da3409a82116a4ba74fca2e58ef927159bb3" +[[package]] +name = "linux-raw-sys" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57bcfdad1b858c2db7c38303a6d2ad4dfaf5eb53dfeb0910128b2c26d6158503" + [[package]] name = "lock_api" version = "0.4.10" @@ -201,7 +262,7 @@ version = "0.93.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9be6e9c7e2d18f651974370d7aff703f9513e0df6e464fd795660edc77e6ca51" dependencies = [ - "bitflags", + "bitflags 1.3.2", "serde", "serde_json", "serde_repr", @@ -250,7 +311,7 @@ version = "0.23.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f3790c00a0150112de0f4cd161e3d7fc4b2d8a5542ffc35f099a2562aecb35c" dependencies = [ - "bitflags", + "bitflags 1.3.2", "cc", "cfg-if", "libc", @@ -325,7 +386,7 @@ version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "567664f262709473930a4bf9e51bf2ebf3348f2e748ccc50dea20646858f8f29" dependencies = [ - "bitflags", + "bitflags 1.3.2", ] [[package]] @@ -334,6 +395,19 @@ version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" +[[package]] +name = "rustix" +version = "0.38.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed6248e1caa625eb708e266e06159f135e8c26f2bb7ceb72dc4b2766d0340964" +dependencies = [ + "bitflags 2.4.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "ryu" version = "1.0.15" @@ -435,6 +509,39 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb94d2f3cc536af71caac6b6fcebf65860b347e7ce0cc9ebe8f70d3e521054ef" +dependencies = [ + "cfg-if", + "fastrand", + "redox_syscall", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a802ec30afc17eee47b2855fc72e0c4cd62be9b4efe6591edde0ec5bd68d8f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb623b56e39ab7dcd4b1b98bb6c8f8d907ed255b18de254088016b27a8ee19b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "thread_local" version = "1.1.7" diff --git a/crates/els/Cargo.toml b/crates/els/Cargo.toml index 829aebaac..fc00a3472 100644 --- a/crates/els/Cargo.toml +++ b/crates/els/Cargo.toml @@ -27,6 +27,9 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0.85" lsp-types = { version = "0.93.2", features = ["proposed"] } +[dev-dependencies] +gag = "1" + [lib] path = "lib.rs" diff --git a/crates/els/file_cache.rs b/crates/els/file_cache.rs index c48dd553d..7b22a8742 100644 --- a/crates/els/file_cache.rs +++ b/crates/els/file_cache.rs @@ -46,7 +46,7 @@ impl FileCacheEntry { /// Stores the contents of the file on-memory. /// This struct can save changes in real-time & incrementally. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Default)] pub struct FileCache { pub files: Shared>, } diff --git a/crates/els/lib.rs b/crates/els/lib.rs index e97afd6cf..d5247b691 100644 --- a/crates/els/lib.rs +++ b/crates/els/lib.rs @@ -18,3 +18,4 @@ mod server; mod sig_help; mod util; pub use server::*; +pub use util::*; diff --git a/crates/els/server.rs b/crates/els/server.rs index 825b77cc9..a89d45237 100644 --- a/crates/els/server.rs +++ b/crates/els/server.rs @@ -197,7 +197,7 @@ impl AnalysisResult { } } -pub(crate) const TRIGGER_CHARS: [&str; 4] = [".", ":", "(", " "]; +pub const TRIGGER_CHARS: [&str; 4] = [".", ":", "(", " "]; #[derive(Debug, Clone, Default)] pub struct AnalysisResultCache(Shared>); @@ -664,7 +664,7 @@ impl Server { Ok(Value::from_str(&s)?) } - fn dispatch(&mut self, msg: Value) -> ELSResult<()> { + pub fn dispatch(&mut self, msg: Value) -> ELSResult<()> { match ( msg.get("id").and_then(|i| i.as_i64()), msg.get("method").and_then(|m| m.as_str()), diff --git a/crates/els/tests/a.er b/crates/els/tests/a.er new file mode 100644 index 000000000..905c3fb3e --- /dev/null +++ b/crates/els/tests/a.er @@ -0,0 +1,2 @@ +x = 1 +_ = x + 1 diff --git a/crates/els/tests/test.rs b/crates/els/tests/test.rs new file mode 100644 index 000000000..4b0504078 --- /dev/null +++ b/crates/els/tests/test.rs @@ -0,0 +1,502 @@ +use std::fs::File; +use std::io::Read; +use std::path::Path; + +use lsp_types::{ + CompletionContext, CompletionParams, CompletionResponse, CompletionTriggerKind, + DidChangeTextDocumentParams, DidOpenTextDocumentParams, GotoDefinitionParams, Hover, + HoverContents, HoverParams, Location, MarkedString, Position, Range, ReferenceContext, + ReferenceParams, RenameParams, SignatureHelp, SignatureHelpContext, SignatureHelpParams, + SignatureHelpTriggerKind, TextDocumentContentChangeEvent, TextDocumentIdentifier, + TextDocumentItem, TextDocumentPositionParams, Url, VersionedTextDocumentIdentifier, + WorkspaceEdit, +}; +use serde::de::Deserialize; +use serde_json::{json, Value}; + +use els::{NormalizedUrl, Server, TRIGGER_CHARS}; +use erg_common::config::ErgConfig; +use erg_common::spawn::safe_yield; + +const FILE_A: &str = "tests/a.er"; + +fn add_char(line: u32, character: u32, text: &str) -> TextDocumentContentChangeEvent { + TextDocumentContentChangeEvent { + range: Some(Range { + start: Position { line, character }, + end: Position { line, character }, + }), + range_length: None, + text: text.to_string(), + } +} + +fn abs_pos(uri: Url, line: u32, col: u32) -> TextDocumentPositionParams { + TextDocumentPositionParams { + text_document: TextDocumentIdentifier::new(uri), + position: Position { + line, + character: col, + }, + } +} + +fn single_range(line: u32, from: u32, to: u32) -> Range { + Range { + start: Position { + line, + character: from, + }, + end: Position { + line, + character: to, + }, + } +} + +fn parse_msgs(mut input: &str) -> Vec { + let mut msgs = Vec::new(); + loop { + if input.starts_with("Content-Length: ") { + let idx = "Content-Length: ".len(); + input = &input[idx..]; + } else { + break; + } + let dights = input.find("\r\n").unwrap(); + let len = input[..dights].parse::().unwrap(); + let idx = dights + "\r\n\r\n".len(); + input = &input[idx..]; + let msg = &input[..len]; + input = &input[len..]; + msgs.push(serde_json::from_str(msg).unwrap()); + } + msgs +} + +pub struct DummyClient { + stdout_buffer: gag::BufferRedirect, + ver: i32, + server: Server, +} + +impl Default for DummyClient { + fn default() -> Self { + Self::new() + } +} + +impl DummyClient { + pub fn new() -> Self { + let stdout_buffer = loop { + // wait until the other thread is finished + match gag::BufferRedirect::stdout() { + Ok(stdout_buffer) => break stdout_buffer, + Err(_) => safe_yield(), + } + }; + DummyClient { + stdout_buffer, + ver: 0, + server: Server::new(ErgConfig::default()), + } + } + + #[allow(dead_code)] + fn wait_output(&mut self) -> Result> { + let mut buf = String::new(); + loop { + self.stdout_buffer.read_to_string(&mut buf)?; + if buf.is_empty() { + safe_yield(); + } else { + break; + } + } + Ok(buf) + } + + /// the server periodically outputs health check messages + fn wait_outputs(&mut self, mut size: usize) -> Result> { + let mut buf = String::new(); + loop { + self.stdout_buffer.read_to_string(&mut buf)?; + if buf.is_empty() { + safe_yield(); + } else { + size -= 1; + if size == 0 { + break; + } + } + } + Ok(buf) + } + + fn request_initialize(&mut self) -> Result> { + let msg = json!({ + "jsonrpc": "2.0", + "id": 0, + "method": "initialize", + }); + self.server.dispatch(msg)?; + let buf = self.wait_outputs(2)?; + // eprintln!("`{}`", buf); + Ok(buf) + } + + fn notify_open(&mut self, file: &str) -> Result> { + let uri = Url::from_file_path(Path::new(file).canonicalize().unwrap()).unwrap(); + let mut text = String::new(); + File::open(file).unwrap().read_to_string(&mut text)?; + let params = DidOpenTextDocumentParams { + text_document: TextDocumentItem::new(uri, "erg".to_string(), self.ver, text), + }; + self.ver += 1; + let msg = json!({ + "jsonrpc": "2.0", + "method": "textDocument/didOpen", + "params": params, + }); + self.server.dispatch(msg)?; + let buf = self.wait_outputs(1)?; + // eprintln!("open: `{}`", buf); + Ok(buf) + } + + fn notify_change( + &mut self, + uri: Url, + change: TextDocumentContentChangeEvent, + ) -> Result> { + let params = DidChangeTextDocumentParams { + text_document: VersionedTextDocumentIdentifier::new(uri.clone(), self.ver), + content_changes: vec![change], + }; + self.ver += 1; + let msg = json!({ + "jsonrpc": "2.0", + "method": "textDocument/didChange", + "params": params, + }); + self.server.dispatch(msg)?; + let buf = self.wait_outputs(1)?; + // eprintln!("{}: `{}`", line!(), buf); + Ok(buf) + } + + fn request_completion( + &mut self, + uri: Url, + line: u32, + col: u32, + character: &str, + ) -> Result> { + let text_document_position = abs_pos(uri, line, col); + let trigger_kind = if TRIGGER_CHARS.contains(&character) { + CompletionTriggerKind::TRIGGER_CHARACTER + } else { + CompletionTriggerKind::INVOKED + }; + let trigger_character = TRIGGER_CHARS + .contains(&character) + .then_some(character.to_string()); + let context = Some(CompletionContext { + trigger_kind, + trigger_character, + }); + let params = CompletionParams { + text_document_position, + context, + work_done_progress_params: Default::default(), + partial_result_params: Default::default(), + }; + let msg = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "textDocument/completion", + "params": params, + }); + self.server.dispatch(msg)?; + let buf = self.wait_outputs(2)?; + // eprintln!("{}: `{}`", line!(), buf); + Ok(buf) + } + + fn request_rename( + &mut self, + uri: Url, + line: u32, + col: u32, + new_name: &str, + ) -> Result> { + let text_document_position = abs_pos(uri, line, col); + let params = RenameParams { + text_document_position, + new_name: new_name.to_string(), + work_done_progress_params: Default::default(), + }; + let msg = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "textDocument/rename", + "params": params, + }); + self.server.dispatch(msg)?; + let buf = self.wait_outputs(2)?; + // eprintln!("{}: `{}`", line!(), buf); + Ok(buf) + } + + fn request_signature_help( + &mut self, + uri: Url, + line: u32, + col: u32, + character: &str, + ) -> Result> { + let text_document_position_params = abs_pos(uri, line, col); + let context = SignatureHelpContext { + trigger_kind: SignatureHelpTriggerKind::TRIGGER_CHARACTER, + trigger_character: Some(character.to_string()), + is_retrigger: false, + active_signature_help: None, + }; + let params = SignatureHelpParams { + text_document_position_params, + context: Some(context), + work_done_progress_params: Default::default(), + }; + let msg = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "textDocument/signatureHelp", + "params": params, + }); + self.server.dispatch(msg)?; + let buf = self.wait_outputs(1)?; + // eprintln!("{}: `{}`", line!(), buf); + Ok(buf) + } + + fn request_hover( + &mut self, + uri: Url, + line: u32, + col: u32, + ) -> Result> { + let params = HoverParams { + text_document_position_params: abs_pos(uri, line, col), + work_done_progress_params: Default::default(), + }; + let msg = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "textDocument/hover", + "params": params, + }); + self.server.dispatch(msg)?; + let buf = self.wait_outputs(2)?; + // eprintln!("{}: `{}`", line!(), buf); + Ok(buf) + } + + fn request_references( + &mut self, + uri: Url, + line: u32, + col: u32, + ) -> Result> { + let context = ReferenceContext { + include_declaration: false, + }; + let params = ReferenceParams { + text_document_position: abs_pos(uri, line, col), + context, + work_done_progress_params: Default::default(), + partial_result_params: Default::default(), + }; + let msg = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "textDocument/references", + "params": params, + }); + self.server.dispatch(msg)?; + let buf = self.wait_outputs(2)?; + // eprintln!("{}: `{}`", line!(), buf); + Ok(buf) + } + + fn request_goto_definition( + &mut self, + uri: Url, + line: u32, + col: u32, + ) -> Result> { + let params = GotoDefinitionParams { + text_document_position_params: abs_pos(uri, line, col), + work_done_progress_params: Default::default(), + partial_result_params: Default::default(), + }; + let msg = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "textDocument/definition", + "params": params, + }); + self.server.dispatch(msg)?; + let buf = self.wait_outputs(1)?; + // eprintln!("{}: `{}`", line!(), buf); + Ok(buf) + } +} + +#[test] +fn test_open() -> Result<(), Box> { + let mut client = DummyClient::new(); + client.request_initialize()?; + let result = client.notify_open(FILE_A)?; + assert!(result.contains("tests/a.er passed, found warns: 0")); + Ok(()) +} + +#[test] +fn test_completion() -> Result<(), Box> { + let mut client = DummyClient::new(); + client.request_initialize()?; + let uri = NormalizedUrl::from_file_path(Path::new(FILE_A).canonicalize()?)?; + client.notify_open(FILE_A)?; + client.notify_change(uri.clone().raw(), add_char(2, 0, "x"))?; + client.notify_change(uri.clone().raw(), add_char(2, 1, "."))?; + let result = client.request_completion(uri.raw(), 2, 2, ".")?; + for msg in parse_msgs(&result) { + if let Some(CompletionResponse::Array(items)) = msg + .get("result") + .and_then(|res| CompletionResponse::deserialize(res).ok()) + { + assert!(items.len() >= 40); + assert!(items.iter().any(|item| item.label == "abs")); + return Ok(()); + } + } + Err("no result".into()) +} + +#[test] +fn test_rename() -> Result<(), Box> { + let mut client = DummyClient::new(); + client.request_initialize()?; + let uri = NormalizedUrl::from_file_path(Path::new(FILE_A).canonicalize()?)?; + client.notify_open(FILE_A)?; + let result = client.request_rename(uri.clone().raw(), 1, 5, "y")?; + for msg in parse_msgs(&result) { + if let Some(edit) = msg + .get("result") + .and_then(|res| WorkspaceEdit::deserialize(res).ok()) + { + assert!(edit + .changes + .is_some_and(|changes| changes.values().next().unwrap().len() == 2)); + return Ok(()); + } + } + Err("no result".into()) +} + +#[test] +fn test_signature_help() -> Result<(), Box> { + let mut client = DummyClient::new(); + client.request_initialize()?; + let uri = NormalizedUrl::from_file_path(Path::new(FILE_A).canonicalize()?)?; + client.notify_open(FILE_A)?; + client.notify_change(uri.clone().raw(), add_char(2, 0, "assert"))?; + client.notify_change(uri.clone().raw(), add_char(2, 6, "("))?; + let result = client.request_signature_help(uri.raw(), 2, 7, "(")?; + for msg in parse_msgs(&result) { + if let Some(help) = msg + .get("result") + .and_then(|res| SignatureHelp::deserialize(res).ok()) + { + assert_eq!(help.signatures.len(), 1); + let sig = &help.signatures[0]; + assert_eq!(sig.label, "::assert: (test: Bool, msg := Str) -> NoneType"); + assert_eq!(sig.active_parameter, Some(0)); + return Ok(()); + } + } + Err("no result".into()) +} + +#[test] +fn test_hover() -> Result<(), Box> { + let mut client = DummyClient::new(); + client.request_initialize()?; + let uri = NormalizedUrl::from_file_path(Path::new(FILE_A).canonicalize()?)?; + client.notify_open(FILE_A)?; + let result = client.request_hover(uri.raw(), 1, 4)?; + for msg in parse_msgs(&result) { + if let Some(hover) = msg + .get("result") + .and_then(|res| Hover::deserialize(res).ok()) + { + let HoverContents::Array(contents) = hover.contents else { + todo!() + }; + assert_eq!(contents.len(), 2); + let MarkedString::LanguageString(content) = &contents[0] else { + todo!() + }; + assert!( + content.value == "# tests/a.er, line 1\nx = 1" + || content.value == "# tests\\a.er, line 1\nx = 1" + ); + let MarkedString::LanguageString(content) = &contents[1] else { + todo!() + }; + assert_eq!(content.value, "x: {1}"); + return Ok(()); + } + } + Err("no result".into()) +} + +#[test] +fn test_references() -> Result<(), Box> { + let mut client = DummyClient::new(); + client.request_initialize()?; + let uri = NormalizedUrl::from_file_path(Path::new(FILE_A).canonicalize()?)?; + client.notify_open(FILE_A)?; + let result = client.request_references(uri.raw(), 1, 4)?; + for msg in parse_msgs(&result) { + if let Some(locations) = msg + .get("result") + .and_then(|res| Vec::::deserialize(res).ok()) + { + assert_eq!(locations.len(), 1); + assert_eq!(&locations[0].range, &single_range(1, 4, 5)); + return Ok(()); + } + } + Err("no result".into()) +} + +#[test] +fn test_goto_definition() -> Result<(), Box> { + let mut client = DummyClient::new(); + client.request_initialize()?; + let uri = NormalizedUrl::from_file_path(Path::new(FILE_A).canonicalize()?)?; + client.notify_open(FILE_A)?; + let result = client.request_goto_definition(uri.raw(), 1, 4)?; + for msg in parse_msgs(&result) { + if let Some(locations) = msg + .get("result") + .and_then(|res| Vec::::deserialize(res).ok()) + { + assert_eq!(locations.len(), 1); + assert_eq!(&locations[0].range, &single_range(0, 0, 1)); + return Ok(()); + } + } + Err("no result".into()) +}