use tower_lsp_server::ls_types::{CodeLens, Command, Position, Range, TextEdit, Uri};
use crate::ParseResult;
#[cfg(test)]
use crate::PublishTime;
use super::{
EcosystemFormatter, LineOffsetTable, VersionData, is_safe_version_string, literal_span_matches,
slice_for_range, strip_whitespace, warn_rejected_value,
};
pub fn collect_update_all_edits(
parse_result: &dyn ParseResult,
content: &str,
versions: VersionData<'_>,
formatter: &dyn EcosystemFormatter,
) -> Vec<TextEdit> {
let deps = parse_result.dependencies();
let mut edits: Vec<TextEdit> = Vec::with_capacity(deps.len());
let line_offsets = LineOffsetTable::new(content);
for dep in deps {
let Some(version_range) = dep.version_range() else {
continue;
};
let normalized_name = formatter.normalize_package_name(dep.name());
let Some(latest) = versions
.cached
.get(normalized_name.as_str())
.or_else(|| versions.cached.get(dep.name()))
.map(|v| v.latest.as_str())
else {
continue;
};
if !is_safe_version_string(latest) {
warn_rejected_value(
"is_safe_version_string",
"update-all code lens edit",
latest,
);
continue;
}
let Some(version_req) = dep.version_requirement() else {
continue;
};
if version_req.as_str().is_empty() {
continue;
}
if formatter.is_requirement_up_to_date(version_req, latest) {
continue;
}
let slice = slice_for_range(content, &line_offsets, version_range);
let literal_target = dep
.version_literal()
.unwrap_or_else(|| version_req.as_str());
if !literal_span_matches(slice, literal_target) {
continue;
}
let new_text = formatter.format_version_replacing(latest, version_req.as_str());
if strip_whitespace(&new_text) == strip_whitespace(literal_target) {
continue;
}
edits.push(TextEdit {
range: version_range,
new_text,
});
}
edits.sort_by_key(|edit| (edit.range.start.line, edit.range.start.character));
let mut non_overlapping: Vec<TextEdit> = Vec::with_capacity(edits.len());
for edit in edits {
let overlaps_prev = non_overlapping.last().is_some_and(|prev: &TextEdit| {
(edit.range.start.line, edit.range.start.character)
< (prev.range.end.line, prev.range.end.character)
});
if overlaps_prev {
tracing::warn!(
range = ?edit.range,
"collect_update_all_edits: dropping overlapping TextEdit"
);
continue;
}
non_overlapping.push(edit);
}
non_overlapping
}
pub fn generate_code_lenses(
parse_result: &dyn ParseResult,
content: &str,
versions: VersionData<'_>,
formatter: &dyn EcosystemFormatter,
uri: &Uri,
command_id: &str,
) -> Vec<CodeLens> {
let edits = collect_update_all_edits(parse_result, content, versions, formatter);
if edits.is_empty() {
return Vec::new();
}
let count = edits.len();
let title = if count == 1 {
"Update 1 outdated dependency".to_string()
} else {
format!("Update {count} outdated dependencies")
};
vec![CodeLens {
range: Range::new(Position::new(0, 0), Position::new(0, 0)),
command: Some(Command {
title,
command: command_id.to_string(),
arguments: Some(vec![serde_json::json!({ "uri": uri })]),
}),
data: None,
}]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lsp_helpers::test_support::*;
use crate::lsp_helpers::*;
use crate::{PackageName, VersionReq};
use std::any::Any;
use std::collections::HashMap;
mod update_all_edits_tests {
use super::*;
use tower_lsp_server::ls_types::{Position, Range};
struct UaeDep {
name: PackageName,
version_req: Option<VersionReq>,
version_range: Option<Range>,
}
impl Dependency for UaeDep {
fn name(&self) -> &PackageName {
&self.name
}
fn name_range(&self) -> Range {
Range::default()
}
fn version_requirement(&self) -> Option<&VersionReq> {
self.version_req.as_ref()
}
fn version_range(&self) -> Option<Range> {
self.version_range
}
fn source(&self) -> crate::parser::DependencySource {
crate::parser::DependencySource::Registry
}
fn as_any(&self) -> &dyn Any {
self
}
}
struct UaeParseResult {
deps: Vec<UaeDep>,
uri: Uri,
}
impl ParseResult for UaeParseResult {
fn dependencies(&self) -> Vec<&dyn Dependency> {
self.deps.iter().map(|d| d as &dyn Dependency).collect()
}
fn workspace_root(&self) -> Option<&std::path::Path> {
None
}
fn uri(&self) -> &Uri {
&self.uri
}
fn as_any(&self) -> &dyn Any {
self
}
}
fn range(sl: u32, sc: u32, el: u32, ec: u32) -> Range {
Range::new(Position::new(sl, sc), Position::new(el, ec))
}
fn dep(name: &str, req: Option<&str>, vr: Option<Range>) -> UaeDep {
UaeDep {
name: PackageName::new(name),
version_req: req.map(VersionReq::new),
version_range: vr,
}
}
fn parse_result(deps: Vec<UaeDep>) -> UaeParseResult {
UaeParseResult {
deps,
uri: crate::test_util::test_uri("/test/Cargo.toml"),
}
}
struct FloorFormatter;
impl EcosystemFormatter for FloorFormatter {
fn format_version_for_text_edit(&self, version: &str) -> String {
format!("{version}-forced")
}
fn package_url(&self, name: &PackageName) -> String {
format!("https://example.com/{name}")
}
fn is_requirement_up_to_date(&self, _requirement: &VersionReq, _latest: &str) -> bool {
false
}
}
#[test]
fn test_zero_outdated_returns_empty_edits_and_no_lens() {
let content = r#"serde = "1.0.0""#;
let pr = parse_result(vec![dep("serde", Some("1.0.0"), Some(range(0, 9, 0, 14)))]);
let mut cached = HashMap::new();
cached.insert("serde".into(), PackageVersions::latest_only("1.0.0"));
let resolved = HashMap::new();
let versions = VersionData::new(&cached, &resolved);
let edits = collect_update_all_edits(&pr, content, versions, &MockFormatter);
assert!(edits.is_empty());
let lenses = generate_code_lenses(
&pr,
content,
versions,
&MockFormatter,
pr.uri(),
"deps-lsp.updateAllOutdated",
);
assert!(lenses.is_empty());
}
#[test]
fn test_n_outdated_produces_n_edits_with_expected_range_and_text() {
let content = "serde = \"1.0.0\"\ntokio = \"1.0.0\"\n";
let pr = parse_result(vec![
dep("serde", Some("1.0.0"), Some(range(0, 9, 0, 14))),
dep("tokio", Some("1.0.0"), Some(range(1, 9, 1, 14))),
]);
let mut cached = HashMap::new();
cached.insert("serde".into(), PackageVersions::latest_only("1.2.0"));
cached.insert("tokio".into(), PackageVersions::latest_only("1.3.0"));
let resolved = HashMap::new();
let versions = VersionData::new(&cached, &resolved);
let edits = collect_update_all_edits(&pr, content, versions, &MockFormatter);
assert_eq!(edits.len(), 2);
assert_eq!(edits[0].range, range(0, 9, 0, 14));
assert_eq!(
edits[0].new_text,
MockFormatter.format_version_for_text_edit("1.2.0")
);
assert_eq!(edits[1].range, range(1, 9, 1, 14));
assert_eq!(
edits[1].new_text,
MockFormatter.format_version_for_text_edit("1.3.0")
);
let lenses = generate_code_lenses(
&pr,
content,
versions,
&MockFormatter,
pr.uri(),
"deps-lsp.updateAllOutdated",
);
assert_eq!(lenses.len(), 1);
let command = lenses[0].command.as_ref().expect("lens has a command");
assert_eq!(command.title, "Update 2 outdated dependencies");
assert_eq!(command.command, "deps-lsp.updateAllOutdated");
}
#[test]
fn test_singular_title_for_one_outdated_dependency() {
let content = r#"serde = "1.0.0""#;
let pr = parse_result(vec![dep("serde", Some("1.0.0"), Some(range(0, 9, 0, 14)))]);
let mut cached = HashMap::new();
cached.insert("serde".into(), PackageVersions::latest_only("1.2.0"));
let resolved = HashMap::new();
let versions = VersionData::new(&cached, &resolved);
let lenses = generate_code_lenses(
&pr,
content,
versions,
&MockFormatter,
pr.uri(),
"deps-lsp.updateAllOutdated",
);
assert_eq!(lenses.len(), 1);
assert_eq!(
lenses[0].command.as_ref().unwrap().title,
"Update 1 outdated dependency"
);
}
#[test]
fn test_missing_version_range_is_skipped() {
let content = "serde = \"1.0.0\"\n";
let pr = parse_result(vec![dep("serde", Some("1.0.0"), None)]);
let mut cached = HashMap::new();
cached.insert("serde".into(), PackageVersions::latest_only("1.2.0"));
let resolved = HashMap::new();
let edits = collect_update_all_edits(
&pr,
content,
VersionData::new(&cached, &resolved),
&MockFormatter,
);
assert!(edits.is_empty());
}
#[test]
fn test_empty_version_requirement_is_skipped() {
let content = "pkg = \"\"\n";
let pr = parse_result(vec![dep("pkg", Some(""), Some(range(0, 6, 0, 6)))]);
let mut cached = HashMap::new();
cached.insert("pkg".into(), PackageVersions::latest_only("1.0.0"));
let resolved = HashMap::new();
let edits = collect_update_all_edits(
&pr,
content,
VersionData::new(&cached, &resolved),
&MockFormatter,
);
assert!(
edits.is_empty(),
"an empty version requirement must never produce an edit"
);
}
#[test]
fn test_dependency_absent_from_cache_is_skipped() {
let content = "git-dep = \"1.0.0\"\n";
let pr = parse_result(vec![dep(
"git-dep",
Some("1.0.0"),
Some(range(0, 11, 0, 16)),
)]);
let cached = HashMap::new();
let resolved = HashMap::new();
let edits = collect_update_all_edits(
&pr,
content,
VersionData::new(&cached, &resolved),
&MockFormatter,
);
assert!(edits.is_empty());
}
#[test]
fn test_empty_cached_latest_is_skipped() {
let content = "serde = \"1.0.0\"\n";
let pr = parse_result(vec![dep("serde", Some("1.0.0"), Some(range(0, 9, 0, 14)))]);
let mut cached = HashMap::new();
cached.insert("serde".into(), PackageVersions::latest_only(""));
let resolved = HashMap::new();
let edits = collect_update_all_edits(
&pr,
content,
VersionData::new(&cached, &resolved),
&MockFormatter,
);
assert!(
edits.is_empty(),
"an empty cached latest must never produce a requirement-erasing edit"
);
}
#[test]
fn test_whitespace_only_cached_latest_is_skipped() {
let content = "serde = \"1.0.0\"\n";
let pr = parse_result(vec![dep("serde", Some("1.0.0"), Some(range(0, 9, 0, 14)))]);
let mut cached = HashMap::new();
cached.insert("serde".into(), PackageVersions::latest_only(" "));
let resolved = HashMap::new();
let edits = collect_update_all_edits(
&pr,
content,
VersionData::new(&cached, &resolved),
&MockFormatter,
);
assert!(edits.is_empty());
}
#[test]
fn test_cached_latest_with_unsafe_characters_is_skipped() {
let content = "serde = \"1.0.0\"\n";
let pr = parse_result(vec![dep("serde", Some("1.0.0"), Some(range(0, 9, 0, 14)))]);
let mut cached = HashMap::new();
cached.insert(
"serde".into(),
PackageVersions::latest_only("1.2.0\", \"evil\": \"true"),
);
let resolved = HashMap::new();
let edits = collect_update_all_edits(
&pr,
content,
VersionData::new(&cached, &resolved),
&MockFormatter,
);
assert!(edits.is_empty());
}
#[test]
fn test_requirement_already_accepts_latest_is_not_counted() {
let content = "serde = \"^1.0\"\n";
let pr = parse_result(vec![dep("serde", Some("^1.0"), Some(range(0, 9, 0, 13)))]);
let mut cached = HashMap::new();
cached.insert("serde".into(), PackageVersions::latest_only("1.2.0"));
let resolved = HashMap::new();
let edits = collect_update_all_edits(
&pr,
content,
VersionData::new(&cached, &resolved),
&MockFormatter,
);
assert!(edits.is_empty());
}
#[test]
fn test_formatter_is_requirement_up_to_date_override_is_honored() {
let content = r#"pkg = "1.0.0""#;
let pr = parse_result(vec![dep("pkg", Some("1.0.0"), Some(range(0, 7, 0, 12)))]);
let mut cached = HashMap::new();
cached.insert("pkg".into(), PackageVersions::latest_only("1.0.0"));
let resolved = HashMap::new();
let edits = collect_update_all_edits(
&pr,
content,
VersionData::new(&cached, &resolved),
&FloorFormatter,
);
assert_eq!(edits.len(), 1);
assert_eq!(edits[0].new_text, "1.0.0-forced");
}
#[test]
fn test_no_op_edit_is_excluded() {
struct NoOpFormatter;
impl EcosystemFormatter for NoOpFormatter {
fn format_version_for_text_edit(&self, version: &str) -> String {
version.to_string()
}
fn package_url(&self, name: &PackageName) -> String {
format!("https://example.com/{name}")
}
fn format_version_replacing(&self, _version: &str, current: &str) -> String {
current.to_string()
}
fn is_requirement_up_to_date(
&self,
_requirement: &VersionReq,
_latest: &str,
) -> bool {
false
}
}
let content = r#"pkg = "1.0.0""#;
let pr = parse_result(vec![dep("pkg", Some("1.0.0"), Some(range(0, 7, 0, 12)))]);
let mut cached = HashMap::new();
cached.insert("pkg".into(), PackageVersions::latest_only("1.2.0"));
let resolved = HashMap::new();
let edits = collect_update_all_edits(
&pr,
content,
VersionData::new(&cached, &resolved),
&NoOpFormatter,
);
assert!(edits.is_empty());
}
#[test]
fn test_guard_rejects_span_that_is_not_the_requirement() {
let content = "<version>${slf4j.version}</version>";
let pr = parse_result(vec![dep(
"slf4j-api",
Some("2.0.16"),
Some(range(0, 9, 0, 25)),
)]);
let mut cached = HashMap::new();
cached.insert("slf4j-api".into(), PackageVersions::latest_only("2.1.0"));
let resolved = HashMap::new();
let edits = collect_update_all_edits(
&pr,
content,
VersionData::new(&cached, &resolved),
&MockFormatter,
);
assert!(
edits.is_empty(),
"a version_range spanning a property reference must not be edited"
);
}
#[test]
fn test_guard_accepts_whitespace_only_difference() {
let content = "pkg>=1.7,<2.0";
let pr = parse_result(vec![dep(
"pkg",
Some(">=1.7, <2.0"),
Some(range(0, 3, 0, 13)),
)]);
let mut cached = HashMap::new();
cached.insert("pkg".into(), PackageVersions::latest_only("3.0.0"));
let resolved = HashMap::new();
let edits = collect_update_all_edits(
&pr,
content,
VersionData::new(&cached, &resolved),
&MockFormatter,
);
assert_eq!(edits.len(), 1, "whitespace-only divergence must not skip");
}
#[test]
fn test_guard_accepts_nuget_bracket_wrap() {
let content = r#"<PackageReference Include="Newtonsoft.Json" Version="1.0.0" />"#;
let pr = parse_result(vec![dep(
"Newtonsoft.Json",
Some("[1.0.0]"),
Some(range(0, 53, 0, 58)),
)]);
let mut cached = HashMap::new();
cached.insert(
"Newtonsoft.Json".into(),
PackageVersions::latest_only("13.0.3"),
);
let resolved = HashMap::new();
let edits = collect_update_all_edits(
&pr,
content,
VersionData::new(&cached, &resolved),
&MockFormatter,
);
assert_eq!(
edits.len(),
1,
"NuGet's bracket-wrapped requirement must be kept"
);
}
#[test]
fn test_guard_accepts_nuget_already_bracketed_source() {
let content = r#"<PackageReference Include="Newtonsoft.Json" Version="[1.0.0]" />"#;
let pr = parse_result(vec![dep(
"Newtonsoft.Json",
Some("[[1.0.0]]"),
Some(range(0, 53, 0, 60)),
)]);
let mut cached = HashMap::new();
cached.insert(
"Newtonsoft.Json".into(),
PackageVersions::latest_only("13.0.3"),
);
let resolved = HashMap::new();
let edits = collect_update_all_edits(
&pr,
content,
VersionData::new(&cached, &resolved),
&MockFormatter,
);
assert_eq!(
edits.len(),
1,
"an already-bracketed NuGet source must not be falsely rejected"
);
}
#[test]
fn test_guard_accepts_nuget_open_ended_lower_bound_spelling() {
let content = r#"<PackageReference Include="Newtonsoft.Json" Version="[1.0.0,]" />"#;
let pr = parse_result(vec![dep(
"Newtonsoft.Json",
Some("[[1.0.0,]]"),
Some(range(0, 53, 0, 61)),
)]);
let mut cached = HashMap::new();
cached.insert(
"Newtonsoft.Json".into(),
PackageVersions::latest_only("13.0.3"),
);
let resolved = HashMap::new();
let edits = collect_update_all_edits(
&pr,
content,
VersionData::new(&cached, &resolved),
&MockFormatter,
);
assert_eq!(
edits.len(),
1,
"the open-ended-lower-bound NuGet spelling must not be falsely rejected"
);
}
#[test]
fn test_guard_accepts_nuget_exclusive_upper_bound_spelling() {
let content = r#"<PackageReference Include="Newtonsoft.Json" Version="[1.0,2.0)" />"#;
let pr = parse_result(vec![dep(
"Newtonsoft.Json",
Some("[[1.0,2.0)]"),
Some(range(0, 53, 0, 62)),
)]);
let mut cached = HashMap::new();
cached.insert(
"Newtonsoft.Json".into(),
PackageVersions::latest_only("13.0.3"),
);
let resolved = HashMap::new();
let edits = collect_update_all_edits(
&pr,
content,
VersionData::new(&cached, &resolved),
&MockFormatter,
);
assert_eq!(
edits.len(),
1,
"the exclusive-upper-bound NuGet spelling must not be falsely rejected"
);
}
#[test]
fn test_guard_rejects_bracketed_interval_against_unbracketed_requirement() {
let content = "<version>[1.0,2.0]</version>";
let pr = parse_result(vec![dep(
"interval-dep",
Some("1.0,2.0"),
Some(range(0, 9, 0, 18)),
)]);
let mut cached = HashMap::new();
cached.insert("interval-dep".into(), PackageVersions::latest_only("3.0.0"));
let resolved = HashMap::new();
let edits = collect_update_all_edits(
&pr,
content,
VersionData::new(&cached, &resolved),
&MockFormatter,
);
assert!(
edits.is_empty(),
"a bracketed interval span must not match an unbracketed requirement"
);
}
#[test]
fn test_invariant_edit_count_matches_diagnostic_count_when_guard_is_noop() {
let content = "serde = \"1.0.0\"\ntokio = \"^1.5\"\nunknown = \"1.0.0\"\n";
let pr = parse_result(vec![
dep("serde", Some("1.0.0"), Some(range(0, 9, 0, 14))),
dep("tokio", Some("^1.5"), Some(range(1, 9, 1, 13))),
dep("unknown", Some("1.0.0"), Some(range(2, 11, 2, 16))),
]);
let mut cached = HashMap::new();
cached.insert("serde".into(), PackageVersions::latest_only("2.0.0"));
cached.insert("tokio".into(), PackageVersions::latest_only("1.9.0"));
let resolved = HashMap::new();
let versions = VersionData::new(&cached, &resolved);
let edits = collect_update_all_edits(&pr, content, versions, &MockFormatter);
let diagnostics = generate_diagnostics_from_cache(
&pr,
versions,
&MockFormatter,
crate::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
let newer_version_diagnostics = diagnostics
.iter()
.filter(|d| d.message.contains("Newer version available"))
.count();
assert_eq!(edits.len(), newer_version_diagnostics);
assert_eq!(edits.len(), 1);
}
#[test]
fn test_overlapping_edits_are_dropped_keeping_the_first() {
let content = "aaaa = \"1.0.0\"\n";
let pr = parse_result(vec![
dep("aaaa", Some("1.0.0"), Some(range(0, 8, 0, 13))),
dep("aaaa-dup", Some("1.0.0"), Some(range(0, 8, 0, 13))),
]);
let mut cached = HashMap::new();
cached.insert("aaaa".into(), PackageVersions::latest_only("2.0.0"));
cached.insert("aaaa-dup".into(), PackageVersions::latest_only("3.0.0"));
let resolved = HashMap::new();
let edits = collect_update_all_edits(
&pr,
content,
VersionData::new(&cached, &resolved),
&MockFormatter,
);
assert_eq!(edits.len(), 1, "the overlapping later edit must be dropped");
}
}
}