Skip to main content

deps_lsp/document/
state.rs

1use dashmap::DashMap;
2use deps_core::HttpCache;
3use deps_core::lockfile::LockFileCache;
4use deps_core::net_policy::RegistryAccessPolicy;
5use deps_core::osv::{OsvClient, VulnerabilityMap};
6use deps_core::{
7    ConcreteVersion, DependencyOutcomes, DepsDevClient, EcosystemId, EcosystemRegistry,
8    PackageName, PackageVersions, ParseResult,
9};
10use std::collections::HashMap;
11use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
12use std::sync::{Arc, RwLock};
13use std::time::{Duration, Instant};
14use tokio::task::JoinHandle;
15use tower_lsp_server::Client;
16use tower_lsp_server::ls_types::Uri;
17
18/// Upper bound on how long a server-to-client request is allowed to wait for a
19/// reply before being abandoned (issue #493). Used both for the detached
20/// `workspace/*/refresh` requests below (S2: without it, a client that declares
21/// refresh support but stops replying would let these detached tasks — and
22/// `tower_lsp_server`'s internal pending-request bookkeeping — accumulate without
23/// limit as the user keeps editing) and, via re-export, for the `initialized()`
24/// registration requests and `workspace/diagnostic/refresh` in `server.rs` (S1: same
25/// hang risk, an unresponsive client would otherwise stall those handlers forever).
26/// Also reused (not itself a "refresh") for `workspace/applyEdit` in `server.rs`'s
27/// `execute_command` handlers (issue #496): same directly-awaited hang risk, since a
28/// client that never answers `applyEdit` would otherwise permanently occupy one of
29/// `tower_lsp_server`'s limited `buffer_unordered` concurrency slots.
30pub(crate) const CLIENT_REFRESH_TIMEOUT: Duration = Duration::from_secs(5);
31
32/// Server-wide cap on concurrent registry-fetch *documents* in flight (issue #592 critic
33/// S2/S3) — an axis `cache.max_concurrent_fetches` does not cover, since that bounds
34/// dependencies within one document's fetch, not how many documents fetch at once.
35/// Deliberately an independent constant, not derived from `cache.max_concurrent_fetches`:
36/// the two bound different axes, and coupling them would let one config knob square the
37/// peak concurrent outbound request count. Shared by the open path
38/// (`document::lifecycle::run_document_open_background_task`, including cold-start
39/// documents `ensure_document_loaded` admits) and the change path
40/// (`document::lifecycle::run_document_change_task`), so neither alone can fan out
41/// unbounded registry traffic.
42const FETCH_PERMITS: usize = 4;
43
44// Re-export LoadingState from deps-core for convenience
45pub use deps_core::LoadingState;
46
47/// State for a single open document.
48///
49/// Stores the document content, parsed dependency information, and cached
50/// version data for a single file. The state is updated when the document
51/// changes or when version information is fetched from the registry.
52///
53/// Supports multiple package ecosystems via the trait-based `ParseResult`.
54///
55/// # Examples
56///
57/// ```no_run
58/// use deps_core::EcosystemId;
59/// use deps_lsp::document::DocumentState;
60///
61/// let state = DocumentState::new_without_parse_result(
62///     EcosystemId::Cargo,
63///     "[dependencies]\nserde = \"1.0\"".into(),
64/// );
65///
66/// assert!(state.cached_versions.is_empty());
67/// ```
68pub struct DocumentState {
69    /// Package ecosystem identifier, exhaustively typed.
70    pub ecosystem: EcosystemId,
71    /// Original document content
72    pub content: String,
73    /// Parsed result as trait object, wrapped in `Arc` (rather than `Box`) so
74    /// [`Self::parse_result_arc`] can hand a caller a cheap owned clone — letting it
75    /// release the DashMap shard `Ref` before an `.await` on a registry-bound
76    /// `generate_*` call without deep-cloning ecosystem-specific parse data (#319).
77    parse_result: Option<Arc<dyn ParseResult>>,
78    /// Latest known version and full version list per package, fetched together in a
79    /// single registry round trip (see [`PackageVersions`]).
80    pub cached_versions: HashMap<PackageName, PackageVersions>,
81    /// Resolved versions from lock file
82    pub resolved_versions: HashMap<PackageName, ConcreteVersion>,
83    /// OSV.dev scan results, keyed by normalized package name. Empty until
84    /// the first background scan completes; carried across document edits
85    /// by `preserve_cache` so it is not wiped on every keystroke.
86    pub vulnerabilities: VulnerabilityMap,
87    /// Yanked, deprecation, and fetch-failure findings from the lifecycle's registry
88    /// fetch, keyed by **normalized** package name. This is deliberately a different
89    /// type from `FetchResult`'s raw-keyed triple: the split makes a forgotten
90    /// normalization at a store/merge site a compile error rather than a silent bug for
91    /// ecosystems where normalization changes the name (e.g. PyPI). See
92    /// [`DependencyOutcome`](deps_core::DependencyOutcome) for what each of the three
93    /// channels means. Empty until the first fetch completes; carried across document
94    /// edits by `preserve_cache` so it doesn't flicker off on every keystroke.
95    pub outcomes: DependencyOutcomes,
96    /// Last successful parse time
97    pub parsed_at: Instant,
98    /// Current loading state for registry data
99    pub loading_state: LoadingState,
100    /// When the current loading operation started (for timeout/metrics)
101    pub loading_started_at: Option<Instant>,
102    /// LSP document version from the client's `didOpen`/`didChange`, `None` if this
103    /// state was populated from disk (cold start) rather than an LSP notification.
104    ///
105    /// Threaded into `WorkspaceEdit.document_changes` so the client can reject a batch
106    /// edit whose ranges were computed against a buffer state it has since moved past
107    /// (see `handlers::code_lens`).
108    pub version: Option<i32>,
109}
110
111impl Clone for DocumentState {
112    fn clone(&self) -> Self {
113        Self {
114            ecosystem: self.ecosystem,
115            content: self.content.clone(),
116            // Cheap: `Arc::clone`, not a deep copy of the parse result.
117            parse_result: self.parse_result.clone(),
118            cached_versions: self.cached_versions.clone(),
119            resolved_versions: self.resolved_versions.clone(),
120            vulnerabilities: self.vulnerabilities.clone(),
121            outcomes: self.outcomes.clone(),
122            parsed_at: self.parsed_at,
123            loading_state: self.loading_state,
124            // Note: Instant is Copy. Clones share the same loading start time.
125            loading_started_at: self.loading_started_at,
126            version: self.version,
127        }
128    }
129}
130
131/// Tracks recent cold start attempts per URI to prevent DOS.
132///
133/// Uses rate limiting with a configurable minimum interval between
134/// cold start attempts for the same URI. This prevents malicious or
135/// buggy clients from overwhelming the server with rapid file loading
136/// requests.
137///
138/// # Examples
139///
140/// ```
141/// use deps_lsp::document::ColdStartLimiter;
142/// use std::time::Duration;
143///
144/// let limiter = ColdStartLimiter::new(Duration::from_secs(10));
145/// let uri = deps_core::test_util::test_uri("/test.toml");
146///
147/// assert!(limiter.allow_cold_start(&uri));
148/// assert!(!limiter.allow_cold_start(&uri)); // Rate limited
149/// ```
150#[derive(Debug)]
151pub struct ColdStartLimiter {
152    /// Maps URI to last cold start attempt time.
153    last_attempts: DashMap<Uri, Instant>,
154    /// Minimum interval between cold start attempts for the same URI, in
155    /// milliseconds. Atomic so `set_min_interval` can live-update it from
156    /// `did_change_configuration` (issue #499) without disturbing in-flight
157    /// `allow_cold_start` callers.
158    min_interval_ms: AtomicU64,
159}
160
161impl ColdStartLimiter {
162    /// Creates a new cold start limiter with the specified minimum interval.
163    pub fn new(min_interval: Duration) -> Self {
164        Self {
165            last_attempts: DashMap::new(),
166            min_interval_ms: AtomicU64::new(min_interval.as_millis() as u64),
167        }
168    }
169
170    /// Updates the minimum interval between cold start attempts.
171    ///
172    /// Takes effect on the next `allow_cold_start` call. Used to apply a
173    /// live-reloaded `cold_start.rate_limit_ms` (issue #499).
174    pub fn set_min_interval(&self, min_interval: Duration) {
175        self.min_interval_ms
176            .store(min_interval.as_millis() as u64, Ordering::Relaxed);
177    }
178
179    /// Returns true if cold start is allowed, false if rate limited.
180    ///
181    /// Updates the last attempt time if the cold start is allowed.
182    pub fn allow_cold_start(&self, uri: &Uri) -> bool {
183        let min_interval = Duration::from_millis(self.min_interval_ms.load(Ordering::Relaxed));
184        let now = Instant::now();
185
186        // Check last attempt time
187        if let Some(mut entry) = self.last_attempts.get_mut(uri) {
188            let elapsed = now.duration_since(*entry);
189            if elapsed < min_interval {
190                let retry_after = min_interval.checked_sub(elapsed).unwrap();
191                tracing::warn!(
192                    "Cold start rate limited for {:?} (retry after {:?})",
193                    uri,
194                    retry_after
195                );
196                return false;
197            }
198            *entry = now;
199        } else {
200            self.last_attempts.insert(uri.clone(), now);
201        }
202
203        true
204    }
205
206    /// Cleans up old entries periodically.
207    ///
208    /// Removes entries older than `max_age` to prevent unbounded memory growth.
209    /// Should be called from a background task.
210    pub fn cleanup_old_entries(&self, max_age: Duration) {
211        let now = Instant::now();
212        self.last_attempts
213            .retain(|_, instant| now.duration_since(*instant) < max_age);
214    }
215
216    /// Returns the number of tracked URIs.
217    #[cfg(test)]
218    pub fn tracked_count(&self) -> usize {
219        self.last_attempts.len()
220    }
221}
222
223impl std::fmt::Debug for DocumentState {
224    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
225        f.debug_struct("DocumentState")
226            .field("ecosystem", &self.ecosystem)
227            .field("ecosystem_id", &self.ecosystem_id())
228            .field("content_len", &self.content.len())
229            .field("has_parse_result", &self.parse_result.is_some())
230            .field("cached_versions_count", &self.cached_versions.len())
231            .field("resolved_versions_count", &self.resolved_versions.len())
232            .field("vulnerabilities_count", &self.vulnerabilities.len())
233            .field("yanked_versions_count", &self.outcomes.yanked_count())
234            .field("deprecations_count", &self.outcomes.deprecation_count())
235            .field("fetch_failed_count", &self.outcomes.fetch_failure_count())
236            .field("parsed_at", &self.parsed_at)
237            .field("loading_state", &self.loading_state)
238            .field("loading_started_at", &self.loading_started_at)
239            .field("version", &self.version)
240            .finish()
241    }
242}
243
244impl DocumentState {
245    /// Creates a new document state using trait objects (new architecture).
246    ///
247    /// This is the preferred constructor for Phase 3+ implementations.
248    pub fn new_from_parse_result(
249        ecosystem: EcosystemId,
250        content: String,
251        parse_result: Box<dyn ParseResult>,
252    ) -> Self {
253        Self {
254            ecosystem,
255            content,
256            parse_result: Some(Arc::from(parse_result)),
257            cached_versions: HashMap::new(),
258            resolved_versions: HashMap::new(),
259            vulnerabilities: VulnerabilityMap::new(),
260            outcomes: DependencyOutcomes::new(),
261            parsed_at: Instant::now(),
262            loading_state: LoadingState::Idle,
263            loading_started_at: None,
264            version: None,
265        }
266    }
267
268    /// Creates a new document state without a parse result.
269    ///
270    /// Used when parsing fails but the document should still be stored
271    /// to enable fallback completion and other LSP features.
272    pub fn new_without_parse_result(ecosystem: EcosystemId, content: String) -> Self {
273        Self {
274            ecosystem,
275            content,
276            parse_result: None,
277            cached_versions: HashMap::new(),
278            resolved_versions: HashMap::new(),
279            vulnerabilities: VulnerabilityMap::new(),
280            outcomes: DependencyOutcomes::new(),
281            parsed_at: Instant::now(),
282            loading_state: LoadingState::Idle,
283            loading_started_at: None,
284            version: None,
285        }
286    }
287
288    /// Returns the ecosystem identifier as a `&'static str`, derived from
289    /// [`DocumentState::ecosystem`]. Registry lookups (`EcosystemRegistry::get`)
290    /// are keyed by string, so this mirrors `ecosystem.id()`.
291    pub fn ecosystem_id(&self) -> &'static str {
292        self.ecosystem.id()
293    }
294
295    /// Gets a reference to the parse result if available.
296    pub fn parse_result(&self) -> Option<&dyn ParseResult> {
297        self.parse_result.as_deref()
298    }
299
300    /// Returns a cheap `Arc` clone of the parse result, if available.
301    ///
302    /// Lets a caller (e.g. a `handlers::{hover,completion,code_actions}` handler) own
303    /// the parse result and release the DashMap shard `Ref` before awaiting a
304    /// registry-bound `Ecosystem::generate_*` call, without deep-cloning
305    /// ecosystem-specific parse data on every request (#319).
306    pub fn parse_result_arc(&self) -> Option<Arc<dyn ParseResult>> {
307        self.parse_result.clone()
308    }
309
310    /// Updates the cached registry version data (new architecture).
311    pub fn update_cached_versions(&mut self, versions: HashMap<PackageName, PackageVersions>) {
312        self.cached_versions = versions;
313    }
314
315    /// Updates the resolved versions from lock file.
316    pub fn update_resolved_versions(&mut self, versions: HashMap<PackageName, ConcreteVersion>) {
317        self.resolved_versions = versions;
318    }
319
320    /// Updates the OSV.dev scan results.
321    pub fn update_vulnerabilities(&mut self, vulnerabilities: VulnerabilityMap) {
322        self.vulnerabilities = vulnerabilities;
323    }
324
325    /// Replaces the yanked/deprecation/fetch-failure outcome map wholesale (normalized-keyed,
326    /// see [`Self::outcomes`]).
327    pub fn replace_outcomes(&mut self, outcomes: DependencyOutcomes) {
328        self.outcomes = outcomes;
329    }
330
331    /// Sets the LSP document version from the client's `didOpen`/`didChange`, or clears
332    /// it (`None`) for a document populated from disk rather than an LSP notification.
333    ///
334    /// # Examples
335    ///
336    /// ```
337    /// use deps_core::EcosystemId;
338    /// use deps_lsp::document::DocumentState;
339    ///
340    /// let mut doc = DocumentState::new_without_parse_result(EcosystemId::Cargo, "".into());
341    /// assert!(doc.version.is_none());
342    /// doc.set_version(Some(3));
343    /// assert_eq!(doc.version, Some(3));
344    /// ```
345    pub fn set_version(&mut self, version: Option<i32>) {
346        self.version = version;
347    }
348
349    /// Whether this document has everything `deps-lsp.updateAllOutdated` (and the code
350    /// lens that surfaces it) need to safely act: version data isn't currently
351    /// `Loading`, and the document has a known LSP version.
352    ///
353    /// `version: None` means this state was populated from disk after a missed
354    /// `didOpen` (server restart/crash) — the client's buffer may hold unsaved edits
355    /// the disk copy does not reflect, so batch-editing it is unsafe even though the
356    /// document is otherwise loaded.
357    ///
358    /// # Examples
359    ///
360    /// ```
361    /// use deps_core::EcosystemId;
362    /// use deps_lsp::document::DocumentState;
363    ///
364    /// let mut doc = DocumentState::new_without_parse_result(EcosystemId::Cargo, "".into());
365    /// assert!(!doc.is_ready_for_batch_update(), "no version yet");
366    ///
367    /// doc.set_version(Some(1));
368    /// assert!(doc.is_ready_for_batch_update());
369    ///
370    /// doc.set_loading();
371    /// assert!(!doc.is_ready_for_batch_update(), "still loading");
372    /// ```
373    #[must_use]
374    pub fn is_ready_for_batch_update(&self) -> bool {
375        self.loading_state != LoadingState::Loading && self.version.is_some()
376    }
377
378    /// Mark document as loading registry data.
379    ///
380    /// # Examples
381    ///
382    /// ```
383    /// use deps_core::EcosystemId;
384    /// use deps_lsp::document::DocumentState;
385    ///
386    /// let mut doc = DocumentState::new_without_parse_result(EcosystemId::Cargo, "".into());
387    /// doc.set_loading();
388    /// assert!(doc.loading_started_at.is_some());
389    /// ```
390    ///
391    /// # Thread Safety
392    ///
393    /// This method requires exclusive access (`&mut self`). When used with
394    /// `DashMap::get_mut()`, thread safety is guaranteed by the lock.
395    /// Calling while already `Loading` resets the timer.
396    pub fn set_loading(&mut self) {
397        self.loading_state = LoadingState::Loading;
398        self.loading_started_at = Some(Instant::now());
399    }
400
401    /// Mark document as loaded with fresh data.
402    ///
403    /// # Examples
404    ///
405    /// ```
406    /// use deps_core::EcosystemId;
407    /// use deps_lsp::document::{DocumentState, LoadingState};
408    ///
409    /// let mut doc = DocumentState::new_without_parse_result(EcosystemId::Cargo, "".into());
410    /// doc.set_loading();
411    /// doc.set_loaded();
412    /// assert_eq!(doc.loading_state, LoadingState::Loaded);
413    /// assert!(doc.loading_started_at.is_none());
414    /// ```
415    pub fn set_loaded(&mut self) {
416        self.loading_state = LoadingState::Loaded;
417        self.loading_started_at = None;
418    }
419
420    /// Mark document as failed to load (keeps old cached data).
421    ///
422    /// # Examples
423    ///
424    /// ```
425    /// use deps_core::EcosystemId;
426    /// use deps_lsp::document::{DocumentState, LoadingState};
427    ///
428    /// let mut doc = DocumentState::new_without_parse_result(EcosystemId::Cargo, "".into());
429    /// doc.set_loading();
430    /// doc.set_failed();
431    /// assert_eq!(doc.loading_state, LoadingState::Failed);
432    /// assert!(doc.loading_started_at.is_none());
433    /// ```
434    pub fn set_failed(&mut self) {
435        self.loading_state = LoadingState::Failed;
436        self.loading_started_at = None;
437    }
438
439    /// Get current loading duration if loading.
440    ///
441    /// Returns `None` if not currently loading, or `Some(Duration)` representing
442    /// how long the current loading operation has been running.
443    ///
444    /// # Examples
445    ///
446    /// ```
447    /// use deps_core::EcosystemId;
448    /// use deps_lsp::document::DocumentState;
449    ///
450    /// let mut doc = DocumentState::new_without_parse_result(EcosystemId::Cargo, "".into());
451    /// assert!(doc.loading_duration().is_none());
452    ///
453    /// doc.set_loading();
454    /// assert!(doc.loading_duration().is_some());
455    /// ```
456    #[must_use]
457    pub fn loading_duration(&self) -> Option<Duration> {
458        self.loading_started_at
459            .map(|start| Instant::now().duration_since(start))
460    }
461}
462
463/// Global LSP server state.
464///
465/// Manages all open documents, HTTP cache, lock file cache, and background
466/// tasks for the server. This state is shared across all LSP handlers via
467/// `Arc` and uses concurrent data structures (`DashMap`, `RwLock`) for
468/// thread-safe access.
469///
470/// # Examples
471///
472/// ```
473/// use deps_lsp::document::ServerState;
474/// use tower_lsp_server::ls_types::Uri;
475///
476/// let state = ServerState::new();
477/// assert_eq!(state.document_count(), 0);
478/// ```
479pub struct ServerState {
480    /// Open documents by URI
481    pub documents: DashMap<Uri, DocumentState>,
482    /// HTTP cache for registry requests
483    pub cache: Arc<HttpCache>,
484    /// OSV.dev vulnerability scan client, shared server-lifetime so every
485    /// open document's scan benefits from the same query/record cache.
486    pub osv: Arc<OsvClient>,
487    /// deps.dev supply-chain trust signal client (spec 037), shared
488    /// server-lifetime so every hover benefits from the same TTL memo.
489    /// `handlers/hover.rs` is the only caller that hands this to
490    /// `VersionData::with_trust` — see that field's docs for why this is
491    /// what makes the feature hover-only by construction (FR-010).
492    pub deps_dev: Arc<DepsDevClient>,
493    /// Lock file cache for parsed lock files
494    pub lockfile_cache: Arc<LockFileCache>,
495    /// Ecosystem registry for trait-based architecture
496    pub ecosystem_registry: Arc<EcosystemRegistry>,
497    /// Live-updatable workspace-registry reachability policy (spec #443,
498    /// `registries.workspace_registries`, widened from Cargo-only by
499    /// `032-npm-npmrc-registry-support`) — the same handle `crate::register_ecosystems` hands
500    /// to `CargoEcosystem::with_context` and `NpmEcosystem::with_context` alike, so
501    /// `Backend::initialize`/`did_change_configuration` updating this value here takes effect
502    /// on every parse from then on, with no need to reconstruct either ecosystem.
503    pub registry_policy: Arc<RegistryAccessPolicy>,
504    /// Live-updatable `registries.nuget_user_profile_sources` setting (issue #561, FR-006) —
505    /// the same handle `crate::register_ecosystems` hands to `NuGetEcosystem`'s
506    /// `NuGetParseContext`, bundled inside `EcosystemRuntime`. See that struct's docs.
507    pub nuget_user_profile_sources: Arc<AtomicBool>,
508    /// Live-updatable `registries.gitlab_instance_host` setting (issue #466, spec
509    /// FR-005a/FR-011a) — the same raw-string handle `crate::register_ecosystems` hands to
510    /// `GitlabCiEcosystem::with_context`, bundled inside `EcosystemRuntime`. See that
511    /// struct's docs for why this is a feature-agnostic `Arc<RwLock<Option<String>>>`
512    /// rather than a `deps-gitlab-ci` type.
513    pub gitlab_instance_host: Arc<RwLock<Option<String>>>,
514    /// Ecosystem ids `crate::register_ecosystems` actually threaded the live
515    /// `registry_policy` handle into (issue #592 security M1) — the single source of truth
516    /// `config::reparse_scope`'s caller uses to scope a `registries.workspace_registries`
517    /// reparse, returned by that same function so the two facts can never independently
518    /// drift. See [`crate::register_ecosystems`]'s doc.
519    pub(crate) workspace_registry_ecosystems: Vec<&'static str>,
520    /// Cold start rate limiter
521    pub cold_start_limiter: ColdStartLimiter,
522    /// Background task handles
523    tasks: tokio::sync::RwLock<HashMap<Uri, JoinHandle<()>>>,
524    /// Whether the client advertised `window.workDoneProgress` support during
525    /// `initialize`. Set once, read from spawned lifecycle tasks that have no
526    /// direct access to `ClientCapabilities` (see `RegistryProgress::start` call
527    /// sites in `document::lifecycle`).
528    progress_supported: AtomicBool,
529    /// Whether the client advertised `workspace.inlayHint.refreshSupport` during
530    /// `initialize`. Set once, read from spawned lifecycle tasks (issue #493: a
531    /// client that never declared this and never replies would otherwise hang the
532    /// unbounded `inlay_hint_refresh` await forever).
533    inlay_hint_refresh_supported: AtomicBool,
534    /// Whether the client advertised `workspace.codeLens.refreshSupport` during
535    /// `initialize`. See `inlay_hint_refresh_supported` for rationale.
536    code_lens_refresh_supported: AtomicBool,
537    /// Whether the client advertised `workspace.diagnostics.refreshSupport` during
538    /// `initialize`. Mirrors `inlay_hint_refresh_supported`'s rationale, but for
539    /// `document::reparse::reparse_open_documents` (issue #592), which — unlike the
540    /// lifecycle tasks the other three mirrors serve — is a free function with no access to
541    /// `Backend::client_capabilities`.
542    diagnostic_refresh_supported: AtomicBool,
543    /// Server-wide bound on concurrent registry-fetch documents in flight. See
544    /// [`FETCH_PERMITS`].
545    pub(crate) fetch_permits: Arc<tokio::sync::Semaphore>,
546    /// Coalesced [`crate::config::ReparseScope`] pending a debounced reparse triggered by
547    /// `workspace/didChangeConfiguration` (issue #592) — unioned (never replaced) by every
548    /// parse-affecting config change in a burst, and drained by whichever debounce worker's
549    /// generation is still current when it wakes, or whichever wakes once
550    /// [`Self::pending_reparse_overdue`] trips. See [`Self::queue_reparse`].
551    pending_reparse: std::sync::Mutex<Option<PendingReparse>>,
552    /// Generation counter bumped by [`Self::queue_reparse`], letting a debounce worker
553    /// detect it was superseded by a newer config change before draining `pending_reparse`.
554    config_generation: AtomicU64,
555}
556
557/// A coalesced, not-yet-drained reparse scope plus when it was first queued (issue #592
558/// security M3): `first_queued_at` is set once, by the change that starts a new pending
559/// entry, and survives every later union — a continuous burst of config changes arriving
560/// faster than the debounce window must not starve the reparse forever, so a debounce
561/// worker forces a drain once this timestamp is old enough, regardless of whether it was
562/// itself superseded by a newer generation.
563struct PendingReparse {
564    scope: crate::config::ReparseScope,
565    first_queued_at: Instant,
566}
567
568impl ServerState {
569    /// Creates a new server state with default configuration.
570    pub fn new() -> Self {
571        let registry_policy = Arc::new(RegistryAccessPolicy::default());
572        // `HttpCache::with_policy` (not `HttpCache::new`) so this server's one long-lived cache
573        // shares the same policy handle `register_ecosystems` hands to `CargoEcosystem` below —
574        // issue #455's workspace-tier connect-time guard needs the live policy, not a
575        // default-initialized copy.
576        let cache = Arc::new(HttpCache::with_policy(Arc::clone(&registry_policy)));
577        let osv = Arc::new(OsvClient::new(Arc::clone(&cache)));
578        let deps_dev = Arc::new(DepsDevClient::new(Arc::clone(&cache)));
579        let lockfile_cache = Arc::new(LockFileCache::new());
580        let ecosystem_registry = Arc::new(EcosystemRegistry::new());
581        let nuget_user_profile_sources = Arc::new(AtomicBool::new(false));
582        let gitlab_instance_host = Arc::new(RwLock::new(None));
583
584        // Register ecosystems based on enabled features
585        let workspace_registry_ecosystems = crate::register_ecosystems(
586            &ecosystem_registry,
587            Arc::clone(&cache),
588            &crate::EcosystemRuntime {
589                policy: Arc::clone(&registry_policy),
590                nuget_user_profile_sources: Arc::clone(&nuget_user_profile_sources),
591                gitlab_instance_host: Arc::clone(&gitlab_instance_host),
592            },
593        );
594
595        // Default interval, live-updated by `set_min_interval` once `initialize`/
596        // `did_change_configuration` parses a real `cold_start.rate_limit_ms` (issue
597        // #499). Sourced from `ColdStartConfig::default()` rather than a bare literal
598        // so this can never drift from `default_rate_limit_ms()`.
599        let cold_start_limiter = ColdStartLimiter::new(Duration::from_millis(
600            crate::config::ColdStartConfig::default().rate_limit_ms,
601        ));
602
603        Self {
604            documents: DashMap::new(),
605            cache,
606            osv,
607            deps_dev,
608            lockfile_cache,
609            ecosystem_registry,
610            registry_policy,
611            nuget_user_profile_sources,
612            gitlab_instance_host,
613            workspace_registry_ecosystems,
614            cold_start_limiter,
615            tasks: tokio::sync::RwLock::new(HashMap::new()),
616            progress_supported: AtomicBool::new(false),
617            inlay_hint_refresh_supported: AtomicBool::new(false),
618            code_lens_refresh_supported: AtomicBool::new(false),
619            diagnostic_refresh_supported: AtomicBool::new(false),
620            fetch_permits: Arc::new(tokio::sync::Semaphore::new(FETCH_PERMITS)),
621            pending_reparse: std::sync::Mutex::new(None),
622            config_generation: AtomicU64::new(0),
623        }
624    }
625
626    /// Returns whether the client supports LSP work done progress notifications.
627    pub fn supports_progress(&self) -> bool {
628        self.progress_supported.load(Ordering::Relaxed)
629    }
630
631    /// Records whether the client supports LSP work done progress notifications.
632    ///
633    /// Called once from `initialize` with the result of negotiating
634    /// `window.workDoneProgress` from `ClientCapabilities`.
635    pub fn set_progress_supported(&self, supported: bool) {
636        self.progress_supported.store(supported, Ordering::Relaxed);
637    }
638
639    /// Returns whether the client supports `workspace/inlayHint/refresh`.
640    pub fn inlay_hint_refresh_supported(&self) -> bool {
641        self.inlay_hint_refresh_supported.load(Ordering::Relaxed)
642    }
643
644    /// Records whether the client supports `workspace/inlayHint/refresh`.
645    ///
646    /// Called once from `initialize` with the result of negotiating
647    /// `workspace.inlayHint.refreshSupport` from `ClientCapabilities`.
648    pub fn set_inlay_hint_refresh_supported(&self, supported: bool) {
649        self.inlay_hint_refresh_supported
650            .store(supported, Ordering::Relaxed);
651    }
652
653    /// Returns whether the client supports `workspace/codeLens/refresh`.
654    pub fn code_lens_refresh_supported(&self) -> bool {
655        self.code_lens_refresh_supported.load(Ordering::Relaxed)
656    }
657
658    /// Records whether the client supports `workspace/codeLens/refresh`.
659    ///
660    /// Called once from `initialize` with the result of negotiating
661    /// `workspace.codeLens.refreshSupport` from `ClientCapabilities`.
662    pub fn set_code_lens_refresh_supported(&self, supported: bool) {
663        self.code_lens_refresh_supported
664            .store(supported, Ordering::Relaxed);
665    }
666
667    /// Returns whether the client supports `workspace/diagnostic/refresh`.
668    pub fn diagnostic_refresh_supported(&self) -> bool {
669        self.diagnostic_refresh_supported.load(Ordering::Relaxed)
670    }
671
672    /// Records whether the client supports `workspace/diagnostic/refresh`.
673    ///
674    /// Called once from `initialize` with the result of negotiating
675    /// `workspace.diagnostics.refreshSupport` from `ClientCapabilities`.
676    pub fn set_diagnostic_refresh_supported(&self, supported: bool) {
677        self.diagnostic_refresh_supported
678            .store(supported, Ordering::Relaxed);
679    }
680
681    /// Unions `scope` into the pending coalesced reparse and bumps the generation counter
682    /// (issue #592), returning the new generation.
683    ///
684    /// Union happens first, and both operations happen while the same lock is held —
685    /// bumping the generation first would let a debounce worker that wakes between the two
686    /// steps drain a union still missing this call's own scope. `first_queued_at` is set
687    /// only when this call starts a fresh pending entry (`None` -> `Some`); a union onto an
688    /// already-pending entry keeps the original timestamp, so [`Self::pending_reparse_overdue`]
689    /// measures from the *first* unhandled change in a burst, not the latest (security M3:
690    /// otherwise a burst arriving faster than the debounce window could starve the reparse
691    /// indefinitely).
692    pub(crate) fn queue_reparse(&self, scope: crate::config::ReparseScope) -> u64 {
693        {
694            let mut pending = self
695                .pending_reparse
696                .lock()
697                .unwrap_or_else(std::sync::PoisonError::into_inner);
698            *pending = Some(match pending.take() {
699                Some(existing) => PendingReparse {
700                    scope: existing.scope.union(scope),
701                    first_queued_at: existing.first_queued_at,
702                },
703                None => PendingReparse {
704                    scope,
705                    first_queued_at: Instant::now(),
706                },
707            });
708        }
709        self.config_generation.fetch_add(1, Ordering::SeqCst) + 1
710    }
711
712    /// Current reparse-coalescing generation (issue #592), read by a debounce worker to
713    /// detect whether it was superseded by a newer config change before draining
714    /// `pending_reparse`.
715    pub(crate) fn config_generation(&self) -> u64 {
716        self.config_generation.load(Ordering::SeqCst)
717    }
718
719    /// Whether the pending coalesced reparse has been waiting at least `max_wait` since it
720    /// was first queued (issue #592 security M3). A debounce worker that finds itself
721    /// superseded by a newer config generation normally defers to that newer worker — but
722    /// under a continuous burst arriving faster than the debounce window, every worker would
723    /// see itself superseded forever. Once this returns `true`, the worker that observes it
724    /// must drain and reparse regardless of its own generation being stale, capping the
725    /// worst-case staleness a live-reloaded, security-relevant setting can be left
726    /// unapplied to open documents' rendered data.
727    pub(crate) fn pending_reparse_overdue(&self, max_wait: Duration) -> bool {
728        self.pending_reparse
729            .lock()
730            .unwrap_or_else(std::sync::PoisonError::into_inner)
731            .as_ref()
732            .is_some_and(|pending| pending.first_queued_at.elapsed() >= max_wait)
733    }
734
735    /// Drains the pending coalesced reparse scope, if any (issue #592).
736    ///
737    /// Returns `None` if no config change is pending — reachable even for a worker that
738    /// just passed its own generation check: one that wakes between a newer change's union
739    /// and its generation bump can lose the race to drain first (see
740    /// [`Self::queue_reparse`]'s ordering). Callers must treat `None` as "nothing to do",
741    /// never `unwrap`/`expect` it.
742    pub(crate) fn take_pending_reparse(&self) -> Option<crate::config::ReparseScope> {
743        self.pending_reparse
744            .lock()
745            .unwrap_or_else(std::sync::PoisonError::into_inner)
746            .take()
747            .map(|pending| pending.scope)
748    }
749
750    /// Fires `workspace/inlayHint/refresh` and `workspace/codeLens/refresh` as
751    /// detached, capability-gated, timeout-bounded background requests (issue #493).
752    ///
753    /// Neither refresh feeds anything downstream (hover/inlay-hint/code-lens
754    /// handlers recompute on demand from already-committed document state), so a
755    /// failure or timeout is only logged and never blocks the caller's critical
756    /// path — the OSV vulnerability commit and diagnostics publish this is called
757    /// alongside. The capability gate skips clients that never declared support (and
758    /// so may never reply); the timeout additionally bounds a client that declares
759    /// support but stops replying, so detached tasks can't accumulate without limit.
760    pub fn spawn_refresh_requests(&self, client: &Client) {
761        if self.inlay_hint_refresh_supported() {
762            let client = client.clone();
763            tokio::spawn(async move {
764                match tokio::time::timeout(CLIENT_REFRESH_TIMEOUT, client.inlay_hint_refresh())
765                    .await
766                {
767                    Ok(Ok(())) => {}
768                    Ok(Err(e)) => tracing::debug!("inlay_hint_refresh failed: {:?}", e),
769                    Err(_) => tracing::debug!(
770                        "inlay_hint_refresh timed out after {CLIENT_REFRESH_TIMEOUT:?}"
771                    ),
772                }
773            });
774        }
775        if self.code_lens_refresh_supported() {
776            let client = client.clone();
777            tokio::spawn(async move {
778                match tokio::time::timeout(CLIENT_REFRESH_TIMEOUT, client.code_lens_refresh()).await
779                {
780                    Ok(Ok(())) => {}
781                    Ok(Err(e)) => tracing::debug!("code_lens_refresh failed: {:?}", e),
782                    Err(_) => tracing::debug!(
783                        "code_lens_refresh timed out after {CLIENT_REFRESH_TIMEOUT:?}"
784                    ),
785                }
786            });
787        }
788    }
789
790    /// Retrieves document state by URI.
791    ///
792    /// Returns a read-only reference to the document state if it exists.
793    /// The reference holds a lock on the internal map, so it should be
794    /// dropped as soon as possible. Prefer [`Self::with_document`] when the
795    /// caller needs to `.await` anything afterward — it makes dropping the
796    /// guard before the `.await` structural rather than a convention to remember.
797    pub fn get_document(
798        &self,
799        uri: &Uri,
800    ) -> Option<dashmap::mapref::one::Ref<'_, Uri, DocumentState>> {
801        self.documents.get(uri)
802    }
803
804    /// Extracts owned data from a document without exposing the DashMap shard `Ref`
805    /// to the caller.
806    ///
807    /// `extract` runs synchronously while the shard lock is held and must return only
808    /// owned or `Arc`-cloned data (e.g. via [`DocumentState::parse_result_arc`]); the
809    /// `Ref` this method acquires is dropped before the call returns, so *that*
810    /// particular guard can never leak across an `.await` through `T`. This does not by
811    /// itself prevent `extract` from independently capturing and returning some other,
812    /// unrelated `Ref` (e.g. from a second `get_document` call on `state`) — `extract`'s
813    /// closure environment is not restricted to this method's own guard. The
814    /// project-wide backstop against the DashMap Ref-across-await hazard (#333) in
815    /// general is the `await-holding-invalid-types` lint configured in the workspace
816    /// `clippy.toml` (#334), not this method's type signature alone.
817    ///
818    /// # Examples
819    ///
820    /// ```no_run
821    /// # use deps_lsp::document::ServerState;
822    /// # use tower_lsp_server::ls_types::Uri;
823    /// # async fn example(state: &ServerState, uri: &Uri) {
824    /// let content_len = state.with_document(uri, |doc| doc.content.len());
825    /// # }
826    /// ```
827    pub fn with_document<T>(
828        &self,
829        uri: &Uri,
830        extract: impl FnOnce(&DocumentState) -> T,
831    ) -> Option<T> {
832        self.documents.get(uri).map(|doc| extract(&doc))
833    }
834
835    /// Retrieves a cloned copy of document state by URI.
836    ///
837    /// This method clones the document state immediately and releases
838    /// the DashMap lock, allowing concurrent access to the map while
839    /// the document is being processed. Use this in hot paths where
840    /// async operations are performed with the document data.
841    ///
842    /// # Performance
843    ///
844    /// Cloning `DocumentState` is relatively cheap: `String`/`HashMap` metadata is
845    /// deep-cloned, but the parse result is an `Arc` clone (a refcount bump), not a
846    /// deep copy of the underlying ecosystem-specific parse data.
847    ///
848    /// # Examples
849    ///
850    /// ```no_run
851    /// # use deps_lsp::document::ServerState;
852    /// # use tower_lsp_server::ls_types::Uri;
853    /// # async fn example(state: &ServerState, uri: &Uri) {
854    /// // Lock released immediately after clone
855    /// let doc = state.get_document_clone(uri);
856    ///
857    /// if let Some(doc) = doc {
858    ///     // Perform async operations without holding lock
859    ///     let result = process_async(&doc).await;
860    /// }
861    /// # }
862    /// # async fn process_async(doc: &deps_lsp::document::DocumentState) {}
863    /// ```
864    pub fn get_document_clone(&self, uri: &Uri) -> Option<DocumentState> {
865        self.documents.get(uri).map(|doc| doc.clone())
866    }
867
868    /// Updates or inserts document state.
869    ///
870    /// If a document already exists at the given URI, it is replaced.
871    /// Otherwise, a new entry is created.
872    pub fn update_document(&self, uri: Uri, state: DocumentState) {
873        self.documents.insert(uri, state);
874    }
875
876    /// Removes document state and returns the removed entry.
877    ///
878    /// Returns `None` if no document exists at the given URI.
879    pub fn remove_document(&self, uri: &Uri) -> Option<(Uri, DocumentState)> {
880        self.documents.remove(uri)
881    }
882
883    /// Spawns a background task for a document.
884    ///
885    /// If a task already exists for the given URI, it is aborted before
886    /// the new task is registered. This ensures only one background task
887    /// runs per document.
888    ///
889    /// Typical use case: fetching version data asynchronously after
890    /// document open or change.
891    pub async fn spawn_background_task(&self, uri: Uri, task: JoinHandle<()>) {
892        let mut tasks = self.tasks.write().await;
893
894        // Cancel existing task if any
895        if let Some(old_task) = tasks.remove(&uri) {
896            old_task.abort();
897        }
898
899        tasks.insert(uri, task);
900    }
901
902    /// Cancels the background task for a document.
903    ///
904    /// If no task exists, this is a no-op.
905    pub async fn cancel_background_task(&self, uri: &Uri) {
906        let mut tasks = self.tasks.write().await;
907        if let Some(task) = tasks.remove(uri) {
908            task.abort();
909        }
910    }
911
912    /// Returns the number of open documents.
913    pub fn document_count(&self) -> usize {
914        self.documents.len()
915    }
916}
917
918impl Default for ServerState {
919    fn default() -> Self {
920        Self::new()
921    }
922}
923
924#[cfg(test)]
925mod tests {
926    use super::*;
927
928    use std::assert_matches;
929
930    // =========================================================================
931    // Generic tests (no feature flag required)
932    // =========================================================================
933
934    // =========================================================================
935    // LoadingState tests
936    // =========================================================================
937
938    mod loading_state_tests {
939        use super::*;
940
941        #[test]
942        fn test_loading_state_default() {
943            let state = LoadingState::default();
944            assert_eq!(state, LoadingState::Idle);
945        }
946
947        #[test]
948        fn test_loading_state_transitions() {
949            use std::time::Duration;
950
951            let content = "[dependencies]\nserde = \"1.0\"".to_string();
952            let mut doc = DocumentState::new_without_parse_result(EcosystemId::Cargo, content);
953
954            // Initial state
955            assert_eq!(doc.loading_state, LoadingState::Idle);
956            assert!(doc.loading_started_at.is_none());
957
958            // Transition to Loading
959            doc.set_loading();
960            assert_eq!(doc.loading_state, LoadingState::Loading);
961            assert!(doc.loading_started_at.is_some());
962
963            // Small sleep to ensure duration is non-zero
964            std::thread::sleep(Duration::from_millis(10));
965
966            // Check loading duration
967            let duration = doc.loading_duration();
968            assert!(duration.is_some());
969            assert!(duration.unwrap() >= Duration::from_millis(10));
970
971            // Transition to Loaded
972            doc.set_loaded();
973            assert_eq!(doc.loading_state, LoadingState::Loaded);
974            assert!(doc.loading_started_at.is_none());
975            assert!(doc.loading_duration().is_none());
976        }
977
978        #[test]
979        fn test_loading_state_failed_transition() {
980            let content = "[dependencies]\nserde = \"1.0\"".to_string();
981            let mut doc = DocumentState::new_without_parse_result(EcosystemId::Cargo, content);
982
983            doc.set_loading();
984            assert_eq!(doc.loading_state, LoadingState::Loading);
985
986            doc.set_failed();
987            assert_eq!(doc.loading_state, LoadingState::Failed);
988            assert!(doc.loading_started_at.is_none());
989        }
990
991        #[test]
992        fn test_loading_state_clone() {
993            let content = "[dependencies]\nserde = \"1.0\"".to_string();
994            let mut doc = DocumentState::new_without_parse_result(EcosystemId::Cargo, content);
995
996            doc.set_loading();
997            let cloned = doc.clone();
998
999            assert_eq!(cloned.loading_state, LoadingState::Loading);
1000            assert!(cloned.loading_started_at.is_some());
1001        }
1002
1003        #[test]
1004        fn test_loading_state_debug() {
1005            let content = "[dependencies]\nserde = \"1.0\"".to_string();
1006            let mut doc = DocumentState::new_without_parse_result(EcosystemId::Cargo, content);
1007            doc.set_loading();
1008
1009            let debug_str = format!("{:?}", doc);
1010            assert!(debug_str.contains("loading_state"));
1011            assert!(debug_str.contains("Loading"));
1012        }
1013
1014        #[test]
1015        fn test_loading_duration_none_when_idle() {
1016            let content = "[dependencies]\nserde = \"1.0\"".to_string();
1017            let doc = DocumentState::new_without_parse_result(EcosystemId::Cargo, content);
1018
1019            assert_eq!(doc.loading_state, LoadingState::Idle);
1020            assert!(doc.loading_duration().is_none());
1021        }
1022
1023        #[test]
1024        fn test_loading_state_equality() {
1025            assert_eq!(LoadingState::Idle, LoadingState::Idle);
1026            assert_eq!(LoadingState::Loading, LoadingState::Loading);
1027            assert_eq!(LoadingState::Loaded, LoadingState::Loaded);
1028            assert_eq!(LoadingState::Failed, LoadingState::Failed);
1029
1030            assert_ne!(LoadingState::Idle, LoadingState::Loading);
1031            assert_ne!(LoadingState::Loading, LoadingState::Loaded);
1032        }
1033
1034        #[test]
1035        fn test_loading_duration_tracks_time_correctly() {
1036            use std::time::Duration;
1037
1038            let content = "[dependencies]\nserde = \"1.0\"".to_string();
1039            let mut doc = DocumentState::new_without_parse_result(EcosystemId::Cargo, content);
1040
1041            doc.set_loading();
1042
1043            // Check duration increases over time
1044            let duration1 = doc.loading_duration().unwrap();
1045            std::thread::sleep(Duration::from_millis(20));
1046            let duration2 = doc.loading_duration().unwrap();
1047
1048            assert!(duration2 > duration1, "Duration should increase over time");
1049        }
1050
1051        #[tokio::test]
1052        async fn test_concurrent_loading_state_mutations() {
1053            use std::sync::Arc;
1054            use tokio::sync::Barrier;
1055
1056            let state = Arc::new(ServerState::new());
1057            let uri = deps_core::test_util::test_uri("/concurrent-loading-test.toml");
1058
1059            let doc = DocumentState::new_without_parse_result(EcosystemId::Cargo, String::new());
1060            state.update_document(uri.clone(), doc);
1061
1062            let barrier = Arc::new(Barrier::new(10));
1063            let mut handles = vec![];
1064
1065            for i in 0..10 {
1066                let state_clone = Arc::clone(&state);
1067                let uri_clone = uri.clone();
1068                let barrier_clone = Arc::clone(&barrier);
1069
1070                handles.push(tokio::spawn(async move {
1071                    barrier_clone.wait().await;
1072                    if let Some(mut doc) = state_clone.documents.get_mut(&uri_clone) {
1073                        if i % 3 == 0 {
1074                            doc.set_loading();
1075                        } else if i % 3 == 1 {
1076                            doc.set_loaded();
1077                        } else {
1078                            doc.set_failed();
1079                        }
1080                    }
1081                }));
1082            }
1083
1084            for handle in handles {
1085                handle.await.unwrap();
1086            }
1087
1088            let doc = state.get_document(&uri).unwrap();
1089            assert_matches!(
1090                doc.loading_state,
1091                LoadingState::Idle
1092                    | LoadingState::Loading
1093                    | LoadingState::Loaded
1094                    | LoadingState::Failed
1095            );
1096        }
1097
1098        #[test]
1099        fn test_set_loaded_idempotent() {
1100            let mut doc =
1101                DocumentState::new_without_parse_result(EcosystemId::Cargo, String::new());
1102
1103            doc.set_loading();
1104            doc.set_loaded();
1105
1106            // Call again - should be safe
1107            doc.set_loaded();
1108
1109            assert_eq!(doc.loading_state, LoadingState::Loaded);
1110            assert!(doc.loading_started_at.is_none());
1111        }
1112
1113        #[test]
1114        fn test_set_loading_resets_timer() {
1115            let mut doc =
1116                DocumentState::new_without_parse_result(EcosystemId::Cargo, String::new());
1117
1118            doc.set_loading();
1119            let first_start = doc.loading_started_at.unwrap();
1120
1121            std::thread::sleep(std::time::Duration::from_millis(10));
1122
1123            // Call set_loading again - should reset timer
1124            doc.set_loading();
1125            let second_start = doc.loading_started_at.unwrap();
1126
1127            assert!(second_start > first_start, "Timer should be reset");
1128            assert_eq!(doc.loading_state, LoadingState::Loading);
1129        }
1130
1131        #[test]
1132        fn test_retry_after_failure() {
1133            let mut doc =
1134                DocumentState::new_without_parse_result(EcosystemId::Cargo, String::new());
1135
1136            doc.set_loading();
1137            doc.set_failed();
1138            assert_eq!(doc.loading_state, LoadingState::Failed);
1139            assert!(doc.loading_started_at.is_none());
1140
1141            // Retry
1142            doc.set_loading();
1143            assert_eq!(doc.loading_state, LoadingState::Loading);
1144            assert!(doc.loading_started_at.is_some());
1145
1146            doc.set_loaded();
1147            assert_eq!(doc.loading_state, LoadingState::Loaded);
1148        }
1149
1150        #[test]
1151        fn test_refresh_after_loaded() {
1152            let mut doc =
1153                DocumentState::new_without_parse_result(EcosystemId::Cargo, String::new());
1154
1155            doc.set_loading();
1156            doc.set_loaded();
1157            assert_eq!(doc.loading_state, LoadingState::Loaded);
1158
1159            // Refresh
1160            doc.set_loading();
1161            assert_eq!(doc.loading_state, LoadingState::Loading);
1162            assert!(doc.loading_started_at.is_some());
1163
1164            doc.set_loaded();
1165            assert_eq!(doc.loading_state, LoadingState::Loaded);
1166        }
1167    }
1168
1169    // =========================================================================
1170    // `is_ready_for_batch_update` tests — the shared predicate `handlers::code_lens`
1171    // and `server::execute_update_all_outdated` both consult (M7/S1).
1172    // =========================================================================
1173
1174    mod is_ready_for_batch_update_tests {
1175        use super::*;
1176
1177        #[test]
1178        fn test_not_ready_without_a_version() {
1179            let mut doc =
1180                DocumentState::new_without_parse_result(EcosystemId::Cargo, String::new());
1181            doc.set_loaded();
1182            assert!(!doc.is_ready_for_batch_update());
1183        }
1184
1185        #[test]
1186        fn test_not_ready_while_loading_even_with_a_version() {
1187            let mut doc =
1188                DocumentState::new_without_parse_result(EcosystemId::Cargo, String::new());
1189            doc.set_version(Some(1));
1190            doc.set_loading();
1191            assert!(!doc.is_ready_for_batch_update());
1192        }
1193
1194        #[test]
1195        fn test_ready_when_loaded_with_a_version() {
1196            let mut doc =
1197                DocumentState::new_without_parse_result(EcosystemId::Cargo, String::new());
1198            doc.set_version(Some(1));
1199            doc.set_loaded();
1200            assert!(doc.is_ready_for_batch_update());
1201        }
1202
1203        #[test]
1204        fn test_ready_when_failed_with_a_version() {
1205            // `Failed` is not `Loading` — a document whose registry fetch failed but
1206            // which still has a known LSP version is safe to batch-edit (the edit only
1207            // touches spans already present in `cached_versions`, which may simply be
1208            // sparse after a failure).
1209            let mut doc =
1210                DocumentState::new_without_parse_result(EcosystemId::Cargo, String::new());
1211            doc.set_version(Some(1));
1212            doc.set_failed();
1213            assert!(doc.is_ready_for_batch_update());
1214        }
1215
1216        #[test]
1217        fn test_not_ready_without_version_or_loaded_state() {
1218            let doc = DocumentState::new_without_parse_result(EcosystemId::Cargo, String::new());
1219            assert!(!doc.is_ready_for_batch_update());
1220        }
1221    }
1222
1223    #[test]
1224    fn test_server_state_creation() {
1225        let state = ServerState::new();
1226        assert_eq!(state.document_count(), 0);
1227        assert!(state.cache.is_empty(), "Cache should start empty");
1228    }
1229
1230    #[test]
1231    fn test_server_state_default() {
1232        let state = ServerState::default();
1233        assert_eq!(state.document_count(), 0);
1234    }
1235
1236    /// Issue #493: the fire-and-forget refresh call sites in `document::lifecycle`
1237    /// read these flags synchronously instead of awaiting `ClientCapabilities` — a
1238    /// wrong default (or a setter that doesn't round-trip) would either suppress a
1239    /// refresh a client wants or resurrect the original hang by letting an
1240    /// unsupported client's request be sent anyway.
1241    #[test]
1242    fn test_inlay_hint_refresh_supported_defaults_false_and_round_trips() {
1243        let state = ServerState::new();
1244        assert!(!state.inlay_hint_refresh_supported());
1245
1246        state.set_inlay_hint_refresh_supported(true);
1247        assert!(state.inlay_hint_refresh_supported());
1248
1249        state.set_inlay_hint_refresh_supported(false);
1250        assert!(!state.inlay_hint_refresh_supported());
1251    }
1252
1253    #[test]
1254    fn test_code_lens_refresh_supported_defaults_false_and_round_trips() {
1255        let state = ServerState::new();
1256        assert!(!state.code_lens_refresh_supported());
1257
1258        state.set_code_lens_refresh_supported(true);
1259        assert!(state.code_lens_refresh_supported());
1260
1261        state.set_code_lens_refresh_supported(false);
1262        assert!(!state.code_lens_refresh_supported());
1263    }
1264
1265    #[test]
1266    fn test_diagnostic_refresh_supported_defaults_false_and_round_trips() {
1267        let state = ServerState::new();
1268        assert!(!state.diagnostic_refresh_supported());
1269
1270        state.set_diagnostic_refresh_supported(true);
1271        assert!(state.diagnostic_refresh_supported());
1272
1273        state.set_diagnostic_refresh_supported(false);
1274        assert!(!state.diagnostic_refresh_supported());
1275    }
1276
1277    // =========================================================================
1278    // Reparse coalescing tests (issue #592)
1279    // =========================================================================
1280
1281    mod reparse_coalescing_tests {
1282        use super::*;
1283        use crate::config::ReparseScope;
1284
1285        #[test]
1286        fn test_queue_reparse_bumps_generation() {
1287            let state = ServerState::new();
1288            assert_eq!(state.config_generation(), 0);
1289
1290            let gen1 = state.queue_reparse(ReparseScope::Ecosystems(vec!["cargo"]));
1291            assert_eq!(gen1, 1);
1292            assert_eq!(state.config_generation(), 1);
1293
1294            let gen2 = state.queue_reparse(ReparseScope::Ecosystems(vec!["npm"]));
1295            assert_eq!(gen2, 2);
1296        }
1297
1298        #[test]
1299        fn test_queue_reparse_unions_pending_scope_across_calls() {
1300            let state = ServerState::new();
1301            state.queue_reparse(ReparseScope::Ecosystems(vec!["cargo"]));
1302            state.queue_reparse(ReparseScope::Ecosystems(vec!["npm"]));
1303
1304            let scope = state
1305                .take_pending_reparse()
1306                .expect("a pending scope must exist after two queue_reparse calls");
1307            assert!(
1308                scope.matches("cargo"),
1309                "earlier change's scope must survive"
1310            );
1311            assert!(scope.matches("npm"));
1312        }
1313
1314        #[test]
1315        fn test_take_pending_reparse_drains_and_returns_none_on_empty() {
1316            let state = ServerState::new();
1317            assert!(state.take_pending_reparse().is_none(), "nothing queued yet");
1318
1319            state.queue_reparse(ReparseScope::All);
1320            assert!(state.take_pending_reparse().is_some());
1321            assert!(
1322                state.take_pending_reparse().is_none(),
1323                "a second drain must see nothing left (M1: never unwrap this)"
1324            );
1325        }
1326
1327        /// Issue #592 security M3: nothing pending must never read as overdue, regardless
1328        /// of `max_wait`.
1329        #[test]
1330        fn test_pending_reparse_overdue_false_when_nothing_queued() {
1331            let state = ServerState::new();
1332            assert!(!state.pending_reparse_overdue(Duration::ZERO));
1333        }
1334
1335        /// Security M3: a freshly queued change is never immediately overdue, but becomes
1336        /// so once real time exceeds `max_wait` — proven with a tiny `max_wait` rather than
1337        /// waiting out the real multi-second constant, since the threshold is a parameter.
1338        #[tokio::test]
1339        async fn test_pending_reparse_overdue_becomes_true_after_max_wait_elapses() {
1340            let state = ServerState::new();
1341            state.queue_reparse(ReparseScope::Ecosystems(vec!["cargo"]));
1342
1343            assert!(
1344                !state.pending_reparse_overdue(Duration::from_secs(10)),
1345                "must not be overdue against a generous max_wait"
1346            );
1347
1348            tokio::time::sleep(Duration::from_millis(5)).await;
1349            assert!(
1350                state.pending_reparse_overdue(Duration::from_millis(1)),
1351                "must be overdue once real elapsed time exceeds max_wait"
1352            );
1353        }
1354
1355        /// Security M3's actual safety property: a union onto an already-pending entry must
1356        /// NOT reset the queued-at clock to the union's own time — otherwise a burst of
1357        /// changes arriving faster than `max_wait` would keep pushing the deadline out
1358        /// forever, exactly the starvation this mechanism exists to cap.
1359        #[tokio::test]
1360        async fn test_queue_reparse_union_preserves_first_queued_at() {
1361            let state = ServerState::new();
1362            state.queue_reparse(ReparseScope::Ecosystems(vec!["cargo"]));
1363
1364            tokio::time::sleep(Duration::from_millis(20)).await;
1365            // A second change arrives well after the first — if this union reset the
1366            // clock, `pending_reparse_overdue` below (checked against the first change's
1367            // age) would wrongly read `false`.
1368            state.queue_reparse(ReparseScope::Ecosystems(vec!["npm"]));
1369
1370            assert!(
1371                state.pending_reparse_overdue(Duration::from_millis(15)),
1372                "overdue must be measured from the first queued change, not the latest union"
1373            );
1374        }
1375
1376        #[tokio::test]
1377        async fn test_fetch_permits_bounds_concurrency() {
1378            let state = Arc::new(ServerState::new());
1379            assert_eq!(state.fetch_permits.available_permits(), 4);
1380
1381            let permit = state.fetch_permits.acquire().await.unwrap();
1382            assert_eq!(state.fetch_permits.available_permits(), 3);
1383            drop(permit);
1384            assert_eq!(state.fetch_permits.available_permits(), 4);
1385        }
1386
1387        /// Issue #592 critic S2/S3: `fetch_permits` must actually bound real concurrent
1388        /// contention, not just track a count in isolation. Ten tasks race for the four
1389        /// permits `run_document_open_background_task` and `run_document_change_task`
1390        /// share; the observed peak concurrency must never exceed `FETCH_PERMITS` (4),
1391        /// mirroring the `ConcurrencyTrackingRegistry` pattern `fetch_latest_versions_parallel`'s
1392        /// own tests use for the orthogonal per-document axis.
1393        #[tokio::test]
1394        async fn test_fetch_permits_bounds_real_concurrent_contention() {
1395            use std::sync::atomic::AtomicUsize;
1396
1397            let state = Arc::new(ServerState::new());
1398            let current = Arc::new(AtomicUsize::new(0));
1399            let max_seen = Arc::new(AtomicUsize::new(0));
1400
1401            let mut handles = Vec::new();
1402            for _ in 0..10 {
1403                let state = Arc::clone(&state);
1404                let current = Arc::clone(&current);
1405                let max_seen = Arc::clone(&max_seen);
1406                handles.push(tokio::spawn(async move {
1407                    let _permit = state.fetch_permits.acquire().await.unwrap();
1408                    let now = current.fetch_add(1, Ordering::SeqCst) + 1;
1409                    max_seen.fetch_max(now, Ordering::SeqCst);
1410                    tokio::time::sleep(std::time::Duration::from_millis(30)).await;
1411                    current.fetch_sub(1, Ordering::SeqCst);
1412                }));
1413            }
1414            for handle in handles {
1415                handle.await.unwrap();
1416            }
1417
1418            let peak = max_seen.load(Ordering::SeqCst);
1419            assert!(
1420                peak <= 4,
1421                "peak concurrent permit-holders ({peak}) must never exceed FETCH_PERMITS \
1422                 (4) — the semaphore failed to bound concurrency"
1423            );
1424            assert!(
1425                peak >= 2,
1426                "peak concurrent permit-holders ({peak}) is implausibly low for 10 tasks \
1427                 racing for 4 permits — this test isn't exercising real contention (a \
1428                 liveness check, not exact equality, since scheduler timing shouldn't pin \
1429                 the assertion to exactly 4)"
1430            );
1431        }
1432    }
1433
1434    #[tokio::test]
1435    async fn test_server_state_background_tasks() {
1436        let state = ServerState::new();
1437        let uri = deps_core::test_util::test_uri("/test.toml");
1438
1439        let task = tokio::spawn(async {
1440            tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1441        });
1442
1443        state.spawn_background_task(uri.clone(), task).await;
1444        state.cancel_background_task(&uri).await;
1445    }
1446
1447    #[tokio::test]
1448    async fn test_spawn_background_task_cancels_previous() {
1449        let state = ServerState::new();
1450        let uri = deps_core::test_util::test_uri("/test.toml");
1451
1452        let task1 = tokio::spawn(async {
1453            tokio::time::sleep(std::time::Duration::from_secs(10)).await;
1454        });
1455        state.spawn_background_task(uri.clone(), task1).await;
1456
1457        let task2 = tokio::spawn(async {
1458            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1459        });
1460        state.spawn_background_task(uri.clone(), task2).await;
1461        state.cancel_background_task(&uri).await;
1462    }
1463
1464    #[tokio::test]
1465    async fn test_cancel_background_task_nonexistent() {
1466        let state = ServerState::new();
1467        let uri = deps_core::test_util::test_uri("/test.toml");
1468        state.cancel_background_task(&uri).await;
1469    }
1470
1471    // =========================================================================
1472    // ColdStartLimiter tests
1473    // =========================================================================
1474
1475    mod cold_start_limiter {
1476        use super::*;
1477        use std::time::Duration;
1478
1479        #[test]
1480        fn test_allows_first_request() {
1481            let limiter = ColdStartLimiter::new(Duration::from_millis(100));
1482            let uri = deps_core::test_util::test_uri("/test.toml");
1483            assert!(
1484                limiter.allow_cold_start(&uri),
1485                "First request should be allowed"
1486            );
1487        }
1488
1489        #[test]
1490        fn test_blocks_rapid_requests() {
1491            let limiter = ColdStartLimiter::new(Duration::from_millis(100));
1492            let uri = deps_core::test_util::test_uri("/test.toml");
1493
1494            assert!(limiter.allow_cold_start(&uri), "First request allowed");
1495            assert!(
1496                !limiter.allow_cold_start(&uri),
1497                "Second immediate request should be blocked"
1498            );
1499        }
1500
1501        #[tokio::test]
1502        async fn test_allows_after_interval() {
1503            let limiter = ColdStartLimiter::new(Duration::from_millis(50));
1504            let uri = deps_core::test_util::test_uri("/test.toml");
1505
1506            assert!(limiter.allow_cold_start(&uri), "First request allowed");
1507            tokio::time::sleep(Duration::from_millis(60)).await;
1508            assert!(
1509                limiter.allow_cold_start(&uri),
1510                "Request after interval should be allowed"
1511            );
1512        }
1513
1514        /// Issue #499: `set_min_interval` must actually change rate-limiting
1515        /// behavior, not just be stored inertly.
1516        #[tokio::test]
1517        async fn test_set_min_interval_changes_behavior() {
1518            let limiter = ColdStartLimiter::new(Duration::from_millis(100));
1519            let uri = deps_core::test_util::test_uri("/test.toml");
1520
1521            assert!(limiter.allow_cold_start(&uri), "First request allowed");
1522            assert!(
1523                !limiter.allow_cold_start(&uri),
1524                "Second immediate request blocked under the original 100ms interval"
1525            );
1526
1527            // `rate_limit_ms: 0` disables rate limiting entirely (`elapsed < ZERO` is
1528            // never true), so this is deterministic regardless of scheduling jitter —
1529            // no sleep needed, unlike a short nonzero interval would require.
1530            limiter.set_min_interval(Duration::ZERO);
1531
1532            assert!(
1533                limiter.allow_cold_start(&uri),
1534                "Lowering the interval to 0 should allow every request immediately"
1535            );
1536            assert!(
1537                limiter.allow_cold_start(&uri),
1538                "A zero interval keeps allowing consecutive requests"
1539            );
1540        }
1541
1542        #[test]
1543        fn test_different_uris_independent() {
1544            let limiter = ColdStartLimiter::new(Duration::from_millis(100));
1545            let uri1 = deps_core::test_util::test_uri("/test1.toml");
1546            let uri2 = deps_core::test_util::test_uri("/test2.toml");
1547
1548            assert!(limiter.allow_cold_start(&uri1), "URI 1 first request");
1549            assert!(limiter.allow_cold_start(&uri2), "URI 2 first request");
1550            assert!(
1551                !limiter.allow_cold_start(&uri1),
1552                "URI 1 second request blocked"
1553            );
1554            assert!(
1555                !limiter.allow_cold_start(&uri2),
1556                "URI 2 second request blocked"
1557            );
1558        }
1559
1560        #[test]
1561        fn test_cleanup() {
1562            let limiter = ColdStartLimiter::new(Duration::from_millis(100));
1563            let uri1 = deps_core::test_util::test_uri("/test1.toml");
1564            let uri2 = deps_core::test_util::test_uri("/test2.toml");
1565
1566            limiter.allow_cold_start(&uri1);
1567            limiter.allow_cold_start(&uri2);
1568            assert_eq!(limiter.tracked_count(), 2, "Should track 2 URIs");
1569
1570            limiter.cleanup_old_entries(Duration::from_millis(0));
1571            assert_eq!(
1572                limiter.tracked_count(),
1573                0,
1574                "All entries should be cleaned up"
1575            );
1576        }
1577
1578        #[tokio::test]
1579        async fn test_concurrent_access() {
1580            use std::sync::Arc;
1581
1582            let limiter = Arc::new(ColdStartLimiter::new(Duration::from_millis(100)));
1583            let uri = deps_core::test_util::test_uri("/concurrent-test.toml");
1584
1585            let mut handles = vec![];
1586            const CONCURRENT_TASKS: usize = 10;
1587
1588            for _ in 0..CONCURRENT_TASKS {
1589                let limiter_clone = Arc::clone(&limiter);
1590                let uri_clone = uri.clone();
1591                let handle =
1592                    tokio::spawn(async move { limiter_clone.allow_cold_start(&uri_clone) });
1593                handles.push(handle);
1594            }
1595
1596            let mut results = vec![];
1597            for handle in handles {
1598                results.push(handle.await.unwrap());
1599            }
1600
1601            let allowed_count = results.iter().filter(|&&allowed| allowed).count();
1602            assert_eq!(allowed_count, 1, "Exactly one concurrent request allowed");
1603
1604            let blocked_count = results.iter().filter(|&&allowed| !allowed).count();
1605            assert_eq!(
1606                blocked_count,
1607                CONCURRENT_TASKS - 1,
1608                "Rest should be blocked"
1609            );
1610        }
1611    }
1612
1613    // =========================================================================
1614    // Issue #118 regression tests: ecosystem_id resolution
1615    // =========================================================================
1616
1617    /// Regression test for issue #118: before the `EcosystemId` refactor, any
1618    /// `ecosystem_id` outside `{cargo, npm, pypi, go}` silently fell back to
1619    /// `Ecosystem::Cargo`. The constructors are now infallible (`DocumentState`
1620    /// takes `EcosystemId` directly, see the #144 follow-up), so the resolution
1621    /// risk now lives entirely in `str::parse::<EcosystemId>()` — exercised here
1622    /// for every registered ecosystem, alongside the constructor's derivation of
1623    /// `ecosystem_id` back from `EcosystemId::id()`.
1624    #[test]
1625    fn test_document_state_new_without_parse_result_resolves_all_ecosystems() {
1626        for (id, expected) in [
1627            ("cargo", EcosystemId::Cargo),
1628            ("npm", EcosystemId::Npm),
1629            ("pypi", EcosystemId::Pypi),
1630            ("go", EcosystemId::Go),
1631            ("bundler", EcosystemId::Bundler),
1632            ("dart", EcosystemId::Dart),
1633            ("maven", EcosystemId::Maven),
1634            ("composer", EcosystemId::Composer),
1635            ("gradle", EcosystemId::Gradle),
1636            ("swift", EcosystemId::Swift),
1637            ("nuget", EcosystemId::NuGet),
1638            ("deno", EcosystemId::Deno),
1639            ("github-actions", EcosystemId::GithubActions),
1640        ] {
1641            let parsed: EcosystemId = id
1642                .parse()
1643                .unwrap_or_else(|_| panic!("ecosystem_id {id:?} failed to parse"));
1644            assert_eq!(parsed, expected, "ecosystem_id {id:?} misresolved");
1645
1646            let doc = DocumentState::new_without_parse_result(expected, String::new());
1647            assert_eq!(doc.ecosystem, expected);
1648            assert_eq!(doc.ecosystem_id(), id);
1649        }
1650    }
1651
1652    /// Same regression as above, but through `new_from_parse_result` with a real
1653    /// `ParseResult` for one of the previously-misclassified ecosystems (maven).
1654    /// Parses `"maven"` explicitly first, mirroring the parse-then-construct
1655    /// sequence `document::lifecycle::resolve_ecosystem_id` performs in production.
1656    #[cfg(feature = "maven")]
1657    #[test]
1658    fn test_document_state_new_from_parse_result_maven_not_misclassified_as_cargo() {
1659        let state = ServerState::new();
1660        let uri = deps_core::test_util::test_uri("/test/pom.xml");
1661        let ecosystem = state.ecosystem_registry.get("maven").unwrap();
1662        let content = r"<project>
1663  <dependencies>
1664    <dependency>
1665      <groupId>org.apache.commons</groupId>
1666      <artifactId>commons-lang3</artifactId>
1667      <version>3.12.0</version>
1668    </dependency>
1669  </dependencies>
1670</project>
1671"
1672        .to_string();
1673
1674        let parse_result = tokio::runtime::Runtime::new()
1675            .unwrap()
1676            .block_on(ecosystem.parse_manifest(&content, &uri))
1677            .unwrap();
1678
1679        let ecosystem_id: EcosystemId = "maven"
1680            .parse()
1681            .expect("maven must resolve to an EcosystemId");
1682        let doc_state = DocumentState::new_from_parse_result(ecosystem_id, content, parse_result);
1683
1684        assert_eq!(doc_state.ecosystem_id(), "maven");
1685        assert_eq!(doc_state.ecosystem, EcosystemId::Maven);
1686    }
1687
1688    // =========================================================================
1689    // Cargo ecosystem tests
1690    // =========================================================================
1691
1692    #[cfg(feature = "cargo")]
1693    mod cargo_tests {
1694        use super::*;
1695
1696        #[test]
1697        fn test_document_state_creation() {
1698            let state =
1699                DocumentState::new_without_parse_result(EcosystemId::Cargo, "test content".into());
1700
1701            assert_eq!(state.ecosystem, EcosystemId::Cargo);
1702            assert_eq!(state.content, "test content");
1703            assert!(state.cached_versions.is_empty());
1704        }
1705
1706        #[test]
1707        fn test_server_state_document_operations() {
1708            let state = ServerState::new();
1709            let uri = deps_core::test_util::test_uri("/test.toml");
1710            let doc_state =
1711                DocumentState::new_without_parse_result(EcosystemId::Cargo, "test".into());
1712
1713            state.update_document(uri.clone(), doc_state);
1714            assert_eq!(state.document_count(), 1);
1715
1716            let retrieved = state.get_document(&uri);
1717            assert!(retrieved.is_some());
1718            assert_eq!(retrieved.unwrap().content, "test");
1719
1720            let removed = state.remove_document(&uri);
1721            assert!(removed.is_some());
1722            assert_eq!(state.document_count(), 0);
1723        }
1724
1725        #[test]
1726        fn test_document_state_new_from_parse_result() {
1727            let state = ServerState::new();
1728            let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
1729            let ecosystem = state.ecosystem_registry.get("cargo").unwrap();
1730            let content = "[dependencies]\nserde = \"1.0\"\n".to_string();
1731
1732            let parse_result = tokio::runtime::Runtime::new()
1733                .unwrap()
1734                .block_on(ecosystem.parse_manifest(&content, &uri))
1735                .unwrap();
1736
1737            let doc_state = DocumentState::new_from_parse_result(
1738                EcosystemId::Cargo,
1739                content.clone(),
1740                parse_result,
1741            );
1742
1743            assert_eq!(doc_state.ecosystem_id(), "cargo");
1744            assert_eq!(doc_state.content, content);
1745            assert!(doc_state.parse_result.is_some());
1746        }
1747
1748        #[test]
1749        fn test_document_state_new_without_parse_result() {
1750            let content = "[dependencies]\nserde = \"1.0\"\n".to_string();
1751            let doc_state = DocumentState::new_without_parse_result(EcosystemId::Cargo, content);
1752
1753            assert_eq!(doc_state.ecosystem_id(), "cargo");
1754            assert_eq!(doc_state.ecosystem, EcosystemId::Cargo);
1755            assert!(doc_state.parse_result.is_none());
1756        }
1757
1758        #[test]
1759        fn test_document_state_update_resolved_versions() {
1760            let mut state =
1761                DocumentState::new_without_parse_result(EcosystemId::Cargo, "test".into());
1762
1763            let mut resolved = HashMap::new();
1764            resolved.insert("serde".into(), "1.0.195".into());
1765
1766            state.update_resolved_versions(resolved);
1767            assert_eq!(state.resolved_versions.len(), 1);
1768            assert_eq!(
1769                state.resolved_versions.get("serde"),
1770                Some(&"1.0.195".into())
1771            );
1772        }
1773
1774        #[test]
1775        fn test_document_state_update_cached_versions() {
1776            let mut state =
1777                DocumentState::new_without_parse_result(EcosystemId::Cargo, "test".into());
1778
1779            let mut cached = HashMap::new();
1780            cached.insert("serde".into(), PackageVersions::latest_only("1.0.210"));
1781
1782            state.update_cached_versions(cached);
1783            assert_eq!(state.cached_versions.len(), 1);
1784        }
1785
1786        #[test]
1787        fn test_document_state_parse_result_accessor() {
1788            let state = DocumentState::new_without_parse_result(EcosystemId::Cargo, "test".into());
1789            assert!(state.parse_result().is_none());
1790        }
1791
1792        #[test]
1793        fn test_document_state_clone() {
1794            let state =
1795                DocumentState::new_without_parse_result(EcosystemId::Cargo, "test content".into());
1796            let cloned = state.clone();
1797
1798            assert_eq!(cloned.ecosystem, state.ecosystem);
1799            assert_eq!(cloned.content, state.content);
1800            assert!(cloned.parse_result.is_none());
1801        }
1802
1803        /// `parse_result` is stored as `Arc<dyn ParseResult>` (#319), so — unlike the
1804        /// old `Box`-backed field, which `Clone` had to silently drop — a clone now
1805        /// carries a cheap `Arc` clone of the *same* parse result rather than losing it.
1806        #[test]
1807        fn test_document_state_clone_preserves_parse_result() {
1808            let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
1809            let ecosystem = ServerState::new().ecosystem_registry.get("cargo").unwrap();
1810            let content = "[dependencies]\nserde = \"1.0\"\n".to_string();
1811            let parse_result = tokio::runtime::Runtime::new()
1812                .unwrap()
1813                .block_on(ecosystem.parse_manifest(&content, &uri))
1814                .unwrap();
1815
1816            let state =
1817                DocumentState::new_from_parse_result(EcosystemId::Cargo, content, parse_result);
1818            let cloned = state.clone();
1819
1820            assert!(
1821                cloned.parse_result_arc().is_some(),
1822                "clone must still carry the parse result"
1823            );
1824            assert!(std::sync::Arc::ptr_eq(
1825                &state.parse_result_arc().unwrap(),
1826                &cloned.parse_result_arc().unwrap()
1827            ));
1828        }
1829
1830        #[test]
1831        fn test_document_state_debug() {
1832            let state = DocumentState::new_without_parse_result(EcosystemId::Cargo, "test".into());
1833            let debug_str = format!("{state:?}");
1834            assert!(debug_str.contains("DocumentState"));
1835        }
1836    }
1837
1838    // =========================================================================
1839    // npm ecosystem tests
1840    // =========================================================================
1841
1842    #[cfg(feature = "npm")]
1843    mod npm_tests {
1844        use super::*;
1845
1846        #[test]
1847        fn test_document_state_new_without_parse_result() {
1848            let content = r#"{"dependencies": {"express": "^4.18.0"}}"#.to_string();
1849            let doc_state = DocumentState::new_without_parse_result(EcosystemId::Npm, content);
1850
1851            assert_eq!(doc_state.ecosystem_id(), "npm");
1852            assert_eq!(doc_state.ecosystem, EcosystemId::Npm);
1853            assert!(doc_state.parse_result.is_none());
1854        }
1855    }
1856
1857    // =========================================================================
1858    // Deno ecosystem tests
1859    // =========================================================================
1860
1861    #[cfg(feature = "deno")]
1862    mod deno_tests {
1863        use super::*;
1864
1865        #[test]
1866        fn test_document_state_new_without_parse_result() {
1867            let content = r#"{"imports": {"@std/fs": "jsr:@std/fs@^1.0"}}"#.to_string();
1868            let doc_state = DocumentState::new_without_parse_result(EcosystemId::Deno, content);
1869
1870            assert_eq!(doc_state.ecosystem_id(), "deno");
1871            assert_eq!(doc_state.ecosystem, EcosystemId::Deno);
1872            assert!(doc_state.parse_result.is_none());
1873        }
1874    }
1875
1876    // =========================================================================
1877    // PyPI ecosystem tests
1878    // =========================================================================
1879
1880    #[cfg(feature = "pypi")]
1881    mod pypi_tests {
1882        use super::*;
1883
1884        #[test]
1885        fn test_document_state_new_without_parse_result() {
1886            let content = "[project]\ndependencies = [\"requests>=2.0.0\"]\n".to_string();
1887            let doc_state = DocumentState::new_without_parse_result(EcosystemId::Pypi, content);
1888
1889            assert_eq!(doc_state.ecosystem_id(), "pypi");
1890            assert_eq!(doc_state.ecosystem, EcosystemId::Pypi);
1891            assert!(doc_state.parse_result.is_none());
1892        }
1893    }
1894
1895    // =========================================================================
1896    // Go ecosystem tests
1897    // =========================================================================
1898
1899    #[cfg(feature = "go")]
1900    mod go_tests {
1901        use super::*;
1902
1903        #[test]
1904        fn test_document_state_new_without_parse_result() {
1905            let content =
1906                "module example.com/myapp\n\ngo 1.21\n\nrequire github.com/gin-gonic/gin v1.9.1\n"
1907                    .to_string();
1908            let doc_state = DocumentState::new_without_parse_result(EcosystemId::Go, content);
1909
1910            assert_eq!(doc_state.ecosystem_id(), "go");
1911            assert_eq!(doc_state.ecosystem, EcosystemId::Go);
1912            assert!(doc_state.parse_result.is_none());
1913        }
1914
1915        #[test]
1916        fn test_document_state_new_from_parse_result() {
1917            let state = ServerState::new();
1918            let uri = deps_core::test_util::test_uri("/test/go.mod");
1919            let ecosystem = state.ecosystem_registry.get("go").unwrap();
1920            let content =
1921                "module example.com/myapp\n\ngo 1.21\n\nrequire github.com/gin-gonic/gin v1.9.1\n"
1922                    .to_string();
1923
1924            let parse_result = tokio::runtime::Runtime::new()
1925                .unwrap()
1926                .block_on(ecosystem.parse_manifest(&content, &uri))
1927                .unwrap();
1928
1929            let doc_state = DocumentState::new_from_parse_result(
1930                EcosystemId::Go,
1931                content.clone(),
1932                parse_result,
1933            );
1934
1935            assert_eq!(doc_state.ecosystem_id(), "go");
1936            assert!(doc_state.parse_result.is_some());
1937        }
1938    }
1939}