1mod actions;
4mod handlers;
5mod routers;
6
7use std::collections::HashMap;
8use std::fs;
9use std::path::PathBuf;
10use std::str::FromStr;
11
12use crossbeam_channel::Sender;
13use ink_analyzer::{Analysis, MinorVersion, Version};
14use lsp_types::request::Request;
15use once_cell::sync::Lazy;
16use regex::Regex;
17
18use crate::dispatch::handlers::command::{
19 CreateProjectResponse, ExtractEventResponse, MigrateProjectResponse,
20};
21use crate::dispatch::routers::{NotificationRouter, RequestRouter};
22use crate::memory::Memory;
23use crate::translator::PositionTranslationContext;
24use crate::utils;
25use crate::utils::{COMMAND_CREATE_PROJECT, COMMAND_EXTRACT_EVENT, COMMAND_MIGRATE_PROJECT};
26
27pub fn main_loop(
29 connection: lsp_server::Connection,
30 client_capabilities: lsp_types::ClientCapabilities,
31) -> anyhow::Result<()> {
32 let mut dispatcher = Dispatcher::new(&connection.sender, client_capabilities);
34
35 for msg in &connection.receiver {
38 match msg {
39 lsp_server::Message::Request(req) => {
40 if connection.handle_shutdown(&req)? {
42 return Ok(());
43 }
44
45 dispatcher.handle_request(req)?;
47 }
48 lsp_server::Message::Notification(not) => {
49 use lsp_types::notification::Notification;
51 if not.method == lsp_types::notification::Exit::METHOD {
52 return Ok(());
53 }
54
55 dispatcher.handle_notification(not)?;
57 }
58 lsp_server::Message::Response(resp) => dispatcher.handle_response(resp)?,
60 }
61 }
62
63 Ok(())
64}
65
66struct Dispatcher<'a> {
68 sender: &'a Sender<lsp_server::Message>,
69 client_capabilities: lsp_types::ClientCapabilities,
70 memory: Memory,
71 snapshots: Snapshots,
72 version_check_fuel: u8,
73}
74
75pub type Snapshots = HashMap<String, Snapshot>;
76
77pub struct Snapshot {
79 analysis: Analysis,
80 context: PositionTranslationContext,
81 doc_version: Option<i32>,
82 lang_version: Version,
83}
84
85impl Snapshot {
86 pub fn new(
88 content: String,
89 encoding: lsp_types::PositionEncodingKind,
90 doc_version: Option<i32>,
91 lang_version: Version,
92 ) -> Self {
93 Self {
94 analysis: Analysis::new(&content, lang_version),
95 context: PositionTranslationContext::new(&content, encoding),
96 doc_version,
97 lang_version,
98 }
99 }
100}
101
102const INITIALIZE_PROJECT_ID_PREFIX: &str = "initialize-project::";
103const SHOW_DOCUMENT_ID_PREFIX: &str = "show-document::";
104const MIGRATE_PROJECT_ID_PREFIX: &str = "migrate-project::";
105const EXTRACT_EVENT_ID_PREFIX: &str = "extract-event::";
106
107impl<'a> Dispatcher<'a> {
108 fn new(
110 sender: &'a Sender<lsp_server::Message>,
111 client_capabilities: lsp_types::ClientCapabilities,
112 ) -> Self {
113 Self {
114 sender,
115 client_capabilities,
116 memory: Memory::new(),
117 snapshots: Snapshots::new(),
118 version_check_fuel: 0,
119 }
120 }
121
122 fn handle_request(&mut self, req: lsp_server::Request) -> anyhow::Result<()> {
124 let cmd = if req.method == lsp_types::request::ExecuteCommand::METHOD {
126 req.params
127 .as_object()
128 .and_then(|params| params.get("command"))
129 .and_then(serde_json::Value::as_str)
130 .map(ToString::to_string)
131 } else {
132 None
133 };
134 let is_migration_resolve = req.method
135 == lsp_types::request::CodeActionResolveRequest::METHOD
136 && req
137 .params
138 .as_object()
139 .and_then(|params| params.get("data"))
140 .and_then(serde_json::Value::as_object)
141 .and_then(|params| params.get("command"))
142 .and_then(serde_json::Value::as_str)
143 .is_some_and(|cmd| cmd == COMMAND_MIGRATE_PROJECT);
144 let mut router = RequestRouter::new(req, &self.snapshots, &self.client_capabilities);
145 let result = router
146 .process::<lsp_types::request::Completion>(handlers::request::handle_completion)
147 .process::<lsp_types::request::HoverRequest>(handlers::request::handle_hover)
148 .process::<lsp_types::request::CodeActionRequest>(handlers::request::handle_code_action)
149 .process::<lsp_types::request::CodeActionResolveRequest>(
150 handlers::request::handle_code_action_resolve,
151 )
152 .process::<lsp_types::request::InlayHintRequest>(handlers::request::handle_inlay_hint)
153 .process::<lsp_types::request::SignatureHelpRequest>(
154 handlers::request::handle_signature_help,
155 )
156 .process::<lsp_types::request::ExecuteCommand>(
157 handlers::request::handle_execute_command,
158 )
159 .finish();
160
161 if let Some(resp) = result {
163 if let Some(ref cmd) = cmd {
164 self.process_command_response(cmd, resp)?;
166 } else {
167 self.send(resp.into())?;
169 }
170 }
171
172 self.process_changes()?;
174
175 if is_migration_resolve
177 || cmd
178 .as_ref()
179 .is_some_and(|cmd| cmd == COMMAND_MIGRATE_PROJECT)
180 {
181 self.version_check_fuel = 2;
182 }
183
184 Ok(())
185 }
186
187 pub fn handle_notification(&mut self, not: lsp_server::Notification) -> anyhow::Result<()> {
189 let mut router = NotificationRouter::new(not, &mut self.memory);
191 router
192 .process::<lsp_types::notification::DidOpenTextDocument>(
193 handlers::notification::handle_did_open_text_document,
194 )?
195 .process::<lsp_types::notification::DidChangeTextDocument>(
196 handlers::notification::handle_did_change_text_document,
197 )?
198 .process::<lsp_types::notification::DidCloseTextDocument>(
199 handlers::notification::handle_did_close_text_document,
200 )?
201 .finish();
202
203 self.process_changes()?;
205
206 Ok(())
207 }
208
209 fn handle_response(&mut self, resp: lsp_server::Response) -> anyhow::Result<()> {
211 if let Some(resp_id) = utils::request_id_as_str(resp.id) {
213 if resp_id.starts_with(INITIALIZE_PROJECT_ID_PREFIX) {
214 if let Some(project_uri) = resp_id
215 .strip_prefix(INITIALIZE_PROJECT_ID_PREFIX)
216 .and_then(|suffix| lsp_types::Uri::from_str(suffix).ok())
217 {
218 let lib_uri = utils::uri_to_url(&project_uri)
219 .ok()
220 .and_then(|url| url.join("lib.rs").ok())
221 .map(|url| utils::url_to_uri(&url));
222 if let Some(Ok(lib_uri)) = lib_uri {
223 let params = lsp_types::ShowDocumentParams {
224 uri: lib_uri.clone(),
225 external: None,
226 take_focus: Some(true),
227 selection: None,
228 };
229 let req = lsp_server::Request::new(
230 lsp_server::RequestId::from(format!(
231 "{SHOW_DOCUMENT_ID_PREFIX}{}",
232 lib_uri.as_str()
233 )),
234 lsp_types::request::ShowDocument::METHOD.to_owned(),
235 params,
236 );
237 self.send(req.into())?;
238 }
239 }
240 }
241 }
242
243 Ok(())
244 }
245
246 fn process_changes(&mut self) -> anyhow::Result<()> {
248 if let Some(changes) = self.memory.take_changes() {
250 for id in changes {
251 let doc_uri = lsp_types::Uri::from_str(&id);
253
254 if let Some(doc) = self.memory.get(&id) {
256 let version_check = || {
258 doc_uri
259 .as_ref()
260 .ok()
261 .and_then(parse_ink_project_version)
262 .as_ref()
263 .map(InkProjectVersion::guess_version)
264 .unwrap_or(Version::V6)
265 };
266 let lang_version = if self.version_check_fuel > 0 {
267 self.version_check_fuel -= 1;
268 version_check()
269 } else {
270 self.snapshots
271 .get(&id)
272 .map(|snapshot| snapshot.lang_version)
273 .unwrap_or_else(version_check)
274 };
275
276 self.snapshots.insert(
277 id,
278 Snapshot::new(
279 doc.content.clone(),
280 utils::position_encoding(&self.client_capabilities),
281 Some(doc.version),
282 lang_version,
283 ),
284 );
285 } else {
286 self.snapshots.remove(&id);
287 }
288
289 if let Ok(uri) = doc_uri {
291 self.publish_diagnostics(&uri)?;
292 }
293 }
294 }
295
296 Ok(())
297 }
298
299 fn publish_diagnostics(&mut self, uri: &lsp_types::Uri) -> anyhow::Result<()> {
301 use lsp_types::notification::Notification;
303 let notification = lsp_server::Notification::new(
304 lsp_types::notification::PublishDiagnostics::METHOD.to_owned(),
305 actions::publish_diagnostics(uri, &self.snapshots)?,
306 );
307 self.send(notification.into())?;
308
309 Ok(())
310 }
311
312 fn process_command_response(
314 &mut self,
315 cmd: &str,
316 resp: lsp_server::Response,
317 ) -> anyhow::Result<()> {
318 let req_data = if cmd == COMMAND_CREATE_PROJECT
319 && utils::can_create_workspace_resources(&self.client_capabilities)
320 {
321 resp.result
324 .as_ref()
325 .and_then(|value| {
326 serde_json::from_value::<CreateProjectResponse>(value.clone()).ok()
327 })
328 .map(|changes| {
329 let id = lsp_server::RequestId::from(format!(
330 "{INITIALIZE_PROJECT_ID_PREFIX}{}",
331 changes.uri.as_str()
332 ));
333 let params = lsp_types::ApplyWorkspaceEditParams {
334 label: Some("New ink! project".to_owned()),
335 edit: lsp_types::WorkspaceEdit {
336 document_changes: Some(lsp_types::DocumentChanges::from(changes)),
337 ..Default::default()
338 },
339 };
340 (id, params)
341 })
342 } else if cmd == COMMAND_MIGRATE_PROJECT {
343 resp.result
345 .as_ref()
346 .and_then(|value| {
347 serde_json::from_value::<MigrateProjectResponse>(value.clone()).ok()
348 })
349 .map(|changes| {
350 let params = lsp_types::ApplyWorkspaceEditParams {
351 label: Some("Migrate to ink! 5.0".to_owned()),
352 edit: lsp_types::WorkspaceEdit {
353 changes: Some(changes.edits),
354 ..Default::default()
355 },
356 };
357 (
358 lsp_server::RequestId::from(format!(
359 "{MIGRATE_PROJECT_ID_PREFIX}{}",
360 changes.uri.as_str()
361 )),
362 params,
363 )
364 })
365 } else if cmd == COMMAND_EXTRACT_EVENT
366 && utils::can_create_workspace_resources(&self.client_capabilities)
367 {
368 resp.result
371 .as_ref()
372 .and_then(|value| {
373 serde_json::from_value::<ExtractEventResponse>(value.clone()).ok()
374 })
375 .map(|changes| {
376 let id = lsp_server::RequestId::from(format!(
377 "{EXTRACT_EVENT_ID_PREFIX}{}{}",
378 changes.uri.as_str(),
379 changes.name
380 ));
381 let params = lsp_types::ApplyWorkspaceEditParams {
382 label: Some("Extract ink! event into standalone package".to_owned()),
383 edit: lsp_types::WorkspaceEdit {
384 document_changes: Some(lsp_types::DocumentChanges::from(changes)),
385 ..Default::default()
386 },
387 };
388 (id, params)
389 })
390 } else {
391 None
392 };
393
394 if let Some((req_id, params)) = req_data {
395 let mut empty_resp = resp.clone();
398 empty_resp.result = Some(serde_json::Value::Null);
399 self.send(empty_resp.into())?;
400
401 let req = lsp_server::Request::new(
403 req_id,
404 lsp_types::request::ApplyWorkspaceEdit::METHOD.to_owned(),
405 params,
406 );
407 self.send(req.into())?;
408 } else {
409 self.send(resp.into())?;
411 }
412
413 Ok(())
414 }
415
416 fn send(&self, msg: lsp_server::Message) -> anyhow::Result<()> {
418 self.sender
419 .send(msg)
420 .map_err(|error| anyhow::format_err!("Failed to send message: {error}"))
421 }
422}
423
424fn parse_ink_project_version(doc_uri: &lsp_types::Uri) -> Option<InkProjectVersion> {
426 let doc_url = utils::uri_to_url(doc_uri).ok()?;
427 let path = doc_url.to_file_path().ok()?;
428 if path.extension().is_some_and(|ext| ext == "rs") {
429 let cargo_toml_path = utils::find_cargo_toml(path)?;
431 let project_version = parse_ink_project_version_inner(&cargo_toml_path, false);
432
433 let is_workspace_dependency = project_version.as_ref().is_some_and(|it| it.workspace);
435 if is_workspace_dependency {
436 let mut parent_dir = cargo_toml_path.clone();
438 parent_dir.pop();
439 if let Some(workspace_cargo_toml_path) = utils::find_cargo_toml(parent_dir) {
440 let workspace_project_version =
441 parse_ink_project_version_inner(&workspace_cargo_toml_path, true);
442 if workspace_project_version.is_some() {
443 return workspace_project_version;
444 }
445 }
446 }
447 return project_version;
448 }
449 return None;
450
451 fn parse_ink_project_version_inner(
452 cargo_toml_path: &PathBuf,
453 workspace: bool,
454 ) -> Option<InkProjectVersion> {
455 if cargo_toml_path.is_file() {
456 if let Ok(cargo_toml) = fs::read_to_string(cargo_toml_path) {
457 if let Ok(package) = toml::from_str::<toml::Table>(&cargo_toml) {
458 let dependencies = if workspace {
459 if let Some(toml::Value::Table(workspace)) = package.get("workspace") {
460 workspace.get("dependencies")
461 } else {
462 None
463 }
464 } else {
465 package.get("dependencies")
466 };
467 if let Some(toml::Value::Table(deps)) = dependencies {
468 if let Some(ink_dep) = deps.get("ink") {
469 let (version, path, git, workspace) = match ink_dep {
470 toml::Value::String(ink_version) => {
471 (Some(ink_version.to_owned()), None, None, false)
472 }
473 toml::Value::Table(ink_dep_info) => {
474 let parse_dep_value = |key: &str| match ink_dep_info.get(key) {
475 Some(toml::Value::String(it)) => Some(it.to_owned()),
476 _ => None,
477 };
478 (
479 parse_dep_value("version"),
480 parse_dep_value("path"),
481 parse_dep_value("git"),
482 matches!(
483 ink_dep_info.get("workspace"),
484 Some(toml::Value::Boolean(true))
485 ),
486 )
487 }
488 _ => (None, None, None, false),
489 };
490
491 return (version.is_some()
492 || path.is_some()
493 || git.is_some()
494 || workspace)
495 .then_some(InkProjectVersion {
496 version,
497 path,
498 git,
499 workspace,
500 });
501 }
502 }
503 }
504 }
505 }
506
507 None
508 }
509}
510
511#[derive(Debug)]
515#[allow(dead_code)] struct InkProjectVersion {
517 version: Option<String>,
518 path: Option<String>,
519 git: Option<String>,
520 workspace: bool,
521}
522
523impl InkProjectVersion {
524 fn guess_version(&self) -> Version {
526 self.version
527 .as_ref()
528 .map(|version| {
529 if is_legacy_version_string(version) {
530 Version::Legacy
532 } else if is_gte_v5_1_version_string(version) {
533 Version::V5(MinorVersion::Latest)
535 } else if is_v5_version_string(version) {
536 Version::V5(MinorVersion::Base)
537 } else {
538 Version::V6
539 }
540 })
541 .unwrap_or(Version::V6)
542 }
543}
544
545fn is_legacy_version_string(text: &str) -> bool {
547 static RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^(([\^~>=])|(>=))?(\s)*[01234]\.").unwrap());
548 RE.is_match(text.trim())
549}
550
551fn is_v5_version_string(text: &str) -> bool {
553 static RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^(([\^~>=])|(>=))?(\s)*5\.").unwrap());
554 RE.is_match(text.trim())
555}
556
557fn is_gte_v5_1_version_string(text: &str) -> bool {
559 static RE: Lazy<Regex> =
560 Lazy::new(|| Regex::new(r"^(([\^~>=])|(>=))?(\s)*5\.[123456789]").unwrap());
561 RE.is_match(text.trim())
562}
563
564#[cfg(test)]
565mod tests {
566 use super::*;
567 use crate::test_utils::document_uri;
568 use std::thread;
569 use test_utils::simple_client_config;
570
571 #[test]
572 fn main_loop_and_dispatcher_works() {
573 let (server_connection, client_connection) = lsp_server::Connection::memory();
575
576 let client_capabilities = simple_client_config();
578
579 thread::spawn(|| main_loop(server_connection, client_capabilities));
581
582 let uri = document_uri();
584
585 use lsp_types::notification::Notification;
588 let open_document_notification = lsp_server::Notification {
589 method: lsp_types::notification::DidOpenTextDocument::METHOD.to_owned(),
590 params: serde_json::to_value(lsp_types::DidOpenTextDocumentParams {
591 text_document: lsp_types::TextDocumentItem {
592 uri: uri.clone(),
593 language_id: "rust".to_owned(),
594 version: 0,
595 text: String::new(),
596 },
597 })
598 .unwrap(),
599 };
600 client_connection
602 .sender
603 .send(open_document_notification.into())
604 .unwrap();
605 let message = client_connection.receiver.recv().unwrap();
607 let publish_diagnostics_notification = match message {
608 lsp_server::Message::Notification(it) => Some(it),
609 _ => None,
610 }
611 .unwrap();
612 assert_eq!(
613 publish_diagnostics_notification.method,
614 lsp_types::notification::PublishDiagnostics::METHOD
615 );
616
617 let completion_request_id = lsp_server::RequestId::from(1);
620 let completion_request = lsp_server::Request {
621 id: completion_request_id.clone(),
622 method: lsp_types::request::Completion::METHOD.to_owned(),
623 params: serde_json::to_value(lsp_types::CompletionParams {
624 text_document_position: lsp_types::TextDocumentPositionParams {
625 text_document: lsp_types::TextDocumentIdentifier { uri },
626 position: Default::default(),
627 },
628 work_done_progress_params: Default::default(),
629 partial_result_params: Default::default(),
630 context: None,
631 })
632 .unwrap(),
633 };
634 client_connection
636 .sender
637 .send(completion_request.into())
638 .unwrap();
639 let message = client_connection.receiver.recv().unwrap();
641 let completion_response = match message {
642 lsp_server::Message::Response(it) => Some(it),
643 _ => None,
644 }
645 .unwrap();
646 assert_eq!(completion_response.id, completion_request_id);
647 }
648}