Skip to main content

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}