Skip to main content

deps_lsp/
server.rs

1use crate::config::DepsConfig;
2use crate::document::{
3    CLIENT_REFRESH_TIMEOUT, ServerState, handle_document_change, handle_document_open,
4};
5use crate::file_watcher;
6use crate::handlers::{
7    code_actions, code_lens, completion, diagnostics, document_link, hover, inlay_hints,
8};
9use deps_core::{PackageName, is_safe_version_string};
10use std::collections::HashMap;
11use std::sync::Arc;
12use tokio::sync::RwLock;
13use tower_lsp_server::ls_types::{
14    CodeActionOptions, CodeActionParams, CodeActionProviderCapability, CodeLens, CodeLensOptions,
15    CodeLensParams, CompletionOptions, CompletionOptionsCompletionItem, CompletionParams,
16    CompletionResponse, DiagnosticOptions, DiagnosticServerCapabilities,
17    DidChangeConfigurationParams, DidChangeTextDocumentParams, DidChangeWatchedFilesParams,
18    DidCloseTextDocumentParams, DidOpenTextDocumentParams, DocumentChanges,
19    DocumentDiagnosticParams, DocumentDiagnosticReport, DocumentDiagnosticReportResult,
20    DocumentLink, DocumentLinkOptions, DocumentLinkParams, ExecuteCommandOptions,
21    ExecuteCommandParams, FullDocumentDiagnosticReport, Hover, HoverParams,
22    HoverProviderCapability, InitializeParams, InitializeResult, InitializedParams, InlayHint,
23    InlayHintParams, MessageType, OneOf, OptionalVersionedTextDocumentIdentifier, Range,
24    Registration, RelatedFullDocumentDiagnosticReport, ServerCapabilities, ServerInfo,
25    TextDocumentEdit, TextDocumentSyncCapability, TextDocumentSyncKind, TextEdit, Uri,
26    WorkspaceEdit,
27};
28use tower_lsp_server::{Client, LanguageServer, jsonrpc::Result};
29
30/// LSP command identifiers.
31mod commands {
32    /// Command to update a dependency version.
33    pub(super) const UPDATE_VERSION: &str = "deps-lsp.updateVersion";
34    /// Command to update every outdated dependency in a document, bound to the code
35    /// lens produced by `handlers::code_lens`.
36    pub(super) const UPDATE_ALL_OUTDATED: &str = crate::handlers::code_lens::COMMAND_ID;
37}
38
39/// Parses a [`DepsConfig`] from a raw JSON settings payload (client
40/// `initializationOptions` or `workspace/didChangeConfiguration` settings), warning and
41/// returning `None` on any failure rather than silently substituting a default-valued
42/// config for the caller to store.
43///
44/// `DepsConfig` carries `#[serde(deny_unknown_fields)]`, so any key that isn't one of its
45/// own top-level fields fails deserialization here rather than being silently ignored —
46/// this is what makes the "keep previous configuration" behavior below actually meaningful
47/// (issue #227 C2). A weaker "at least one recognized key" check was tried first and
48/// rejected: a client that flattens its whole settings tree (e.g. `{"editor": ...,
49/// "diagnostics": ..., "python": ...}`) would still pass that check on the one generic key
50/// it happens to share with `DepsConfig`, then silently reset every *other* section
51/// (`freshness`, `inlay_hints`, ...) to its default — the same silent-wipe bug through a
52/// different door. `deny_unknown_fields` closes it structurally: every unrecognized key,
53/// anywhere in the payload, is a hard rejection. An empty object `{}` still parses fine —
54/// it legitimately means "use every default" for every section.
55fn parse_config(value: serde_json::Value) -> Option<DepsConfig> {
56    match serde_json::from_value::<DepsConfig>(value) {
57        Ok(config) => Some(config),
58        Err(e) => {
59            tracing::warn!(
60                "failed to parse deps-lsp configuration: {e} (keeping previous configuration)"
61            );
62            None
63        }
64    }
65}
66
67/// Validates a newly configured `registries.gitlab_instance_host` value and, when it is
68/// rejected, surfaces the rejection to the user via `window/showMessage` rather than only
69/// `tracing::warn` (security review, issue #466) — an invalid value silently redirecting
70/// `PRIVATE-TOKEN` to `gitlab.com` (or disabling instance-host resolution entirely) with no
71/// visible signal was the exact failure mode that review flagged.
72///
73/// `deps_gitlab_ci::host::GitlabInstanceHost::get` re-validates (and logs at `warn`) the
74/// same value lazily on every read, since `EcosystemRuntime` is feature-agnostic and can't
75/// hold a `GitlabHost` directly (see that struct's docs) — this duplicates just the
76/// validation call, once per config update, to turn it into a one-time, user-visible
77/// notice instead of a read that never surfaces past the log.
78#[cfg(feature = "gitlab-ci")]
79async fn warn_if_gitlab_instance_host_invalid(
80    client: &Client,
81    raw: &str,
82    policy: &deps_core::net_policy::RegistryAccessPolicy,
83) {
84    if let Err(error) = deps_gitlab_ci::GitlabHost::parse(raw, policy) {
85        client
86            .show_message(
87                MessageType::WARNING,
88                format!(
89                    "deps-lsp: registries.gitlab_instance_host value '{raw}' is invalid \
90                     ({error}) and will be ignored — instance-host resolution stays \
91                     unresolved and GITLAB_TOKEN will not be sent to gitlab.com or any other \
92                     host until this is corrected"
93                ),
94            )
95            .await;
96    }
97}
98
99pub struct Backend {
100    pub(crate) client: Client,
101    state: Arc<ServerState>,
102    config: Arc<RwLock<DepsConfig>>,
103    client_capabilities: Arc<RwLock<Option<tower_lsp_server::ls_types::ClientCapabilities>>>,
104}
105
106impl Backend {
107    pub fn new(client: Client) -> Self {
108        Self {
109            client,
110            state: Arc::new(ServerState::new()),
111            config: Arc::new(RwLock::new(DepsConfig::default())),
112            client_capabilities: Arc::new(RwLock::new(None)),
113        }
114    }
115
116    /// Get a reference to the LSP client (primarily for testing/benchmarking).
117    #[doc(hidden)]
118    pub const fn client(&self) -> &Client {
119        &self.client
120    }
121
122    /// Handles opening a document using unified ecosystem registry.
123    async fn handle_open(
124        &self,
125        uri: tower_lsp_server::ls_types::Uri,
126        content: String,
127        version: i32,
128    ) {
129        match handle_document_open(
130            uri.clone(),
131            content,
132            Some(version),
133            Arc::clone(&self.state),
134            self.client.clone(),
135            Arc::clone(&self.config),
136        )
137        .await
138        {
139            Ok(task) => {
140                self.state.spawn_background_task(uri, task).await;
141            }
142            Err(e) => {
143                tracing::error!("failed to open document {:?}: {}", uri, e);
144                self.client
145                    .log_message(MessageType::ERROR, format!("Parse error: {e}"))
146                    .await;
147            }
148        }
149    }
150
151    /// Handles changes to a document using unified ecosystem registry.
152    async fn handle_change(
153        &self,
154        uri: tower_lsp_server::ls_types::Uri,
155        content: String,
156        version: i32,
157    ) {
158        match handle_document_change(
159            uri.clone(),
160            content,
161            Some(version),
162            Arc::clone(&self.state),
163            self.client.clone(),
164            Arc::clone(&self.config),
165        )
166        .await
167        {
168            Ok(task) => {
169                self.state.spawn_background_task(uri, task).await;
170            }
171            Err(e) => {
172                tracing::error!("failed to process document change {:?}: {}", uri, e);
173                // Without this, a rejected change (e.g. oversized content) leaves the
174                // client editing against a stale server-side DocumentState with no
175                // indication the edit was never applied.
176                self.client
177                    .log_message(MessageType::ERROR, format!("Change rejected: {e}"))
178                    .await;
179            }
180        }
181    }
182
183    async fn handle_lockfile_change(&self, lockfile_path: &std::path::Path, ecosystem_id: &str) {
184        let Some(ecosystem) = self.state.ecosystem_registry.get(ecosystem_id) else {
185            tracing::error!("Unknown ecosystem: {}", ecosystem_id);
186            return;
187        };
188
189        let Some(lock_provider) = ecosystem.lockfile_provider() else {
190            tracing::warn!("Ecosystem {} has no lock file provider", ecosystem_id);
191            return;
192        };
193
194        // Find all open documents using this lock file
195        let affected_uris: Vec<Uri> = self
196            .state
197            .documents
198            .iter()
199            .filter_map(|entry| {
200                let uri = entry.key();
201                let doc = entry.value();
202                if doc.ecosystem_id() != ecosystem_id {
203                    return None;
204                }
205                let doc_lockfile = lock_provider.locate_lockfile(uri)?;
206                if doc_lockfile == lockfile_path {
207                    Some(uri.clone())
208                } else {
209                    None
210                }
211            })
212            .collect();
213
214        if affected_uris.is_empty() {
215            tracing::debug!(
216                "No open manifests affected by lock file: {}",
217                lockfile_path.display()
218            );
219            return;
220        }
221
222        tracing::info!(
223            "Updating {} manifest(s) affected by lock file change",
224            affected_uris.len()
225        );
226
227        // Reload lock file (cache was invalidated, so this re-parses)
228        let resolved_versions = match self
229            .state
230            .lockfile_cache
231            .get_or_parse(lock_provider.as_ref(), lockfile_path)
232            .await
233        {
234            Ok(packages) => packages
235                .iter()
236                .map(|(name, pkg)| (PackageName::new(name.as_str()), pkg.version.clone().into()))
237                .collect::<HashMap<PackageName, deps_core::ConcreteVersion>>(),
238            Err(e) => {
239                tracing::error!("Failed to reload lock file: {}", e);
240                self.client
241                    .log_message(
242                        MessageType::ERROR,
243                        format!("Failed to reload lock file: {e}"),
244                    )
245                    .await;
246                HashMap::new()
247            }
248        };
249
250        // Snapshot before the loop and drop the guard: `generate_diagnostics_internal`
251        // doesn't touch `self.config`, but the affected documents are already open
252        // (sourced from `self.state.documents` above), so re-loading them via
253        // `handle_diagnostics` (which re-reads `self.config` per URI) would hold this
254        // guard across a nested read of the same write-preferring `RwLock` — a writer
255        // queued in between would then block that nested read forever.
256        let (freshness, severities, offline) = {
257            let config = self.config.read().await;
258            (
259                config.freshness.to_settings(),
260                config.diagnostics.to_severities(),
261                config.network.offline,
262            )
263        };
264
265        for uri in affected_uris {
266            if let Some(mut doc) = self.state.documents.get_mut(&uri) {
267                doc.update_resolved_versions(resolved_versions.clone());
268            }
269
270            let items = diagnostics::generate_diagnostics_internal(
271                Arc::clone(&self.state),
272                &uri,
273                freshness,
274                severities,
275                offline,
276            )
277            .await;
278
279            self.client.publish_diagnostics(uri, items, None).await;
280        }
281
282        // Detached, capability-gated, timeout-bounded (issue #493): see
283        // `ServerState::spawn_refresh_requests` for rationale.
284        self.state.spawn_refresh_requests(&self.client);
285    }
286
287    /// Reacts to a change in one of `ecosystem_id`'s
288    /// [`deps_core::Ecosystem::watched_config_filenames`] (issue #590) by fully re-parsing
289    /// every currently open document of that ecosystem.
290    ///
291    /// Unlike [`Self::handle_lockfile_change`], this cannot get away with refreshing only
292    /// `resolved_versions` and re-running diagnostics on the existing `ParseResult`: a
293    /// watched config file (e.g. npm's `pnpm-workspace.yaml` catalog, `.npmrc` registry
294    /// override) is resolved *inside* `parse_manifest` itself, so its effect is already
295    /// baked into the cached `ParseResult` and only a real re-parse picks up a change. Every
296    /// open document of the ecosystem is reparsed rather than only those under the changed
297    /// file's directory tree — the per-document "which config file did this resolve against"
298    /// walk each ecosystem does internally (e.g. `deps_npm::catalog::find_workspace_file`)
299    /// isn't exposed through the [`deps_core::Ecosystem`] trait the way
300    /// [`deps_core::lockfile::LockFileProvider::locate_lockfile`] is for lock files, and
301    /// re-parsing an already-open document is cheap.
302    ///
303    /// A thin wrapper around [`crate::document::reparse::reparse_open_documents`] (issue
304    /// #592), the same version-guarded, sequential-await driver a live-reloaded
305    /// `DepsConfig` setting change now also uses — awaited directly here rather than
306    /// spawned, since this is triggered by a `didChangeWatchedFiles` notification handler
307    /// that already returns promptly, unlike `did_change_configuration`'s debounced path.
308    /// `RefetchPolicy::Diff` matches this path's pre-#592 behavior exactly: only
309    /// added/version-changed dependencies are re-fetched, since a watched-config change
310    /// affects how a manifest *parses*, not the routing decisions a forced full refetch
311    /// exists to correct.
312    async fn handle_watched_config_change(&self, ecosystem_id: &'static str) {
313        crate::document::reparse::reparse_open_documents(
314            crate::config::ReparseScope::Ecosystems(vec![ecosystem_id]),
315            crate::document::RefetchPolicy::Diff,
316            "watched config file change",
317            Arc::clone(&self.state),
318            self.client.clone(),
319            Arc::clone(&self.config),
320        )
321        .await;
322    }
323
324    /// Check if client supports work done progress.
325    async fn supports_progress(&self) -> bool {
326        let caps = self.client_capabilities.read().await;
327        caps.as_ref()
328            .and_then(|c| c.window.as_ref())
329            .and_then(|w| w.work_done_progress)
330            .unwrap_or(false)
331    }
332
333    /// Whether the client requires dynamic registration before it will send
334    /// `workspace/didChangeConfiguration` notifications (M3): without this, some clients
335    /// never send the notification at all, making live-reload unverifiable.
336    async fn did_change_configuration_dynamic_registration_supported(&self) -> bool {
337        let caps = self.client_capabilities.read().await;
338        caps.as_ref()
339            .and_then(|c| c.workspace.as_ref())
340            .and_then(|w| w.did_change_configuration.as_ref())
341            .and_then(|d| d.dynamic_registration)
342            .unwrap_or(false)
343    }
344
345    /// Whether the client implements `workspace/diagnostic/refresh`, the notification
346    /// used to nudge a pull-diagnostics client to re-request diagnostics after a
347    /// configuration change (§2.1). Push-only clients are a known v1 gap (M2).
348    async fn diagnostic_refresh_supported(&self) -> bool {
349        let caps = self.client_capabilities.read().await;
350        caps.as_ref()
351            .and_then(|c| c.workspace.as_ref())
352            .and_then(|w| w.diagnostics.as_ref())
353            .and_then(|d| d.refresh_support)
354            .unwrap_or(false)
355    }
356
357    /// Whether the client implements `workspace/inlayHint/refresh` (issue #493: a
358    /// client that never declares this may also never reply, which would hang an
359    /// unbounded `inlay_hint_refresh` await forever).
360    async fn inlay_hint_refresh_supported(&self) -> bool {
361        let caps = self.client_capabilities.read().await;
362        caps.as_ref()
363            .and_then(|c| c.workspace.as_ref())
364            .and_then(|w| w.inlay_hint.as_ref())
365            .and_then(|h| h.refresh_support)
366            .unwrap_or(false)
367    }
368
369    /// Whether the client implements `workspace/codeLens/refresh`. See
370    /// `inlay_hint_refresh_supported` for rationale.
371    async fn code_lens_refresh_supported(&self) -> bool {
372        let caps = self.client_capabilities.read().await;
373        caps.as_ref()
374            .and_then(|c| c.workspace.as_ref())
375            .and_then(|w| w.code_lens.as_ref())
376            .and_then(|c| c.refresh_support)
377            .unwrap_or(false)
378    }
379
380    fn server_capabilities() -> ServerCapabilities {
381        ServerCapabilities {
382            text_document_sync: Some(TextDocumentSyncCapability::Kind(TextDocumentSyncKind::FULL)),
383            completion_provider: Some(CompletionOptions {
384                trigger_characters: Some(vec!["\"".into(), "=".into(), ".".into()]),
385                resolve_provider: Some(false),
386                completion_item: Some(CompletionOptionsCompletionItem {
387                    label_details_support: Some(true),
388                }),
389                ..Default::default()
390            }),
391            hover_provider: Some(HoverProviderCapability::Simple(true)),
392            inlay_hint_provider: Some(OneOf::Left(true)),
393            code_action_provider: Some(CodeActionProviderCapability::Options(CodeActionOptions {
394                code_action_kinds: Some(vec![
395                    tower_lsp_server::ls_types::CodeActionKind::REFACTOR,
396                    tower_lsp_server::ls_types::CodeActionKind::QUICKFIX,
397                ]),
398                ..Default::default()
399            })),
400            code_lens_provider: Some(CodeLensOptions {
401                resolve_provider: Some(false),
402            }),
403            document_link_provider: Some(DocumentLinkOptions {
404                resolve_provider: Some(false),
405                work_done_progress_options: Default::default(),
406            }),
407            diagnostic_provider: Some(DiagnosticServerCapabilities::Options(DiagnosticOptions {
408                identifier: Some("deps".into()),
409                inter_file_dependencies: false,
410                workspace_diagnostics: false,
411                ..Default::default()
412            })),
413            execute_command_provider: Some(ExecuteCommandOptions {
414                commands: vec![
415                    commands::UPDATE_VERSION.into(),
416                    commands::UPDATE_ALL_OUTDATED.into(),
417                ],
418                ..Default::default()
419            }),
420            ..Default::default()
421        }
422    }
423}
424
425impl LanguageServer for Backend {
426    async fn initialize(&self, params: InitializeParams) -> Result<InitializeResult> {
427        tracing::info!("initializing deps-lsp server");
428
429        // Store client capabilities
430        *self.client_capabilities.write().await = Some(params.capabilities.clone());
431        self.state
432            .set_progress_supported(self.supports_progress().await);
433        let inlay_hint_refresh_supported = self.inlay_hint_refresh_supported().await;
434        self.state
435            .set_inlay_hint_refresh_supported(inlay_hint_refresh_supported);
436        let code_lens_refresh_supported = self.code_lens_refresh_supported().await;
437        self.state
438            .set_code_lens_refresh_supported(code_lens_refresh_supported);
439        // Mirrored onto `ServerState` (issue #592) so `document::reparse::reparse_open_documents`
440        // — a free function with no access to `Backend::client_capabilities` — can gate its
441        // post-reparse `workspace/diagnostic/refresh` on it.
442        let diagnostic_refresh_supported = self.diagnostic_refresh_supported().await;
443        self.state
444            .set_diagnostic_refresh_supported(diagnostic_refresh_supported);
445        // Note (issue #493 M1): a client that implements refresh but never declares
446        // `refreshSupport` is gated off here too, and will not see hints/lenses update
447        // after a background fetch until the document is reopened.
448        if !inlay_hint_refresh_supported {
449            tracing::debug!(
450                "client did not declare workspace.inlayHint.refreshSupport; inlay hints won't auto-refresh after background fetches"
451            );
452        }
453        if !code_lens_refresh_supported {
454            tracing::debug!(
455                "client did not declare workspace.codeLens.refreshSupport; code lenses won't auto-refresh after background fetches"
456            );
457        }
458
459        // Parse initialization options
460        if let Some(init_options) = params.initialization_options
461            && let Some(config) = parse_config(init_options)
462        {
463            tracing::debug!("loaded configuration: {:?}", config);
464            self.state
465                .cache
466                .set_registry_policy(config.registries.workspace_registries.to_policy());
467            self.state.nuget_user_profile_sources.store(
468                config.registries.nuget_user_profile_sources,
469                std::sync::atomic::Ordering::Relaxed,
470            );
471            let gitlab_instance_host = (!config.registries.gitlab_instance_host.is_empty())
472                .then(|| config.registries.gitlab_instance_host.clone());
473            #[cfg(feature = "gitlab-ci")]
474            if let Some(raw) = &gitlab_instance_host {
475                warn_if_gitlab_instance_host_invalid(
476                    &self.client,
477                    raw,
478                    &self.state.registry_policy,
479                )
480                .await;
481            }
482            *self
483                .state
484                .gitlab_instance_host
485                .write()
486                .expect("gitlab_instance_host lock poisoned") = gitlab_instance_host;
487            self.state.cache.set_offline(config.network.offline);
488            self.state.cache.set_cache_enabled(config.cache.enabled);
489            self.state
490                .cold_start_limiter
491                .set_min_interval(std::time::Duration::from_millis(
492                    config.cold_start.rate_limit_ms,
493                ));
494            *self.config.write().await = config;
495        }
496
497        Ok(InitializeResult {
498            capabilities: Self::server_capabilities(),
499            server_info: Some(ServerInfo {
500                name: "deps-lsp".into(),
501                version: Some(env!("CARGO_PKG_VERSION").into()),
502            }),
503            offset_encoding: None,
504        })
505    }
506
507    async fn initialized(&self, _: InitializedParams) {
508        tracing::info!("deps-lsp server initialized");
509        self.client
510            .log_message(
511                MessageType::INFO,
512                format!(
513                    "deps-lsp v{} ({} {})",
514                    env!("CARGO_PKG_VERSION"),
515                    env!("GIT_HASH"),
516                    env!("BUILD_TIME")
517                ),
518            )
519            .await;
520
521        // Spawn background cleanup task for cold start rate limiter, supervised so a
522        // panic surfaces as an `error!` log instead of silently stopping cleanup
523        // forever. Spawned before the two registration requests below (issue #493
524        // S1) so an unresponsive client stalling those never delays this from
525        // starting.
526        let state_clone = Arc::clone(&self.state);
527        let cleanup_task = tokio::spawn(async move {
528            let mut interval = tokio::time::interval(std::time::Duration::from_mins(1));
529            loop {
530                interval.tick().await;
531                state_clone
532                    .cold_start_limiter
533                    .cleanup_old_entries(std::time::Duration::from_mins(5));
534                tracing::trace!("Cleaned up old cold start rate limit entries");
535            }
536        });
537        tokio::spawn(async move {
538            // Inner loop never returns (`JoinHandle<!>`), so `Ok` is unreachable and this pattern is irrefutable.
539            let Err(e) = cleanup_task.await;
540            tracing::error!("Cold start rate limiter cleanup task exited unexpectedly: {e}");
541        });
542
543        // Register lock file watchers using patterns from all ecosystems, plus each
544        // ecosystem's non-lockfile watched config files (e.g. npm's pnpm-workspace.yaml
545        // and .npmrc, issue #590) — one registration, since both are just glob-pattern
546        // watches to the client. Timeout-bounded (issue #493 S1): tower-lsp-server 0.23.0
547        // dispatches handlers via `buffer_unordered(4)`, so an unresponsive client hanging
548        // this await would permanently burn one of only 4 concurrent message slots for the
549        // session.
550        let mut patterns = self.state.ecosystem_registry.all_lockfile_patterns();
551        patterns.extend(self.state.ecosystem_registry.all_watched_config_patterns());
552        match tokio::time::timeout(
553            CLIENT_REFRESH_TIMEOUT,
554            file_watcher::register_lock_file_watchers(&self.client, &patterns),
555        )
556        .await
557        {
558            Ok(Ok(())) => {}
559            Ok(Err(e)) => {
560                tracing::warn!("Failed to register file watchers: {}", e);
561                self.client
562                    .log_message(MessageType::WARNING, format!("File watching disabled: {e}"))
563                    .await;
564            }
565            Err(_) => {
566                tracing::warn!("Timed out registering file watchers");
567                self.client
568                    .log_message(
569                        MessageType::WARNING,
570                        "File watching disabled: registration timed out".to_string(),
571                    )
572                    .await;
573            }
574        }
575
576        // Dynamically register for `workspace/didChangeConfiguration` so clients that
577        // gate the notification on this (M3) actually send it — without it, a changed
578        // `freshness.cooldown_secs` would never reach `did_change_configuration`.
579        // Timeout-bounded for the same reason as the file watcher registration above.
580        if self
581            .did_change_configuration_dynamic_registration_supported()
582            .await
583        {
584            let registration = Registration {
585                id: "deps-lsp-did-change-configuration".to_string(),
586                method: "workspace/didChangeConfiguration".to_string(),
587                register_options: None,
588            };
589            match tokio::time::timeout(
590                CLIENT_REFRESH_TIMEOUT,
591                self.client.register_capability(vec![registration]),
592            )
593            .await
594            {
595                Ok(Ok(())) => {}
596                Ok(Err(e)) => tracing::warn!("Failed to register didChangeConfiguration: {}", e),
597                Err(_) => tracing::warn!("Timed out registering didChangeConfiguration"),
598            }
599        }
600    }
601
602    /// Handles `workspace/didChangeConfiguration`, applying a live-reloaded
603    /// [`DepsConfig`] without requiring an editor restart (issue #227 §2.1).
604    ///
605    /// Replace-whole-config semantics, matching [`Self::initialize`]. `null`/absent
606    /// settings mean the client expects the pull form (`workspace/configuration`)
607    /// instead, which is not implemented in v1 — logged at `debug` and otherwise a
608    /// no-op. A payload that fails to parse (or has no keys `DepsConfig` recognizes,
609    /// C2) keeps the previously stored configuration rather than silently resetting it
610    /// to defaults.
611    ///
612    /// Issue #592: beyond applying the new config, a field that affects parse-time
613    /// decisions (currently `registries.workspace_registries`,
614    /// `registries.nuget_user_profile_sources`, `registries.gitlab_instance_host` — see
615    /// `config::reparse_scope`) also
616    /// re-parses every open document its `config::ReparseScope` covers, forcing a full
617    /// re-fetch (`document::RefetchPolicy::AllDependencies`) since the routing changed, not
618    /// the manifest content. A burst of config changes is coalesced into one debounced
619    /// reparse (`ServerState::queue_reparse`) rather than firing once per notification.
620    async fn did_change_configuration(&self, params: DidChangeConfigurationParams) {
621        if params.settings.is_null() {
622            tracing::debug!(
623                "workspace/didChangeConfiguration received null settings; the \
624                 workspace/configuration pull form is not implemented, ignoring"
625            );
626            return;
627        }
628
629        let Some(config) = parse_config(params.settings) else {
630            return;
631        };
632
633        tracing::info!("configuration updated via workspace/didChangeConfiguration");
634
635        // Captured before `config` is moved into the write guard below (`DepsConfig` has
636        // no `Clone`, see revision item 6): these shared-handle updates don't need to be
637        // atomic with the config swap itself (M4) — they're applied *after* `*guard =
638        // config` below, not before, but with no `.await` between the swap and this
639        // update, no other task can observe `self.config` already reflecting the new
640        // value while the policy `Arc` (the thing that actually gates a fetch) still
641        // reflects the old one.
642        let workspace_registries_policy = config.registries.workspace_registries.to_policy();
643        let nuget_user_profile_sources = config.registries.nuget_user_profile_sources;
644        let offline = config.network.offline;
645        let cache_enabled = config.cache.enabled;
646        let cold_start_rate_limit_ms = config.cold_start.rate_limit_ms;
647        let gitlab_instance_host = (!config.registries.gitlab_instance_host.is_empty())
648            .then(|| config.registries.gitlab_instance_host.clone());
649
650        // Diff the old vs new config for parse-affecting changes (issue #592) and swap in
651        // the new config under one write-guard acquisition — `DepsConfig` has no `Clone`,
652        // so the diff must read the not-yet-overwritten guard before `config` is moved
653        // into it.
654        let scope = {
655            let mut guard = self.config.write().await;
656            let scope = crate::config::reparse_scope(
657                &guard,
658                &config,
659                &self.state.workspace_registry_ecosystems,
660            );
661            *guard = config;
662            scope
663        };
664
665        self.state
666            .cache
667            .set_registry_policy(workspace_registries_policy);
668        self.state.nuget_user_profile_sources.store(
669            nuget_user_profile_sources,
670            std::sync::atomic::Ordering::Relaxed,
671        );
672        #[cfg(feature = "gitlab-ci")]
673        if let Some(raw) = &gitlab_instance_host {
674            warn_if_gitlab_instance_host_invalid(&self.client, raw, &self.state.registry_policy)
675                .await;
676        }
677        *self
678            .state
679            .gitlab_instance_host
680            .write()
681            .expect("gitlab_instance_host lock poisoned") = gitlab_instance_host;
682        // Must land before either refresh notification below, or the refresh re-renders
683        // diagnostics under the stale flag values (critic M5).
684        self.state.cache.set_offline(offline);
685        self.state.cache.set_cache_enabled(cache_enabled);
686        self.state
687            .cold_start_limiter
688            .set_min_interval(std::time::Duration::from_millis(cold_start_rate_limit_ms));
689
690        match scope {
691            Some(scope) => {
692                // Coalescing: union into the pending scope and bump the generation before
693                // spawning a debounced worker, so a burst of changes collapses into one
694                // reparse without losing any individual change's scope.
695                let generation = self.state.queue_reparse(scope);
696                let state = Arc::clone(&self.state);
697                let client = self.client.clone();
698                let config = Arc::clone(&self.config);
699                tokio::spawn(async move {
700                    tokio::time::sleep(crate::document::reparse::RECONFIGURE_DEBOUNCE).await;
701                    let superseded = state.config_generation() != generation;
702                    // Security M3: a superseded worker normally defers to the newer one —
703                    // but under a continuous burst arriving faster than the debounce
704                    // window, every worker would see itself superseded forever, leaving a
705                    // security-relevant setting's reparse starved indefinitely while
706                    // `set_registry_policy` has already taken effect. Once the pending
707                    // scope has been waiting at least `MAX_DEBOUNCE_WAIT`, drain it
708                    // regardless of staleness.
709                    if superseded
710                        && !state
711                            .pending_reparse_overdue(crate::document::reparse::MAX_DEBOUNCE_WAIT)
712                    {
713                        return;
714                    }
715                    let Some(scope) = state.take_pending_reparse() else {
716                        return;
717                    };
718                    crate::document::reparse::reparse_open_documents(
719                        scope,
720                        crate::document::RefetchPolicy::AllDependencies,
721                        "workspace/didChangeConfiguration",
722                        state,
723                        client,
724                        config,
725                    )
726                    .await;
727                });
728            }
729            None => {
730                // Nothing parse-affecting changed. Hover/completion/code actions are
731                // computed on demand and pick up the new config for free. Diagnostics are
732                // pull-based, so a pull-capable client must be told to re-request them
733                // (push-only clients are a known v1 gap, M2). Timeout-bounded (issue #493,
734                // same class as S1): capability-gated already, but an unresponsive client
735                // would otherwise hang this handler forever.
736                if self.diagnostic_refresh_supported().await {
737                    match tokio::time::timeout(
738                        CLIENT_REFRESH_TIMEOUT,
739                        self.client.workspace_diagnostic_refresh(),
740                    )
741                    .await
742                    {
743                        Ok(Ok(())) => {}
744                        Ok(Err(e)) => {
745                            tracing::debug!("workspace/diagnostic/refresh failed: {:?}", e);
746                        }
747                        Err(_) => tracing::debug!("workspace/diagnostic/refresh timed out"),
748                    }
749                }
750            }
751        }
752    }
753
754    fn shutdown(&self) -> impl std::future::Future<Output = Result<()>> + Send {
755        tracing::info!("shutting down deps-lsp server");
756        std::future::ready(Ok(()))
757    }
758
759    async fn did_open(&self, params: DidOpenTextDocumentParams) {
760        let uri = params.text_document.uri;
761        let content = params.text_document.text;
762        let version = params.text_document.version;
763
764        tracing::info!("document opened: {:?}", uri);
765
766        // Use ecosystem registry to check if we support this file type
767        if self.state.ecosystem_registry.get_for_uri(&uri).is_none() {
768            tracing::debug!("unsupported file type: {:?}", uri);
769            return;
770        }
771
772        self.handle_open(uri, content, version).await;
773    }
774
775    async fn did_change(&self, params: DidChangeTextDocumentParams) {
776        let uri = params.text_document.uri;
777        let version = params.text_document.version;
778
779        if let Some(change) = params.content_changes.first() {
780            let content = change.text.clone();
781
782            // Use ecosystem registry to check if we support this file type
783            if self.state.ecosystem_registry.get_for_uri(&uri).is_none() {
784                tracing::debug!("unsupported file type: {:?}", uri);
785                return;
786            }
787
788            self.handle_change(uri, content, version).await;
789        }
790    }
791
792    async fn did_close(&self, params: DidCloseTextDocumentParams) {
793        let uri = params.text_document.uri;
794        tracing::info!("document closed: {:?}", uri);
795
796        self.state.remove_document(&uri);
797        self.state.cancel_background_task(&uri).await;
798    }
799
800    async fn did_change_watched_files(&self, params: DidChangeWatchedFilesParams) {
801        tracing::debug!("Received {} file change events", params.changes.len());
802
803        for change in params.changes {
804            let Some(path) = change.uri.to_file_path() else {
805                tracing::warn!("Invalid file path in change event: {:?}", change.uri);
806                continue;
807            };
808
809            let Some(filename) = file_watcher::extract_lockfile_name(&path) else {
810                continue;
811            };
812
813            if let Some(ecosystem) = self.state.ecosystem_registry.get_for_lockfile(filename) {
814                tracing::info!(
815                    "Lock file changed: {} (ecosystem: {})",
816                    filename,
817                    ecosystem.id()
818                );
819
820                self.state.lockfile_cache.invalidate(&path);
821                self.handle_lockfile_change(&path, ecosystem.id()).await;
822                continue;
823            }
824
825            if let Some(ecosystem) = self
826                .state
827                .ecosystem_registry
828                .get_for_watched_config(filename)
829            {
830                tracing::info!(
831                    "Watched config file changed: {} (ecosystem: {})",
832                    filename,
833                    ecosystem.id()
834                );
835
836                // No cache invalidation here (unlike the lock-file branch above): every
837                // `MtimeFileCache`-backed config cache (e.g. `PnpmWorkspaceCache`,
838                // `NpmConfigCache`) already invalidates itself by mtime on its next
839                // `get_or_parse` — the reparse below is what triggers that next call.
840                self.handle_watched_config_change(ecosystem.id()).await;
841                continue;
842            }
843
844            tracing::debug!("Skipping unrecognized watched-file change: {}", filename);
845        }
846    }
847
848    async fn hover(&self, params: HoverParams) -> Result<Option<Hover>> {
849        Ok(hover::handle_hover(
850            Arc::clone(&self.state),
851            params,
852            self.client.clone(),
853            Arc::clone(&self.config),
854        )
855        .await)
856    }
857
858    async fn completion(&self, params: CompletionParams) -> Result<Option<CompletionResponse>> {
859        Ok(completion::handle_completion(
860            Arc::clone(&self.state),
861            params,
862            self.client.clone(),
863            Arc::clone(&self.config),
864        )
865        .await)
866    }
867
868    async fn inlay_hint(&self, params: InlayHintParams) -> Result<Option<Vec<InlayHint>>> {
869        // Clone config before async call to release lock early
870        let inlay_config = { self.config.read().await.inlay_hints.clone() };
871        let range = params.range;
872
873        let hints: Vec<_> = inlay_hints::handle_inlay_hints(
874            Arc::clone(&self.state),
875            params,
876            &inlay_config,
877            self.client.clone(),
878            Arc::clone(&self.config),
879        )
880        .await
881        .into_iter()
882        .filter(|h| h.position.line >= range.start.line && h.position.line <= range.end.line)
883        .collect();
884
885        Ok(Some(hints))
886    }
887
888    async fn code_action(
889        &self,
890        params: CodeActionParams,
891    ) -> Result<Option<Vec<tower_lsp_server::ls_types::CodeActionOrCommand>>> {
892        tracing::info!(
893            "code_action request: uri={:?}, range={:?}",
894            params.text_document.uri,
895            params.range
896        );
897        let actions = code_actions::handle_code_actions(
898            Arc::clone(&self.state),
899            params,
900            self.client.clone(),
901            Arc::clone(&self.config),
902        )
903        .await;
904        tracing::info!("code_action response: {} actions", actions.len());
905        Ok(Some(actions))
906    }
907
908    async fn code_lens(&self, params: CodeLensParams) -> Result<Option<Vec<CodeLens>>> {
909        let enabled = { self.config.read().await.code_lens.enabled };
910        let lenses = code_lens::handle_code_lens(
911            Arc::clone(&self.state),
912            params,
913            enabled,
914            self.client.clone(),
915            Arc::clone(&self.config),
916        )
917        .await;
918        Ok(Some(lenses))
919    }
920
921    async fn document_link(&self, params: DocumentLinkParams) -> Result<Option<Vec<DocumentLink>>> {
922        let links = document_link::handle_document_link(
923            Arc::clone(&self.state),
924            params,
925            self.client.clone(),
926            Arc::clone(&self.config),
927        )
928        .await;
929        Ok(Some(links))
930    }
931
932    async fn diagnostic(
933        &self,
934        params: DocumentDiagnosticParams,
935    ) -> Result<DocumentDiagnosticReportResult> {
936        let uri = params.text_document.uri;
937        tracing::info!("diagnostic request for: {:?}", uri);
938
939        // Clone config before async call to release lock early
940        let diagnostics_config = { self.config.read().await.diagnostics.clone() };
941
942        let items = diagnostics::handle_diagnostics(
943            Arc::clone(&self.state),
944            &uri,
945            &diagnostics_config,
946            self.client.clone(),
947            Arc::clone(&self.config),
948        )
949        .await;
950
951        tracing::info!("returning {} diagnostics", items.len());
952
953        Ok(DocumentDiagnosticReportResult::Report(
954            DocumentDiagnosticReport::Full(RelatedFullDocumentDiagnosticReport {
955                related_documents: None,
956                full_document_diagnostic_report: FullDocumentDiagnosticReport {
957                    result_id: None,
958                    items,
959                },
960            }),
961        ))
962    }
963
964    async fn execute_command(
965        &self,
966        params: ExecuteCommandParams,
967    ) -> Result<Option<serde_json::Value>> {
968        tracing::info!("execute_command: {:?}", params.command);
969
970        if params.command == commands::UPDATE_VERSION
971            && let Some(args) = params.arguments.first()
972            && let Ok(update_args) = serde_json::from_value::<UpdateVersionArgs>(args.clone())
973        {
974            if let Some(edit) = build_update_version_edit(&update_args) {
975                match tokio::time::timeout(CLIENT_REFRESH_TIMEOUT, self.client.apply_edit(edit))
976                    .await
977                {
978                    Ok(Ok(_)) => {}
979                    Ok(Err(e)) => tracing::error!("Failed to apply edit: {:?}", e),
980                    Err(_) => tracing::warn!(
981                        "apply_edit for deps-lsp.updateVersion timed out after {CLIENT_REFRESH_TIMEOUT:?}"
982                    ),
983                }
984            }
985        } else if params.command == commands::UPDATE_ALL_OUTDATED
986            && let Some(args) = params.arguments.first()
987            && let Ok(update_args) = serde_json::from_value::<UpdateAllOutdatedArgs>(args.clone())
988        {
989            self.execute_update_all_outdated(update_args.uri).await;
990        }
991
992        Ok(None)
993    }
994}
995
996impl Backend {
997    /// Warns the client that the "update all outdated" command was refused because the
998    /// document's dependency data isn't safely usable (see the three-condition cold-start
999    /// refusal below).
1000    async fn warn_update_all_outdated_not_ready(&self) {
1001        self.client
1002            .show_message(
1003                MessageType::WARNING,
1004                "deps-lsp: dependency data is not ready for this document",
1005            )
1006            .await;
1007    }
1008
1009    /// Recomputes and applies the batch, version-guarded `WorkspaceEdit` for
1010    /// `deps-lsp.updateAllOutdated`.
1011    ///
1012    /// Refuses to act — no-op plus a `window/showMessage` — unless all of:
1013    /// - the document is present in `state` (never calls `ensure_document_loaded` — a
1014    ///   client-supplied URI must not trigger a cold disk read here);
1015    /// - [`DocumentState::is_ready_for_batch_update`](crate::document::DocumentState::is_ready_for_batch_update)
1016    ///   holds: `loading_state` is not `Loading`, and it has a known LSP `version`
1017    ///   (`None` means this state was populated from disk after a missed `didOpen` —
1018    ///   server restart/crash — where the client's buffer may hold unsaved edits disk
1019    ///   does not reflect). The same predicate gates whether `handlers::code_lens` even
1020    ///   renders the lens, so a visible lens never leads to this refusal;
1021    /// - the ecosystem and parse result are resolvable (in practice always true once
1022    ///   the above hold — surfaced with the same message as the conditions above, since
1023    ///   the caller cannot act on the difference);
1024    /// - recomputing the edits at click time still finds at least one outdated,
1025    ///   safely-editable dependency — a distinct, non-`WARNING` message covers the case
1026    ///   where the document changed between the lens render and this click.
1027    ///
1028    /// The edits are recomputed from the current document, not baked into the lens
1029    /// arguments, so a lens computed at T and clicked at T+n reflects the state at click
1030    /// time. When the client advertises `workspace.workspaceEdit.documentChanges`, the
1031    /// `WorkspaceEdit` also carries the document's LSP version, so the client rejects
1032    /// the whole batch if its buffer moved between computation and apply — this closes
1033    /// the remaining race for clients that support it. Clients that don't advertise the
1034    /// capability get the plain `changes` map instead, which carries no version; for
1035    /// those, this recompute-at-click-time step is the only staleness mitigation.
1036    // `doc` (a DashMap shard `Ref`) is dropped via an explicit `drop(doc)` before every
1037    // `.await` reachable from this point (see below) — clippy's `await_holding_invalid_type`
1038    // does not recognize a manual `drop()` in this control-flow shape and flags the
1039    // binding regardless. Verified as a false positive, not a real hazard: nothing to fix.
1040    #[allow(clippy::await_holding_invalid_type)]
1041    async fn execute_update_all_outdated(&self, uri: Uri) {
1042        let Some(doc) = self.state.get_document(&uri) else {
1043            self.warn_update_all_outdated_not_ready().await;
1044            return;
1045        };
1046
1047        if !doc.is_ready_for_batch_update() {
1048            drop(doc);
1049            self.warn_update_all_outdated_not_ready().await;
1050            return;
1051        }
1052
1053        let Some(ecosystem) = self.state.ecosystem_registry.get(doc.ecosystem_id()) else {
1054            tracing::warn!("Unknown ecosystem for {:?}", uri);
1055            drop(doc);
1056            self.warn_update_all_outdated_not_ready().await;
1057            return;
1058        };
1059
1060        let Some(parse_result) = doc.parse_result() else {
1061            tracing::warn!("No parse result for {:?}", uri);
1062            drop(doc);
1063            self.warn_update_all_outdated_not_ready().await;
1064            return;
1065        };
1066
1067        let edits = deps_core::collect_update_all_edits(
1068            parse_result,
1069            &doc.content,
1070            deps_core::VersionData::new(&doc.cached_versions, &doc.resolved_versions),
1071            ecosystem.formatter(),
1072        );
1073        let version = doc.version;
1074        drop(doc);
1075
1076        if edits.is_empty() {
1077            // Not a failure — the document changed between the lens render and this
1078            // click (or the client sent a stale command), so there is nothing left to
1079            // apply. Still worth a message: a silent no-op after a visible click reads
1080            // as a broken button (§4.6's rationale for not swallowing failures here).
1081            self.client
1082                .show_message(
1083                    MessageType::INFO,
1084                    "deps-lsp: no outdated dependencies to update",
1085                )
1086                .await;
1087            return;
1088        }
1089
1090        let supports_document_changes = self
1091            .client_capabilities
1092            .read()
1093            .await
1094            .as_ref()
1095            .and_then(|c| c.workspace.as_ref())
1096            .and_then(|w| w.workspace_edit.as_ref())
1097            .and_then(|we| we.document_changes)
1098            .unwrap_or(false);
1099
1100        let edit = build_update_all_outdated_edit(&uri, version, edits, supports_document_changes);
1101
1102        match tokio::time::timeout(CLIENT_REFRESH_TIMEOUT, self.client.apply_edit(edit)).await {
1103            Ok(Ok(response)) if response.applied => {}
1104            Ok(Ok(response)) => {
1105                tracing::warn!(
1106                    "workspace/applyEdit for {:?} was rejected: {:?}",
1107                    uri,
1108                    response.failure_reason
1109                );
1110                self.client
1111                    .show_message(
1112                        MessageType::WARNING,
1113                        "deps-lsp: failed to apply dependency updates",
1114                    )
1115                    .await;
1116            }
1117            Ok(Err(e)) => {
1118                tracing::error!("Failed to apply edit for {:?}: {:?}", uri, e);
1119                self.client
1120                    .show_message(
1121                        MessageType::WARNING,
1122                        "deps-lsp: failed to apply dependency updates",
1123                    )
1124                    .await;
1125            }
1126            Err(_) => {
1127                tracing::warn!(
1128                    "apply_edit for {:?} timed out after {CLIENT_REFRESH_TIMEOUT:?}",
1129                    uri
1130                );
1131                self.client
1132                    .show_message(
1133                        MessageType::WARNING,
1134                        format!(
1135                            "deps-lsp: the editor did not respond to the update within {CLIENT_REFRESH_TIMEOUT:?}"
1136                        ),
1137                    )
1138                    .await;
1139            }
1140        }
1141    }
1142}
1143
1144#[derive(serde::Deserialize)]
1145struct UpdateVersionArgs {
1146    uri: Uri,
1147    range: Range,
1148    version: String,
1149}
1150
1151/// Builds the `WorkspaceEdit` for `deps-lsp.updateVersion`, or `None` if `args.version`
1152/// fails [`is_safe_version_string`] — this command builds its `TextEdit` directly from a
1153/// client-supplied argument, bypassing `EcosystemFormatter` entirely, so the same
1154/// manifest-injection risk `is_safe_version_string` guards elsewhere applies here too.
1155fn build_update_version_edit(args: &UpdateVersionArgs) -> Option<WorkspaceEdit> {
1156    if !is_safe_version_string(&args.version) {
1157        tracing::error!(
1158            version = %args.version,
1159            "deps-lsp.updateVersion: rejecting unsafe version string"
1160        );
1161        return None;
1162    }
1163
1164    let mut edits = HashMap::new();
1165    edits.insert(
1166        args.uri.clone(),
1167        vec![TextEdit {
1168            range: args.range,
1169            new_text: format!("\"{}\"", args.version),
1170        }],
1171    );
1172
1173    Some(WorkspaceEdit {
1174        changes: Some(edits),
1175        ..Default::default()
1176    })
1177}
1178
1179/// Arguments for `deps-lsp.updateAllOutdated` — the URI only. Ranges are recomputed at
1180/// execution time (see `Backend::execute_update_all_outdated`), never baked into the
1181/// command arguments.
1182#[derive(serde::Deserialize)]
1183struct UpdateAllOutdatedArgs {
1184    uri: Uri,
1185}
1186
1187/// Builds the `WorkspaceEdit` for `deps-lsp.updateAllOutdated`.
1188///
1189/// Emits `document_changes` (versioned per `TextDocumentEdit`) when
1190/// `supports_document_changes` is `true` — gated on the client's
1191/// `workspace.workspaceEdit.documentChanges` capability — and falls back to the untyped
1192/// `changes` map otherwise.
1193fn build_update_all_outdated_edit(
1194    uri: &Uri,
1195    version: Option<i32>,
1196    edits: Vec<TextEdit>,
1197    supports_document_changes: bool,
1198) -> WorkspaceEdit {
1199    if supports_document_changes {
1200        WorkspaceEdit {
1201            document_changes: Some(DocumentChanges::Edits(vec![TextDocumentEdit {
1202                text_document: OptionalVersionedTextDocumentIdentifier {
1203                    uri: uri.clone(),
1204                    version,
1205                },
1206                edits: edits.into_iter().map(OneOf::Left).collect(),
1207            }])),
1208            ..Default::default()
1209        }
1210    } else {
1211        let mut changes = HashMap::new();
1212        changes.insert(uri.clone(), edits);
1213        WorkspaceEdit {
1214            changes: Some(changes),
1215            ..Default::default()
1216        }
1217    }
1218}
1219
1220#[cfg(test)]
1221mod tests {
1222    use super::*;
1223
1224    use std::assert_matches;
1225
1226    #[test]
1227    fn test_server_capabilities() {
1228        let caps = Backend::server_capabilities();
1229
1230        // Verify text document sync
1231        assert!(caps.text_document_sync.is_some());
1232
1233        // Verify completion provider
1234        assert!(caps.completion_provider.is_some());
1235        let completion = caps.completion_provider.unwrap();
1236        assert!(!completion.resolve_provider.unwrap()); // resolve_provider is disabled
1237
1238        // Verify hover provider
1239        assert!(caps.hover_provider.is_some());
1240
1241        // Verify inlay hints
1242        assert!(caps.inlay_hint_provider.is_some());
1243
1244        // Verify diagnostics
1245        assert!(caps.diagnostic_provider.is_some());
1246    }
1247
1248    #[tokio::test]
1249    async fn test_backend_creation() {
1250        let (_service, _socket) = tower_lsp_server::LspService::build(Backend::new).finish();
1251        // Backend should be created successfully
1252        // This is a minimal smoke test
1253    }
1254
1255    #[tokio::test]
1256    async fn test_initialize_without_options() {
1257        let (_service, _socket) = tower_lsp_server::LspService::build(Backend::new).finish();
1258        // Should initialize successfully with default config
1259        // Integration tests will test actual LSP protocol
1260    }
1261
1262    #[test]
1263    fn test_server_capabilities_text_document_sync() {
1264        let caps = Backend::server_capabilities();
1265
1266        match caps.text_document_sync {
1267            Some(TextDocumentSyncCapability::Kind(kind)) => {
1268                assert_eq!(kind, TextDocumentSyncKind::FULL);
1269            }
1270            _ => panic!("Expected text document sync kind to be FULL"),
1271        }
1272    }
1273
1274    #[test]
1275    fn test_server_capabilities_completion_triggers() {
1276        let caps = Backend::server_capabilities();
1277
1278        let completion = caps
1279            .completion_provider
1280            .expect("completion provider should exist");
1281        let triggers = completion
1282            .trigger_characters
1283            .expect("trigger characters should exist");
1284
1285        assert!(triggers.contains(&"\"".to_string()));
1286        assert!(triggers.contains(&"=".to_string()));
1287        assert!(triggers.contains(&".".to_string()));
1288        assert_eq!(triggers.len(), 3);
1289    }
1290
1291    #[test]
1292    fn test_server_capabilities_code_actions() {
1293        let caps = Backend::server_capabilities();
1294
1295        match caps.code_action_provider {
1296            Some(CodeActionProviderCapability::Options(opts)) => {
1297                let kinds = opts
1298                    .code_action_kinds
1299                    .expect("code action kinds should exist");
1300                assert!(kinds.contains(&tower_lsp_server::ls_types::CodeActionKind::REFACTOR));
1301                assert!(kinds.contains(&tower_lsp_server::ls_types::CodeActionKind::QUICKFIX));
1302            }
1303            _ => panic!("Expected code action provider options"),
1304        }
1305    }
1306
1307    #[test]
1308    fn test_server_capabilities_diagnostics_config() {
1309        let caps = Backend::server_capabilities();
1310
1311        match caps.diagnostic_provider {
1312            Some(DiagnosticServerCapabilities::Options(opts)) => {
1313                assert_eq!(opts.identifier, Some("deps".to_string()));
1314                assert!(!opts.inter_file_dependencies);
1315                assert!(!opts.workspace_diagnostics);
1316            }
1317            _ => panic!("Expected diagnostic options"),
1318        }
1319    }
1320
1321    #[test]
1322    fn test_server_capabilities_execute_command() {
1323        let caps = Backend::server_capabilities();
1324
1325        let execute = caps
1326            .execute_command_provider
1327            .expect("execute command provider should exist");
1328        assert!(
1329            execute
1330                .commands
1331                .contains(&commands::UPDATE_VERSION.to_string())
1332        );
1333    }
1334
1335    #[test]
1336    fn test_commands_constants() {
1337        assert_eq!(commands::UPDATE_VERSION, "deps-lsp.updateVersion");
1338    }
1339
1340    #[tokio::test]
1341    async fn test_backend_state_initialization() {
1342        let (service, _socket) = tower_lsp_server::LspService::build(Backend::new).finish();
1343        let backend = service.inner();
1344
1345        assert_eq!(backend.state.documents.len(), 0);
1346    }
1347
1348    #[tokio::test]
1349    async fn test_backend_config_initialization() {
1350        let (service, _socket) = tower_lsp_server::LspService::build(Backend::new).finish();
1351        let backend = service.inner();
1352
1353        let config = backend.config.read().await;
1354        assert!(config.inlay_hints.enabled);
1355    }
1356
1357    /// Issue #590 end-to-end: an on-disk `pnpm-workspace.yaml` change, delivered via
1358    /// `workspace/didChangeWatchedFiles`, must reparse an already-open `package.json` that
1359    /// references its catalog — not just refresh cached resolved versions the way a lock
1360    /// file change does (`Self::handle_lockfile_change`), since catalog resolution is baked
1361    /// into the parse result itself (see `Self::handle_watched_config_change`'s doc).
1362    #[tokio::test]
1363    async fn test_watched_config_change_reparses_open_document_with_catalog_dependency() {
1364        use tower_lsp_server::ls_types::{
1365            FileChangeType, FileEvent, HoverContents, Position, TextDocumentIdentifier,
1366            TextDocumentItem, TextDocumentPositionParams,
1367        };
1368
1369        let temp_dir = tempfile::tempdir().unwrap();
1370        let workspace_path = temp_dir.path().join("pnpm-workspace.yaml");
1371        std::fs::write(&workspace_path, "catalog:\n  react: ^17.0.0\n").unwrap();
1372
1373        let manifest_path = temp_dir.path().join("package.json");
1374        let content = r#"{"dependencies": {"react": "catalog:"}}"#;
1375        std::fs::write(&manifest_path, content).unwrap();
1376        let uri = Uri::from_file_path(&manifest_path).unwrap();
1377
1378        let (service, _socket) = tower_lsp_server::LspService::build(Backend::new).finish();
1379        let backend = service.inner();
1380
1381        backend
1382            .did_open(DidOpenTextDocumentParams {
1383                text_document: TextDocumentItem {
1384                    uri: uri.clone(),
1385                    language_id: "json".to_string(),
1386                    version: 1,
1387                    text: content.to_string(),
1388                },
1389            })
1390            .await;
1391
1392        let hover_params = |uri: Uri| HoverParams {
1393            text_document_position_params: TextDocumentPositionParams {
1394                text_document: TextDocumentIdentifier { uri },
1395                position: Position::new(0, 20), // inside "react"'s name
1396            },
1397            work_done_progress_params: Default::default(),
1398        };
1399
1400        let hover = backend
1401            .hover(hover_params(uri.clone()))
1402            .await
1403            .unwrap()
1404            .expect("hover must fire for a catalog-resolved dependency");
1405        let HoverContents::Markup(before) = hover.contents else {
1406            panic!("expected markup hover contents");
1407        };
1408        assert!(before.value.contains("^17.0.0"), "{}", before.value);
1409
1410        // Ensure a distinguishable mtime on filesystems with coarse timestamp resolution
1411        // (matches `mtime_cache::tests::forward_mtime_bump_invalidates`).
1412        let future = std::time::SystemTime::now() + std::time::Duration::from_secs(2);
1413        std::fs::write(&workspace_path, "catalog:\n  react: ^18.3.0\n").unwrap();
1414        std::fs::OpenOptions::new()
1415            .write(true)
1416            .open(&workspace_path)
1417            .unwrap()
1418            .set_modified(future)
1419            .unwrap();
1420
1421        backend
1422            .did_change_watched_files(DidChangeWatchedFilesParams {
1423                changes: vec![FileEvent {
1424                    uri: Uri::from_file_path(&workspace_path).unwrap(),
1425                    typ: FileChangeType::CHANGED,
1426                }],
1427            })
1428            .await;
1429
1430        let hover = backend
1431            .hover(hover_params(uri))
1432            .await
1433            .unwrap()
1434            .expect("hover must still fire after reparse");
1435        let HoverContents::Markup(after) = hover.contents else {
1436            panic!("expected markup hover contents");
1437        };
1438        assert!(
1439            after.value.contains("^18.3.0"),
1440            "watched config file change did not trigger a reparse of the open document: {}",
1441            after.value
1442        );
1443    }
1444
1445    #[test]
1446    fn test_update_version_args_deserialization() {
1447        let json = serde_json::json!({
1448            "uri": "file:///test/Cargo.toml",
1449            "range": {
1450                "start": {"line": 5, "character": 10},
1451                "end": {"line": 5, "character": 15}
1452            },
1453            "version": "1.0.0"
1454        });
1455
1456        let args: UpdateVersionArgs = serde_json::from_value(json).unwrap();
1457        assert_eq!(args.version, "1.0.0");
1458        assert_eq!(args.range.start.line, 5);
1459        assert_eq!(args.range.start.character, 10);
1460    }
1461
1462    #[tokio::test]
1463    async fn test_execute_command_update_version_with_unsafe_version_does_not_panic() {
1464        // Smoke test only: `execute_command` returns `Ok(None)` on this uninitialized-
1465        // backend harness whether `build_update_version_edit`'s guard fires or not
1466        // (`apply_edit` itself errors out on an uninitialized client, per
1467        // `test_execute_command_update_all_outdated_apply_edit_failure_does_not_panic`'s
1468        // own comment) — it cannot distinguish "guard fired" from "guard absent". The
1469        // actual regression coverage for the guard lives on `build_update_version_edit`
1470        // directly, below.
1471        let (service, _socket) = tower_lsp_server::LspService::build(Backend::new).finish();
1472        let backend = service.inner();
1473
1474        let params = ExecuteCommandParams {
1475            command: commands::UPDATE_VERSION.to_string(),
1476            arguments: vec![serde_json::json!({
1477                "uri": "file:///test/Cargo.toml",
1478                "range": {
1479                    "start": {"line": 0, "character": 9},
1480                    "end": {"line": 0, "character": 14}
1481                },
1482                "version": "1.2.0\", \"evil\": \"true"
1483            })],
1484            work_done_progress_params: Default::default(),
1485        };
1486
1487        let result = backend.execute_command(params).await;
1488        assert!(result.is_ok());
1489    }
1490
1491    fn update_version_args(version: &str) -> UpdateVersionArgs {
1492        UpdateVersionArgs {
1493            uri: deps_core::test_util::test_uri("/test/Cargo.toml"),
1494            range: Range::default(),
1495            version: version.to_string(),
1496        }
1497    }
1498
1499    #[test]
1500    fn test_build_update_version_edit_rejects_unsafe_version() {
1501        // Regression for #302: an unsafe client-supplied version must never reach a
1502        // `TextEdit` via `deps-lsp.updateVersion`.
1503        let args = update_version_args("1.2.0\", \"evil\": \"true");
1504        assert!(build_update_version_edit(&args).is_none());
1505    }
1506
1507    #[test]
1508    fn test_build_update_version_edit_accepts_safe_version() {
1509        let args = update_version_args("1.2.0");
1510        let edit = build_update_version_edit(&args).expect("a safe version must produce an edit");
1511
1512        let changes = edit.changes.expect("changes present");
1513        let edits = changes.get(&args.uri).expect("edit for the given uri");
1514        assert_eq!(edits.len(), 1);
1515        assert_eq!(edits[0].range, args.range);
1516        assert_eq!(edits[0].new_text, "\"1.2.0\"");
1517    }
1518
1519    #[test]
1520    fn test_server_capabilities_code_lens() {
1521        let caps = Backend::server_capabilities();
1522        let code_lens = caps
1523            .code_lens_provider
1524            .expect("code lens provider should exist");
1525        assert_eq!(code_lens.resolve_provider, Some(false));
1526    }
1527
1528    #[test]
1529    fn test_server_capabilities_execute_command_includes_update_all_outdated() {
1530        let caps = Backend::server_capabilities();
1531        let execute = caps
1532            .execute_command_provider
1533            .expect("execute command provider should exist");
1534        assert!(
1535            execute
1536                .commands
1537                .contains(&commands::UPDATE_ALL_OUTDATED.to_string())
1538        );
1539    }
1540
1541    #[test]
1542    fn test_commands_update_all_outdated_matches_code_lens_command_id() {
1543        assert_eq!(commands::UPDATE_ALL_OUTDATED, "deps-lsp.updateAllOutdated");
1544    }
1545
1546    #[test]
1547    fn test_update_all_outdated_args_deserialization() {
1548        let json = serde_json::json!({ "uri": "file:///test/Cargo.toml" });
1549        let args: UpdateAllOutdatedArgs = serde_json::from_value(json).unwrap();
1550        assert_eq!(args.uri.as_str(), "file:///test/Cargo.toml");
1551    }
1552
1553    #[test]
1554    fn test_build_update_all_outdated_edit_uses_document_changes_with_version() {
1555        let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
1556        let edits = vec![TextEdit {
1557            range: Range::default(),
1558            new_text: "1.2.0".into(),
1559        }];
1560
1561        let edit = build_update_all_outdated_edit(&uri, Some(7), edits, true);
1562
1563        assert!(edit.changes.is_none());
1564        let DocumentChanges::Edits(doc_edits) =
1565            edit.document_changes.expect("document_changes present")
1566        else {
1567            panic!("expected DocumentChanges::Edits variant");
1568        };
1569        assert_eq!(doc_edits.len(), 1);
1570        assert_eq!(doc_edits[0].text_document.uri, uri);
1571        assert_eq!(doc_edits[0].text_document.version, Some(7));
1572        assert_eq!(doc_edits[0].edits.len(), 1);
1573    }
1574
1575    #[test]
1576    fn test_build_update_all_outdated_edit_falls_back_to_changes_map() {
1577        let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
1578        let edits = vec![TextEdit {
1579            range: Range::default(),
1580            new_text: "1.2.0".into(),
1581        }];
1582
1583        let edit = build_update_all_outdated_edit(&uri, Some(7), edits, false);
1584
1585        assert!(edit.document_changes.is_none());
1586        let changes = edit.changes.expect("changes present");
1587        assert_eq!(changes.get(&uri).map(Vec::len), Some(1));
1588    }
1589
1590    // =========================================================================
1591    // Issue #227: `parse_config` (C2) and `did_change_configuration` live-reload
1592    // =========================================================================
1593
1594    mod parse_config_tests {
1595        use super::*;
1596
1597        #[test]
1598        fn test_parse_config_accepts_empty_object() {
1599            let config = parse_config(serde_json::json!({})).expect("empty object is valid");
1600            assert!(config.freshness.enabled);
1601        }
1602
1603        #[test]
1604        fn test_parse_config_accepts_recognized_keys() {
1605            let config = parse_config(serde_json::json!({
1606                "freshness": { "cooldown_secs": 60 }
1607            }))
1608            .expect("payload with a recognized key is valid");
1609            assert_eq!(config.freshness.cooldown_secs, 60);
1610        }
1611
1612        /// C2 regression: a section-wrapped payload (a real shape some clients send)
1613        /// has none of `DepsConfig`'s own keys, so it would otherwise deserialize
1614        /// silently into an all-defaults config, discarding the user's settings.
1615        /// `deny_unknown_fields` rejects it as `deps-lsp` not being a `DepsConfig` field.
1616        #[test]
1617        fn test_parse_config_rejects_section_wrapped_payload() {
1618            let result = parse_config(serde_json::json!({
1619                "deps-lsp": { "freshness": { "cooldown_secs": 60 } }
1620            }));
1621            assert!(
1622                result.is_none(),
1623                "a payload with no recognized top-level key must be rejected, not \
1624                 silently accepted as all-defaults"
1625            );
1626        }
1627
1628        /// Security audit regression: the *previous* "at least one recognized key"
1629        /// positive-signal check would have accepted this payload outright (it does
1630        /// contain a real `diagnostics` key) and then silently reset `freshness` and
1631        /// every other unmentioned section to its default — the same C2 silent-wipe
1632        /// through a different door. `deny_unknown_fields` closes it: any unrecognized
1633        /// sibling key anywhere in the payload rejects the whole thing.
1634        #[test]
1635        fn test_parse_config_rejects_mixed_blob_with_one_recognized_key_and_unknown_siblings() {
1636            let result = parse_config(serde_json::json!({
1637                "diagnostics": { "outdated_severity": 1 },
1638                "editor": { "fontSize": 14 },
1639                "python": { "linting": true }
1640            }));
1641            assert!(
1642                result.is_none(),
1643                "a payload with unrecognized sibling keys must be rejected wholesale, \
1644                 not accepted because one key happens to match"
1645            );
1646        }
1647
1648        #[test]
1649        fn test_parse_config_rejects_malformed_field_value() {
1650            let result = parse_config(serde_json::json!({ "freshness": "not an object" }));
1651            assert!(result.is_none());
1652        }
1653
1654        #[test]
1655        fn test_parse_config_rejects_non_object_payload() {
1656            let result = parse_config(serde_json::json!(["not", "an", "object"]));
1657            assert!(result.is_none());
1658        }
1659    }
1660
1661    /// Tester gap: only the `false`/absent branch of these two capability checks was
1662    /// incidentally covered (every other test builds a `Backend` that never sets
1663    /// `client_capabilities`). These pin the `true` branch directly.
1664    mod capability_support_tests {
1665        use super::*;
1666        use tower_lsp_server::ls_types::{
1667            ClientCapabilities, CodeLensWorkspaceClientCapabilities,
1668            DiagnosticWorkspaceClientCapabilities, DynamicRegistrationClientCapabilities,
1669            InlayHintWorkspaceClientCapabilities, WorkspaceClientCapabilities,
1670        };
1671
1672        #[tokio::test]
1673        async fn test_did_change_configuration_dynamic_registration_supported_true_branch() {
1674            let (service, _socket) = tower_lsp_server::LspService::build(Backend::new).finish();
1675            let backend = service.inner();
1676
1677            *backend.client_capabilities.write().await = Some(ClientCapabilities {
1678                workspace: Some(WorkspaceClientCapabilities {
1679                    did_change_configuration: Some(DynamicRegistrationClientCapabilities {
1680                        dynamic_registration: Some(true),
1681                    }),
1682                    ..Default::default()
1683                }),
1684                ..Default::default()
1685            });
1686
1687            assert!(
1688                backend
1689                    .did_change_configuration_dynamic_registration_supported()
1690                    .await
1691            );
1692        }
1693
1694        #[tokio::test]
1695        async fn test_diagnostic_refresh_supported_true_branch() {
1696            let (service, _socket) = tower_lsp_server::LspService::build(Backend::new).finish();
1697            let backend = service.inner();
1698
1699            *backend.client_capabilities.write().await = Some(ClientCapabilities {
1700                workspace: Some(WorkspaceClientCapabilities {
1701                    diagnostics: Some(DiagnosticWorkspaceClientCapabilities {
1702                        refresh_support: Some(true),
1703                    }),
1704                    ..Default::default()
1705                }),
1706                ..Default::default()
1707            });
1708
1709            assert!(backend.diagnostic_refresh_supported().await);
1710        }
1711
1712        /// Issue #493: `inlay_hint_refresh`/`code_lens_refresh` are now capability-gated
1713        /// before being fired off, so a wrong reading here would either silently drop a
1714        /// refresh a client actually wants, or (pre-fix) let a client that never
1715        /// declared support hang the caller. Pin both branches for each helper.
1716        #[tokio::test]
1717        async fn test_inlay_hint_refresh_supported_true_branch() {
1718            let (service, _socket) = tower_lsp_server::LspService::build(Backend::new).finish();
1719            let backend = service.inner();
1720
1721            *backend.client_capabilities.write().await = Some(ClientCapabilities {
1722                workspace: Some(WorkspaceClientCapabilities {
1723                    inlay_hint: Some(InlayHintWorkspaceClientCapabilities {
1724                        refresh_support: Some(true),
1725                    }),
1726                    ..Default::default()
1727                }),
1728                ..Default::default()
1729            });
1730
1731            assert!(backend.inlay_hint_refresh_supported().await);
1732        }
1733
1734        #[tokio::test]
1735        async fn test_inlay_hint_refresh_supported_false_when_absent() {
1736            let (service, _socket) = tower_lsp_server::LspService::build(Backend::new).finish();
1737            let backend = service.inner();
1738
1739            assert!(!backend.inlay_hint_refresh_supported().await);
1740        }
1741
1742        #[tokio::test]
1743        async fn test_code_lens_refresh_supported_true_branch() {
1744            let (service, _socket) = tower_lsp_server::LspService::build(Backend::new).finish();
1745            let backend = service.inner();
1746
1747            *backend.client_capabilities.write().await = Some(ClientCapabilities {
1748                workspace: Some(WorkspaceClientCapabilities {
1749                    code_lens: Some(CodeLensWorkspaceClientCapabilities {
1750                        refresh_support: Some(true),
1751                    }),
1752                    ..Default::default()
1753                }),
1754                ..Default::default()
1755            });
1756
1757            assert!(backend.code_lens_refresh_supported().await);
1758        }
1759
1760        #[tokio::test]
1761        async fn test_code_lens_refresh_supported_false_when_absent() {
1762            let (service, _socket) = tower_lsp_server::LspService::build(Backend::new).finish();
1763            let backend = service.inner();
1764
1765            assert!(!backend.code_lens_refresh_supported().await);
1766        }
1767
1768        /// `initialize` must snapshot both flags into `ServerState` (mirroring
1769        /// `progress_supported`) so the fire-and-forget call sites in
1770        /// `document::lifecycle` can read them without an async `ClientCapabilities`
1771        /// lock (issue #493).
1772        #[tokio::test]
1773        async fn test_initialize_propagates_refresh_support_flags_into_state() {
1774            let (service, _socket) = tower_lsp_server::LspService::build(Backend::new).finish();
1775            let backend = service.inner();
1776
1777            assert!(!backend.state.inlay_hint_refresh_supported());
1778            assert!(!backend.state.code_lens_refresh_supported());
1779
1780            let result = backend
1781                .initialize(InitializeParams {
1782                    capabilities: ClientCapabilities {
1783                        workspace: Some(WorkspaceClientCapabilities {
1784                            inlay_hint: Some(InlayHintWorkspaceClientCapabilities {
1785                                refresh_support: Some(true),
1786                            }),
1787                            code_lens: Some(CodeLensWorkspaceClientCapabilities {
1788                                refresh_support: Some(true),
1789                            }),
1790                            ..Default::default()
1791                        }),
1792                        ..Default::default()
1793                    },
1794                    ..Default::default()
1795                })
1796                .await;
1797
1798            assert!(result.is_ok());
1799            assert!(backend.state.inlay_hint_refresh_supported());
1800            assert!(backend.state.code_lens_refresh_supported());
1801        }
1802
1803        #[tokio::test]
1804        async fn test_initialize_without_refresh_capabilities_keeps_state_flags_false() {
1805            let (service, _socket) = tower_lsp_server::LspService::build(Backend::new).finish();
1806            let backend = service.inner();
1807
1808            let result = backend.initialize(InitializeParams::default()).await;
1809
1810            assert!(result.is_ok());
1811            assert!(!backend.state.inlay_hint_refresh_supported());
1812            assert!(!backend.state.code_lens_refresh_supported());
1813        }
1814    }
1815
1816    mod initialize_tests {
1817        use super::*;
1818
1819        /// Tester gap: `initialize` shares `parse_config` with `did_change_configuration`
1820        /// (only the latter had end-to-end coverage), so this exercises the same
1821        /// `deny_unknown_fields` positive-signal path through `Backend::initialize` itself
1822        /// — a section-wrapped `initializationOptions` payload must not silently reset the
1823        /// user's config to defaults.
1824        #[tokio::test]
1825        async fn test_initialize_applies_valid_initialization_options() {
1826            let (service, _socket) = tower_lsp_server::LspService::build(Backend::new).finish();
1827            let backend = service.inner();
1828
1829            let result = backend
1830                .initialize(InitializeParams {
1831                    initialization_options: Some(
1832                        serde_json::json!({ "freshness": { "cooldown_secs": 60 } }),
1833                    ),
1834                    ..Default::default()
1835                })
1836                .await;
1837
1838            assert!(result.is_ok());
1839            assert_eq!(backend.config.read().await.freshness.cooldown_secs, 60);
1840        }
1841
1842        /// C2 through `initialize`: a section-wrapped payload (`deny_unknown_fields`
1843        /// rejects `deps-lsp` as an unrecognized top-level key) must leave the
1844        /// already-`Default`-constructed config untouched, not reset it to some other
1845        /// all-defaults value silently.
1846        #[tokio::test]
1847        async fn test_initialize_keeps_default_config_on_malformed_initialization_options() {
1848            let (service, _socket) = tower_lsp_server::LspService::build(Backend::new).finish();
1849            let backend = service.inner();
1850
1851            let result = backend
1852                .initialize(InitializeParams {
1853                    initialization_options: Some(
1854                        serde_json::json!({ "deps-lsp": { "freshness": { "cooldown_secs": 60 } } }),
1855                    ),
1856                    ..Default::default()
1857                })
1858                .await;
1859
1860            assert!(result.is_ok());
1861            assert_eq!(
1862                backend.config.read().await.freshness.cooldown_secs,
1863                deps_core::DEFAULT_COOLDOWN_SECS,
1864                "malformed initializationOptions must not silently change the config"
1865            );
1866        }
1867
1868        #[tokio::test]
1869        async fn test_initialize_without_initialization_options_keeps_defaults() {
1870            let (service, _socket) = tower_lsp_server::LspService::build(Backend::new).finish();
1871            let backend = service.inner();
1872
1873            let result = backend.initialize(InitializeParams::default()).await;
1874
1875            assert!(result.is_ok());
1876            assert!(backend.config.read().await.freshness.enabled);
1877        }
1878    }
1879
1880    mod did_change_configuration_tests {
1881        use super::*;
1882        use tower_lsp_server::ls_types::DidChangeConfigurationParams;
1883
1884        #[tokio::test]
1885        async fn test_did_change_configuration_applies_valid_payload() {
1886            let (service, _socket) = tower_lsp_server::LspService::build(Backend::new).finish();
1887            let backend = service.inner();
1888
1889            backend
1890                .did_change_configuration(DidChangeConfigurationParams {
1891                    settings: serde_json::json!({ "freshness": { "cooldown_secs": 60 } }),
1892                })
1893                .await;
1894
1895            assert_eq!(backend.config.read().await.freshness.cooldown_secs, 60);
1896        }
1897
1898        /// C2 end-to-end: a section-wrapped payload must never wipe the previously
1899        /// stored configuration back to defaults.
1900        #[tokio::test]
1901        async fn test_did_change_configuration_keeps_previous_on_malformed_payload() {
1902            let (service, _socket) = tower_lsp_server::LspService::build(Backend::new).finish();
1903            let backend = service.inner();
1904
1905            backend
1906                .did_change_configuration(DidChangeConfigurationParams {
1907                    settings: serde_json::json!({ "freshness": { "cooldown_secs": 60 } }),
1908                })
1909                .await;
1910            assert_eq!(backend.config.read().await.freshness.cooldown_secs, 60);
1911
1912            backend
1913                .did_change_configuration(DidChangeConfigurationParams {
1914                    settings: serde_json::json!({ "deps-lsp": { "freshness": { "cooldown_secs": 999 } } }),
1915                })
1916                .await;
1917
1918            assert_eq!(
1919                backend.config.read().await.freshness.cooldown_secs,
1920                60,
1921                "a malformed/unrecognized payload must not overwrite the previous configuration"
1922            );
1923        }
1924
1925        /// §2.1 point 4: `null` settings mean the client expects the pull form
1926        /// (`workspace/configuration`), which v1 does not implement — must be a no-op,
1927        /// not a reset to defaults.
1928        #[tokio::test]
1929        async fn test_did_change_configuration_null_settings_is_noop() {
1930            let (service, _socket) = tower_lsp_server::LspService::build(Backend::new).finish();
1931            let backend = service.inner();
1932
1933            backend
1934                .did_change_configuration(DidChangeConfigurationParams {
1935                    settings: serde_json::json!({ "freshness": { "cooldown_secs": 60 } }),
1936                })
1937                .await;
1938            assert_eq!(backend.config.read().await.freshness.cooldown_secs, 60);
1939
1940            backend
1941                .did_change_configuration(DidChangeConfigurationParams {
1942                    settings: serde_json::Value::Null,
1943                })
1944                .await;
1945
1946            assert_eq!(backend.config.read().await.freshness.cooldown_secs, 60);
1947        }
1948
1949        /// Issue #483 (critic M6a): the primary UX of the flag — a live
1950        /// `workspace/didChangeConfiguration` toggle must both block fetches immediately
1951        /// when turned on and let them resume immediately when turned back off, with no
1952        /// editor restart.
1953        #[tokio::test]
1954        async fn test_did_change_configuration_offline_to_online_transition_resumes_fetching() {
1955            let (service, _socket) = tower_lsp_server::LspService::build(Backend::new).finish();
1956            let backend = service.inner();
1957
1958            let mut server = mockito::Server::new_async().await;
1959            let url = format!("{}/api/data", server.url());
1960
1961            backend
1962                .did_change_configuration(DidChangeConfigurationParams {
1963                    settings: serde_json::json!({ "network": { "offline": true } }),
1964                })
1965                .await;
1966            assert!(backend.state.cache.is_offline());
1967
1968            let blocked_mock = server
1969                .mock("GET", "/api/data")
1970                .with_status(200)
1971                .with_body("must not be fetched")
1972                .expect(0)
1973                .create_async()
1974                .await;
1975            let result = backend.state.cache.get_cached(&url).await;
1976            assert_matches!(result, Err(deps_core::DepsError::Offline { .. }));
1977            blocked_mock.assert_async().await;
1978
1979            backend
1980                .did_change_configuration(DidChangeConfigurationParams {
1981                    settings: serde_json::json!({ "network": { "offline": false } }),
1982                })
1983                .await;
1984            assert!(!backend.state.cache.is_offline());
1985
1986            let resumed_mock = server
1987                .mock("GET", "/api/data")
1988                .with_status(200)
1989                .with_body("fetched after returning online")
1990                .expect(1)
1991                .create_async()
1992                .await;
1993            let result = backend.state.cache.get_cached(&url).await.unwrap();
1994            assert_eq!(result.as_ref(), b"fetched after returning online");
1995            resumed_mock.assert_async().await;
1996        }
1997
1998        /// Issue #499: `cold_start.rate_limit_ms` was parsed into `DepsConfig` but
1999        /// never reached the live `ColdStartLimiter`, which always used the
2000        /// hardcoded 100ms interval it was constructed with. A live-reloaded,
2001        /// shorter interval must actually change rate-limiting behavior.
2002        #[tokio::test]
2003        async fn test_did_change_configuration_updates_cold_start_rate_limit() {
2004            let (service, _socket) = tower_lsp_server::LspService::build(Backend::new).finish();
2005            let backend = service.inner();
2006            let uri = deps_core::test_util::test_uri("/test.toml");
2007
2008            assert!(backend.state.cold_start_limiter.allow_cold_start(&uri));
2009            assert!(
2010                !backend.state.cold_start_limiter.allow_cold_start(&uri),
2011                "second immediate request blocked under the default 100ms interval"
2012            );
2013
2014            // `rate_limit_ms: 0` disables rate limiting entirely (`elapsed < ZERO` is
2015            // never true), so the assertion below is deterministic regardless of
2016            // scheduling jitter — no sleep, unlike a short nonzero interval would need.
2017            backend
2018                .did_change_configuration(DidChangeConfigurationParams {
2019                    settings: serde_json::json!({ "cold_start": { "rate_limit_ms": 0 } }),
2020                })
2021                .await;
2022            assert_eq!(backend.config.read().await.cold_start.rate_limit_ms, 0);
2023
2024            assert!(
2025                backend.state.cold_start_limiter.allow_cold_start(&uri),
2026                "rate_limit_ms: 0 should allow a cold start immediately, with no wait"
2027            );
2028        }
2029
2030        /// C1 regression: `did_change_configuration` makes a concurrent `config.write()`
2031        /// reachable for the first time. Every handler that nested-reads `config` inside
2032        /// `ensure_document_loaded` must drop its own outer guard first — otherwise a
2033        /// writer queued in between permanently blocks the nested read (tokio's `RwLock`
2034        /// is write-preferring).
2035        ///
2036        /// An earlier version of this test used an unseeded `test_uri`, so both handlers
2037        /// bailed out of `ensure_document_loaded` on ENOENT *before* ever reaching their
2038        /// own config snapshot — it passed in 0.01s regardless of whether the deadlock
2039        /// existed. Fixed here by seeding the document directly (so the fast path in
2040        /// `ensure_document_loaded` returns without touching `config` at all, and both
2041        /// handlers reach their real snapshot reads), running on a multi-threaded runtime
2042        /// (genuine OS-thread concurrency, not `current_thread`'s single deterministic
2043        /// poll order), and lining hover/diagnostics/the config write up on a `Barrier` so
2044        /// all three contend for the lock at essentially the same instant every run.
2045        #[cfg(feature = "cargo")]
2046        #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
2047        async fn test_no_deadlock_between_config_write_and_concurrent_hover_and_diagnostics() {
2048            use crate::document::DocumentState;
2049            use crate::handlers::{diagnostics, hover};
2050            use deps_core::EcosystemId;
2051            use tokio::sync::Barrier;
2052            use tower_lsp_server::ls_types::{
2053                HoverParams, Position, TextDocumentIdentifier, TextDocumentPositionParams,
2054            };
2055
2056            let (service, _socket) = tower_lsp_server::LspService::build(Backend::new).finish();
2057            let backend = service.inner();
2058            let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
2059
2060            // Seed the document so `ensure_document_loaded`'s fast path (already loaded)
2061            // returns immediately, letting both handlers reach their own config reads.
2062            let ecosystem = backend.state.ecosystem_registry.get("cargo").unwrap();
2063            let content = "[dependencies]\nserde = \"1.0.0\"\n".to_string();
2064            let parse_result = ecosystem.parse_manifest(&content, &uri).await.unwrap();
2065            let doc_state =
2066                DocumentState::new_from_parse_result(EcosystemId::Cargo, content, parse_result);
2067            backend.state.update_document(uri.clone(), doc_state);
2068
2069            let barrier = Arc::new(Barrier::new(3));
2070
2071            let hover_task = tokio::spawn({
2072                let state = Arc::clone(&backend.state);
2073                let config = Arc::clone(&backend.config);
2074                let client = backend.client.clone();
2075                let uri = uri.clone();
2076                let barrier = Arc::clone(&barrier);
2077                async move {
2078                    barrier.wait().await;
2079                    // Cursor position outside any dependency's span — `generate_hover`
2080                    // returns immediately without a registry round trip, so this stays
2081                    // offline and fast while still exercising hover's own config read.
2082                    let params = HoverParams {
2083                        text_document_position_params: TextDocumentPositionParams {
2084                            text_document: TextDocumentIdentifier { uri },
2085                            position: Position::new(99, 0),
2086                        },
2087                        work_done_progress_params: Default::default(),
2088                    };
2089                    hover::handle_hover(state, params, client, config).await
2090                }
2091            });
2092
2093            let diagnostics_config_snapshot = { backend.config.read().await.diagnostics.clone() };
2094            let diagnostics_task = tokio::spawn({
2095                let state = Arc::clone(&backend.state);
2096                let config = Arc::clone(&backend.config);
2097                let client = backend.client.clone();
2098                let uri = uri.clone();
2099                let barrier = Arc::clone(&barrier);
2100                async move {
2101                    barrier.wait().await;
2102                    diagnostics::handle_diagnostics(
2103                        state,
2104                        &uri,
2105                        &diagnostics_config_snapshot,
2106                        client,
2107                        config,
2108                    )
2109                    .await
2110                }
2111            });
2112
2113            let outcome = tokio::time::timeout(std::time::Duration::from_secs(5), async {
2114                barrier.wait().await;
2115                backend
2116                    .did_change_configuration(DidChangeConfigurationParams {
2117                        settings: serde_json::json!({ "freshness": { "cooldown_secs": 42 } }),
2118                    })
2119                    .await;
2120                tokio::join!(hover_task, diagnostics_task)
2121            })
2122            .await
2123            .expect(
2124                "hover/diagnostics must not deadlock against a concurrent \
2125                 did_change_configuration write (issue #227 C1)",
2126            );
2127
2128            outcome.0.expect("hover task panicked");
2129            outcome.1.expect("diagnostics task panicked");
2130            assert_eq!(backend.config.read().await.freshness.cooldown_secs, 42);
2131        }
2132
2133        /// Issue #592 S1 regression: two rapid `didChangeConfiguration` notifications, each
2134        /// touching a *different* parse-affecting setting, must coalesce into one reparse
2135        /// that covers the union of both scopes — not just the second (narrower) one, which
2136        /// a naive "recompute against the immediately-previous config" coalescing scheme
2137        /// would lose.
2138        ///
2139        /// **Security review S5 correction**: the second payload must explicitly repeat
2140        /// `"workspace_registries": "off"`. `did_change_configuration` uses
2141        /// replace-whole-config semantics, so a payload that omits a `RegistriesConfig`
2142        /// field resets it to its type default (`PublicOnly`) — a second payload that only
2143        /// sets `nuget_user_profile_sources` would silently flip `workspace_registries` from
2144        /// `Off` (set by the first call) back to `PublicOnly`, which is *itself* a change
2145        /// and would independently re-trigger the full workspace-ecosystems scope on the
2146        /// second call alone. That would make this test pass even if `queue_reparse`
2147        /// replaced the pending scope instead of unioning it, since the second call's own
2148        /// (accidentally broad) scope would already cover the cargo document. Repeating
2149        /// `"off"` holds `workspace_registries` constant across both calls, so the second
2150        /// call's own scope is genuinely just `["nuget"]` — only a real union still covers
2151        /// cargo.
2152        ///
2153        /// Observed via `cached_versions` being cleared: `RefetchPolicy::AllDependencies`
2154        /// clears it unconditionally before attempting the (network, and in this sandboxed
2155        /// test environment expected-to-fail) fetch, so an empty map is proof the document's
2156        /// scope was actually reparsed, regardless of whether the fetch itself succeeds.
2157        #[cfg(all(feature = "cargo", feature = "nuget"))]
2158        #[tokio::test]
2159        async fn test_rapid_config_changes_coalesce_into_a_union_scope_reparse() {
2160            use crate::document::DocumentState;
2161            use deps_core::{EcosystemId, PackageName, PackageVersions};
2162
2163            let (service, _socket) = tower_lsp_server::LspService::build(Backend::new).finish();
2164            let backend = service.inner();
2165
2166            // `cargo` is only ever in scope via the `workspace_registries` change (the
2167            // first call); `nuget_user_profile_sources` (the second call) never mentions
2168            // cargo at all — so cargo's `cached_versions` being cleared is proof the first
2169            // call's scope survived the union, not an artifact of the second call's own
2170            // (unrelated) scope.
2171            let cargo_uri = deps_core::test_util::test_uri("/test/Cargo.toml");
2172            let cargo_ecosystem = backend.state.ecosystem_registry.get("cargo").unwrap();
2173            let cargo_content = "[dependencies]\nserde = \"1.0\"\n".to_string();
2174            let cargo_parse = cargo_ecosystem
2175                .parse_manifest(&cargo_content, &cargo_uri)
2176                .await
2177                .unwrap();
2178            let mut cargo_doc = DocumentState::new_from_parse_result(
2179                EcosystemId::Cargo,
2180                cargo_content,
2181                cargo_parse,
2182            );
2183            cargo_doc.set_version(Some(1));
2184            cargo_doc.update_cached_versions(HashMap::from([(
2185                PackageName::new("serde"),
2186                PackageVersions::latest_only("1.0.999"),
2187            )]));
2188            backend.state.update_document(cargo_uri.clone(), cargo_doc);
2189
2190            let nuget_uri = deps_core::test_util::test_uri("/test/project.csproj");
2191            let nuget_ecosystem = backend.state.ecosystem_registry.get("nuget").unwrap();
2192            let nuget_content = r#"<Project><ItemGroup><PackageReference Include="Newtonsoft.Json" Version="12.0.3" /></ItemGroup></Project>"#.to_string();
2193            let nuget_parse = nuget_ecosystem
2194                .parse_manifest(&nuget_content, &nuget_uri)
2195                .await
2196                .unwrap();
2197            let mut nuget_doc = DocumentState::new_from_parse_result(
2198                EcosystemId::NuGet,
2199                nuget_content,
2200                nuget_parse,
2201            );
2202            nuget_doc.set_version(Some(1));
2203            nuget_doc.update_cached_versions(HashMap::from([(
2204                PackageName::new("Newtonsoft.Json"),
2205                PackageVersions::latest_only("99.0.0"),
2206            )]));
2207            backend.state.update_document(nuget_uri.clone(), nuget_doc);
2208
2209            // Fired back-to-back, no `.await`ed sleep in between — the second call's
2210            // `queue_reparse` must union onto, not replace, the first's pending scope.
2211            backend
2212                .did_change_configuration(DidChangeConfigurationParams {
2213                    settings: serde_json::json!({ "registries": { "workspace_registries": "off" } }),
2214                })
2215                .await;
2216            backend
2217                .did_change_configuration(DidChangeConfigurationParams {
2218                    settings: serde_json::json!({
2219                        "registries": {
2220                            "workspace_registries": "off",
2221                            "nuget_user_profile_sources": true
2222                        }
2223                    }),
2224                })
2225                .await;
2226
2227            let both_cleared = tokio::time::timeout(std::time::Duration::from_secs(5), async {
2228                loop {
2229                    let cargo_cleared = backend
2230                        .state
2231                        .get_document(&cargo_uri)
2232                        .is_some_and(|d| d.cached_versions.is_empty());
2233                    let nuget_cleared = backend
2234                        .state
2235                        .get_document(&nuget_uri)
2236                        .is_some_and(|d| d.cached_versions.is_empty());
2237                    if cargo_cleared && nuget_cleared {
2238                        return;
2239                    }
2240                    tokio::time::sleep(std::time::Duration::from_millis(20)).await;
2241                }
2242            })
2243            .await;
2244
2245            assert!(
2246                both_cleared.is_ok(),
2247                "both documents' stale cached_versions must be dropped by the coalesced \
2248                 reparse — a lost scope would leave one of them untouched"
2249            );
2250        }
2251    }
2252
2253    #[cfg(feature = "cargo")]
2254    mod update_all_outdated_execute_command_tests {
2255        use super::*;
2256        use crate::document::DocumentState;
2257        use deps_core::EcosystemId;
2258
2259        fn command_params(uri: &Uri) -> ExecuteCommandParams {
2260            ExecuteCommandParams {
2261                command: commands::UPDATE_ALL_OUTDATED.to_string(),
2262                arguments: vec![serde_json::json!({ "uri": uri.as_str() })],
2263                work_done_progress_params: Default::default(),
2264            }
2265        }
2266
2267        #[tokio::test]
2268        async fn test_execute_command_update_all_outdated_closed_document_no_op() {
2269            let (service, _socket) = tower_lsp_server::LspService::build(Backend::new).finish();
2270            let backend = service.inner();
2271            let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
2272
2273            // Pin the precondition the refusal actually depends on: no document at all.
2274            assert!(backend.state.get_document(&uri).is_none());
2275
2276            let result = backend.execute_command(command_params(&uri)).await;
2277            assert!(result.is_ok());
2278            assert!(
2279                backend.state.get_document(&uri).is_none(),
2280                "a refused command must not create a document"
2281            );
2282        }
2283
2284        #[tokio::test]
2285        async fn test_execute_command_update_all_outdated_loading_document_no_op() {
2286            let (service, _socket) = tower_lsp_server::LspService::build(Backend::new).finish();
2287            let backend = service.inner();
2288            let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
2289
2290            let ecosystem = backend.state.ecosystem_registry.get("cargo").unwrap();
2291            let content = "[dependencies]\nserde = \"1.0.0\"\n".to_string();
2292            let parse_result = ecosystem.parse_manifest(&content, &uri).await.unwrap();
2293            let mut doc_state = DocumentState::new_from_parse_result(
2294                EcosystemId::Cargo,
2295                content.clone(),
2296                parse_result,
2297            );
2298            doc_state.set_version(Some(1));
2299            doc_state.set_loading();
2300            // Pin the precondition directly: this fixture must actually be "not ready"
2301            // per the same predicate `execute_command` consults, not just assumed to be.
2302            assert!(!doc_state.is_ready_for_batch_update());
2303            backend.state.update_document(uri.clone(), doc_state);
2304
2305            let result = backend.execute_command(command_params(&uri)).await;
2306            assert!(result.is_ok());
2307            assert_eq!(
2308                backend.state.get_document(&uri).unwrap().content,
2309                content,
2310                "a refused command must not touch document content"
2311            );
2312        }
2313
2314        #[tokio::test]
2315        async fn test_execute_command_update_all_outdated_no_version_no_op() {
2316            // `version: None` mirrors a document populated from disk after a missed
2317            // didOpen (server restart/crash) — must be refused even though loaded and
2318            // not `Loading`.
2319            let (service, _socket) = tower_lsp_server::LspService::build(Backend::new).finish();
2320            let backend = service.inner();
2321            let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
2322
2323            let ecosystem = backend.state.ecosystem_registry.get("cargo").unwrap();
2324            let content = "[dependencies]\nserde = \"1.0.0\"\n".to_string();
2325            let parse_result = ecosystem.parse_manifest(&content, &uri).await.unwrap();
2326            let mut doc_state =
2327                DocumentState::new_from_parse_result(EcosystemId::Cargo, content, parse_result);
2328            doc_state.set_loaded();
2329            // `version` deliberately left as `None`.
2330            assert!(!doc_state.is_ready_for_batch_update());
2331            backend.state.update_document(uri.clone(), doc_state);
2332
2333            let result = backend.execute_command(command_params(&uri)).await;
2334            assert!(result.is_ok());
2335        }
2336
2337        #[tokio::test]
2338        async fn test_execute_command_update_all_outdated_apply_edit_failure_does_not_panic() {
2339            // This test `Backend` is never `initialize`d, so `apply_edit` returns `Err`
2340            // (per its documented behavior) — exercises the failure/warning path.
2341            let (service, _socket) = tower_lsp_server::LspService::build(Backend::new).finish();
2342            let backend = service.inner();
2343            let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
2344
2345            let ecosystem = backend.state.ecosystem_registry.get("cargo").unwrap();
2346            let content = "[dependencies]\nserde = \"1.0.0\"\n".to_string();
2347            let parse_result = ecosystem.parse_manifest(&content, &uri).await.unwrap();
2348            let mut doc_state =
2349                DocumentState::new_from_parse_result(EcosystemId::Cargo, content, parse_result);
2350            doc_state.set_version(Some(1));
2351            doc_state.set_loaded();
2352            // This fixture must actually pass the readiness gate — the failure below is
2353            // from `apply_edit`, not from the refusal predicate this pins as satisfied.
2354            assert!(doc_state.is_ready_for_batch_update());
2355            let mut cached = HashMap::new();
2356            cached.insert(
2357                "serde".into(),
2358                deps_core::PackageVersions::latest_only("1.2.0"),
2359            );
2360            doc_state.update_cached_versions(cached);
2361            backend.state.update_document(uri.clone(), doc_state);
2362
2363            let result = backend.execute_command(command_params(&uri)).await;
2364            assert!(result.is_ok());
2365        }
2366    }
2367}