diff --git a/README.md b/README.md index 4a82568af..bf0d7923f 100644 --- a/README.md +++ b/README.md @@ -31,8 +31,18 @@ iris lsp --stdio --config-file ./iris.json `--config` and `--config-file` are mutually exclusive and replace `--source-command` and `--diagnostics-on-open`, `--diagnostics-on-save`, and `--diagnostics-on-change`. File paths are relative to the process working directory, not the editor's workspace or the configuration file's -directory. Settings are read once before the server starts; changing them requires a restart. -Configuration files are not watched, and LSP configuration notifications do not reload settings. +directory. Startup configuration files are read once and are not watched. + +Editors that advertise the LSP `workspace.configuration` capability can provide the same settings +object in the `iris.server` workspace configuration section. Iris requests that section for the first +workspace folder after initialization and requests it again after each +`workspace/didChangeConfiguration` notification; the notification's `settings` value is only an +invalidation signal. Runtime settings take precedence over startup settings. Each response is a +complete runtime layer, so omitted or `null` fields inherit from the startup configuration rather +than from the preceding response. Invalid updates are shown in the editor and leave the last valid +configuration active. Source-setting updates rediscover and reconcile the loaded workspace without +discarding open buffers. Clients without workspace-configuration support continue using only the +startup configuration. The defaults are: diff --git a/compiler-bin/src/lsp.rs b/compiler-bin/src/lsp.rs index cce20faf2..b7ac76f10 100644 --- a/compiler-bin/src/lsp.rs +++ b/compiler-bin/src/lsp.rs @@ -7,6 +7,7 @@ pub mod extension; mod tests; use std::borrow::BorrowMut; +use std::collections::BTreeMap; use std::ops::ControlFlow; use std::path::PathBuf; use std::sync::{Arc, LazyLock}; @@ -27,7 +28,7 @@ use building::lifecycle::{ AnalysisInvalidation, DiskObservation, DocumentKey, DocumentKind, FileLifecycle, ForeignEvent, LifecycleChange, LifecycleEvent, ReloadFailure, SourceEvent, SourceUnitKey, }; -use configuration::{Configuration, SourceDiscovery}; +use configuration::{Configuration, ConfigurationSettings, SourceDiscovery}; use files::{FileId, ForeignSourceKind}; use itertools::Itertools; use lsp_types::notification::Notification; @@ -40,7 +41,10 @@ use tempfile::TempDir; use tokio::task; use tower::ServiceBuilder; -use crate::lsp::capabilities::{negotiate_analyzer_capabilities, negotiate_position_encoding}; +use crate::lsp::capabilities::{ + ConfigurationCapabilities, negotiate_analyzer_capabilities, + negotiate_configuration_capabilities, negotiate_position_encoding, +}; use crate::lsp::error::{AnalyzerResultExt, LspError}; use crate::walk; @@ -73,6 +77,7 @@ fn configure_materialized_prim(engine: &QueryEngine, files: &mut FileLifecycle, pub config: Arc, pub client: ClientSocket, @@ -84,6 +89,12 @@ pub struct State { pub suggestions_cache: Arc>, pub root: Option, + pub configuration_scope: Option, + pub configuration_capabilities: ConfigurationCapabilities, + pub configuration_generation: u64, + pub workspace_loaded: bool, + pub selected_sources: FxHashSet>, + pub excluded_sources: FxHashSet>, pub position_encoding: PositionEncoding, pub analyzer_capabilities: AnalyzerCapabilities, pub watched_files_dynamic_registration: bool, @@ -105,11 +116,18 @@ impl State { let suggestions_cache = Arc::new(RwLock::new(suggestions_cache)); let root = None; + let configuration_scope = None; + let configuration_capabilities = ConfigurationCapabilities::default(); + let configuration_generation = 0; + let workspace_loaded = false; + let selected_sources = FxHashSet::default(); + let excluded_sources = FxHashSet::default(); let position_encoding = PositionEncoding::Utf16; let analyzer_capabilities = AnalyzerCapabilities::default(); let watched_files_dynamic_registration = false; State { + startup_config: Arc::clone(&config), config, client, engine, @@ -118,6 +136,12 @@ impl State { workspace_symbols_cache, suggestions_cache, root, + configuration_scope, + configuration_capabilities, + configuration_generation, + workspace_loaded, + selected_sources, + excluded_sources, position_encoding, analyzer_capabilities, watched_files_dynamic_registration, @@ -214,16 +238,18 @@ fn initialize( let position_encoding = negotiate_position_encoding(&p.initialize_params); state.position_encoding = position_encoding; state.analyzer_capabilities = negotiate_analyzer_capabilities(&p.initialize_params); + state.configuration_capabilities = negotiate_configuration_capabilities(&p.initialize_params); state.watched_files_dynamic_registration = watched_files_dynamic_registration(&p.initialize_params.capabilities); - state.root = p + state.configuration_scope = p .initialize_params .workspace_folders - .and_then(|folders| { - let folder = folders.first()?; - folder.uri.to_file_path().ok() - }) + .and_then(|folders| folders.first().map(|folder| Url::clone(&folder.uri))); + state.root = state + .configuration_scope + .as_ref() + .and_then(|uri| uri.to_file_path().ok()) .or_else(|| env::current_dir().ok()); async move { Ok(InitializeResult { @@ -308,13 +334,130 @@ fn shutdown(_state: &mut State, (): ()) -> impl Future Result<(), LspError> { let _span = tracing::info_span!("initialization").entered(); register_file_watcher(state); + register_configuration_changes(state); - let config = Arc::clone(&state.config); - match &config.sources { - SourceDiscovery::Spago {} => initialized_spago(state), - SourceDiscovery::Command { program, arguments } => { - initialized_manual(state, program, arguments) + if state.configuration_capabilities.workspace_configuration { + request_workspace_configuration(state); + Ok(()) + } else { + apply_configuration(state, Arc::clone(&state.startup_config)) + } +} + +fn register_configuration_changes(state: &State) { + if !state.configuration_capabilities.dynamic_registration { + return; + } + + let registration = Registration { + id: "iris-workspace-configuration".to_string(), + method: notification::DidChangeConfiguration::METHOD.to_string(), + register_options: None, + }; + let parameters = RegistrationParams { registrations: vec![registration] }; + let mut client = ClientSocket::clone(&state.client); + task::spawn(async move { + if let Err(error) = client.register_capability(parameters).await { + tracing::warn!("Failed to register workspace configuration changes: {error}"); + } + }); +} + +struct ConfigurationReceived { + generation: u64, + result: Result, String>, +} + +fn request_workspace_configuration(state: &mut State) { + state.configuration_generation = state.configuration_generation.wrapping_add(1); + let generation = state.configuration_generation; + let parameters = ConfigurationParams { + items: vec![ConfigurationItem { + scope_uri: state.configuration_scope.clone(), + section: Some("iris.server".to_string()), + }], + }; + let mut client = ClientSocket::clone(&state.client); + task::spawn(async move { + let result = tokio::time::timeout( + std::time::Duration::from_secs(10), + client.configuration(parameters), + ) + .await + .map_err(|_| "workspace/configuration request timed out".to_string()) + .and_then(|result| result.map_err(|error| error.to_string())); + if let Err(error) = client.emit(ConfigurationReceived { generation, result }) { + tracing::error!("Failed to deliver workspace configuration: {error}"); } + }); +} + +fn finish_workspace_configuration( + state: &mut State, + ConfigurationReceived { generation, result }: ConfigurationReceived, +) -> Result<(), LspError> { + if generation != state.configuration_generation { + return Ok(()); + } + + let configuration = result + .map_err(|error| format!("Failed to retrieve Iris settings: {error}")) + .and_then(|mut values| { + if values.len() != 1 { + return Err(format!( + "Invalid workspace/configuration response: expected one item, received {}", + values.len() + )); + } + let value = values.pop().expect("invariant violated: expected one configuration item"); + serde_json::from_value::>(value) + .map(|settings| settings.unwrap_or_default().apply_to(&state.startup_config)) + .map_err(|error| format!("Invalid Iris settings: {error}")) + }); + + match configuration { + Ok(configuration) => { + if let Err(error) = apply_configuration(state, Arc::new(configuration)) { + let error = format!("Failed to apply Iris settings: {error}"); + report_configuration_error(state, &error); + if !state.workspace_loaded { + apply_configuration(state, Arc::clone(&state.startup_config))?; + } + } + Ok(()) + } + Err(error) => { + report_configuration_error(state, &error); + if state.workspace_loaded { + Ok(()) + } else { + apply_configuration(state, Arc::clone(&state.startup_config)) + } + } + } +} + +fn did_change_configuration( + state: &mut State, + _: DidChangeConfigurationParams, +) -> Result<(), LspError> { + if state.configuration_capabilities.workspace_configuration { + request_workspace_configuration(state); + } + Ok(()) +} + +fn report_configuration_error(state: &mut State, error: &str) { + tracing::error!("{error}"); + let message = if state.workspace_loaded { + format!("{error}. The previous Iris settings remain active.") + } else { + format!("{error}. Iris will use its startup settings.") + }; + if let Err(error) = + state.client.show_message(ShowMessageParams { typ: MessageType::ERROR, message }) + { + tracing::warn!("Failed to report configuration error: {error}"); } } @@ -363,34 +506,31 @@ fn exit(_state: &mut State, (): ()) -> Result<(), LspError> { Ok(()) } -fn initialized_manual( - state: &mut State, +fn discover_manual( + root: &std::path::Path, program: &str, arguments: &[String], -) -> Result<(), LspError> { - let root = Option::clone(&state.root).ok_or(LspError::MissingRoot)?; - +) -> Result, LspError> { tracing::info!("Using '{}'", program); let mut command = process::Command::new(program); command.args(arguments); let output = command.output()?; + if !output.status.success() { + return Err(LspError::SourceCommandFailed(output.status)); + } let output = str::from_utf8(&output.stdout)?; - let walk::Walk { files, .. } = walk::walk(&root, output.lines())?; + let walk::Walk { files, .. } = walk::walk(root, output.lines())?; let files = files.into_iter().map(|file| { - let editable = file.starts_with(&root); + let editable = file.starts_with(root); (file, editable) }); - load_files(state, files)?; - - Ok(()) + Ok(files.collect()) } -fn initialized_spago(state: &mut State) -> Result<(), LspError> { - let root = state.root.as_ref().ok_or(LspError::MissingRoot)?; - +fn discover_spago(root: &std::path::Path) -> Result, LspError> { tracing::info!("Using 'spago.lock'"); let packages = spago::source_files_by_package(root).map_err(LspError::SpagoLock)?; @@ -399,32 +539,77 @@ fn initialized_spago(state: &mut State) -> Result<(), LspError> { package.sources.into_iter().map(move |file| (file, editable)) }); - let files = files.sorted().collect_vec(); - load_files(state, files)?; + Ok(files.sorted().collect_vec()) +} + +fn apply_configuration( + state: &mut State, + configuration: Arc, +) -> Result<(), LspError> { + if state.workspace_loaded && configuration.sources == state.config.sources { + state.config = configuration; + return Ok(()); + } + let root = state.root.as_ref().ok_or(LspError::MissingRoot)?; + let files = match &configuration.sources { + SourceDiscovery::Spago {} => discover_spago(root)?, + SourceDiscovery::Command { program, arguments } => { + discover_manual(root, program, arguments)? + } + }; + let mut prepared = BTreeMap::new(); + for (path, editable) in files { + let content = fs::read_to_string(&path)?; + prepared.insert(path, (Arc::::from(content), editable)); + } + let refresh_diagnostics = state.workspace_loaded; + reconcile_files(state, &prepared, refresh_diagnostics)?; + state.config = configuration; + state.workspace_loaded = true; Ok(()) } -fn load_files( +fn reconcile_files( state: &mut State, - files: impl IntoIterator, + files: &BTreeMap, bool)>, + refresh_diagnostics: bool, ) -> Result<(), LspError> { - let files = files.into_iter().collect_vec(); tracing::info!("Loading {} files.", files.len()); - let mut lifecycle_change = LifecycleChange::default(); - for (file, editable) in &files { - let url = url::Url::from_file_path(file).map_err(|_| { - let file = PathBuf::clone(file); - LspError::PathParseFail(file) - })?; + let mut selected_sources = FxHashSet::default(); + for path in files.keys() { + let uri = + Url::from_file_path(path).map_err(|_| LspError::PathParseFail(PathBuf::clone(path)))?; + selected_sources.insert(Arc::from(uri.as_str())); + } - let text = fs::read_to_string(file)?; - let unit = source_unit_from_source_uri(&url)?; + let mut lifecycle_change = LifecycleChange::default(); + for source in state.selected_sources.difference(&selected_sources).cloned().collect_vec() { + let uri = Url::parse(&source)?; + let unit = source_unit_from_source_uri(&uri)?; + let event = LifecycleEvent::Source { + unit: SourceUnitKey::clone(&unit), + event: SourceEvent::DiskObserved { disk: DiskObservation::NotFound, metadata: false }, + }; + lifecycle_change.combine(apply_lifecycle_event(state, event)); + for kind in ForeignSourceKind::ALL { + let event = LifecycleEvent::Foreign { + unit: SourceUnitKey::clone(&unit), + kind, + event: ForeignEvent::DiskObserved { disk: DiskObservation::NotFound }, + }; + lifecycle_change.combine(apply_lifecycle_event(state, event)); + } + } + for (file, (content, editable)) in files { + let uri = + Url::from_file_path(file).map_err(|_| LspError::PathParseFail(PathBuf::clone(file)))?; + let unit = source_unit_from_source_uri(&uri)?; let event = LifecycleEvent::Source { unit: SourceUnitKey::clone(&unit), event: SourceEvent::DiskObserved { - disk: DiskObservation::Found(Arc::from(text)), + disk: DiskObservation::Found(Arc::clone(content)), metadata: *editable, }, }; @@ -432,6 +617,15 @@ fn load_files( lifecycle_change.combine(observe_sibling_foreign(state, &unit)?); } finish_lifecycle_change(state, &lifecycle_change)?; + if refresh_diagnostics { + emit_diagnostics_for_change(state, &lifecycle_change)?; + } + + state.excluded_sources.extend(state.selected_sources.difference(&selected_sources).cloned()); + for selected in &selected_sources { + state.excluded_sources.remove(selected); + } + state.selected_sources = selected_sources; tracing::info!("Loaded {} files.", files.len()); @@ -739,7 +933,12 @@ fn did_open(state: &mut State, p: DidOpenTextDocumentParams) -> Result<(), LspEr fn did_close(state: &mut State, p: DidCloseTextDocumentParams) -> Result<(), LspError> { let uri = p.text_document.uri; let (document, unit) = source_unit_from_document_uri(&uri)?; - let disk = observe_disk(&uri); + let source_uri = Arc::::from(unit.source()); + let disk = if state.excluded_sources.contains(&source_uri) { + DiskObservation::NotFound + } else { + observe_disk(&uri) + }; let change = match document { DocumentKind::Foreign(kind) => { let event = @@ -784,10 +983,17 @@ fn did_change_watched_files( match document_kind(&change.uri) { Some(DocumentKind::Foreign(kind)) => { let unit = source_unit_from_foreign_uri(&change.uri)?; + if state.excluded_sources.contains(unit.source()) { + continue; + } foreign_units.insert((unit, kind)); } Some(DocumentKind::Source) => { - source_units.insert(source_unit_from_source_uri(&change.uri)?); + let unit = source_unit_from_source_uri(&change.uri)?; + if state.excluded_sources.contains(unit.source()) { + continue; + } + source_units.insert(unit); } None => {} } @@ -1068,11 +1274,12 @@ pub async fn async_start(config: Arc) { .notification_ext::(did_open) .notification_ext::(did_save) .notification_ext::(did_close) - .notification_ext::(|_, _| Ok(())) + .notification_ext::(did_change_configuration) .notification_ext::(did_change) .notification_ext::(did_change_watched_files) .event_ext::(event::collect_diagnostics) - .event_ext::(event::finish_diagnostics); + .event_ext::(event::finish_diagnostics) + .event_ext::(finish_workspace_configuration); ServiceBuilder::new() .layer(LifecycleLayer::default()) diff --git a/compiler-bin/src/lsp/capabilities.rs b/compiler-bin/src/lsp/capabilities.rs index 4bfc8cd7f..f43b12c67 100644 --- a/compiler-bin/src/lsp/capabilities.rs +++ b/compiler-bin/src/lsp/capabilities.rs @@ -2,6 +2,27 @@ use analyzer::AnalyzerCapabilities; use analyzer::position::PositionEncoding; use lsp_types::{InitializeParams, PositionEncodingKind}; +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct ConfigurationCapabilities { + pub workspace_configuration: bool, + pub dynamic_registration: bool, +} + +pub fn negotiate_configuration_capabilities( + params: &InitializeParams, +) -> ConfigurationCapabilities { + let Some(workspace) = ¶ms.capabilities.workspace else { + return ConfigurationCapabilities::default(); + }; + let workspace_configuration = workspace.configuration == Some(true); + let dynamic_registration = workspace_configuration + && workspace + .did_change_configuration + .as_ref() + .is_some_and(|capability| capability.dynamic_registration == Some(true)); + ConfigurationCapabilities { workspace_configuration, dynamic_registration } +} + pub fn negotiate_analyzer_capabilities(params: &InitializeParams) -> AnalyzerCapabilities { let workspace_edit = params .capabilities @@ -50,7 +71,10 @@ pub fn negotiate_position_encoding(params: &InitializeParams) -> PositionEncodin #[cfg(test)] mod tests { - use lsp_types::{ClientCapabilities, GeneralClientCapabilities}; + use lsp_types::{ + ClientCapabilities, DynamicRegistrationClientCapabilities, GeneralClientCapabilities, + WorkspaceClientCapabilities, + }; use super::*; @@ -102,4 +126,45 @@ mod tests { let encoding = negotiate_position_encoding(¶ms); assert_eq!(encoding, PositionEncoding::Utf32); } + + #[test] + fn workspace_configuration_and_registration_are_negotiated_independently() { + let params = InitializeParams { + capabilities: ClientCapabilities { + workspace: Some(WorkspaceClientCapabilities { + configuration: Some(true), + did_change_configuration: Some(DynamicRegistrationClientCapabilities { + dynamic_registration: Some(true), + }), + ..WorkspaceClientCapabilities::default() + }), + ..ClientCapabilities::default() + }, + ..InitializeParams::default() + }; + + assert_eq!( + negotiate_configuration_capabilities(¶ms), + ConfigurationCapabilities { workspace_configuration: true, dynamic_registration: true } + ); + + let params = InitializeParams { + capabilities: ClientCapabilities { + workspace: Some(WorkspaceClientCapabilities { + configuration: Some(false), + did_change_configuration: Some(DynamicRegistrationClientCapabilities { + dynamic_registration: Some(true), + }), + ..WorkspaceClientCapabilities::default() + }), + ..ClientCapabilities::default() + }, + ..InitializeParams::default() + }; + + assert_eq!( + negotiate_configuration_capabilities(¶ms), + ConfigurationCapabilities::default() + ); + } } diff --git a/compiler-bin/src/lsp/error.rs b/compiler-bin/src/lsp/error.rs index ea846cf15..ccb035034 100644 --- a/compiler-bin/src/lsp/error.rs +++ b/compiler-bin/src/lsp/error.rs @@ -1,5 +1,5 @@ use std::path::PathBuf; -use std::{io, str}; +use std::{io, process, str}; use analyzer::AnalyzerError; use async_lsp::ErrorCode; @@ -37,6 +37,8 @@ pub enum LspError { JoinError(#[from] task::JoinError), #[error("Utf8Error: {0}")] Utf8Error(#[from] str::Utf8Error), + #[error("Source discovery command failed with {0}")] + SourceCommandFailed(process::ExitStatus), #[error("GlobSetError: {0}")] GlobSetError(#[from] globset::Error), #[error("WalkError: {0}")] diff --git a/tests-e2e/tests/package_manager/lsp.rs b/tests-e2e/tests/package_manager/lsp.rs index 3c46ad544..26cfb85a3 100644 --- a/tests-e2e/tests/package_manager/lsp.rs +++ b/tests-e2e/tests/package_manager/lsp.rs @@ -3,7 +3,7 @@ use std::path::Path; use std::process::Child; use std::sync::mpsc::{self, Receiver, RecvTimeoutError}; use std::thread; -use std::time::Duration; +use std::time::{Duration, Instant}; use serde_json::{Value, json}; use url::Url; @@ -15,6 +15,9 @@ struct LanguageServer { messages: Receiver, notifications: Vec, next_request: u32, + configuration: Option, + configuration_requests: usize, + registrations: Vec, } impl LanguageServer { @@ -23,6 +26,24 @@ impl LanguageServer { directory: &str, arguments: &[&str], root: &Path, + ) -> LanguageServer { + LanguageServer::start_with_capabilities( + workspace, + directory, + arguments, + root, + json!({}), + None, + ) + } + + fn start_with_capabilities( + workspace: &TestWorkspace, + directory: &str, + arguments: &[&str], + root: &Path, + capabilities: Value, + configuration: Option, ) -> LanguageServer { let mut arguments = arguments.to_vec(); arguments.extend(["--stdio", "--lsp-log", "off"]); @@ -52,10 +73,18 @@ impl LanguageServer { } } }); - let mut server = LanguageServer { child, messages, notifications: vec![], next_request: 1 }; + let mut server = LanguageServer { + child, + messages, + notifications: vec![], + next_request: 1, + configuration, + configuration_requests: 0, + registrations: vec![], + }; let result = server.request("initialize", json!({ "processId": null, - "capabilities": {}, + "capabilities": capabilities, "workspaceFolders": [{"uri": Url::from_directory_path(root).unwrap(), "name": "project"}] })); assert!(result["capabilities"].is_object(), "{result}"); @@ -74,17 +103,118 @@ impl LanguageServer { self.send(json!({"jsonrpc": "2.0", "method": method, "params": parameters})); } + #[track_caller] fn request(&mut self, method: &str, parameters: Value) -> Value { - let request = self.next_request; - self.next_request += 1; - self.send(json!({"jsonrpc": "2.0", "id": request, "method": method, "params": parameters})); - loop { - let message = self.messages.recv_timeout(Duration::from_secs(10)).unwrap(); - if message.get("id") == Some(&json!(request)) { - assert!(message.get("error").is_none(), "{message}"); - return message["result"].clone(); + for _ in 0..20 { + let request = self.next_request; + self.next_request += 1; + let deadline = Instant::now() + Duration::from_secs(10); + let waiting_for = format!("response to {method} request"); + self.send(json!({ + "jsonrpc": "2.0", + "id": request, + "method": method, + "params": parameters + })); + loop { + let message = self.receive_before(deadline, &waiting_for); + if message.get("method").is_some() { + self.handle_server_message(message); + continue; + } + if message.get("id") == Some(&json!(request)) { + if message["error"]["code"] == -32800 { + break; + } + assert!(message.get("error").is_none(), "{message}"); + return message["result"].clone(); + } + panic!("unexpected response while waiting for {waiting_for}: {message}"); } + thread::sleep(Duration::from_millis(10)); + } + panic!("request {method} was repeatedly cancelled"); + } + + fn handle_server_message(&mut self, message: Value) { + let Some(request) = message.get("id").cloned() else { self.notifications.push(message); + return; + }; + match message["method"].as_str().unwrap() { + "workspace/configuration" => { + let items = message["params"]["items"].as_array().unwrap(); + assert_eq!(items.len(), 1, "{message}"); + assert_eq!(items[0]["section"], "iris.server"); + assert!(items[0]["scopeUri"].is_string(), "{message}"); + self.configuration_requests += 1; + let configuration = self.configuration.clone().unwrap_or(Value::Null); + self.send(json!({"jsonrpc": "2.0", "id": request, "result": [configuration]})); + } + "client/registerCapability" => { + self.registrations.push(message["params"].clone()); + self.send(json!({"jsonrpc": "2.0", "id": request, "result": null})); + } + method => panic!("unexpected server request {method}: {message}"), + } + } + + fn set_configuration(&mut self, configuration: Value) { + self.configuration = Some(configuration); + self.notify("workspace/didChangeConfiguration", json!({"settings": null})); + } + + fn wait_for_symbol(&mut self, name: &str, present: bool) { + for _ in 0..20 { + let symbols = self.request("workspace/symbol", json!({"query": name})); + let found = symbols.as_array().unwrap().iter().any(|symbol| symbol["name"] == name); + if found == present { + return; + } + thread::sleep(Duration::from_millis(50)); + } + panic!("symbol {name:?} presence did not become {present}"); + } + + #[track_caller] + fn wait_for_notification(&mut self, method: &str) -> Value { + self.wait_for_notification_matching(method, |_| true) + } + + #[track_caller] + fn wait_for_notification_matching( + &mut self, + method: &str, + predicate: impl Fn(&Value) -> bool, + ) -> Value { + let deadline = Instant::now() + Duration::from_secs(10); + let waiting_for = format!("notification {method}"); + loop { + if let Some(index) = self.notifications.iter().position(|notification| { + notification["method"] == method && predicate(notification) + }) { + return self.notifications.remove(index); + } + let message = self.receive_before(deadline, &waiting_for); + if message.get("method").is_some() { + self.handle_server_message(message); + } else { + panic!("unexpected response while waiting for notification {method}: {message}") + } + } + } + + #[track_caller] + fn receive_before(&self, deadline: Instant, waiting_for: &str) -> Value { + let remaining = deadline + .checked_duration_since(Instant::now()) + .unwrap_or_else(|| panic!("timed out waiting for {waiting_for}")); + match self.messages.recv_timeout(remaining) { + Ok(message) => message, + Err(RecvTimeoutError::Timeout) => panic!("timed out waiting for {waiting_for}"), + Err(RecvTimeoutError::Disconnected) => { + panic!("language server disconnected while waiting for {waiting_for}") + } } } @@ -93,22 +223,30 @@ impl LanguageServer { // the bounded wait for asynchronous diagnostics, including when none are expected. self.request("workspace/symbol", json!({"query": "noSuchSymbol"})); if enabled { - let message = if self.notifications.is_empty() { - self.messages.recv_timeout(Duration::from_secs(10)).unwrap() - } else { - self.notifications.remove(0) - }; - assert_eq!(message["method"], "textDocument/publishDiagnostics"); + let message = self.wait_for_notification_matching( + "textDocument/publishDiagnostics", + |notification| notification["params"]["uri"] == uri.as_str(), + ); assert_eq!(message["params"]["uri"], uri.as_str()); assert_eq!(message["params"]["version"], version); let diagnostics = message["params"]["diagnostics"].as_array().unwrap(); assert!(diagnostics.iter().any(|diagnostic| diagnostic["severity"] == 1), "{message}"); } else { - assert!(self.notifications.is_empty(), "{:?}", self.notifications); - assert_eq!( - self.messages.recv_timeout(Duration::from_millis(500)), - Err(RecvTimeoutError::Timeout) - ); + assert!(!self.notifications.iter().any(|notification| { + notification["method"] == "textDocument/publishDiagnostics" + && notification["params"]["uri"] == uri.as_str() + })); + match self.messages.recv_timeout(Duration::from_millis(500)) { + Ok(message) if message.get("method").is_some() => { + assert_ne!(message["params"]["uri"], uri.as_str(), "{message}"); + self.handle_server_message(message); + } + Ok(message) => { + panic!("unexpected response while checking diagnostics are disabled: {message}") + } + Err(RecvTimeoutError::Timeout) => {} + Err(error) => panic!("language server disconnected: {error}"), + } } } @@ -137,8 +275,20 @@ fn assert_diagnostic_triggers( on_save: bool, on_change: bool, ) { - let uri = Url::from_file_path(root.join("Main.purs")).unwrap(); - let text = "module Main where\nvalue :: Int\nvalue = \"invalid\"\n"; + assert_diagnostic_triggers_for(server, root, "Main.purs", on_open, on_save, on_change); +} + +fn assert_diagnostic_triggers_for( + server: &mut LanguageServer, + root: &Path, + file: &str, + on_open: bool, + on_save: bool, + on_change: bool, +) { + let uri = Url::from_file_path(root.join(file)).unwrap(); + let module = file.strip_suffix(".purs").unwrap(); + let text = format!("module {module} where\nvalue :: Int\nvalue = \"invalid\"\n"); server.notify( "textDocument/didOpen", json!({ @@ -245,3 +395,132 @@ fn partial_diagnostic_configuration_preserves_omitted_triggers() { assert_diagnostic_triggers(&mut server, workspace.path(), false, true, false); server.shutdown(); } + +#[test] +fn workspace_configuration_applies_initial_and_runtime_snapshots() { + let workspace = TestWorkspace::empty(); + workspace.write("project/startup/Library.purs", "module Library where\nfromStartup = 1\n"); + workspace.write("project/runtime/Library.purs", "module Library where\nfromRuntime = 2\n"); + workspace.write("launcher/startup.mjs", "console.log('../project/startup/*.purs');\n"); + workspace.write("launcher/runtime.mjs", "console.log('../project/runtime/*.purs');\n"); + let startup = r#"{"sources":{"kind":"command","program":"node","arguments":["startup.mjs"]}}"#; + let runtime = json!({ + "sources": {"kind": "command", "program": "node", "arguments": ["runtime.mjs"]}, + "diagnostics": {"onOpen": false, "onSave": false, "onChange": true} + }); + let root = workspace.path().join("project"); + let mut server = LanguageServer::start_with_capabilities( + &workspace, + "launcher", + &["lsp", "--config", startup], + &root, + json!({"workspace": {"configuration": true}}), + Some(runtime), + ); + + server.wait_for_symbol("fromRuntime", true); + server.wait_for_symbol("fromStartup", false); + assert_diagnostic_triggers(&mut server, &root, false, false, true); + let runtime_uri = Url::from_file_path(root.join("runtime/Library.purs")).unwrap(); + server.notify( + "textDocument/didOpen", + json!({ + "textDocument": { + "uri": runtime_uri, + "languageId": "purescript", + "version": 1, + "text": "module Library where\nunsavedRuntime = 3\n" + } + }), + ); + server.wait_for_symbol("unsavedRuntime", true); + + server.set_configuration(json!({"diagnostics": {"onOpen": true}})); + server.wait_for_symbol("fromStartup", true); + server.wait_for_symbol("fromRuntime", false); + server.wait_for_symbol("unsavedRuntime", true); + server.notify("textDocument/didClose", json!({"textDocument": {"uri": runtime_uri}})); + server.wait_for_symbol("unsavedRuntime", false); + assert_diagnostic_triggers_for(&mut server, &root, "AfterUpdate.purs", true, true, false); + assert!(server.configuration_requests >= 2); + server.shutdown(); +} + +#[test] +fn invalid_runtime_configuration_preserves_the_previous_workspace() { + let workspace = TestWorkspace::empty(); + workspace.write( + "spago.lock", + r#"{"workspace":{"packages":{"application":{"path":"."}}},"packages":{}}"#, + ); + workspace.write("src/Library.purs", "module Library where\nstillLoaded = 42\n"); + workspace.write( + "slow failure.mjs", + r#" +setTimeout(() => { + process.stderr.write("slow failure\n"); + process.exit(1); +}, 1000); +"#, + ); + let mut server = LanguageServer::start_with_capabilities( + &workspace, + "", + &["lsp"], + workspace.path(), + json!({ + "workspace": { + "configuration": true, + "didChangeConfiguration": {"dynamicRegistration": true} + } + }), + Some(json!({})), + ); + server.wait_for_symbol("stillLoaded", true); + + server.set_configuration(json!({"diagnostics": {"onOpen": "invalid"}})); + let message = server.wait_for_notification("window/showMessage"); + assert_eq!(message["params"]["type"], 1); + assert!( + message["params"]["message"] + .as_str() + .unwrap() + .contains("previous Iris settings remain active") + ); + server.wait_for_symbol("stillLoaded", true); + + server.set_configuration(json!({ + "sources": { + "kind": "command", + "program": "node", + "arguments": ["slow failure.mjs"] + } + })); + let message = server.wait_for_notification("window/showMessage"); + assert!( + message["params"]["message"].as_str().unwrap().contains("Failed to apply Iris settings") + ); + server.wait_for_symbol("stillLoaded", true); + assert!(server.registrations.iter().any(|parameters| { + parameters["registrations"][0]["method"] == "workspace/didChangeConfiguration" + })); + server.shutdown(); +} + +#[test] +fn clients_without_workspace_configuration_keep_startup_settings() { + let workspace = TestWorkspace::empty(); + workspace.write( + "spago.lock", + r#"{"workspace":{"packages":{"application":{"path":"."}}},"packages":{}}"#, + ); + workspace.write("src/Library.purs", "module Library where\nstartupOnly = 42\n"); + let mut server = LanguageServer::start(&workspace, "", &["lsp"], workspace.path()); + server.notify( + "workspace/didChangeConfiguration", + json!({"settings": {"sources": {"kind": "command", "program": "missing"}}}), + ); + server.wait_for_symbol("startupOnly", true); + assert_eq!(server.configuration_requests, 0); + server.shutdown(); +}