1use std::any::Any;
21use std::collections::HashSet;
22use std::sync::Arc;
23use tower_lsp_server::ls_types::{CompletionItem, Hover, HoverContents, Position, Range, Uri};
24
25use deps_core::{
26 Ecosystem, ParseResult as ParseResultTrait, Registry, Result, completion::Completions,
27 lsp_helpers::EcosystemFormatter, parser::DependencySource,
28};
29
30use crate::config::NuGetParseContext;
31use crate::formatter::NuGetFormatter;
32use crate::lockfile::NuGetLockParser;
33use crate::registry::NuGetRegistry;
34use crate::types::NuGetParseResult;
35
36const HOVER_UNLISTED_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
41
42pub struct NuGetEcosystem {
47 registry: Arc<NuGetRegistry>,
48 formatter: NuGetFormatter,
49 lockfile_provider: Arc<NuGetLockParser>,
50 context: NuGetParseContext,
51}
52
53impl NuGetEcosystem {
54 pub fn new(cache: Arc<deps_core::HttpCache>) -> Self {
55 Self::with_context(
56 Arc::new(NuGetRegistry::new(cache)),
57 NuGetParseContext::default(),
58 )
59 }
60
61 #[must_use]
69 pub fn with_context(registry: Arc<NuGetRegistry>, context: NuGetParseContext) -> Self {
70 Self {
71 registry,
72 formatter: NuGetFormatter,
73 lockfile_provider: Arc::new(NuGetLockParser),
74 context,
75 }
76 }
77
78 async fn complete_package_names(&self, prefix: &str, range: Range) -> Vec<CompletionItem> {
85 deps_core::completion::complete_package_names_generic(
86 self.registry.as_ref(),
87 prefix,
88 20,
89 range,
90 )
91 .await
92 }
93
94 async fn complete_versions(
110 &self,
111 parse_result: &dyn ParseResultTrait,
112 position: Position,
113 prefix: &str,
114 freshness: deps_core::FreshnessSettings,
115 ) -> Vec<CompletionItem> {
116 deps_core::completion::complete_versions_at_position(
117 self.registry.as_ref(),
118 &self.formatter,
119 parse_result,
120 position,
121 prefix,
122 &[],
123 freshness,
124 )
125 .await
126 }
127
128 #[cfg(test)]
131 fn with_registry(registry: NuGetRegistry) -> Self {
132 Self::with_context(Arc::new(registry), NuGetParseContext::default())
133 }
134
135 fn parse_by_filename(content: &str, uri: &Uri) -> Result<NuGetParseResult> {
140 let path = uri.path().as_str();
141 let filename = path.rsplit('/').next().unwrap_or(path);
142
143 match filename.to_lowercase().as_str() {
144 "directory.packages.props" => {
145 crate::parser::parse_directory_packages_props(content, uri)
146 }
147 "packages.config" => crate::parser::parse_packages_config(content, uri),
148 _ => crate::parser::parse_project_file(content, uri),
149 }
150 }
151}
152
153impl deps_core::ecosystem::private::Sealed for NuGetEcosystem {}
154
155impl Ecosystem for NuGetEcosystem {
156 fn id(&self) -> &'static str {
157 "nuget"
158 }
159
160 fn display_name(&self) -> &'static str {
161 "NuGet (.NET)"
162 }
163
164 fn manifest_filenames(&self) -> &[&'static str] {
165 &["Directory.Packages.props", "packages.config"]
166 }
167
168 fn manifest_extensions(&self) -> &[&'static str] {
169 &[".csproj", ".fsproj", ".vbproj"]
170 }
171
172 fn lockfile_filenames(&self) -> &[&'static str] {
173 &["packages.lock.json", "packages.*.lock.json"]
179 }
180
181 fn parse_manifest<'a>(
182 &'a self,
183 content: &'a str,
184 uri: &'a Uri,
185 ) -> deps_core::ecosystem::BoxFuture<'a, Result<Box<dyn ParseResultTrait>>> {
186 Box::pin(async move {
187 let mut result = Self::parse_by_filename(content, uri)?;
188
189 let config = uri
195 .to_file_path()
196 .and_then(|path| path.parent().map(std::path::Path::to_path_buf))
197 .map(|dir| {
198 crate::config::resolve_with_context(
199 &dir,
200 &self.context.config_cache,
201 &self.context.policy,
202 self.context.user_profile_config.as_deref(),
203 &self.context.user_profile_sources,
204 )
205 })
206 .unwrap_or_default();
207
208 for dep in &mut result.dependencies {
209 dep.source = config.resolve_source_for(&dep.name);
210 }
211 result.resolved_chains = config.resolved_chains();
212 for chain in &result.resolved_chains {
213 NuGetRegistry::register_chain(&self.registry, chain, &self.context.policy);
214 }
215
216 Ok(Box::new(result) as Box<dyn ParseResultTrait>)
217 })
218 }
219
220 fn registry(&self) -> Arc<dyn Registry> {
221 self.registry.clone() as Arc<dyn Registry>
222 }
223
224 fn lockfile_provider(&self) -> Option<Arc<dyn deps_core::lockfile::LockFileProvider>> {
225 Some(self.lockfile_provider.clone() as Arc<dyn deps_core::lockfile::LockFileProvider>)
226 }
227
228 fn formatter(&self) -> &dyn EcosystemFormatter {
229 &self.formatter
230 }
231
232 fn generate_completions<'a>(
233 &'a self,
234 parse_result: &'a dyn ParseResultTrait,
235 position: Position,
236 content: &'a str,
237 freshness: deps_core::FreshnessSettings,
238 ) -> deps_core::ecosystem::BoxFuture<'a, Completions> {
239 Box::pin(async move {
240 use deps_core::completion::{CompletionContext, detect_completion_context};
241
242 match detect_completion_context(parse_result, position, content) {
243 CompletionContext::PackageName { prefix, range } => {
244 self.complete_package_names(&prefix, range).await
245 }
246 CompletionContext::Version { prefix, .. } => {
247 self.complete_versions(parse_result, position, &prefix, freshness)
248 .await
249 }
250 CompletionContext::Feature { .. } | CompletionContext::None => vec![],
251 }
252 .into()
253 })
254 }
255
256 fn generate_hover<'a>(
286 &'a self,
287 parse_result: &'a dyn ParseResultTrait,
288 position: Position,
289 versions: deps_core::VersionData<'a>,
290 freshness: deps_core::FreshnessSettings,
291 ) -> deps_core::ecosystem::BoxFuture<'a, Option<Hover>> {
292 Box::pin(async move {
293 let base_hover = deps_core::lsp_generate_hover(
294 parse_result,
295 position,
296 versions,
297 self.registry.as_ref(),
298 self.formatter(),
299 freshness,
300 deps_core::PublishTime::now(),
301 );
302
303 let dep = parse_result.dependencies().into_iter().find(|d| {
307 deps_core::position_in_range(position, d.name_range())
308 || d.version_range()
309 .is_some_and(|r| deps_core::position_in_range(position, r))
310 });
311 let Some(dep) = dep else {
312 return base_hover.await;
313 };
314
315 let unlisted_client: Option<Arc<NuGetRegistry>> = match dep.source() {
323 DependencySource::Registry => Some(Arc::clone(&self.registry)),
324 DependencySource::AlternateRegistry { index, .. } => {
325 self.registry.alternate_client(&index)
326 }
327 _ => None,
328 };
329 let Some(unlisted_client) = unlisted_client else {
330 return base_hover.await;
331 };
332
333 let unlisted_fetch = tokio::time::timeout(
334 HOVER_UNLISTED_TIMEOUT,
335 unlisted_client.unlisted_versions_for_hover(dep.name().as_str()),
336 );
337
338 let (hover, unlisted_result) = tokio::join!(base_hover, unlisted_fetch);
339 let mut hover = hover?;
340
341 let unlisted = match unlisted_result {
342 Ok(Ok(unlisted)) => unlisted,
343 Ok(Err(error)) => {
344 tracing::debug!(package = %dep.name(), %error, "hover unlisted-versions fetch failed");
345 return Some(hover);
346 }
347 Err(_) => {
348 tracing::warn!(
349 package = %dep.name(),
350 timeout_secs = HOVER_UNLISTED_TIMEOUT.as_secs(),
351 "hover unlisted-versions fetch timed out"
352 );
353 return Some(hover);
354 }
355 };
356 if unlisted.is_empty() {
357 return Some(hover);
358 }
359
360 if let HoverContents::Markup(content) = &mut hover.contents {
361 content.value = annotate_unlisted_versions(&content.value, &unlisted);
362 }
363
364 Some(hover)
365 })
366 }
367
368 fn as_any(&self) -> &dyn Any {
369 self
370 }
371}
372
373fn annotate_unlisted_versions(markdown: &str, unlisted: &HashSet<String>) -> String {
380 let mut out = String::with_capacity(markdown.len() + unlisted.len() * 14);
381 for line in markdown.split_inclusive('\n') {
382 let body = line.strip_suffix('\n').unwrap_or(line);
383 let matched = body.strip_prefix("- `").and_then(|rest| {
384 let tick = rest.find('`')?;
385 Some((&rest[..tick], &rest[tick + 1..]))
386 });
387 match matched {
388 Some((version, rest)) if unlisted.contains(version) => {
389 out.push_str("- `");
390 out.push_str(version);
391 out.push_str("` *(unlisted)*");
392 out.push_str(rest);
393 }
394 _ => out.push_str(body),
395 }
396 if line.ends_with('\n') {
397 out.push('\n');
398 }
399 }
400 out
401}
402
403#[cfg(test)]
404mod tests {
405 use super::*;
406 use crate::types::NuGetDependency;
407
408 #[test]
409 fn test_ecosystem_id() {
410 let cache = Arc::new(deps_core::HttpCache::new());
411 let eco = NuGetEcosystem::new(cache);
412 assert_eq!(eco.id(), "nuget");
413 }
414
415 #[test]
416 fn test_ecosystem_display_name() {
417 let cache = Arc::new(deps_core::HttpCache::new());
418 let eco = NuGetEcosystem::new(cache);
419 assert_eq!(eco.display_name(), "NuGet (.NET)");
420 }
421
422 #[test]
423 fn test_manifest_filenames_and_extensions() {
424 let cache = Arc::new(deps_core::HttpCache::new());
425 let eco = NuGetEcosystem::new(cache);
426 assert_eq!(
427 eco.manifest_filenames(),
428 &["Directory.Packages.props", "packages.config"]
429 );
430 assert_eq!(
431 eco.manifest_extensions(),
432 &[".csproj", ".fsproj", ".vbproj"]
433 );
434 }
435
436 #[test]
437 fn test_lockfile_filenames() {
438 let cache = Arc::new(deps_core::HttpCache::new());
439 let eco = NuGetEcosystem::new(cache);
440 assert_eq!(
441 eco.lockfile_filenames(),
442 &["packages.lock.json", "packages.*.lock.json"]
443 );
444 }
445
446 #[test]
447 fn test_lockfile_provider_some() {
448 let cache = Arc::new(deps_core::HttpCache::new());
449 let eco = NuGetEcosystem::new(cache);
450 assert!(eco.lockfile_provider().is_some());
451 }
452
453 #[test]
454 fn test_as_any() {
455 let cache = Arc::new(deps_core::HttpCache::new());
456 let eco = NuGetEcosystem::new(cache);
457 assert!(eco.as_any().is::<NuGetEcosystem>());
458 }
459
460 #[tokio::test]
461 async fn test_package_name_completion_context_has_real_range() {
462 let cache = Arc::new(deps_core::HttpCache::new());
465 let eco = NuGetEcosystem::new(cache);
466 let content = "<Project>\n <ItemGroup>\n <PackageReference Include=\"Foo\" Version=\"1.0.0\" />\n </ItemGroup>\n</Project>";
467 let uri = deps_core::test_util::test_uri("/test/App.csproj");
468
469 let parse_result = eco.parse_manifest(content, &uri).await.unwrap();
470 let position = Position::new(2, 33); let context = deps_core::completion::detect_completion_context(
473 parse_result.as_ref(),
474 position,
475 content,
476 );
477
478 match context {
479 deps_core::completion::CompletionContext::PackageName { prefix, range } => {
480 assert_eq!(prefix, "Fo");
481 assert_ne!(range, Range::default());
482 assert_eq!(
483 range,
484 Range::new(Position::new(2, 31), Position::new(2, 34))
485 );
486 }
487 other => panic!("Expected PackageName context, got {other:?}"),
488 }
489 }
490
491 #[tokio::test]
492 async fn test_parse_manifest_csproj() {
493 let cache = Arc::new(deps_core::HttpCache::new());
494 let eco = NuGetEcosystem::new(cache);
495 let uri = deps_core::test_util::test_uri("/test/App.csproj");
496 let content = r#"<Project><ItemGroup><PackageReference Include="Newtonsoft.Json" Version="13.0.3" /></ItemGroup></Project>"#;
497
498 let result = eco.parse_manifest(content, &uri).await.unwrap();
499 assert_eq!(result.dependencies().len(), 1);
500 }
501
502 #[tokio::test]
503 async fn test_parse_manifest_fsproj_routes_as_project_file() {
504 let cache = Arc::new(deps_core::HttpCache::new());
505 let eco = NuGetEcosystem::new(cache);
506 let uri = deps_core::test_util::test_uri("/test/App.fsproj");
507 let content = r#"<Project><ItemGroup><PackageReference Include="Newtonsoft.Json" Version="13.0.3" /></ItemGroup></Project>"#;
508
509 let result = eco.parse_manifest(content, &uri).await.unwrap();
510 assert_eq!(result.dependencies().len(), 1);
511 }
512
513 #[tokio::test]
514 async fn test_parse_manifest_directory_packages_props() {
515 let cache = Arc::new(deps_core::HttpCache::new());
516 let eco = NuGetEcosystem::new(cache);
517 let uri = deps_core::test_util::test_uri("/test/Directory.Packages.props");
518 let content = r#"<Project><ItemGroup><PackageVersion Include="Serilog" Version="3.1.1" /></ItemGroup></Project>"#;
519
520 let result = eco.parse_manifest(content, &uri).await.unwrap();
521 assert_eq!(result.dependencies().len(), 1);
522 }
523
524 #[tokio::test]
525 async fn test_parse_manifest_packages_config() {
526 let cache = Arc::new(deps_core::HttpCache::new());
527 let eco = NuGetEcosystem::new(cache);
528 let uri = deps_core::test_util::test_uri("/test/packages.config");
529 let content = r#"<packages><package id="Newtonsoft.Json" version="13.0.3" targetFramework="net48" /></packages>"#;
530
531 let result = eco.parse_manifest(content, &uri).await.unwrap();
532 assert_eq!(result.dependencies().len(), 1);
533 }
534
535 #[tokio::test]
536 async fn test_parse_manifest_invalid_xml_errors() {
537 let cache = Arc::new(deps_core::HttpCache::new());
538 let eco = NuGetEcosystem::new(cache);
539 let uri = deps_core::test_util::test_uri("/test/App.csproj");
540 let content = r#"<Project attr="unclosed></Project>"#;
541
542 let result = eco.parse_manifest(content, &uri).await;
543 assert!(result.is_err());
544 }
545
546 #[tokio::test]
547 async fn test_complete_package_names_min_prefix() {
548 let cache = Arc::new(deps_core::HttpCache::new());
549 let eco = NuGetEcosystem::new(cache);
550 assert!(
551 eco.complete_package_names("", Range::default())
552 .await
553 .is_empty()
554 );
555 }
556
557 fn dep_with_source(name: &str, source: DependencySource, line: u32) -> NuGetDependency {
562 NuGetDependency {
563 name: name.into(),
564 name_range: Range::new(Position::new(line, 0), Position::new(line, 0)),
565 version_requirement: Some("1.0.0".into()),
566 version_range: Some(Range::new(Position::new(line, 0), Position::new(line, 10))),
567 source,
568 }
569 }
570
571 #[tokio::test]
572 async fn test_complete_versions_position_based_lookup_finds_correct_dependency() {
573 let mut server = mockito::Server::new_async().await;
574 let base = server.url();
575 let _index_mock = server
576 .mock("GET", "/index.json")
577 .with_status(200)
578 .with_body(nuget_service_index_body(&base))
579 .create_async()
580 .await;
581 let _flat_mock = server
582 .mock("GET", "/flatcontainer/targetpkg/index.json")
583 .with_status(200)
584 .with_body(r#"{"versions": ["1.0.0", "1.2.0"]}"#)
585 .create_async()
586 .await;
587
588 let registry = NuGetRegistry::with_service_index_url(
589 Arc::new(deps_core::HttpCache::new()),
590 format!("{base}/index.json"),
591 );
592 let eco = NuGetEcosystem::with_registry(registry);
593
594 let other = dep_with_source("otherpkg", DependencySource::Registry, 0);
598 let target = dep_with_source("targetpkg", DependencySource::Registry, 1);
599 let target_position = target.version_range.unwrap().start;
600 let parse_result = NuGetParseResult {
601 dependencies: vec![other, target],
602 uri: deps_core::test_util::test_uri("/test/App.csproj"),
603 resolved_chains: Vec::new(),
604 };
605
606 let results = eco
607 .complete_versions(
608 &parse_result,
609 target_position,
610 "1",
611 deps_core::FreshnessSettings::default(),
612 )
613 .await;
614 assert!(
615 !results.is_empty(),
616 "position-based lookup must resolve the dependency at the cursor position"
617 );
618 }
619
620 #[tokio::test]
621 async fn test_complete_versions_no_dependency_at_position_offers_nothing() {
622 let cache = Arc::new(deps_core::HttpCache::new());
623 let eco = NuGetEcosystem::new(cache);
624
625 let dep = dep_with_source("somepkg", DependencySource::Registry, 0);
626 let parse_result = NuGetParseResult {
627 dependencies: vec![dep],
628 uri: deps_core::test_util::test_uri("/test/App.csproj"),
629 resolved_chains: Vec::new(),
630 };
631
632 let results = eco
633 .complete_versions(
634 &parse_result,
635 Position::new(99, 0),
636 "1",
637 deps_core::FreshnessSettings::default(),
638 )
639 .await;
640 assert!(results.is_empty());
641 }
642
643 #[tokio::test]
647 async fn test_complete_versions_gate_blocks_unresolvable_source() {
648 let mut server = mockito::Server::new_async().await;
649 let base = server.url();
650 let _index_mock = server
651 .mock("GET", "/index.json")
652 .expect(0)
653 .create_async()
654 .await;
655
656 let registry = NuGetRegistry::with_service_index_url(
657 Arc::new(deps_core::HttpCache::new()),
658 format!("{base}/index.json"),
659 );
660 let eco = NuGetEcosystem::with_registry(registry);
661
662 let dep = dep_with_source(
663 "privatepkg",
664 DependencySource::CustomRegistry {
665 url: "https://feed.mycorp.example/v3/index.json".to_string(),
666 },
667 0,
668 );
669 let position = dep.version_range.unwrap().start;
670 let parse_result = NuGetParseResult {
671 dependencies: vec![dep],
672 uri: deps_core::test_util::test_uri("/test/App.csproj"),
673 resolved_chains: Vec::new(),
674 };
675
676 let results = eco
677 .complete_versions(
678 &parse_result,
679 position,
680 "1",
681 deps_core::FreshnessSettings::default(),
682 )
683 .await;
684 assert!(
685 results.is_empty(),
686 "an unresolvable CustomRegistry source must offer no completions"
687 );
688 _index_mock.assert_async().await;
689 }
690
691 #[tokio::test]
694 async fn test_complete_versions_unregistered_alternate_offers_nothing() {
695 let mut server = mockito::Server::new_async().await;
696 let base = server.url();
697 let _index_mock = server
698 .mock("GET", "/index.json")
699 .expect(0)
700 .create_async()
701 .await;
702
703 let registry = NuGetRegistry::with_service_index_url(
704 Arc::new(deps_core::HttpCache::new()),
705 format!("{base}/index.json"),
706 );
707 let eco = NuGetEcosystem::with_registry(registry);
708
709 let dep = dep_with_source(
710 "internal.auth",
711 DependencySource::AlternateRegistry {
712 index: "nuget-chain:never-registered".to_string(),
713 mirrors_crates_io: false,
714 },
715 0,
716 );
717 let position = dep.version_range.unwrap().start;
718 let parse_result = NuGetParseResult {
719 dependencies: vec![dep],
720 uri: deps_core::test_util::test_uri("/test/App.csproj"),
721 resolved_chains: Vec::new(),
722 };
723
724 let results = eco
725 .complete_versions(
726 &parse_result,
727 position,
728 "1.",
729 deps_core::FreshnessSettings::default(),
730 )
731 .await;
732 assert!(
733 results.is_empty(),
734 "unregistered alternate index must offer no completions"
735 );
736 _index_mock.assert_async().await;
737 }
738
739 #[tokio::test]
742 async fn test_complete_versions_routes_to_registered_alternate_client() {
743 let mut alt_server = mockito::Server::new_async().await;
744 let alt_base = alt_server.url();
745 let _alt_index_mock = alt_server
746 .mock("GET", "/index.json")
747 .with_status(200)
748 .with_body(nuget_service_index_body(&alt_base))
749 .create_async()
750 .await;
751 let _alt_flat_mock = alt_server
752 .mock("GET", "/flatcontainer/internal.auth/index.json")
753 .with_status(200)
754 .with_body(r#"{"versions": ["1.2.3", "1.3.0"]}"#)
755 .create_async()
756 .await;
757
758 let mut public_server = mockito::Server::new_async().await;
759 let public_base = public_server.url();
760 let _public_index_mock = public_server
761 .mock("GET", "/index.json")
762 .expect(0)
763 .create_async()
764 .await;
765
766 let policy = Arc::new(deps_core::net_policy::RegistryAccessPolicy::new(
767 deps_core::net_policy::WorkspaceRegistryAccess::All,
768 ));
769 let root = Arc::new(NuGetRegistry::with_service_index_url(
770 Arc::new(deps_core::HttpCache::new()),
771 format!("{public_base}/index.json"),
772 ));
773
774 let feed_url =
775 crate::config::NuGetFeedUrl::new(&format!("{alt_base}/index.json"), &policy).unwrap();
776 let chain = crate::config::NuGetSourceChain {
777 key: "nuget-chain:test-alt".to_string(),
778 hops: vec![crate::config::ResolvedHop {
779 url: feed_url,
780 slot: None,
781 auth: None,
782 }],
783 implicit_public_fallback: false,
784 };
785 NuGetRegistry::register_chain(&root, &chain, &policy);
786
787 let context = crate::config::NuGetParseContext {
788 policy: Arc::clone(&policy),
789 config_cache: Arc::new(crate::config::NuGetConfigCache::new()),
790 user_profile_config: None,
791 user_profile_sources: Arc::new(std::sync::atomic::AtomicBool::new(false)),
792 };
793 let eco = NuGetEcosystem::with_context(root, context);
794
795 let dep = dep_with_source(
796 "internal.auth",
797 DependencySource::AlternateRegistry {
798 index: "nuget-chain:test-alt".to_string(),
799 mirrors_crates_io: false,
800 },
801 0,
802 );
803 let position = dep.version_range.unwrap().start;
804 let parse_result = NuGetParseResult {
805 dependencies: vec![dep],
806 uri: deps_core::test_util::test_uri("/test/App.csproj"),
807 resolved_chains: Vec::new(),
808 };
809
810 let results = eco
811 .complete_versions(
812 &parse_result,
813 position,
814 "1.",
815 deps_core::FreshnessSettings::default(),
816 )
817 .await;
818 assert!(
819 !results.is_empty(),
820 "a registered alternate index must route completions through its own client"
821 );
822 _public_index_mock.assert_async().await;
823 }
824
825 #[tokio::test]
829 async fn test_complete_versions_same_name_different_sources_routes_by_position() {
830 let mut server = mockito::Server::new_async().await;
831 let base = server.url();
832 let _index_mock = server
833 .mock("GET", "/index.json")
834 .expect(0)
835 .create_async()
836 .await;
837
838 let registry = NuGetRegistry::with_service_index_url(
839 Arc::new(deps_core::HttpCache::new()),
840 format!("{base}/index.json"),
841 );
842 let eco = NuGetEcosystem::with_registry(registry);
843
844 let registry_dep = dep_with_source("shared.pkg", DependencySource::Registry, 0);
845 let alternate_dep = dep_with_source(
846 "shared.pkg",
847 DependencySource::AlternateRegistry {
848 index: "nuget-chain:never-registered".to_string(),
849 mirrors_crates_io: false,
850 },
851 1,
852 );
853 let alternate_position = alternate_dep.version_range.unwrap().start;
854 let parse_result = NuGetParseResult {
855 dependencies: vec![registry_dep, alternate_dep],
856 uri: deps_core::test_util::test_uri("/test/App.csproj"),
857 resolved_chains: Vec::new(),
858 };
859
860 let results = eco
865 .complete_versions(
866 &parse_result,
867 alternate_position,
868 "1",
869 deps_core::FreshnessSettings::default(),
870 )
871 .await;
872 assert!(
873 results.is_empty(),
874 "unregistered alternate index must offer no completions, not fall back to the \
875 co-occurring Registry-sourced entry"
876 );
877 _index_mock.assert_async().await;
878 }
879
880 async fn inlay_hint_labels(
884 eco: &NuGetEcosystem,
885 content: &str,
886 uri: &tower_lsp_server::ls_types::Uri,
887 latest: &str,
888 ) -> Vec<String> {
889 use deps_core::lsp_helpers::VersionData;
890 use deps_core::{EcosystemConfig, LoadingState, PackageVersions};
891 use tower_lsp_server::ls_types::InlayHintLabel;
892
893 let parse_result = eco.parse_manifest(content, uri).await.unwrap();
894 let mut cached = std::collections::HashMap::new();
895 cached.insert(
896 "newtonsoft.json".into(),
897 PackageVersions::latest_only(latest),
898 );
899 let resolved = std::collections::HashMap::new();
900
901 let hints = eco
902 .generate_inlay_hints(
903 parse_result.as_ref(),
904 VersionData::new(&cached, &resolved),
905 LoadingState::Idle,
906 &EcosystemConfig::default(),
907 )
908 .await;
909
910 hints
911 .into_iter()
912 .map(|h| match h.label {
913 InlayHintLabel::String(s) => s,
914 InlayHintLabel::LabelParts(_) => unreachable!("NuGet never emits label parts"),
915 })
916 .collect()
917 }
918
919 #[tokio::test]
920 async fn test_inlay_hint_flags_outdated_csproj_package_reference() {
921 let cache = Arc::new(deps_core::HttpCache::new());
922 let eco = NuGetEcosystem::new(cache);
923 let uri = deps_core::test_util::test_uri("/test/App.csproj");
924 let content = r#"<Project><ItemGroup><PackageReference Include="Newtonsoft.Json" Version="13.0.3" /></ItemGroup></Project>"#;
925
926 let labels = inlay_hint_labels(&eco, content, &uri, "13.0.4").await;
927 assert_eq!(labels, vec!["❌ 13.0.4"]);
928 }
929
930 #[tokio::test]
931 async fn test_inlay_hint_marks_up_to_date_csproj_package_reference() {
932 let cache = Arc::new(deps_core::HttpCache::new());
933 let eco = NuGetEcosystem::new(cache);
934 let uri = deps_core::test_util::test_uri("/test/App.csproj");
935 let content = r#"<Project><ItemGroup><PackageReference Include="Newtonsoft.Json" Version="13.0.3" /></ItemGroup></Project>"#;
936
937 let labels = inlay_hint_labels(&eco, content, &uri, "13.0.3").await;
938 assert_eq!(labels, vec!["✅"]);
939 }
940
941 #[tokio::test]
942 async fn test_inlay_hint_flags_outdated_directory_packages_props() {
943 let cache = Arc::new(deps_core::HttpCache::new());
944 let eco = NuGetEcosystem::new(cache);
945 let uri = deps_core::test_util::test_uri("/test/Directory.Packages.props");
946 let content = r#"<Project><ItemGroup><PackageVersion Include="Newtonsoft.Json" Version="13.0.3" /></ItemGroup></Project>"#;
947
948 let labels = inlay_hint_labels(&eco, content, &uri, "13.0.4").await;
949 assert_eq!(labels, vec!["❌ 13.0.4"]);
950 }
951
952 #[tokio::test]
953 async fn test_inlay_hint_packages_config_exact_pin_unaffected() {
954 let cache = Arc::new(deps_core::HttpCache::new());
955 let eco = NuGetEcosystem::new(cache);
956 let uri = deps_core::test_util::test_uri("/test/packages.config");
957 let content = r#"<packages><package id="Newtonsoft.Json" version="13.0.3" targetFramework="net48" /></packages>"#;
958
959 assert_eq!(
960 inlay_hint_labels(&eco, content, &uri, "13.0.4").await,
961 vec!["❌ 13.0.4"]
962 );
963 assert_eq!(
964 inlay_hint_labels(&eco, content, &uri, "13.0.3").await,
965 vec!["✅"]
966 );
967 }
968
969 async fn diagnostic_messages(
975 eco: &NuGetEcosystem,
976 content: &str,
977 uri: &tower_lsp_server::ls_types::Uri,
978 latest: &str,
979 ) -> Vec<String> {
980 use deps_core::PackageVersions;
981 use deps_core::lsp_helpers::VersionData;
982
983 let parse_result = eco.parse_manifest(content, uri).await.unwrap();
984 let mut cached = std::collections::HashMap::new();
985 cached.insert(
986 "newtonsoft.json".into(),
987 PackageVersions::latest_only(latest),
988 );
989 let resolved = std::collections::HashMap::new();
990
991 eco.generate_diagnostics(
992 parse_result.as_ref(),
993 VersionData::new(&cached, &resolved),
994 uri,
995 deps_core::FreshnessSettings::default(),
996 deps_core::DiagnosticSeverities::default(),
997 )
998 .await
999 .into_iter()
1000 .map(|d| d.message)
1001 .collect()
1002 }
1003
1004 #[tokio::test]
1005 async fn test_diagnostics_flag_outdated_csproj_package_reference() {
1006 let cache = Arc::new(deps_core::HttpCache::new());
1007 let eco = NuGetEcosystem::new(cache);
1008 let uri = deps_core::test_util::test_uri("/test/App.csproj");
1009 let content = r#"<Project><ItemGroup><PackageReference Include="Newtonsoft.Json" Version="13.0.3" /></ItemGroup></Project>"#;
1010
1011 let messages = diagnostic_messages(&eco, content, &uri, "13.0.4").await;
1012 assert_eq!(messages, vec!["Newer version available: 13.0.4"]);
1013 }
1014
1015 #[tokio::test]
1016 async fn test_diagnostics_silent_when_up_to_date() {
1017 let cache = Arc::new(deps_core::HttpCache::new());
1018 let eco = NuGetEcosystem::new(cache);
1019 let uri = deps_core::test_util::test_uri("/test/App.csproj");
1020 let content = r#"<Project><ItemGroup><PackageReference Include="Newtonsoft.Json" Version="13.0.3" /></ItemGroup></Project>"#;
1021
1022 assert!(
1023 diagnostic_messages(&eco, content, &uri, "13.0.3")
1024 .await
1025 .is_empty()
1026 );
1027 }
1028
1029 #[test]
1032 fn test_annotate_unlisted_versions_tags_matching_bullet() {
1033 let markdown = "**Recent versions**:\n- `2.0.0` *(latest)*\n- `1.0.0`\n";
1034 let unlisted: HashSet<String> = HashSet::from(["1.0.0".to_string()]);
1035 let out = annotate_unlisted_versions(markdown, &unlisted);
1036 assert!(out.contains("- `1.0.0` *(unlisted)*\n"));
1037 assert!(out.contains("- `2.0.0` *(latest)*\n"));
1038 }
1039
1040 #[test]
1041 fn test_annotate_unlisted_versions_preserves_existing_tags_and_age_suffix() {
1042 let markdown = "- `1.2.1` *(yanked)* — 5 months ago\n";
1043 let unlisted: HashSet<String> = HashSet::from(["1.2.1".to_string()]);
1044 let out = annotate_unlisted_versions(markdown, &unlisted);
1045 assert_eq!(out, "- `1.2.1` *(unlisted)* *(yanked)* — 5 months ago\n");
1046 }
1047
1048 #[test]
1049 fn test_annotate_unlisted_versions_untagged_line_when_no_match() {
1050 let markdown = "- `1.0.0`\n";
1051 let unlisted: HashSet<String> = HashSet::from(["2.0.0".to_string()]);
1052 assert_eq!(annotate_unlisted_versions(markdown, &unlisted), markdown);
1053 }
1054
1055 #[test]
1056 fn test_annotate_unlisted_versions_leaves_non_bullet_lines_untouched() {
1057 let markdown = "**Latest**: `1.0.0`\n\n---\n⌨️ **Press `Cmd+.` to update version**";
1058 let unlisted: HashSet<String> = HashSet::from(["1.0.0".to_string()]);
1059 assert_eq!(annotate_unlisted_versions(markdown, &unlisted), markdown);
1060 }
1061
1062 #[test]
1063 fn test_annotate_unlisted_versions_no_trailing_newline_preserved() {
1064 let markdown = "- `1.0.0`";
1065 let unlisted: HashSet<String> = HashSet::from(["1.0.0".to_string()]);
1066 assert_eq!(
1067 annotate_unlisted_versions(markdown, &unlisted),
1068 "- `1.0.0` *(unlisted)*"
1069 );
1070 }
1071
1072 fn nuget_service_index_body(base: &str) -> String {
1075 format!(
1076 r#"{{"version": "3.0.0", "resources": [
1077 {{"@id": "{base}/flatcontainer", "@type": "PackageBaseAddress/3.0.0"}},
1078 {{"@id": "{base}/query", "@type": "SearchQueryService/3.5.0"}},
1079 {{"@id": "{base}/registrations", "@type": "RegistrationsBaseUrl/3.6.0"}}
1080 ]}}"#
1081 )
1082 }
1083
1084 #[tokio::test]
1085 async fn test_generate_hover_marks_unlisted_recent_version() {
1086 let mut server = mockito::Server::new_async().await;
1087 let base = server.url();
1088
1089 let _service_index_mock = server
1090 .mock("GET", "/index.json")
1091 .with_status(200)
1092 .with_body(nuget_service_index_body(&base))
1093 .create_async()
1094 .await;
1095 let _flat_mock = server
1096 .mock("GET", "/flatcontainer/newtonsoft.json/index.json")
1097 .with_status(200)
1098 .with_body(r#"{"versions": ["12.0.1", "13.0.3"]}"#)
1099 .create_async()
1100 .await;
1101 let _reg_mock = server
1102 .mock("GET", "/registrations/newtonsoft.json/index.json")
1103 .with_status(200)
1104 .with_body(
1105 r#"{"count": 1, "items": [{"@id": "x", "count": 2, "items": [
1106 {"catalogEntry": {"version": "12.0.1", "listed": true}},
1107 {"catalogEntry": {"version": "13.0.3", "listed": false}}
1108 ]}]}"#,
1109 )
1110 .create_async()
1111 .await;
1112
1113 let registry = NuGetRegistry::with_service_index_url(
1114 Arc::new(deps_core::HttpCache::new()),
1115 format!("{base}/index.json"),
1116 );
1117 let eco = NuGetEcosystem::with_registry(registry);
1118
1119 let uri = deps_core::test_util::test_uri("/test/App.csproj");
1120 let content = r#"<Project><ItemGroup><PackageReference Include="Newtonsoft.Json" Version="13.0.3" /></ItemGroup></Project>"#;
1121 let parse_result = eco.parse_manifest(content, &uri).await.unwrap();
1122
1123 let cached = std::collections::HashMap::new();
1124 let resolved = std::collections::HashMap::new();
1125 let freshness = deps_core::FreshnessSettings {
1129 enabled: false,
1130 cooldown_secs: deps_core::DEFAULT_COOLDOWN_SECS,
1131 };
1132
1133 let hover = eco
1134 .generate_hover(
1135 parse_result.as_ref(),
1136 Position::new(0, 49), deps_core::VersionData::new(&cached, &resolved),
1138 freshness,
1139 )
1140 .await
1141 .expect("hover for a resolvable in-range dependency must not be None");
1142
1143 let HoverContents::Markup(content) = hover.contents else {
1144 panic!("expected markup hover contents");
1145 };
1146 assert!(
1147 content.value.contains("- `13.0.3` *(unlisted)*"),
1148 "unlisted version must be tagged, got: {}",
1149 content.value
1150 );
1151 assert!(
1152 !content.value.contains("`12.0.1` *(unlisted)*"),
1153 "listed version must not be tagged, got: {}",
1154 content.value
1155 );
1156 }
1157
1158 #[tokio::test]
1159 async fn test_generate_hover_degrades_gracefully_when_registration_fetch_fails() {
1160 let mut server = mockito::Server::new_async().await;
1161 let base = server.url();
1162
1163 let _service_index_mock = server
1164 .mock("GET", "/index.json")
1165 .with_status(200)
1166 .with_body(nuget_service_index_body(&base))
1167 .create_async()
1168 .await;
1169 let _flat_mock = server
1170 .mock("GET", "/flatcontainer/newtonsoft.json/index.json")
1171 .with_status(200)
1172 .with_body(r#"{"versions": ["13.0.3"]}"#)
1173 .create_async()
1174 .await;
1175 let _reg_mock = server
1176 .mock("GET", "/registrations/newtonsoft.json/index.json")
1177 .with_status(500)
1178 .create_async()
1179 .await;
1180
1181 let registry = NuGetRegistry::with_service_index_url(
1182 Arc::new(deps_core::HttpCache::new()),
1183 format!("{base}/index.json"),
1184 );
1185 let eco = NuGetEcosystem::with_registry(registry);
1186
1187 let uri = deps_core::test_util::test_uri("/test/App.csproj");
1188 let content = r#"<Project><ItemGroup><PackageReference Include="Newtonsoft.Json" Version="13.0.3" /></ItemGroup></Project>"#;
1189 let parse_result = eco.parse_manifest(content, &uri).await.unwrap();
1190
1191 let cached = std::collections::HashMap::new();
1192 let resolved = std::collections::HashMap::new();
1193
1194 let hover = eco
1195 .generate_hover(
1196 parse_result.as_ref(),
1197 Position::new(0, 49),
1198 deps_core::VersionData::new(&cached, &resolved),
1199 deps_core::FreshnessSettings {
1200 enabled: false,
1201 cooldown_secs: deps_core::DEFAULT_COOLDOWN_SECS,
1202 },
1203 )
1204 .await
1205 .expect("a registration-hive failure must still degrade to the base hover");
1206
1207 let HoverContents::Markup(content) = hover.contents else {
1208 panic!("expected markup hover contents");
1209 };
1210 assert!(content.value.contains("`13.0.3`"));
1211 assert!(!content.value.contains("*(unlisted)*"));
1212 }
1213
1214 #[tokio::test]
1219 async fn test_generate_hover_skips_unlisted_fetch_when_no_dependency_at_position() {
1220 let mut server = mockito::Server::new_async().await;
1221 let base = server.url();
1222
1223 let _service_index_mock = server
1224 .mock("GET", "/index.json")
1225 .with_status(200)
1226 .with_body(nuget_service_index_body(&base))
1227 .expect(0)
1228 .create_async()
1229 .await;
1230
1231 let registry = NuGetRegistry::with_service_index_url(
1232 Arc::new(deps_core::HttpCache::new()),
1233 format!("{base}/index.json"),
1234 );
1235 let eco = NuGetEcosystem::with_registry(registry);
1236
1237 let uri = deps_core::test_util::test_uri("/test/App.csproj");
1238 let content = r#"<Project><ItemGroup><PackageReference Include="Newtonsoft.Json" Version="13.0.3" /></ItemGroup></Project>"#;
1239 let parse_result = eco.parse_manifest(content, &uri).await.unwrap();
1240
1241 let cached = std::collections::HashMap::new();
1242 let resolved = std::collections::HashMap::new();
1243
1244 let hover = eco
1245 .generate_hover(
1246 parse_result.as_ref(),
1247 Position::new(0, 0), deps_core::VersionData::new(&cached, &resolved),
1249 deps_core::FreshnessSettings {
1250 enabled: false,
1251 cooldown_secs: deps_core::DEFAULT_COOLDOWN_SECS,
1252 },
1253 )
1254 .await;
1255
1256 assert!(hover.is_none());
1257 _service_index_mock.assert_async().await;
1259 }
1260
1261 #[tokio::test]
1269 async fn test_private_feed_clear_resolves_zero_requests_to_public_registry() {
1270 let mut server = mockito::Server::new_async().await;
1271 let base = server.url();
1272
1273 let _public_index_mock = server
1274 .mock("GET", "/public/index.json")
1275 .with_status(200)
1276 .expect(0)
1277 .create_async()
1278 .await;
1279 let _corp_index_mock = server
1280 .mock("GET", "/corp/index.json")
1281 .with_status(200)
1282 .with_body(nuget_service_index_body(&format!("{base}/corp")))
1283 .create_async()
1284 .await;
1285 let _corp_flat_mock = server
1286 .mock("GET", "/corp/flatcontainer/mycompany.internal/index.json")
1287 .with_status(200)
1288 .with_body(r#"{"versions": ["1.2.3"]}"#)
1289 .create_async()
1290 .await;
1291
1292 let dir = tempfile::tempdir().unwrap();
1293 std::fs::write(
1294 dir.path().join("NuGet.Config"),
1295 format!(
1296 r#"<configuration><packageSources>
1297 <clear />
1298 <add key="CorpFeed" value="{base}/corp/index.json" />
1299 </packageSources></configuration>"#
1300 ),
1301 )
1302 .unwrap();
1303 let manifest_path = dir.path().join("App.csproj");
1304 let content = r#"<Project><ItemGroup><PackageReference Include="MyCompany.Internal" Version="1.0.0" /></ItemGroup></Project>"#;
1305 std::fs::write(&manifest_path, content).unwrap();
1306 let uri = tower_lsp_server::ls_types::Uri::from_file_path(&manifest_path).unwrap();
1307
1308 let registry = NuGetRegistry::with_service_index_url(
1309 Arc::new(deps_core::HttpCache::new()),
1310 format!("{base}/public/index.json"),
1311 );
1312 let policy = Arc::new(deps_core::net_policy::RegistryAccessPolicy::new(
1313 deps_core::net_policy::WorkspaceRegistryAccess::All,
1314 ));
1315 let context = crate::config::NuGetParseContext {
1316 policy: Arc::clone(&policy),
1317 config_cache: Arc::new(crate::config::NuGetConfigCache::new()),
1318 user_profile_config: None,
1319 user_profile_sources: Arc::new(std::sync::atomic::AtomicBool::new(false)),
1320 };
1321 let eco = NuGetEcosystem::with_context(Arc::new(registry), context);
1322
1323 let parse_result = eco.parse_manifest(content, &uri).await.unwrap();
1324 let dep = parse_result
1325 .dependencies()
1326 .into_iter()
1327 .find(|d| d.name().as_str() == "MyCompany.Internal")
1328 .expect("dependency must be present");
1329 let source = dep.source();
1330 assert!(
1331 matches!(source, DependencySource::AlternateRegistry { .. }),
1332 "expected AlternateRegistry, got {source:?}"
1333 );
1334 let name = dep.name().clone();
1335
1336 let versions = eco
1337 .registry
1338 .as_ref()
1339 .get_versions_from(&name, &source, deps_core::FreshnessSettings::default())
1340 .await
1341 .unwrap();
1342 assert_eq!(versions.len(), 1);
1343
1344 _public_index_mock.assert_async().await;
1345 _corp_index_mock.assert_async().await;
1346 _corp_flat_mock.assert_async().await;
1347 }
1348
1349 #[tokio::test]
1356 async fn test_generate_hover_skips_unlisted_fetch_for_private_feed_dependency() {
1357 let mut server = mockito::Server::new_async().await;
1358 let base = server.url();
1359
1360 let _public_index_mock = server
1361 .mock("GET", "/public/index.json")
1362 .with_status(200)
1363 .expect(0)
1364 .create_async()
1365 .await;
1366 let _corp_index_mock = server
1367 .mock("GET", "/corp/index.json")
1368 .with_status(200)
1369 .with_body(nuget_service_index_body(&format!("{base}/corp")))
1370 .create_async()
1371 .await;
1372 let _corp_flat_mock = server
1373 .mock("GET", "/corp/flatcontainer/mycompany.internal/index.json")
1374 .with_status(200)
1375 .with_body(r#"{"versions": ["1.2.3"]}"#)
1376 .create_async()
1377 .await;
1378
1379 let dir = tempfile::tempdir().unwrap();
1380 std::fs::write(
1381 dir.path().join("NuGet.Config"),
1382 format!(
1383 r#"<configuration><packageSources>
1384 <clear />
1385 <add key="CorpFeed" value="{base}/corp/index.json" />
1386 </packageSources></configuration>"#
1387 ),
1388 )
1389 .unwrap();
1390 let manifest_path = dir.path().join("App.csproj");
1391 let content = r#"<Project><ItemGroup><PackageReference Include="MyCompany.Internal" Version="1.0.0" /></ItemGroup></Project>"#;
1392 std::fs::write(&manifest_path, content).unwrap();
1393 let uri = tower_lsp_server::ls_types::Uri::from_file_path(&manifest_path).unwrap();
1394
1395 let registry = NuGetRegistry::with_service_index_url(
1396 Arc::new(deps_core::HttpCache::new()),
1397 format!("{base}/public/index.json"),
1398 );
1399 let policy = Arc::new(deps_core::net_policy::RegistryAccessPolicy::new(
1400 deps_core::net_policy::WorkspaceRegistryAccess::All,
1401 ));
1402 let context = crate::config::NuGetParseContext {
1403 policy: Arc::clone(&policy),
1404 config_cache: Arc::new(crate::config::NuGetConfigCache::new()),
1405 user_profile_config: None,
1406 user_profile_sources: Arc::new(std::sync::atomic::AtomicBool::new(false)),
1407 };
1408 let eco = NuGetEcosystem::with_context(Arc::new(registry), context);
1409
1410 let parse_result = eco.parse_manifest(content, &uri).await.unwrap();
1411 let position = Position::new(0, 49);
1413
1414 let cached = std::collections::HashMap::new();
1415 let resolved = std::collections::HashMap::new();
1416 let hover = eco
1417 .generate_hover(
1418 parse_result.as_ref(),
1419 position,
1420 deps_core::VersionData::new(&cached, &resolved),
1421 deps_core::FreshnessSettings {
1422 enabled: false,
1423 cooldown_secs: deps_core::DEFAULT_COOLDOWN_SECS,
1424 },
1425 )
1426 .await;
1427
1428 assert!(
1429 hover.is_some(),
1430 "expected a hover render for a resolvable in-range dependency"
1431 );
1432 _public_index_mock.assert_async().await;
1433 }
1434
1435 #[tokio::test]
1440 async fn test_generate_hover_marks_unlisted_for_alternate_registry_dependency() {
1441 let mut server = mockito::Server::new_async().await;
1442 let base = server.url();
1443
1444 let _corp_index_mock = server
1445 .mock("GET", "/corp/index.json")
1446 .with_status(200)
1447 .with_body(nuget_service_index_body(&format!("{base}/corp")))
1448 .create_async()
1449 .await;
1450 let _corp_flat_mock = server
1451 .mock("GET", "/corp/flatcontainer/mycompany.internal/index.json")
1452 .with_status(200)
1453 .with_body(r#"{"versions": ["1.2.3"]}"#)
1454 .create_async()
1455 .await;
1456 let _corp_reg_mock = server
1457 .mock("GET", "/corp/registrations/mycompany.internal/index.json")
1458 .with_status(200)
1459 .with_body(
1460 r#"{"count": 1, "items": [{"@id": "x", "count": 1, "items": [
1461 {"catalogEntry": {"version": "1.2.3", "listed": false}}
1462 ]}]}"#,
1463 )
1464 .create_async()
1465 .await;
1466
1467 let dir = tempfile::tempdir().unwrap();
1468 std::fs::write(
1469 dir.path().join("NuGet.Config"),
1470 format!(
1471 r#"<configuration><packageSources>
1472 <clear />
1473 <add key="CorpFeed" value="{base}/corp/index.json" />
1474 </packageSources></configuration>"#
1475 ),
1476 )
1477 .unwrap();
1478 let manifest_path = dir.path().join("App.csproj");
1479 let content = r#"<Project><ItemGroup><PackageReference Include="MyCompany.Internal" Version="1.2.3" /></ItemGroup></Project>"#;
1480 std::fs::write(&manifest_path, content).unwrap();
1481 let uri = tower_lsp_server::ls_types::Uri::from_file_path(&manifest_path).unwrap();
1482
1483 let registry = NuGetRegistry::with_service_index_url(
1484 Arc::new(deps_core::HttpCache::new()),
1485 format!("{base}/public/index.json"),
1486 );
1487 let policy = Arc::new(deps_core::net_policy::RegistryAccessPolicy::new(
1488 deps_core::net_policy::WorkspaceRegistryAccess::All,
1489 ));
1490 let context = crate::config::NuGetParseContext {
1491 policy: Arc::clone(&policy),
1492 config_cache: Arc::new(crate::config::NuGetConfigCache::new()),
1493 user_profile_config: None,
1494 user_profile_sources: Arc::new(std::sync::atomic::AtomicBool::new(false)),
1495 };
1496 let eco = NuGetEcosystem::with_context(Arc::new(registry), context);
1497
1498 let parse_result = eco.parse_manifest(content, &uri).await.unwrap();
1499 let position = Position::new(0, 49); let cached = std::collections::HashMap::new();
1502 let resolved = std::collections::HashMap::new();
1503 let hover = eco
1504 .generate_hover(
1505 parse_result.as_ref(),
1506 position,
1507 deps_core::VersionData::new(&cached, &resolved),
1508 deps_core::FreshnessSettings {
1509 enabled: false,
1510 cooldown_secs: deps_core::DEFAULT_COOLDOWN_SECS,
1511 },
1512 )
1513 .await
1514 .expect("hover for a resolvable alternate-feed dependency must not be None");
1515
1516 let HoverContents::Markup(content) = hover.contents else {
1517 panic!("expected markup hover contents");
1518 };
1519 assert!(
1520 content.value.contains("- `1.2.3` *(unlisted)*"),
1521 "expected the alternate-feed dependency's unlisted marker, got: {}",
1522 content.value
1523 );
1524 _corp_index_mock.assert_async().await;
1525 _corp_flat_mock.assert_async().await;
1526 _corp_reg_mock.assert_async().await;
1527 }
1528}