ink_lsp_server/
dispatch.rs

1//! LSP server main loop for dispatching requests, notifications and handling responses.
2
3mod 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
27/// Implements the main loop for dispatching LSP requests, notifications and handling responses.
28pub fn main_loop(
29    connection: lsp_server::Connection,
30    client_capabilities: lsp_types::ClientCapabilities,
31) -> anyhow::Result<()> {
32    // Creates a dispatcher.
33    let mut dispatcher = Dispatcher::new(&connection.sender, client_capabilities);
34
35    // Iterates over a crossbeam channel receiver for LSP messages (blocks until next message is received).
36    // Ref: <https://docs.rs/crossbeam-channel/0.5.8/crossbeam_channel/#iteration>.
37    for msg in &connection.receiver {
38        match msg {
39            lsp_server::Message::Request(req) => {
40                // Handles shutdown request.
41                if connection.handle_shutdown(&req)? {
42                    return Ok(());
43                }
44
45                // Handles all other requests using the dispatcher.
46                dispatcher.handle_request(req)?;
47            }
48            lsp_server::Message::Notification(not) => {
49                // Handles exit notification in case it comes out of band of a shutdown request.
50                use lsp_types::notification::Notification;
51                if not.method == lsp_types::notification::Exit::METHOD {
52                    return Ok(());
53                }
54
55                // Handles all other notifications using the dispatcher.
56                dispatcher.handle_notification(not)?;
57            }
58            // Handles responses to requests initiated by the server (e.g workspace edits).
59            lsp_server::Message::Response(resp) => dispatcher.handle_response(resp)?,
60        }
61    }
62
63    Ok(())
64}
65
66/// A stateful type for dispatching LSP requests and notifications.
67struct 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
77/// An immutable analysis snapshot (and metadata).
78pub struct Snapshot {
79    analysis: Analysis,
80    context: PositionTranslationContext,
81    doc_version: Option<i32>,
82    lang_version: Version,
83}
84
85impl Snapshot {
86    /// Creates an Analysis snapshot.
87    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    /// Creates a dispatcher for an LSP server connection.
109    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    /// Handles LSP requests and sends responses (if any) as appropriate.
123    fn handle_request(&mut self, req: lsp_server::Request) -> anyhow::Result<()> {
124        // Computes request response (if any).
125        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        // Sends response (if any).
162        if let Some(resp) = result {
163            if let Some(ref cmd) = cmd {
164                // Handles command responses.
165                self.process_command_response(cmd, resp)?;
166            } else {
167                // Otherwise return response.
168                self.send(resp.into())?;
169            }
170        }
171
172        // Process memory changes made by request handlers (if any).
173        self.process_changes()?;
174
175        // Increase the "version check fuel" if we just attempted an ink! version migration.
176        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    /// Handles LSP notifications and processes resulting changes to state (if any) as appropriate.
188    pub fn handle_notification(&mut self, not: lsp_server::Notification) -> anyhow::Result<()> {
189        // Routes notification to appropriate handler (if any).
190        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        // Process memory changes (if any) made by notification handlers.
204        self.process_changes()?;
205
206        Ok(())
207    }
208
209    /// Handles LSP responses.
210    fn handle_response(&mut self, resp: lsp_server::Response) -> anyhow::Result<()> {
211        // Open `lib.rs` after project initialization.
212        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    /// Processes changes to state and triggers appropriate actions (if any).
247    fn process_changes(&mut self) -> anyhow::Result<()> {
248        // Retrieves document changes (if any).
249        if let Some(changes) = self.memory.take_changes() {
250            for id in changes {
251                // Converts doc id to LSP URI.
252                let doc_uri = lsp_types::Uri::from_str(&id);
253
254                // Update analysis snapshot.
255                if let Some(doc) = self.memory.get(&id) {
256                    // Parse the ink! version.
257                    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                // Publish diagnostics for each document with changes.
290                if let Ok(uri) = doc_uri {
291                    self.publish_diagnostics(&uri)?;
292                }
293            }
294        }
295
296        Ok(())
297    }
298
299    /// Sends diagnostics notifications to the client for changed (including new) documents.
300    fn publish_diagnostics(&mut self, uri: &lsp_types::Uri) -> anyhow::Result<()> {
301        // Composes and sends `PublishDiagnostics` notification for document with changes.
302        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    /// Handles command responses.
313    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            // Convert `createProject` response into a workspace edit
322            // (only if the client supports the resource operations and document changes).
323            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            // Convert `migrateProject` response into a workspace edit.
344            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            // Convert `extractEvent` response into a workspace edit
369            // (only if the client supports the resource operations and document changes).
370            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            // Return an empty response for commands that should be executed as workspace edits
396            // (e.g. `createProject` and `migrateProject`).
397            let mut empty_resp = resp.clone();
398            empty_resp.result = Some(serde_json::Value::Null);
399            self.send(empty_resp.into())?;
400
401            // Apply workspace edit.
402            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            // Otherwise return response.
410            self.send(resp.into())?;
411        }
412
413        Ok(())
414    }
415
416    /// Sends an LSP message to the client.
417    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
424/// Parses the ink! project version from the `Cargo.toml` file for a given `*.rs` file (if possible).
425fn 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        // Finds `Cargo.toml` for file (if any).
430        let cargo_toml_path = utils::find_cargo_toml(path)?;
431        let project_version = parse_ink_project_version_inner(&cargo_toml_path, false);
432
433        // Handles workspace dependencies.
434        let is_workspace_dependency = project_version.as_ref().is_some_and(|it| it.workspace);
435        if is_workspace_dependency {
436            // Finds `Cargo.toml` in workspace root directory (if any).
437            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/// Represents the ink! project version details as parsed from a `Cargo.toml` file.
512///
513/// Ref: <https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html>
514#[derive(Debug)]
515#[allow(dead_code)] // `path` and `git` are now unused, but will likely be used again.
516struct InkProjectVersion {
517    version: Option<String>,
518    path: Option<String>,
519    git: Option<String>,
520    workspace: bool,
521}
522
523impl InkProjectVersion {
524    // Returns the best guess for the ink! project version.
525    fn guess_version(&self) -> Version {
526        self.version
527            .as_ref()
528            .map(|version| {
529                if is_legacy_version_string(version) {
530                    // We only actually support v4 and upwards.
531                    Version::Legacy
532                } else if is_gte_v5_1_version_string(version) {
533                    // v5.1 introduced some IDE relevant differences.
534                    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
545/// Returns true if the version string matches v4 or earlier.
546fn 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
551/// Returns true if the version string matches v5.
552fn 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
557/// Returns true if the version string matches version v5, but with a minimum minor release of v5.1
558fn 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        // Creates pair of in-memory connections to simulate an LSP client and server.
574        let (server_connection, client_connection) = lsp_server::Connection::memory();
575
576        // Creates client capabilities.
577        let client_capabilities = simple_client_config();
578
579        // Runs the message dispatch loop on a separate thread (because `main_loop` function is blocking).
580        thread::spawn(|| main_loop(server_connection, client_capabilities));
581
582        // Creates test document URI.
583        let uri = document_uri();
584
585        // Verifies that document synchronization notifications (from client to server) trigger `PublishDiagnostics` notifications (from server to client).
586        // Creates `DidOpenTextDocument` notification.
587        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        // Sends `DidOpenTextDocument` notification from client to server.
601        client_connection
602            .sender
603            .send(open_document_notification.into())
604            .unwrap();
605        // Confirms receipt of `PublishDiagnostics` notification by the client.
606        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        // Verifies that LSP requests (from client to server) get appropriate LSP responses (from server to client).
618        // Creates LSP completion request.
619        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        // Sends LSP completion request from client to server.
635        client_connection
636            .sender
637            .send(completion_request.into())
638            .unwrap();
639        // Confirms receipt of LSP completion response by the client.
640        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}