use tower_lsp_server::ls_types::{Hover, HoverContents, MarkupContent, MarkupKind, Position};
use crate::osv::{ADVISORY_DISPLAY_CAP, ScanOutcome};
use crate::{
ParseResult, PublishTime, Registry, Version, VersionReq, format_relative_age,
is_within_cooldown,
};
use super::{
EcosystemFormatter, HOVER_RECENT_VERSIONS, VersionData, escape_markdown, markdown_code_span,
position_in_range,
};
fn version_age_suffix(version: &dyn Version, now: PublishTime) -> String {
version
.published_at()
.map(|published| format!(" — {}", format_relative_age(published.age_secs_from(now))))
.unwrap_or_default()
}
const HOVER_FALLBACK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
pub async fn generate_hover<R: Registry + ?Sized>(
parse_result: &dyn ParseResult,
position: Position,
versions: VersionData<'_>,
registry: &R,
formatter: &dyn EcosystemFormatter,
freshness: crate::freshness::FreshnessSettings,
now: PublishTime,
) -> Option<Hover> {
use std::fmt::Write;
let dep = parse_result.dependencies().into_iter().find(|d| {
let on_name = position_in_range(position, d.name_range());
let on_version = d
.version_range()
.is_some_and(|r| position_in_range(position, r));
on_name || on_version
})?;
let resolvable = dep.source().is_version_resolvable();
let available_versions = if resolvable {
Some(
registry
.get_versions_with(dep.name(), freshness)
.await
.ok()?,
)
} else {
None
};
let url = formatter.package_url(dep.name());
let mut markdown = String::with_capacity(512);
write!(
&mut markdown,
"# [{}]({})\n\n",
escape_markdown(dep.name().as_str()),
url
)
.unwrap();
let normalized_name = formatter.normalize_package_name(dep.name());
let resolved: Option<&str> = if formatter.manifest_requirement_is_resolved_version(dep) {
dep.version_requirement().map(VersionReq::as_str)
} else {
versions
.resolved
.get(normalized_name.as_str())
.or_else(|| versions.resolved.get(dep.name()))
.map(String::as_str)
};
if let Some(resolved_ver) = resolved {
write!(
&mut markdown,
"**Current**: {}\n\n",
markdown_code_span(resolved_ver)
)
.unwrap();
} else if let Some(version_req) = dep.version_requirement() {
write!(
&mut markdown,
"**Requirement**: {}\n\n",
markdown_code_span(version_req.as_str())
)
.unwrap();
}
if let Some(marker_expr) = dep.markers() {
write!(
&mut markdown,
"**Active when**: {}\n\n",
markdown_code_span(marker_expr)
)
.unwrap();
}
let wildcard_req = VersionReq::new("*");
let live_latest_idx = available_versions
.as_ref()
.and_then(|v| registry.select_latest_matching(v, &wildcard_req));
let list_fallback_latest = if available_versions.as_ref().is_some_and(|v| !v.is_empty())
&& live_latest_idx.is_none()
{
match tokio::time::timeout(
HOVER_FALLBACK_TIMEOUT,
registry.get_latest_matching(dep.name(), &wildcard_req),
)
.await
{
Ok(Ok(found)) => {
tracing::debug!(package = %dep.name(), found = found.is_some(), "hover latest fallback (get_latest_matching) resolved");
found
}
Ok(Err(error)) => {
tracing::warn!(package = %dep.name(), %error, "hover latest fallback (get_latest_matching) failed");
None
}
Err(_) => {
tracing::warn!(
package = %dep.name(),
timeout_secs = HOVER_FALLBACK_TIMEOUT.as_secs(),
"hover latest fallback (get_latest_matching) timed out"
);
None
}
}
} else {
None
};
let cached_latest = resolvable
.then(|| {
versions
.cached
.get(normalized_name.as_str())
.or_else(|| versions.cached.get(dep.name()))
})
.flatten();
let latest_line: Option<(&str, Option<PublishTime>)> = match &available_versions {
Some(v) if !v.is_empty() => live_latest_idx
.map(|idx| &v[idx])
.map(|live| (live.version_string(), live.published_at()))
.or_else(|| {
list_fallback_latest
.as_deref()
.map(|live| (live.version_string(), live.published_at()))
}),
_ => cached_latest.map(|v| (v.latest.as_str(), v.published_at)),
};
if let Some((latest_ver, raw_published_at)) = latest_line {
let published_at = freshness.enabled.then_some(raw_published_at).flatten();
let age_secs = published_at.map(|p| p.age_secs_from(now));
write!(
&mut markdown,
"**Latest**: {}",
markdown_code_span(latest_ver)
)
.unwrap();
if let Some(age_secs) = age_secs {
write!(
&mut markdown,
" *(published {})*",
format_relative_age(age_secs)
)
.unwrap();
}
markdown.push_str("\n\n");
if age_secs.is_some_and(|age| is_within_cooldown(age, freshness.cooldown_secs)) {
markdown.push_str(
"> ⏳ **Recently published** — this release is still within the cooldown window.\n\
> It may still be yanked or superseded; consider verifying before upgrading.\n\n",
);
}
}
let vuln_outcome = versions.vulnerabilities.and_then(|m| {
m.get(&normalized_name)
.or_else(|| m.get(dep.name().as_str()))
});
push_vulnerability_hover_section(&mut markdown, vuln_outcome);
if let Some(available_versions) = &available_versions {
markdown.push_str("**Recent versions**:\n");
for (i, version) in available_versions
.iter()
.take(HOVER_RECENT_VERSIONS)
.enumerate()
{
let version_span = markdown_code_span(version.version_string());
let age_suffix = if freshness.enabled {
version_age_suffix(version.as_ref(), now)
} else {
String::new()
};
if Some(i) == live_latest_idx {
if version.removal_status().is_flagged() {
writeln!(
&mut markdown,
"- {version_span} *(latest)* {}{age_suffix}",
formatter.yanked_label()
)
.unwrap();
} else {
writeln!(&mut markdown, "- {version_span} *(latest)*{age_suffix}").unwrap();
}
} else if version.removal_status().is_flagged() {
writeln!(
&mut markdown,
"- {} {}{}",
version_span,
formatter.yanked_label(),
age_suffix
)
.unwrap();
} else {
writeln!(&mut markdown, "- {version_span}{age_suffix}").unwrap();
}
}
}
markdown.push_str("\n---\n⌨️ **Press `Cmd+.` to update version**");
Some(Hover {
contents: HoverContents::Markup(MarkupContent {
kind: MarkupKind::Markdown,
value: markdown,
}),
range: Some(dep.name_range()),
})
}
const fn severity_label(severity: crate::osv::VulnSeverity) -> &'static str {
match severity {
crate::osv::VulnSeverity::Critical => "critical",
crate::osv::VulnSeverity::High => "high",
crate::osv::VulnSeverity::Medium => "medium",
crate::osv::VulnSeverity::Low => "low",
crate::osv::VulnSeverity::Unknown => "unknown severity",
}
}
fn push_vulnerability_hover_section(markdown: &mut String, outcome: Option<&ScanOutcome>) {
use std::fmt::Write;
match outcome {
Some(ScanOutcome::Vulnerable(dv)) => {
markdown.push_str("### Security advisories\n\n");
let shown = dv.advisories.iter().take(ADVISORY_DISPLAY_CAP);
for advisory in shown {
writeln!(
markdown,
"- **[{}]({})** — {}",
escape_markdown(&advisory.id),
advisory.url,
severity_label(advisory.severity)
)
.unwrap();
writeln!(
markdown,
" {}",
escape_markdown(
advisory
.summary
.as_deref()
.unwrap_or("(no summary provided)")
)
)
.unwrap();
let mut details = Vec::with_capacity(2);
if let Some(fixed) = advisory.fixed_versions.last() {
details.push(format!("Fixed in: {}", markdown_code_span(fixed)));
}
if !advisory.aliases.is_empty() {
details.push(format!(
"Aliases: {}",
escape_markdown(&advisory.aliases.join(", "))
));
}
if !details.is_empty() {
writeln!(markdown, " {}", details.join(" \u{b7} ")).unwrap();
}
}
let shown_count = dv.advisories.len().min(ADVISORY_DISPLAY_CAP);
let remaining = dv.total_known.saturating_sub(shown_count);
if remaining > 0 {
writeln!(markdown, "- *(+{remaining} more advisories)*").unwrap();
}
if let crate::osv::UpgradeStatus::CandidateVulnerable { version, .. } =
&dv.upgrade_status
{
writeln!(
markdown,
"\n\u{26a0}\u{fe0f} Latest version {} is also affected.",
markdown_code_span(version)
)
.unwrap();
}
markdown.push('\n');
}
Some(ScanOutcome::Clean) => {
markdown.push_str("**No known vulnerabilities** (OSV.dev)\n\n");
}
Some(ScanOutcome::Skipped(_)) | None => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::RemovalStatus;
use crate::lsp_helpers::test_support::*;
use crate::lsp_helpers::*;
use std::collections::HashMap;
use std::sync::Arc;
#[tokio::test]
async fn test_generate_hover_recent_versions_shows_age_when_known() {
use std::collections::HashMap;
let registry = MockRegistryWithVersions {
versions: vec![MockVersionWithAge {
version: "1.2.3".to_string(),
yanked: false,
published_at: Some(PublishTime::from_unix_secs(
PublishTime::now().as_unix_secs() - 2 * 24 * 60 * 60,
)),
}],
};
let parse_result = freshness_test_parse_result("serde");
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&HashMap::new(), &HashMap::new()),
®istry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated for a dependency at the cursor");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(
content.value.contains("- `1.2.3` *(latest)* — 2 days ago"),
"got: {}",
content.value
);
}
#[tokio::test]
async fn test_generate_hover_recent_versions_omits_age_when_unknown() {
use std::collections::HashMap;
let registry = MockRegistryWithVersions {
versions: vec![MockVersionWithAge {
version: "1.2.3".to_string(),
yanked: false,
published_at: None,
}],
};
let parse_result = freshness_test_parse_result("serde");
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&HashMap::new(), &HashMap::new()),
®istry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated for a dependency at the cursor");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(content.value.contains("- `1.2.3` *(latest)*\n"));
assert!(!content.value.contains("ago"));
}
#[tokio::test]
async fn test_generate_hover_latest_marker_skips_prerelease_at_raw_top() {
use std::collections::HashMap;
let registry = MockRegistryWithVersions {
versions: vec![
MockVersionWithAge {
version: "13.0.5-beta1".to_string(),
yanked: false,
published_at: None,
},
MockVersionWithAge {
version: "13.0.4".to_string(),
yanked: false,
published_at: None,
},
],
};
let parse_result = freshness_test_parse_result("Newtonsoft.Json");
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&HashMap::new(), &HashMap::new()),
®istry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated for a dependency at the cursor");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(
content.value.contains("**Latest**: `13.0.4`"),
"got: {}",
content.value
);
assert!(
content.value.contains("- `13.0.4` *(latest)*"),
"the stable version, not the raw-top pre-release, should carry the marker; got: {}",
content.value
);
assert!(
!content.value.contains("13.0.5-beta1` *(latest)*"),
"the pre-release must not be tagged latest; got: {}",
content.value
);
}
#[tokio::test]
async fn test_generate_hover_latest_marker_omitted_when_stable_outside_top_n() {
use std::collections::HashMap;
let mut versions: Vec<MockVersionWithAge> = (0..=HOVER_RECENT_VERSIONS)
.map(|i| MockVersionWithAge {
version: format!("2.0.0-alpha{i}"),
yanked: false,
published_at: None,
})
.collect();
versions.push(MockVersionWithAge {
version: "1.9.0".to_string(),
yanked: false,
published_at: None,
});
let registry = MockRegistryWithVersions { versions };
let parse_result = freshness_test_parse_result("example");
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&HashMap::new(), &HashMap::new()),
®istry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated for a dependency at the cursor");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(
content.value.contains("**Latest**: `1.9.0`"),
"got: {}",
content.value
);
assert!(
!content.value.contains("*(latest)*"),
"the stable latest isn't in the truncated top-N slice, so no entry should be marked; got: {}",
content.value
);
}
#[tokio::test]
async fn test_generate_hover_latest_marker_all_prerelease_degrades_gracefully() {
use std::collections::HashMap;
let registry = MockRegistryWithVersions {
versions: vec![
MockVersionWithAge {
version: "2.0.0-beta2".to_string(),
yanked: false,
published_at: None,
},
MockVersionWithAge {
version: "2.0.0-beta1".to_string(),
yanked: false,
published_at: None,
},
MockVersionWithAge {
version: "1.9.0-alpha1".to_string(),
yanked: false,
published_at: None,
},
],
};
let parse_result = freshness_test_parse_result("example");
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&HashMap::new(), &HashMap::new()),
®istry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated for a dependency at the cursor, not panic");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(
!content.value.contains("**Latest**:"),
"no stable version exists, so the header should be omitted rather than picking a pre-release; got: {}",
content.value
);
assert!(
!content.value.contains("*(latest)*"),
"no stable version exists, so no entry in the list should be marked latest; got: {}",
content.value
);
assert!(
content.value.contains("2.0.0-beta2"),
"the raw version list should still render even without a latest marker; got: {}",
content.value
);
}
#[tokio::test]
async fn test_generate_hover_latest_marker_all_prerelease_live_list_ignores_stale_cache() {
use std::collections::HashMap;
let registry = MockRegistryWithVersions {
versions: vec![MockVersionWithAge {
version: "2.0.0-beta2".to_string(),
yanked: false,
published_at: None,
}],
};
let parse_result = freshness_test_parse_result("example");
let mut cached_versions = HashMap::new();
cached_versions.insert("example".into(), PackageVersions::latest_only("1.5.0"));
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&cached_versions, &HashMap::new()),
®istry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated for a dependency at the cursor, not panic");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(
!content.value.contains("**Latest**:"),
"a live fetch with no stable entry must not fall back to a stale cached version \
that isn't in the live list; got: {}",
content.value
);
assert!(
!content.value.contains("1.5.0"),
"the stale cached version must not leak into the response at all; got: {}",
content.value
);
}
#[tokio::test]
async fn test_generate_hover_latest_falls_back_to_get_latest_matching_when_list_pick_fails() {
use std::collections::HashMap;
let registry = MockRegistryListFailsLatestFallbackSucceeds {
versions: vec![MockVersionWithAge {
version: "v0.0.0-20230101000000-abcdef123456".to_string(),
yanked: false,
published_at: None,
}],
fallback_latest: MockVersionWithAge {
version: "v1.2.3".to_string(),
yanked: false,
published_at: None,
},
list_pick_index: None,
get_latest_matching_calls: std::sync::atomic::AtomicUsize::new(0),
};
let parse_result = freshness_test_parse_result("example.com/mod");
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&HashMap::new(), &HashMap::new()),
®istry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated for a dependency at the cursor");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(
content.value.contains("**Latest**: `v1.2.3`"),
"the list-based pick failed, so hover must fall back to get_latest_matching's \
result instead of omitting the Latest line; got: {}",
content.value
);
}
#[tokio::test]
async fn test_generate_hover_does_not_call_fallback_when_list_pick_succeeds() {
use std::collections::HashMap;
use std::sync::atomic::Ordering;
let registry = MockRegistryListFailsLatestFallbackSucceeds {
versions: vec![MockVersionWithAge {
version: "v1.2.3".to_string(),
yanked: false,
published_at: None,
}],
fallback_latest: MockVersionWithAge {
version: "v9.9.9".to_string(),
yanked: false,
published_at: None,
},
list_pick_index: Some(0),
get_latest_matching_calls: std::sync::atomic::AtomicUsize::new(0),
};
let parse_result = freshness_test_parse_result("example.com/mod");
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&HashMap::new(), &HashMap::new()),
®istry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated for a dependency at the cursor");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(
content.value.contains("**Latest**: `v1.2.3`"),
"expected the list-based pick's own version, not the fallback's; got: {}",
content.value
);
assert_eq!(
registry.get_latest_matching_calls.load(Ordering::Relaxed),
0,
"get_latest_matching must not be called when the list-based pick already succeeded"
);
}
#[tokio::test]
async fn test_generate_hover_go_prefers_manifest_requirement_over_stale_resolved_version() {
use std::collections::HashMap;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "example.com/mod".into(),
version_req: "v0.8.1".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 16)),
}],
uri: crate::test_util::test_uri("/test/go.mod"),
};
let mut resolved_versions = HashMap::new();
resolved_versions.insert("example.com/mod".into(), "v0.9.1".to_string());
let cached_versions = HashMap::new();
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&cached_versions, &resolved_versions),
&MockRegistry,
&MockGoFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated for a dependency at the cursor");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(
content.value.contains("**Current**: `v0.8.1`"),
"expected hover to show go.mod's pinned version, got: {}",
content.value
);
assert!(
!content.value.contains("v0.9.1"),
"hover must not surface the stale go.sum version: {}",
content.value
);
}
#[tokio::test]
async fn test_generate_hover_non_go_formatter_uses_resolved_lockfile_version() {
use std::collections::HashMap;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "serde".into(),
version_req: "1.0.0".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
}],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let mut resolved_versions = HashMap::new();
resolved_versions.insert("serde".into(), "1.2.0".to_string());
let cached_versions = HashMap::new();
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&cached_versions, &resolved_versions),
&MockRegistry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated for a dependency at the cursor");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(
content.value.contains("**Current**: `1.2.0`"),
"expected hover to show the resolved lockfile version, got: {}",
content.value
);
assert!(!content.value.contains("**Current**: `1.0.0`"));
}
#[tokio::test]
async fn test_generate_hover_recent_versions_preserves_yanked_marker_with_age() {
use std::collections::HashMap;
let registry = MockRegistryWithVersions {
versions: vec![
MockVersionWithAge {
version: "1.2.3".to_string(),
yanked: false,
published_at: None,
},
MockVersionWithAge {
version: "1.2.1".to_string(),
yanked: true,
published_at: Some(PublishTime::from_unix_secs(
PublishTime::now().as_unix_secs() - 5 * 30 * 24 * 60 * 60,
)),
},
],
};
let parse_result = freshness_test_parse_result("serde");
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&HashMap::new(), &HashMap::new()),
®istry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated for a dependency at the cursor");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(
content
.value
.contains("- `1.2.1` *(yanked)* — 5 months ago"),
"got: {}",
content.value
);
}
#[tokio::test]
async fn test_generate_hover_recent_versions_respects_freshness_disabled() {
use std::collections::HashMap;
let registry = MockRegistryWithVersions {
versions: vec![MockVersionWithAge {
version: "1.2.3".to_string(),
yanked: false,
published_at: Some(PublishTime::from_unix_secs(
PublishTime::now().as_unix_secs() - 2 * 24 * 60 * 60,
)),
}],
};
let parse_result = freshness_test_parse_result("serde");
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&HashMap::new(), &HashMap::new()),
®istry,
&MockFormatter,
crate::freshness::FreshnessSettings {
enabled: false,
cooldown_secs: crate::freshness::DEFAULT_COOLDOWN_SECS,
},
PublishTime::now(),
)
.await
.expect("hover should be generated for a dependency at the cursor");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(content.value.contains("- `1.2.3` *(latest)*\n"));
assert!(!content.value.contains("ago"));
}
#[tokio::test]
async fn test_generate_hover_latest_line_shows_age_and_cooldown_callout_when_within_cooldown() {
use std::collections::HashMap;
let parse_result = freshness_test_parse_result("serde");
let mut cached_versions = HashMap::new();
cached_versions.insert(
"serde".into(),
PackageVersions {
latest: "2.0.0".to_string(),
available: Arc::from(vec!["2.0.0".to_string()]),
yanked: Arc::from(Vec::new()),
published_at: Some(PublishTime::from_unix_secs(
PublishTime::now().as_unix_secs() - 60 * 60,
)),
},
);
let resolved_versions = HashMap::new();
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&cached_versions, &resolved_versions),
&MockRegistry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated for a dependency at the cursor");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(
content
.value
.contains("**Latest**: `2.0.0` *(published 1 hour ago)*"),
"got: {}",
content.value
);
assert!(
content.value.contains(
"> ⏳ **Recently published** — this release is still within the cooldown window."
),
"got: {}",
content.value
);
}
#[tokio::test]
async fn test_generate_hover_latest_line_no_callout_when_outside_cooldown() {
use std::collections::HashMap;
let parse_result = freshness_test_parse_result("serde");
let mut cached_versions = HashMap::new();
cached_versions.insert(
"serde".into(),
PackageVersions {
latest: "2.0.0".to_string(),
available: Arc::from(vec!["2.0.0".to_string()]),
yanked: Arc::from(Vec::new()),
published_at: Some(PublishTime::from_unix_secs(
PublishTime::now().as_unix_secs() - 10 * 24 * 60 * 60,
)),
},
);
let resolved_versions = HashMap::new();
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&cached_versions, &resolved_versions),
&MockRegistry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated for a dependency at the cursor");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(
content
.value
.contains("**Latest**: `2.0.0` *(published 1 week ago)*"),
"got: {}",
content.value
);
assert!(!content.value.contains("Recently published"));
}
#[tokio::test]
async fn test_generate_hover_latest_line_omits_age_when_published_at_unknown() {
use std::collections::HashMap;
let parse_result = freshness_test_parse_result("serde");
let mut cached_versions = HashMap::new();
cached_versions.insert("serde".into(), PackageVersions::latest_only("2.0.0"));
let resolved_versions = HashMap::new();
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&cached_versions, &resolved_versions),
&MockRegistry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated for a dependency at the cursor");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(content.value.contains("**Latest**: `2.0.0`\n\n"));
assert!(!content.value.contains("published"));
assert!(!content.value.contains("Recently published"));
}
#[tokio::test]
async fn test_generate_hover_latest_line_respects_freshness_disabled() {
use std::collections::HashMap;
let parse_result = freshness_test_parse_result("serde");
let mut cached_versions = HashMap::new();
cached_versions.insert(
"serde".into(),
PackageVersions {
latest: "2.0.0".to_string(),
available: Arc::from(vec!["2.0.0".to_string()]),
yanked: Arc::from(Vec::new()),
published_at: Some(PublishTime::from_unix_secs(
PublishTime::now().as_unix_secs() - 60 * 60,
)),
},
);
let resolved_versions = HashMap::new();
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&cached_versions, &resolved_versions),
&MockRegistry,
&MockFormatter,
crate::freshness::FreshnessSettings {
enabled: false,
cooldown_secs: crate::freshness::DEFAULT_COOLDOWN_SECS,
},
PublishTime::now(),
)
.await
.expect("hover should be generated for a dependency at the cursor");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(content.value.contains("**Latest**: `2.0.0`\n\n"));
assert!(!content.value.contains("published"));
assert!(!content.value.contains("Recently published"));
}
#[tokio::test]
async fn test_generate_hover_latest_line_cooldown_boundary_is_exclusive() {
use std::collections::HashMap;
const COOLDOWN_SECS: u64 = 100;
let now = PublishTime::from_unix_secs(10_000);
let published_at_at_boundary =
PublishTime::from_unix_secs(10_000 - COOLDOWN_SECS.cast_signed());
let parse_result = freshness_test_parse_result("serde");
let mut cached_versions = HashMap::new();
cached_versions.insert(
"serde".into(),
PackageVersions {
latest: "2.0.0".to_string(),
available: Arc::from(vec!["2.0.0".to_string()]),
yanked: Arc::from(Vec::new()),
published_at: Some(published_at_at_boundary),
},
);
let resolved_versions = HashMap::new();
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&cached_versions, &resolved_versions),
&MockRegistry,
&MockFormatter,
crate::freshness::FreshnessSettings {
enabled: true,
cooldown_secs: COOLDOWN_SECS,
},
now,
)
.await
.expect("hover should be generated for a dependency at the cursor");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(
!content.value.contains("Recently published"),
"age exactly equal to cooldown_secs must not be within cooldown, got: {}",
content.value
);
}
#[tokio::test]
async fn test_generate_hover_latest_line_cooldown_boundary_one_second_inside_shows_callout() {
use std::collections::HashMap;
const COOLDOWN_SECS: u64 = 100;
let now = PublishTime::from_unix_secs(10_000);
let published_at_just_inside =
PublishTime::from_unix_secs(10_000 - (COOLDOWN_SECS.cast_signed() - 1));
let parse_result = freshness_test_parse_result("serde");
let mut cached_versions = HashMap::new();
cached_versions.insert(
"serde".into(),
PackageVersions {
latest: "2.0.0".to_string(),
available: Arc::from(vec!["2.0.0".to_string()]),
yanked: Arc::from(Vec::new()),
published_at: Some(published_at_just_inside),
},
);
let resolved_versions = HashMap::new();
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&cached_versions, &resolved_versions),
&MockRegistry,
&MockFormatter,
crate::freshness::FreshnessSettings {
enabled: true,
cooldown_secs: COOLDOWN_SECS,
},
now,
)
.await
.expect("hover should be generated for a dependency at the cursor");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(
content.value.contains("Recently published"),
"age == cooldown_secs - 1 must be within cooldown, got: {}",
content.value
);
}
#[tokio::test]
async fn test_generate_hover_latest_line_prefers_live_fetch_over_stale_ch1_cache() {
use std::collections::HashMap;
let now = PublishTime::now();
let live_latest_published = PublishTime::from_unix_secs(now.as_unix_secs() - 60 * 60);
let registry = MockRegistryWithVersions {
versions: vec![
MockVersionWithAge {
version: "1.0.214".to_string(),
yanked: false,
published_at: Some(live_latest_published),
},
MockVersionWithAge {
version: "1.0.213".to_string(),
yanked: false,
published_at: Some(PublishTime::from_unix_secs(
now.as_unix_secs() - 30 * 24 * 60 * 60,
)),
},
],
};
let parse_result = freshness_test_parse_result("serde");
let mut cached_versions = HashMap::new();
cached_versions.insert(
"serde".into(),
PackageVersions {
latest: "1.0.213".to_string(),
available: Arc::from(vec!["1.0.213".to_string()]),
yanked: Arc::from(Vec::new()),
published_at: Some(PublishTime::from_unix_secs(
now.as_unix_secs() - 90 * 24 * 60 * 60,
)),
},
);
let resolved_versions = HashMap::new();
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&cached_versions, &resolved_versions),
®istry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
now,
)
.await
.expect("hover should be generated for a dependency at the cursor");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(
content
.value
.contains("**Latest**: `1.0.214` *(published 1 hour ago)*"),
"Latest line must reflect the live Ch2 fetch, not the stale Ch1 cache entry \
`1.0.213`, got: {}",
content.value
);
assert!(
!content.value.contains("**Latest**: `1.0.213`"),
"must not render the stale Ch1 version, got: {}",
content.value
);
assert!(
content
.value
.contains("- `1.0.214` *(latest)* — 1 hour ago"),
"the Recent versions list's own *(latest)* entry must agree with the Latest \
line above it, got: {}",
content.value
);
}
#[tokio::test]
async fn test_generate_hover_latest_agrees_with_npm_shaped_deprecated_ranking() {
use std::collections::HashMap;
let now = PublishTime::now();
let registry = MockRegistryPreferringUnflagged {
versions: vec![
MockVersionWithStatus {
version: "2.0.0".to_string(),
status: RemovalStatus::AdvisoryDeprecated,
},
MockVersionWithStatus {
version: "1.9.0".to_string(),
status: RemovalStatus::Available,
},
],
};
let parse_result = freshness_test_parse_result("pkg");
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&cached_versions, &resolved_versions),
®istry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
now,
)
.await
.expect("hover should be generated for a dependency at the cursor");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(
content.value.contains("**Latest**: `1.9.0`"),
"hover must agree with select_latest_matching's non-deprecated-preferred pick \
(1.9.0), not a naive is_stable() scan that would pick the newer but deprecated \
2.0.0, got: {}",
content.value
);
assert!(
content.value.contains("- `1.9.0` *(latest)*"),
"the Recent versions list's own *(latest)* marker must agree with the Latest \
line above it, got: {}",
content.value
);
assert!(
content.value.contains("- `2.0.0` *(yanked)*"),
"2.0.0 must keep its flagged label even though it isn't the resolved latest, \
got: {}",
content.value
);
}
#[tokio::test]
async fn test_generate_hover_latest_keeps_flagged_label_when_resolved_version_is_flagged() {
use std::collections::HashMap;
let now = PublishTime::now();
let registry = MockRegistryPreferringUnflagged {
versions: vec![MockVersionWithStatus {
version: "2.0.0".to_string(),
status: RemovalStatus::AdvisoryDeprecated,
}],
};
let parse_result = freshness_test_parse_result("pkg");
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&cached_versions, &resolved_versions),
®istry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
now,
)
.await
.expect("hover should be generated for a dependency at the cursor");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(
content.value.contains("**Latest**: `2.0.0`"),
"the only version resolves as latest even though it's flagged, got: {}",
content.value
);
assert!(
content.value.contains("- `2.0.0` *(latest)* *(yanked)*"),
"the resolved latest must keep its flagged label instead of the warning \
silently vanishing behind *(latest)*, got: {}",
content.value
);
}
#[tokio::test]
async fn test_generate_hover_latest_resolves_when_all_versions_yanked() {
use std::collections::HashMap;
let now = PublishTime::now();
let registry = MockRegistryPreferringUnflagged {
versions: vec![
MockVersionWithStatus {
version: "2.0.0".to_string(),
status: RemovalStatus::Yanked,
},
MockVersionWithStatus {
version: "1.9.0".to_string(),
status: RemovalStatus::Yanked,
},
],
};
let parse_result = freshness_test_parse_result("pkg");
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&cached_versions, &resolved_versions),
®istry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
now,
)
.await
.expect("hover should be generated for a dependency at the cursor");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(
content.value.contains("**Latest**: `2.0.0`"),
"an all-yanked package still exists: hover must resolve the newest yanked \
version as latest rather than showing no Latest line, got: {}",
content.value
);
assert!(
content.value.contains("- `2.0.0` *(latest)* *(yanked)*"),
"the resolved latest must keep its yanked label, got: {}",
content.value
);
}
#[tokio::test]
async fn test_generate_hover_surfaces_markers() {
use std::collections::HashMap;
use tower_lsp_server::ls_types::{Position, Range};
let parse_result = MockMarkedParseResult {
dep: MockMarkedDep {
name: "numpy".into(),
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
markers: Some("python_full_version >= '3.9'".to_string()),
},
uri: crate::test_util::test_uri("/test/pyproject.toml"),
};
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&HashMap::new(), &HashMap::new()),
&MockRegistry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated for a dependency at the cursor");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(
content
.value
.contains("**Active when**: `python_full_version >= '3.9'`")
);
}
#[tokio::test]
async fn test_generate_hover_omits_active_when_without_markers() {
use std::collections::HashMap;
use tower_lsp_server::ls_types::{Position, Range};
let parse_result = MockMarkedParseResult {
dep: MockMarkedDep {
name: "requests".into(),
name_range: Range::new(Position::new(0, 0), Position::new(0, 8)),
markers: None,
},
uri: crate::test_util::test_uri("/test/pyproject.toml"),
};
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&HashMap::new(), &HashMap::new()),
&MockRegistry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated for a dependency at the cursor");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(!content.value.contains("Active when"));
}
#[tokio::test]
async fn test_generate_hover_escapes_malicious_dependency_name() {
use std::collections::HashMap;
use tower_lsp_server::ls_types::{Position, Range};
let malicious_name = "real-pkg](https://legit-looking-typosquat.example/download)[real-pkg";
let parse_result = MockParseResult {
deps: vec![MockDep {
name: malicious_name.into(),
version_req: "1.0.0".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(
Position::new(0, 0),
Position::new(0, malicious_name.len() as u32),
),
}],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&HashMap::new(), &HashMap::new()),
&MockRegistry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated for a dependency at the cursor");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
let header_line = content
.value
.lines()
.next()
.expect("hover markdown has a header line");
let label = header_line
.strip_prefix("# [")
.expect("header starts with link label")
.split("](")
.next()
.expect("header contains label/url separator");
assert_eq!(
label,
r"real\-pkg\]\(https\:\/\/legit\-looking\-typosquat\.example\/download\)\[real\-pkg"
);
}
#[tokio::test]
async fn test_generate_hover_newline_in_name_cannot_forge_new_heading() {
use std::collections::HashMap;
use tower_lsp_server::ls_types::{Position, Range};
let malicious_name = "react\n# [fake](https://evil.example) <https://evil.example>";
let parse_result = MockParseResult {
deps: vec![MockDep {
name: malicious_name.into(),
version_req: "1.0.0".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(
Position::new(0, 0),
Position::new(0, malicious_name.len() as u32),
),
}],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&HashMap::new(), &HashMap::new()),
&MockRegistry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated for a dependency at the cursor");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
let header_line = content
.value
.lines()
.next()
.expect("hover markdown has a header line");
let label = header_line
.strip_prefix("# [")
.expect("header starts with link label")
.split("](")
.next()
.expect("header contains label/url separator");
assert_eq!(label, escape_markdown(malicious_name));
assert!(!label.contains('\n'));
assert!(label.contains(r"\<https"));
}
#[tokio::test]
async fn test_generate_hover_marker_with_parens_renders_unescaped() {
use std::collections::HashMap;
use tower_lsp_server::ls_types::{Position, Range};
let marker = "python_version >= \"3.8\" and (sys_platform == \"linux\")";
let parse_result = MockMarkedParseResult {
dep: MockMarkedDep {
name: "numpy".into(),
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
markers: Some(marker.to_string()),
},
uri: crate::test_util::test_uri("/test/pyproject.toml"),
};
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&HashMap::new(), &HashMap::new()),
&MockRegistry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated for a dependency at the cursor");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(
content
.value
.contains(&format!("**Active when**: `{marker}`"))
);
}
#[tokio::test]
async fn test_generate_hover_registry_sections_suppressed_for_non_registry_sources() {
use crate::parser::DependencySource;
let registry = MockRegistryWithVersions {
versions: vec![MockVersionWithAge {
version: "9.9.9".to_string(),
yanked: false,
published_at: None,
}],
};
let cached_versions = {
let mut m = HashMap::new();
m.insert("dep".into(), PackageVersions::latest_only("9.9.9"));
m
};
let resolved_versions = HashMap::new();
let uri = crate::test_util::test_uri("/test/Cargo.toml");
let parse_result = SingleDepParseResult {
dep: NonRegistryDep(
dep_at("dep"),
DependencySource::CustomRegistry {
url: "my-corp".into(),
},
),
uri: uri.clone(),
};
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&cached_versions, &resolved_versions),
®istry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should still be generated for a non-resolvable-source dependency");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(!content.value.contains("**Latest**"));
assert!(!content.value.contains("**Recent versions**"));
assert!(content.value.contains("**Requirement**"));
let registry_parse_result = SingleDepParseResult {
dep: dep_at("dep"),
uri,
};
let hover = generate_hover(
®istry_parse_result,
Position::new(0, 2),
VersionData::new(&cached_versions, &resolved_versions),
®istry,
&MockFormatter,
crate::freshness::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(content.value.contains("**Latest**"));
assert!(content.value.contains("**Recent versions**"));
}
#[tokio::test]
async fn test_generate_hover_clean_outcome_states_no_known_vulnerabilities() {
use crate::osv::{ScanOutcome, VulnerabilityMap};
let parse_result = MockParseResult {
deps: vec![dep_at("clean-pkg")],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let mut vulns: VulnerabilityMap = VulnerabilityMap::new();
vulns.insert("clean-pkg".to_string(), ScanOutcome::Clean);
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&cached_versions, &resolved_versions).with_vulnerabilities(&vulns),
&MockRegistry,
&MockFormatter,
crate::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(content.value.contains("No known vulnerabilities"));
}
#[tokio::test]
async fn test_generate_hover_skipped_outcome_says_nothing_about_vulnerabilities() {
use crate::osv::{ScanOutcome, SkipReason, VulnerabilityMap};
let parse_result = MockParseResult {
deps: vec![dep_at("path-pkg")],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let mut vulns: VulnerabilityMap = VulnerabilityMap::new();
vulns.insert(
"path-pkg".to_string(),
ScanOutcome::Skipped(SkipReason::NonRegistrySource),
);
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&cached_versions, &resolved_versions).with_vulnerabilities(&vulns),
&MockRegistry,
&MockFormatter,
crate::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(!content.value.contains("Security advisories"));
assert!(!content.value.contains("No known vulnerabilities"));
}
#[tokio::test]
async fn test_generate_hover_vulnerable_outcome_shows_advisories_and_more_count() {
use crate::osv::{
DependencyVulnerabilities, ScanOutcome, UpgradeStatus, VulnSeverity, VulnerabilityMap,
};
let parse_result = MockParseResult {
deps: vec![dep_at("bad-pkg")],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let mut vulns: VulnerabilityMap = VulnerabilityMap::new();
vulns.insert(
"bad-pkg".to_string(),
ScanOutcome::Vulnerable(DependencyVulnerabilities {
advisories: vec![sample_advisory("RUSTSEC-2020-0071", VulnSeverity::Critical)],
total_known: 3,
upgrade_status: UpgradeStatus::CandidateVulnerable {
version: "2.0.0".to_string(),
advisory_ids: vec!["RUSTSEC-2020-0071".to_string()],
},
}),
);
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&cached_versions, &resolved_versions).with_vulnerabilities(&vulns),
&MockRegistry,
&MockFormatter,
crate::FreshnessSettings::default(),
PublishTime::now(),
)
.await
.expect("hover should be generated");
let HoverContents::Markup(content) = hover.contents else {
panic!("expected markup hover contents");
};
assert!(content.value.contains("Security advisories"));
assert!(content.value.contains("RUSTSEC-2020-0071"));
assert!(content.value.contains("Fixed in"));
assert!(
content.value.contains("1.5.0"),
"must show highest fixed version"
);
assert!(content.value.contains("+2 more advisories"));
assert!(content.value.contains("also affected"));
}
#[tokio::test]
async fn test_generate_hover_none_when_registry_reports_not_found() {
let parse_result = MockParseResult {
deps: vec![dep_at("com.example:..")],
uri: crate::test_util::test_uri("/test/pom.xml"),
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let hover = generate_hover(
&parse_result,
Position::new(0, 2),
VersionData::new(&cached_versions, &resolved_versions),
&NotFoundRegistry,
&MockFormatter,
crate::FreshnessSettings::default(),
PublishTime::now(),
)
.await;
assert!(hover.is_none());
}
}