deps_core/lsp_helpers/code_lenses.rs
1use tower_lsp_server::ls_types::{CodeLens, Command, Position, Range, TextEdit, Uri};
2
3use crate::ParseResult;
4#[cfg(test)]
5use crate::PublishTime;
6
7use super::{
8 EcosystemFormatter, LineOffsetTable, VersionData, is_safe_version_string, literal_span_matches,
9 slice_for_range, strip_whitespace, warn_rejected_value,
10};
11
12/// Manifest edits bringing every safely-editable outdated dependency to `latest`.
13///
14/// A dependency is included when all of the following hold:
15/// - it declares a `version_range` (a span to rewrite exists);
16/// - a `latest` version is known in `versions.cached` (normalized name first, then raw —
17/// mirroring [`crate::lsp_helpers::generate_diagnostics_from_cache`]);
18/// - `formatter.is_requirement_up_to_date` reports the declared requirement as *not*
19/// satisfying `latest` — the same predicate diagnostics use, so on a fixture where the
20/// guard below is a no-op, `collect_update_all_edits(..).len()` equals the number of
21/// `generate_diagnostics_from_cache` "Newer version available" diagnostics;
22/// - the **literal-span guard** (`literal_span_matches`): `content` sliced over
23/// `version_range` must still be (up to whitespace and NuGet's bracket wrap) the
24/// literal text — [`Dependency::version_literal`](crate::Dependency::version_literal)
25/// when the ecosystem provides one (e.g. `deps-swift`, whose synthesized comparator
26/// requirement string diverges from the bare literal `version_range` spans), falling
27/// back to the declared requirement text otherwise. Some ecosystems point
28/// `version_range` at something that is not a version literal at all — a Maven
29/// `${property}` reference or a Gradle DSL variable/version-catalog alias — and
30/// rewriting those spans would corrupt the manifest instead of fixing it. A dependency
31/// that fails the guard is skipped entirely: neither counted nor edited.
32///
33/// Accepted edits are sorted by start position; a later edit whose start falls before the
34/// previous edit's end (an overlap — a `WorkspaceEdit` protocol violation) is dropped with
35/// a `tracing::warn!`. No current parser produces overlapping `version_range`s, so this is
36/// a guard against future parser changes, not an expected code path.
37///
38/// `content` is the manifest source, needed for the literal-span guard above — the same
39/// parameter [`Ecosystem::generate_completions`](crate::Ecosystem::generate_completions)
40/// already threads through for a similar reason.
41///
42/// # Examples
43///
44/// ```
45/// use deps_core::lsp_helpers::{
46/// collect_update_all_edits, DiagnosticMessages, DiagnosticPolicy, OsvNaming, PackageNaming,
47/// PackageRendering, PackageVersions, RequirementResolution, SourcePolicy, VersionData,
48/// };
49/// use deps_core::{ConcreteVersion, Dependency, ParseResult, PackageName, VersionReq};
50/// use std::any::Any;
51/// use std::collections::HashMap;
52/// use tower_lsp_server::ls_types::{Position, Range, Uri};
53///
54/// struct MockFormatter;
55/// impl PackageNaming for MockFormatter {}
56/// impl PackageRendering for MockFormatter {
57/// fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
58/// version.to_string()
59/// }
60/// fn package_url(&self, name: &PackageName) -> String {
61/// format!("https://example.com/{name}")
62/// }
63/// }
64/// impl RequirementResolution for MockFormatter {}
65/// impl DiagnosticMessages for MockFormatter {}
66/// impl DiagnosticPolicy for MockFormatter {}
67/// impl SourcePolicy for MockFormatter {}
68/// impl OsvNaming for MockFormatter {}
69///
70/// struct MockDep {
71/// name: PackageName,
72/// version_req: VersionReq,
73/// version_range: Range,
74/// name_range: Range,
75/// }
76/// impl Dependency for MockDep {
77/// fn name(&self) -> &PackageName { &self.name }
78/// fn name_range(&self) -> Range { self.name_range }
79/// fn version_requirement(&self) -> Option<&VersionReq> { Some(&self.version_req) }
80/// fn version_range(&self) -> Option<Range> { Some(self.version_range) }
81/// fn source(&self) -> deps_core::parser::DependencySource {
82/// deps_core::parser::DependencySource::Registry
83/// }
84/// fn as_any(&self) -> &dyn Any { self }
85/// }
86///
87/// struct MockParseResult { deps: Vec<MockDep>, uri: Uri }
88/// impl ParseResult for MockParseResult {
89/// fn dependencies(&self) -> Vec<&dyn Dependency> {
90/// self.deps.iter().map(|d| d as &dyn Dependency).collect()
91/// }
92/// fn workspace_root(&self) -> Option<&std::path::Path> { None }
93/// fn uri(&self) -> &Uri { &self.uri }
94/// fn as_any(&self) -> &dyn Any { self }
95/// }
96///
97/// let content = r#"serde = "1.0.0""#;
98/// let parse_result = MockParseResult {
99/// deps: vec![MockDep {
100/// name: PackageName::new("serde"),
101/// version_req: VersionReq::new("1.0.0"),
102/// version_range: Range::new(Position::new(0, 9), Position::new(0, 14)),
103/// name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
104/// }],
105/// uri: deps_core::test_util::test_uri("/test/Cargo.toml"),
106/// };
107///
108/// let mut cached = HashMap::new();
109/// cached.insert("serde".into(), PackageVersions::latest_only("1.2.0"));
110/// let resolved = HashMap::new();
111///
112/// let edits = collect_update_all_edits(
113/// &parse_result,
114/// content,
115/// VersionData::new(&cached, &resolved),
116/// &MockFormatter,
117/// );
118///
119/// assert_eq!(edits.len(), 1);
120/// assert_eq!(edits[0].new_text, "1.2.0");
121/// ```
122pub fn collect_update_all_edits(
123 parse_result: &dyn ParseResult,
124 content: &str,
125 versions: VersionData<'_>,
126 formatter: &dyn EcosystemFormatter,
127) -> Vec<TextEdit> {
128 let deps = parse_result.dependencies();
129 let mut edits: Vec<TextEdit> = Vec::with_capacity(deps.len());
130 // Built once and reused for every dependency below — content is fixed for the
131 // whole call, so re-scanning it per dependency would be O(n²) in dependency count.
132 let line_offsets = LineOffsetTable::new(content);
133
134 for dep in deps {
135 let Some(version_range) = dep.version_range() else {
136 continue;
137 };
138
139 let normalized_name = formatter.normalize_package_name(dep.name());
140 let Some(latest) = versions
141 .cached
142 .get(normalized_name.as_str())
143 .or_else(|| versions.cached.get(dep.name()))
144 .map(|v| &v.latest)
145 else {
146 continue;
147 };
148 if !is_safe_version_string(latest.as_str()) {
149 warn_rejected_value(
150 "is_safe_version_string",
151 "update-all code lens edit",
152 latest.as_str(),
153 );
154 continue;
155 }
156
157 let Some(version_req) = dep.version_requirement() else {
158 continue;
159 };
160 if version_req.as_str().is_empty() {
161 // Defense-in-depth: an empty requirement would trivially satisfy the guard
162 // below (both sides normalize to ""), so without this, a future formatter
163 // whose `is_requirement_up_to_date` doesn't treat "" as up to date could
164 // emit an edit anchored on a span that was never a version literal.
165 continue;
166 }
167 if formatter.is_requirement_up_to_date(version_req, latest) {
168 continue;
169 }
170
171 let slice = slice_for_range(content, &line_offsets, version_range);
172 let literal_target = dep
173 .version_literal()
174 .unwrap_or_else(|| version_req.as_str());
175 if !literal_span_matches(slice, literal_target) {
176 continue;
177 }
178
179 let new_text = formatter.format_version_replacing_for(dep, latest, version_req.as_str());
180 // No-op guard, mirroring the REFACTOR-loop dedup and vulnerability-fix N1
181 // guard in `code_actions`: a formatter can decide a declared
182 // requirement has no single unambiguous rewrite (e.g. `deps-gradle`'s
183 // `{strictly}!!{preferred}` shorthand, left unchanged rather than risking a
184 // destructive or misleading edit) and return it unchanged. Without this
185 // check, such a dependency would still count toward — and appear fixed
186 // by — the "Update N outdated dependencies" lens while its click applies
187 // nothing. Compares against `literal_target` (not `version_req`) for the same
188 // reason the literal-span guard above does: for `deps-swift`, `version_req` is a
189 // synthesized comparator that never equals the bare-literal formatted text even
190 // when the edit genuinely is a no-op.
191 if strip_whitespace(&new_text) == strip_whitespace(literal_target) {
192 continue;
193 }
194
195 edits.push(TextEdit {
196 range: version_range,
197 new_text,
198 });
199 }
200
201 edits.sort_by_key(|edit| (edit.range.start.line, edit.range.start.character));
202
203 let mut non_overlapping: Vec<TextEdit> = Vec::with_capacity(edits.len());
204 for edit in edits {
205 let overlaps_prev = non_overlapping.last().is_some_and(|prev: &TextEdit| {
206 (edit.range.start.line, edit.range.start.character)
207 < (prev.range.end.line, prev.range.end.character)
208 });
209 if overlaps_prev {
210 tracing::warn!(
211 range = ?edit.range,
212 "collect_update_all_edits: dropping overlapping TextEdit"
213 );
214 continue;
215 }
216 non_overlapping.push(edit);
217 }
218
219 non_overlapping
220}
221
222/// Zero or one lens for the document, bound to `command_id`.
223///
224/// Delegates to [`collect_update_all_edits`] for the count in the lens title — the same
225/// call the command handler makes to produce the edits it applies, so `title N == edits
226/// applied` holds by construction rather than by two implementations agreeing. Returns no
227/// lens when there is nothing to update: a permanent line-0 annotation on every
228/// up-to-date manifest would be noise.
229///
230/// # Examples
231///
232/// ```
233/// use deps_core::lsp_helpers::{
234/// generate_code_lenses, DiagnosticMessages, DiagnosticPolicy, OsvNaming, PackageNaming,
235/// PackageRendering, RequirementResolution, SourcePolicy, VersionData,
236/// };
237/// use deps_core::{ConcreteVersion, PackageName, ParseResult};
238/// use std::collections::HashMap;
239///
240/// struct MockFormatter;
241/// impl PackageNaming for MockFormatter {}
242/// impl PackageRendering for MockFormatter {
243/// fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
244/// version.to_string()
245/// }
246/// fn package_url(&self, name: &PackageName) -> String {
247/// format!("https://example.com/{name}")
248/// }
249/// }
250/// impl RequirementResolution for MockFormatter {}
251/// impl DiagnosticMessages for MockFormatter {}
252/// impl DiagnosticPolicy for MockFormatter {}
253/// impl SourcePolicy for MockFormatter {}
254/// impl OsvNaming for MockFormatter {}
255///
256/// // An empty parse result yields no outdated dependencies, so no lens is generated.
257/// # struct EmptyParseResult { uri: tower_lsp_server::ls_types::Uri }
258/// # impl deps_core::ParseResult for EmptyParseResult {
259/// # fn dependencies(&self) -> Vec<&dyn deps_core::Dependency> { vec![] }
260/// # fn workspace_root(&self) -> Option<&std::path::Path> { None }
261/// # fn uri(&self) -> &tower_lsp_server::ls_types::Uri { &self.uri }
262/// # fn as_any(&self) -> &dyn std::any::Any { self }
263/// # }
264/// let parse_result = EmptyParseResult { uri: deps_core::test_util::test_uri("/test/Cargo.toml") };
265/// let cached = HashMap::new();
266/// let resolved = HashMap::new();
267///
268/// let lenses = generate_code_lenses(
269/// &parse_result,
270/// "",
271/// VersionData::new(&cached, &resolved),
272/// &MockFormatter,
273/// parse_result.uri(),
274/// "deps-lsp.updateAllOutdated",
275/// );
276///
277/// assert!(lenses.is_empty());
278/// ```
279pub fn generate_code_lenses(
280 parse_result: &dyn ParseResult,
281 content: &str,
282 versions: VersionData<'_>,
283 formatter: &dyn EcosystemFormatter,
284 uri: &Uri,
285 command_id: &str,
286) -> Vec<CodeLens> {
287 let edits = collect_update_all_edits(parse_result, content, versions, formatter);
288 if edits.is_empty() {
289 return Vec::new();
290 }
291
292 let count = edits.len();
293 let title = if count == 1 {
294 "Update 1 outdated dependency".to_string()
295 } else {
296 format!("Update {count} outdated dependencies")
297 };
298
299 vec![CodeLens {
300 range: Range::new(Position::new(0, 0), Position::new(0, 0)),
301 command: Some(Command {
302 title,
303 command: command_id.to_string(),
304 arguments: Some(vec![serde_json::json!({ "uri": uri })]),
305 }),
306 data: None,
307 }]
308}
309
310#[cfg(test)]
311mod tests {
312 use super::*;
313 use crate::lsp_helpers::test_support::*;
314 use crate::lsp_helpers::*;
315 use crate::{ConcreteVersion, Dependency, PackageName, VersionReq};
316 use std::any::Any;
317 use std::collections::HashMap;
318
319 mod update_all_edits_tests {
320 use super::*;
321 use tower_lsp_server::ls_types::{Position, Range};
322
323 struct UaeDep {
324 name: PackageName,
325 version_req: Option<VersionReq>,
326 version_range: Option<Range>,
327 }
328
329 impl Dependency for UaeDep {
330 fn name(&self) -> &PackageName {
331 &self.name
332 }
333 fn name_range(&self) -> Range {
334 Range::default()
335 }
336 fn version_requirement(&self) -> Option<&VersionReq> {
337 self.version_req.as_ref()
338 }
339 fn version_range(&self) -> Option<Range> {
340 self.version_range
341 }
342 fn source(&self) -> crate::parser::DependencySource {
343 crate::parser::DependencySource::Registry
344 }
345 fn as_any(&self) -> &dyn Any {
346 self
347 }
348 }
349
350 struct UaeParseResult {
351 deps: Vec<UaeDep>,
352 uri: Uri,
353 }
354
355 impl ParseResult for UaeParseResult {
356 fn dependencies(&self) -> Vec<&dyn Dependency> {
357 self.deps.iter().map(|d| d as &dyn Dependency).collect()
358 }
359 fn workspace_root(&self) -> Option<&std::path::Path> {
360 None
361 }
362 fn uri(&self) -> &Uri {
363 &self.uri
364 }
365 fn as_any(&self) -> &dyn Any {
366 self
367 }
368 }
369
370 fn range(sl: u32, sc: u32, el: u32, ec: u32) -> Range {
371 Range::new(Position::new(sl, sc), Position::new(el, ec))
372 }
373
374 fn dep(name: &str, req: Option<&str>, vr: Option<Range>) -> UaeDep {
375 UaeDep {
376 name: PackageName::new(name),
377 version_req: req.map(VersionReq::new),
378 version_range: vr,
379 }
380 }
381
382 fn parse_result(deps: Vec<UaeDep>) -> UaeParseResult {
383 UaeParseResult {
384 deps,
385 uri: crate::test_util::test_uri("/test/Cargo.toml"),
386 }
387 }
388
389 /// A formatter whose `is_requirement_up_to_date` ignores range semantics and
390 /// always reports "not up to date" — mirrors NuGet's bare-requirement-is-a-floor
391 /// override (`crates/deps-nuget/src/formatter.rs`), used to prove the override
392 /// point is actually consulted rather than the trait default. Appends `-forced`
393 /// in `format_version_for_text_edit` so the resulting edit is never a no-op: the
394 /// override-is-honored test below intentionally declares a requirement already
395 /// textually identical to `latest` (to isolate "was the hook consulted" from "is
396 /// this genuinely outdated"), which would otherwise be indistinguishable from a
397 /// real no-op and get filtered by `collect_update_all_edits`'s no-op guard.
398 struct FloorFormatter;
399
400 impl PackageNaming for FloorFormatter {}
401
402 impl PackageRendering for FloorFormatter {
403 fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
404 format!("{version}-forced")
405 }
406
407 fn package_url(&self, name: &PackageName) -> String {
408 format!("https://example.com/{name}")
409 }
410 }
411
412 impl RequirementResolution for FloorFormatter {
413 fn is_requirement_up_to_date(
414 &self,
415 _requirement: &VersionReq,
416 _latest: &ConcreteVersion,
417 ) -> bool {
418 false
419 }
420 }
421
422 impl DiagnosticMessages for FloorFormatter {}
423
424 impl DiagnosticPolicy for FloorFormatter {}
425
426 impl SourcePolicy for FloorFormatter {}
427
428 impl OsvNaming for FloorFormatter {}
429
430 #[test]
431 fn test_zero_outdated_returns_empty_edits_and_no_lens() {
432 let content = r#"serde = "1.0.0""#;
433 let pr = parse_result(vec![dep("serde", Some("1.0.0"), Some(range(0, 9, 0, 14)))]);
434 let mut cached = HashMap::new();
435 cached.insert("serde".into(), PackageVersions::latest_only("1.0.0"));
436 let resolved = HashMap::new();
437 let versions = VersionData::new(&cached, &resolved);
438
439 let edits = collect_update_all_edits(&pr, content, versions, &MockFormatter);
440 assert!(edits.is_empty());
441
442 let lenses = generate_code_lenses(
443 &pr,
444 content,
445 versions,
446 &MockFormatter,
447 pr.uri(),
448 "deps-lsp.updateAllOutdated",
449 );
450 assert!(lenses.is_empty());
451 }
452
453 #[test]
454 fn test_n_outdated_produces_n_edits_with_expected_range_and_text() {
455 let content = "serde = \"1.0.0\"\ntokio = \"1.0.0\"\n";
456 let pr = parse_result(vec![
457 dep("serde", Some("1.0.0"), Some(range(0, 9, 0, 14))),
458 dep("tokio", Some("1.0.0"), Some(range(1, 9, 1, 14))),
459 ]);
460 let mut cached = HashMap::new();
461 cached.insert("serde".into(), PackageVersions::latest_only("1.2.0"));
462 cached.insert("tokio".into(), PackageVersions::latest_only("1.3.0"));
463 let resolved = HashMap::new();
464 let versions = VersionData::new(&cached, &resolved);
465
466 let edits = collect_update_all_edits(&pr, content, versions, &MockFormatter);
467 assert_eq!(edits.len(), 2);
468 assert_eq!(edits[0].range, range(0, 9, 0, 14));
469 assert_eq!(
470 edits[0].new_text,
471 MockFormatter.format_version_for_text_edit(&ConcreteVersion::new("1.2.0"))
472 );
473 assert_eq!(edits[1].range, range(1, 9, 1, 14));
474 assert_eq!(
475 edits[1].new_text,
476 MockFormatter.format_version_for_text_edit(&ConcreteVersion::new("1.3.0"))
477 );
478
479 let lenses = generate_code_lenses(
480 &pr,
481 content,
482 versions,
483 &MockFormatter,
484 pr.uri(),
485 "deps-lsp.updateAllOutdated",
486 );
487 assert_eq!(lenses.len(), 1);
488 let command = lenses[0].command.as_ref().expect("lens has a command");
489 assert_eq!(command.title, "Update 2 outdated dependencies");
490 assert_eq!(command.command, "deps-lsp.updateAllOutdated");
491 }
492
493 #[test]
494 fn test_singular_title_for_one_outdated_dependency() {
495 let content = r#"serde = "1.0.0""#;
496 let pr = parse_result(vec![dep("serde", Some("1.0.0"), Some(range(0, 9, 0, 14)))]);
497 let mut cached = HashMap::new();
498 cached.insert("serde".into(), PackageVersions::latest_only("1.2.0"));
499 let resolved = HashMap::new();
500 let versions = VersionData::new(&cached, &resolved);
501
502 let lenses = generate_code_lenses(
503 &pr,
504 content,
505 versions,
506 &MockFormatter,
507 pr.uri(),
508 "deps-lsp.updateAllOutdated",
509 );
510 assert_eq!(lenses.len(), 1);
511 assert_eq!(
512 lenses[0].command.as_ref().unwrap().title,
513 "Update 1 outdated dependency"
514 );
515 }
516
517 #[test]
518 fn test_missing_version_range_is_skipped() {
519 let content = "serde = \"1.0.0\"\n";
520 let pr = parse_result(vec![dep("serde", Some("1.0.0"), None)]);
521 let mut cached = HashMap::new();
522 cached.insert("serde".into(), PackageVersions::latest_only("1.2.0"));
523 let resolved = HashMap::new();
524
525 let edits = collect_update_all_edits(
526 &pr,
527 content,
528 VersionData::new(&cached, &resolved),
529 &MockFormatter,
530 );
531 assert!(edits.is_empty());
532 }
533
534 #[test]
535 fn test_empty_version_requirement_is_skipped() {
536 // Defense-in-depth (H2): an empty requirement would trivially satisfy
537 // `literal_span_matches` if the guard were reached (both sides normalize to
538 // "") and the span text would then be discarded and overwritten outright —
539 // this must never reach the guard in the first place.
540 let content = "pkg = \"\"\n";
541 let pr = parse_result(vec![dep("pkg", Some(""), Some(range(0, 6, 0, 6)))]);
542 let mut cached = HashMap::new();
543 cached.insert("pkg".into(), PackageVersions::latest_only("1.0.0"));
544 let resolved = HashMap::new();
545
546 let edits = collect_update_all_edits(
547 &pr,
548 content,
549 VersionData::new(&cached, &resolved),
550 &MockFormatter,
551 );
552 assert!(
553 edits.is_empty(),
554 "an empty version requirement must never produce an edit"
555 );
556 }
557
558 #[test]
559 fn test_dependency_absent_from_cache_is_skipped() {
560 let content = "git-dep = \"1.0.0\"\n";
561 let pr = parse_result(vec![dep(
562 "git-dep",
563 Some("1.0.0"),
564 Some(range(0, 11, 0, 16)),
565 )]);
566 let cached = HashMap::new();
567 let resolved = HashMap::new();
568
569 let edits = collect_update_all_edits(
570 &pr,
571 content,
572 VersionData::new(&cached, &resolved),
573 &MockFormatter,
574 );
575 assert!(edits.is_empty());
576 }
577
578 #[test]
579 fn test_empty_cached_latest_is_skipped() {
580 // Regression for #303: an empty cached `latest` must never produce an
581 // edit — the old no-op guard (comparing formatted text to the declared
582 // requirement) doesn't catch this because `"" != "1.0.0"`, so without an
583 // explicit guard the requirement gets erased instead of updated.
584 let content = "serde = \"1.0.0\"\n";
585 let pr = parse_result(vec![dep("serde", Some("1.0.0"), Some(range(0, 9, 0, 14)))]);
586 let mut cached = HashMap::new();
587 cached.insert("serde".into(), PackageVersions::latest_only(""));
588 let resolved = HashMap::new();
589
590 let edits = collect_update_all_edits(
591 &pr,
592 content,
593 VersionData::new(&cached, &resolved),
594 &MockFormatter,
595 );
596 assert!(
597 edits.is_empty(),
598 "an empty cached latest must never produce a requirement-erasing edit"
599 );
600 }
601
602 #[test]
603 fn test_whitespace_only_cached_latest_is_skipped() {
604 let content = "serde = \"1.0.0\"\n";
605 let pr = parse_result(vec![dep("serde", Some("1.0.0"), Some(range(0, 9, 0, 14)))]);
606 let mut cached = HashMap::new();
607 cached.insert("serde".into(), PackageVersions::latest_only(" "));
608 let resolved = HashMap::new();
609
610 let edits = collect_update_all_edits(
611 &pr,
612 content,
613 VersionData::new(&cached, &resolved),
614 &MockFormatter,
615 );
616 assert!(edits.is_empty());
617 }
618
619 #[test]
620 fn test_cached_latest_with_unsafe_characters_is_skipped() {
621 // Regression for #302: a registry-cached `latest` containing manifest-
622 // structural characters must never be written verbatim into a `TextEdit`.
623 let content = "serde = \"1.0.0\"\n";
624 let pr = parse_result(vec![dep("serde", Some("1.0.0"), Some(range(0, 9, 0, 14)))]);
625 let mut cached = HashMap::new();
626 cached.insert(
627 "serde".into(),
628 PackageVersions::latest_only("1.2.0\", \"evil\": \"true"),
629 );
630 let resolved = HashMap::new();
631
632 let edits = collect_update_all_edits(
633 &pr,
634 content,
635 VersionData::new(&cached, &resolved),
636 &MockFormatter,
637 );
638 assert!(edits.is_empty());
639 }
640
641 #[test]
642 fn test_requirement_already_accepts_latest_is_not_counted() {
643 // "^1.0" already accepts "1.2.0" per the default `is_requirement_up_to_date`,
644 // so no edit is produced even though `latest` differs from the source text.
645 let content = "serde = \"^1.0\"\n";
646 let pr = parse_result(vec![dep("serde", Some("^1.0"), Some(range(0, 9, 0, 13)))]);
647 let mut cached = HashMap::new();
648 cached.insert("serde".into(), PackageVersions::latest_only("1.2.0"));
649 let resolved = HashMap::new();
650
651 let edits = collect_update_all_edits(
652 &pr,
653 content,
654 VersionData::new(&cached, &resolved),
655 &MockFormatter,
656 );
657 assert!(edits.is_empty());
658 }
659
660 #[test]
661 fn test_formatter_is_requirement_up_to_date_override_is_honored() {
662 // With the trait default, "1.0.0" satisfying "1.0.0" would be up to date.
663 // `FloorFormatter` overrides the hook to always report outdated, proving
664 // `collect_update_all_edits` calls through the formatter, not the default.
665 let content = r#"pkg = "1.0.0""#;
666 let pr = parse_result(vec![dep("pkg", Some("1.0.0"), Some(range(0, 7, 0, 12)))]);
667 let mut cached = HashMap::new();
668 cached.insert("pkg".into(), PackageVersions::latest_only("1.0.0"));
669 let resolved = HashMap::new();
670
671 let edits = collect_update_all_edits(
672 &pr,
673 content,
674 VersionData::new(&cached, &resolved),
675 &FloorFormatter,
676 );
677 assert_eq!(edits.len(), 1);
678 assert_eq!(edits[0].new_text, "1.0.0-forced");
679 }
680
681 #[test]
682 fn test_no_op_edit_is_excluded() {
683 // M2: a formatter can decide a declared requirement has no single
684 // unambiguous rewrite and return it unchanged (e.g. `deps-gradle`'s
685 // `{strictly}!!{preferred}` infix shorthand). Without a no-op guard, this
686 // dependency would still count toward, and be "fixed" by, the "Update N
687 // outdated dependencies" lens while applying nothing.
688 struct NoOpFormatter;
689 impl PackageNaming for NoOpFormatter {}
690
691 impl PackageRendering for NoOpFormatter {
692 fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
693 version.to_string()
694 }
695
696 fn package_url(&self, name: &PackageName) -> String {
697 format!("https://example.com/{name}")
698 }
699
700 fn format_version_replacing(
701 &self,
702 _version: &ConcreteVersion,
703 current: &str,
704 ) -> String {
705 current.to_string()
706 }
707 }
708
709 impl RequirementResolution for NoOpFormatter {
710 fn is_requirement_up_to_date(
711 &self,
712 _requirement: &VersionReq,
713 _latest: &ConcreteVersion,
714 ) -> bool {
715 false
716 }
717 }
718
719 impl DiagnosticMessages for NoOpFormatter {}
720
721 impl DiagnosticPolicy for NoOpFormatter {}
722
723 impl SourcePolicy for NoOpFormatter {}
724
725 impl OsvNaming for NoOpFormatter {}
726
727 let content = r#"pkg = "1.0.0""#;
728 let pr = parse_result(vec![dep("pkg", Some("1.0.0"), Some(range(0, 7, 0, 12)))]);
729 let mut cached = HashMap::new();
730 cached.insert("pkg".into(), PackageVersions::latest_only("1.2.0"));
731 let resolved = HashMap::new();
732
733 let edits = collect_update_all_edits(
734 &pr,
735 content,
736 VersionData::new(&cached, &resolved),
737 &NoOpFormatter,
738 );
739 assert!(edits.is_empty());
740 }
741
742 #[test]
743 fn test_guard_rejects_span_that_is_not_the_requirement() {
744 // Simulates the Maven `${property}` class: version_range spans a reference,
745 // version_requirement is the already-resolved value.
746 let content = "<version>${slf4j.version}</version>";
747 let pr = parse_result(vec![dep(
748 "slf4j-api",
749 Some("2.0.16"),
750 Some(range(0, 9, 0, 25)),
751 )]);
752 let mut cached = HashMap::new();
753 cached.insert("slf4j-api".into(), PackageVersions::latest_only("2.1.0"));
754 let resolved = HashMap::new();
755
756 let edits = collect_update_all_edits(
757 &pr,
758 content,
759 VersionData::new(&cached, &resolved),
760 &MockFormatter,
761 );
762 assert!(
763 edits.is_empty(),
764 "a version_range spanning a property reference must not be edited"
765 );
766 }
767
768 #[test]
769 fn test_guard_accepts_whitespace_only_difference() {
770 // PyPI's pep508 round-trip: `version_requirement()` is normalized to
771 // ">=1.7, <2.0" while `version_range` still spans the un-normalized source.
772 let content = "pkg>=1.7,<2.0";
773 let pr = parse_result(vec![dep(
774 "pkg",
775 Some(">=1.7, <2.0"),
776 Some(range(0, 3, 0, 13)),
777 )]);
778 let mut cached = HashMap::new();
779 cached.insert("pkg".into(), PackageVersions::latest_only("3.0.0"));
780 let resolved = HashMap::new();
781
782 let edits = collect_update_all_edits(
783 &pr,
784 content,
785 VersionData::new(&cached, &resolved),
786 &MockFormatter,
787 );
788 assert_eq!(edits.len(), 1, "whitespace-only divergence must not skip");
789 }
790
791 #[test]
792 fn test_guard_accepts_nuget_bracket_wrap() {
793 // NuGet wraps a bare source version as the requirement: source "1.0.0" ->
794 // requirement "[1.0.0]". The guard's bracket branch is the exact inverse.
795 let content = r#"<PackageReference Include="Newtonsoft.Json" Version="1.0.0" />"#;
796 let pr = parse_result(vec![dep(
797 "Newtonsoft.Json",
798 Some("[1.0.0]"),
799 Some(range(0, 53, 0, 58)),
800 )]);
801 let mut cached = HashMap::new();
802 cached.insert(
803 "Newtonsoft.Json".into(),
804 PackageVersions::latest_only("13.0.3"),
805 );
806 let resolved = HashMap::new();
807
808 let edits = collect_update_all_edits(
809 &pr,
810 content,
811 VersionData::new(&cached, &resolved),
812 &MockFormatter,
813 );
814 assert_eq!(
815 edits.len(),
816 1,
817 "NuGet's bracket-wrapped requirement must be kept"
818 );
819 }
820
821 #[test]
822 fn test_guard_accepts_nuget_already_bracketed_source() {
823 // The real reason the guard wraps only the slice, not both operands: NuGet's
824 // parser wraps *unconditionally* — a source that is already bracketed,
825 // `Version="[1.0.0]"`, still yields a double-wrapped requirement `[[1.0.0]]`
826 // (`crates/deps-nuget/src/parser.rs`). A symmetric strip would compare
827 // `[1.0.0]` against `1.0.0` here and falsely reject an editable dependency;
828 // the asymmetric wrap-the-slice rule handles it correctly.
829 let content = r#"<PackageReference Include="Newtonsoft.Json" Version="[1.0.0]" />"#;
830 let pr = parse_result(vec![dep(
831 "Newtonsoft.Json",
832 Some("[[1.0.0]]"),
833 Some(range(0, 53, 0, 60)),
834 )]);
835 let mut cached = HashMap::new();
836 cached.insert(
837 "Newtonsoft.Json".into(),
838 PackageVersions::latest_only("13.0.3"),
839 );
840 let resolved = HashMap::new();
841
842 let edits = collect_update_all_edits(
843 &pr,
844 content,
845 VersionData::new(&cached, &resolved),
846 &MockFormatter,
847 );
848 assert_eq!(
849 edits.len(),
850 1,
851 "an already-bracketed NuGet source must not be falsely rejected"
852 );
853 }
854
855 #[test]
856 fn test_guard_accepts_nuget_open_ended_lower_bound_spelling() {
857 // Another double-bracket NuGet spelling from §4.4's table: source
858 // "[1.0.0,]" (open-ended lower bound) wraps to requirement "[[1.0.0,]]".
859 let content = r#"<PackageReference Include="Newtonsoft.Json" Version="[1.0.0,]" />"#;
860 let pr = parse_result(vec![dep(
861 "Newtonsoft.Json",
862 Some("[[1.0.0,]]"),
863 Some(range(0, 53, 0, 61)),
864 )]);
865 let mut cached = HashMap::new();
866 cached.insert(
867 "Newtonsoft.Json".into(),
868 PackageVersions::latest_only("13.0.3"),
869 );
870 let resolved = HashMap::new();
871
872 let edits = collect_update_all_edits(
873 &pr,
874 content,
875 VersionData::new(&cached, &resolved),
876 &MockFormatter,
877 );
878 assert_eq!(
879 edits.len(),
880 1,
881 "the open-ended-lower-bound NuGet spelling must not be falsely rejected"
882 );
883 }
884
885 #[test]
886 fn test_guard_accepts_nuget_exclusive_upper_bound_spelling() {
887 // Third double-bracket NuGet spelling from §4.4's table: source
888 // "[1.0,2.0)" (exclusive upper bound) wraps to requirement "[[1.0,2.0)]".
889 let content = r#"<PackageReference Include="Newtonsoft.Json" Version="[1.0,2.0)" />"#;
890 let pr = parse_result(vec![dep(
891 "Newtonsoft.Json",
892 Some("[[1.0,2.0)]"),
893 Some(range(0, 53, 0, 62)),
894 )]);
895 let mut cached = HashMap::new();
896 cached.insert(
897 "Newtonsoft.Json".into(),
898 PackageVersions::latest_only("13.0.3"),
899 );
900 let resolved = HashMap::new();
901
902 let edits = collect_update_all_edits(
903 &pr,
904 content,
905 VersionData::new(&cached, &resolved),
906 &MockFormatter,
907 );
908 assert_eq!(
909 edits.len(),
910 1,
911 "the exclusive-upper-bound NuGet spelling must not be falsely rejected"
912 );
913 }
914
915 #[test]
916 fn test_guard_rejects_bracketed_interval_against_unbracketed_requirement() {
917 // Regression guard for the OLD (broken) symmetric-strip rule: stripping
918 // brackets from *both* operands would wrongly match a Maven-style bracketed
919 // interval span `[1.0,2.0]` against an unbracketed requirement `1.0,2.0`.
920 // The corrected asymmetric rule only wraps the *slice*, so
921 // `format!("[{slice}]")` produces `[[1.0,2.0]]`, which does not equal
922 // `1.0,2.0` either — the dependency must be skipped.
923 let content = "<version>[1.0,2.0]</version>";
924 let pr = parse_result(vec![dep(
925 "interval-dep",
926 Some("1.0,2.0"),
927 Some(range(0, 9, 0, 18)),
928 )]);
929 let mut cached = HashMap::new();
930 cached.insert("interval-dep".into(), PackageVersions::latest_only("3.0.0"));
931 let resolved = HashMap::new();
932
933 let edits = collect_update_all_edits(
934 &pr,
935 content,
936 VersionData::new(&cached, &resolved),
937 &MockFormatter,
938 );
939 assert!(
940 edits.is_empty(),
941 "a bracketed interval span must not match an unbracketed requirement"
942 );
943 }
944
945 #[test]
946 fn test_invariant_edit_count_matches_diagnostic_count_when_guard_is_noop() {
947 // On a fixture where every span already equals its requirement (the guard is
948 // a no-op), the edit count must equal the diagnostic count — same predicate.
949 let content = "serde = \"1.0.0\"\ntokio = \"^1.5\"\nunknown = \"1.0.0\"\n";
950 let pr = parse_result(vec![
951 dep("serde", Some("1.0.0"), Some(range(0, 9, 0, 14))),
952 dep("tokio", Some("^1.5"), Some(range(1, 9, 1, 13))),
953 dep("unknown", Some("1.0.0"), Some(range(2, 11, 2, 16))),
954 ]);
955 let mut cached = HashMap::new();
956 cached.insert("serde".into(), PackageVersions::latest_only("2.0.0"));
957 cached.insert("tokio".into(), PackageVersions::latest_only("1.9.0"));
958 let resolved = HashMap::new();
959 let versions = VersionData::new(&cached, &resolved);
960
961 let edits = collect_update_all_edits(&pr, content, versions, &MockFormatter);
962 let diagnostics = generate_diagnostics_from_cache(
963 &pr,
964 versions,
965 &MockFormatter,
966 pr.uri(),
967 crate::FreshnessSettings::default(),
968 DiagnosticSeverities::default(),
969 PublishTime::now(),
970 );
971 let newer_version_diagnostics = diagnostics
972 .iter()
973 .filter(|d| d.message.contains("Newer version available"))
974 .count();
975
976 assert_eq!(edits.len(), newer_version_diagnostics);
977 assert_eq!(edits.len(), 1);
978 }
979
980 #[test]
981 fn test_overlapping_edits_are_dropped_keeping_the_first() {
982 let content = "aaaa = \"1.0.0\"\n";
983 // Two dependencies whose declared version_range identically overlaps —
984 // synthesizes the protocol-violation case the sort+assert guard exists for.
985 let pr = parse_result(vec![
986 dep("aaaa", Some("1.0.0"), Some(range(0, 8, 0, 13))),
987 dep("aaaa-dup", Some("1.0.0"), Some(range(0, 8, 0, 13))),
988 ]);
989 let mut cached = HashMap::new();
990 cached.insert("aaaa".into(), PackageVersions::latest_only("2.0.0"));
991 cached.insert("aaaa-dup".into(), PackageVersions::latest_only("3.0.0"));
992 let resolved = HashMap::new();
993
994 let edits = collect_update_all_edits(
995 &pr,
996 content,
997 VersionData::new(&cached, &resolved),
998 &MockFormatter,
999 );
1000 assert_eq!(edits.len(), 1, "the overlapping later edit must be dropped");
1001 }
1002 }
1003}