// SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: 2023 The Gleam contributors use super::{ DownloadDependencies, MakeLocker, engine::{self, LanguageServerEngine}, feedback::{Feedback, FeedbackBookKeeper}, files::FileSystemProxy, messages::{Message, MessageBuffer, Next, Notification, Request}, progress::ConnectionProgressReporter, router::Router, src_span_to_lsp_range, }; use camino::{Utf8Path, Utf8PathBuf}; use debug_ignore::DebugIgnore; use gleam_core::{ Result, diagnostic::{Diagnostic, ExtraLabel, Level}, io::{BeamCompilerIO, CommandExecutor, FileSystemReader, FileSystemWriter}, line_numbers::LineNumbers, }; use itertools::Itertools; use lsp_server::ResponseError; use lsp_types::{ self as lsp, FileOperationFilter, FileOperationOptions, FileOperationPattern, FileOperationPatternKind, FileOperationRegistrationOptions, InitializeParams, Position, PublishDiagnosticsParams, Range, RenameFilesParams, RenameOptions, TextEdit, Uri as Url, WorkspaceOptions, }; use serde_json::Value as Json; use std::collections::{HashMap, HashSet}; /// This class is responsible for handling the language server protocol and /// delegating the work to the engine. /// /// - Configuring watching of the `gleam.toml` file. /// - Decoding requests. /// - Encoding responses. /// - Sending diagnostics and messages to the client. /// - Tracking the state of diagnostics and messages. /// - Performing the initialisation handshake. /// #[derive(Debug)] pub struct LanguageServer<'a, IO> { initialise_params: InitializeParams, connection: DebugIgnore<&'a lsp_server::Connection>, outside_of_project_feedback: FeedbackBookKeeper, router: Router>, changed_projects: HashSet, io: FileSystemProxy, } impl<'a, IO> LanguageServer<'a, IO> where IO: FileSystemReader + FileSystemWriter + BeamCompilerIO + CommandExecutor + DownloadDependencies + MakeLocker + Clone, { pub fn new(connection: &'a lsp_server::Connection, io: IO) -> Result { let initialise_params = initialisation_handshake(connection); let reporter = ConnectionProgressReporter::new(connection, &initialise_params); let io = FileSystemProxy::new(io); let router = Router::new(reporter, io.clone()); Ok(Self { connection: connection.into(), initialise_params, changed_projects: HashSet::new(), outside_of_project_feedback: FeedbackBookKeeper::default(), router, io, }) } pub fn run(&mut self) -> Result<()> { self.start_watching_gleam_toml(); let mut buffer = MessageBuffer::new(); loop { match buffer.receive(*self.connection) { Next::Stop => break, Next::MorePlease => (), Next::Handle(messages) => { for message in messages { self.handle_message(message); } } } } Ok(()) } fn handle_message(&mut self, message: Message) { match message { Message::Request(id, request) => self.handle_request(id, request), Message::Notification(notification) => self.handle_notification(notification), } } fn handle_request(&mut self, id: lsp_server::RequestId, request: Request) { let (outcome, feedback) = match request { Request::Format(param) => self.format(param), Request::Hover(param) => self.hover(param), Request::GoToDefinition(param) => self.goto_definition(param), Request::Completion(param) => self.completion(param), Request::CodeAction(param) => self.code_action(param), Request::SignatureHelp(param) => self.signature_help(param), Request::DocumentSymbol(param) => self.document_symbol(param), Request::FoldingRange(param) => self.folding_range(param), Request::PrepareRename(param) => self.prepare_rename(param), Request::Rename(param) => self.rename(param), Request::GoToTypeDefinition(param) => self.goto_type_definition(param), Request::FindReferences(param) => self.find_references(param), Request::DocumentHighlight(param) => self.document_highlight(param), Request::RenameFiles(param) => self.rename_files(param), }; self.publish_feedback(feedback); let response = match outcome { Ok(payload) => lsp_server::Response { id, error: None, result: Some(payload), }, Err(error) => lsp_server::Response { id, error: Some(error), result: None, }, }; self.connection .sender .send(lsp_server::Message::Response(response)) .expect("channel send LSP response"); } fn handle_notification(&mut self, notification: Notification) { let feedback = match notification { Notification::CompilePlease => self.compile_please(), Notification::SourceFileOpened { path, text } => self.source_file_opened(path, text), Notification::SourceFileClosed { path } => self.source_file_closed(path), Notification::SourceFileSaved { path } => self.discard_in_memory_cache(path), Notification::SourceFileChangedInMemory { path, text } => { self.cache_file_in_memory(path, text) } Notification::ConfigFileChanged { path } => self.watched_files_changed(path), }; self.publish_feedback(feedback); } fn publish_feedback(&self, feedback: Feedback) { self.publish_diagnostics(feedback.diagnostics); self.publish_messages(feedback.messages); } fn publish_diagnostics(&self, diagnostics: HashMap>) { for (path, diagnostics) in diagnostics { let diagnostics = diagnostics .into_iter() .flat_map(diagnostic_to_lsp) .collect::>(); let uri = path_to_uri(path); // Publish the diagnostics let diagnostic_params = PublishDiagnosticsParams { uri, diagnostics, version: None, }; let notification = lsp_server::Notification { method: "textDocument/publishDiagnostics".into(), params: serde_json::to_value(diagnostic_params) .expect("textDocument/publishDiagnostics to json"), }; self.connection .sender .send(lsp_server::Message::Notification(notification)) .expect("send textDocument/publishDiagnostics"); } } fn start_watching_gleam_toml(&mut self) { let supports_watch_files = self .initialise_params .capabilities .workspace .as_ref() .and_then(|w| w.did_change_watched_files) .map(|wf| wf.dynamic_registration.unwrap_or(false)) .unwrap_or(false); if !supports_watch_files { tracing::warn!("lsp_client_cannot_watch_gleam_toml"); return; } // Register gleam.toml as a watched file so we get a notification when // it changes and thus know that we need to rebuild the entire project. let watch_config = lsp::Registration { id: "watch-gleam-toml".into(), method: "workspace/didChangeWatchedFiles".into(), register_options: Some( serde_json::value::to_value(lsp::DidChangeWatchedFilesRegistrationOptions { watchers: vec![lsp::FileSystemWatcher { glob_pattern: "**/gleam.toml".to_string().into(), kind: Some(lsp::WatchKind::Change), }], }) .expect("workspace/didChangeWatchedFiles to json"), ), }; let request = lsp_server::Request { id: 1.into(), method: "client/registerCapability".into(), params: serde_json::value::to_value(lsp::RegistrationParams { registrations: vec![watch_config], }) .expect("client/registerCapability to json"), }; self.connection .sender .send(lsp_server::Message::Request(request)) .expect("send client/registerCapability"); } fn publish_messages(&self, messages: Vec) { for message in messages { let params = lsp::ShowMessageParams { kind: match message.level { Level::Error => lsp::MessageType::Error, Level::Warning => lsp::MessageType::Warning, }, message: message.text, }; let notification = lsp_server::Notification { method: "window/showMessage".into(), params: serde_json::to_value(params).expect("window/showMessage to json"), }; self.connection .sender .send(lsp_server::Message::Notification(notification)) .expect("send window/showMessage"); } } fn respond_with_engine( &mut self, path: Utf8PathBuf, handler: Handler, ) -> (Result, Feedback) where T: serde::Serialize, Handler: FnOnce( &mut LanguageServerEngine>, ) -> engine::Response, { self.fallible_respond_with_engine(path, |engine| { let response = handler(engine); engine::Response { result: response.result.map(Ok), warnings: response.warnings, compilation: response.compilation, } }) } fn fallible_respond_with_engine( &mut self, path: Utf8PathBuf, handler: Handler, ) -> (Result, Feedback) where T: serde::Serialize, Handler: FnOnce( &mut LanguageServerEngine>, ) -> engine::Response>, { match self.router.project_for_path(path) { Ok(Some(project)) => { let engine::Response { result, warnings, compilation, } = handler(&mut project.engine); match result { Ok(Ok(value)) => { let feedback = project.feedback.response(compilation, warnings); let json = serde_json::to_value(value).expect("response to json"); (Ok(json), feedback) } Ok(Err(error)) => { let feedback = project.feedback.response(compilation, warnings); (Err(error), feedback) } Err(e) => { let feedback = project.feedback.build_with_error(e, compilation, warnings); (Ok(Json::Null), feedback) } } } Ok(None) => (Ok(Json::Null), Feedback::default()), Err(error) => ( Ok(Json::Null), self.outside_of_project_feedback.error(error), ), } } fn path_error_response( &mut self, path: Utf8PathBuf, error: gleam_core::Error, ) -> (Result, Feedback) { let feedback = match self.router.project_for_path(path) { Ok(Some(project)) => project.feedback.error(error), Ok(None) | Err(_) => self.outside_of_project_feedback.error(error), }; (Ok(Json::Null), feedback) } fn format( &mut self, params: lsp::DocumentFormattingParams, ) -> (Result, Feedback) { let path = super::path(¶ms.text_document.uri); let mut new_text = String::new(); let src = match self.io.read(&path) { Ok(src) => src.into(), Err(error) => return self.path_error_response(path, error), }; if let Err(error) = gleam_format::pretty(&mut new_text, &src, &path) { return self.path_error_response(path, error); } let line_count = src.lines().count() as u32; let edit = TextEdit { range: Range::new(Position::new(0, 0), Position::new(line_count, 0)), new_text, }; let json = serde_json::to_value(vec![edit]).expect("to JSON value"); (Ok(json), Feedback::default()) } fn hover(&mut self, params: lsp::HoverParams) -> (Result, Feedback) { let path = super::path(¶ms.text_document_position_params.text_document.uri); self.respond_with_engine(path, |engine| engine.hover(params)) } fn goto_definition( &mut self, params: lsp::DefinitionParams, ) -> (Result, Feedback) { let path = super::path(¶ms.text_document_position_params.text_document.uri); self.respond_with_engine(path, |engine| engine.goto_definition(params)) } fn goto_type_definition( &mut self, params: lsp_types::TypeDefinitionParams, ) -> (Result, Feedback) { let path = super::path(¶ms.text_document_position_params.text_document.uri); self.respond_with_engine(path, |engine| engine.goto_type_definition(params)) } fn completion( &mut self, params: lsp::CompletionParams, ) -> (Result, Feedback) { let path = super::path(¶ms.text_document_position_params.text_document.uri); let src = match self.io.read(&path) { Ok(src) => src.into(), Err(error) => return self.path_error_response(path, error), }; self.respond_with_engine(path, |engine| { engine.completion(params.text_document_position_params, src) }) } fn signature_help( &mut self, params: lsp_types::SignatureHelpParams, ) -> (Result, Feedback) { let path = super::path(¶ms.text_document_position_params.text_document.uri); self.respond_with_engine(path, |engine| engine.signature_help(params)) } fn code_action( &mut self, params: lsp::CodeActionParams, ) -> (Result, Feedback) { let path = super::path(¶ms.text_document.uri); self.respond_with_engine(path, |engine| engine.code_actions(params)) } fn document_symbol( &mut self, params: lsp::DocumentSymbolParams, ) -> (Result, Feedback) { let path = super::path(¶ms.text_document.uri); self.respond_with_engine(path, |engine| engine.document_symbol(params)) } fn folding_range( &mut self, params: lsp::FoldingRangeParams, ) -> (Result, Feedback) { let path = super::path(¶ms.text_document.uri); self.respond_with_engine(path, |engine| engine.folding_range(params)) } fn prepare_rename( &mut self, params: lsp::PrepareRenameParams, ) -> (Result, Feedback) { let path = super::path(¶ms.text_document_position_params.text_document.uri); self.respond_with_engine(path, |engine| engine.prepare_rename(params)) } fn rename(&mut self, params: lsp::RenameParams) -> (Result, Feedback) { let path = super::path(¶ms.text_document_position_params.text_document.uri); self.fallible_respond_with_engine( path, |engine: &mut LanguageServerEngine>| { engine.rename(params) }, ) } fn rename_files( &mut self, params: RenameFilesParams, ) -> (Result, Feedback) { let renames = params .files .into_iter() .map(|file| { ( Url::parse(&file.old_uri).expect("Uri should be valid"), Url::parse(&file.new_uri).expect("Uri should be valid"), ) }) .collect_vec(); let Some((_, first_renamed_file)) = renames.first() else { return (Ok(serde_json::json!(null)), Feedback::none()); }; self.respond_with_engine(super::path(first_renamed_file), |engine| { engine.rename_files(renames) }) } fn find_references( &mut self, params: lsp_types::ReferenceParams, ) -> (Result, Feedback) { let path = super::path(¶ms.text_document_position_params.text_document.uri); self.respond_with_engine(path, |engine| engine.find_references(params)) } fn document_highlight( &mut self, params: lsp_types::DocumentHighlightParams, ) -> (Result, Feedback) { let path = super::path(¶ms.text_document_position_params.text_document.uri); self.respond_with_engine(path, |engine| engine.document_highlight(params)) } fn cache_file_in_memory(&mut self, path: Utf8PathBuf, text: String) -> Feedback { self.project_changed(&path); if let Err(error) = self.io.write_mem_cache(&path, &text) { return self.outside_of_project_feedback.error(error); } Feedback::none() } fn discard_in_memory_cache(&mut self, path: Utf8PathBuf) -> Feedback { self.project_changed(&path); if let Err(error) = self.io.delete_mem_cache(&path) { return self.outside_of_project_feedback.error(error); } Feedback::none() } fn watched_files_changed(&mut self, path: Utf8PathBuf) -> Feedback { self.router.delete_engine_for_path(&path); Feedback::none() } fn compile_please(&mut self) -> Feedback { let mut accumulator = Feedback::none(); let projects = std::mem::take(&mut self.changed_projects); for path in projects { let (_, feedback) = self.respond_with_engine(path, |this| this.compile_please()); accumulator.append_feedback(feedback); } accumulator } fn project_changed(&mut self, path: &Utf8Path) { let project_path = self.router.project_path(path); if let Some(project_path) = project_path { _ = self.changed_projects.insert(project_path); } } fn source_file_opened(&mut self, path: Utf8PathBuf, text: String) -> Feedback { let mut feedback = self.cache_file_in_memory(path.clone(), text); if let Ok(Some(project)) = self.router.project_for_path(path.clone()) { feedback.append_feedback(project.feedback.open_file(path)); } feedback } fn source_file_closed(&mut self, path: Utf8PathBuf) -> Feedback { let mut feedback = self.discard_in_memory_cache(path.clone()); if let Ok(Some(project)) = self.router.project_for_path(path.clone()) { feedback.append_feedback(project.feedback.close_file(&path)); } feedback } } fn initialisation_handshake(connection: &lsp_server::Connection) -> InitializeParams { let server_capabilities = lsp::ServerCapabilities { text_document_sync: Some( lsp::TextDocumentSyncOptions { open_close: Some(true), change: Some(lsp::TextDocumentSyncKind::Full), will_save: None, will_save_wait_until: None, save: Some( lsp::SaveOptions { include_text: Some(false), } .into(), ), } .into(), ), selection_range_provider: None, hover_provider: Some(true.into()), completion_provider: Some(lsp::CompletionOptions { resolve_provider: None, trigger_characters: Some(vec![".".into()]), all_commit_characters: None, work_done_progress_options: lsp::WorkDoneProgressOptions { work_done_progress: None, }, completion_item: None, }), signature_help_provider: Some(lsp::SignatureHelpOptions { trigger_characters: Some(vec!["(".into(), ",".into(), ":".into()]), retrigger_characters: None, work_done_progress_options: lsp::WorkDoneProgressOptions { work_done_progress: None, }, }), definition_provider: Some(true.into()), type_definition_provider: Some(true.into()), implementation_provider: None, references_provider: Some(true.into()), document_highlight_provider: Some(true.into()), document_symbol_provider: Some(true.into()), workspace_symbol_provider: None, code_action_provider: Some(true.into()), code_lens_provider: None, document_formatting_provider: Some(true.into()), document_range_formatting_provider: None, document_on_type_formatting_provider: None, rename_provider: Some( RenameOptions { prepare_provider: Some(true), work_done_progress_options: lsp::WorkDoneProgressOptions { work_done_progress: None, }, } .into(), ), document_link_provider: None, color_provider: None, folding_range_provider: Some(true.into()), declaration_provider: None, execute_command_provider: None, workspace: Some(WorkspaceOptions { workspace_folders: None, file_operations: Some(FileOperationOptions { did_create: None, will_create: None, will_rename: Some(FileOperationRegistrationOptions { filters: vec![FileOperationFilter { scheme: Some("file".into()), pattern: FileOperationPattern { glob: "**/*.gleam".into(), matches: Some(FileOperationPatternKind::File), options: None, }, }], }), did_rename: None, did_delete: None, will_delete: None, }), text_document_content: None, }), call_hierarchy_provider: None, semantic_tokens_provider: None, moniker_provider: None, linked_editing_range_provider: None, experimental: None, position_encoding: None, inline_value_provider: None, inlay_hint_provider: None, diagnostic_provider: None, type_hierarchy_provider: None, notebook_document_sync: None, inline_completion_provider: None, }; let server_capabilities_json = serde_json::to_value(server_capabilities).expect("server_capabilities_serde"); let initialise_params_json = connection .initialize(server_capabilities_json) .expect("LSP initialize"); let initialise_params: InitializeParams = serde_json::from_value(initialise_params_json).expect("LSP InitializeParams from json"); initialise_params } fn diagnostic_to_lsp(diagnostic: Diagnostic) -> Vec { let severity = match diagnostic.level { Level::Error => lsp::DiagnosticSeverity::Error, Level::Warning => lsp::DiagnosticSeverity::Warning, }; let hint = diagnostic.hint; let mut text = diagnostic.title; if let Some(label) = diagnostic .location .as_ref() .and_then(|location| location.label.text.as_deref()) { text.push_str("\n\n"); text.push_str(label); if !label.ends_with(['.', '?']) { text.push('.'); } } if !diagnostic.text.is_empty() { text.push_str("\n\n"); text.push_str(&diagnostic.text); } // TODO: Redesign the diagnostic type so that we can be sure there is always // a location. Locationless diagnostics would be handled separately. let location = diagnostic .location .expect("Diagnostic given to LSP without location"); let line_numbers = LineNumbers::new(&location.src); let path = path_to_uri(location.path); let range = src_span_to_lsp_range(location.label.span, &line_numbers); let main = lsp::Diagnostic { range, severity: Some(severity), code: None, code_description: None, source: None, message: text, related_information: related_information( &hint, &location.extra_labels, &path, &line_numbers, range, ), tags: None, data: None, }; match hint { Some(hint) => { let hint = lsp::Diagnostic { severity: Some(lsp::DiagnosticSeverity::Hint), message: hint, // Some editors require this kind of "link" to group diagnostics. // For example, in Zed "go to next diagnostic" would move you from // the warning to the hint in the same location without this. related_information: Some(vec![lsp::DiagnosticRelatedInformation { location: lsp::Location { uri: path, range }, message: String::new(), }]), ..main.clone() }; vec![main, hint] } None => vec![main], } } fn related_information( hint: &Option, extra_labels: &[ExtraLabel], path: &Url, line_numbers: &LineNumbers, range: Range, ) -> Option> { let mut related_info = Vec::with_capacity(extra_labels.len() + 1); // The hint is included as a dedicated diagnostic _and_ the related information // to maximize compatibility if let Some(hint) = hint { let hint = lsp::DiagnosticRelatedInformation { message: hint.clone(), location: lsp::Location { uri: path.clone(), range, }, }; related_info.push(hint); } let additional_info = extra_labels.iter().map(|extra| { let message = extra.label.text.clone().unwrap_or_default(); let location = match &extra.src_info { Some((src, path)) => { let line_numbers = LineNumbers::new(src); lsp::Location { uri: path_to_uri(path.clone()), range: src_span_to_lsp_range(extra.label.span, &line_numbers), } } _ => lsp::Location { uri: path.clone(), range: src_span_to_lsp_range(extra.label.span, line_numbers), }, }; lsp::DiagnosticRelatedInformation { location, message } }); related_info.extend(additional_info); if related_info.is_empty() { None } else { Some(related_info) } } fn path_to_uri(path: Utf8PathBuf) -> Url { let mut file: String = "file://".into(); file.push_str(&path.as_os_str().to_string_lossy()); Url::parse(&file).expect("path_to_uri URL parse") }